/* === CoreScope — 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; let highlightTimers = []; const TYPE_COLORS = window.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; transition: box-shadow 0.1s; } .alab-byte-bar.playing { box-shadow: 0 0 8px 2px currentColor; transform: scaleY(1.15); } .alab-hex .playing { background: #ff6b6b !important; color: #fff !important; border-radius: 2px; padding: 0 2px; transition: background 0.1s; } .alab-note-table tr.playing { background: var(--accent) !important; color: #fff; } .alab-note-table tr.playing td { color: #fff; } .alab-map-table { width: 100%; font-size: 13px; border-collapse: collapse; } .alab-map-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; } .alab-map-table .map-param { font-weight: 600; white-space: nowrap; width: 110px; } .alab-map-table .map-value { font-family: var(--mono); font-weight: 700; white-space: nowrap; width: 120px; } .alab-map-table .map-why { font-size: 11px; color: var(--text-muted); font-family: var(--mono); } .map-why-inline { display: block; font-size: 10px; color: var(--text-muted); font-family: var(--mono); margin-top: 2px; } .alab-note-play { background: none; border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 10px; padding: 2px 6px; color: var(--text-muted); } .alab-note-play:hover { background: var(--accent); color: #fff; border-color: var(--accent); } .alab-note-clickable { cursor: pointer; } .alab-note-clickable:hover { background: var(--hover-bg); } .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; let panSource = 'no location data → center'; 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))); panSource = `lon ${d.lon.toFixed(1)}° → map(-125...-65) → ${panValue.toFixed(2)}`; } } catch {} // Detune description const detuneDesc = []; for (let v = 0; v < voiceCount; v++) { const d = v === 0 ? 0 : (v % 2 === 0 ? 1 : -1) * (v * 5 + 3); detuneDesc.push((d >= 0 ? '+' : '') + d + '¢'); } const bpm = MeshAudio.getBPM ? MeshAudio.getBPM() : 120; const tm = 60 / bpm; // BPM already includes speed multiplier 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, panSource, detuneDesc, }; } 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} payload_type = ${m.typeName} → ${m.oscType} oscillator
Scale ${m.scaleName} payload_type = ${m.typeName} → ${m.scaleName} (root MIDI ${SCALE_INTERVALS[m.typeName]?.root || 48})
Notes ${m.noteCount} ⌈√${m.payloadSize}⌉ = ⌈${Math.sqrt(m.payloadSize).toFixed(1)}⌉ = ${m.noteCount} bytes sampled evenly across payload
Filter Cutoff ${m.filterHz} Hz ${m.hopCount} hops → map(1...10 → 8000...800 Hz) = ${m.filterHz} Hz lowpass — more hops = more muffled
Volume ${m.volume} min(0.6, 0.15 + (${m.obsCount} obs − 1) × 0.02) = ${m.volume} — more observers = louder
Voices ${m.voiceCount} min(⌈log₂(${m.obsCount} + 1)⌉, 8) = ${m.voiceCount} — more observers = richer chord
Detune ${m.detuneDesc.join(', ')} ${m.voiceCount} voices detuned for shimmer — wider spread with more voices
Pan ${m.panValue} ${m.panSource}

🎹 Note Sequence

${m.notes.map((n, i) => { const durWhy = `byte ${n.byte} → map(0...255 → 50...400ms) × tempo`; const gapWhy = i < m.notes.length - 1 ? `|${n.byte} − ${m.notes[i+1].byte}| = ${Math.abs(m.notes[i+1].byte - n.byte)} → map(0...255 → 30...300ms) × tempo` : ''; return ``;}).join('')}
#Payload IndexByte→ MIDI→ FreqDuration (why)Gap (why)
${i + 1} [${n.index}] 0x${n.byte.toString(16).padStart(2, '0').toUpperCase()} (${n.byte}) ${n.midi} ${n.freq} Hz ${n.duration} ms ${durWhy} ${i < m.notes.length - 1 ? n.gap + ' ms ' + gapWhy + '' : '—'}

📊 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'; bar.id = 'byteBar' + i; 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); } } // Wire up individual note play buttons document.querySelectorAll('.alab-note-play').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); playOneNote(parseInt(btn.dataset.noteIdx)); }); }); // Also allow clicking anywhere on the row document.querySelectorAll('.alab-note-clickable').forEach(row => { row.addEventListener('click', () => playOneNote(parseInt(row.dataset.noteIdx))); }); } function clearHighlights() { highlightTimers.forEach(t => clearTimeout(t)); highlightTimers = []; document.querySelectorAll('.alab-hex .playing, .alab-note-table .playing, .alab-byte-bar.playing').forEach(el => el.classList.remove('playing')); } function highlightPlayback(mapping) { clearHighlights(); let timeOffset = 0; mapping.notes.forEach((note, i) => { // Highlight ON highlightTimers.push(setTimeout(() => { // Clear previous note highlights document.querySelectorAll('.alab-hex .playing, .alab-note-table .playing, .alab-byte-bar.playing').forEach(el => el.classList.remove('playing')); // Hex byte const hexEl = document.getElementById('hexByte' + note.index); if (hexEl) { hexEl.classList.add('playing'); hexEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } // Note row const rowEl = document.getElementById('noteRow' + i); if (rowEl) { rowEl.classList.add('playing'); rowEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } // Byte bar const barEl = document.getElementById('byteBar' + note.index); if (barEl) barEl.classList.add('playing'); }, timeOffset)); timeOffset += note.duration + (i < mapping.notes.length - 1 ? note.gap : 0); }); // Clear all at end highlightTimers.push(setTimeout(clearHighlights, timeOffset + 200)); } function playOneNote(noteIdx) { if (!selectedPacket) return; const m = computeMapping(selectedPacket); if (!m || !m.notes[noteIdx]) return; if (window.MeshAudio && !MeshAudio.isEnabled()) MeshAudio.setEnabled(true); const audioCtx = MeshAudio.getContext(); if (!audioCtx) return; if (audioCtx.state === 'suspended') audioCtx.resume(); const note = m.notes[noteIdx]; const oscType = SYNTH_TYPES[m.typeName] || 'triangle'; const ADSR = { ADVERT: { a: 0.02, d: 0.3, s: 0.4, r: 0.5 }, GRP_TXT: { a: 0.005, d: 0.15, s: 0.1, r: 0.2 }, TXT_MSG: { a: 0.01, d: 0.2, s: 0.3, r: 0.4 }, TRACE: { a: 0.05, d: 0.4, s: 0.5, r: 0.8 } }; const env = ADSR[m.typeName] || ADSR.ADVERT; const vol = parseFloat(m.volume) || 0.3; const dur = note.duration / 1000; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); const filter = audioCtx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = m.filterHz; osc.type = oscType; osc.frequency.value = note.freq; const now = audioCtx.currentTime + 0.02; const sustainVol = Math.max(vol * env.s, 0.0001); gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(Math.max(vol, 0.0001), now + env.a); gain.gain.exponentialRampToValueAtTime(sustainVol, now + env.a + env.d); gain.gain.setTargetAtTime(0.0001, now + dur, env.r / 5); osc.connect(gain); gain.connect(filter); filter.connect(audioCtx.destination); osc.start(now); osc.stop(now + dur + env.r + 0.1); osc.onended = () => { osc.disconnect(); gain.disconnect(); filter.disconnect(); }; // Highlight this note clearHighlights(); const hexEl = document.getElementById('hexByte' + note.index); const rowEl = document.getElementById('noteRow' + noteIdx); const barEl = document.getElementById('byteBar' + note.index); if (hexEl) hexEl.classList.add('playing'); if (rowEl) rowEl.classList.add('playing'); if (barEl) barEl.classList.add('playing'); highlightTimers.push(setTimeout(clearHighlights, note.duration + 200)); } 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); // Sync highlights with audio const m = computeMapping(selectedPacket); if (m) highlightPlayback(m); } } 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() { clearHighlights(); if (loopTimer) { clearInterval(loopTimer); loopTimer = null; } if (styleEl) { styleEl.remove(); styleEl = null; } selectedPacket = null; } registerPage('audio-lab', { init, destroy }); })();