+303
-86
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user