Skip to content

app: a richer sound mode, and the channel budget a per-prefix voice has to fit in #649

Open

Description

@khagele

Kasper, 13 September: a new sound mode with pulsing LFOs and instruments, and identification by a unique timbre per sender prefix, down to a 1-byte prefix.

Recorded rather than shaped: the form is still open. What follows is what the engine already does, the two rules the idea runs into, and the channel budget any answer has to fit in, so none of it has to be re-derived.

What already exists

  • Instruments. VOICES carries five families (advert, channel, message, trace, network), chosen by packet type, plus the damped treatment for relayed traffic. The reasoning above the table: "families, not one voice per chip: four to six timbres is what anyone tells apart in a 35-95 ms event in a moving car."
  • LFOs. The bed runs three noise layers at lfoHz 0.07 / 0.11 / 0.05 with panning at 0.013 / 0.021 / 0.009 Hz. Deliberately slow: swells of 9 to 110 seconds, under the threshold where you catch them moving (app: the ambient soundbed is thin and static, give it space and movement #496).
  • Three modes, cycled by the FAB: off / rxtx / full, switched in one place (sound.js:581).

The two rules in the way

Sound is always real (#145, #255, docs/2026-07-16-sound-modes.md): every information-carrying sound corresponds to an actual event. The bed is allowed precisely because it carries nothing. #568 is what enforcing that looks like: a 1.2 Hz pulsing tone that carried no information was removed, because "a pulsing square is the most attention-seeking shape in the engine, spent on the one voice that carries no information by design".

Identity is not an audio dimension. From sound.js, above CUE_FAMILY_BY_TYPE: the type survives even when the sender cannot be named (#454), so the instrument carries WHAT is transmitting even when nothing can say WHO, "which is why identity is not an audio dimension". AGENTS §7 is the same refusal in the data: "merging starts at 2 bytes; a 1-byte path hash is 1-in-256, too coarse to attribute", and for the app "Do not loosen this".

#496 also ruled one thing out by name: "making the bed react to mesh activity. That would put data in the ambient layer, which is what the cues are for."

The channel budget of one cue

channel carries today
pitch RSSI, reserved: "it carries the signal strength and nothing else"
length and gain RSSI
wave, attack, partial, noise strike packet-type family
500 Hz lowpass + 4-tap echo relayed
stereo position nothing

Panning appears only in the bed (sound.js:441) and the generative music (:492, :547). The cue path is og → master, or through the damping chain to master, and pop() likewise. So pan is the one free channel.

The finding that matters for a per-prefix voice

A 1-byte prefix is a path_hash, and those arrive on relayed FLOOD packets. That is exactly the traffic already pushed through the 500 Hz lowpass and the echo, so a timbre distinction lands on the least audible material in the engine. Panning survives it, because panning happens after the filter in the graph.

Also worth stating plainly: 256 values cannot be told apart as timbres in a 35-95 ms event. What a byte can carry is sameness rather than identity: "this is the same thing again, from the same place in the field" versus "something new arrived". That claims no more than the screen already does when it prints #64.

Three shapes, with what each costs

  1. Stereo position carries the byte, timbre stays the packet type, pitch stays RSSI. Uses the only free channel, survives the damping, promises recognition rather than identification.
  2. A voice for the selected target only. Your own choice, not a claim about who; the audio twin of the map's highlight. One voice added, the timbre budget stays intact.
  3. Timbre per prefix. Requires dropping the damping for those cues, which is what distinguishes a relayed reception from a direct one (app: two thirds of what the radio hears is permanently silent, and the filter mutes the rest #468, feat(app): make every recorded reception audible, one instrument per type (+ sound tweaks) #470). That trade is the reason this is not simply a tuning change.

The richer palette itself (more instruments, audible movement) is uncontroversial and collides with nothing, whether it lands as a fourth mode or inside full.

To measure before choosing

How many distinct 1-byte values actually occur in a session. A signature space is worth building for a handful of recurring bytes and worthless for two hundred, and nothing in the repo answers that today.

Neighbours

A fourth FAB stop reopens #255, which collapsed four states to three on purpose, and interacts with #620 (the ring counts on-states only). Open and adjacent: #602 (the transmit cues are louder and more frequent than the receptions they sit over) and #301 (sound keeps running when backgrounded).

Activity

  1. khagele commented on Sep 13, 2026

    @khagele
    ContributorAuthor

    Decided (Kasper, 13 September): an exhibition mode, gated in Settings

    The ambition that shapes this: "een rijke complexe geluidservaring die niet zou misstaan op een Glow of hackerspace". That is a different context from hunting, and it is what resolves the tension in the description above. At an installation nobody steers by ear, so the rules that protect the hunting modes (sound is always real, identity is not an audio dimension, the bed does not react to mesh activity) are guarding a user who is not in the room. An exhibition mode gets its own rules on purpose, fed by the same live mesh data.

    Samples are in scope there, from the start, rather than synthesis-only.

    It is off until you turn it on. A "mesh expo" toggle in Settings enables it; until then the FAB stop does not exist and the sound cycle stays off / rxtx / full. Two reasons that shape is right here: the cycle is what a hunter taps blind while driving, and an exhibition mode in that cycle is a wrong tap away at exactly the wrong moment; and the samples only have to exist once someone asks for them.

    What that touches

    • The cycle. SOUND_MODES is a flat array and nextSoundMode is indexOf plus a modulo (sound.js:28-35), so the fourth stop has to be conditional on the setting rather than a fourth entry. loadSoundMode already does the right thing at the other end: SOUND_MODES.includes(v) ? v : 'off', so a stored expo falls back to off by itself once the setting is disabled, the same way the retired ping/ambient/music values were handled.
    • The ring. The sound FAB draws fabRingSvg(idx, SOUND_MODES.length, { offIndex }) (app.js:2805), so the segment count changes with the setting. app: the node-positions ring spends a segment on off, so two on states read as three #620 is already changing what that ring counts; the two want to land in a consistent order.
    • The toggle itself. Same shape as Share my node name: an ss-check-row with its own storage key (app.js:2118, :2232, settings.js:56-61). It must not light the settings dot: app: one dot on the settings button stands for four unrelated things #635 decided that dot means the attenuator and nothing else.

    Getting samples in without reopening the cache decision

    app/nginx.conf serves /assets/ with Cache-Control: public, max-age=31536000, immutable, and Vite hashes build assets into that directory. So samples shipped as build assets are fetched once and then come from the browser cache, and sw.js can stay exactly as deliberate as it is ("it deliberately does NOT cache").

    Two conditions make that hold:

    • They load when the mode is enabled, not at startup. The startup path is where app: dismissing the float window leaves the app on a launch screen it never leaves #617 lives; nothing new belongs in it.
    • The weight is deliberate. The whole app is 812K built today, 680K of it one JS chunk. A sample pack is not a rounding error against that, and a minute of stereo ambience is bigger than everything currently shipped. Whatever the set is, it gets a budget before it gets recorded.

    The licensing half of the original rejection does not apply: that was "a public repo can't ship Artlist tracks". Material made for this does not carry that problem.

    What does not change

    The hunting modes keep their rules. Nothing in this comment relaxes "sound is always real" or puts identity into audio for off / rxtx / full; the channel budget in the description still governs those.

  2. khagele commented on Sep 13, 2026

    @khagele
    ContributorAuthor

    Casting the expo mode (Kasper, 13 September)

    Decided: cast the rendered stream, not a URL. The Chromecast shows what the phone renders, rather than loading an expo view of its own.

    What there is to reuse

    floatreadout.js already does the hard half: a canvas, canvas.captureStream(), and a <video> fed by it. That is the same shape a castable element needs.

    Two of its choices are deliberately the opposite of what casting wants, and both have to be inverted for this mode rather than changed globally:

    • Frames on demand. The float stream is captureStream(0) with track.requestFrame() on every draw, so "frames flow only when requestFrame says so, the stream costs nothing between receptions and nothing depends on requestAnimationFrame, which a hidden page never runs". An installation wants a continuous frame rate.
    • Silent by design. video.muted = true, because the float readout is a picture of a number. An expo stream needs sound, which means a MediaStreamAudioDestinationNode off the engine's master bus, its track merged into the canvas stream so one element carries both.

    The thing to test before building anything

    Whether a MediaStream-backed <video> can be cast at all. Both routes expect a media source they can fetch or demux: the Remote Playback API and the Cast SDK are built around URLs and MSE, not around a stream generated in the page. If the platform refuses it, this decision inverts by itself and the fallback is the route not chosen here: cast a URL and let the receiver render.

    That is a half-hour experiment with a real Chromecast, and it decides the whole shape. It belongs before any engine work.

    Two consequences worth knowing now

    Not in scope here

    The float readout keeps its current behaviour. Its canvas already has open work of its own (#615 on the 4:3 canvas in a 16:9 window, #616 on the fullscreen step), and none of that changes because a second, differently-configured stream exists for the expo mode.

  3. khagele commented on Sep 21, 2026

    @khagele
    ContributorAuthor

    Two references for the expo mode's sound (Kasper, 21 September)

    They are the two ends the mode sits between.

    Ólafur Arnalds, "月夜": https://open.spotify.com/track/75pBoutIvdGz7YuiCfLYMZ. Reference for timbre and for how a piece builds. Acoustic material, so this end needs samples.

    Islandy, a mini-game on Nanogram: https://nanogram.app/game/df82e8f6-7758-4257-8ca8-8a4544b425e1. Its score is generated in the browser with Web Audio and no samples. Read from the game's source:

    voice how it is made
    pluck a sine plus a short second sine at 2.01× the frequency, 0.6 s decay, sent into a feedback delay (0.29 s, feedback 0.34, lowpass 1500 Hz)
    pad two triangles per note at ±4 cents, lowpass 800 Hz, 1.4 s fade-in
    bass one sine on the root per bar
    melody a random walk through a note pool per chord; a fixed chance per step, and a density that falls from 0.62 by day to 0.3 at night
    ambience bird and cricket chirps, also oscillators

    76 BPM in eighth notes over a four-chord loop (Cmaj9, Am7, Fmaj7, G). The only buffer is 2 s of generated noise.

    What this adds to the decision of 13 September: samples are in scope and get a weight budget first. The Islandy end costs no bytes and uses the same technique as sound.js, so it shows how much of the palette fits before any sample is recorded. In the expo mode the density input can be mesh activity, where the game uses the time of day.

    Not done: nobody has compared the two by ear against the current full mode.

  4. khagele commented on Sep 27, 2026

    @khagele
    ContributorAuthor

    Two more references for the expo mode's sound (Kasper, 22 September)

    Philip Glass, and his score for Koyaanisqatsi (1982). Noted by Kasper as sound inspiration, next to the Arnalds and Islandy references above. What I read in it for this mode: short repeated figures that change by adding or dropping a note at a time, so the music keeps moving without starting over. How the mode would use that is not decided.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions