Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
F04C
2026-05-03 12:22:34 +08:00
parent 1352c62ec1
commit 30aa032990
+303 -86
View File
@@ -164,6 +164,27 @@
text-overflow: ellipsis;
}
.orbit-speed {
display: flex;
align-items: center;
gap: 8px;
color: rgba(255, 255, 255, 0.75);
font-size: 12px;
white-space: nowrap;
}
.orbit-speed input[type="range"] {
width: 120px;
accent-color: #79a7ff;
}
.orbit-speed .value {
min-width: 44px;
text-align: right;
color: rgba(255, 255, 255, 0.9);
font-variant-numeric: tabular-nums;
}
#loading-dot {
width: 8px;
height: 8px;
@@ -288,6 +309,24 @@
color: rgba(255, 255, 255, 0.3);
margin-top: 8px;
}
#hz-label {
position: fixed;
left: 0;
top: 0;
transform: translate(-50%, -130%);
font-size: 12px;
color: rgba(255, 255, 255, 0.95);
background: rgba(8, 12, 24, 0.9);
border: 1px solid rgba(120, 170, 255, 0.35);
border-radius: 6px;
padding: 4px 7px;
pointer-events: none;
opacity: 0;
display: none;
white-space: nowrap;
z-index: 180;
}
</style>
<!-- Three.js r155 via importmap -->
@@ -318,6 +357,8 @@
<div class="sub">Upload an audio file to begin</div>
</div>
<div id="hz-label"></div>
<div id="info-panel">
<h3>Audio Info</h3>
<div id="info-rows"></div>
@@ -327,16 +368,27 @@
<div class="grad-ends"><span>Low</span><span>High</span></div>
</div>
<div class="axes-info">
X axis → Time<br />
Y axis → Frequency<br />
Z axis → Amplitude
Azimuth → Time<br />
Polar angle → Frequency<br />
Radius → Amplitude
</div>
</div>
<div id="controls">
<input type="file" id="file-input" accept=".mp3,.wav,.ogg,.flac,.m4a" />
<button class="btn" id="pick-btn">📁 Choose File</button>
<button class="btn" id="analyze-btn" disabled>🔍 Analyze</button>
<label class="orbit-speed" for="orbit-speed-slider">
Orbit
<input
id="orbit-speed-slider"
type="range"
min="0"
max="2.5"
step="0.05"
value="0.1"
/>
<span class="value" id="orbit-speed-value">0.10</span>
</label>
<audio id="audio-player" controls></audio>
<div id="loading-dot"></div>
<span id="status-text">No file selected</span>
@@ -359,17 +411,18 @@
0.1,
2000,
);
camera.position.set(0, 30, 120);
camera.position.set(0, 56, 145);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(devicePixelRatio);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setSize(innerWidth, innerHeight);
container.appendChild(renderer.domElement);
const orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enableDamping = true;
orbitControls.dampingFactor = 0.06;
orbitControls.target.set(0, 20, 0);
orbitControls.target.set(0, 24, 0);
orbitControls.update();
// Lights
@@ -381,9 +434,19 @@
fill.position.set(-60, -20, -50);
scene.add(fill);
// Grid
const grid = new THREE.GridHelper(140, 28, 0x1a1a33, 0x101028);
scene.add(grid);
// Spherical plotting boundary
const plotBoundary = new THREE.Mesh(
new THREE.SphereGeometry(54, 48, 32),
new THREE.MeshBasicMaterial({
color: 0x3a4f7a,
wireframe: true,
transparent: true,
opacity: 0,
}),
);
plotBoundary.position.y = 24;
plotBoundary.visible = false;
scene.add(plotBoundary);
// Axis arrows
const arrowMat = (hex) => new THREE.MeshBasicMaterial({ color: hex });
@@ -416,7 +479,35 @@
// Audio visualization mesh (reused across analyses)
let audioMesh = null;
let audioLine = null;
let audioData = null; // full dataset kept for real-time reveal
let audioPositions = null;
let lastRevealIndex = -1;
let hzLabelIndex = -1;
let hzLabelStartMs = 0;
const TIME_TURNS = 7;
const SPHERE_CENTER_Y = 24;
const MIN_PLOT_RADIUS = 10;
const MAX_PLOT_RADIUS = 50;
const PLOT_RADIUS_SPAN = MAX_PLOT_RADIUS - MIN_PLOT_RADIUS;
const MIN_SPHERE_SIZE = 0.16;
const MAX_SPHERE_SIZE = 0.62;
const HZ_LABEL_FADE_MS = 2000;
function setFrequencyColor(color, fn, an) {
const t = Math.min(Math.max(fn, 0), 1);
const low = { r: 0.1, g: 0.55, b: 1 };
const high = { r: 1, g: 0.25, b: 0.12 };
const r = low.r + (high.r - low.r) * t;
const g = low.g + (high.g - low.g) * t;
const b = low.b + (high.b - low.b) * t;
const boost = 0.8 + an * 0.35;
color.setRGB(
Math.min(r * boost, 1),
Math.min(g * boost, 1),
Math.min(b * boost, 1),
);
}
// ── Build instanced visualization ────────────────────────────────────────
// All instances are pre-loaded but initially count=0 so nothing is drawn.
@@ -428,6 +519,15 @@
audioMesh.material.dispose();
audioMesh = null;
}
if (audioLine) {
scene.remove(audioLine);
audioLine.geometry.dispose();
audioLine.material.dispose();
audioLine = null;
}
audioPositions = null;
lastRevealIndex = -1;
hzLabelIndex = -1;
// Sort all arrays by raw time so count-based reveal is correct
const order = Array.from({ length: data.t.length }, (_, i) => i).sort(
(a, b) => data.t[a] - data.t[b],
@@ -442,43 +542,76 @@
audioData = data;
const n = data.tn.length;
const geo = new THREE.SphereGeometry(0.22, 5, 4);
const mat = new THREE.MeshPhongMaterial({
const positions = new Float32Array(n * 3);
const sphereGeo = new THREE.SphereGeometry(1, 10, 8);
const sphereMat = new THREE.MeshBasicMaterial({
vertexColors: true,
shininess: 60,
transparent: false,
opacity: 1,
fog: false,
toneMapped: false,
});
audioMesh = new THREE.InstancedMesh(sphereGeo, sphereMat, n);
audioMesh.count = 0;
const lineGeo = new THREE.BufferGeometry();
const lineMat = new THREE.LineBasicMaterial({
color: 0xbfd5ff,
linewidth: 1,
transparent: true,
opacity: 0.32,
fog: false,
});
audioMesh = new THREE.InstancedMesh(geo, mat, n);
audioMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
audioMesh.count = 0; // start with nothing visible
const matrix = new THREE.Matrix4();
const color = new THREE.Color();
const temp = new THREE.Object3D();
for (let i = 0; i < n; i++) {
const tn = data.tn[i];
const fn = data.fn[i];
const an = data.an[i];
const x = tn * 100 - 50;
const y = fn * 60;
const z = an * 30 - 15;
const theta = (tn * TIME_TURNS + fn * 0.15) * Math.PI * 2;
const phi = fn * Math.PI;
const radius = MIN_PLOT_RADIUS + an * PLOT_RADIUS_SPAN;
const sinPhi = Math.sin(phi);
const x = radius * sinPhi * Math.cos(theta);
const y = SPHERE_CENTER_Y + radius * Math.cos(phi);
const z = radius * sinPhi * Math.sin(theta);
matrix.makeTranslation(x, y, z);
audioMesh.setMatrixAt(i, matrix);
const p3 = i * 3;
positions[p3] = x;
positions[p3 + 1] = y;
positions[p3 + 2] = z;
const hue = (1 - fn) * 0.667;
const lightness = 0.3 + an * 0.4;
color.setHSL(hue, 1.0, lightness);
const size =
MIN_SPHERE_SIZE +
Math.random() * (MAX_SPHERE_SIZE - MIN_SPHERE_SIZE);
temp.position.set(x, y, z);
temp.scale.setScalar(size);
temp.updateMatrix();
audioMesh.setMatrixAt(i, temp.matrix);
setFrequencyColor(color, fn, an);
audioMesh.setColorAt(i, color);
}
audioPositions = positions;
audioMesh.instanceMatrix.needsUpdate = true;
audioMesh.instanceColor.needsUpdate = true;
lineGeo.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3),
);
lineGeo.setDrawRange(0, 0);
audioLine = new THREE.Line(lineGeo, lineMat);
scene.add(audioMesh);
scene.add(audioLine);
// Show time cursor
timeCursor.visible = true;
timeCursor.position.x = -50;
timeCursor.position.set(MIN_PLOT_RADIUS, SPHERE_CENTER_Y, 0);
// Update info panel
document.getElementById("info-panel").style.display = "block";
@@ -489,79 +622,155 @@
`;
// Camera reset
camera.position.set(0, 30, 120);
orbitControls.target.set(0, 20, 0);
camera.position.set(0, 56, 145);
orbitControls.target.set(0, SPHERE_CENTER_Y, 0);
orbitControls.update();
}
// ── Auto-orbit state ─────────────────────────────────────────────────────
// Camera orbits slowly when idle; pauses while user is interacting.
let autoOrbit = true;
let orbitResumeTimer = null;
const ORBIT_SPEED = 0.003; // radians per frame
const RESUME_DELAY = 3000; // ms after user releases controls
let isPointerHeldInCanvas = false;
let isControlInteracting = false;
let orbitSpeed = 0.6; // radians per second
const orbitClock = new THREE.Clock();
function syncAutoOrbitState() {
autoOrbit = !(isPointerHeldInCanvas || isControlInteracting);
}
renderer.domElement.addEventListener("pointerdown", () => {
autoOrbit = false;
clearTimeout(orbitResumeTimer);
isPointerHeldInCanvas = true;
syncAutoOrbitState();
});
renderer.domElement.addEventListener("pointerup", () => {
clearTimeout(orbitResumeTimer);
orbitResumeTimer = setTimeout(() => {
autoOrbit = true;
}, RESUME_DELAY);
globalThis.addEventListener("pointerup", () => {
isPointerHeldInCanvas = false;
syncAutoOrbitState();
});
renderer.domElement.addEventListener("wheel", () => {
autoOrbit = false;
clearTimeout(orbitResumeTimer);
orbitResumeTimer = setTimeout(() => {
autoOrbit = true;
}, RESUME_DELAY);
isControlInteracting = true;
syncAutoOrbitState();
requestAnimationFrame(() => {
isControlInteracting = false;
syncAutoOrbitState();
});
});
orbitControls.addEventListener("start", () => {
isControlInteracting = true;
syncAutoOrbitState();
});
orbitControls.addEventListener("end", () => {
isControlInteracting = false;
syncAutoOrbitState();
});
// ── Animation loop ───────────────────────────────────────────────────────
const audioEl = document.getElementById("audio-player");
const hzLabel = document.getElementById("hz-label");
const labelWorld = new THREE.Vector3();
function showHzLabel(index, nowMs) {
if (!audioData || index < 0 || index >= audioData.f.length) return;
hzLabelIndex = index;
hzLabelStartMs = nowMs;
hzLabel.textContent = `${Math.round(audioData.f[index]).toLocaleString()} Hz`;
hzLabel.style.display = "block";
}
function updateHzLabel(nowMs) {
if (hzLabelIndex < 0 || !audioPositions) {
hzLabel.style.display = "none";
return;
}
const age = nowMs - hzLabelStartMs;
if (age >= HZ_LABEL_FADE_MS) {
hzLabel.style.display = "none";
hzLabelIndex = -1;
return;
}
const p3 = hzLabelIndex * 3;
labelWorld.set(
audioPositions[p3],
audioPositions[p3 + 1] + 1.6,
audioPositions[p3 + 2],
);
labelWorld.project(camera);
const x = (labelWorld.x * 0.5 + 0.5) * innerWidth;
const y = (-labelWorld.y * 0.5 + 0.5) * innerHeight;
hzLabel.style.left = `${x}px`;
hzLabel.style.top = `${y}px`;
hzLabel.style.opacity = (1 - age / HZ_LABEL_FADE_MS).toFixed(3);
}
function getVisibleCountAtCurrentTime() {
if (!audioMesh || !audioData || audioEl.duration <= 0) return 0;
const currentT = audioEl.currentTime;
if (audioEl.paused && currentT === 0) return 0;
const times = audioData.t;
let lo = 0;
let hi = times.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (times[mid] <= currentT) lo = mid + 1;
else hi = mid;
}
return lo;
}
function updateRevealAndTrail(nowMs) {
const visibleCount = getVisibleCountAtCurrentTime();
if (!audioMesh || !audioLine) return;
audioMesh.count = visibleCount;
audioLine.geometry.setDrawRange(0, Math.max(0, visibleCount));
const revealIndex = visibleCount - 1;
if (revealIndex !== lastRevealIndex && revealIndex >= 0) {
lastRevealIndex = revealIndex;
showHzLabel(revealIndex, nowMs);
}
}
function updateTimeCursorFromPlayback() {
if (!timeCursor.visible || audioEl.duration <= 0) return;
const tNorm = audioEl.currentTime / audioEl.duration;
const theta = tNorm * TIME_TURNS * Math.PI * 2;
const cursorRadius = MIN_PLOT_RADIUS + PLOT_RADIUS_SPAN * 0.45;
timeCursor.position.x = Math.cos(theta) * cursorRadius;
timeCursor.position.y = SPHERE_CENTER_Y;
timeCursor.position.z = Math.sin(theta) * cursorRadius;
}
function animate() {
requestAnimationFrame(animate);
const nowMs = performance.now();
const dt = orbitClock.getDelta();
// Auto-orbit: rotate the camera around the scene target
if (autoOrbit) {
const target = orbitControls.target;
const dx = camera.position.x - target.x;
const dz = camera.position.z - target.z;
const cos = Math.cos(ORBIT_SPEED);
const sin = Math.sin(ORBIT_SPEED);
const step = orbitSpeed * dt;
const cos = Math.cos(step);
const sin = Math.sin(step);
camera.position.x = target.x + dx * cos - dz * sin;
camera.position.z = target.z + dx * sin + dz * cos;
camera.lookAt(target);
}
orbitControls.update();
updateRevealAndTrail(nowMs);
updateTimeCursorFromPlayback();
// Real-time point reveal: binary search on time-sorted t[] array
if (audioMesh && audioData && audioEl.duration > 0) {
const currentT = audioEl.currentTime;
if (audioEl.paused && currentT === 0) {
audioMesh.count = 0;
} else {
const times = audioData.t;
let lo = 0,
hi = times.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (times[mid] <= currentT) lo = mid + 1;
else hi = mid;
}
audioMesh.count = lo;
}
}
// Sync time cursor
if (timeCursor.visible && audioEl.duration > 0) {
const t = audioEl.currentTime / audioEl.duration;
timeCursor.position.x = t * 100 - 50;
}
updateHzLabel(nowMs);
renderer.render(scene, camera);
}
@@ -576,11 +785,17 @@
// ── UI ───────────────────────────────────────────────────────────────────
const fileInput = document.getElementById("file-input");
const analyzeBtn = document.getElementById("analyze-btn");
const pickBtn = document.getElementById("pick-btn");
const statusText = document.getElementById("status-text");
const loadingDot = document.getElementById("loading-dot");
const prompt = document.getElementById("prompt");
const orbitSpeedSlider = document.getElementById("orbit-speed-slider");
const orbitSpeedValue = document.getElementById("orbit-speed-value");
orbitSpeedSlider.addEventListener("input", (e) => {
orbitSpeed = Number(e.target.value);
orbitSpeedValue.textContent = orbitSpeed.toFixed(2);
});
// Progress bar helpers
const progressWrap = document.getElementById("progress-bar-wrap");
@@ -622,27 +837,14 @@
}
let selectedFile = null;
let isAnalyzing = false;
pickBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => {
const file = e.target.files[0];
if (!file) return;
selectedFile = file;
statusText.textContent = file.name;
analyzeBtn.disabled = false;
prompt.classList.add("hidden");
async function analyzeSelectedFile() {
if (!selectedFile || isAnalyzing) return;
// Revoke previous object URL if any
if (audioEl.src) URL.revokeObjectURL(audioEl.src);
audioEl.src = URL.createObjectURL(file);
audioEl.style.display = "block";
});
analyzeBtn.addEventListener("click", async () => {
if (!selectedFile) return;
analyzeBtn.disabled = true;
isAnalyzing = true;
pickBtn.disabled = true;
loadingDot.classList.add("active");
statusText.textContent = "Analyzing…";
@@ -670,10 +872,25 @@
statusText.textContent = "Error.";
} finally {
finishProgress();
analyzeBtn.disabled = false;
pickBtn.disabled = false;
loadingDot.classList.remove("active");
isAnalyzing = false;
}
}
fileInput.addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
selectedFile = file;
statusText.textContent = file.name;
prompt.classList.add("hidden");
// Revoke previous object URL if any
if (audioEl.src) URL.revokeObjectURL(audioEl.src);
audioEl.src = URL.createObjectURL(file);
audioEl.style.display = "block";
await analyzeSelectedFile();
});
</script>
</body>