From 3926c6e7aba9e75fa0c44dfa2d1899e90c070396 Mon Sep 17 00:00:00 2001 From: F04C Date: Sun, 3 May 2026 12:31:25 +0800 Subject: [PATCH] final Co-authored-by: Copilot --- templates/index.html | 119 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 112 insertions(+), 7 deletions(-) diff --git a/templates/index.html b/templates/index.html index bc19fa0..d5410c4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -493,6 +493,7 @@ const MIN_SPHERE_SIZE = 0.16; const MAX_SPHERE_SIZE = 0.62; const HZ_LABEL_FADE_MS = 2000; + const POINT_FADE_WINDOW_SEC = 2; function setFrequencyColor(color, fn, an) { const t = Math.min(Math.max(fn, 0), 1); @@ -509,6 +510,22 @@ ); } + function syncFrequencyLegend() { + const gradStrip = document.querySelector(".grad-strip"); + if (!gradStrip) return; + + const samples = [0, 0.25, 0.5, 0.75, 1]; + const c = new THREE.Color(); + const stops = samples.map((fn) => { + setFrequencyColor(c, fn, 0.85); + return `#${c.getHexString()} ${Math.round(fn * 100)}%`; + }); + + gradStrip.style.background = `linear-gradient(to right, ${stops.join(", ")})`; + } + + syncFrequencyLegend(); + // ── Build instanced visualization ──────────────────────────────────────── // All instances are pre-loaded but initially count=0 so nothing is drawn. // The animation loop reveals them as the audio plays. @@ -543,20 +560,47 @@ const n = data.tn.length; const positions = new Float32Array(n * 3); + const instanceColors = new Float32Array(n * 3); + const instanceAlphas = new Float32Array(n); const sphereGeo = new THREE.SphereGeometry(1, 10, 8); + const baseVertexColors = new Float32Array( + sphereGeo.attributes.position.count * 3, + ); + baseVertexColors.fill(1); + sphereGeo.setAttribute( + "color", + new THREE.Float32BufferAttribute(baseVertexColors, 3), + ); const sphereMat = new THREE.MeshBasicMaterial({ + color: 0xffffff, vertexColors: true, - transparent: false, - opacity: 1, + transparent: true, + opacity: 0.78, + depthWrite: false, fog: false, toneMapped: false, }); + sphereMat.onBeforeCompile = (shader) => { + shader.vertexShader = + "attribute float instanceAlpha; varying float vInstanceAlpha;\n" + + shader.vertexShader.replace( + "#include ", + "#include \n vInstanceAlpha = instanceAlpha;", + ); + shader.fragmentShader = + "varying float vInstanceAlpha;\n" + + shader.fragmentShader.replace( + "vec4 diffuseColor = vec4( diffuse, opacity );", + "vec4 diffuseColor = vec4( diffuse, opacity * vInstanceAlpha );", + ); + }; audioMesh = new THREE.InstancedMesh(sphereGeo, sphereMat, n); - audioMesh.count = 0; + audioMesh.count = n; const lineGeo = new THREE.BufferGeometry(); const lineMat = new THREE.LineBasicMaterial({ color: 0xbfd5ff, + vertexColors: true, linewidth: 1, transparent: true, opacity: 0.32, @@ -594,15 +638,31 @@ setFrequencyColor(color, fn, an); audioMesh.setColorAt(i, color); + instanceColors[p3] = color.r; + instanceColors[p3 + 1] = color.g; + instanceColors[p3 + 2] = color.b; + instanceAlphas[i] = 0; } + audioMesh.instanceColor = new THREE.InstancedBufferAttribute( + instanceColors, + 3, + ); + audioMesh.geometry.setAttribute( + "instanceAlpha", + new THREE.InstancedBufferAttribute(instanceAlphas, 1), + ); audioPositions = positions; audioMesh.instanceMatrix.needsUpdate = true; - audioMesh.instanceColor.needsUpdate = true; + if (audioMesh.instanceColor) audioMesh.instanceColor.needsUpdate = true; lineGeo.setAttribute( "position", new THREE.BufferAttribute(positions, 3), ); + lineGeo.setAttribute( + "color", + new THREE.BufferAttribute(instanceColors, 3), + ); lineGeo.setDrawRange(0, 0); audioLine = new THREE.Line(lineGeo, lineMat); @@ -632,7 +692,7 @@ let autoOrbit = true; let isPointerHeldInCanvas = false; let isControlInteracting = false; - let orbitSpeed = 0.6; // radians per second + let orbitSpeed = 0.1; // radians per second const orbitClock = new THREE.Clock(); function syncAutoOrbitState() { @@ -726,9 +786,25 @@ function updateRevealAndTrail(nowMs) { const visibleCount = getVisibleCountAtCurrentTime(); if (!audioMesh || !audioLine) return; + const cutoffTime = audioEl.currentTime - POINT_FADE_WINDOW_SEC; + const times = audioData?.t; + let startIndex = 0; - audioMesh.count = visibleCount; - audioLine.geometry.setDrawRange(0, Math.max(0, visibleCount)); + if (times && cutoffTime > 0) { + let lo = 0; + let hi = visibleCount; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (times[mid] < cutoffTime) lo = mid + 1; + else hi = mid; + } + startIndex = lo; + } + + audioLine.geometry.setDrawRange( + startIndex, + Math.max(0, visibleCount - startIndex), + ); const revealIndex = visibleCount - 1; if (revealIndex !== lastRevealIndex && revealIndex >= 0) { @@ -737,6 +813,34 @@ } } + function updatePointFadeByPlayback() { + if (!audioMesh || !audioData || audioEl.duration <= 0) return; + const alphaAttr = audioMesh.geometry.getAttribute("instanceAlpha"); + if (!alphaAttr) return; + + const currentT = audioEl.currentTime; + const isAtStart = audioEl.paused && currentT === 0; + const times = audioData.t; + const alphas = alphaAttr.array; + + for (let i = 0; i < times.length; i++) { + if (isAtStart) { + alphas[i] = 0; + continue; + } + + const age = currentT - times[i]; + if (age < 0 || age > POINT_FADE_WINDOW_SEC) { + alphas[i] = 0; + } else { + const fade = 1 - age / POINT_FADE_WINDOW_SEC; + alphas[i] = fade; + } + } + + alphaAttr.needsUpdate = true; + } + function updateTimeCursorFromPlayback() { if (!timeCursor.visible || audioEl.duration <= 0) return; @@ -768,6 +872,7 @@ orbitControls.update(); updateRevealAndTrail(nowMs); + updatePointFadeByPlayback(); updateTimeCursorFromPlayback(); updateHzLabel(nowMs);