latent_music_visual / explorer.html
tothepoweroftom's picture
Update explorer.html
e771c76 verified
Raw
History Blame Contribute Delete
32.6 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Global Sample Map</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script>
<style>
body { margin: 0; background: #050505; color: #fff; font-family: sans-serif; overflow: hidden; }
#ui {
position: absolute; top: 0; left: 0; bottom: 0; width: 260px;
background: rgba(20,20,20,0.95); border-right: 1px solid #333;
display: flex; flex-direction: column; padding: 15px; box-sizing: border-box;
z-index: 10;
}
h3 { margin-top: 0; color: #ddd; }
.btn-group { display: flex; gap: 5px; margin-bottom: 15px; }
button {
background: #333; color: #eee; border: 1px solid #555;
height: 30px; cursor: pointer; border-radius: 4px; flex: 1;
font-size: 12px;
}
button:hover:not(:disabled) { background: #444; }
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
button.primary { background: #2980b9; border-color: #3498db; color: #fff; }
button.primary:disabled { background: #1a4a6e; border-color: #1a4a6e; }
button.danger { background: #922; border-color: #a33; }
#status { font-size: 12px; color: #aaa; margin-bottom: 10px; height: 20px; }
#file-list {
flex: 1; overflow-y: auto; background: #111; border: 1px solid #333;
margin-bottom: 15px; border-radius: 4px; font-size: 12px; padding: 5px;
}
.file-row { padding: 4px; color: #888; border-bottom: 1px solid #222; }
#info-panel {
position: absolute; bottom: 20px; right: 20px;
background: rgba(0,0,0,0.8); padding: 15px; border-radius: 8px;
text-align: right; display: none; pointer-events: none;
}
#info-filename { font-size: 18px; font-weight: bold; color: #fff; }
#info-id { font-size: 12px; color: #888; }
</style>
</head>
<body>
<div id="ui">
<h3>Sample Library</h3>
<p style="font-size: 11px; color: #777; margin: 0 0 12px 0; line-height: 1.5;">
Upload audio files, then click Visualize to map them in latent space.
<strong style="color: #aaa;">Click</strong> a point to play its sound.
</p>
<div id="status">Ready</div>
<div class="btn-group">
<!-- <button onclick="document.getElementById('fileInput').click()">+ Files/Zip</button> -->
<!-- <button onclick="document.getElementById('folderInput').click()">+ Folder</button> -->
<button onclick="document.getElementById('mapInput').click()">+ Map Zip</button>
</div>
<div id="file-count" style="font-size: 11px; color: #666; margin-bottom: 5px;">No files loaded</div>
<div id="map-count" style="font-size: 11px; color: #666; margin-bottom: 10px;">No precomputed map loaded</div>
<div id="file-list"></div>
<div style="margin: 10px 0;">
<div style="color:#ddd; font-size:13px; margin-bottom:4px;">Axis fields</div>
<div style="display:flex; gap:6px; margin-bottom:6px;">
<label style="flex:1; color:#aaa; font-size:12px;">X
<select id="axis-x" style="width:100%; background:#111; color:#eee; border:1px solid #333; height:28px;"></select>
</label>
<label style="flex:1; color:#aaa; font-size:12px;">Y
<select id="axis-y" style="width:100%; background:#111; color:#eee; border:1px solid #333; height:28px;"></select>
</label>
<label style="flex:1; color:#aaa; font-size:12px;">Z
<select id="axis-z" style="width:100%; background:#111; color:#eee; border:1px solid #333; height:28px;"></select>
</label>
<label style="flex:1; color:#aaa; font-size:12px;">Size
<select id="axis-size" style="width:100%; background:#111; color:#eee; border:1px solid #333; height:28px;"></select>
</label>
</div>
</div>
<label style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px; color: #ddd; font-size: 13px; cursor: pointer;">
<input type="checkbox" id="view2D" style="cursor: pointer;" onchange="toggleViewMode()">
<span>2D View (Z as Color)</span>
</label>
<button style="max-height: 30px; margin-top: 15px;" class="primary" id="btn-map" onclick="computeMap()" disabled>Visualize Library</button>
<button style="max-height: 30px; margin-top: 15px;" class="danger" id="btn-clear" onclick="resetLibrary()" disabled>Clear All</button>
</div>
<div id="info-panel">
<div id="info-filename">Kick_01.wav</div>
<div id="info-id">ID: ...</div>
</div>
<!-- Hidden Inputs -->
<input type="file" id="fileInput" accept="audio/*, .zip" multiple style="display: none">
<input type="file" id="folderInput" webkitdirectory directory style="display: none">
<input type="file" id="mapInput" accept=".zip" style="display: none">
<script>
// GLOBAL STATE
let pointsData = [];
let sortedIndices = []; // For render order (blue behind, yellow in front)
let zMin = 0, zMax = 1; // For normalized color mapping
let scene, camera, renderer, pointsMesh, hoverMesh;
let audioCtx = new (window.AudioContext || window.webkitAudioContext)();
let libraryCount = 0;
let mapCount = 0;
let hoveredIndex = -1;
let is2DView = false; // 3D default
let rawSamples = [];
let numericFields = [];
let xField = null;
let yField = null;
let zField = null;
let sizeField = null;
let currentMapId = null;
const playbackCache = new Map(); // key -> AudioBuffer
// Visual config
const MIN_SIZE = 0.5;
const MAX_SIZE = 1.0;
const NONE_SIZE_NORM = 0.6; // size fraction when Size axis is None
const CATEGORY_COLORS = {
"Percussive": {r: 0.95, g: 0.78, b: 0.20},
"Melodic": {r: 0.20, g: 0.25, b: 0.95},
"Bass": {r: 0.60, g: 0.85, b: 0.35},
"FX": {r: 0.90, g: 0.40, b: 0.90},
"Silent": {r: 0.55, g: 0.55, b: 0.55},
"Silent/Noise": {r: 0.55, g: 0.55, b: 0.55}
};
const SIZE_SKEW = 1.0; // How much size falls off toward edges
const NONE_OPTION = '__none__';
// Color config: Yellow (H=60) to Blue (H=240) in HSB
const HUE_YELLOW = 60;
const HUE_BLUE = 240;
const SAT = 80; // Saturation 0-100
const BRI = 80; // Brightness 0-100
// HSB to RGB conversion (h: 0-360, s: 0-100, b: 0-100)
function hsbToRgb(h, s, b) {
s /= 100;
b /= 100;
const k = (n) => (n + h / 60) % 6;
const f = (n) => b * (1 - s * Math.max(0, Math.min(k(n), 4 - k(n), 1)));
return { r: f(5), g: f(3), b: f(1) };
}
// Update button states based on file count
function updateButtonStates() {
const hasLibrary = libraryCount > 0;
document.getElementById('btn-map').disabled = !hasLibrary;
document.getElementById('btn-clear').disabled = !hasLibrary;
document.getElementById('file-count').innerText = hasLibrary
? `Library: ${libraryCount} file${libraryCount > 1 ? 's' : ''}`
: 'Library empty';
document.getElementById('map-count').innerText = mapCount > 0
? `Map loaded: ${mapCount} sample${mapCount > 1 ? 's' : ''}`
: 'No precomputed map loaded';
}
// --- UPLOAD HANDLING ---
const handleUpload = async (files) => {
if(!files.length) return;
const formData = new FormData();
const status = document.getElementById('status');
// Batch upload logic handled by backend zip or single loop?
// Let's loop individually for folders to show progress
if(files.length > 1 && !files[0].name.endsWith('.zip')) {
// Multi-file upload logic
// Ideally we zip client side or send one by one.
// Sending one by one is easiest for progress bars.
for(let i=0; i<files.length; i++) {
if(!files[i].type.startsWith('audio/')) continue;
status.innerText = `Uploading ${i+1}/${files.length}: ${files[i].name}`;
await uploadSingle(files[i]);
}
} else {
// Zip or Single File
status.innerText = "Uploading...";
await uploadSingle(files[0]);
}
status.innerText = "Upload Complete. Click Visualize.";
updateButtonStates();
};
const uploadSingle = async (file) => {
const fd = new FormData();
fd.append('file', file);
const relPath = file.webkitRelativePath || file.name;
fd.append('relative_path', relPath);
// Cache audio locally for playback
await cacheFileAudio(file, buildPlaybackKey({mapId: null, relative_path: relPath, filename: file.name}));
try {
const resp = await fetch('/upload', { method: 'POST', body: fd });
const reader = resp.body.getReader();
const dec = new TextDecoder();
while(true) {
const {done, value} = await reader.read();
if(done) break;
const txt = dec.decode(value);
// Parse potentially multiple JSONs
const lines = txt.split('\n\n');
for(let line of lines) {
if(line.startsWith('data: ')) {
const data = JSON.parse(line.substring(6));
if(data.stage === 'done') {
document.getElementById('file-list').innerHTML += `<div class="file-row">${file.name}</div>`;
libraryCount = data.total_library;
}
}
}
}
} catch(e) { console.error(e); }
};
document.getElementById('fileInput').addEventListener('change', e => handleUpload(e.target.files));
document.getElementById('folderInput').addEventListener('change', e => handleUpload(e.target.files));
document.getElementById('mapInput').addEventListener('change', e => handleMapUpload(e.target.files));
async function resetLibrary() {
await fetch('/reset');
document.getElementById('file-list').innerHTML = '';
document.getElementById('status').innerText = 'Ready';
if(pointsMesh) scene.remove(pointsMesh);
if(hoverMesh) scene.remove(hoverMesh);
pointsData = [];
libraryCount = 0;
hoveredIndex = -1;
updateButtonStates();
}
// --- VISUALIZATION ---
async function computeMap() {
const btn = document.getElementById('btn-map');
btn.innerText = "Computing UMAP...";
try {
const res = await fetch('/compute_map');
if(!res.ok) throw new Error(await res.text());
const data = await res.json();
currentMapId = null;
const samples = data.points.map(p => ({
...p,
mapId: null,
audioId: p.id,
displayName: p.filename || p.id,
category: p.category || categoryFromPath(p.relative_path)
}));
ingestDataset(samples, ['x', 'y', 'z']);
btn.innerText = "Visualize Library";
} catch(e) {
alert(e.message);
btn.innerText = "Visualize Library";
}
}
// --- PRECOMPUTED MAP UPLOAD ---
async function handleMapUpload(files) {
if(!files.length) return;
const file = files[0];
const status = document.getElementById('status');
status.innerText = "Uploading map...";
const fd = new FormData();
fd.append('file', file);
try {
// Kick off backend upload (for consistency) but do not depend on it for playback
const res = await fetch('/upload_precomputed_map', { method: 'POST', body: fd });
if(!res.ok) throw new Error(await res.text());
const payload = await res.json();
currentMapId = payload.map_id || null;
// Parse locally for playback and metadata
const arrayBuf = await file.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuf);
const jsonEntry = Object.values(zip.files).find(f => f.name.toLowerCase().endsWith('.json'));
if(!jsonEntry) throw new Error('No JSON file found in map zip');
const jsonText = await jsonEntry.async('string');
const jsonPayload = JSON.parse(jsonText);
const samples = (jsonPayload.samples || []).map((s, idx) => ({
...s,
mapId: currentMapId,
audioId: null,
displayName: s.name || s.filename || s.relative_path || `Sample ${idx+1}`
}));
// Decode and cache audio files present in the zip
await cacheZipAudio(zip, samples, currentMapId);
// Numeric fields: prefer payload from backend; fallback to infer
const numericFields = payload.numeric_fields || inferNumericFields(samples);
ingestDataset(samples, numericFields);
mapCount = samples.length;
status.innerText = `Loaded precomputed map (${samples.length})`;
updateButtonStates();
} catch(err) {
status.innerText = "Map upload failed";
alert(err.message || err);
}
}
// --- DATA INGEST / AXIS ---
function ingestDataset(samples, numeric) {
rawSamples = samples || [];
numericFields = numeric || [];
mapCount = rawSamples.length;
if(numericFields.length === 0 && rawSamples.length > 0) {
// Fallback: infer numeric keys
const first = rawSamples[0];
numericFields = Object.keys(first).filter(k => typeof first[k] === 'number');
}
const pickField = (name, fallbackList) => {
if(numericFields.includes(name)) return name;
for(const f of fallbackList) if(numericFields.includes(f)) return f;
return NONE_OPTION;
};
const xDefault = pickField('rms', numericFields);
const yDefault = pickField('duration_sec', numericFields);
const zDefault = pickField('quality_score', numericFields);
const sizeDefault = NONE_OPTION;
xField = (xField && (numericFields.includes(xField) || xField === NONE_OPTION)) ? xField : xDefault;
yField = (yField && (numericFields.includes(yField) || yField === NONE_OPTION)) ? yField : yDefault;
zField = (zField && (numericFields.includes(zField) || zField === NONE_OPTION)) ? zField : zDefault;
sizeField = (sizeField && (numericFields.includes(sizeField) || sizeField === NONE_OPTION)) ? sizeField : sizeDefault;
populateAxisSelect('axis-x', xField);
populateAxisSelect('axis-y', yField);
populateAxisSelect('axis-z', zField);
populateAxisSelect('axis-size', sizeField);
rebuildPointsFromSamples();
updateButtonStates();
}
function populateAxisSelect(id, current) {
const sel = document.getElementById(id);
sel.innerHTML = '';
// None option
const noneOpt = document.createElement('option');
noneOpt.value = NONE_OPTION;
noneOpt.textContent = 'None';
if(current === NONE_OPTION) noneOpt.selected = true;
sel.appendChild(noneOpt);
numericFields.forEach(f => {
const opt = document.createElement('option');
opt.value = f;
opt.textContent = f;
if(f === current) opt.selected = true;
sel.appendChild(opt);
});
sel.onchange = () => {
if(id === 'axis-x') xField = sel.value;
if(id === 'axis-y') yField = sel.value;
if(id === 'axis-z') zField = sel.value;
if(id === 'axis-size') sizeField = sel.value;
rebuildPointsFromSamples();
};
}
function categoryFromPath(path) {
if(!path) return null;
const parts = path.split(/[/\\\\]/);
return parts.length ? parts[0] : null;
}
function colorForCategory(cat) {
if(cat && CATEGORY_COLORS[cat]) return CATEGORY_COLORS[cat];
// fallback: hash hue
const h = Math.abs(hashString(cat || 'uncat')) % 360;
const rgb = hsbToRgb(h, 60, 85);
return {r: rgb.r, g: rgb.g, b: rgb.b};
}
function hashString(str) {
let h = 0;
for(let i=0; i<str.length; i++) {
h = ((h << 5) - h) + str.charCodeAt(i);
h |= 0;
}
return h;
}
function rebuildPointsFromSamples() {
if(!rawSamples || rawSamples.length === 0) return;
const xKey = (xField && (numericFields.includes(xField) || xField === NONE_OPTION)) ? xField : numericFields[0];
const yKey = (yField && (numericFields.includes(yField) || yField === NONE_OPTION)) ? yField : numericFields[Math.min(1, Math.max(0, numericFields.length-1))];
const zKey = (zField && (numericFields.includes(zField) || zField === NONE_OPTION)) ? zField : (numericFields.find(f => f !== xKey && f !== yKey) || numericFields[0]);
const sizeKey = (sizeField && (numericFields.includes(sizeField) || sizeField === NONE_OPTION)) ? sizeField : (numericFields.find(f => ![xKey, yKey, zKey].includes(f)) || numericFields[0]);
const xs = xKey === NONE_OPTION ? [] : rawSamples.map(s => Number(s?.[xKey])).filter(v => Number.isFinite(v));
const ys = yKey === NONE_OPTION ? [] : rawSamples.map(s => Number(s?.[yKey])).filter(v => Number.isFinite(v));
const zs = zKey === NONE_OPTION ? [] : rawSamples.map(s => Number(s?.[zKey])).filter(v => Number.isFinite(v));
const ss = sizeKey === NONE_OPTION ? [] : rawSamples.map(s => Number(s?.[sizeKey])).filter(v => Number.isFinite(v));
const norm = (val, minv, maxv) => {
if(!Number.isFinite(val)) return 0;
if(!Number.isFinite(minv) || !Number.isFinite(maxv) || maxv === minv) return 0;
const mid = (maxv + minv) / 2;
const span = (maxv - minv) / 2 || 1;
return (val - mid) / span;
};
const norm01 = (val, minv, maxv) => {
if(!Number.isFinite(val)) return 0.5;
if(!Number.isFinite(minv) || !Number.isFinite(maxv) || maxv === minv) return 0.5;
return (val - minv) / (maxv - minv);
};
const minX = xs.length ? Math.min(...xs) : 0, maxX = xs.length ? Math.max(...xs) : 0;
const minY = ys.length ? Math.min(...ys) : 0, maxY = ys.length ? Math.max(...ys) : 0;
const minZ = zs.length ? Math.min(...zs) : 0, maxZ = zs.length ? Math.max(...zs) : 0;
const minS = ss.length ? Math.min(...ss) : 0, maxS = ss.length ? Math.max(...ss) : 0;
pointsData = rawSamples.map((s, idx) => {
const rawX = xKey === NONE_OPTION ? 0 : Number(s?.[xKey]);
const rawY = yKey === NONE_OPTION ? 0 : Number(s?.[yKey]);
const rawZ = zKey === NONE_OPTION ? 0 : Number(s?.[zKey]);
const rawS = sizeKey === NONE_OPTION ? null : Number(s?.[sizeKey]);
const x = xKey === NONE_OPTION ? 0 : norm(rawX, minX, maxX);
const y = yKey === NONE_OPTION ? 0 : norm(rawY, minY, maxY);
const z = zKey === NONE_OPTION ? 0 : norm(rawZ, minZ, maxZ);
const sizeNorm = (sizeKey === NONE_OPTION || rawS === null) ? NONE_SIZE_NORM : norm01(rawS, minS, maxS);
const size = MIN_SIZE + (MAX_SIZE - MIN_SIZE) * sizeNorm;
const category = s.category || categoryFromPath(s.relative_path) || null;
const playbackKey = buildPlaybackKey(s);
return {
id: `map-${idx}`,
audioId: s.audioId || null,
mapId: s.mapId || null,
relative_path: s.relative_path,
filename: s.displayName || s.filename || s.name || s.relative_path || `Sample ${idx+1}`,
category,
playbackKey,
x, y, z, size
};
});
renderScene(pointsData);
}
function renderScene(data) {
if(pointsMesh) scene.remove(pointsMesh);
if(hoverMesh) {
scene.remove(hoverMesh);
hoverMesh = null;
}
// Calculate min/max z for normalized color mapping (still used for depth sorting)
zMin = Math.min(...data.map(p => p.z));
zMax = Math.max(...data.map(p => p.z));
const zRange = zMax - zMin || 1; // Avoid divide by zero
// Sort by z (low z/blue first so high z/yellow renders on top in 2D)
sortedIndices = data.map((p, i) => i).sort((a, b) => data[a].z - data[b].z);
const geo = new THREE.BufferGeometry();
const pos = [];
const col = [];
const sizes = [];
sortedIndices.forEach(i => {
const p = data[i];
if(is2DView) {
pos.push(p.x * 4, p.y * 4, 0);
} else {
pos.push(p.x * 4, p.y * 4, p.z * 4);
}
// Color by category (fixed)
const rgb = colorForCategory(p.category);
col.push(rgb.r, rgb.g, rgb.b);
const sizeVal = Number.isFinite(p.size) ? p.size : MIN_SIZE;
sizes.push(sizeVal);
});
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
geo.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1));
// Custom shader for flat circles with variable sizes
const mat = new THREE.ShaderMaterial({
vertexShader: `
attribute float size;
varying vec3 vColor;
void main() {
vColor = color;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = size * (300.0 / -mvPosition.z);
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
varying vec3 vColor;
void main() {
vec2 uv = gl_PointCoord * 2.0 - 1.0;
float dist = dot(uv, uv);
if(dist > 1.0) discard;
gl_FragColor = vec4(vColor, 1.0);
}
`,
transparent: true,
vertexColors: true,
depthWrite: false
});
pointsMesh = new THREE.Points(geo, mat);
scene.add(pointsMesh);
// Adjust camera for 2D view
if(is2DView) {
camera.position.set(0, 0, 6);
camera.lookAt(0, 0, 0);
scene.rotation.set(0, 0, 0);
}
}
function updateHoverVisual(idx) {
if(hoverMesh) scene.remove(hoverMesh);
hoverMesh = null;
if(idx === -1 || !pointsData[idx]) return;
const pt = pointsData[idx];
const hoverGeo = new THREE.BufferGeometry();
const hoverPos = is2DView
? [pt.x * 4, pt.y * 4, 0.01] // Slight z offset to render on top
: [pt.x * 4, pt.y * 4, pt.z * 4];
hoverGeo.setAttribute('position', new THREE.Float32BufferAttribute(hoverPos, 3));
// Use same category color as renderScene but brighter
const baseColor = colorForCategory(pt.category);
const rgb = {
r: Math.min(1, baseColor.r * 1.15),
g: Math.min(1, baseColor.g * 1.15),
b: Math.min(1, baseColor.b * 1.15)
};
const hoverCol = [rgb.r, rgb.g, rgb.b];
hoverGeo.setAttribute('color', new THREE.Float32BufferAttribute(hoverCol, 3));
// Hover size based on data-driven size
const baseSize = Number.isFinite(pt.size) ? pt.size : MIN_SIZE;
const hoverSize = baseSize * 1.5; // 50% larger on hover
hoverGeo.setAttribute('size', new THREE.Float32BufferAttribute([hoverSize], 1));
// Flat circle shader for hover
const hoverMat = new THREE.ShaderMaterial({
vertexShader: `
attribute float size;
varying vec3 vColor;
void main() {
vColor = color;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = size * (300.0 / -mvPosition.z);
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
varying vec3 vColor;
void main() {
vec2 uv = gl_PointCoord * 2.0 - 1.0;
float dist = dot(uv, uv);
if(dist > 1.0) discard;
gl_FragColor = vec4(vColor, 1.0);
}
`,
transparent: true,
vertexColors: true,
depthWrite: false
});
hoverMesh = new THREE.Points(hoverGeo, hoverMat);
scene.add(hoverMesh);
}
// --- THREE.JS ---
function init3D() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 100);
camera.position.z = 5;
renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Navigation
let isDown = false, lastX=0, lastY=0;
document.body.addEventListener('mousedown', e => { isDown=true; lastX=e.clientX; lastY=e.clientY; });
document.body.addEventListener('mouseup', () => isDown=false);
document.body.addEventListener('mousemove', e => {
if(isDown && !is2DView) {
const dy = e.clientY - lastY;
const dx = e.clientX - lastX;
scene.rotation.y += dx * 0.005;
scene.rotation.x += dy * 0.005;
lastX = e.clientX; lastY = e.clientY;
}
checkHover(e);
});
document.body.addEventListener('wheel', e => {
if(is2DView) {
camera.position.z += e.deltaY * 0.01;
} else {
camera.position.z += e.deltaY * 0.01;
}
});
// Interaction
const raycaster = new THREE.Raycaster();
raycaster.params.Points.threshold = 0.35; // match variable sprite sizes
const mouse = new THREE.Vector2();
function checkHover(e) {
if(!pointsMesh) return;
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObject(pointsMesh);
const panel = document.getElementById('info-panel');
if(hits.length > 0) {
// Map sorted index back to original pointsData index
const sortedIdx = hits[0].index;
const originalIdx = sortedIndices[sortedIdx];
const pt = pointsData[originalIdx];
if(hoveredIndex !== originalIdx) {
hoveredIndex = originalIdx;
updateHoverVisual(originalIdx);
}
panel.style.display = 'block';
document.getElementById('info-filename').innerText = pt.filename;
document.getElementById('info-id').innerText = pt.audioId ? pt.audioId.substring(0,8) : pt.id;
} else {
if(hoveredIndex !== -1) {
hoveredIndex = -1;
updateHoverVisual(-1);
}
panel.style.display = 'none';
}
}
document.body.addEventListener('click', e => {
if(!pointsMesh) return;
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const hits = raycaster.intersectObject(pointsMesh);
if(hits.length > 0) {
const sortedIdx = hits[0].index;
const originalIdx = sortedIndices[sortedIdx];
const target = pointsData[originalIdx];
if(!playFromCache(target.playbackKey)) {
document.getElementById('status').innerText = 'Audio not cached for this point';
console.warn('No audio attached to this point.');
}
}
});
animate();
}
function buildPlaybackKey(sample) {
const scope = sample.mapId ? `map:${sample.mapId}` : 'lib';
const path = sample.relative_path || sample.filename || sample.name || sample.id || 'unknown';
return `${scope}:${path}`;
}
async function cacheFileAudio(file, key) {
try {
const buf = await file.arrayBuffer();
const audioBuf = await audioCtx.decodeAudioData(buf);
playbackCache.set(key, audioBuf);
} catch(e) {
console.warn('Failed to cache audio', e);
}
}
async function cacheZipAudio(zip, samples, mapId) {
const audioEntries = Object.values(zip.files).filter(f => !f.dir && isAudioFile(f.name));
const findEntryForRelPath = (rel) => {
if(!rel) return null;
const norm = normalizePath(rel);
// exact match
let e = audioEntries.find(f => normalizePath(f.name) === norm);
if(e) return e;
// trailing match (handles leading folder in zip)
e = audioEntries.find(f => normalizePath(f.name).endsWith(norm));
if(e) return e;
// basename match as last resort
const base = norm.split('/').pop();
if(!base) return null;
e = audioEntries.find(f => normalizePath(f.name).split('/').pop() === base);
return e || null;
};
for(const s of samples) {
const rel = s.relative_path || s.filename || s.name;
const entry = findEntryForRelPath(rel);
if(!entry) continue;
try {
const arr = await entry.async('arraybuffer');
const audioBuf = await audioCtx.decodeAudioData(arr);
playbackCache.set(buildPlaybackKey({mapId, relative_path: rel}), audioBuf);
} catch(e) {
console.warn('Failed to decode map audio', rel, e);
}
}
}
function normalizePath(p) {
return p.replace(/^\.?\//, '');
}
function isAudioFile(name) {
return /\.(wav|mp3|flac|aiff|aif|ogg)$/i.test(name);
}
function inferNumericFields(samples) {
if(!samples || !samples.length) return [];
const first = samples[0];
return Object.keys(first).filter(k => typeof first[k] === 'number');
}
function playFromCache(key) {
if(!key) return false;
if(audioCtx.state === 'suspended') audioCtx.resume();
const buf = playbackCache.get(key);
if(!buf) return false;
const src = audioCtx.createBufferSource();
src.buffer = buf;
src.connect(audioCtx.destination);
src.start(0);
return true;
}
function toggleViewMode() {
is2DView = document.getElementById('view2D').checked;
if(pointsData.length > 0) {
renderScene(pointsData);
}
}
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
init3D();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>