Spaces:
Sleeping
Sleeping
| <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> |