From 7bfef73715185394b32302b2ece6ba1aeb724307 Mon Sep 17 00:00:00 2001 From: orfelorfel23 Date: Mon, 7 Sep 2026 17:52:25 +0200 Subject: [PATCH] feat: Stereo L/R split, 3-band routing, BPM sync, analog VU-meter, fluid blob, VHS scanlines and phosphor afterglow --- public/app.js | 822 ++++++++++++++++++++++++++-- public/index.html | 163 ++++++ public/presets/fluid_blob.json | 81 +++ public/presets/vintage_vumeter.json | 81 +++ public/style.css | 33 ++ server.mjs | 2 +- 6 files changed, 1128 insertions(+), 54 deletions(-) create mode 100644 public/presets/fluid_blob.json create mode 100644 public/presets/vintage_vumeter.json diff --git a/public/app.js b/public/app.js index f8a6457..32c25dd 100644 --- a/public/app.js +++ b/public/app.js @@ -16,6 +16,9 @@ audioSource: null, audioCtx: null, analyser: null, + analyserLeft: null, + analyserRight: null, + splitter: null, gainNode: null, isPlaying: false, startTime: 0, @@ -23,6 +26,25 @@ duration: 0, loop: true, + // BPM & Beat Engine + bpm: 128, + detectedBpm: 128, + tapTimes: [], + + // VU Meter Ballistics + vuMeter: { + leftNeedle: -Math.PI * 0.28, + rightNeedle: -Math.PI * 0.28, + leftVel: 0, + rightVel: 0, + leftPeak: 0, + rightPeak: 0 + }, + + // Afterglow / Trail Canvas Buffer + afterglowCanvas: null, + afterglowCtx: null, + aspectRatio: '16:9', width: 1920, height: 1080, @@ -152,10 +174,25 @@ if (!state.audioCtx) { const AudioCtxClass = window.AudioContext || window.webkitAudioContext; state.audioCtx = new AudioCtxClass(); + + // Master Analyser (Mono / Summed) state.analyser = state.audioCtx.createAnalyser(); state.analyser.fftSize = 2048; state.analyser.smoothingTimeConstant = 0.82; + // Stereo Channel Splitter & Analysers (L & R) + state.splitter = state.audioCtx.createChannelSplitter(2); + state.analyserLeft = state.audioCtx.createAnalyser(); + state.analyserLeft.fftSize = 2048; + state.analyserLeft.smoothingTimeConstant = 0.82; + + state.analyserRight = state.audioCtx.createAnalyser(); + state.analyserRight.fftSize = 2048; + state.analyserRight.smoothingTimeConstant = 0.82; + + state.splitter.connect(state.analyserLeft, 0); + state.splitter.connect(state.analyserRight, 1); + state.gainNode = state.audioCtx.createGain(); state.gainNode.gain.value = parseFloat(volumeSlider.value); state.gainNode.connect(state.audioCtx.destination); @@ -165,6 +202,81 @@ } } + // Fast Automated BPM Detector + function detectAudioBPM(audioBuffer) { + if (!audioBuffer) return 128; + try { + const sampleRate = audioBuffer.sampleRate; + const channelData = audioBuffer.getChannelData(0); + const analyzeSeconds = Math.min(60, audioBuffer.duration); + const maxSamples = Math.floor(sampleRate * analyzeSeconds); + const step = Math.max(1, Math.floor(sampleRate / 4000)); + const downsampledLen = Math.floor(maxSamples / step); + const downsampled = new Float32Array(downsampledLen); + + for (let i = 0; i < downsampledLen; i++) { + downsampled[i] = channelData[i * step]; + } + + const envelope = new Float32Array(downsampledLen); + let prev = 0; + let diffSum = 0; + for (let i = 1; i < downsampledLen; i++) { + const diff = Math.max(0, Math.abs(downsampled[i]) - Math.abs(downsampled[i - 1])); + prev = prev * 0.88 + diff * 0.12; + envelope[i] = prev; + diffSum += prev; + } + + const threshold = (diffSum / downsampledLen) * 1.6; + const minDistance = Math.floor((4000 / 220) * 60); + + const peaks = []; + for (let i = 2; i < downsampledLen - 2; i++) { + if (envelope[i] > threshold && + envelope[i] > envelope[i - 1] && + envelope[i] > envelope[i + 1] && + envelope[i] >= envelope[i - 2] && + envelope[i] >= envelope[i + 2]) { + if (peaks.length === 0 || (i - peaks[peaks.length - 1]) > minDistance) { + peaks.push(i); + } + } + } + + if (peaks.length < 5) return 128; + + const intervals = []; + for (let i = 1; i < peaks.length; i++) { + const deltaSec = ((peaks[i] - peaks[i - 1]) * step) / sampleRate; + if (deltaSec > 0.1) { + let bpm = 60 / deltaSec; + while (bpm < 75) bpm *= 2; + while (bpm > 175) bpm /= 2; + intervals.push(Math.round(bpm)); + } + } + + if (intervals.length === 0) return 128; + + const counts = {}; + let bestBpm = 128; + let maxScore = -1; + for (const b of intervals) { + counts[b] = (counts[b] || 0) + 1; + const score = (counts[b] || 0) + ((counts[b - 1] || 0) * 0.5) + ((counts[b + 1] || 0) * 0.5); + if (score > maxScore) { + maxScore = score; + bestBpm = b; + } + } + return bestBpm; + } catch (e) { + console.warn('BPM detection error:', e); + return 128; + } + } + async function loadAudioFile(file) { if (!file) return; initAudioContext(); @@ -189,7 +301,17 @@ currentTimeEl.textContent = '00:00'; document.getElementById('timelineProgressBar').style.width = '0%'; - audioFileBadge.textContent = `🎵 ${file.name} (${formatTime(state.duration)})`; + // Detect BPM + const detectedBpm = detectAudioBPM(decoded); + state.bpm = detectedBpm; + state.detectedBpm = detectedBpm; + state.config.bpmValue = detectedBpm; + const bpmInput = document.getElementById('bpmValue'); + if (bpmInput) bpmInput.value = detectedBpm; + const bpmBadge = document.getElementById('bpmDetectedBadge'); + if (bpmBadge) bpmBadge.textContent = `${detectedBpm} BPM (Erkannt)`; + + audioFileBadge.textContent = `🎵 ${file.name} (${formatTime(state.duration)} • ${detectedBpm} BPM)`; // Extract metadata if empty const titleInput = document.getElementById('trackTitle'); @@ -273,7 +395,12 @@ state.audioSource = state.audioCtx.createBufferSource(); state.audioSource.buffer = state.audioBuffer; state.audioSource.loop = state.loop; + + // Connect to master analyser and stereo splitter state.audioSource.connect(state.analyser); + if (state.splitter) { + state.audioSource.connect(state.splitter); + } state.analyser.connect(state.gainNode); const safeOffset = Math.max(0, Math.min(offsetSeconds, state.duration || 0)); @@ -540,6 +667,7 @@ } const globalFastFFT = new FastFFT(2048); + const globalFastFFT_R = new FastFFT(2048); // --- Helper: Precise Polygon Perimeter Point & Smooth Radial Normal --- function getPolygonPerimeterPoint(sides, radius, t) { @@ -578,29 +706,350 @@ angle: Math.atan2(py, px) }; } + + // --- Helper: Fluid / Organic Liquid Blob Path --- + function drawFluidBlob(targetCtx, baseRadius, processedBars, rawBassEnergy, midEnergy, currentTime) { + const points = 16; + const coords = []; + const rot = state.currentRotation || 0; + + for (let i = 0; i < points; i++) { + const angle = (i / points) * Math.PI * 2 + rot; + const barIdx = Math.floor((i / points) * processedBars.length); + const freqBoost = (processedBars[barIdx] || 0) * 0.42; + + const wave1 = Math.sin(angle * 3 + currentTime * 3.2) * (rawBassEnergy * 28); + const wave2 = Math.cos(angle * 2 - currentTime * 2.6) * (midEnergy * 20); + const r = Math.max(30, baseRadius + freqBoost + wave1 + wave2); + + coords.push({ + x: Math.cos(angle) * r, + y: Math.sin(angle) * r + }); + } + + targetCtx.beginPath(); + targetCtx.moveTo((coords[0].x + coords[points - 1].x) / 2, (coords[0].y + coords[points - 1].y) / 2); + + for (let i = 0; i < points; i++) { + const nextIdx = (i + 1) % points; + const midX = (coords[i].x + coords[nextIdx].x) / 2; + const midY = (coords[i].y + coords[nextIdx].y) / 2; + targetCtx.quadraticCurveTo(coords[i].x, coords[i].y, midX, midY); + } + targetCtx.closePath(); + } + + // --- Helper: Dual Vintage Analog VU Meters --- + function drawAnalogVuMeters(targetCtx, width, height, leftLevel, rightLevel, currentTime, cfg) { + targetCtx.save(); + const meterW = Math.min(370, width * 0.24); + const meterH = meterW * 0.62; + const gap = 34; + const totalW = meterW * 2 + gap; + const startX = -totalW / 2; + const startY = -meterH / 2; + + const meters = [ + { x: startX, label: 'CH 1 • LEFT', level: leftLevel, needleKey: 'leftNeedle', velKey: 'leftVel' }, + { x: startX + meterW + gap, label: 'CH 2 • RIGHT', level: rightLevel, needleKey: 'rightNeedle', velKey: 'rightVel' } + ]; + + for (const m of meters) { + targetCtx.save(); + targetCtx.translate(m.x, startY); + + // 1. Meter Outer Housing + targetCtx.fillStyle = '#0f131a'; + targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.2)'; + targetCtx.lineWidth = 3; + targetCtx.shadowColor = 'rgba(0, 0, 0, 0.75)'; + targetCtx.shadowBlur = 16; + targetCtx.beginPath(); + if (targetCtx.roundRect) targetCtx.roundRect(0, 0, meterW, meterH, 12); + else targetCtx.rect(0, 0, meterW, meterH); + targetCtx.fill(); + targetCtx.stroke(); + targetCtx.shadowBlur = 0; + + // 2. Vintage Dial Face (Illuminated warm parchment) + const dialPad = 10; + const dialW = meterW - dialPad * 2; + const dialH = meterH - dialPad * 2; + const dialGrad = targetCtx.createLinearGradient(0, dialPad, 0, dialPad + dialH); + dialGrad.addColorStop(0, '#fff4dc'); + dialGrad.addColorStop(0.85, '#fae5b8'); + dialGrad.addColorStop(1, '#e5ca95'); + targetCtx.fillStyle = dialGrad; + targetCtx.beginPath(); + if (targetCtx.roundRect) targetCtx.roundRect(dialPad, dialPad, dialW, dialH, 8); + else targetCtx.rect(dialPad, dialPad, dialW, dialH); + targetCtx.fill(); + + // Dial Typography + targetCtx.fillStyle = '#1a1a1a'; + targetCtx.font = `800 ${Math.round(meterW * 0.05)}px 'Orbitron', sans-serif`; + targetCtx.textAlign = 'center'; + targetCtx.fillText('VU', dialPad + dialW / 2, dialPad + dialH * 0.32); + + targetCtx.font = `700 ${Math.round(meterW * 0.034)}px 'Inter', sans-serif`; + targetCtx.fillStyle = '#444444'; + targetCtx.fillText(m.label, dialPad + dialW / 2, dialPad + dialH * 0.46); + + // Arc Scale Geometry + const pivotX = dialPad + dialW / 2; + const pivotY = dialPad + dialH * 1.15; + const arcR = dialH * 0.98; + const startAng = -Math.PI * 0.72; + const zeroDbAng = -Math.PI * 0.53; + const endAng = -Math.PI * 0.38; + + // Black Scale Line (-20 to 0 dB) + targetCtx.strokeStyle = '#1b1b1b'; + targetCtx.lineWidth = 2; + targetCtx.beginPath(); + targetCtx.arc(pivotX, pivotY, arcR, startAng, zeroDbAng); + targetCtx.stroke(); + + // Red Scale Line (0 to +3 dB Overload) + targetCtx.strokeStyle = '#d4142e'; + targetCtx.lineWidth = 3.5; + targetCtx.beginPath(); + targetCtx.arc(pivotX, pivotY, arcR, zeroDbAng, endAng); + targetCtx.stroke(); + + // Scale Ticks + const scaleTicks = [ + { val: -20, t: 0.0 }, + { val: -10, t: 0.25 }, + { val: -7, t: 0.42 }, + { val: -5, t: 0.55 }, + { val: -3, t: 0.70 }, + { val: 0, t: 0.82 }, + { val: +1, t: 0.88 }, + { val: +2, t: 0.94 }, + { val: +3, t: 1.0 } + ]; + + for (const tick of scaleTicks) { + const ang = startAng + tick.t * (endAng - startAng); + const tickLen = (tick.val === 0 || tick.val === -20 || tick.val === 3) ? 8 : 4; + const x1 = pivotX + Math.cos(ang) * arcR; + const y1 = pivotY + Math.sin(ang) * arcR; + const x2 = pivotX + Math.cos(ang) * (arcR - tickLen); + const y2 = pivotY + Math.sin(ang) * (arcR - tickLen); + + targetCtx.strokeStyle = tick.val >= 0 ? '#d4142e' : '#1b1b1b'; + targetCtx.lineWidth = tick.val === 0 ? 2.5 : 1.5; + targetCtx.beginPath(); + targetCtx.moveTo(x1, y1); + targetCtx.lineTo(x2, y2); + targetCtx.stroke(); + + if (tick.val === -20 || tick.val === -7 || tick.val === 0 || tick.val === 3) { + const tx = pivotX + Math.cos(ang) * (arcR - 16); + const ty = pivotY + Math.sin(ang) * (arcR - 16); + targetCtx.fillStyle = tick.val >= 0 ? '#d4142e' : '#222222'; + targetCtx.font = `700 ${Math.round(meterW * 0.03)}px 'Inter', sans-serif`; + targetCtx.fillText(tick.val > 0 ? `+${tick.val}` : `${tick.val}`, tx, ty); + } + } + + // Overload Peak LED + const ledX = dialPad + dialW - 22; + const ledY = dialPad + 18; + const isPeak = (m.level > 0.82); + targetCtx.beginPath(); + targetCtx.arc(ledX, ledY, 5, 0, Math.PI * 2); + if (isPeak) { + targetCtx.fillStyle = '#ff1122'; + targetCtx.shadowColor = '#ff2244'; + targetCtx.shadowBlur = 10; + } else { + targetCtx.fillStyle = '#551119'; + targetCtx.shadowBlur = 0; + } + targetCtx.fill(); + targetCtx.shadowBlur = 0; + + // Ballistic Needle Physics Simulation + const targetAngle = startAng + Math.min(1.0, Math.pow(m.level, 0.85)) * (endAng - startAng); + const spring = 0.26; + const damping = 0.68; + state.vuMeter[m.velKey] = (state.vuMeter[m.velKey] + (targetAngle - state.vuMeter[m.needleKey]) * spring) * damping; + state.vuMeter[m.needleKey] += state.vuMeter[m.velKey]; + + // Draw Needle + const curAng = state.vuMeter[m.needleKey]; + const needleLen = arcR + 4; + const nx = pivotX + Math.cos(curAng) * needleLen; + const ny = pivotY + Math.sin(curAng) * needleLen; + + targetCtx.strokeStyle = '#1b1b1b'; + targetCtx.lineWidth = 2.0; + targetCtx.lineCap = 'round'; + targetCtx.shadowColor = 'rgba(0, 0, 0, 0.3)'; + targetCtx.shadowBlur = 3; + targetCtx.shadowOffsetX = 2; + targetCtx.shadowOffsetY = 2; + + targetCtx.beginPath(); + targetCtx.moveTo(pivotX, pivotY); + targetCtx.lineTo(nx, ny); + targetCtx.stroke(); + targetCtx.shadowColor = 'transparent'; + + // Pivot Cover Cap + targetCtx.fillStyle = '#222630'; + targetCtx.beginPath(); + targetCtx.arc(pivotX, pivotY, 13, 0, Math.PI * 2); + targetCtx.fill(); + targetCtx.strokeStyle = '#444c60'; + targetCtx.lineWidth = 1.5; + targetCtx.stroke(); + + targetCtx.restore(); + } + targetCtx.restore(); + } + + // --- Helper: Phosphor Afterglow / Persistence Engine --- + function updatePhosphorAfterglow(targetCtx, width, height, cfg) { + if (!cfg.enableAfterglow) return; + + if (!state.afterglowCanvas || state.afterglowCanvas.width !== width || state.afterglowCanvas.height !== height) { + state.afterglowCanvas = document.createElement('canvas'); + state.afterglowCanvas.width = width; + state.afterglowCanvas.height = height; + state.afterglowCtx = state.afterglowCanvas.getContext('2d', { alpha: true }); + } + + const aCtx = state.afterglowCtx; + const decay = cfg.afterglowDecay || 0.82; + + aCtx.save(); + aCtx.globalCompositeOperation = 'destination-out'; + aCtx.fillStyle = `rgba(0, 0, 0, ${1 - decay})`; + aCtx.fillRect(0, 0, width, height); + aCtx.restore(); + + if (cfg.afterglowMode === 'green_p1') { + aCtx.save(); + aCtx.globalCompositeOperation = 'source-atop'; + aCtx.fillStyle = 'rgba(0, 255, 68, 0.06)'; + aCtx.fillRect(0, 0, width, height); + aCtx.restore(); + } else if (cfg.afterglowMode === 'amber_p4') { + aCtx.save(); + aCtx.globalCompositeOperation = 'source-atop'; + aCtx.fillStyle = 'rgba(255, 170, 0, 0.06)'; + aCtx.fillRect(0, 0, width, height); + aCtx.restore(); + } else if (cfg.afterglowMode === 'cyan_neon') { + aCtx.save(); + aCtx.globalCompositeOperation = 'source-atop'; + aCtx.fillStyle = 'rgba(0, 240, 255, 0.06)'; + aCtx.fillRect(0, 0, width, height); + aCtx.restore(); + } + + targetCtx.save(); + targetCtx.globalCompositeOperation = (cfg.backgroundMode === 'transparent') ? 'source-over' : 'screen'; + targetCtx.drawImage(state.afterglowCanvas, 0, 0); + targetCtx.restore(); + } + + function capturePhosphorAfterglow(targetCtx, width, height, cfg) { + if (!cfg.enableAfterglow || !state.afterglowCtx) return; + state.afterglowCtx.save(); + state.afterglowCtx.globalCompositeOperation = 'source-over'; + state.afterglowCtx.drawImage(targetCtx.canvas, 0, 0); + state.afterglowCtx.restore(); + } + + // --- Helper: Retro VHS, Scanlines & CRT Distortion --- + function applyVhsAndCrt(targetCtx, width, height, currentTime, cfg) { + if (!cfg.enableVhs) return; + targetCtx.save(); + + // 1. Scanlines + const intensity = cfg.vhsScanlineIntensity ?? 0.25; + targetCtx.fillStyle = `rgba(0, 0, 0, ${intensity})`; + const step = 4; + for (let y = 0; y < height; y += step) { + targetCtx.fillRect(0, y, width, 1.5); + } + + // 2. VHS Noise & Tracking Jitter + const noise = cfg.vhsNoiseIntensity ?? 0.15; + if (noise > 0.02) { + targetCtx.fillStyle = `rgba(255, 255, 255, ${noise * 0.12})`; + const bandY = ((currentTime * 1.5) % 1) * height; + targetCtx.fillRect(0, bandY, width, 6); + const bandY2 = (((currentTime * 0.7) + 0.5) % 1) * height; + targetCtx.fillRect(0, bandY2, width, 3); + } + + // 3. CRT Vignette & Curvature Mask + if (cfg.vhsCrtCurvature) { + const crtGrad = targetCtx.createRadialGradient(width / 2, height / 2, width * 0.42, width / 2, height / 2, width * 0.72); + crtGrad.addColorStop(0, 'rgba(0, 0, 0, 0)'); + crtGrad.addColorStop(0.85, 'rgba(0, 0, 0, 0.35)'); + crtGrad.addColorStop(1, 'rgba(0, 0, 0, 0.75)'); + targetCtx.fillStyle = crtGrad; + targetCtx.fillRect(0, 0, width, height); + } + + targetCtx.restore(); + } + function renderFrame(targetCtx, width, height, freqData, currentTime, duration, cfg, isPlaying = true) { targetCtx.save(); targetCtx.clearRect(0, 0, width, height); - // 1. Analyze Frequency Bands + // 0. Update Phosphor Afterglow / Trails (under the current scene) + updatePhosphorAfterglow(targetCtx, width, height, cfg); + + // 1. Stereo Unpacking & 3-Band Frequency Analysis + const isStereo = (cfg.stereoSplit && freqData && freqData.left && freqData.right); + const fMono = isStereo ? freqData.mono : (freqData.length !== undefined ? freqData : (freqData.mono || new Uint8Array(1024))); + const fLeft = isStereo ? freqData.left : fMono; + const fRight = isStereo ? freqData.right : fMono; + let subBassSum = 0; let midBassSum = 0; let trebleSum = 0; + const binCount = fMono.length || 1024; - const binCount = freqData.length; - for (let i = 0; i < 8; i++) subBassSum += freqData[i] || 0; - for (let i = 8; i < 28; i++) midBassSum += freqData[i] || 0; - for (let i = 40; i < 140; i++) trebleSum += freqData[i] || 0; + for (let i = 0; i < 8; i++) subBassSum += fMono[i] || 0; + for (let i = 8; i < 36; i++) midBassSum += fMono[i] || 0; + for (let i = 36; i < 160; i++) trebleSum += fMono[i] || 0; const subBassAvg = (subBassSum / 8) / 255; - const midBassAvg = (midBassSum / 20) / 255; - const trebleAvg = (trebleSum / 100) / 255; + const midBassAvg = (midBassSum / 28) / 255; + const trebleAvg = (trebleSum / 124) / 255; - const rawBassEnergy = isPlaying ? Math.max(subBassAvg * 1.25, midBassAvg) : 0; + const subBassEnergy = Math.min(1.0, subBassAvg * 1.3); + const midEnergy = Math.min(1.0, midBassAvg * 1.45); + const trebleEnergy = Math.min(1.0, trebleAvg * 2.1); + const fullMixEnergy = Math.min(1.0, (subBassEnergy * 0.5 + midEnergy * 0.3 + trebleEnergy * 0.2)); + + function getBandEnergy(route) { + if (route === 'mids') return midEnergy; + if (route === 'treble') return trebleEnergy; + if (route === 'full_mix') return fullMixEnergy; + return subBassEnergy; // 'sub_bass' default + } + + const pulseEnergy = isPlaying ? getBandEnergy(cfg.routePulse || 'sub_bass') : 0; + const particleEnergy = isPlaying ? getBandEnergy(cfg.routeParticles || 'treble') : 0; + const shakeEnergy = isPlaying ? getBandEnergy(cfg.routeShake || 'sub_bass') : 0; + + const rawBassEnergy = pulseEnergy; state.beatEnergy = rawBassEnergy; // Target Pulse calculation with smooth attack/decay - const targetPulse = isPlaying ? (1.0 + (rawBassEnergy * cfg.pulseIntensity * 0.6)) : 1.0; + const targetPulse = isPlaying ? (1.0 + (pulseEnergy * cfg.pulseIntensity * 0.6)) : 1.0; const decay = cfg.pulseDecay || 0.8; if (targetPulse > state.currentPulse) { state.currentPulse = targetPulse; @@ -613,22 +1062,31 @@ let shakeX = 0; let shakeY = 0; - if (isPlaying && cfg.enableShake && cfg.camShakeIntensity > 0 && rawBassEnergy > 0.35) { - const shakeFactor = (rawBassEnergy - 0.35) * cfg.camShakeIntensity * 38; + if (isPlaying && cfg.enableShake && cfg.camShakeIntensity > 0 && shakeEnergy > 0.35) { + const shakeFactor = (shakeEnergy - 0.35) * cfg.camShakeIntensity * 38; shakeX = (Math.random() - 0.5) * shakeFactor; shakeY = (Math.random() - 0.5) * shakeFactor; } - if (isPlaying && cfg.enableBassZoom && rawBassEnergy > 0.3) { - shakeZoom = 1.0 + (rawBassEnergy - 0.3) * 0.07; + if (isPlaying && cfg.enableBassZoom && pulseEnergy > 0.3) { + shakeZoom = 1.0 + (pulseEnergy - 0.3) * 0.07; } - // Rotation calculation + // BPM & Rhythmic Clock Calculation + const activeBpm = parseFloat(cfg.bpmValue) || state.bpm || 128; + const beatSec = 60 / activeBpm; + const beatPhase = (currentTime % beatSec) / beatSec; + + // Rotation calculation (BPM sync or manual RPM) if (isPlaying) { - const rpm = cfg.rotationSpeed || 0; - state.currentRotation += (rpm * Math.PI * 2 / (60 * 60)); - if (cfg.beatSpinImpulse && rawBassEnergy > 0.7) { - state.currentRotation += (rawBassEnergy - 0.7) * 0.045; + if (cfg.bpmSync && cfg.bpmSyncRotation) { + state.currentRotation = (currentTime / (beatSec * 8)) * Math.PI * 2; + } else { + const rpm = cfg.rotationSpeed || 0; + state.currentRotation += (rpm * Math.PI * 2 / (60 * 60)); + if (cfg.beatSpinImpulse && pulseEnergy > 0.7) { + state.currentRotation += (pulseEnergy - 0.7) * 0.045; + } } } @@ -662,8 +1120,12 @@ const horizonY = height * (cfg.gridPerspective || 0.65); const gridColor = cfg.colorGrid || '#ff007f'; if (isPlaying) { - const speed = 120 * (1.0 + rawBassEnergy * 1.5); - state.gridOffset = (state.gridOffset + speed / 60) % 50; + if (cfg.bpmSync && cfg.bpmSyncGrid) { + state.gridOffset = beatPhase * 50; + } else { + const speed = 120 * (1.0 + rawBassEnergy * 1.5); + state.gridOffset = (state.gridOffset + speed / 60) % 50; + } } targetCtx.strokeStyle = gridColor; @@ -714,7 +1176,7 @@ if (cfg.enableParticles && state.particles.length > 0) { targetCtx.save(); const pType = cfg.particleType || 'starfield'; - const speedMult = cfg.particleSpeed * (1.0 + (rawBassEnergy * cfg.particleWarp)); + const speedMult = cfg.particleSpeed * (1.0 + (particleEnergy * cfg.particleWarp)); for (let i = 0; i < state.particles.length; i++) { const p = state.particles[i]; @@ -799,8 +1261,11 @@ const barSpacing = cfg.barSpacing || 10; const sensitivity = cfg.freqSensitivity || 1.3; - // Filter Frequency Array for Bars + // Filter Frequency Array for Bars (Mono & Stereo) const processedBars = []; + const processedBarsL = []; + const processedBarsR = []; + for (let i = 0; i < barCount; i++) { let freqIndex; if (cfg.mirrorSpectrum) { @@ -815,9 +1280,41 @@ freqIndex = Math.floor(Math.pow(i / barCount, 1.8) * (binCount * 0.35)); } - const val = (freqData[freqIndex] || 0) / 255; + const val = (fMono[freqIndex] || 0) / 255; + const valL = (fLeft[freqIndex] || 0) / 255; + const valR = (fRight[freqIndex] || 0) / 255; + const heightVal = Math.pow(val, 1.2) * maxHeight * sensitivity; + const heightValL = Math.pow(valL, 1.2) * maxHeight * sensitivity; + const heightValR = Math.pow(valR, 1.2) * maxHeight * sensitivity; + processedBars.push(Math.max(2, heightVal)); + processedBarsL.push(Math.max(2, heightValL)); + processedBarsR.push(Math.max(2, heightValR)); + } + + // Active bars assignment based on Stereo Split setting + let activeBars = processedBars; + if (isStereo) { + activeBars = []; + const half = Math.floor(barCount / 2); + const sMode = cfg.stereoMode || 'split_hemisphere'; + for (let i = 0; i < barCount; i++) { + if (sMode === 'split_hemisphere') { + activeBars.push(i < half ? processedBarsL[i] : processedBarsR[i]); + } else if (sMode === 'interleaved') { + activeBars.push(i % 2 === 0 ? processedBarsL[i] : processedBarsR[i]); + } else { + activeBars.push(processedBarsL[i]); + } + } + } + + // 5.0 SPECIAL SHAPE: Dual Analog VU-Meter + if (shape === 'vumeter') { + const leftEnergy = isStereo ? Math.min(1.0, (processedBarsL[3] || 0) / (maxHeight * 0.7)) : rawBassEnergy; + const rightEnergy = isStereo ? Math.min(1.0, (processedBarsR[3] || 0) / (maxHeight * 0.7)) : rawBassEnergy; + drawAnalogVuMeters(targetCtx, width, height, leftEnergy, rightEnergy, currentTime, cfg); } // Build Visualizer Color Gradient @@ -853,7 +1350,7 @@ } // --- LAYER 6: Audio Spectrum Rendering (Radial, Polygon, Linear, Oscilloscope) --- - if (cfg.showSpectrum) { + if (cfg.showSpectrum && shape !== 'vumeter') { targetCtx.save(); targetCtx.fillStyle = spectrumGrad; targetCtx.strokeStyle = spectrumGrad; @@ -876,7 +1373,7 @@ targetCtx.beginPath(); for (let i = 0; i < barCount; i++) { const x = startX + i * step; - const h = processedBars[i]; + const h = activeBars[i]; const y = align === 'bottom_up' ? height * 0.35 - h : (align === 'top_down' ? -height * 0.35 + h : -h); if (i === 0) targetCtx.moveTo(x, y); else targetCtx.lineTo(x, y); @@ -890,7 +1387,7 @@ targetCtx.beginPath(); for (let i = 0; i < barCount; i++) { const x = startX + i * step; - const h = processedBars[i]; + const h = activeBars[i]; if (i === 0) targetCtx.moveTo(x, h); else targetCtx.lineTo(x, h); } @@ -909,7 +1406,7 @@ const blockGap = 3; for (let i = 0; i < barCount; i++) { const x = startX + i * step; - const h = processedBars[i]; + const h = activeBars[i]; const blocks = Math.floor(h / (blockSize + blockGap)); for (let b = 0; b < blocks; b++) { const by = align === 'bottom_up' ? height * 0.38 - (b * (blockSize + blockGap)) : - (b * (blockSize + blockGap)); @@ -925,7 +1422,7 @@ targetCtx.lineCap = (cfg.barCornerRadius || 0) > 0 ? 'round' : 'butt'; for (let i = 0; i < barCount; i++) { const x = startX + i * step; - const h = processedBars[i]; + const h = activeBars[i]; targetCtx.beginPath(); if (align === 'center_mirrored') { @@ -962,7 +1459,7 @@ targetCtx.beginPath(); for (let i = 0; i < barCount; i++) { const x = startX + i * step; - const val = (freqData[i % binCount] || 0) / 255; + const val = (fMono[i % binCount] || 0) / 255; const oscY = Math.sin(i * 0.15 + currentTime * 8) * (val * maxHeight * 1.5) + (Math.cos(i * 0.3) * val * 20); if (i === 0) targetCtx.moveTo(x, oscY); else targetCtx.lineTo(x, oscY); @@ -980,9 +1477,9 @@ targetCtx.restore(); } - // 6.3 POLYGON & RADIAL SPECTRUMS (Circle, Hexagon, Diamond, Triangle, Octagon, Tunnel) + // 6.3 POLYGON, BLOB & RADIAL SPECTRUMS (Circle, Hexagon, Diamond, Triangle, Octagon, Tunnel, Blob) } else { - const sidesMap = { circle: 0, hexagon: 6, diamond: 4, triangle: 3, octagon: 8, tunnel: 0 }; + const sidesMap = { circle: 0, hexagon: 6, diamond: 4, triangle: 3, octagon: 8, tunnel: 0, blob: 0 }; const sides = sidesMap[shape] || 0; const barW = cfg.barWidth || 4; @@ -993,7 +1490,7 @@ for (let i = 0; i < barCount; i++) { const t = i / barCount; const pt = getPolygonPerimeterPoint(sides, baseRadius, t); - const barH = processedBars[i]; + const barH = activeBars[i]; const startDist = barSpacing; const endDist = barSpacing + barH; @@ -1024,7 +1521,7 @@ const idx = i % barCount; const t = idx / barCount; const pt = getPolygonPerimeterPoint(sides, baseRadius, t); - const rOffset = barSpacing + processedBars[idx]; + const rOffset = barSpacing + activeBars[idx]; const x = pt.x + pt.nx * rOffset; const y = pt.y + pt.ny * rOffset; @@ -1048,7 +1545,7 @@ for (let i = 0; i < barCount; i++) { const t = i / barCount; const pt = getPolygonPerimeterPoint(sides, baseRadius, t); - const rOffset = barSpacing + processedBars[i]; + const rOffset = barSpacing + activeBars[i]; const x = pt.x + pt.nx * rOffset; const y = pt.y + pt.ny * rOffset; @@ -1060,11 +1557,11 @@ targetCtx.lineWidth = barW; targetCtx.lineCap = (cfg.barCornerRadius || 0) > 0 ? 'round' : 'butt'; - // Inner Ring + // Inner Ring (Channel Left in Stereo mode) for (let i = 0; i < barCount; i++) { const t = i / barCount; const pt = getPolygonPerimeterPoint(sides, baseRadius, t); - const barH = processedBars[i] * 0.7; + const barH = (isStereo ? processedBarsL[i] : activeBars[i]) * 0.7; const x1 = pt.x + pt.nx * barSpacing; const y1 = pt.y + pt.ny * barSpacing; const x2 = pt.x + pt.nx * (barSpacing + barH); @@ -1076,12 +1573,12 @@ targetCtx.stroke(); } - // Outer Ring (Orbital Dots) + // Outer Ring (Channel Right in Stereo mode) targetCtx.fillStyle = cfg.colorGrad2 || '#ff007f'; for (let i = 0; i < barCount; i += 2) { const t = (i / barCount + (currentTime * 0.05)) % 1; const pt = getPolygonPerimeterPoint(sides, baseRadius, t); - const rOffset = barSpacing + maxHeight * 0.8 + (processedBars[i] * 0.4); + const rOffset = barSpacing + maxHeight * 0.8 + ((isStereo ? processedBarsR[i] : activeBars[i]) * 0.4); const x = pt.x + pt.nx * rOffset; const y = pt.y + pt.ny * rOffset; @@ -1095,10 +1592,35 @@ } // --- LAYER 7: Central Core Artwork & Shape Mask --- - const sidesMap = { circle: 0, hexagon: 6, diamond: 4, triangle: 3, octagon: 8, linear: 0, oscilloscope: 0, tunnel: 0 }; + const sidesMap = { circle: 0, hexagon: 6, diamond: 4, triangle: 3, octagon: 8, linear: 0, oscilloscope: 0, tunnel: 0, blob: 0, vumeter: 0 }; const polySides = sidesMap[shape] || 0; - if (cfg.showCover && state.coverImage && shape !== 'linear' && shape !== 'oscilloscope') { + if (shape === 'blob') { + targetCtx.save(); + drawFluidBlob(targetCtx, baseRadius, activeBars, rawBassEnergy, midEnergy, currentTime); + if (cfg.showCover && state.coverImage) { + targetCtx.save(); + targetCtx.clip(); + targetCtx.imageSmoothingEnabled = true; + targetCtx.imageSmoothingQuality = 'high'; + targetCtx.drawImage(state.coverImage, -baseRadius * 1.35, -baseRadius * 1.35, baseRadius * 2.7, baseRadius * 2.7); + targetCtx.restore(); + } else { + targetCtx.fillStyle = spectrumGrad; + targetCtx.globalAlpha = 0.85; + targetCtx.fill(); + } + if (cfg.showBorder && (cfg.borderWidth || 6) > 0) { + targetCtx.lineWidth = cfg.borderWidth || 6; + targetCtx.strokeStyle = cfg.borderColor || '#ffffff'; + if (cfg.enableGlow) { + targetCtx.shadowColor = cfg.borderColor || '#ffffff'; + targetCtx.shadowBlur = 12; + } + targetCtx.stroke(); + } + targetCtx.restore(); + } else if (cfg.showCover && state.coverImage && shape !== 'linear' && shape !== 'oscilloscope' && shape !== 'vumeter') { targetCtx.save(); targetCtx.imageSmoothingEnabled = true; targetCtx.imageSmoothingQuality = 'high'; @@ -1120,7 +1642,7 @@ } // --- LAYER 8: Shape Border & Progress Ring --- - if (cfg.showBorder && (cfg.borderWidth || 6) > 0 && shape !== 'linear' && shape !== 'oscilloscope') { + if (cfg.showBorder && (cfg.borderWidth || 6) > 0 && shape !== 'linear' && shape !== 'oscilloscope' && shape !== 'blob' && shape !== 'vumeter') { targetCtx.save(); drawPolygonPath(targetCtx, polySides, baseRadius); targetCtx.lineWidth = cfg.borderWidth || 6; @@ -1323,10 +1845,18 @@ } targetCtx.restore(); // Restore Root Context + + // --- LAYER 10: Retro VHS, Scanlines & CRT Distortion --- + applyVhsAndCrt(targetCtx, width, height, currentTime, cfg); + + // Capture frame for Phosphor Afterglow trails + capturePhosphorAfterglow(targetCtx, width, height, cfg); } // --- Real-Time Animation Loop (60 FPS) --- const freqArray = new Uint8Array(1024); + const freqArrayL = new Uint8Array(1024); + const freqArrayR = new Uint8Array(1024); function mainLoop() { if (!state.isExporting) { @@ -1335,17 +1865,25 @@ if (state.isPlaying) { if (state.audioLoaded && state.analyser) { state.analyser.getByteFrequencyData(freqArray); + if (state.config.stereoSplit && state.analyserLeft && state.analyserRight) { + state.analyserLeft.getByteFrequencyData(freqArrayL); + state.analyserRight.getByteFrequencyData(freqArrayR); + } } else if (!state.audioLoaded) { // Idle demo wave animation const t = performance.now() * 0.003; for (let i = 0; i < freqArray.length; i++) { freqArray[i] = Math.max(0, Math.sin(t + i * 0.08) * 32); + freqArrayL[i] = Math.max(0, Math.sin(t + i * 0.08) * 32); + freqArrayR[i] = Math.max(0, Math.cos(t + i * 0.08) * 32); } } } else { // Paused: smoothly decay frequencies to 0 so visualizer stays quiet & frozen for (let i = 0; i < freqArray.length; i++) { freqArray[i] = Math.floor(freqArray[i] * 0.8); + freqArrayL[i] = Math.floor(freqArrayL[i] * 0.8); + freqArrayR[i] = Math.floor(freqArrayR[i] * 0.8); } } @@ -1356,7 +1894,11 @@ document.getElementById('timelineProgressBar').style.width = `${pct}%`; } - renderFrame(ctx, state.width, state.height, freqArray, curTime, state.duration, state.config, state.isPlaying); + const freqPayload = (state.config.stereoSplit && state.analyserLeft && state.analyserRight) + ? { mono: freqArray, left: freqArrayL, right: freqArrayR } + : freqArray; + + renderFrame(ctx, state.width, state.height, freqPayload, curTime, state.duration, state.config, state.isPlaying); } requestAnimationFrame(mainLoop); @@ -1454,6 +1996,51 @@ state.config.progressBarHeight = parseInt(document.getElementById('progressBarHeight').value, 10) || 4; state.config.progressBarColor = document.getElementById('progressBarColor').value || '#00f0ff'; + // Stereo L/R Split + const stereoSplitEl = document.getElementById('stereoSplit'); + if (stereoSplitEl) state.config.stereoSplit = stereoSplitEl.checked; + const stereoModeEl = document.getElementById('stereoMode'); + if (stereoModeEl) state.config.stereoMode = stereoModeEl.value; + + // 3-Band Routing + const routePulseEl = document.getElementById('routePulse'); + if (routePulseEl) state.config.routePulse = routePulseEl.value; + const routeParticlesEl = document.getElementById('routeParticles'); + if (routeParticlesEl) state.config.routeParticles = routeParticlesEl.value; + const routeShakeEl = document.getElementById('routeShake'); + if (routeShakeEl) state.config.routeShake = routeShakeEl.value; + + // BPM & Beat Synchronization + const bpmSyncEl = document.getElementById('bpmSync'); + if (bpmSyncEl) state.config.bpmSync = bpmSyncEl.checked; + const bpmValEl = document.getElementById('bpmValue'); + if (bpmValEl) { + state.config.bpmValue = parseFloat(bpmValEl.value) || 128; + state.bpm = state.config.bpmValue; + } + const bpmSyncRotEl = document.getElementById('bpmSyncRotation'); + if (bpmSyncRotEl) state.config.bpmSyncRotation = bpmSyncRotEl.checked; + const bpmSyncGridEl = document.getElementById('bpmSyncGrid'); + if (bpmSyncGridEl) state.config.bpmSyncGrid = bpmSyncGridEl.checked; + + // Retro VHS & Scanlines + const enableVhsEl = document.getElementById('enableVhs'); + if (enableVhsEl) state.config.enableVhs = enableVhsEl.checked; + const vhsScanlineEl = document.getElementById('vhsScanlineIntensity'); + if (vhsScanlineEl) state.config.vhsScanlineIntensity = parseFloat(vhsScanlineEl.value); + const vhsNoiseEl = document.getElementById('vhsNoiseIntensity'); + if (vhsNoiseEl) state.config.vhsNoiseIntensity = parseFloat(vhsNoiseEl.value); + const vhsCrtEl = document.getElementById('vhsCrtCurvature'); + if (vhsCrtEl) state.config.vhsCrtCurvature = vhsCrtEl.checked; + + // Phosphor Afterglow / Trails + const enableAfterglowEl = document.getElementById('enableAfterglow'); + if (enableAfterglowEl) state.config.enableAfterglow = enableAfterglowEl.checked; + const afterglowDecayEl = document.getElementById('afterglowDecay'); + if (afterglowDecayEl) state.config.afterglowDecay = parseFloat(afterglowDecayEl.value); + const afterglowModeEl = document.getElementById('afterglowMode'); + if (afterglowModeEl) state.config.afterglowMode = afterglowModeEl.value; + // Sub-containers opacity / display const textOpts = document.getElementById('textOptionsContainer'); const titleOpts = document.getElementById('titleOptionsContainer'); @@ -1467,6 +2054,19 @@ if (timeOpts) timeOpts.style.opacity = (state.config.showTextOverlay && state.config.showTimecode) ? '1' : '0.35'; if (progOpts) progOpts.style.opacity = (state.config.showTextOverlay && state.config.showProgressBar) ? '1' : '0.35'; + // Option containers hidden toggle + const stereoOpts = document.getElementById('stereoOptionsContainer'); + if (stereoOpts) stereoOpts.classList.toggle('hidden', !state.config.stereoSplit); + + const bpmOpts = document.getElementById('bpmOptionsContainer'); + if (bpmOpts) bpmOpts.classList.toggle('hidden', !state.config.bpmSync); + + const vhsOpts = document.getElementById('vhsOptionsContainer'); + if (vhsOpts) vhsOpts.classList.toggle('hidden', !state.config.enableVhs); + + const afterglowOpts = document.getElementById('afterglowOptionsContainer'); + if (afterglowOpts) afterglowOpts.classList.toggle('hidden', !state.config.enableAfterglow); + // Toggle container visibility based on shape & toggles document.getElementById('linearAlignmentSection').style.display = state.config.shapeType === 'linear' ? 'block' : 'none'; coverUploadContainer.style.opacity = state.config.showCover ? '1' : '0.4'; @@ -1501,6 +2101,13 @@ document.getElementById('valProgressBarWidth').textContent = `${state.config.progressBarWidth}px`; document.getElementById('valProgressBarHeight').textContent = `${state.config.progressBarHeight}px`; + const valVhsScan = document.getElementById('valVhsScanlineIntensity'); + if (valVhsScan) valVhsScan.textContent = `${Math.round((state.config.vhsScanlineIntensity ?? 0.25) * 100)}%`; + const valVhsNoise = document.getElementById('valVhsNoiseIntensity'); + if (valVhsNoise) valVhsNoise.textContent = `${Math.round((state.config.vhsNoiseIntensity ?? 0.15) * 100)}%`; + const valAfterglow = document.getElementById('valAfterglowDecay'); + if (valAfterglow) valAfterglow.textContent = `${Math.round((state.config.afterglowDecay ?? 0.82) * 100)}%`; + initParticles(state.config.particleCount); } @@ -1610,6 +2217,32 @@ document.getElementById('progressBarHeight').value = state.config.progressBarHeight || 4; document.getElementById('progressBarColor').value = state.config.progressBarColor || '#00f0ff'; + // Stereo L/R Split + if (document.getElementById('stereoSplit')) document.getElementById('stereoSplit').checked = !!state.config.stereoSplit; + if (document.getElementById('stereoMode')) document.getElementById('stereoMode').value = state.config.stereoMode || 'split_hemisphere'; + + // 3-Band Routing + if (document.getElementById('routePulse')) document.getElementById('routePulse').value = state.config.routePulse || 'sub_bass'; + if (document.getElementById('routeParticles')) document.getElementById('routeParticles').value = state.config.routeParticles || 'treble'; + if (document.getElementById('routeShake')) document.getElementById('routeShake').value = state.config.routeShake || 'sub_bass'; + + // BPM Sync + if (document.getElementById('bpmSync')) document.getElementById('bpmSync').checked = !!state.config.bpmSync; + if (document.getElementById('bpmValue')) document.getElementById('bpmValue').value = state.config.bpmValue || state.bpm || 128; + if (document.getElementById('bpmSyncRotation')) document.getElementById('bpmSyncRotation').checked = state.config.bpmSyncRotation !== false; + if (document.getElementById('bpmSyncGrid')) document.getElementById('bpmSyncGrid').checked = state.config.bpmSyncGrid !== false; + + // Retro VHS + if (document.getElementById('enableVhs')) document.getElementById('enableVhs').checked = !!state.config.enableVhs; + if (document.getElementById('vhsScanlineIntensity')) document.getElementById('vhsScanlineIntensity').value = state.config.vhsScanlineIntensity ?? 0.25; + if (document.getElementById('vhsNoiseIntensity')) document.getElementById('vhsNoiseIntensity').value = state.config.vhsNoiseIntensity ?? 0.15; + if (document.getElementById('vhsCrtCurvature')) document.getElementById('vhsCrtCurvature').checked = state.config.vhsCrtCurvature !== false; + + // Phosphor Afterglow + if (document.getElementById('enableAfterglow')) document.getElementById('enableAfterglow').checked = !!state.config.enableAfterglow; + if (document.getElementById('afterglowDecay')) document.getElementById('afterglowDecay').value = state.config.afterglowDecay ?? 0.82; + if (document.getElementById('afterglowMode')) document.getElementById('afterglowMode').value = state.config.afterglowMode || 'original'; + updateConfigFromUI(); } @@ -1849,9 +2482,12 @@ const totalFrames = Math.ceil(state.duration * fps); const sampleRate = state.audioBuffer.sampleRate; const numChannels = Math.min(2, state.audioBuffer.numberOfChannels); - const channelData = state.audioBuffer.getChannelData(0); + const hasStereo = !!state.config.stereoSplit && state.audioBuffer.numberOfChannels >= 2; + const channelDataL = state.audioBuffer.getChannelData(0); + const channelDataR = hasStereo ? state.audioBuffer.getChannelData(1) : channelDataL; const sampleWindow = 2048; - const samplesBuffer = new Float32Array(sampleWindow); + const samplesBufferL = new Float32Array(sampleWindow); + const samplesBufferR = hasStereo ? new Float32Array(sampleWindow) : null; // Create Offscreen Canvas for Frame Rendering const renderCanvas = document.createElement('canvas'); @@ -2084,13 +2720,30 @@ for (let s = 0; s < sampleWindow; s++) { const idx = startSample + s; - samplesBuffer[s] = (idx < channelData.length) ? channelData[idx] : 0; + samplesBufferL[s] = (idx < channelDataL.length) ? channelDataL[idx] : 0; + if (hasStereo) { + samplesBufferR[s] = (idx < channelDataR.length) ? channelDataR[idx] : 0; + } } - const frameFreq = globalFastFFT.compute(samplesBuffer, 1024); + const frameFreqL = globalFastFFT.compute(samplesBufferL, 1024); + let frameFreqPayload = frameFreqL; + + if (hasStereo) { + const frameFreqR = globalFastFFT_R.compute(samplesBufferR, 1024); + const frameFreqMono = new Uint8Array(1024); + for (let i = 0; i < 1024; i++) { + frameFreqMono[i] = Math.round((frameFreqL[i] + frameFreqR[i]) / 2); + } + frameFreqPayload = { + mono: frameFreqMono, + left: frameFreqL, + right: frameFreqR + }; + } // Render frame to offscreen canvas - renderFrame(renderCtx, targetWidth, targetHeight, frameFreq, currentTime, state.duration, state.config, true); + renderFrame(renderCtx, targetWidth, targetHeight, frameFreqPayload, currentTime, state.duration, state.config, true); let finalFrameCanvas = renderCanvas; if (isTopBottom) { @@ -2346,9 +2999,12 @@ const totalFrames = Math.ceil(state.duration * fps); const sampleRate = state.audioBuffer.sampleRate; - const channelData = state.audioBuffer.getChannelData(0); + const hasStereo = !!state.config.stereoSplit && state.audioBuffer.numberOfChannels >= 2; + const channelDataL = state.audioBuffer.getChannelData(0); + const channelDataR = hasStereo ? state.audioBuffer.getChannelData(1) : channelDataL; const sampleWindow = 2048; - const samplesBuffer = new Float32Array(sampleWindow); + const samplesBufferL = new Float32Array(sampleWindow); + const samplesBufferR = hasStereo ? new Float32Array(sampleWindow) : null; const renderStartTime = performance.now(); let lastEtaUpdate = 0; @@ -2368,13 +3024,30 @@ for (let s = 0; s < sampleWindow; s++) { const idx = startSample + s; - samplesBuffer[s] = (idx < channelData.length) ? channelData[idx] : 0; + samplesBufferL[s] = (idx < channelDataL.length) ? channelDataL[idx] : 0; + if (hasStereo) { + samplesBufferR[s] = (idx < channelDataR.length) ? channelDataR[idx] : 0; + } } - const frameFreq = globalFastFFT.compute(samplesBuffer, 1024); + const frameFreqL = globalFastFFT.compute(samplesBufferL, 1024); + let frameFreqPayload = frameFreqL; + + if (hasStereo) { + const frameFreqR = globalFastFFT_R.compute(samplesBufferR, 1024); + const frameFreqMono = new Uint8Array(1024); + for (let i = 0; i < 1024; i++) { + frameFreqMono[i] = Math.round((frameFreqL[i] + frameFreqR[i]) / 2); + } + frameFreqPayload = { + mono: frameFreqMono, + left: frameFreqL, + right: frameFreqR + }; + } // Render high-precision frame - renderFrame(renderCtx, targetWidth, targetHeight, frameFreq, currentTime, state.duration, state.config, true); + renderFrame(renderCtx, targetWidth, targetHeight, frameFreqPayload, currentTime, state.duration, state.config, true); // Extract raw RGBA const imgData = renderCtx.getImageData(0, 0, targetWidth, targetHeight); @@ -2673,6 +3346,49 @@ applyPreset(e.target.value); }); + // Tap Tempo & BPM Handling + const btnTapTempo = document.getElementById('btnTapTempo'); + if (btnTapTempo) { + btnTapTempo.addEventListener('click', () => { + const now = performance.now(); + if (state.tapTimes.length > 0 && (now - state.tapTimes[state.tapTimes.length - 1] > 2500)) { + state.tapTimes = []; + } + state.tapTimes.push(now); + if (state.tapTimes.length > 8) state.tapTimes.shift(); + + if (state.tapTimes.length >= 2) { + const intervals = []; + for (let i = 1; i < state.tapTimes.length; i++) { + intervals.push(state.tapTimes[i] - state.tapTimes[i - 1]); + } + const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length; + const tapBpm = Math.round(60000 / avgInterval); + if (tapBpm >= 40 && tapBpm <= 260) { + state.bpm = tapBpm; + state.config.bpmValue = tapBpm; + const bpmInp = document.getElementById('bpmValue'); + if (bpmInp) bpmInp.value = tapBpm; + const badge = document.getElementById('bpmDetectedBadge'); + if (badge) badge.textContent = `Tapped: ${tapBpm}`; + } + } + }); + } + + const bpmInput = document.getElementById('bpmValue'); + if (bpmInput) { + bpmInput.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + if (val >= 40 && val <= 260) { + state.bpm = val; + state.config.bpmValue = val; + const badge = document.getElementById('bpmDetectedBadge'); + if (badge) badge.textContent = `Manuell: ${val}`; + } + }); + } + // AIWentNonsense 1-Click Mode Trigger const btnAiWentNonsenseMode = document.getElementById('btnAiWentNonsenseMode'); if (btnAiWentNonsenseMode) { diff --git a/public/index.html b/public/index.html index c11b4cb..6549b68 100644 --- a/public/index.html +++ b/public/index.html @@ -280,6 +280,20 @@ 3D Tunnel + + @@ -493,6 +507,90 @@ + + +
+
+

Stereo L/R Dual-Channel

+ +
+ + +
+ + +
+

3-Band Element-Routing

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

BPM & Beat-Synchronisation

+ +
+ + +
@@ -614,6 +712,71 @@ + + +
+
+

Retro VHS & Scanlines

+ +
+ + +
+ + +
+
+

Phosphor Afterglow (Schweif)

+ +
+ + +
diff --git a/public/presets/fluid_blob.json b/public/presets/fluid_blob.json new file mode 100644 index 0000000..3932c03 --- /dev/null +++ b/public/presets/fluid_blob.json @@ -0,0 +1,81 @@ +{ + "name": "Fluid Organic Blob", + "icon": "🫧", + "shapeType": "blob", + "showCover": true, + "coverShape": "circle", + "circleRadius": 160, + "pulseIntensity": 0.45, + "pulseDecay": 0.85, + "showBorder": true, + "borderWidth": 5, + "borderColor": "#00f0ff", + "showProgressRing": false, + "progressRingColor": "#00f0ff", + "progressRingWidth": 5, + "rotationSpeed": 2.0, + "beatSpinImpulse": false, + "showSpectrum": false, + "spectrumMode": "radial_bars", + "linearAlignment": "center_mirrored", + "barCount": 96, + "spectrumMaxHeight": 120, + "barWidth": 4, + "barSpacing": 10, + "barCornerRadius": 2, + "freqSensitivity": 1.3, + "freqFocus": "balanced", + "mirrorSpectrum": false, + "stereoSplit": false, + "stereoMode": "split_hemisphere", + "routePulse": "sub_bass", + "routeParticles": "treble", + "routeShake": "sub_bass", + "bpmSync": false, + "bpmValue": 128, + "bpmSyncRotation": false, + "bpmSyncGrid": false, + "enableParticles": true, + "particleType": "starfield", + "particleCount": 85, + "particleSpeed": 0.9, + "particleWarp": 2.0, + "enableGrid": false, + "colorGrid": "#00f0ff", + "gridPerspective": 0.65, + "gridAudioReactive": 0.3, + "enableShake": false, + "camShakeIntensity": 0.2, + "enableGlitch": false, + "rgbSplitIntensity": 0.1, + "beatFlash": false, + "enableBassZoom": true, + "enableVhs": false, + "vhsScanlineIntensity": 0.2, + "vhsNoiseIntensity": 0.1, + "vhsCrtCurvature": false, + "enableAfterglow": true, + "afterglowDecay": 0.78, + "afterglowMode": "cyan_neon", + "colorMode": "gradient_3", + "colorGrad1": "#00f0ff", + "colorGrad2": "#7000ff", + "colorGrad3": "#ff007f", + "colorParticle": "#00f0ff", + "gradientStyle": "angular", + "enableGlow": true, + "glowIntensity": 0.85, + "glowBlur": 26, + "laserCore": false, + "backgroundMode": "transparent", + "showTextOverlay": true, + "fontFamily": "'Outfit', sans-serif", + "textPosition": "below_circle", + "titleFontSize": 38, + "artistFontSize": 22, + "textPulseWithBeat": true, + "textGlow": true, + "titleColor": "#ffffff", + "artistColor": "#00f0ff", + "timecodeColor": "#7000ff" +} diff --git a/public/presets/vintage_vumeter.json b/public/presets/vintage_vumeter.json new file mode 100644 index 0000000..1b33078 --- /dev/null +++ b/public/presets/vintage_vumeter.json @@ -0,0 +1,81 @@ +{ + "name": "Analog VU-Meter", + "icon": "📻", + "shapeType": "vumeter", + "showCover": false, + "coverShape": "circle", + "circleRadius": 150, + "pulseIntensity": 0.4, + "pulseDecay": 0.82, + "showBorder": false, + "borderWidth": 4, + "borderColor": "#ffaa22", + "showProgressRing": false, + "progressRingColor": "#ffaa22", + "progressRingWidth": 5, + "rotationSpeed": 0, + "beatSpinImpulse": false, + "showSpectrum": false, + "spectrumMode": "radial_bars", + "linearAlignment": "center_mirrored", + "barCount": 96, + "spectrumMaxHeight": 120, + "barWidth": 4, + "barSpacing": 10, + "barCornerRadius": 2, + "freqSensitivity": 1.3, + "freqFocus": "balanced", + "mirrorSpectrum": false, + "stereoSplit": true, + "stereoMode": "split_hemisphere", + "routePulse": "sub_bass", + "routeParticles": "treble", + "routeShake": "sub_bass", + "bpmSync": false, + "bpmValue": 128, + "bpmSyncRotation": false, + "bpmSyncGrid": false, + "enableParticles": true, + "particleType": "dust", + "particleCount": 60, + "particleSpeed": 0.5, + "particleWarp": 1.2, + "enableGrid": false, + "colorGrid": "#ffaa22", + "gridPerspective": 0.65, + "gridAudioReactive": 0.3, + "enableShake": false, + "camShakeIntensity": 0.2, + "enableGlitch": false, + "rgbSplitIntensity": 0.1, + "beatFlash": false, + "enableBassZoom": false, + "enableVhs": true, + "vhsScanlineIntensity": 0.2, + "vhsNoiseIntensity": 0.1, + "vhsCrtCurvature": true, + "enableAfterglow": false, + "afterglowDecay": 0.8, + "afterglowMode": "amber_p4", + "colorMode": "gradient_2", + "colorGrad1": "#ffcc44", + "colorGrad2": "#ff4422", + "colorGrad3": "#222222", + "colorParticle": "#ffdd88", + "gradientStyle": "angular", + "enableGlow": true, + "glowIntensity": 0.7, + "glowBlur": 20, + "laserCore": false, + "backgroundMode": "dark_vignette", + "showTextOverlay": true, + "fontFamily": "'Outfit', sans-serif", + "textPosition": "below_circle", + "titleFontSize": 36, + "artistFontSize": 20, + "textPulseWithBeat": false, + "textGlow": true, + "titleColor": "#ffeedd", + "artistColor": "#ccbbaa", + "timecodeColor": "#ffaa22" +} diff --git a/public/style.css b/public/style.css index 6e2980a..51add35 100644 --- a/public/style.css +++ b/public/style.css @@ -1477,3 +1477,36 @@ body { font-size: 0.75rem; padding: 4px 8px; } + +/* BPM Controls */ +.bpm-input-wrapper { + display: flex; + align-items: center; + gap: 8px; +} +.bpm-num-input { + width: 72px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-main); + padding: 6px 8px; + font-family: inherit; + font-weight: 600; + font-size: 0.9rem; + text-align: center; +} +.bpm-num-input:focus { + outline: none; + border-color: var(--border-focus); +} +.bpm-badge { + background: rgba(0, 240, 255, 0.12); + border: 1px solid rgba(0, 240, 255, 0.35); + color: var(--accent-cyan); + font-size: 0.72rem; + font-weight: 700; + padding: 4px 8px; + border-radius: var(--radius-sm); + white-space: nowrap; +} diff --git a/server.mjs b/server.mjs index b22c557..72ad361 100644 --- a/server.mjs +++ b/server.mjs @@ -146,7 +146,7 @@ app.get('/api/presets', (req, res) => { const key = file.replace('.json', ''); const data = fs.readFileSync(path.join(presetsDir, file), 'utf-8'); try { - presets[key] = JSON.parse(data); + presets[key] = JSON.parse(data.replace(/^\uFEFF/, '')); } catch (e) { console.error(`Error parsing preset ${file}:`, e); }