Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,16 @@ node type. Improved successor to an earlier method. Two reused building blocks:
2. **Map / visualisation base** — the **CoreScope** map and point-visualisation layer: map setup,
point/marker rendering, coverage/hex heat, and SNR/RSSI colour scaling.

**Direction-finding principle:** only what the hunter hears **directly (zero-hop, `hops === 0`)**
tells you where a transmitter is. A relayed packet's RSSI/SNR describes the last repeater that
forwarded it, not the target. Drive toward the strongest zero-hop heat to close in on the source.
**Direction-finding principle:** every reception is heard directly, from whichever node transmitted
it last. What zero hops (`hops === 0`) adds is that this node is also the **originator**, so only
then does the RSSI/SNR say something about where the *source* stands. A relayed packet's signal
describes the repeater that forwarded it -- which is a real transmitter on a real spot, and one this
app lets you select as a target, just not the one whose packet you are reading. Drive toward the
strongest zero-hop heat to close in on a source; toward relayed heat to close in on the repeater.

Say this precisely, because the loose form ("only zero-hop tells you where a transmitter is") has
misled work twice: it reads as a capture rule, and it is not one. Capture keeps everything it can
place (#454), and audio follows the same rule (#468).

**"Direct" is not "authenticated" (#320).** The hop count and path are plaintext header fields with
no signature or MAC over them. MeshCore does authenticate payloads (channel/direct MACs, an Advert's
Expand Down
14 changes: 14 additions & 0 deletions app/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@
"title": "The target button names the node you picked",
"body": "Picking a target from the list left no trace once the panel closed: the button beside the sender box was a bare arrow, so a filtered map looked exactly like an unfiltered one. It now reads the node's name, or the number of targets when you pick several, and lights up while a pick is active."
},
{
"id": "2026-08-25-ambient-layer-has-room",
"date": "2026-08-25",
"where": "app",
"title": "Sound mode with the music gets some room",
"body": "The ambient layer under the reception sounds was two thin bands of noise and seven fixed notes, so after a few minutes you had heard everything it had. There are three noise layers now, each drifting slowly across the stereo field in a reverb of its own, and the music has ten voices over three octaves that quietly change note every couple of minutes, so the harmony is somewhere else by the end of a drive. The tone the app holds while it sits in your pocket is a chord with a slow breath instead of one flat sine."
},
{
"id": "2026-08-25-filter-a-flood-with-no-sender",
"date": "2026-08-25",
Expand All @@ -118,6 +125,13 @@
"title": "Mapping has a way in from the map",
"body": "The map is what mapping produces, but nothing here said so: the login box offered logging in and nothing else, so anyone without an account was left with nowhere to click. Start mapping now stands in the toolbar and opens the RX webapp, where you pair a companion radio and your own receptions start landing here. That is also where the account is made, from the companion you paired, which is why this page has no sign-up form. The walkthrough and mesh-hunter.eu spell out the rest, including the admin verification that opens the full history."
},
{
"id": "2026-08-25-every-reception-audible",
"date": "2026-08-25",
"where": "app",
"title": "You hear everything the radio hears, and what kind it is",
"body": "Sound used to be reserved for receptions heard straight from the transmitter, which is about a third of what the radio picks up, and it went quiet whenever you filtered the map to one node. Everything is audible now, and the packet type picks the instrument: adverts ring, chat is softer, the constant network chatter stays the driest sound in the mix. A relayed reception barely gives you the strike at all: what you hear is mostly what came back off the repeater, a darker echo a beat behind, so you can tell without looking whether something reached you directly. Pitch and loudness still tell you the signal strength."
},
{
"id": "2026-08-24-keep-what-cannot-be-named",
"date": "2026-08-24",
Expand Down
132 changes: 116 additions & 16 deletions app/src/__tests__/sound.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import { SOUND_MODES, nextSoundMode, harmFreq, pingGain, shouldPing, createSoundEngine } from '../sound.js'
import { SOUND_MODES, nextSoundMode, harmFreq, pingGain, receptionCue, cueFamily, coalesceCue, CUE_GAP_MS, createSoundEngine } from '../sound.js'
import { FILTER_PACKET_TYPES } from '../filters.js'
import { RSSI_WEAK_DBM, RSSI_STRONG_DBM } from '../signal.js'

describe('nextSoundMode', () => {
Expand Down Expand Up @@ -104,24 +105,123 @@ describe('pingGain', () => {
})
})

describe('shouldPing', () => {
const pass = () => true
const reject = () => false
const rec = { hops: 0, rssi: -90 }
it('pings a zero-hop reception that passes the filter in rxtx mode', () => {
expect(shouldPing(rec, 'rxtx', pass, 0)).toBe(true)
// #468: every reception the app records must be able to be heard. What used to
// be a yes/no gate now answers WHICH cue, because the distinction is the only
// thing that keeps the majority class from drowning the one you steer by.
describe('receptionCue', () => {
const direct = { hops: 0, rssi: -90, packet_type: 'Advert' }

it('names a cue for a zero-hop reception in both sounding modes', () => {
expect(receptionCue(direct, 'rxtx')).toEqual({ family: 'advert', damped: false })
expect(receptionCue(direct, 'full')).toEqual({ family: 'advert', damped: false })
})

it('says nothing when sound is off, or when there is no reception', () => {
expect(receptionCue(direct, 'off')).toBeNull()
expect(receptionCue(null, 'rxtx')).toBeNull()
})

// The gate this replaces refused these outright. A relayed packet is a real
// reception of a real transmitter (the repeater that forwarded it), so it is
// audible -- damped, because it reached us through something.
it('damps a relayed reception instead of silencing it', () => {
expect(receptionCue({ ...direct, hops: 2 }, 'rxtx')).toEqual({ family: 'advert', damped: true })
})

// No filter argument at all: narrowing the map must not narrow what the
// radio is heard to have heard.
it('takes no filter, so a target selection cannot mute anything', () => {
expect(receptionCue.length).toBe(2)
})

it('gives a reception with no identifiable sender the voice of its type', () => {
expect(receptionCue({ hops: 0, rssi: -110, packet_type: 'Trace', sender_id: null }, 'rxtx'))
.toEqual({ family: 'trace', damped: false })
})
})

describe('cueFamily', () => {
it('groups the types a hunter reads differently', () => {
expect(cueFamily('Advert')).toBe('advert')
expect(cueFamily('GroupText')).toBe('channel')
expect(cueFamily('GroupData')).toBe('channel')
expect(cueFamily('TextMessage')).toBe('message')
expect(cueFamily('Trace')).toBe('trace')
})
it('pings in full mode too', () => {
expect(shouldPing(rec, 'full', pass, 0)).toBe(true)

// The measured majority (RESPONSE 71k, REQ 46k, PATH 17k of the newly
// captured traffic, #455) shares the driest voice, or it becomes a drone.
it('puts the network chatter in one dry family', () => {
for (const t of ['Response', 'Request', 'AnonRequest', 'Ack', 'Control', 'Path', 'Multipart', 'RawCustom']) {
expect(cueFamily(t)).toBe('network')
}
})
it('never pings when sound is off', () => {
expect(shouldPing(rec, 'off', pass, 0)).toBe(false)

// An unrecognised type must not fall into a prominent voice by accident --
// same reasoning as #341's chip coverage, in the other direction.
it('defaults an unknown or absent type to the dry family', () => {
expect(cueFamily('SomethingNew')).toBe('network')
expect(cueFamily(null)).toBe('network')
expect(cueFamily(undefined)).toBe('network')
})
it('ignores relayed receptions — only what the hunter heard directly', () => {
expect(shouldPing({ ...rec, hops: 2 }, 'rxtx', pass, 0)).toBe(false)

it('has a family for every type the filter chips can name', () => {
const known = new Set(['advert', 'channel', 'message', 'network', 'trace'])
for (const t of FILTER_PACKET_TYPES) expect(known.has(cueFamily(t.value))).toBe(true)
})
it('follows the active filter set (you hear what the map shows)', () => {
expect(shouldPing(rec, 'rxtx', reject, 0)).toBe(false)
})

// The coalescer decides what survives a burst. It is pure so the rule can be
// pinned: the least important sound must never eat the most important one.
describe('coalesceCue', () => {
const direct = { family: 'network', damped: false }
const relayed = { family: 'network', damped: true }

it('lets a direct cue through even in a burst of relayed ones', () => {
let st = {}
;({ state: st } = coalesceCue(st, relayed, 1000))
const r = coalesceCue(st, direct, 1010)
expect(r.play).toBe(true)
})

it('holds a relayed cue that follows another of its family too closely', () => {
const { state } = coalesceCue({}, relayed, 1000)
expect(coalesceCue(state, relayed, 1000 + CUE_GAP_MS - 1).play).toBe(false)
expect(coalesceCue(state, relayed, 1000 + CUE_GAP_MS).play).toBe(true)
})

it('holds a relayed cue in the shadow of a direct one', () => {
const { state } = coalesceCue({}, direct, 1000)
expect(coalesceCue(state, { family: 'advert', damped: true }, 1010).play).toBe(false)
})

it('lets different families sound close together', () => {
const { state } = coalesceCue({}, { family: 'network', damped: true }, 1000)
expect(coalesceCue(state, { family: 'advert', damped: true }, 1010).play).toBe(true)
})

// Review of #470, measured on the ingestor DB: 39.2% of consecutive receptions
// share a timestamp to the millisecond, in groups of up to 40. Batched BLE
// frames land in one turn and carry one rx_at, so without a gap of their own
// 40 direct cues start at the same ac.currentTime and sum into one transient
// instead of sounding like 40 dits. Direct cues yield to each other; what they
// must not yield to is a relayed one, which is the test above.
it('holds a direct cue that follows another direct one inside the gap', () => {
const { state } = coalesceCue({}, direct, 1000)
expect(coalesceCue(state, direct, 1000).play).toBe(false)
expect(coalesceCue(state, direct, 1000 + CUE_GAP_MS - 1).play).toBe(false)
expect(coalesceCue(state, direct, 1000 + CUE_GAP_MS).play).toBe(true)
})

it('holds a direct cue behind a direct one of another family, since they sum in the same instant', () => {
const { state } = coalesceCue({}, direct, 1000)
expect(coalesceCue(state, { family: 'advert', damped: false }, 1000).play).toBe(false)
})

it('does not mutate the state it is given', () => {
const st = {}
coalesceCue(st, direct, 1000)
expect(st).toEqual({})
})
})

Expand All @@ -130,7 +230,7 @@ describe('createSoundEngine without Web Audio (node / unsupported browser)', ()
const s = createSoundEngine()
expect(() => {
s.setMode('full')
s.ping(-90, 0)
s.cue({ family: 'network', damped: false }, -90, 0)
s.txBlip('discover')
s.txBlip('trace')
s.setMode('off')
Expand Down
Loading
Loading