Acoustic Link

16-MFSK, 4.0–6.8 kHz, ~187 bps raw · RS(15,11)/GF(16) FEC + erasure flagging · a third signaling channel for filedrop-manual: no camera, no QR, no list

Link settings

Density is a send choice — the receiver reads it from the frame header, so it needs no coordination. The salt is a shared secret that scrambles the payload: both sides must set the same value, and a wrong or empty salt just fails to decode. It hides content from casual listeners but is not strong encryption — encrypt the text yourself first if you need real confidentiality. Both persist in the URL (?k=…&salt=…) for sharing.

Send — speaker

Receive — microphone

gold bar = pilot (3500 Hz) · teal bars = the 16 data tones

Live decode

idle
hex = raw tones as heard · each cell flips to its character once its RS block is corrected · solid teal = CRC-verified. Run the self-test to watch a frame stream in with no hardware.

Self-test — no audio hardware needed

encode → add 15 dB-SNR noise → decode, entirely in RAM

Notes

Demo on one machine: press Listen, then Send — the mic hears the speaker across the air gap between them. Two machines: one listens, the other sends.

Frame format: 250 ms pilot · 8-symbol sync · version + mode + length header block · payload + CRC-16/CCITT, all carried in RS(15,11) codewords (11 data + 4 parity symbols each), each block correcting 2 symbol errors or 4 erasures. Chord density is selectable: the payload lights k = 1, 2, 4 or 8 tones per symbol, and each constant-weight k-of-16 chord is one symbol in GF(24/6/10/13) — so a misdetected chord is still exactly one correctable RS symbol. That trades robustness for rate: 4, 6, 10 or 13 bits per symbol (up to 3.25×). Sync and the header always stay single-tone, so acquisition is mode-independent and the receiver auto-detects the mode. Per-tone amplitude is split 1/k to stay clear of clipping, so denser chords are quieter — the physical robustness/throughput trade.

Validated headless (Node invariant suite): GF(16) arithmetic, RS errata recovery across 400 random error/erasure patterns, clean loopback at 48 k and 44.1 k, random silence padding, AWGN to 6 dB SNR (decodes to 4 dB), 48 k → 44.1 k resample mismatch, 25% gain wobble, all combined, and recovery from a steady in-band interfering tone; pure noise never false-decodes (CRC + sync gate).

Browser-path (cannot be tested headless): mic requires HTTPS and a user grant; the getUserMedia constraints disable echo cancellation / noise suppression / AGC, but some stacks ignore them — OS-level noise suppression can eat the tones; AudioWorklet loads from a Blob URL and falls back to ScriptProcessorNode automatically; on iOS, sending forces the loudspeaker route instead of the earpiece; speaker volume around 60–80% works best; decode passes cost ~100–250 ms of CPU each while listening.

Known limits: RS(15,11) corrects up to 2 wrong tones or 4 erasures per 15-symbol block; a burst ruining 3+ symbols in one block still fails that frame (CRC catches it, resend is the fallback). Broadband noise spread thin across a long frame is the hard case, not localized transients. Range is conference-room scale, not hallway scale.

Deploy: single self-contained file, zero dependencies — drop modem.html at the site root; GitHub Pages serves it at /modem.