diff --git a/app.js b/app.js index e5ea2f0..52b828d 100644 --- a/app.js +++ b/app.js @@ -847,6 +847,51 @@ for (let i = 0; i < RUNE_COUNT; i++) { } +// === MUSIC VISUALIZER === +// Ring of frequency-reactive bars around the glass platform. +// Powered by a Web Audio AnalyserNode tapped off masterGain. +// Bars are invisible when audio is stopped. + +const VIZ_BAR_COUNT = 64; +const VIZ_RING_RADIUS = 5.8; +const VIZ_BAR_WIDTH = 0.07; +const VIZ_BAR_MAX_HEIGHT = 4.5; +const VIZ_BAR_MIN_HEIGHT = 0.04; + +/** @type {AnalyserNode|null} */ +let vizAnalyser = null; +/** @type {Uint8Array|null} */ +let vizFreqData = null; + +const vizGroup = new THREE.Group(); +vizGroup.visible = false; +scene.add(vizGroup); + +const _vizBarGeo = new THREE.BoxGeometry(VIZ_BAR_WIDTH, 1.0, VIZ_BAR_WIDTH); + +/** @type {THREE.Mesh[]} */ +const vizBars = []; + +for (let i = 0; i < VIZ_BAR_COUNT; i++) { + const angle = (i / VIZ_BAR_COUNT) * Math.PI * 2; + const mat = new THREE.MeshBasicMaterial({ + color: new THREE.Color(NEXUS.colors.accent), + transparent: true, + opacity: 0.8, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const bar = new THREE.Mesh(_vizBarGeo, mat); + bar.position.set( + Math.cos(angle) * VIZ_RING_RADIUS, + VIZ_BAR_MIN_HEIGHT / 2, + Math.sin(angle) * VIZ_RING_RADIUS + ); + bar.scale.y = VIZ_BAR_MIN_HEIGHT; + vizGroup.add(bar); + vizBars.push(bar); +} + // === WARP TUNNEL EFFECT === const WarpShader = { uniforms: { @@ -1029,6 +1074,26 @@ function animate() { rune.sprite.material.opacity = 0.65 + Math.sin(elapsed * 1.2 + rune.floatPhase) * 0.2; } + // Animate music visualizer bars + if (audioRunning && vizAnalyser && vizFreqData) { + vizAnalyser.getByteFrequencyData(vizFreqData); + for (let i = 0; i < VIZ_BAR_COUNT; i++) { + const freqVal = vizFreqData[i] / 255; + const h = VIZ_BAR_MIN_HEIGHT + freqVal * VIZ_BAR_MAX_HEIGHT; + const bar = vizBars[i]; + bar.scale.y = h; + bar.position.y = h / 2; + // Color gradient: low freq = blue, high freq = cyan + const t = i / VIZ_BAR_COUNT; + bar.material.color.setRGB( + 0.05 + freqVal * 0.7, + 0.35 + t * 0.35 + freqVal * 0.5, + 1.0 + ); + bar.material.opacity = 0.2 + freqVal * 0.8; + } + } + // Portal collision detection forwardVector.set(0, 0, -1).applyQuaternion(camera.quaternion); raycaster.set(camera.position, forwardVector); @@ -1125,6 +1190,14 @@ function startAmbient() { masterGain = audioCtx.createGain(); masterGain.gain.value = 0; + // Frequency analyser tap for the music visualizer + vizAnalyser = audioCtx.createAnalyser(); + vizAnalyser.fftSize = 128; // 64 frequency bins + vizAnalyser.smoothingTimeConstant = 0.82; + vizFreqData = new Uint8Array(vizAnalyser.frequencyBinCount); + masterGain.connect(vizAnalyser); + vizGroup.visible = true; + // Reverb const convolver = audioCtx.createConvolver(); convolver.buffer = buildReverbIR(audioCtx, 3.5, 2.8); @@ -1240,6 +1313,9 @@ function startAmbient() { function stopAmbient() { if (!audioRunning || !audioCtx) return; audioRunning = false; + vizAnalyser = null; + vizFreqData = null; + vizGroup.visible = false; if (sparkleTimer !== null) { clearTimeout(sparkleTimer); sparkleTimer = null; } const gain = masterGain;