diff --git a/public/audio-lab.js b/public/audio-lab.js new file mode 100644 index 00000000..600e1942 --- /dev/null +++ b/public/audio-lab.js @@ -0,0 +1,395 @@ +/* === MeshCore Analyzer — audio-lab.js === */ +/* Audio Lab: Packet Jukebox for sound debugging & understanding */ +'use strict'; + +(function () { + let styleEl = null; + let loopTimer = null; + let selectedPacket = null; + let baseBPM = 120; + let speedMult = 1; + + const TYPE_COLORS = { + ADVERT: '#f59e0b', GRP_TXT: '#10b981', TXT_MSG: '#6366f1', + TRACE: '#8b5cf6', REQ: '#ef4444', RESPONSE: '#3b82f6', + ACK: '#6b7280', PATH: '#ec4899', ANON_REQ: '#f97316', UNKNOWN: '#6b7280' + }; + + const SCALE_NAMES = { + ADVERT: 'C major pentatonic', GRP_TXT: 'A minor pentatonic', + TXT_MSG: 'E natural minor', TRACE: 'D whole tone' + }; + + const SYNTH_TYPES = { + ADVERT: 'triangle', GRP_TXT: 'sine', TXT_MSG: 'triangle', TRACE: 'sine' + }; + + const SCALE_INTERVALS = { + ADVERT: { intervals: [0,2,4,7,9], root: 48 }, + GRP_TXT: { intervals: [0,3,5,7,10], root: 45 }, + TXT_MSG: { intervals: [0,2,3,5,7,8,10], root: 40 }, + TRACE: { intervals: [0,2,4,6,8,10], root: 50 }, + }; + + function injectStyles() { + if (styleEl) return; + styleEl = document.createElement('style'); + styleEl.textContent = ` + .alab { display: flex; height: 100%; overflow: hidden; } + .alab-sidebar { width: 280px; min-width: 200px; border-right: 1px solid var(--border); + overflow-y: auto; padding: 12px; background: var(--surface-1); } + .alab-main { flex: 1; overflow-y: auto; padding: 16px 24px; } + .alab-type-hdr { font-weight: 700; font-size: 13px; padding: 6px 8px; margin-top: 8px; + border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; } + .alab-type-hdr:hover { opacity: 0.8; } + .alab-type-list { padding: 0; } + .alab-pkt { padding: 5px 8px 5px 16px; font-size: 12px; font-family: var(--mono); + cursor: pointer; border-radius: 4px; color: var(--text-muted); } + .alab-pkt:hover { background: var(--hover-bg); } + .alab-pkt.selected { background: var(--selected-bg); color: var(--text); font-weight: 600; } + .alab-controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; + padding: 12px 16px; background: var(--surface-1); border-radius: 8px; margin-bottom: 16px; border: 1px solid var(--border); } + .alab-btn { padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; + background: var(--surface-1); color: var(--text); cursor: pointer; font-size: 13px; } + .alab-btn:hover { background: var(--hover-bg); } + .alab-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); } + .alab-speed { padding: 4px 8px; font-size: 12px; border-radius: 4px; border: 1px solid var(--border); + background: var(--surface-1); color: var(--text-muted); cursor: pointer; } + .alab-speed.active { background: var(--accent); color: #fff; border-color: var(--accent); } + .alab-section { background: var(--surface-1); border: 1px solid var(--border); + border-radius: 8px; padding: 16px; margin-bottom: 16px; } + .alab-section h3 { margin: 0 0 12px 0; font-size: 14px; color: var(--text-muted); font-weight: 600; } + .alab-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; } + .alab-stat { font-size: 12px; } + .alab-stat .label { color: var(--text-muted); } + .alab-stat .value { font-weight: 600; font-family: var(--mono); } + .alab-hex { font-family: var(--mono); font-size: 11px; word-break: break-all; line-height: 1.6; + max-height: 80px; overflow: hidden; transition: max-height 0.3s; } + .alab-hex.expanded { max-height: none; } + .alab-hex .sampled { background: var(--accent); color: #fff; border-radius: 2px; padding: 0 1px; } + .alab-note-table { width: 100%; font-size: 12px; border-collapse: collapse; } + .alab-note-table th { text-align: left; font-weight: 600; color: var(--text-muted); + padding: 4px 8px; border-bottom: 1px solid var(--border); font-size: 11px; } + .alab-note-table td { padding: 4px 8px; border-bottom: 1px solid var(--border); font-family: var(--mono); } + .alab-byte-viz { display: flex; align-items: flex-end; height: 60px; gap: 1px; margin-top: 8px; } + .alab-byte-bar { flex: 1; min-width: 2px; border-radius: 1px 1px 0 0; } + .alab-empty { text-align: center; padding: 60px 20px; color: var(--text-muted); font-size: 15px; } + .alab-slider-group { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); } + .alab-slider-group input[type=range] { width: 80px; } + .alab-slider-group select { font-size: 12px; padding: 2px 4px; background: var(--input-bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; } + @media (max-width: 768px) { + .alab { flex-direction: column; } + .alab-sidebar { width: 100%; max-height: 200px; border-right: none; border-bottom: 1px solid var(--border); } + .alab-main { padding: 12px; } + } + `; + document.head.appendChild(styleEl); + } + + function parseHex(hex) { + const bytes = []; + for (let i = 0; i < hex.length; i += 2) { + const b = parseInt(hex.slice(i, i + 2), 16); + if (!isNaN(b)) bytes.push(b); + } + return bytes; + } + + function computeMapping(pkt) { + const { buildScale, midiToFreq, mapRange, quantizeToScale } = MeshAudio.helpers; + const rawHex = pkt.raw_hex || ''; + const allBytes = parseHex(rawHex); + if (allBytes.length < 3) return null; + + const payloadBytes = allBytes.slice(3); + let typeName = 'UNKNOWN'; + try { const d = JSON.parse(pkt.decoded_json || '{}'); typeName = d.type || 'UNKNOWN'; } catch {} + + const hops = []; + try { const p = JSON.parse(pkt.path_json || '[]'); if (Array.isArray(p)) hops.push(...p); } catch {} + const hopCount = Math.max(1, hops.length); + const obsCount = pkt.observation_count || 1; + + const si = SCALE_INTERVALS[typeName] || SCALE_INTERVALS.ADVERT; + const scale = buildScale(si.intervals, si.root); + const scaleName = SCALE_NAMES[typeName] || 'C major pentatonic'; + const oscType = SYNTH_TYPES[typeName] || 'triangle'; + + const noteCount = Math.max(2, Math.min(10, Math.ceil(Math.sqrt(payloadBytes.length)))); + const sampledIndices = []; + const sampledBytes = []; + for (let i = 0; i < noteCount; i++) { + const idx = Math.floor((i / noteCount) * payloadBytes.length); + sampledIndices.push(idx); + sampledBytes.push(payloadBytes[idx]); + } + + const filterHz = Math.round(mapRange(Math.min(hopCount, 10), 1, 10, 8000, 800)); + const volume = Math.min(0.6, 0.15 + (obsCount - 1) * 0.02); + const voiceCount = Math.min(Math.max(1, Math.ceil(Math.log2(obsCount + 1))), 8); + let panValue = 0; + try { + const d = JSON.parse(pkt.decoded_json || '{}'); + if (d.lon != null) panValue = Math.max(-1, Math.min(1, mapRange(d.lon, -125, -65, -1, 1))); + } catch {} + + const bpm = MeshAudio.getBPM ? MeshAudio.getBPM() : 120; + const tm = 60 / bpm * speedMult; + + const notes = sampledBytes.map((byte, i) => { + const midi = quantizeToScale(byte, scale); + const freq = midiToFreq(midi); + const duration = mapRange(byte, 0, 255, 0.05, 0.4) * tm * 1000; + let gap = 0.05 * tm * 1000; + if (i < sampledBytes.length - 1) { + const delta = Math.abs(sampledBytes[i + 1] - byte); + gap = mapRange(delta, 0, 255, 0.03, 0.3) * tm * 1000; + } + return { index: sampledIndices[i], byte, midi, freq: Math.round(freq), duration: Math.round(duration), gap: Math.round(gap) }; + }); + + return { + typeName, allBytes, payloadBytes, sampledIndices, sampledBytes, notes, + noteCount, filterHz, volume: volume.toFixed(3), voiceCount, panValue: panValue.toFixed(2), + oscType, scaleName, hopCount, obsCount, + totalSize: allBytes.length, payloadSize: payloadBytes.length, + color: TYPE_COLORS[typeName] || TYPE_COLORS.UNKNOWN, + }; + } + + function renderDetail(pkt, app) { + const m = computeMapping(pkt); + if (!m) { document.getElementById('alabDetail').innerHTML = '
No raw hex data for this packet
'; return; } + + // Hex dump with sampled bytes highlighted + const sampledSet = new Set(m.sampledIndices); + let hexHtml = ''; + for (let i = 0; i < m.payloadBytes.length; i++) { + const h = m.payloadBytes[i].toString(16).padStart(2, '0').toUpperCase(); + if (sampledSet.has(i)) hexHtml += `${h} `; + else hexHtml += h + ' '; + } + + document.getElementById('alabDetail').innerHTML = ` +
+

📦 Packet Data

+
+
Type
${m.typeName}
+
Total Size
${m.totalSize} bytes
+
Payload Size
${m.payloadSize} bytes
+
Hops
${m.hopCount}
+
Observations
${m.obsCount}
+
Hash
${pkt.hash || '—'}
+
+
+
${hexHtml}
+
+
+ +
+

🎵 Sound Mapping

+
+
Instrument
${m.oscType}
+
Scale
${m.scaleName}
+
Notes
${m.noteCount} (√${m.payloadSize})
+
Filter Cutoff
${m.filterHz} Hz
+
Volume
${m.volume} (${m.obsCount} obs)
+
Voices
${m.voiceCount}
+
Pan
${m.panValue}
+
+
+ +
+

🎹 Note Sequence

+ + + ${m.notes.map((n, i) => ` + + + + + + + `).join('')} +
#ByteMIDIFreqDurationGap
${i + 1}0x${n.byte.toString(16).padStart(2, '0').toUpperCase()} (${n.byte})${n.midi}${n.freq} Hz${n.duration} ms${i < m.notes.length - 1 ? n.gap + ' ms' : '—'}
+
+ +
+

📊 Byte Visualizer

+
+
+ `; + + // Render byte visualizer + const viz = document.getElementById('alabByteViz'); + if (viz) { + for (let i = 0; i < m.payloadBytes.length; i++) { + const bar = document.createElement('div'); + bar.className = 'alab-byte-bar'; + const h = Math.max(2, (m.payloadBytes[i] / 255) * 60); + bar.style.height = h + 'px'; + bar.style.background = sampledSet.has(i) ? m.color : '#555'; + bar.style.opacity = sampledSet.has(i) ? '1' : '0.3'; + bar.title = `[${i}] 0x${m.payloadBytes[i].toString(16).padStart(2, '0')} = ${m.payloadBytes[i]}`; + viz.appendChild(bar); + } + } + } + + function playSelected() { + if (!selectedPacket) return; + if (window.MeshAudio) { + if (!MeshAudio.isEnabled()) MeshAudio.setEnabled(true); + // Build a packet object that sonifyPacket expects + const pkt = { + raw_hex: selectedPacket.raw_hex, + raw: selectedPacket.raw_hex, + observation_count: selectedPacket.observation_count || 1, + decoded: {} + }; + try { + const d = JSON.parse(selectedPacket.decoded_json || '{}'); + const typeName = d.type || 'UNKNOWN'; + pkt.decoded = { + header: { payloadTypeName: typeName }, + payload: d, + path: { hops: JSON.parse(selectedPacket.path_json || '[]') } + }; + } catch {} + MeshAudio.sonifyPacket(pkt); + } + } + + async function init(app) { + injectStyles(); + baseBPM = (MeshAudio && MeshAudio.getBPM) ? MeshAudio.getBPM() : 120; + speedMult = 1; + + app.innerHTML = ` +
+
Loading packets...
+
+
+ + + Speed: + + + + +
+ BPM + + ${baseBPM} +
+
+ Vol + + ${MeshAudio && MeshAudio.getVolume ? Math.round(MeshAudio.getVolume() * 100) : 30}% +
+
+ Voice + +
+
+
← Select a packet from the sidebar to explore its sound
+
+
+ `; + + // Controls + document.getElementById('alabPlay').addEventListener('click', playSelected); + + document.getElementById('alabLoop').addEventListener('click', function () { + if (loopTimer) { clearInterval(loopTimer); loopTimer = null; this.classList.remove('active'); return; } + this.classList.add('active'); + playSelected(); + loopTimer = setInterval(playSelected, 3000); + }); + + document.querySelectorAll('.alab-speed').forEach(btn => { + btn.addEventListener('click', function () { + document.querySelectorAll('.alab-speed').forEach(b => b.classList.remove('active')); + this.classList.add('active'); + speedMult = parseFloat(this.dataset.speed); + if (MeshAudio && MeshAudio.setBPM) MeshAudio.setBPM(baseBPM * speedMult); + if (selectedPacket) renderDetail(selectedPacket, app); + }); + }); + + document.getElementById('alabBPM').addEventListener('input', function () { + baseBPM = parseInt(this.value); + document.getElementById('alabBPMVal').textContent = baseBPM; + if (MeshAudio && MeshAudio.setBPM) MeshAudio.setBPM(baseBPM * speedMult); + if (selectedPacket) renderDetail(selectedPacket, app); + }); + + document.getElementById('alabVol').addEventListener('input', function () { + const v = parseInt(this.value) / 100; + document.getElementById('alabVolVal').textContent = Math.round(v * 100) + '%'; + if (MeshAudio && MeshAudio.setVolume) MeshAudio.setVolume(v); + }); + + document.getElementById('alabVoice').addEventListener('change', function () { + if (MeshAudio && MeshAudio.setVoice) MeshAudio.setVoice(this.value); + }); + + // Load buckets + try { + const data = await api('/audio-lab/buckets'); + const sidebar = document.getElementById('alabSidebar'); + if (!data.buckets || Object.keys(data.buckets).length === 0) { + sidebar.innerHTML = '
No packets in memory yet
'; + return; + } + + let html = ''; + for (const [type, pkts] of Object.entries(data.buckets)) { + const color = TYPE_COLORS[type] || TYPE_COLORS.UNKNOWN; + html += `
+ ${type}${pkts.length}
`; + html += `
`; + pkts.forEach((p, i) => { + const size = p.raw_hex ? p.raw_hex.length / 2 : 0; + html += `
#${i + 1} — ${size}B — ${p.observation_count || 1} obs
`; + }); + html += '
'; + } + sidebar.innerHTML = html; + + // Store buckets for selection + sidebar._buckets = data.buckets; + + // Click handlers + sidebar.addEventListener('click', function (e) { + const typeHdr = e.target.closest('.alab-type-hdr'); + if (typeHdr) { + const list = sidebar.querySelector(`[data-type-list="${typeHdr.dataset.type}"]`); + if (list) list.style.display = list.style.display === 'none' ? '' : 'none'; + return; + } + const pktEl = e.target.closest('.alab-pkt'); + if (pktEl) { + sidebar.querySelectorAll('.alab-pkt').forEach(el => el.classList.remove('selected')); + pktEl.classList.add('selected'); + const type = pktEl.dataset.type; + const idx = parseInt(pktEl.dataset.idx); + selectedPacket = sidebar._buckets[type][idx]; + renderDetail(selectedPacket, app); + } + }); + } catch (err) { + document.getElementById('alabSidebar').innerHTML = `
Error loading packets: ${err.message}
`; + } + } + + function destroy() { + if (loopTimer) { clearInterval(loopTimer); loopTimer = null; } + if (styleEl) { styleEl.remove(); styleEl = null; } + selectedPacket = null; + } + + registerPage('audio-lab', { init, destroy }); +})(); diff --git a/public/index.html b/public/index.html index d1db1a5b..cce26f5c 100644 --- a/public/index.html +++ b/public/index.html @@ -54,6 +54,7 @@ Observers Analytics ⚡ Perf + 🎵 Lab