As each note plays, highlights sync across all three views:
- Hex dump: current byte pulses red
- Note table: current row highlights blue
- Byte visualizer: current bar glows and scales up
Timing derived from note duration + gap (same values the voice
module uses), scheduled via setTimeout in parallel with audio.
Clears previous note before highlighting next. Auto-clears at end.
New #/audio-lab page for understanding and debugging audio sonification.
Server: GET /api/audio-lab/buckets — returns representative packets
bucketed by type (up to 8 per type spanning size range).
Client: Left sidebar with collapsible type sections, right panel with:
- Controls: Play, Loop, Speed (0.25x-4x), BPM, Volume, Voice select
- Packet Data: type, sizes, hops, obs count, hex dump with sampled
bytes highlighted
- Sound Mapping: computed instrument, scale, filter, volume, voices,
pan — shows exactly why it sounds the way it does
- Note Sequence: table of sampled bytes → MIDI → freq → duration → gap
- Byte Visualizer: bar chart of payload bytes, sampled ones colored
Enables MeshAudio automatically on first play. Mobile responsive.
Three pop sources fixed:
1. setValueAtTime(0) at note start — oscillator starting at exact zero
causes click. Now starts at 0.0001 with exponentialRamp up.
2. setValueAtTime at noteEnd jumping to sustain level — removed.
Decay ramp flows naturally into setTargetAtTime release (smooth
exponential decay, no discontinuities).
3. No amplitude limiting — multiple overlapping packets could spike.
Added DynamicsCompressor as limiter per packet chain (-6dB
threshold, 12:1 ratio, 1ms attack).
Also: 20ms lookahead (was 10ms) gives scheduler more headroom.
packets.find(g => g.hash === h) was O(n) and could race with
loadPackets replacing the array. hashIndex Map stays in sync —
rebuilt on API fetch, updated on WS insert. Prevents duplicate
rows for same hash in grouped mode.
Realistic mode buffers observations then fires once — but was
passing the first packet (obs_count=1). Now passes consolidated
packet with obs_count=packets.length so the voice module gets
the real count for volume + chord voicing.
Secondary broadcast paths (ADVERT, GRP_TXT, TXT_MSG, TRACE, API)
were missing hash field. Without hash, realistic mode's buffer
check (if pkt.hash) failed and packets fell through to
animatePacket individually — causing duplicate feed items and
duplicate sonification.
Also added missing addFeedItem call in animateRealisticPropagation
so the feed shows consolidated entries in realistic mode.
Linear gain ramps + osc.stop() too close to release end caused
waveform discontinuities. Switched to exponentialRamp (natural
decay curve), 0.0001 floor (-80dB), 50ms extra headroom before
oscillator stop.
Instead of silently dropping notes or hoping gesture listeners fire,
show a clear overlay on first packet if AudioContext is suspended.
One tap resumes context and removes overlay. Standard pattern used
by every browser game/music site.
When audio was previously enabled, registers one-shot click/touch/key
listener to init AudioContext on first interaction. Any tap on the
page is enough — no need to toggle the checkbox.
restore() was creating AudioContext without user gesture (on page load
when volume was saved), causing browser to permanently suspend it.
Now restore() only sets flags; AudioContext created lazily on first
sonifyPacket() call or setEnabled() click. Pending volume applied
when context is finally created.
audio.js is now the core engine (context, routing, voice mgmt).
Voice modules register via MeshAudio.registerVoice(name, module).
Each module exports { name, play(ctx, master, parsed, opts) }.
Voice selector dropdown appears in audio controls.
Voices persist in localStorage. Adding a new voice = new file +
script tag. Previous voices are never lost.
v1 "constellation" extracted as audio-v1-constellation.js.
AudioContext starts suspended until user gesture — now resumes on
setEnabled(). Also added sonifyPacket to animateRealisticPropagation
which is the main code path when Realistic mode is on.
Each observer sees a different path length in reality. Extra
rain columns now randomly vary ±1 hop from the base, giving
different fall distances for visual variety.
Trail was limited to hops*30px which meant 1-hop packets showed
1 character. Now shows up to 20 visible chars at once, scrolling
through the entire packet byte array as the drop falls.
dbPacketToLive() wasn't including raw_hex from API data.
VCR replay and timeline scrub packets had no raw bytes,
so rain silently dropped them all. Now includes pkt.raw_hex
as 'raw' field. Removed debug log.
Format 2 MQTT packets (companion bridge) have no raw hex field.
Now falls back to pkt.hash, then extracts hex from decoded payload
JSON. Random bytes only as absolute last resort.
New 'Rain' toggle on live map. Each incoming packet spawns a
falling column of hex bytes from its raw data:
- Fall distance proportional to hop count (8+ hops = full screen)
- 5 second fall time for full-height drops, proportional for shorter
- Leading char: bright white with green glow
- Trail chars: green, progressively fading
- Entire column fades out in last 30% of life
- Random x position across screen width
- Canvas-rendered at 60fps (no DOM overhead)
- Works independently of Matrix mode (can combine both)
- Pre-create pool of 6 reusable markers (no create/destroy per frame)
- CSS transition: transform 80ms linear for position, opacity 200ms ease
- will-change: transform, opacity for GPU compositing
- Styles moved from inline to .matrix-char span class
- Marker positions updated via setLatLng, browser interpolates between
- Fade-out via CSS transition instead of rAF opacity loop
Revert to bbaecd6 if this doesn't feel better.
Replaced setInterval(40ms) with rAF + time-based interpolation.
Same 1.4s duration per hop, but buttery smooth movement.
Fade-out also uses rAF instead of setInterval.