Compare commits

...

68 Commits

Author SHA1 Message Date
you
28e3d8319b v2.6.0 — Audio Sonification, Regional Hop Filtering, Audio Lab 2026-03-23 00:19:45 +00:00
you
3155504d70 Fix CI: use docker rm -f to avoid stale container conflicts 2026-03-23 00:18:19 +00:00
you
dda69e7e1e Fix: revert WS handler to sync — async resolve was blocking rendering
Per-observer resolve in the WS handler made it async, which
broke the debounce callback (unhandled promise + race conditions).
Live packets now render immediately with global cache. Per-observer
resolution happens on initial load and packet detail only.
2026-03-23 00:16:44 +00:00
you
ca3ba6d04e Fix: per-observer hop resolution for WS live packets too
New packets arriving via WebSocket were only getting global
resolution. Now ambiguous hops in WS batches also get per-observer
server-side resolution before rendering.
2026-03-23 00:11:01 +00:00
you
5149bb3295 Fix: per-observer hop resolution for packet list
Ambiguous hops in the list now get resolved per-observer via
batch server API calls. Cache uses observer-scoped keys
(hop:observerId) so the same 1-byte prefix shows different
names depending on which observer saw the packet.

Flow: global resolve first (fast, covers unambiguous hops),
then batch per-observer resolve for ambiguous ones only.
2026-03-23 00:08:41 +00:00
you
961ac7b68c Fix: resolve sender GPS from DB for channel texts and all packet types
When packet doesn't have lat/lon directly (channel messages, DMs),
look up sender node from DB by pubkey or name. Use that GPS as
the origin anchor for hop disambiguation. We've seen ADVERTs from
these senders — use that known location.
2026-03-23 00:05:27 +00:00
you
dbbe280f17 Fix: don't apply time window when navigating to specific packet hash
Direct links like #/packets/HASH should always show the packet
regardless of the time window filter.
2026-03-23 00:04:03 +00:00
you
c267e58418 Fix: sort regional candidates by distance to IATA center
Without sender GPS (channel texts etc), the forward pass had no
anchor and just took candidates[0] — random order. Now regional
candidates are sorted by distKm to observer IATA center before
disambiguation. Closest to region center = default pick.
2026-03-22 23:59:33 +00:00
you
19c60e6872 Fix: packet detail uses server-side resolve-hops with GPS anchor
Client-side HopResolver wasn't properly disambiguating despite
correct data. Switched detail view to use the server API directly:
/api/resolve-hops?hops=...&observer=...&originLat=...&originLon=...

Server-side resolution is battle-tested and handles regional
filtering + GPS-anchored disambiguation correctly.
2026-03-22 23:52:44 +00:00
you
f4002def4c Debug: log renderDetail GPS anchor and resolved hops 2026-03-22 23:50:07 +00:00
you
5f7626ab3c Fix: detail view always re-resolves hops fresh with sender GPS
List view resolves hops without anchor (no per-packet context).
Detail view now always re-resolves with the packet's actual GPS
coordinates + observer, overwriting stale cache entries.
Removed debug logging.
2026-03-22 23:47:34 +00:00
you
55ff793e37 Debug: trace renderDetail re-resolve inputs 2026-03-22 23:46:47 +00:00
you
646afa2cf9 Debug: log HopResolver.resolve inputs to trace disambiguation 2026-03-22 23:44:53 +00:00
you
3c44b4638d Fix: pass sender lat/lon as origin anchor for hop disambiguation
ADVERT packets have GPS coordinates — use them as the forward
pass anchor so the first hop resolves to the nearest candidate
to the sender, not random pick order.
2026-03-22 23:35:10 +00:00
you
398363cc9d Packet detail: re-resolve hops with observer for regional conflicts
The general hop cache was populated without observer context,
so all conflicts showed filterMethod=none. Now renderDetail()
re-resolves hops with pkt.observer_id, getting proper regional
filtering with distances and conflict flags.
2026-03-22 23:29:46 +00:00
you
632e684029 Conflict badge: bigger clickable button with popover pane
⚠3 is now a yellow button (not tiny superscript). Clicking it
opens a popover listing all regional candidates with:
- Node name (clickable → node detail page)
- Distance from observer region center
- Truncated pubkey

Popover dismisses on outside click. Each candidate is a link
to #/nodes/PUBKEY for full details.
2026-03-22 23:25:32 +00:00
you
70db798aed Packet detail byte breakdown uses shared HopDisplay for path hops
Replaces inline conflict rendering with HopDisplay.renderHop() —
consistent regional-only tooltips everywhere.
2026-03-22 23:24:02 +00:00
you
28475722d7 Conflict tooltips show only regional candidates, ignore global noise 2026-03-22 23:22:16 +00:00
you
e732d52e0e Hex Paths mode still shows conflict tooltips
hexMode flag shows raw hex prefix as display text but keeps
the full conflict tooltip, link, and warning badges.
2026-03-22 23:20:28 +00:00
you
3491fdabef Shared HopDisplay module for consistent conflict tooltips
New hop-display.js: shared renderHop() and renderPath() with
full conflict info — candidate count, regional/global flags,
distance, filter method. Tooltip shows all candidates with
details on hover.

packets.js: uses HopDisplay.renderHop() (was inline)
nodes.js: path rendering uses HopDisplay when available
style.css: .hop-current for highlighting the viewed node in paths

Consistent conflict display across packets + node detail pages.
2026-03-22 23:12:11 +00:00
you
501d685003 Fix: hoist targetNodeKey to module scope so loadNodes can access it 2026-03-22 23:05:56 +00:00
you
f0243ff332 Fix: delay popup open after setView, add debug logging
Leaflet needs map to settle after setView before popup can open.
Added 500ms delay + console.warn if target marker not found.
2026-03-22 23:01:18 +00:00
you
46868b7dcd Packet detail: resolve location for channel texts + all node types
For packets without direct lat/lon (GRP_TXT, TXT_MSG):
- Look up sender by pubKey via /api/nodes/:key
- Look up sender by name via /api/nodes/search?q=name
- Show location + 📍map link when node has coordinates

Works for decrypted channel messages (sender field), direct
messages (srcPubKey), and any packet type with a resolvable sender.
2026-03-22 23:00:09 +00:00
you
537e04d0ad Fix: map node highlight uses _nodeKey instead of alt text matching
Store public_key on each Leaflet marker as _nodeKey. Match by
exact pubkey instead of fragile alt text substring search.
2026-03-22 22:55:46 +00:00
you
5273d1fd01 Map: navigate to node by pubkey from packet detail
📍map link now uses #/map?node=PUBKEY. Map centers on the node
at zoom 14 and opens its popup. No fake markers — uses the
existing node marker already on the map.
2026-03-22 22:51:15 +00:00
you
5ad498a662 Map: highlight pin when navigated from packet detail
Red circle marker with tooltip at the target coordinates,
fades out after 10 seconds. Makes it obvious where the
packet location is on the map.
2026-03-22 22:47:01 +00:00
you
0974e7b15b Fix: observer detail packet links use hash, not ID
Was linking to #/packet/ID (wrong route + observation ID).
Now links to #/packets/HASH (correct route + packet hash).
2026-03-22 22:44:00 +00:00
you
b11722d854 Location link points to our own map, not Google Maps
Packet detail 📍map link now navigates to #/map?lat=X&lon=Y&zoom=12.
Map page reads lat/lon/zoom from URL query params to center on
the linked location.
2026-03-22 22:43:29 +00:00
you
25c85aeeb2 Fix: packet-detail page loads observers before rendering
Direct navigation to #/packet/ID skipped loadObservers(), so
obsName() fell through to raw hex pubkey. Now loads observers
first.
2026-03-22 22:42:38 +00:00
you
a2dd96812d Packet detail: show location for ADVERTs and nodes with lat/lon
Shows coordinates with Google Maps link for packets that have
lat/lon in decoded payload (ADVERTs, known nodes). Includes
node name when available.
2026-03-22 22:41:35 +00:00
you
13460cfc93 Fix: move /api/iata-coords route after app initialization
Route was at line 16 before express app was created — caused
'Cannot access app before initialization' crash on startup.
2026-03-22 22:26:15 +00:00
you
935ed6ef85 Show observer IATA regions in packet, node, and live detail views
- packets.js: obsName() now shows IATA code next to observer name, e.g. 'EW-SFC-DR01 (SFO)'
- packets.js: hop conflicts in field table show distance (e.g. '37km')
- nodes.js: both full and sidebar detail views show 'Regions: SJC, OAK, SFO' badges and per-observer IATA
- live.js: node detail panel shows regions in 'Heard By' heading and per-observer IATA
- server.js: /api/nodes/:pubkey/health now returns iata field for each observer
- Bump cache busters
2026-03-22 22:21:01 +00:00
you
70c67d8551 Client-side regional hop filtering (#117)
HopResolver now mirrors server-side layered regional filtering:
- init() accepts observers list + IATA coords
- resolve() accepts observerId, looks up IATA, filters candidates
  by haversine distance (300km radius) to IATA center
- Candidates include regional, filterMethod, distKm fields
- Packet detail view passes observer_id to resolve()

New endpoint: GET /api/iata-coords returns airport coordinates
for client-side use.

Fixes: conflict badges showing "0 conflicts" in packet detail
because client-side resolver had no regional filtering.
2026-03-22 22:18:01 +00:00
you
c1d789b5d7 Regional hop filtering: layered geo + observer approach (#117)
Layer 1 (GPS, bridge-proof): Nodes with lat/lon are checked via
haversine distance to the observer IATA center. Only nodes within
300km are considered regional. Bridged WA nodes appearing in SJC
MQTT feeds are correctly rejected because their GPS coords are
1100km+ from SJC.

Layer 2 (observer-based, fallback): Nodes without GPS fall back to
_advertByObserver index — were they seen by a regional observer?
Less precise but still useful for nodes that never sent ADVERTs
with coordinates.

Layer 3: Global fallback, flagged.

New module: iata-coords.js with 60+ IATA airport coordinates +
haversine distance function.

API response now includes filterMethod (geo/observer/none) and
distKm per conflict candidate.

Tests: 22 unit tests (haversine, boundaries, cross-regional
collision sim, layered fallback, bridge rejection).
2026-03-22 22:09:43 +00:00
you
4cc1ad7b34 Fix #117: Regional filtering for repeater ID resolution
1-byte (and 2-byte) hop IDs match many nodes globally. Previously
resolve-hops picked candidates from anywhere, causing cross-regional
false paths (e.g. Eugene packet showing Vancouver repeaters).

Fix: Use observer IATA to determine packet region. Filter candidates
to nodes seen by observers in the same IATA region via the existing
_advertByObserver index. Fall back to global only if zero regional
candidates exist (flagged as globalFallback).

API changes to /api/resolve-hops response:
- conflicts[]: all candidates with regional flag per hop
- totalGlobal/totalRegional: candidate counts
- globalFallback: true when no regional candidates found
- region: packet IATA region in top-level response

UI changes:
- Conflict count badge (⚠3) instead of bare ⚠
- Tooltip shows regional vs global candidates
- Unreliable hops shown with strikethrough + opacity
- Global fallback hops shown with red dashed underline
2026-03-22 21:38:24 +00:00
you
b7a29d4849 feat: add built-in IATA-to-city mapping for region dropdown (#116)
Add window.IATA_CITIES with ~150 common airport codes covering US, Canada,
Europe, Asia, Oceania, South America, and Africa. The region filter now
falls back to this mapping when no user-configured label exists, so region
dropdowns show friendly city names out of the box.

Closes #116
2026-03-22 21:22:23 +00:00
you
d474d0b427 fix: region dropdown layout for longer city name labels (#116)
- Add width: max-content to dropdown menu for auto-sizing
- Add overflow ellipsis + max-width on dropdown items for very long labels
- Checkboxes already flex-shrink: 0, no text wrapping with white-space: nowrap
2026-03-22 21:21:39 +00:00
you
2609a26605 Audio Lab: click any note row to play it individually
Each note in the sequence table has a ▶ button and the whole row
is clickable. Plays a single oscillator with the correct envelope,
filter, and frequency for that note. Highlights the corresponding
hex byte, table row, and byte bar while it plays.

Also added MeshAudio.getContext() accessor for audio lab to create
individual notes without duplicating AudioContext.
2026-03-22 19:41:00 +00:00
you
0934d8bbb6 Audio Lab: fix highlight timing vs speed setting
computeMapping was applying speedMult on top of BPM that already
included it (setBPM(baseBPM * speedMult)). Double-multiplication
made highlights run at wrong speed. BPM already encodes speed.
2026-03-22 19:39:18 +00:00
you
2fd0d3e07b Audio Lab: show WHY each parameter has its value
Sound Mapping: 3-column table (Parameter | Value | Formula/Reason)
Note Sequence: payload index + duration/gap derivation formulas
2026-03-22 19:36:07 +00:00
you
7c1132b7cf Audio Lab: real-time playback highlighting
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.
2026-03-22 19:27:35 +00:00
you
f523d4f3c4 Audio Lab page (Milestone 1): Packet Jukebox
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.
2026-03-22 19:19:45 +00:00
you
edbaddbd37 Audio Workbench: expand M2 parameter overrides — envelope, filter, limiter, timing 2026-03-22 19:00:48 +00:00
you
d5e6481d9b Audio: eliminate pops — proper envelope + per-packet limiter
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.
2026-03-22 18:58:33 +00:00
you
1450bc928b Packets page: O(1) hash dedup via Map index
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.
2026-03-22 18:55:22 +00:00
you
c7f12c72b9 Audio: log2 voice scaling (up to 8), wider detune spread
1 obs = solo, 3 = duo, 8 = trio, 15 = quartet, 30 = quintet, 60 = sextet.
Wider detune (±8, ±13, ±18...) so stacked voices shimmer instead
of sounding like the same oscillator copied.
2026-03-22 18:45:47 +00:00
you
2688f3e63a Audio: pass consolidated observation_count to sonifyPacket
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.
2026-03-22 18:44:45 +00:00
you
90bd9e12e5 Fix realistic mode: all WS broadcasts include hash + raw_hex
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.
2026-03-22 18:37:53 +00:00
you
4e8b1b2584 Audio: fix pop/crackle — exponential ramps + longer release tail
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.
2026-03-22 18:31:36 +00:00
you
288c1b048b CI: skip deploy on markdown, docs, LICENSE, gitignore changes 2026-03-22 18:28:56 +00:00
you
85ecddd92c Audio Workbench plan — packet jukebox, parameter overrides, A/B comparison 2026-03-22 18:26:49 +00:00
you
d0b02b7070 Remove legacy playSound/🔇 button — MeshAudio is the only audio system now 2026-03-22 18:11:55 +00:00
you
08255aeba5 Audio: "Tap to enable audio" overlay when context is suspended
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.
2026-03-22 17:31:23 +00:00
you
51df14521b Audio: create context eagerly on restore, gesture listener as fallback 2026-03-22 17:27:54 +00:00
you
f3572c646a Audio: unlock on first user gesture after restore
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.
2026-03-22 17:27:00 +00:00
you
0dbe5fd229 Audio: fix inconsistent init — lazy AudioContext, no premature creation
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.
2026-03-22 17:25:42 +00:00
you
f206ae48ef Modularize audio: engine + swappable voice modules
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.
2026-03-22 17:06:18 +00:00
you
ff0f26293e Audio plan: add percussion layer — kick/hat/snare/rim/crash from packet types 2026-03-22 09:41:04 +00:00
you
d0de0770ec Revert audio fixes — restore to initial working audio commit (ddc86a2) 2026-03-22 09:35:18 +00:00
you
6b8e4447c0 Audio: debug logs to trace why packets aren't playing 2026-03-22 09:33:47 +00:00
you
a7e8a70c2f Audio: resume suspended AudioContext + sonify realistic propagation path
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.
2026-03-22 09:29:54 +00:00
you
ddc86a2574 Add mesh audio sonification — raw packet bytes become music
Per AUDIO-PLAN.md:
- payload_type selects instrument (bell/marimba/piano/ethereal),
  scale (major penta/minor penta/natural minor/whole tone), and root key
- sqrt(payload_length) bytes sampled evenly across payload for melody
- byte value → pitch (quantized to scale) + note duration (50-400ms)
- byte-to-byte delta → note spacing (30-300ms)
- hop_count → low-pass filter cutoff (bright nearby, muffled far)
- observation_count → volume + chord voicing (detuned stacked voices)
- origin longitude → stereo pan
- BPM tempo slider scales all timings
- Volume slider for master gain
- ADSR envelopes per instrument type
- Max 12 simultaneous voices with voice stealing
- Pure Web Audio API, no dependencies
2026-03-22 09:19:36 +00:00
you
3f8b8aec79 Audio plan: final — byte-driven timing, BPM tempo control, no fixed spacing 2026-03-22 09:15:14 +00:00
you
73dd0d34d2 Audio plan: header configures voice, payload sings the melody 2026-03-22 09:11:02 +00:00
you
fbf1648ae3 Audio plan: payload type as root key, document guaranteed fields 2026-03-22 09:06:15 +00:00
you
36eb04c016 Audio plan: drop SNR/RSSI, use obs count + hops for dynamics 2026-03-22 08:58:05 +00:00
you
7739b7ef71 Add mesh audio sonification plan (AUDIO-PLAN.md) 2026-03-22 08:56:51 +00:00
you
7fbbea11a4 Remove scanline flicker animation — was causing visible pulsing 2026-03-22 08:39:47 +00:00
23 changed files with 2497 additions and 136 deletions

View File

@@ -3,6 +3,11 @@ name: Deploy
on:
push:
branches: [master]
paths-ignore:
- '**.md'
- 'LICENSE'
- '.gitignore'
- 'docs/**'
concurrency:
group: deploy
@@ -25,7 +30,7 @@ jobs:
run: |
set -e
docker build -t meshcore-analyzer .
docker stop meshcore-analyzer 2>/dev/null && docker rm meshcore-analyzer 2>/dev/null || true
docker rm -f meshcore-analyzer 2>/dev/null || true
docker run -d \
--name meshcore-analyzer \
--restart unless-stopped \

146
AUDIO-PLAN.md Normal file
View File

@@ -0,0 +1,146 @@
# Mesh Audio — Sonification Plan
*Turn raw packet bytes into generative music.*
## What Every Packet Has (guaranteed)
- `raw_hex` — melody source
- `hop_count` — note duration + filter cutoff
- `observation_count` — volume + chord voicing
- `payload_type` — instrument + scale + root key
- `node_lat/lon` — stereo pan
- `timestamp` — arrival timing
## Final Mapping
| Data | Musical Role |
|------|-------------|
| **payload_type** | Instrument + scale + root key |
| **payload bytes** (evenly sampled, sqrt(len) count) | Melody notes (pitch) |
| **byte value** | Note length (higher = longer sustain, lower = staccato) |
| **byte-to-byte delta** | Note spacing (big jump = longer gap, small = rapid) |
| **hop_count** | Low-pass filter cutoff (more hops = more muffled) |
| **observation_count** | Volume + chord voicing (more observers = louder + stacked detuned voices) |
| **node longitude** | Stereo pan (west = left, east = right) |
| **BPM tempo** (user control) | Master time multiplier on all durations |
## Instruments & Scales by Type
| Type | Instrument | Scale | Root |
|------|-----------|-------|------|
| ADVERT | Bell / pad | C major pentatonic | C |
| GRP_TXT | Marimba / pluck | A minor pentatonic | A |
| TXT_MSG | Piano | E natural minor | E |
| TRACE | Ethereal synth | D whole tone | D |
## How a Packet Plays
1. **Header configures the voice** — payload type selects instrument, scale, root key. Flags/transport codes select envelope shape. Header bytes are NOT played as notes.
2. **Sample payload bytes** — pick `sqrt(payload_length)` bytes, evenly spaced across payload:
- 16-byte payload → 4 notes
- 36-byte payload → 6 notes
- 64-byte payload → 8 notes
3. **Each sampled byte → a note:**
- **Pitch**: byte value (0-255) quantized to selected scale across 2-3 octaves
- **Length**: byte value maps to sustain duration (low byte = short staccato ~50ms, high byte = sustained ~400ms)
- **Spacing**: delta between current and next sampled byte determines gap to next note (small delta = rapid fire, large delta = pause). Scaled by BPM tempo multiplier.
4. **Filter**: low-pass cutoff from hop_count — few hops = bright/clear, many hops = muffled (signal traveled far)
5. **Volume**: observation_count — more observers = louder
6. **Chord voicing**: if observations > 1, stack slightly detuned voices (±5-15 cents per voice, chorus effect)
7. **Pan**: origin node longitude mapped to stereo field
8. **All timings scaled by BPM tempo control**
## UI Controls
- **Audio toggle** — on/off (next to Matrix / Rain)
- **BPM tempo slider** — master time multiplier (slow = ambient, fast = techno)
- **Volume slider** — master gain
- **Mute button** — pause audio without losing toggle state
## Implementation
### Library: Tone.js (~150KB)
- `Tone.Synth` / `Tone.PolySynth` for melody + chords
- `Tone.Sampler` for realistic instruments
- `Tone.Filter` for hop-based cutoff
- `Tone.Chorus` for observation detuning
- `Tone.Panner` for geographic stereo
- `Tone.Reverb` for spatial depth
### Integration
- `animatePacket(pkt)` also calls `sonifyPacket(pkt)`
- Optional "Sonify" button on packet detail page
- Web Audio runs on separate thread — won't block UI/animations
- Polyphony capped at 8-12 voices to prevent mudding
- Voice stealing when busy
### Core Function
```
sonifyPacket(pkt):
1. Extract raw_hex → byte array
2. Separate header (first ~3 bytes) from payload
3. Header → select instrument, scale, root key, envelope
4. Sample sqrt(payload.length) bytes evenly across payload
5. For each sampled byte:
- pitch = quantize(byte, scale, rootKey)
- duration = map(byte, 50ms, 400ms) × tempoMultiplier
- gap to next = map(abs(nextByte - byte), 30ms, 300ms) × tempoMultiplier
6. Set filter cutoff from hop_count
7. Set gain from observation_count
8. Set pan from origin longitude
9. If observation_count > 1: detune +/- cents per voice
10. Schedule note sequence via Tone.js
```
## Percussion Layer
Percussion fires **instantly** on packet arrival — gives you the rhythmic pulse while the melodic notes unfold underneath.
### Drum Kit Mapping
| Packet Type | Drum Sound | Why |
|-------------|-----------|-----|
| **Any packet** | Kick drum | Network heartbeat. Every arrival = one kick. Busier network = faster kicks. |
| **ADVERT** | Hi-hat | Most frequent, repetitive — the timekeeper tick. |
| **GRP_TXT / TXT_MSG** | Snare | Human-initiated messages are accent hits. |
| **TRACE** | Rim click | Sparse, searching — light metallic tick. |
| **8+ hops OR 10+ observations** | Cymbal crash | Big network events get a crash. Rare = special. |
### Sound Design (all synthesized, no samples)
**Kick:** Sine oscillator, frequency ramp 150Hz → 40Hz in ~50ms, short gain envelope.
**Hi-hat:** White noise through highpass filter (7-10kHz).
- **Closed** (1-2 hops): 30ms decay — tight tick
- **Open** (3+ hops): 150ms decay — sizzle
**Snare:** White noise burst (bandpass ~200-1000Hz) + sine tone body (~180Hz). Observation count scales intensity (more observers = louder crack, longer decay).
**Rim click:** Short sine pulse at ~800Hz with fast decay (20ms). Dry, metallic.
**Cymbal crash:** White noise through bandpass (3-8kHz), long decay (500ms-1s). Only triggers on exceptional packets.
### Byte-Driven Variation
First payload byte mod 4 selects between variations of each percussion sound:
- Slightly different pitch (±10-20%)
- Different decay length
- Different filter frequency
Prevents machine-gun effect of identical repeated hits.
### Timing
- Percussion: fires immediately on packet arrival (t=0)
- Melody: unfolds over 0.6-1.6s starting at t=0
- Result: rhythmic hit gives you the pulse, melody gives you the data underneath
## The Full Experience
Matrix mode + Rain + Audio: green hex bytes flow across the map, columns of raw data rain down, and each packet plays its own unique melody derived from its actual bytes. Quiet periods are sparse atmospheric ambience; traffic bursts become dense polyrhythmic cascades. Crank the BPM for techno, slow it down for ambient.
## Future Ideas
- "Record" button → export MIDI or WAV
- Per-type mute toggles (silence ADVERTs, only hear messages)
- "DJ mode" — crossfade between regions
- Historical playback at accelerated speed = mesh network symphony
- Presets (ambient, techno, classical, minimal)
- ADVERT ambient drone layer (single modulated oscillator, not per-packet)

175
AUDIO-WORKBENCH.md Normal file
View File

@@ -0,0 +1,175 @@
# AUDIO-WORKBENCH.md — Sound Shaping & Debug Interface
## Problem
Live packets arrive randomly and animate too fast to understand what's happening musically. You hear sound, but can't connect it to what the data is doing — which bytes become which notes, why this packet sounds different from that one.
## Milestone 1: Packet Jukebox
A standalone page (`#/audio-lab`) that lets you trigger packets manually and understand the data→sound mapping.
### Packet Buckets
Pre-load representative packets from the database, bucketed by type:
| Type ID | Name | Typical Size | Notes |
|---------|------|-------------|-------|
| 0x04 | ADVERT | 109-177 bytes | Node advertisements, most musical (long payload) |
| 0x05 | GRP_TXT | 18-173 bytes | Group messages, wide size range |
| 0x01 | TXT_MSG | 22-118 bytes | Direct messages |
| 0x02 | ACK/REQ | 22-57 bytes | Short acknowledgments |
| 0x09 | TRACE | 11-13 bytes | Very short, sparse |
| 0x00 | RAW | 22-33 bytes | Raw packets |
For each type, pull 5-10 representative packets spanning the size range (smallest, median, largest) and observation count range (1 obs, 10+ obs, 50+ obs).
### API
New endpoint: `GET /api/audio-lab/buckets`
Returns pre-selected packets grouped by type with decoded data and raw_hex. Server picks representatives so the client doesn't need to sift through hundreds.
### UI Layout
```
┌─────────────────────────────────────────────────────┐
│ 🎵 Audio Lab │
├──────────┬──────────────────────────────────────────┤
│ │ │
│ ADVERT │ [▶ Play] [🔁 Loop] [⏱ Slow 0.5x] │
│ ▸ #1 │ │
│ ▸ #2 │ ┌─ Packet Data ──────────────────────┐ │
│ ▸ #3 │ │ Type: ADVERT │ │
│ │ │ Size: 141 bytes (payload: 138) │ │
│ GRP_TXT │ │ Hops: 3 Observations: 12 │ │
│ ▸ #1 │ │ Raw: 04 8b 33 87 e9 c5 cd ea ... │ │
│ ▸ #2 │ └────────────────────────────────────┘ │
│ │ │
│ TXT_MSG │ ┌─ Sound Mapping ────────────────────┐ │
│ ▸ #1 │ │ Instrument: Bell (triangle) │ │
│ │ │ Scale: C major pentatonic │ │
│ TRACE │ │ Notes: 12 (√138 ≈ 11.7) │ │
│ ▸ #1 │ │ Filter: 4200 Hz (3 hops) │ │
│ │ │ Volume: 0.48 (12 obs) │ │
│ │ │ Voices: 4 (12 obs, capped) │ │
│ │ │ Pan: -0.3 (lon: -105.2) │ │
│ │ └────────────────────────────────────┘ │
│ │ │
│ │ ┌─ Note Sequence ────────────────────┐ │
│ │ │ #1: byte 0x8B → C4 (880Hz) 310ms │ │
│ │ │ gap: 82ms (Δ=0x58) │ │
│ │ │ #2: byte 0x33 → G3 (392Hz) 120ms │ │
│ │ │ gap: 210ms (Δ=0xB4) │ │
│ │ │ ... │ │
│ │ └────────────────────────────────────┘ │
│ │ │
│ │ ┌─ Byte Visualizer ──────────────────┐ │
│ │ │ ████░░██████░░░████████░░██░░░░████ │ │
│ │ │ ↑ ↑ ↑ ↑ │ │
│ │ │ sampled bytes highlighted in payload │ │
│ │ └────────────────────────────────────┘ │
├──────────┴──────────────────────────────────────────┤
│ BPM [====●========] 120 Vol [==●===========] 30 │
│ Voice: [constellation ▾] │
└─────────────────────────────────────────────────────┘
```
### Key Features
1. **Play button** — triggers `sonifyPacket()` with the selected packet
2. **Loop** — retrigger every N seconds (configurable)
3. **Slow mode** — 0.25x / 0.5x / 1x / 2x tempo override (separate from BPM, multiplies it)
4. **Note sequence breakdown** — shows every sampled byte, its MIDI note, frequency, duration, gap to next. Highlights each note in real-time as it plays.
5. **Byte visualizer** — hex dump of payload with sampled bytes highlighted. Shows which bytes the voice module chose and what they became.
6. **Sound mapping panel** — shows computed parameters (instrument, scale, filter, pan, volume, voice count) so you can see exactly why it sounds the way it does.
### Playback Highlighting
As each note plays, highlight:
- The corresponding byte in the hex dump
- The note row in the sequence table
- A playhead marker on the byte visualizer bar
This connects the visual and auditory — you SEE which byte is playing RIGHT NOW.
---
## Milestone 2: Parameter Overrides
Once you can hear individual packets clearly, add override sliders to shape the sound:
### Envelope & Tone
- **Oscillator type** — sine / triangle / square / sawtooth
- **ADSR sliders** — attack, decay, sustain, release (with real-time envelope visualizer curve)
- **Scale override** — force any scale regardless of packet type (C maj pent, A min pent, E nat minor, D whole tone, chromatic, etc.)
- **Root note** — base MIDI note for the scale
### Spatial & Filter
- **Filter type** — lowpass / highpass / bandpass
- **Filter cutoff** — manual override of hop-based cutoff (Hz slider + "data-driven" toggle)
- **Filter Q/resonance** — 0.1 to 20
- **Pan lock** — force stereo position (-1 to +1)
### Voicing & Dynamics
- **Voice count** — force 1-8 voices regardless of observation count
- **Detune spread** — cents per voice (0-50)
- **Volume** — manual override of observation-based volume
- **Limiter threshold** — per-packet compressor threshold (dB)
- **Limiter ratio** — 1:1 to 20:1
### Note Timing
- **Note duration range** — min/max duration mapped from byte value
- **Note gap range** — min/max gap mapped from byte delta
- **Lookahead** — scheduling buffer (ms)
Each override has a "lock 🔒" toggle — locked = your value, unlocked = data-driven. Unlocked shows the computed value in real-time so you can see what the data would produce.
The voice module's `play()` accepts an `overrides` object from the workbench. Locked parameters override computed values; unlocked ones pass through.
---
## Milestone 3: A/B Voice Comparison
- Split-screen: two voice modules side by side
- Same packet, different voices
- "Play Both" button with configurable delay between them
- Good for iterating on v2/v3 voices against v1 constellation
---
## Milestone 4: Sequence Editor
- Drag packets into a timeline to create a sequence
- Adjust timing between packets manually
- Play the sequence as a composition
- Export as audio (MediaRecorder API → WAV/WebM)
- Useful for demoing "this is what the mesh sounds like" without waiting for live traffic
---
## Milestone 5: Live Annotation Mode
- Toggle on live map that shows the sound mapping panel for each packet as it plays
- Small floating card near the animated path showing: type, notes, instrument
- Fades out after the notes finish
- Connects the live visualization with the audio in real-time
---
## Architecture Notes
- Audio Lab is a new SPA page like packets/nodes/analytics
- Reuses existing `MeshAudio.sonifyPacket()` and voice modules
- Voice modules need a small extension: `play()` should return a `NoteSequence` object describing what it will play, not just play it. This enables the visualizer.
- Or: add a `describe(parsed, opts)` method that returns the mapping without playing
- BPM/volume/voice selection shared with live map via `MeshAudio.*`
## Implementation Order
1. API endpoint for bucketed representative packets
2. Basic page layout with packet list and play button
3. Sound mapping panel (computed parameters display)
4. Note sequence breakdown
5. Playback highlighting
6. Byte visualizer
7. Override sliders (M2)

64
RELEASE-v2.6.0.md Normal file
View File

@@ -0,0 +1,64 @@
# v2.6.0 — Audio Sonification, Regional Hop Filtering, Audio Lab
## 🔊 Mesh Audio Sonification
Packets now have sound. Each packet's raw bytes become music through a modular voice engine.
- **Payload type → instrument + scale**: ADVERTs play triangle waves on C major pentatonic, GRP_TXT uses sine on A minor pentatonic, TXT_MSG on E natural minor, TRACE on D whole tone
- **Payload bytes → melody**: √(payload_length) bytes sampled evenly, quantized to scale
- **Byte value → note duration**: low bytes = staccato, high = sustained
- **Byte delta → note spacing**: small deltas = rapid fire, large = pauses
- **Observation count → volume + chord voicing**: more observers = louder + richer (up to 8 detuned voices via log₂ scaling)
- **Hop count → filter cutoff**: more hops = more muffled (lowpass 800-8000Hz)
- **Node longitude → stereo pan**
- **BPM tempo slider** for ambient ↔ techno feel
- **Per-packet limiter** prevents amplitude spikes from overlapping notes
- **Exponential envelopes** eliminate click/pop artifacts
- **"Tap to enable audio" overlay** handles browser autoplay policy
- **Modular voice architecture**: engine (`audio.js`) + swappable voice modules. New voices = new file + script tag.
## 🎵 Audio Lab (Packet Jukebox)
New `#/audio-lab` page for understanding and debugging the audio:
- **Packet buckets by type** — representative packets spanning size/observation ranges
- **Play/Loop/Speed controls** — trigger individual packets, 0.25x to 4x speed
- **Sound Mapping panel** — shows WHY each parameter has its value (formulas + computed results)
- **Note Sequence table** — every sampled byte → MIDI note → frequency → duration → gap, with derivation formulas
- **Real-time playback highlighting** — hex dump, note rows, and byte visualizer highlight in sync as each note plays
- **Click individual notes** — play any single note from the sequence
- **Byte Visualizer** — bar chart of payload bytes, sampled bytes colored by type
## 🗺️ Regional Hop Filtering (#117)
1-byte repeater IDs (0-255) collide globally. Previously, resolve-hops picked candidates from anywhere, causing false cross-regional paths (e.g., Eugene packet showing Vancouver repeaters).
- **Layered filtering**: GPS distance to IATA center (bridge-proof) → observer-based fallback → global fallback
- **60+ IATA airport coordinates** built in for geographic distance calculations
- **Regional candidates sorted by distance** — closest to region center wins when no sender GPS available
- **Sender GPS as origin anchor** — ADVERTs use their own coordinates; channel messages look up sender node GPS from previous ADVERTs in the database
- **Per-observer resolution** — packet list batch-resolves ambiguous hops per observer via server API
- **Conflict popover** — clickable ⚠ badges show all regional candidates with distances, each linking to node detail
- **Shared HopDisplay module** — consistent conflict display across packets, nodes, and detail views
## 🏷️ Region Dropdown Improvements (#116)
- **150+ built-in IATA-to-city mappings** — dropdown shows `SEA - Seattle, WA` automatically, no config needed
- **Layout fixes** — dropdown auto-sizes for longer labels, checkbox alignment, ellipsis overflow
## 📍 Location & Navigation
- **Packet detail shows location** for ADVERTs (direct GPS), channel texts (sender node lookup), and all resolvable senders
- **📍 Map link** navigates to `#/map?node=PUBKEY` — centers on the actual node and opens its popup
- **Observer IATA regions** shown in packet detail, node detail, and live map node panels
## 🔧 Fixes
- **Realistic mode fixed** — secondary WS broadcast paths (ADVERT, GRP_TXT, TXT_MSG, TRACE) were missing `hash` field, bypassing the 5-second grouping buffer entirely
- **Observation count passed to sonification** — realistic mode now provides actual observer count for volume/chord voicing
- **Packet list dedup** — O(1) hash index via Map prevents duplicate rows
- **Observer names in packet detail** — direct navigation to `#/packets/HASH` now loads observers first
- **Observer detail packet links** — fixed to use hash (not ID) and correct route
- **Time window bypassed for direct links** — `#/packets/HASH` always shows the packet regardless of time filter
- **CI: `docker rm -f`** — prevents stale container conflicts during deploy
- **CI: `paths-ignore`** — skips deploy on markdown/docs/license changes

90
iata-coords.js Normal file
View File

@@ -0,0 +1,90 @@
// IATA airport coordinates for regional node filtering
// Used by resolve-hops to determine if a node is geographically near an observer's region
const IATA_COORDS = {
// US West Coast
SJC: { lat: 37.3626, lon: -121.9290 },
SFO: { lat: 37.6213, lon: -122.3790 },
OAK: { lat: 37.7213, lon: -122.2208 },
SEA: { lat: 47.4502, lon: -122.3088 },
PDX: { lat: 45.5898, lon: -122.5951 },
LAX: { lat: 33.9425, lon: -118.4081 },
SAN: { lat: 32.7338, lon: -117.1933 },
SMF: { lat: 38.6954, lon: -121.5908 },
MRY: { lat: 36.5870, lon: -121.8430 },
EUG: { lat: 44.1246, lon: -123.2119 },
RDD: { lat: 40.5090, lon: -122.2934 },
MFR: { lat: 42.3742, lon: -122.8735 },
FAT: { lat: 36.7762, lon: -119.7181 },
SBA: { lat: 34.4262, lon: -119.8405 },
RNO: { lat: 39.4991, lon: -119.7681 },
BOI: { lat: 43.5644, lon: -116.2228 },
LAS: { lat: 36.0840, lon: -115.1537 },
PHX: { lat: 33.4373, lon: -112.0078 },
SLC: { lat: 40.7884, lon: -111.9778 },
// US Mountain/Central
DEN: { lat: 39.8561, lon: -104.6737 },
DFW: { lat: 32.8998, lon: -97.0403 },
IAH: { lat: 29.9844, lon: -95.3414 },
AUS: { lat: 30.1975, lon: -97.6664 },
MSP: { lat: 44.8848, lon: -93.2223 },
// US East Coast
ATL: { lat: 33.6407, lon: -84.4277 },
ORD: { lat: 41.9742, lon: -87.9073 },
JFK: { lat: 40.6413, lon: -73.7781 },
EWR: { lat: 40.6895, lon: -74.1745 },
BOS: { lat: 42.3656, lon: -71.0096 },
MIA: { lat: 25.7959, lon: -80.2870 },
IAD: { lat: 38.9531, lon: -77.4565 },
CLT: { lat: 35.2144, lon: -80.9473 },
DTW: { lat: 42.2124, lon: -83.3534 },
MCO: { lat: 28.4312, lon: -81.3081 },
BNA: { lat: 36.1263, lon: -86.6774 },
RDU: { lat: 35.8801, lon: -78.7880 },
// Canada
YVR: { lat: 49.1967, lon: -123.1815 },
YYZ: { lat: 43.6777, lon: -79.6248 },
YYC: { lat: 51.1215, lon: -114.0076 },
YEG: { lat: 53.3097, lon: -113.5800 },
YOW: { lat: 45.3225, lon: -75.6692 },
// Europe
LHR: { lat: 51.4700, lon: -0.4543 },
CDG: { lat: 49.0097, lon: 2.5479 },
FRA: { lat: 50.0379, lon: 8.5622 },
AMS: { lat: 52.3105, lon: 4.7683 },
MUC: { lat: 48.3537, lon: 11.7750 },
SOF: { lat: 42.6952, lon: 23.4062 },
// Asia/Pacific
NRT: { lat: 35.7720, lon: 140.3929 },
HND: { lat: 35.5494, lon: 139.7798 },
ICN: { lat: 37.4602, lon: 126.4407 },
SYD: { lat: -33.9461, lon: 151.1772 },
MEL: { lat: -37.6690, lon: 144.8410 },
};
// Haversine distance in km
function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
// Default radius for "near region" — LoRa max realistic range ~300km
const DEFAULT_REGION_RADIUS_KM = 300;
/**
* Check if a node is geographically within radius of an IATA region center.
* Returns { near: boolean, distKm: number } or null if can't determine.
*/
function nodeNearRegion(nodeLat, nodeLon, iata, radiusKm = DEFAULT_REGION_RADIUS_KM) {
const center = IATA_COORDS[iata];
if (!center) return null;
if (nodeLat == null || nodeLon == null || (nodeLat === 0 && nodeLon === 0)) return null;
const distKm = haversineKm(nodeLat, nodeLon, center.lat, center.lon);
return { near: distKm <= radiusKm, distKm: Math.round(distKm) };
}
module.exports = { IATA_COORDS, haversineKm, nodeNearRegion, DEFAULT_REGION_RADIUS_KM };

View File

@@ -1,6 +1,6 @@
{
"name": "meshcore-analyzer",
"version": "2.5.0",
"version": "2.6.0",
"description": "Community-run alternative to the closed-source `analyzer.letsmesh.net`. MQTT packet collection + open-source web analyzer for the Bay Area MeshCore mesh.",
"main": "index.js",
"scripts": {

562
public/audio-lab.js Normal file
View File

@@ -0,0 +1,562 @@
/* === 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;
let highlightTimers = [];
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; 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 = '<div class="alab-empty">No raw hex data for this packet</div>'; 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 += `<span class="sampled" id="hexByte${i}">${h}</span> `;
else hexHtml += `<span id="hexByte${i}">${h}</span> `;
}
document.getElementById('alabDetail').innerHTML = `
<div class="alab-section">
<h3>📦 Packet Data</h3>
<div class="alab-grid">
<div class="alab-stat"><span class="label">Type</span><br><span class="value" style="color:${m.color}">${m.typeName}</span></div>
<div class="alab-stat"><span class="label">Total Size</span><br><span class="value">${m.totalSize} bytes</span></div>
<div class="alab-stat"><span class="label">Payload Size</span><br><span class="value">${m.payloadSize} bytes</span></div>
<div class="alab-stat"><span class="label">Hops</span><br><span class="value">${m.hopCount}</span></div>
<div class="alab-stat"><span class="label">Observations</span><br><span class="value">${m.obsCount}</span></div>
<div class="alab-stat"><span class="label">Hash</span><br><span class="value">${pkt.hash || '—'}</span></div>
</div>
<div style="margin-top:10px">
<div class="alab-hex" id="alabHex" onclick="this.classList.toggle('expanded')" title="Click to expand">${hexHtml}</div>
</div>
</div>
<div class="alab-section">
<h3>🎵 Sound Mapping</h3>
<table class="alab-map-table">
<tr>
<td class="map-param">Instrument</td>
<td class="map-value">${m.oscType}</td>
<td class="map-why">payload_type = ${m.typeName}${m.oscType} oscillator</td>
</tr>
<tr>
<td class="map-param">Scale</td>
<td class="map-value">${m.scaleName}</td>
<td class="map-why">payload_type = ${m.typeName}${m.scaleName} (root MIDI ${SCALE_INTERVALS[m.typeName]?.root || 48})</td>
</tr>
<tr>
<td class="map-param">Notes</td>
<td class="map-value">${m.noteCount}</td>
<td class="map-why">⌈√${m.payloadSize}⌉ = ⌈${Math.sqrt(m.payloadSize).toFixed(1)}⌉ = ${m.noteCount} bytes sampled evenly across payload</td>
</tr>
<tr>
<td class="map-param">Filter Cutoff</td>
<td class="map-value">${m.filterHz} Hz</td>
<td class="map-why">${m.hopCount} hops → map(1...10 → 8000...800 Hz) = ${m.filterHz} Hz lowpass — more hops = more muffled</td>
</tr>
<tr>
<td class="map-param">Volume</td>
<td class="map-value">${m.volume}</td>
<td class="map-why">min(0.6, 0.15 + (${m.obsCount} obs 1) × 0.02) = ${m.volume} — more observers = louder</td>
</tr>
<tr>
<td class="map-param">Voices</td>
<td class="map-value">${m.voiceCount}</td>
<td class="map-why">min(⌈log₂(${m.obsCount} + 1)⌉, 8) = ${m.voiceCount} — more observers = richer chord</td>
</tr>
<tr>
<td class="map-param">Detune</td>
<td class="map-value">${m.detuneDesc.join(', ')}</td>
<td class="map-why">${m.voiceCount} voices detuned for shimmer — wider spread with more voices</td>
</tr>
<tr>
<td class="map-param">Pan</td>
<td class="map-value">${m.panValue}</td>
<td class="map-why">${m.panSource}</td>
</tr>
</table>
</div>
<div class="alab-section">
<h3>🎹 Note Sequence</h3>
<table class="alab-note-table">
<tr><th></th><th>#</th><th>Payload Index</th><th>Byte</th><th>→ MIDI</th><th>→ Freq</th><th>Duration (why)</th><th>Gap (why)</th></tr>
${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 `<tr id="noteRow${i}" class="alab-note-clickable" data-note-idx="${i}">
<td><button class="alab-note-play" data-note-idx="${i}" title="Play this note">▶</button></td>
<td>${i + 1}</td>
<td>[${n.index}]</td>
<td>0x${n.byte.toString(16).padStart(2, '0').toUpperCase()} (${n.byte})</td>
<td>${n.midi}</td>
<td>${n.freq} Hz</td>
<td>${n.duration} ms <span class="map-why-inline">${durWhy}</span></td>
<td>${i < m.notes.length - 1 ? n.gap + ' ms <span class="map-why-inline">' + gapWhy + '</span>' : '—'}</td>
</tr>`;}).join('')}
</table>
</div>
<div class="alab-section">
<h3>📊 Byte Visualizer</h3>
<div class="alab-byte-viz" id="alabByteViz"></div>
</div>
`;
// 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 = `
<div class="alab">
<div class="alab-sidebar" id="alabSidebar"><div style="color:var(--text-muted);font-size:13px;padding:8px">Loading packets...</div></div>
<div class="alab-main">
<div class="alab-controls" id="alabControls">
<button class="alab-btn" id="alabPlay" title="Play selected packet">▶ Play</button>
<button class="alab-btn" id="alabLoop" title="Loop playback">🔁 Loop</button>
<span style="font-size:12px;color:var(--text-muted)">Speed:</span>
<button class="alab-speed" data-speed="0.25">0.25x</button>
<button class="alab-speed active" data-speed="1">1x</button>
<button class="alab-speed" data-speed="2">2x</button>
<button class="alab-speed" data-speed="4">4x</button>
<div class="alab-slider-group">
<span>BPM</span>
<input type="range" id="alabBPM" min="30" max="300" value="${baseBPM}">
<span id="alabBPMVal">${baseBPM}</span>
</div>
<div class="alab-slider-group">
<span>Vol</span>
<input type="range" id="alabVol" min="0" max="100" value="${MeshAudio && MeshAudio.getVolume ? Math.round(MeshAudio.getVolume() * 100) : 30}">
<span id="alabVolVal">${MeshAudio && MeshAudio.getVolume ? Math.round(MeshAudio.getVolume() * 100) : 30}%</span>
</div>
<div class="alab-slider-group">
<span>Voice</span>
<select id="alabVoice">${(MeshAudio && MeshAudio.getVoiceNames ? MeshAudio.getVoiceNames() : ['constellation']).map(v =>
`<option value="${v}" ${(MeshAudio && MeshAudio.getVoiceName && MeshAudio.getVoiceName() === v) ? 'selected' : ''}>${v}</option>`
).join('')}</select>
</div>
</div>
<div id="alabDetail"><div class="alab-empty">← Select a packet from the sidebar to explore its sound</div></div>
</div>
</div>
`;
// 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 = '<div style="color:var(--text-muted);font-size:13px;padding:8px">No packets in memory yet</div>';
return;
}
let html = '';
for (const [type, pkts] of Object.entries(data.buckets)) {
const color = TYPE_COLORS[type] || TYPE_COLORS.UNKNOWN;
html += `<div class="alab-type-hdr" style="background:${color}22;color:${color}" data-type="${type}">
<span>${type}</span><span style="font-size:11px;opacity:0.7">${pkts.length}</span></div>`;
html += `<div class="alab-type-list" data-type-list="${type}">`;
pkts.forEach((p, i) => {
const size = p.raw_hex ? p.raw_hex.length / 2 : 0;
html += `<div class="alab-pkt" data-type="${type}" data-idx="${i}">#${i + 1}${size}B — ${p.observation_count || 1} obs</div>`;
});
html += '</div>';
}
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 = `<div style="color:var(--text-muted);padding:8px">Error loading packets: ${err.message}</div>`;
}
}
function destroy() {
clearHighlights();
if (loopTimer) { clearInterval(loopTimer); loopTimer = null; }
if (styleEl) { styleEl.remove(); styleEl = null; }
selectedPacket = null;
}
registerPage('audio-lab', { init, destroy });
})();

View File

@@ -0,0 +1,139 @@
// Voice v1: "Constellation" — melodic packet sonification
// Original voice: type-based instruments, scale-quantized melody from payload bytes,
// byte-driven note duration and spacing, hop-based filter, observation chord voicing.
(function () {
'use strict';
const { buildScale, midiToFreq, mapRange, quantizeToScale } = MeshAudio.helpers;
// Scales per payload type
const SCALES = {
ADVERT: buildScale([0, 2, 4, 7, 9], 48), // C major pentatonic
GRP_TXT: buildScale([0, 3, 5, 7, 10], 45), // A minor pentatonic
TXT_MSG: buildScale([0, 2, 3, 5, 7, 8, 10], 40),// E natural minor
TRACE: buildScale([0, 2, 4, 6, 8, 10], 50), // D whole tone
};
const DEFAULT_SCALE = SCALES.ADVERT;
// Synth ADSR envelopes per type
const SYNTHS = {
ADVERT: { type: 'triangle', attack: 0.02, decay: 0.3, sustain: 0.4, release: 0.5 },
GRP_TXT: { type: 'sine', attack: 0.005, decay: 0.15, sustain: 0.1, release: 0.2 },
TXT_MSG: { type: 'triangle', attack: 0.01, decay: 0.2, sustain: 0.3, release: 0.4 },
TRACE: { type: 'sine', attack: 0.05, decay: 0.4, sustain: 0.5, release: 0.8 },
};
const DEFAULT_SYNTH = SYNTHS.ADVERT;
function play(audioCtx, masterGain, parsed, opts) {
const { payloadBytes, typeName, hopCount, obsCount, payload, hops } = parsed;
const tm = opts.tempoMultiplier;
const scale = SCALES[typeName] || DEFAULT_SCALE;
const synthConfig = SYNTHS[typeName] || DEFAULT_SYNTH;
// Sample sqrt(len) bytes evenly
const noteCount = Math.max(2, Math.min(10, Math.ceil(Math.sqrt(payloadBytes.length))));
const sampledBytes = [];
for (let i = 0; i < noteCount; i++) {
const idx = Math.floor((i / noteCount) * payloadBytes.length);
sampledBytes.push(payloadBytes[idx]);
}
// Pan from longitude
let panValue = 0;
if (payload.lat !== undefined && payload.lon !== undefined) {
panValue = Math.max(-1, Math.min(1, mapRange(payload.lon, -125, -65, -1, 1)));
} else if (hops.length > 0) {
panValue = (Math.random() - 0.5) * 0.6;
}
// Filter from hops
const filterFreq = mapRange(Math.min(hopCount, 10), 1, 10, 8000, 800);
// Volume from observations
const volume = Math.min(0.6, 0.15 + (obsCount - 1) * 0.02);
// More observers = richer chord: 1→1, 3→2, 8→3, 15→4, 30→5, 60→6
const voiceCount = Math.min(Math.max(1, Math.ceil(Math.log2(obsCount + 1))), 8);
// Audio chain: filter → limiter → panner → master
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = filterFreq;
filter.Q.value = 1;
const limiter = audioCtx.createDynamicsCompressor();
limiter.threshold.value = -6;
limiter.knee.value = 6;
limiter.ratio.value = 12;
limiter.attack.value = 0.001;
limiter.release.value = 0.05;
const panner = audioCtx.createStereoPanner();
panner.pan.value = panValue;
filter.connect(limiter);
limiter.connect(panner);
panner.connect(masterGain);
let timeOffset = audioCtx.currentTime + 0.02; // small lookahead avoids scheduling on "now"
let lastNoteEnd = timeOffset;
for (let i = 0; i < sampledBytes.length; i++) {
const byte = sampledBytes[i];
const freq = midiToFreq(quantizeToScale(byte, scale));
const duration = mapRange(byte, 0, 255, 0.05, 0.4) * tm;
let gap = 0.05 * tm;
if (i < sampledBytes.length - 1) {
const delta = Math.abs(sampledBytes[i + 1] - byte);
gap = mapRange(delta, 0, 255, 0.03, 0.3) * tm;
}
const noteStart = timeOffset;
const noteEnd = noteStart + duration;
const { attack: a, decay: d, sustain: s, release: r } = synthConfig;
for (let v = 0; v < voiceCount; v++) {
const detune = v === 0 ? 0 : (v % 2 === 0 ? 1 : -1) * (v * 5 + 3);
const osc = audioCtx.createOscillator();
const envGain = audioCtx.createGain();
osc.type = synthConfig.type;
osc.frequency.value = freq;
osc.detune.value = detune;
const voiceVol = volume / voiceCount;
const sustainVol = Math.max(voiceVol * s, 0.0001);
// Envelope: start silent, ramp up, decay to sustain, hold, release to silence
// Use exponentialRamp throughout to avoid discontinuities
envGain.gain.setValueAtTime(0.0001, noteStart);
envGain.gain.exponentialRampToValueAtTime(Math.max(voiceVol, 0.0001), noteStart + a);
envGain.gain.exponentialRampToValueAtTime(sustainVol, noteStart + a + d);
// Hold sustain — cancelAndHoldAtTime not universal, so just let it ride
// Release: ramp down from wherever we are
envGain.gain.setTargetAtTime(0.0001, noteEnd, r / 5); // smooth exponential decay
osc.connect(envGain);
envGain.connect(filter);
osc.start(noteStart);
osc.stop(noteEnd + r + 0.1);
osc.onended = () => { osc.disconnect(); envGain.disconnect(); };
}
timeOffset = noteEnd + gap;
lastNoteEnd = noteEnd + (synthConfig.release || 0.2);
}
// Cleanup shared nodes
const cleanupMs = (lastNoteEnd - audioCtx.currentTime + 1) * 1000;
setTimeout(() => {
try { filter.disconnect(); limiter.disconnect(); panner.disconnect(); } catch (e) {}
}, cleanupMs);
return lastNoteEnd - audioCtx.currentTime;
}
MeshAudio.registerVoice('constellation', { name: 'constellation', play });
})();

214
public/audio.js Normal file
View File

@@ -0,0 +1,214 @@
// Mesh Audio Engine — public/audio.js
// Core audio infrastructure + swappable voice modules
// Each voice module is a separate file (audio-v1.js, audio-v2.js, etc.)
(function () {
'use strict';
// === Engine State ===
let audioEnabled = false;
let audioCtx = null;
let masterGain = null;
let bpm = 120;
let activeVoices = 0;
const MAX_VOICES = 12;
let currentVoice = null;
let _pendingVolume = 0.3; // active voice module
// === Shared Helpers (available to voice modules) ===
function buildScale(intervals, rootMidi) {
const notes = [];
for (let oct = 0; oct < 3; oct++) {
for (const interval of intervals) {
notes.push(rootMidi + oct * 12 + interval);
}
}
return notes;
}
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
function mapRange(value, inMin, inMax, outMin, outMax) {
return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin);
}
function quantizeToScale(byteVal, scale) {
const idx = Math.floor((byteVal / 256) * scale.length);
return scale[Math.min(idx, scale.length - 1)];
}
function tempoMultiplier() {
return 120 / bpm;
}
function parsePacketBytes(pkt) {
const rawHex = pkt.raw || pkt.raw_hex || (pkt.packet && pkt.packet.raw_hex) || '';
if (!rawHex || rawHex.length < 6) return null;
const allBytes = [];
for (let i = 0; i < rawHex.length; i += 2) {
const b = parseInt(rawHex.slice(i, i + 2), 16);
if (!isNaN(b)) allBytes.push(b);
}
if (allBytes.length < 3) return null;
const decoded = pkt.decoded || {};
const header = decoded.header || {};
const payload = decoded.payload || {};
const hops = decoded.path?.hops || [];
return {
allBytes,
headerBytes: allBytes.slice(0, 3),
payloadBytes: allBytes.slice(3),
typeName: header.payloadTypeName || 'UNKNOWN',
hopCount: Math.max(1, hops.length),
obsCount: pkt.observation_count || (pkt.packet && pkt.packet.observation_count) || 1,
payload,
hops,
};
}
// === Engine: Init ===
function initAudio() {
if (audioCtx) {
if (audioCtx.state === 'suspended') audioCtx.resume();
return;
}
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = audioCtx.createGain();
masterGain.gain.value = _pendingVolume;
masterGain.connect(audioCtx.destination);
}
// === Engine: Sonify ===
function sonifyPacket(pkt) {
if (!audioEnabled || !currentVoice) return;
if (!audioCtx) initAudio();
if (!audioCtx) return;
if (audioCtx.state === 'suspended') {
// Show unlock overlay if not already showing
_showUnlockOverlay();
return; // don't schedule notes on suspended context
}
if (activeVoices >= MAX_VOICES) return;
const parsed = parsePacketBytes(pkt);
if (!parsed || parsed.payloadBytes.length === 0) return;
activeVoices++;
try {
const duration = currentVoice.play(audioCtx, masterGain, parsed, {
bpm, tempoMultiplier: tempoMultiplier(),
});
// Release voice slot after estimated duration
const releaseMs = (duration || 3) * 1000 + 500;
setTimeout(() => { activeVoices = Math.max(0, activeVoices - 1); }, releaseMs);
} catch (e) {
activeVoices = Math.max(0, activeVoices - 1);
console.error('[audio] voice error:', e);
}
}
// === Voice Registration ===
function registerVoice(name, voiceModule) {
// voiceModule must have: { name, play(audioCtx, masterGain, parsed, opts) → durationSec }
if (!window._meshAudioVoices) window._meshAudioVoices = {};
window._meshAudioVoices[name] = voiceModule;
// Auto-select first registered voice if none active
if (!currentVoice) currentVoice = voiceModule;
}
function setVoice(name) {
if (window._meshAudioVoices && window._meshAudioVoices[name]) {
currentVoice = window._meshAudioVoices[name];
localStorage.setItem('live-audio-voice', name);
return true;
}
return false;
}
function getVoiceName() {
return currentVoice ? currentVoice.name : null;
}
function getVoiceNames() {
return Object.keys(window._meshAudioVoices || {});
}
// === Public API ===
function setEnabled(on) {
audioEnabled = on;
if (on) initAudio();
localStorage.setItem('live-audio-enabled', on);
}
function isEnabled() { return audioEnabled; }
function setBPM(val) {
bpm = Math.max(40, Math.min(300, val));
localStorage.setItem('live-audio-bpm', bpm);
}
function getBPM() { return bpm; }
function setVolume(val) {
if (masterGain) masterGain.gain.value = Math.max(0, Math.min(1, val));
localStorage.setItem('live-audio-volume', val);
}
function getVolume() { return masterGain ? masterGain.gain.value : 0.3; }
function restore() {
const saved = localStorage.getItem('live-audio-enabled');
if (saved === 'true') audioEnabled = true;
const savedBpm = localStorage.getItem('live-audio-bpm');
if (savedBpm) bpm = parseInt(savedBpm, 10) || 120;
const savedVol = localStorage.getItem('live-audio-volume');
if (savedVol) _pendingVolume = parseFloat(savedVol) || 0.3;
const savedVoice = localStorage.getItem('live-audio-voice');
if (savedVoice) setVoice(savedVoice);
// If audio was enabled, create context eagerly. If browser suspends it,
// the unlock overlay will appear when the first packet arrives.
if (audioEnabled) {
initAudio();
}
}
let _overlayShown = false;
function _showUnlockOverlay() {
if (_overlayShown) return;
_overlayShown = true;
const overlay = document.createElement('div');
overlay.className = 'audio-unlock-overlay';
overlay.innerHTML = '<div class="audio-unlock-prompt">🔊 Tap to enable audio</div>';
overlay.addEventListener('click', () => {
if (audioCtx) audioCtx.resume();
overlay.remove();
}, { once: true });
document.body.appendChild(overlay);
}
// Export engine + helpers for voice modules
window.MeshAudio = {
sonifyPacket,
setEnabled, isEnabled,
setBPM, getBPM,
setVolume, getVolume,
registerVoice, setVoice, getVoiceName, getVoiceNames,
restore,
getContext() { return audioCtx; },
// Helpers for voice modules
helpers: { buildScale, midiToFreq, mapRange, quantizeToScale },
};
})();

121
public/hop-display.js Normal file
View File

@@ -0,0 +1,121 @@
/* === MeshCore Analyzer — hop-display.js === */
/* Shared hop rendering with conflict info for all pages */
'use strict';
window.HopDisplay = (function() {
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Dismiss any open conflict popover
function dismissPopover() {
const old = document.querySelector('.hop-conflict-popover');
if (old) old.remove();
}
// Global click handler to dismiss popovers
let _listenerAttached = false;
function ensureGlobalListener() {
if (_listenerAttached) return;
_listenerAttached = true;
document.addEventListener('click', (e) => {
if (!e.target.closest('.hop-conflict-popover') && !e.target.closest('.hop-conflict-btn')) {
dismissPopover();
}
});
}
function showConflictPopover(btn, h, conflicts, globalFallback) {
dismissPopover();
ensureGlobalListener();
const regional = conflicts.filter(c => c.regional);
const shown = regional.length > 0 ? regional : conflicts;
let html = `<div class="hop-conflict-header">${escapeHtml(h)}${shown.length} candidate${shown.length > 1 ? 's' : ''}${regional.length > 0 ? ' in region' : ' (global fallback)'}</div>`;
html += '<div class="hop-conflict-list">';
for (const c of shown) {
const name = escapeHtml(c.name || c.pubkey?.slice(0, 16) || '?');
const dist = c.distKm != null ? `<span class="hop-conflict-dist">${c.distKm}km</span>` : '';
const pk = c.pubkey ? c.pubkey.slice(0, 12) + '…' : '';
html += `<a href="#/nodes/${encodeURIComponent(c.pubkey || '')}" class="hop-conflict-item">
<span class="hop-conflict-name">${name}</span>
${dist}
<span class="hop-conflict-pk">${pk}</span>
</a>`;
}
html += '</div>';
const popover = document.createElement('div');
popover.className = 'hop-conflict-popover';
popover.innerHTML = html;
document.body.appendChild(popover);
// Position near the button
const rect = btn.getBoundingClientRect();
popover.style.top = (rect.bottom + window.scrollY + 4) + 'px';
popover.style.left = Math.max(8, Math.min(rect.left + window.scrollX - 60, window.innerWidth - 280)) + 'px';
}
/**
* Render a hop prefix as HTML with conflict info.
*/
function renderHop(h, entry, opts) {
opts = opts || {};
if (!entry) entry = {};
if (typeof entry === 'string') entry = { name: entry };
const name = entry.name || null;
const pubkey = entry.pubkey || h;
const ambiguous = entry.ambiguous || false;
const conflicts = entry.conflicts || [];
const globalFallback = entry.globalFallback || false;
const unreliable = entry.unreliable || false;
const display = opts.hexMode ? h : (name ? escapeHtml(opts.truncate ? name.slice(0, opts.truncate) : name) : h);
// Simple title for the hop link itself
let title = h;
if (unreliable) title += ' — unreliable';
// Badge — only count regional conflicts
const regionalConflicts = conflicts.filter(c => c.regional);
const badgeCount = regionalConflicts.length > 0 ? regionalConflicts.length : (globalFallback ? conflicts.length : 0);
const conflictData = escapeHtml(JSON.stringify({ h, conflicts, globalFallback }));
const warnBadge = badgeCount > 1
? ` <button class="hop-conflict-btn" data-conflict='${conflictData}' onclick="event.preventDefault();event.stopPropagation();HopDisplay._showFromBtn(this)" title="${badgeCount} candidates — click for details">⚠${badgeCount}</button>`
: '';
const cls = [
'hop',
name ? 'hop-named' : '',
ambiguous ? 'hop-ambiguous' : '',
unreliable ? 'hop-unreliable' : '',
globalFallback ? 'hop-global-fallback' : '',
].filter(Boolean).join(' ');
if (opts.link !== false) {
return `<a class="${cls} hop-link" href="#/nodes/${encodeURIComponent(pubkey)}" title="${escapeHtml(title)}" data-hop-link="true">${display}</a>${warnBadge}`;
}
return `<span class="${cls}" title="${escapeHtml(title)}">${display}</span>${warnBadge}`;
}
/**
* Render a full path as HTML.
*/
function renderPath(hops, cache, opts) {
opts = opts || {};
const sep = opts.separator || ' → ';
if (!hops || !hops.length) return '—';
return hops.filter(Boolean).map(h => renderHop(h, cache[h], opts)).join(sep);
}
// Called from inline onclick
function _showFromBtn(btn) {
try {
const data = JSON.parse(btn.dataset.conflict);
showConflictPopover(btn, data.h, data.conflicts, data.globalFallback);
} catch (e) { console.error('Conflict popover error:', e); }
}
return { renderHop, renderPath, _showFromBtn };
})();

View File

@@ -6,18 +6,32 @@ window.HopResolver = (function() {
'use strict';
const MAX_HOP_DIST = 1.8; // ~200km in degrees
const REGION_RADIUS_KM = 300;
let prefixIdx = {}; // lowercase hex prefix → [node, ...]
let nodesList = [];
let observerIataMap = {}; // observer_id → iata
let iataCoords = {}; // iata → {lat, lon}
function dist(lat1, lon1, lat2, lon2) {
return Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
}
function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Initialize (or rebuild) the prefix index from the full nodes list.
* @param {Array} nodes - Array of {public_key, name, lat, lon, ...}
* @param {Object} [opts] - Optional: { observers: [{id, iata}], iataCoords: {code: {lat,lon}} }
*/
function init(nodes) {
function init(nodes, opts) {
nodesList = nodes || [];
prefixIdx = {};
for (const n of nodesList) {
@@ -29,6 +43,28 @@ window.HopResolver = (function() {
prefixIdx[p].push(n);
}
}
// Store observer IATA mapping and coords if provided
observerIataMap = {};
if (opts && opts.observers) {
for (const o of opts.observers) {
if (o.id && o.iata) observerIataMap[o.id] = o.iata;
}
}
iataCoords = (opts && opts.iataCoords) || (window.IATA_COORDS_GEO) || {};
}
/**
* Check if a node is near an IATA region center.
* Returns { near, method, distKm } or null.
*/
function nodeInRegion(candidate, iata) {
const center = iataCoords[iata];
if (!center) return null;
if (candidate.lat && candidate.lon && !(candidate.lat === 0 && candidate.lon === 0)) {
const d = haversineKm(candidate.lat, candidate.lon, center.lat, center.lon);
return { near: d <= REGION_RADIUS_KM, method: 'geo', distKm: Math.round(d) };
}
return null; // no GPS — can't geo-filter client-side
}
/**
@@ -42,22 +78,51 @@ window.HopResolver = (function() {
* @param {number|null} observerLon - Observer longitude (backward anchor)
* @returns {Object} resolved map keyed by hop prefix
*/
function resolve(hops, originLat, originLon, observerLat, observerLon) {
function resolve(hops, originLat, originLon, observerLat, observerLon, observerId) {
if (!hops || !hops.length) return {};
// Determine observer's IATA for regional filtering
const packetIata = observerId ? observerIataMap[observerId] : null;
const resolved = {};
const hopPositions = {};
// First pass: find candidates
// First pass: find candidates with regional filtering
for (const hop of hops) {
const h = hop.toLowerCase();
const candidates = prefixIdx[h] || [];
if (candidates.length === 0) {
resolved[hop] = { name: null, candidates: [] };
} else if (candidates.length === 1) {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, candidates: [{ name: candidates[0].name, pubkey: candidates[0].public_key }] };
const allCandidates = prefixIdx[h] || [];
if (allCandidates.length === 0) {
resolved[hop] = { name: null, candidates: [], conflicts: [] };
} else if (allCandidates.length === 1) {
const c = allCandidates[0];
const regionCheck = packetIata ? nodeInRegion(c, packetIata) : null;
resolved[hop] = { name: c.name, pubkey: c.public_key,
candidates: [{ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon, regional: regionCheck ? regionCheck.near : false, filterMethod: regionCheck ? regionCheck.method : 'none', distKm: regionCheck ? regionCheck.distKm : undefined }],
conflicts: [] };
} else {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, ambiguous: true, candidates: candidates.map(c => ({ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon })) };
// Multiple candidates — apply geo regional filtering
const checked = allCandidates.map(c => {
const r = packetIata ? nodeInRegion(c, packetIata) : null;
return { ...c, regional: r ? r.near : false, filterMethod: r ? r.method : 'none', distKm: r ? r.distKm : undefined };
});
const regional = checked.filter(c => c.regional);
regional.sort((a, b) => (a.distKm || 9999) - (b.distKm || 9999));
const candidates = regional.length > 0 ? regional : checked;
const globalFallback = regional.length === 0 && checked.length > 0 && packetIata != null;
const conflicts = candidates.map(c => ({
name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon,
regional: c.regional, filterMethod: c.filterMethod, distKm: c.distKm
}));
if (candidates.length === 1) {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key,
candidates: conflicts, conflicts, globalFallback };
} else {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key,
ambiguous: true, candidates: conflicts, conflicts, globalFallback,
hopBytes: Math.ceil(hop.length / 2), totalGlobal: allCandidates.length, totalRegional: regional.length };
}
}
}

View File

@@ -22,7 +22,7 @@
<meta name="twitter:title" content="MeshCore Analyzer">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774164970">
<link rel="stylesheet" href="style.css?v=1774221932">
<link rel="stylesheet" href="home.css">
<link rel="stylesheet" href="live.css?v=1774058575">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
@@ -54,6 +54,7 @@
<a href="#/observers" class="nav-link" data-route="observers">Observers</a>
<a href="#/analytics" class="nav-link" data-route="analytics">Analytics</a>
<a href="#/perf" class="nav-link" data-route="perf">⚡ Perf</a>
<a href="#/audio-lab" class="nav-link" data-route="audio-lab">🎵 Lab</a>
</div>
</div>
<div class="nav-right">
@@ -79,20 +80,24 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774290000"></script>
<script src="region-filter.js?v=1774136865"></script>
<script src="hop-resolver.js?v=1774126708"></script>
<script src="roles.js?v=1774325000"></script>
<script src="region-filter.js?v=1774325000"></script>
<script src="hop-resolver.js?v=1774223973"></script>
<script src="hop-display.js?v=1774221932"></script>
<script src="app.js?v=1774126708"></script>
<script src="home.js?v=1774042199"></script>
<script src="packets.js?v=1774167561"></script>
<script src="map.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
<script src="packets.js?v=1774225004"></script>
<script src="map.js?v=1774220756" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774331200" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774221131" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774135052" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774167561" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774208460" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774207165" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774208460" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774218049" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774290000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774219440" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1773985649" onerror="console.error('Failed to load:', this.src)"></script>
</body>

View File

@@ -8,8 +8,6 @@
let activeAnims = 0;
let nodeActivity = {};
let recentPaths = [];
let audioCtx = null;
let soundEnabled = false;
let showGhostHops = localStorage.getItem('live-ghost-hops') !== 'false';
let realisticPropagation = localStorage.getItem('live-realistic-propagation') === 'true';
let showOnlyFavorites = localStorage.getItem('live-favorites-only') === 'true';
@@ -48,21 +46,6 @@
REQUEST: '❓', RESPONSE: '📨', TRACE: '🔍', PATH: '🛤️'
};
function playSound(typeName) {
if (!soundEnabled || !audioCtx) return;
try {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain); gain.connect(audioCtx.destination);
const freqs = { ADVERT: 880, GRP_TXT: 523, TXT_MSG: 659, ACK: 330, REQUEST: 740, TRACE: 987 };
osc.frequency.value = freqs[typeName] || 440;
osc.type = typeName === 'GRP_TXT' ? 'sine' : typeName === 'ADVERT' ? 'triangle' : 'square';
gain.gain.setValueAtTime(0.03, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
osc.start(audioCtx.currentTime); osc.stop(audioCtx.currentTime + 0.15);
} catch {}
}
function initResizeHandler() {
let resizeTimer = null;
_onResize = function() {
@@ -627,7 +610,6 @@
<div class="live-stat-pill anim-pill"><span id="liveAnimCount">0</span> active</div>
<div class="live-stat-pill rate-pill"><span id="livePktRate">0</span>/min</div>
</div>
<button class="live-sound-btn" id="liveSoundBtn" title="Toggle sound">🔇</button>
<div class="live-toggles">
<label><input type="checkbox" id="liveHeatToggle" checked aria-describedby="heatDesc"> Heat</label>
<span id="heatDesc" class="sr-only">Overlay a density heat map on the mesh nodes</span>
@@ -639,9 +621,16 @@
<span id="matrixDesc" class="sr-only">Animate packet hex bytes flowing along paths like the Matrix</span>
<label><input type="checkbox" id="liveMatrixRainToggle" aria-describedby="rainDesc"> Rain</label>
<span id="rainDesc" class="sr-only">Matrix rain overlay — packets fall as hex columns</span>
<label><input type="checkbox" id="liveAudioToggle" aria-describedby="audioDesc"> 🎵 Audio</label>
<span id="audioDesc" class="sr-only">Sonify packets — turn raw bytes into generative music</span>
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> ⭐ Favorites</label>
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
</div>
<div class="audio-controls hidden" id="audioControls">
<label class="audio-slider-label">Voice <select id="audioVoiceSelect" class="audio-voice-select"></select></label>
<label class="audio-slider-label">BPM <input type="range" id="audioBpmSlider" min="40" max="300" value="120" class="audio-slider"><span id="audioBpmVal">120</span></label>
<label class="audio-slider-label">Vol <input type="range" id="audioVolSlider" min="0" max="100" value="30" class="audio-slider"><span id="audioVolVal">30</span></label>
</div>
</div>
<div class="live-overlay live-feed" id="liveFeed">
<button class="feed-hide-btn" id="feedHideBtn" title="Hide feed">✕</button>
@@ -760,13 +749,6 @@
map.on('zoomend', rescaleMarkers);
// Sound toggle
document.getElementById('liveSoundBtn').addEventListener('click', () => {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
soundEnabled = !soundEnabled;
document.getElementById('liveSoundBtn').textContent = soundEnabled ? '🔊' : '🔇';
});
// Heat map toggle
document.getElementById('liveHeatToggle').addEventListener('change', (e) => {
if (e.target.checked) showHeatMap(); else hideHeatMap();
@@ -825,6 +807,52 @@
});
if (matrixRain) startMatrixRain();
// Audio toggle
const audioToggle = document.getElementById('liveAudioToggle');
const audioControls = document.getElementById('audioControls');
const bpmSlider = document.getElementById('audioBpmSlider');
const bpmVal = document.getElementById('audioBpmVal');
const volSlider = document.getElementById('audioVolSlider');
const volVal = document.getElementById('audioVolVal');
if (window.MeshAudio) {
MeshAudio.restore();
audioToggle.checked = MeshAudio.isEnabled();
if (MeshAudio.isEnabled()) audioControls.classList.remove('hidden');
bpmSlider.value = MeshAudio.getBPM();
bpmVal.textContent = MeshAudio.getBPM();
volSlider.value = Math.round(MeshAudio.getVolume() * 100);
volVal.textContent = Math.round(MeshAudio.getVolume() * 100);
// Populate voice selector
const voiceSelect = document.getElementById('audioVoiceSelect');
const voices = MeshAudio.getVoiceNames();
voices.forEach(v => {
const opt = document.createElement('option');
opt.value = v; opt.textContent = v;
voiceSelect.appendChild(opt);
});
voiceSelect.value = MeshAudio.getVoiceName() || voices[0] || '';
voiceSelect.addEventListener('change', (e) => MeshAudio.setVoice(e.target.value));
}
audioToggle.addEventListener('change', (e) => {
if (window.MeshAudio) {
MeshAudio.setEnabled(e.target.checked);
audioControls.classList.toggle('hidden', !e.target.checked);
}
});
bpmSlider.addEventListener('input', (e) => {
const v = parseInt(e.target.value, 10);
bpmVal.textContent = v;
if (window.MeshAudio) MeshAudio.setBPM(v);
});
volSlider.addEventListener('input', (e) => {
const v = parseInt(e.target.value, 10);
volVal.textContent = v;
if (window.MeshAudio) MeshAudio.setVolume(v / 100);
});
// Feed show/hide
const feedEl = document.getElementById('liveFeed');
// Keyboard support for feed items (event delegation)
@@ -1141,9 +1169,10 @@
</table>`;
if (observers.length) {
html += `<h4 style="font-size:12px;margin:12px 0 6px;color:var(--text-muted);">Heard By</h4>
const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))];
html += `<h4 style="font-size:12px;margin:12px 0 6px;color:var(--text-muted);">Heard By${regions.length ? ' — Regions: ' + regions.join(', ') : ''}</h4>
<div style="font-size:11px;">` +
observers.map(o => `<div style="padding:2px 0;"><a href="#/observers/${encodeURIComponent(o.observer_id)}" style="color:var(--accent);text-decoration:none;">${escapeHtml(o.observer_name || o.observer_id.slice(0, 12))}</a> — ${o.packetCount || o.count || 0} pkts</div>`).join('') +
observers.map(o => `<div style="padding:2px 0;"><a href="#/observers/${encodeURIComponent(o.observer_id)}" style="color:var(--accent);text-decoration:none;">${escapeHtml(o.observer_name || o.observer_id.slice(0, 12))}${o.iata ? ' (' + escapeHtml(o.iata) + ')' : ''}</a> — ${o.packetCount || o.count || 0} pkts</div>`).join('') +
'</div>';
}
@@ -1393,7 +1422,7 @@
const hops = decoded.path?.hops || [];
const color = TYPE_COLORS[typeName] || '#6b7280';
playSound(typeName);
if (window.MeshAudio) MeshAudio.sonifyPacket(pkt);
addFeedItem(icon, typeName, payload, hops, color, pkt);
addRainDrop(pkt);
// Spawn extra rain columns for multiple observations with varied hop counts
@@ -1441,7 +1470,11 @@
// Favorites filter: skip if none of the packets involve a favorite
if (showOnlyFavorites && !packets.some(p => packetInvolvesFavorite(p))) return;
playSound(typeName);
const consolidated = Object.assign({}, first, { observation_count: packets.length });
if (window.MeshAudio) MeshAudio.sonifyPacket(consolidated);
// Add single consolidated feed item for the group
const allHops = (decoded.path?.hops) || [];
addFeedItem(icon, typeName, payload, allHops, color, consolidated);
// Rain drop per observation in the group
packets.forEach((p, i) => setTimeout(() => addRainDrop(p), i * 150));

View File

@@ -7,6 +7,7 @@
let markerLayer = null;
let clusterGroup = null;
let nodes = [];
let targetNodeKey = null;
let observers = [];
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false, hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false' };
let wsHandler = null;
@@ -126,12 +127,22 @@
} catch {}
let initCenter = defaultCenter;
let initZoom = defaultZoom;
const savedView = localStorage.getItem('map-view');
if (savedView) {
try { const v = JSON.parse(savedView); initCenter = [v.lat, v.lng]; initZoom = v.zoom; } catch {}
// Check URL query params first (from packet detail links)
const urlParams = new URLSearchParams(location.hash.split('?')[1] || '');
if (urlParams.get('lat') && urlParams.get('lon')) {
initCenter = [parseFloat(urlParams.get('lat')), parseFloat(urlParams.get('lon'))];
initZoom = parseInt(urlParams.get('zoom')) || 12;
} else {
const savedView = localStorage.getItem('map-view');
if (savedView) {
try { const v = JSON.parse(savedView); initCenter = [v.lat, v.lng]; initZoom = v.zoom; } catch {}
}
}
map = L.map('leaflet-map', { zoomControl: true }).setView(initCenter, initZoom);
// If navigated with ?node=PUBKEY, highlight that node after markers load
targetNodeKey = urlParams.get('node') || null;
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, {
@@ -368,6 +379,27 @@
buildJumpButtons();
renderMarkers();
// If navigated with ?node=PUBKEY, center on and highlight that node
if (targetNodeKey) {
const targetNode = nodes.find(n => n.public_key === targetNodeKey);
if (targetNode && targetNode.lat && targetNode.lon) {
map.setView([targetNode.lat, targetNode.lon], 14);
// Delay popup open slightly — Leaflet needs the map to settle after setView
setTimeout(() => {
let found = false;
markerLayer.eachLayer(m => {
if (found) return;
if (m._nodeKey === targetNodeKey && m.openPopup) {
m.openPopup();
found = true;
}
});
if (!found) console.warn('[map] Target node marker not found:', targetNodeKey);
}, 500);
}
}
// Don't fitBounds on initial load — respect the Bay Area default or saved view
// Only fitBounds on subsequent data refreshes if user hasn't manually panned
} catch (e) {
@@ -537,6 +569,7 @@
for (const m of allMarkers) {
const pos = m.adjustedLatLng || m.latLng;
const marker = L.marker(pos, { icon: m.icon, alt: m.alt });
marker._nodeKey = m.node.public_key || m.node.id || null;
marker.bindPopup(m.popupFn(), { maxWidth: 280 });
markerLayer.addLayer(marker);

View File

@@ -143,12 +143,14 @@
</div>
${observers.length ? `<div class="node-full-card">
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:8px"><strong>Regions:</strong> ${regions.map(r => '<span class="badge" style="margin:0 2px">' + escapeHtml(r) + '</span>').join(' ')}</div>` : ''; })()}
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
<table class="data-table" style="font-size:12px">
<thead><tr><th>Observer</th><th>Packets</th><th>Avg SNR</th><th>Avg RSSI</th></tr></thead>
<thead><tr><th>Observer</th><th>Region</th><th>Packets</th><th>Avg SNR</th><th>Avg RSSI</th></tr></thead>
<tbody>
${observers.map(o => `<tr>
<td style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</td>
<td>${o.iata ? escapeHtml(o.iata) : '—'}</td>
<td>${o.packetCount}</td>
<td>${o.avgSnr != null ? o.avgSnr.toFixed(1) + ' dB' : '—'}</td>
<td>${o.avgRssi != null ? o.avgRssi.toFixed(0) + ' dBm' : '—'}</td>
@@ -233,6 +235,11 @@
return paths.map(p => {
const chain = p.hops.map(h => {
const isThis = h.pubkey === n.public_key;
if (window.HopDisplay) {
const entry = { name: h.name, pubkey: h.pubkey, ambiguous: h.ambiguous, conflicts: h.conflicts, totalGlobal: h.totalGlobal, totalRegional: h.totalRegional, globalFallback: h.globalFallback, unreliable: h.unreliable };
const html = HopDisplay.renderHop(h.prefix, entry);
return isThis ? html.replace('class="', 'class="hop-current ') : html;
}
const name = escapeHtml(h.name || h.prefix);
const link = h.pubkey ? `<a href="#/nodes/${encodeURIComponent(h.pubkey)}" style="${isThis ? 'font-weight:700;color:var(--accent, #3b82f6)' : ''}">${name}</a>` : `<span>${name}</span>`;
return link;
@@ -518,10 +525,11 @@
</div>
${observers.length ? `<div class="node-detail-section">
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:6px;font-size:12px"><strong>Regions:</strong> ${regions.join(', ')}</div>` : ''; })()}
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
<div class="observer-list">
${observers.map(o => `<div class="observer-row" style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px">
<span style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</span>
<span style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}${o.iata ? ' <span class="badge" style="font-size:10px">' + escapeHtml(o.iata) + '</span>' : ''}</span>
<span style="color:var(--text-muted)">${o.packetCount} pkts · ${o.avgSnr != null ? 'SNR ' + o.avgSnr.toFixed(1) + 'dB' : ''}${o.avgRssi != null ? ' · RSSI ' + o.avgRssi.toFixed(0) : ''}</span>
</div>`).join('')}
</div>

View File

@@ -304,7 +304,7 @@
const decoded = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : (p.decoded_json || {});
const hops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : (p.path_json || []);
const typeName = PAYLOAD_LABELS[p.payload_type] || 'Type ' + p.payload_type;
return `<tr style="cursor:pointer" onclick="location.hash='#/packet/${p.id}'">
return `<tr style="cursor:pointer" onclick="location.hash='#/packets/${p.hash || p.id}'">
<td>${timeAgo(p.timestamp)}</td>
<td>${typeName}</td>
<td class="mono" style="font-size:0.85em">${(p.hash || '').substring(0, 10)}</td>

View File

@@ -3,12 +3,14 @@
(function () {
let packets = [];
let hashIndex = new Map(); // hash → packet group for O(1) dedup
// Resolve observer_id to friendly name from loaded observers list
function obsName(id) {
if (!id) return '—';
const o = observers.find(ob => ob.id === id);
return o?.name || id;
if (!o) return id;
return o.iata ? `${o.name} (${o.iata})` : o.name;
}
let selectedId = null;
let groupByHash = true;
@@ -98,12 +100,19 @@
}, { passive: false });
}
// Ensure HopResolver is initialized with the nodes list
// Ensure HopResolver is initialized with the nodes list + observer IATA data
async function ensureHopResolver() {
if (!HopResolver.ready()) {
try {
const data = await api('/nodes?limit=2000', { ttl: 60000 });
HopResolver.init(data.nodes || []);
const [nodeData, obsData, coordData] = await Promise.all([
api('/nodes?limit=2000', { ttl: 60000 }),
api('/observers', { ttl: 60000 }),
api('/iata-coords', { ttl: 300000 }).catch(() => ({ coords: {} })),
]);
HopResolver.init(nodeData.nodes || [], {
observers: obsData.observers || obsData || [],
iataCoords: coordData.coords || {},
});
} catch {}
}
}
@@ -120,24 +129,16 @@
}
}
function renderHop(h) {
if (showHexHashes) {
return `<span class="hop">${escapeHtml(h)}</span>`;
}
const entry = hopNameCache[h];
const name = entry ? (typeof entry === 'string' ? entry : entry.name) : null;
const pubkey = entry?.pubkey || h;
const ambiguous = entry?.ambiguous || false;
const display = name ? escapeHtml(name) : h;
const title = ambiguous
? `${h} — ⚠ ${entry.candidates.length} matches: ${entry.candidates.map(c => c.name).join(', ')}`
: h;
return `<a class="hop hop-link ${name ? 'hop-named' : ''} ${ambiguous ? 'hop-ambiguous' : ''}" href="#/nodes/${encodeURIComponent(pubkey)}" title="${title}" data-hop-link="true">${display}${ambiguous ? '<span class="hop-warn">⚠</span>' : ''}</a>`;
function renderHop(h, observerId) {
// Use per-packet cache key if observer context available (ambiguous hops differ by region)
const cacheKey = observerId ? h + ':' + observerId : h;
const entry = hopNameCache[cacheKey] || hopNameCache[h];
return HopDisplay.renderHop(h, entry, { hexMode: showHexHashes });
}
function renderPath(hops) {
function renderPath(hops, observerId) {
if (!hops || !hops.length) return '—';
return hops.map(renderHop).join('<span class="arrow">→</span>');
return hops.map(h => renderHop(h, observerId)).join('<span class="arrow">→</span>');
}
let directPacketId = null;
@@ -255,7 +256,7 @@
const newHops = hops.filter(h => !(h in hopNameCache));
if (newHops.length) await resolveHops(newHops);
} catch {}
renderDetail(content, data);
await renderDetail(content, data);
initPanelResize();
}
} catch {}
@@ -297,7 +298,7 @@
// Update existing groups or create new ones
for (const p of filtered) {
const h = p.hash;
const existing = packets.find(g => g.hash === h);
const existing = hashIndex.get(h);
if (existing) {
existing.count = (existing.count || 1) + 1;
existing.observation_count = (existing.observation_count || 1) + 1;
@@ -316,7 +317,7 @@
}
} else {
// New group
packets.unshift({
const newGroup = {
hash: h,
count: 1,
observer_count: 1,
@@ -327,7 +328,9 @@
payload_type: p.payload_type,
raw_hex: p.raw_hex,
decoded_json: p.decoded_json,
});
};
packets.unshift(newGroup);
if (h) hashIndex.set(h, newGroup);
}
}
// Re-sort by latest DESC, cap size
@@ -347,7 +350,7 @@
if (wsHandler) offWS(wsHandler);
wsHandler = null;
packets = [];
selectedId = null;
hashIndex = new Map(); selectedId = null;
filtersBuilt = false;
delete filters.node;
expandedHashes = new Set();
@@ -372,7 +375,7 @@
try {
const params = new URLSearchParams();
const windowMin = Number(document.getElementById('fTimeWindow')?.value || 15);
if (windowMin > 0) {
if (windowMin > 0 && !filters.hash) {
const since = new Date(Date.now() - windowMin * 60000).toISOString();
params.set('since', since);
}
@@ -385,6 +388,8 @@
const data = await api('/packets?' + params.toString());
packets = data.packets || [];
hashIndex = new Map();
for (const p of packets) { if (p.hash) hashIndex.set(p.hash, p); }
totalCount = data.total || packets.length;
// When ungrouped, fetch observations for all multi-obs packets and flatten
@@ -416,6 +421,32 @@
}
if (allHops.size) await resolveHops([...allHops]);
// Per-observer batch resolve for ambiguous hops (context-aware disambiguation)
const hopsByObserver = {};
for (const p of packets) {
if (!p.observer_id) continue;
try {
const path = JSON.parse(p.path_json || '[]');
const ambiguous = path.filter(h => hopNameCache[h]?.ambiguous);
if (ambiguous.length) {
if (!hopsByObserver[p.observer_id]) hopsByObserver[p.observer_id] = new Set();
ambiguous.forEach(h => hopsByObserver[p.observer_id].add(h));
}
} catch {}
}
// Batch resolve — one API call per observer (typically 4-5 observers)
await Promise.all(Object.entries(hopsByObserver).map(async ([obsId, hopsSet]) => {
try {
const params = new URLSearchParams({ hops: [...hopsSet].join(','), observer: obsId });
const result = await api(`/resolve-hops?${params}`);
if (result?.resolved) {
for (const [k, v] of Object.entries(result.resolved)) {
hopNameCache[k + ':' + obsId] = v;
}
}
} catch {}
}));
// Restore expanded group children
if (groupByHash && expandedHashes.size > 0) {
for (const hash of expandedHashes) {
@@ -925,7 +956,7 @@
const groupRegion = headerObserverId ? (observers.find(o => o.id === headerObserverId)?.iata || '') : '';
let groupPath = [];
try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {}
const groupPathStr = renderPath(groupPath);
const groupPathStr = renderPath(groupPath, headerObserverId);
const groupTypeName = payloadTypeName(p.payload_type);
const groupTypeClass = payloadTypeColor(p.payload_type);
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
@@ -957,7 +988,7 @@
const childRegion = c.observer_id ? (observers.find(o => o.id === c.observer_id)?.iata || '') : '';
let childPath = [];
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
const childPathStr = renderPath(childPath);
const childPathStr = renderPath(childPath, child.observer_id);
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" tabindex="0" role="row">
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
<td class="col-time">${timeAgo(c.timestamp)}</td>
@@ -985,8 +1016,7 @@
const typeName = payloadTypeName(p.payload_type);
const typeClass = payloadTypeColor(p.payload_type);
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const pathStr = renderPath(pathHops);
const detail = getDetailPreview(decoded);
const pathStr = renderPath(pathHops, p.observer_id); const detail = getDetailPreview(decoded);
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
@@ -1082,14 +1112,14 @@
panel.innerHTML = isMobileNow ? '' : '<div class="panel-resize-handle" id="pktResizeHandle"></div>';
const content = document.createElement('div');
panel.appendChild(content);
renderDetail(content, data);
await renderDetail(content, data);
if (!isMobileNow) initPanelResize();
} catch (e) {
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
}
}
function renderDetail(panel, data) {
async function renderDetail(panel, data) {
const pkt = data.packet;
const breakdown = data.breakdown || {};
const ranges = breakdown.ranges || [];
@@ -1098,6 +1128,44 @@
let pathHops;
try { pathHops = JSON.parse(pkt.path_json || '[]'); } catch { pathHops = []; }
// Resolve sender GPS — from packet directly, or from known node in DB
let senderLat = decoded.lat != null ? decoded.lat : (decoded.latitude || null);
let senderLon = decoded.lon != null ? decoded.lon : (decoded.longitude || null);
if (senderLat == null) {
// Try to find sender node GPS from DB
const senderKey = decoded.pubKey || decoded.srcPubKey;
const senderName = decoded.sender || decoded.name;
try {
if (senderKey) {
const nd = await api(`/nodes/${senderKey}`, { ttl: 30000 }).catch(() => null);
if (nd?.node?.lat && nd.node.lon) { senderLat = nd.node.lat; senderLon = nd.node.lon; }
}
if (senderLat == null && senderName) {
const sd = await api(`/nodes/search?q=${encodeURIComponent(senderName)}`, { ttl: 30000 }).catch(() => null);
const match = sd?.nodes?.[0];
if (match?.lat && match.lon) { senderLat = match.lat; senderLon = match.lon; }
}
} catch {}
}
// Re-resolve hops using SERVER-SIDE API with sender GPS + observer
if (pathHops.length) {
try {
const params = new URLSearchParams({ hops: pathHops.join(',') });
if (pkt.observer_id) params.set('observer', pkt.observer_id);
if (senderLat != null) params.set('originLat', senderLat);
if (senderLon != null) params.set('originLon', senderLon);
const serverResolved = await api(`/resolve-hops?${params}`);
if (serverResolved?.resolved) {
for (const [k, v] of Object.entries(serverResolved.resolved)) {
hopNameCache[k] = v;
// Also store observer-scoped key for list view
if (pkt.observer_id) hopNameCache[k + ':' + pkt.observer_id] = v;
}
}
} catch {}
}
// Parse hash size from path byte
const rawPathByte = pkt.raw_hex ? parseInt(pkt.raw_hex.slice(2, 4), 16) : NaN;
const hashSize = isNaN(rawPathByte) ? null : ((rawPathByte >> 6) + 1);
@@ -1145,19 +1213,56 @@
}
}
// Location: from ADVERT lat/lon, or from known node via pubkey/sender name
let locationHtml = '—';
let locationNodeKey = null;
if (decoded.lat != null && decoded.lon != null && !(decoded.lat === 0 && decoded.lon === 0)) {
locationNodeKey = decoded.pubKey || decoded.srcPubKey || '';
const nodeName = decoded.name || '';
locationHtml = `${decoded.lat.toFixed(5)}, ${decoded.lon.toFixed(5)}`;
if (nodeName) locationHtml = `${escapeHtml(nodeName)}${locationHtml}`;
if (locationNodeKey) locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" style="font-size:0.85em">📍map</a>`;
} else {
// Try to resolve sender node location from nodes list
const senderKey = decoded.pubKey || decoded.srcPubKey;
const senderName = decoded.sender || decoded.name;
if (senderKey || senderName) {
try {
const nodeData = senderKey ? await api(`/nodes/${senderKey}`, { ttl: 30000 }).catch(() => null) : null;
if (nodeData && nodeData.node && nodeData.node.lat && nodeData.node.lon) {
locationNodeKey = nodeData.node.public_key;
locationHtml = `${nodeData.node.lat.toFixed(5)}, ${nodeData.node.lon.toFixed(5)}`;
if (nodeData.node.name) locationHtml = `${escapeHtml(nodeData.node.name)}${locationHtml}`;
locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" style="font-size:0.85em">📍map</a>`;
} else if (senderName && !senderKey) {
// Search by name
const searchData = await api(`/nodes/search?q=${encodeURIComponent(senderName)}`, { ttl: 30000 }).catch(() => null);
const match = searchData && searchData.nodes && searchData.nodes[0];
if (match && match.lat && match.lon) {
locationNodeKey = match.public_key;
locationHtml = `${match.lat.toFixed(5)}, ${match.lon.toFixed(5)}`;
locationHtml = `${escapeHtml(match.name)}${locationHtml}`;
locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" style="font-size:0.85em">📍map</a>`;
}
}
} catch {}
}
}
panel.innerHTML = `
<div class="detail-title">${hasRawHex ? `Packet Byte Breakdown (${size} bytes)` : typeName + ' Packet'}</div>
<div class="detail-hash">${pkt.hash || 'Packet #' + pkt.id}</div>
${messageHtml}
<dl class="detail-meta">
<dt>Observer</dt><dd>${obsName(pkt.observer_id)}</dd>
<dt>Location</dt><dd>${locationHtml}</dd>
<dt>SNR / RSSI</dt><dd>${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}</dd>
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
${hashSize ? `<dt>Hash Size</dt><dd>${hashSize} byte${hashSize !== 1 ? 's' : ''}</dd>` : ''}
<dt>Timestamp</dt><dd>${pkt.timestamp}</dd>
<dt>Propagation</dt><dd>${propagationHtml}</dd>
<dt>Path</dt><dd>${pathHops.length ? renderPath(pathHops) : '—'}</dd>
<dt>Path</dt><dd>${pathHops.length ? renderPath(pathHops, pkt.observer_id) : '—'}</dd>
</dl>
<div class="detail-actions">
<button class="copy-link-btn" data-packet-hash="${pkt.hash || ''}" data-packet-id="${pkt.id}" title="Copy link to this packet">🔗 Copy Link</button>
@@ -1236,7 +1341,7 @@
// Try to find observer in nodes list by name — best effort
}
await ensureHopResolver();
const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon) };
const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon, pkt.observer_id) };
// Pass full pubkeys (client-disambiguated) to map, falling back to short prefix
const resolvedKeys = pathHops.map(h => {
const r = data.resolved?.[h];
@@ -1297,13 +1402,8 @@
const pathByte = parseInt(buf.slice(2, 4), 16);
const hashSize = (pathByte >> 6) + 1;
for (let i = 0; i < pathHops.length; i++) {
const hopEntry = hopNameCache[pathHops[i]];
const hopName = hopEntry ? (typeof hopEntry === 'string' ? hopEntry : hopEntry.name) : null;
const hopPubkey = hopEntry?.pubkey || pathHops[i];
const nameHtml = hopName
? `<a href="#/nodes/${encodeURIComponent(hopPubkey)}" class="hop-link hop-named" data-hop-link="true">${escapeHtml(hopName)}</a>${hopEntry?.ambiguous ? ' ⚠' : ''}`
: '';
const label = hopName ? `Hop ${i}${nameHtml}` : `Hop ${i}`;
const hopHtml = HopDisplay.renderHop(pathHops[i], hopNameCache[pathHops[i]]);
const label = `Hop ${i}${hopHtml}`;
rows += fieldRow(off + i * hashSize, label, pathHops[i], '');
}
off += hashSize * pathHops.length;
@@ -1603,6 +1703,7 @@
const param = routeParam;
app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:20px"><div class="text-center text-muted" style="padding:40px">Loading packet…</div></div>`;
try {
await loadObservers();
const data = await api(`/packets/${param}`);
if (!data?.packet) { app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:40px;text-align:center"><h2>Packet not found</h2><p>Packet ${param} doesn't exist.</p><a href="#/packets">← Back to packets</a></div>`; return; }
const hops = [];
@@ -1614,7 +1715,7 @@
container.innerHTML = `<div style="margin-bottom:16px"><a href="#/packets" style="color:var(--primary);text-decoration:none">← Back to packets</a></div>`;
const detail = document.createElement('div');
container.appendChild(detail);
renderDetail(detail, data);
await renderDetail(detail, data);
app.innerHTML = '';
app.appendChild(container);
} catch (e) {

View File

@@ -127,7 +127,9 @@
html += '<label class="region-dropdown-item"><input type="checkbox" data-region="__all__"' +
(allSelected ? ' checked' : '') + '> <strong>All</strong></label>';
codes.forEach(function (code) {
var label = _regions[code] ? (code + ' - ' + _regions[code]) : code;
var configLabel = _regions[code];
var cityName = configLabel || (window.IATA_CITIES && window.IATA_CITIES[code]);
var label = cityName ? (code + ' - ' + cityName) : code;
var active = allSelected || (_selected && _selected.has(code));
html += '<label class="region-dropdown-item"><input type="checkbox" data-region="' + code + '"' +
(active ? ' checked' : '') + '> ' + label + '</label>';

View File

@@ -128,4 +128,179 @@
if (ROLE_COLORS[role]) ROLE_STYLE[role].color = ROLE_COLORS[role];
}
}).catch(function () { /* use defaults */ });
// ─── Built-in IATA airport code → city name mapping ───
window.IATA_CITIES = {
// United States
'SEA': 'Seattle, WA',
'SFO': 'San Francisco, CA',
'PDX': 'Portland, OR',
'LAX': 'Los Angeles, CA',
'DEN': 'Denver, CO',
'SLC': 'Salt Lake City, UT',
'PHX': 'Phoenix, AZ',
'DFW': 'Dallas, TX',
'ATL': 'Atlanta, GA',
'ORD': 'Chicago, IL',
'JFK': 'New York, NY',
'LGA': 'New York, NY',
'BOS': 'Boston, MA',
'MIA': 'Miami, FL',
'FLL': 'Fort Lauderdale, FL',
'IAH': 'Houston, TX',
'HOU': 'Houston, TX',
'MSP': 'Minneapolis, MN',
'DTW': 'Detroit, MI',
'CLT': 'Charlotte, NC',
'EWR': 'Newark, NJ',
'IAD': 'Washington, DC',
'DCA': 'Washington, DC',
'BWI': 'Baltimore, MD',
'LAS': 'Las Vegas, NV',
'MCO': 'Orlando, FL',
'TPA': 'Tampa, FL',
'BNA': 'Nashville, TN',
'AUS': 'Austin, TX',
'SAT': 'San Antonio, TX',
'RDU': 'Raleigh, NC',
'SAN': 'San Diego, CA',
'OAK': 'Oakland, CA',
'SJC': 'San Jose, CA',
'SMF': 'Sacramento, CA',
'PHL': 'Philadelphia, PA',
'PIT': 'Pittsburgh, PA',
'CLE': 'Cleveland, OH',
'CMH': 'Columbus, OH',
'CVG': 'Cincinnati, OH',
'IND': 'Indianapolis, IN',
'MCI': 'Kansas City, MO',
'STL': 'St. Louis, MO',
'MSY': 'New Orleans, LA',
'MEM': 'Memphis, TN',
'SDF': 'Louisville, KY',
'JAX': 'Jacksonville, FL',
'RIC': 'Richmond, VA',
'ORF': 'Norfolk, VA',
'BDL': 'Hartford, CT',
'PVD': 'Providence, RI',
'ABQ': 'Albuquerque, NM',
'OKC': 'Oklahoma City, OK',
'TUL': 'Tulsa, OK',
'OMA': 'Omaha, NE',
'BOI': 'Boise, ID',
'GEG': 'Spokane, WA',
'ANC': 'Anchorage, AK',
'HNL': 'Honolulu, HI',
'OGG': 'Maui, HI',
'BUF': 'Buffalo, NY',
'SYR': 'Syracuse, NY',
'ROC': 'Rochester, NY',
'ALB': 'Albany, NY',
'BTV': 'Burlington, VT',
'PWM': 'Portland, ME',
'MKE': 'Milwaukee, WI',
'DSM': 'Des Moines, IA',
'LIT': 'Little Rock, AR',
'BHM': 'Birmingham, AL',
'CHS': 'Charleston, SC',
'SAV': 'Savannah, GA',
// Canada
'YVR': 'Vancouver, BC',
'YYZ': 'Toronto, ON',
'YUL': 'Montreal, QC',
'YOW': 'Ottawa, ON',
'YYC': 'Calgary, AB',
'YEG': 'Edmonton, AB',
'YWG': 'Winnipeg, MB',
'YHZ': 'Halifax, NS',
'YQB': 'Quebec City, QC',
// Europe
'LHR': 'London, UK',
'LGW': 'London, UK',
'STN': 'London, UK',
'CDG': 'Paris, FR',
'ORY': 'Paris, FR',
'FRA': 'Frankfurt, DE',
'MUC': 'Munich, DE',
'BER': 'Berlin, DE',
'AMS': 'Amsterdam, NL',
'MAD': 'Madrid, ES',
'BCN': 'Barcelona, ES',
'FCO': 'Rome, IT',
'MXP': 'Milan, IT',
'ZRH': 'Zurich, CH',
'GVA': 'Geneva, CH',
'VIE': 'Vienna, AT',
'CPH': 'Copenhagen, DK',
'ARN': 'Stockholm, SE',
'OSL': 'Oslo, NO',
'HEL': 'Helsinki, FI',
'DUB': 'Dublin, IE',
'LIS': 'Lisbon, PT',
'ATH': 'Athens, GR',
'IST': 'Istanbul, TR',
'WAW': 'Warsaw, PL',
'PRG': 'Prague, CZ',
'BUD': 'Budapest, HU',
'OTP': 'Bucharest, RO',
'SOF': 'Sofia, BG',
'ZAG': 'Zagreb, HR',
'BEG': 'Belgrade, RS',
'KBP': 'Kyiv, UA',
'LED': 'St. Petersburg, RU',
'SVO': 'Moscow, RU',
'BRU': 'Brussels, BE',
'EDI': 'Edinburgh, UK',
'MAN': 'Manchester, UK',
// Asia
'NRT': 'Tokyo, JP',
'HND': 'Tokyo, JP',
'KIX': 'Osaka, JP',
'ICN': 'Seoul, KR',
'PEK': 'Beijing, CN',
'PVG': 'Shanghai, CN',
'HKG': 'Hong Kong',
'TPE': 'Taipei, TW',
'SIN': 'Singapore',
'BKK': 'Bangkok, TH',
'KUL': 'Kuala Lumpur, MY',
'CGK': 'Jakarta, ID',
'MNL': 'Manila, PH',
'DEL': 'New Delhi, IN',
'BOM': 'Mumbai, IN',
'BLR': 'Bangalore, IN',
'CCU': 'Kolkata, IN',
'SGN': 'Ho Chi Minh City, VN',
'HAN': 'Hanoi, VN',
'DOH': 'Doha, QA',
'DXB': 'Dubai, AE',
'AUH': 'Abu Dhabi, AE',
'TLV': 'Tel Aviv, IL',
// Oceania
'SYD': 'Sydney, AU',
'MEL': 'Melbourne, AU',
'BNE': 'Brisbane, AU',
'PER': 'Perth, AU',
'AKL': 'Auckland, NZ',
'WLG': 'Wellington, NZ',
'CHC': 'Christchurch, NZ',
// South America
'GRU': 'São Paulo, BR',
'GIG': 'Rio de Janeiro, BR',
'EZE': 'Buenos Aires, AR',
'SCL': 'Santiago, CL',
'BOG': 'Bogota, CO',
'LIM': 'Lima, PE',
'UIO': 'Quito, EC',
'CCS': 'Caracas, VE',
'MVD': 'Montevideo, UY',
// Africa
'JNB': 'Johannesburg, ZA',
'CPT': 'Cape Town, ZA',
'CAI': 'Cairo, EG',
'NBO': 'Nairobi, KE',
'ADD': 'Addis Ababa, ET',
'CMN': 'Casablanca, MA',
'LOS': 'Lagos, NG'
};
})();

View File

@@ -1224,7 +1224,25 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
/* Ambiguous hop indicator */
.hop-ambiguous { border-bottom: 1px dashed #f59e0b; }
.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; }
.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; color: #f59e0b; }
.hop-conflict-btn { background: #f59e0b; color: #000; border: none; border-radius: 4px; font-size: 11px;
font-weight: 700; padding: 1px 5px; cursor: pointer; vertical-align: middle; margin-left: 3px; line-height: 1.2; }
.hop-conflict-btn:hover { background: #d97706; }
.hop-conflict-popover { position: absolute; z-index: 9999; background: var(--surface-1); border: 1px solid var(--border);
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25); width: 260px; max-height: 300px; overflow-y: auto; }
.hop-conflict-header { padding: 10px 12px; font-size: 12px; font-weight: 700; border-bottom: 1px solid var(--border);
color: var(--text-muted); }
.hop-conflict-list { padding: 4px 0; }
.hop-conflict-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; text-decoration: none;
color: var(--text); font-size: 13px; border-bottom: 1px solid var(--border); }
.hop-conflict-item:last-child { border-bottom: none; }
.hop-conflict-item:hover { background: var(--hover-bg); }
.hop-conflict-name { font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hop-conflict-dist { font-size: 11px; color: var(--text-muted); font-family: var(--mono); white-space: nowrap; }
.hop-conflict-pk { font-size: 10px; color: var(--text-muted); font-family: var(--mono); }
.hop-unreliable { opacity: 0.5; text-decoration: line-through; }
.hop-global-fallback { border-bottom: 1px dashed #ef4444; }
.hop-current { font-weight: 700 !important; color: var(--accent) !important; }
/* Self-loop subpath rows */
.subpath-selfloop { opacity: 0.6; }
@@ -1491,13 +1509,14 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.region-dropdown-trigger:hover { border-color: var(--accent); color: var(--accent); }
.region-dropdown-menu {
position: absolute; top: 100%; left: 0; z-index: 90;
min-width: 220px; max-height: 260px; overflow-y: auto;
min-width: 220px; width: max-content; max-height: 260px; overflow-y: auto;
background: var(--card-bg, #fff); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12); padding: 4px 0;
}
.region-dropdown-item {
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
font-size: 13px; cursor: pointer; color: var(--text); white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; max-width: 320px;
}
.region-dropdown-item input[type="checkbox"] {
width: 14px; height: 14px; margin: 0; flex-shrink: 0;
@@ -1556,11 +1575,6 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.matrix-scanlines {
position: absolute; inset: 0; z-index: 9999; pointer-events: none;
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,255,65,0.02) 2px, rgba(0,255,65,0.02) 4px);
animation: matrix-flicker 0.15s infinite alternate;
}
@keyframes matrix-flicker {
from { opacity: 0.8; }
to { opacity: 1; }
}
/* Feed panel in matrix mode */
@@ -1597,3 +1611,79 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
/* Node labels on map */
.matrix-theme .node-label { color: #00ff41 !important; text-shadow: 0 0 4px #00ff41 !important; }
.matrix-theme .leaflet-marker-icon:not(.matrix-char) { filter: hue-rotate(90deg) saturate(1) brightness(0.35) opacity(0.5); }
/* Audio controls */
.audio-controls {
display: flex;
gap: 12px;
align-items: center;
padding: 4px 8px;
font-size: 12px;
}
.audio-controls.hidden { display: none; }
.audio-slider-label {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-secondary, #6b7280);
font-size: 11px;
white-space: nowrap;
}
.audio-slider {
width: 80px;
height: 4px;
cursor: pointer;
accent-color: #8b5cf6;
}
.audio-slider-label span {
min-width: 24px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.matrix-theme .audio-controls label,
.matrix-theme .audio-controls span { color: #00ff41 !important; }
.matrix-theme .audio-slider { accent-color: #00ff41; }
/* Audio voice selector */
.audio-voice-select {
background: var(--bg-secondary, #1f2937);
color: var(--text-primary, #e5e7eb);
border: 1px solid var(--border, #374151);
border-radius: 4px;
padding: 2px 4px;
font-size: 11px;
cursor: pointer;
}
.matrix-theme .audio-voice-select {
background: #001a00 !important;
color: #00ff41 !important;
border-color: #00ff4130 !important;
}
/* Audio unlock overlay */
.audio-unlock-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.6);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.audio-unlock-prompt {
background: #1f2937;
color: #e5e7eb;
padding: 24px 40px;
border-radius: 12px;
font-size: 20px;
font-weight: 600;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
user-select: none;
}
.matrix-theme .audio-unlock-prompt {
background: #001a00;
color: #00ff41;
box-shadow: 0 0 30px rgba(0,255,65,0.2);
}

166
server.js
View File

@@ -9,6 +9,8 @@ const path = require('path');
const fs = require('fs');
const config = require('./config.json');
const decoder = require('./decoder');
const PAYLOAD_TYPES = decoder.PAYLOAD_TYPES;
const { nodeNearRegion, IATA_COORDS } = require('./iata-coords');
// Health thresholds — configurable with sensible defaults
const _ht = config.healthThresholds || {};
@@ -796,7 +798,7 @@ for (const source of mqttSources) {
};
const packetId = pktStore.insert(advertPktData); _updateHashSizeForPacket(advertPktData);
try { db.insertTransmission(advertPktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'ADVERT' }, payload: advert } } });
broadcast({ type: 'packet', data: { id: packetId, hash: advertPktData.hash, raw: advertPktData.raw_hex, decoded: { header: { payloadTypeName: 'ADVERT' }, payload: advert } } });
}
return;
}
@@ -829,8 +831,8 @@ for (const source of mqttSources) {
};
const packetId = pktStore.insert(chPktData); _updateHashSizeForPacket(chPktData);
try { db.insertTransmission(chPktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: channelMsg } } });
broadcast({ type: 'message', data: { id: packetId, decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: channelMsg } } });
broadcast({ type: 'packet', data: { id: packetId, hash: chPktData.hash, raw: chPktData.raw_hex, decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: channelMsg } } });
broadcast({ type: 'message', data: { id: packetId, hash: chPktData.hash, decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: channelMsg } } });
return;
}
@@ -852,7 +854,7 @@ for (const source of mqttSources) {
};
const packetId = pktStore.insert(dmPktData); _updateHashSizeForPacket(dmPktData);
try { db.insertTransmission(dmPktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'TXT_MSG' }, payload: dm } } });
broadcast({ type: 'packet', data: { id: packetId, hash: dmPktData.hash, raw: dmPktData.raw_hex, decoded: { header: { payloadTypeName: 'TXT_MSG' }, payload: dm } } });
return;
}
@@ -874,7 +876,7 @@ for (const source of mqttSources) {
};
const packetId = pktStore.insert(tracePktData); _updateHashSizeForPacket(tracePktData);
try { db.insertTransmission(tracePktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'TRACE' }, payload: trace } } });
broadcast({ type: 'packet', data: { id: packetId, hash: tracePktData.hash, raw: tracePktData.raw_hex, decoded: { header: { payloadTypeName: 'TRACE' }, payload: trace } } });
return;
}
@@ -1112,7 +1114,7 @@ app.post('/api/packets', requireApiKey, (req, res) => {
// Invalidate caches on new data
cache.debouncedInvalidateAll();
broadcast({ type: 'packet', data: { id: packetId, decoded } });
broadcast({ type: 'packet', data: { id: packetId, hash: apiPktData.hash, raw: apiPktData.raw_hex, decoded } });
res.json({ id: packetId, decoded });
} catch (e) {
@@ -2002,25 +2004,60 @@ app.get('/api/analytics/hash-sizes', (req, res) => {
app.get('/api/resolve-hops', (req, res) => {
const hops = (req.query.hops || '').split(',').filter(Boolean);
const observerId = req.query.observer || null;
// Origin anchor: sender's lat/lon for forward-pass disambiguation.
// Without this, the first ambiguous hop falls through to the backward pass
// which anchors from the observer — wrong when sender and observer are far apart.
const originLat = req.query.originLat ? parseFloat(req.query.originLat) : null;
const originLon = req.query.originLon ? parseFloat(req.query.originLon) : null;
if (!hops.length) return res.json({ resolved: {} });
const allNodes = getCachedNodes(false);
const allObservers = db.getObservers();
// Build observer IATA lookup and regional observer sets
const observerIataMap = {}; // observer_id → iata
const observersByIata = {}; // iata → Set<observer_id>
for (const obs of allObservers) {
if (obs.iata) {
observerIataMap[obs.id] = obs.iata;
if (!observersByIata[obs.iata]) observersByIata[obs.iata] = new Set();
observersByIata[obs.iata].add(obs.id);
}
}
// Determine this packet's region from its observer
const packetIata = observerId ? observerIataMap[observerId] : null;
const regionalObserverIds = packetIata ? observersByIata[packetIata] : null;
// Helper: check if a node is near the packet's region using layered filtering
// Layer 1: Node has lat/lon → geographic distance to IATA center (bridge-proof)
// Layer 2: Node has no lat/lon → observer-based (was ADVERT seen by regional observer)
// Returns: { near: boolean, method: 'geo'|'observer'|'none', distKm?: number }
const nodeInRegion = (candidate) => {
// Layer 1: Geographic check (ground truth, bridge-proof)
if (packetIata && candidate.lat && candidate.lon && !(candidate.lat === 0 && candidate.lon === 0)) {
const geoCheck = nodeNearRegion(candidate.lat, candidate.lon, packetIata);
if (geoCheck) return { near: geoCheck.near, method: 'geo', distKm: geoCheck.distKm };
}
// Layer 2: Observer-based check (fallback for nodes without GPS)
if (regionalObserverIds) {
const nodeObservers = pktStore._advertByObserver.get(candidate.public_key);
if (nodeObservers) {
for (const obsId of nodeObservers) {
if (regionalObserverIds.has(obsId)) return { near: true, method: 'observer' };
}
}
return { near: false, method: 'observer' };
}
// No region info available
return { near: false, method: 'none' };
};
// Build observer geographic position
let observerLat = null, observerLon = null;
if (observerId) {
// Try exact name match first
const obsNode = allNodes.find(n => n.name === observerId);
if (obsNode && obsNode.lat && obsNode.lon && !(obsNode.lat === 0 && obsNode.lon === 0)) {
observerLat = obsNode.lat;
observerLon = obsNode.lon;
} else {
// Fall back to averaging nearby nodes from adverts this observer received
const obsNodes = db.db.prepare(`
SELECT n.lat, n.lon FROM packets_v p
JOIN nodes n ON n.public_key = json_extract(p.decoded_json, '$.pubKey')
@@ -2039,25 +2076,55 @@ app.get('/api/resolve-hops', (req, res) => {
}
const resolved = {};
// First pass: find all candidates for each hop
// First pass: find all candidates for each hop, split into regional and global
for (const hop of hops) {
const hopLower = hop.toLowerCase();
const candidates = allNodes.filter(n => n.public_key.toLowerCase().startsWith(hopLower));
if (candidates.length === 0) {
resolved[hop] = { name: null, candidates: [] };
} else if (candidates.length === 1) {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, candidates: [{ name: candidates[0].name, pubkey: candidates[0].public_key }] };
const hopByteLen = Math.ceil(hop.length / 2); // 2 hex chars = 1 byte
const allCandidates = allNodes.filter(n => n.public_key.toLowerCase().startsWith(hopLower));
if (allCandidates.length === 0) {
resolved[hop] = { name: null, candidates: [], conflicts: [] };
} else if (allCandidates.length === 1) {
const c = allCandidates[0];
const regionCheck = nodeInRegion(c);
resolved[hop] = { name: c.name, pubkey: c.public_key,
candidates: [{ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon, regional: regionCheck.near, filterMethod: regionCheck.method, distKm: regionCheck.distKm }],
conflicts: [] };
} else {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, ambiguous: true, candidates: candidates.map(c => ({ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon })) };
// Multiple candidates — apply layered regional filtering
const checked = allCandidates.map(c => {
const r = nodeInRegion(c);
return { ...c, regional: r.near, filterMethod: r.method, distKm: r.distKm };
});
const regional = checked.filter(c => c.regional);
// Sort by distance to region center — closest first
regional.sort((a, b) => (a.distKm || 9999) - (b.distKm || 9999));
const candidates = regional.length > 0 ? regional : checked;
const globalFallback = regional.length === 0 && checked.length > 0;
const conflicts = candidates.map(c => ({
name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon,
regional: c.regional, filterMethod: c.filterMethod, distKm: c.distKm
}));
if (candidates.length === 1) {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key,
candidates: conflicts, conflicts, globalFallback,
filterMethod: candidates[0].filterMethod };
} else {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key,
ambiguous: true, candidates: conflicts, conflicts, globalFallback,
hopBytes: hopByteLen, totalGlobal: allCandidates.length, totalRegional: regional.length,
filterMethods: [...new Set(candidates.map(c => c.filterMethod))] };
}
}
}
// Sequential disambiguation: each hop must be near the previous one
// Walk the path forward, resolving ambiguous hops by distance to last known position
// Start from first unambiguous hop (or observer position as anchor for last hop)
// Build initial resolved positions map
const hopPositions = {}; // hop -> {lat, lon}
const dist = (lat1, lon1, lat2, lon2) => Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
// Forward pass: resolve each ambiguous hop using previous hop's position
const hopPositions = {};
// Seed unambiguous positions
for (const hop of hops) {
const r = resolved[hop];
if (r && !r.ambiguous && r.pubkey) {
@@ -2068,9 +2135,6 @@ app.get('/api/resolve-hops', (req, res) => {
}
}
const dist = (lat1, lon1, lat2, lon2) => Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
// Forward pass: resolve each ambiguous hop using previous hop's position
let lastPos = (originLat != null && originLon != null) ? { lat: originLat, lon: originLon } : null;
for (let hi = 0; hi < hops.length; hi++) {
const hop = hops[hi];
@@ -2083,7 +2147,6 @@ app.get('/api/resolve-hops', (req, res) => {
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
if (!withLoc.length) continue;
// Use previous hop position, or observer position for last hop, or skip
let anchor = lastPos;
if (!anchor && hi === hops.length - 1 && observerLat != null) {
anchor = { lat: observerLat, lon: observerLon };
@@ -2116,7 +2179,7 @@ app.get('/api/resolve-hops', (req, res) => {
nextPos = hopPositions[hop];
}
// Sanity check: drop hops impossibly far from both neighbors (>200km ≈ 1.8°)
// Sanity check: drop hops impossibly far from both neighbors
const MAX_HOP_DIST = MAX_HOP_DIST_SERVER;
for (let i = 0; i < hops.length; i++) {
const pos = hopPositions[hops[i]];
@@ -2129,14 +2192,13 @@ app.get('/api/resolve-hops', (req, res) => {
const tooFarPrev = prev && dPrev > MAX_HOP_DIST;
const tooFarNext = next && dNext > MAX_HOP_DIST;
if ((tooFarPrev && tooFarNext) || (tooFarPrev && !next) || (tooFarNext && !prev)) {
// Mark as unreliable — likely prefix collision with distant node
const r = resolved[hops[i]];
if (r) { r.unreliable = true; }
delete hopPositions[hops[i]];
}
}
res.json({ resolved });
res.json({ resolved, region: packetIata || null });
});
// channelHashNames removed — we only use decoded channel names now
@@ -2405,9 +2467,15 @@ app.get('/api/nodes/:pubkey/health', (req, res) => {
}
}
// Build observer iata lookup
const allObservers = db.getObservers();
const obsIataMap = {};
for (const obs of allObservers) { if (obs.iata) obsIataMap[obs.id] = obs.iata; }
const observers = Object.entries(obsMap).map(([observer_id, o]) => ({
observer_id, observer_name: o.observer_name, packetCount: o.packetCount,
avgSnr: o.snrN ? o.snrSum / o.snrN : null, avgRssi: o.rssiN ? o.rssiSum / o.rssiN : null
avgSnr: o.snrN ? o.snrSum / o.snrN : null, avgRssi: o.rssiN ? o.rssiSum / o.rssiN : null,
iata: obsIataMap[observer_id] || null
})).sort((a, b) => b.packetCount - a.packetCount);
const recentPackets = packets.slice(0, 20);
@@ -2848,6 +2916,40 @@ app.get('/api/analytics/subpath-detail', (req, res) => {
res.json(_sdResult);
});
// IATA coordinates for client-side regional filtering
app.get('/api/iata-coords', (req, res) => {
res.json({ coords: IATA_COORDS });
});
// Audio Lab: representative packets bucketed by type
app.get('/api/audio-lab/buckets', (req, res) => {
const buckets = {};
const byType = {};
for (const tx of pktStore.packets) {
if (!tx.raw_hex) continue;
let typeName = 'UNKNOWN';
try { const d = JSON.parse(tx.decoded_json || '{}'); typeName = d.type || (PAYLOAD_TYPES[tx.payload_type] || 'UNKNOWN'); } catch {}
if (!byType[typeName]) byType[typeName] = [];
byType[typeName].push(tx);
}
for (const [type, pkts] of Object.entries(byType)) {
const sorted = pkts.sort((a, b) => (a.raw_hex || '').length - (b.raw_hex || '').length);
const count = Math.min(8, sorted.length);
const picked = [];
for (let i = 0; i < count; i++) {
const idx = Math.floor((i / count) * sorted.length);
const tx = sorted[idx];
picked.push({
hash: tx.hash, raw_hex: tx.raw_hex, decoded_json: tx.decoded_json,
observation_count: tx.observation_count || 1, payload_type: tx.payload_type,
path_json: tx.path_json, observer_id: tx.observer_id, timestamp: tx.timestamp,
});
}
buckets[type] = picked;
}
res.json({ buckets });
});
// Static files + SPA fallback
app.use(express.static(path.join(__dirname, 'public'), {
etag: false,

135
test-regional-filter.js Normal file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env node
// Test: Regional hop resolution filtering
// Validates that resolve-hops correctly filters candidates by geography and observer region
const { IATA_COORDS, haversineKm, nodeNearRegion } = require('./iata-coords');
let pass = 0, fail = 0;
function assert(condition, msg) {
if (condition) { pass++; console.log(`${msg}`); }
else { fail++; console.error(` ❌ FAIL: ${msg}`); }
}
// === 1. Haversine distance tests ===
console.log('\n=== Haversine Distance ===');
const sjcToSea = haversineKm(37.3626, -121.9290, 47.4502, -122.3088);
assert(sjcToSea > 1100 && sjcToSea < 1150, `SJC→SEA = ${Math.round(sjcToSea)}km (expect ~1125km)`);
const sjcToOak = haversineKm(37.3626, -121.9290, 37.7213, -122.2208);
assert(sjcToOak > 40 && sjcToOak < 55, `SJC→OAK = ${Math.round(sjcToOak)}km (expect ~48km)`);
const sjcToSjc = haversineKm(37.3626, -121.9290, 37.3626, -121.9290);
assert(sjcToSjc === 0, `SJC→SJC = ${sjcToSjc}km (expect 0)`);
const sjcToEug = haversineKm(37.3626, -121.9290, 44.1246, -123.2119);
assert(sjcToEug > 750 && sjcToEug < 780, `SJC→EUG = ${Math.round(sjcToEug)}km (expect ~762km)`);
// === 2. nodeNearRegion tests ===
console.log('\n=== Node Near Region ===');
// Node in San Jose, check against SJC region
const sjNode = nodeNearRegion(37.35, -121.95, 'SJC');
assert(sjNode && sjNode.near, `San Jose node near SJC: ${sjNode.distKm}km`);
// Node in Seattle, check against SJC region — should NOT be near
const seaNode = nodeNearRegion(47.45, -122.30, 'SJC');
assert(seaNode && !seaNode.near, `Seattle node NOT near SJC: ${seaNode.distKm}km`);
// Node in Seattle, check against SEA region — should be near
const seaNodeSea = nodeNearRegion(47.45, -122.30, 'SEA');
assert(seaNodeSea && seaNodeSea.near, `Seattle node near SEA: ${seaNodeSea.distKm}km`);
// Node in Eugene, check against EUG — should be near
const eugNode = nodeNearRegion(44.05, -123.10, 'EUG');
assert(eugNode && eugNode.near, `Eugene node near EUG: ${eugNode.distKm}km`);
// Eugene node should NOT be near SJC (~762km)
const eugNodeSjc = nodeNearRegion(44.05, -123.10, 'SJC');
assert(eugNodeSjc && !eugNodeSjc.near, `Eugene node NOT near SJC: ${eugNodeSjc.distKm}km`);
// Node with no location — returns null
const noLoc = nodeNearRegion(null, null, 'SJC');
assert(noLoc === null, 'Null lat/lon returns null');
// Node at 0,0 — returns null
const zeroLoc = nodeNearRegion(0, 0, 'SJC');
assert(zeroLoc === null, 'Zero lat/lon returns null');
// Unknown IATA — returns null
const unkIata = nodeNearRegion(37.35, -121.95, 'ZZZ');
assert(unkIata === null, 'Unknown IATA returns null');
// === 3. Edge cases: nodes just inside/outside 300km radius ===
console.log('\n=== Boundary Tests (300km radius) ===');
// Sacramento is ~145km from SJC — inside
const smfNode = nodeNearRegion(38.58, -121.49, 'SJC');
assert(smfNode && smfNode.near, `Sacramento near SJC: ${smfNode.distKm}km (expect ~145)`);
// Fresno is ~235km from SJC — inside
const fatNode = nodeNearRegion(36.74, -119.79, 'SJC');
assert(fatNode && fatNode.near, `Fresno near SJC: ${fatNode.distKm}km (expect ~235)`);
// Redding is ~400km from SJC — outside
const rddNode = nodeNearRegion(40.59, -122.39, 'SJC');
assert(rddNode && !rddNode.near, `Redding NOT near SJC: ${rddNode.distKm}km (expect ~400)`);
// === 4. Simulate the core issue: 1-byte hop with cross-regional collision ===
console.log('\n=== Cross-Regional Collision Simulation ===');
// Two nodes with pubkeys starting with "D6": one in SJC area, one in SEA area
const candidates = [
{ name: 'Redwood Mt. Tam', pubkey: 'D6...sjc', lat: 37.92, lon: -122.60 }, // Marin County, CA
{ name: 'VE7RSC North Repeater', pubkey: 'D6...sea', lat: 49.28, lon: -123.12 }, // Vancouver, BC
{ name: 'KK7RXY Lynden', pubkey: 'D6...bel', lat: 48.94, lon: -122.47 }, // Bellingham, WA
];
// Packet observed in SJC region
const packetIata = 'SJC';
const geoFiltered = candidates.filter(c => {
const check = nodeNearRegion(c.lat, c.lon, packetIata);
return check && check.near;
});
assert(geoFiltered.length === 1, `Geo filter SJC: ${geoFiltered.length} candidates (expect 1)`);
assert(geoFiltered[0].name === 'Redwood Mt. Tam', `Winner: ${geoFiltered[0].name} (expect Redwood Mt. Tam)`);
// Packet observed in SEA region
const seaFiltered = candidates.filter(c => {
const check = nodeNearRegion(c.lat, c.lon, 'SEA');
return check && check.near;
});
assert(seaFiltered.length === 2, `Geo filter SEA: ${seaFiltered.length} candidates (expect 2 — Vancouver + Bellingham)`);
// Packet observed in EUG region — Eugene is ~300km from SEA nodes
const eugFiltered = candidates.filter(c => {
const check = nodeNearRegion(c.lat, c.lon, 'EUG');
return check && check.near;
});
assert(eugFiltered.length === 0, `Geo filter EUG: ${eugFiltered.length} candidates (expect 0 — all too far)`);
// === 5. Layered fallback logic ===
console.log('\n=== Layered Fallback ===');
const nodeWithGps = { lat: 37.92, lon: -122.60 }; // has GPS
const nodeNoGps = { lat: null, lon: null }; // no GPS
const observerSawNode = true; // observer-based filter says yes
// Layer 1: GPS check
const gpsCheck = nodeNearRegion(nodeWithGps.lat, nodeWithGps.lon, 'SJC');
assert(gpsCheck && gpsCheck.near, 'Layer 1 (GPS): node with GPS near SJC');
// Layer 2: No GPS, fall back to observer
const gpsCheckNoLoc = nodeNearRegion(nodeNoGps.lat, nodeNoGps.lon, 'SJC');
assert(gpsCheckNoLoc === null, 'Layer 2: no GPS returns null → use observer-based fallback');
// Bridged WA node with GPS — should be REJECTED by SJC even though observer saw it
const bridgedWaNode = { lat: 47.45, lon: -122.30 }; // Seattle
const bridgedCheck = nodeNearRegion(bridgedWaNode.lat, bridgedWaNode.lon, 'SJC');
assert(bridgedCheck && !bridgedCheck.near, `Bridge test: WA node rejected by SJC geo filter (${bridgedCheck.distKm}km)`);
// === Summary ===
console.log(`\n${'='.repeat(40)}`);
console.log(`Results: ${pass} passed, ${fail} failed`);
process.exit(fail > 0 ? 1 : 0);

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env node
// Integration test: Verify layered filtering works against live prod API
// Tests that resolve-hops returns regional metadata and correct filtering
const https = require('https');
const BASE = 'https://analyzer.00id.net';
function apiGet(path) {
return new Promise((resolve, reject) => {
https.get(BASE + path, { timeout: 10000 }, (res) => {
let data = '';
res.on('data', d => data += d);
res.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } });
}).on('error', reject);
});
}
let pass = 0, fail = 0;
function assert(condition, msg) {
if (condition) { pass++; console.log(`${msg}`); }
else { fail++; console.error(` ❌ FAIL: ${msg}`); }
}
async function run() {
console.log('\n=== Integration: resolve-hops API with regional filtering ===\n');
// 1. Get a packet with short hops and a known observer
const packets = await apiGet('/api/packets?limit=100&groupByHash=true');
const pkt = packets.packets.find(p => {
const path = JSON.parse(p.path_json || '[]');
return path.length > 0 && path.some(h => h.length <= 2) && p.observer_id;
});
if (!pkt) {
console.log(' ⚠ No packets with short hops found — skipping API tests');
return;
}
const path = JSON.parse(pkt.path_json);
const shortHops = path.filter(h => h.length <= 2);
console.log(` Using packet ${pkt.hash.slice(0,12)} observed by ${pkt.observer_name || pkt.observer_id.slice(0,12)}`);
console.log(` Path: ${path.join(' → ')} (${shortHops.length} short hops)`);
// 2. Resolve WITH observer (should get regional filtering)
const withObs = await apiGet(`/api/resolve-hops?hops=${path.join(',')}&observer=${pkt.observer_id}`);
assert(withObs.region != null, `Response includes region: ${withObs.region}`);
// 3. Check that conflicts have filterMethod field
let hasFilterMethod = false;
let hasDistKm = false;
for (const [hop, info] of Object.entries(withObs.resolved)) {
if (info.conflicts && info.conflicts.length > 0) {
for (const c of info.conflicts) {
if (c.filterMethod) hasFilterMethod = true;
if (c.distKm != null) hasDistKm = true;
}
}
if (info.filterMethods) {
assert(Array.isArray(info.filterMethods), `Hop ${hop}: filterMethods is array: ${JSON.stringify(info.filterMethods)}`);
}
}
assert(hasFilterMethod, 'At least one conflict has filterMethod');
// 4. Resolve WITHOUT observer (no regional filtering)
const withoutObs = await apiGet(`/api/resolve-hops?hops=${path.join(',')}`);
assert(withoutObs.region === null, `Without observer: region is null`);
// 5. Compare: with observer should have same or fewer candidates per ambiguous hop
for (const hop of shortHops) {
const withInfo = withObs.resolved[hop];
const withoutInfo = withoutObs.resolved[hop];
if (withInfo && withoutInfo && withInfo.conflicts && withoutInfo.conflicts) {
const withCount = withInfo.totalRegional || withInfo.conflicts.length;
const withoutCount = withoutInfo.totalGlobal || withoutInfo.conflicts.length;
assert(withCount <= withoutCount + 1,
`Hop ${hop}: regional(${withCount}) <= global(${withoutCount}) — ${withInfo.name || '?'}`);
}
}
// 6. Check that geo-filtered candidates have distKm
for (const [hop, info] of Object.entries(withObs.resolved)) {
if (info.conflicts) {
const geoFiltered = info.conflicts.filter(c => c.filterMethod === 'geo');
for (const c of geoFiltered) {
assert(c.distKm != null, `Hop ${hop} candidate ${c.name}: has distKm=${c.distKm}km (geo filter)`);
}
}
}
console.log(`\n${'='.repeat(40)}`);
console.log(`Results: ${pass} passed, ${fail} failed`);
process.exit(fail > 0 ? 1 : 0);
}
run().catch(e => { console.error('Test error:', e); process.exit(1); });