Skip to content

About

Send files and messages between devices over the speaker and mic: OFDM up to ~50 kbps, LoRa-style chirps below the noise floor, live spectrogram. Runs in the browser.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

Sonic Modem

Send files and messages between devices over the speaker and microphone, with a live spectrogram. It also has an "analog" mode that paints a picture into the spectrogram. Everything runs in the browser and nothing loads from the internet.

Run it

Hosted: https://ari-ahm.github.io/sonicmodem/. It's served over HTTPS, so phones allow the microphone with no certificate warning. Open it on both devices.

Locally:

python3 serve.py            # https://<lan-ip>:8443/, prints a QR code

Phones only give web pages the microphone over HTTPS, so serve.py makes a self-signed certificate in certs/. The first time you open it on the phone, accept the warning ("Advanced → Proceed"). If the phone can't connect, the firewall is blocking the port: sudo ufw allow 8443/tcp.

On each device, press Enable microphone & speaker. Keep the devices near each other, and use the built-in speaker, not headphones. If the default output looks like headphones, the page switches to the built-in speaker where the browser allows it. Otherwise it refuses to transmit until you pick another output.

Modes

  • Receive: listens all the time. With Talk back on it answers the sender, which lets the sender choose the rate.
  • Send → Two-way: needs the receiver's Talk back. It opens with a chirp handshake: SF7 first (≈ 1 s), then SF9 and SF11 (≈ 13 s) if nobody answers, so the slow long-range attempt is only made when it's needed. If the handshake was loud enough: probe → the receiver measures the SNR of every carrier and replies with a bit-loading plan → bursts → after each burst the receiver reports how much it has and gives a new plan. Bursts grow from ≈ 4 s to ≈ 8.5 s while the link stays clean, so less time goes to waiting. If OFDM can't get through, the whole transfer stays on chirps at the SF that worked, with an ack after each batch.
  • Send → Two-way, full duplex (experimental): both ends leave 13.4–17.6 kHz free and the sender never waits. The receiver acks each burst with a quiet SF7 chirp frame in that hole (3 kHz wide) while the next burst plays. The receiver notches the hole out before its FFT so its own ack doesn't leak into the data. It saves the waiting but gives up ≈ 20% of the carriers, so in tests it was no faster than half duplex (simulated 100 KB: 26.7 s vs 25.0 s; over the air on one laptop: slower).
  • Send → One-way: needs no reply. Robust / normal / fast OFDM, the last two-way rate, or below-noise chirps. It keeps repeating fountain-coded packets until you stop it. Any number of receivers can join at any time.
  • One-way → Below noise (SF7 / SF9 / SF11): LoRa-style chirp spread spectrum for when the signal is quieter than the background. It's slow (≈125 / 34 / 7 bps) and meant for short messages. Receivers always listen for it on both of its bands (2.5–8.5 kHz and 11–17 kHz), at every spreading factor, alongside the fast mode.
  • Analog image: turns an image, or some text, into sound so that it shows up on any spectrogram that is listening. Nothing is decoded.

How it gets its speed

  • OFDM: 1024-point FFT at 48 kHz (46.9 Hz spacing) with a 256-sample cyclic prefix. It uses 418 carriers from 420 Hz to 20 kHz, and every 10th carrier is a pilot.
  • Per-band bit loading. Each group of 8 carriers gets 0–10 bits per carrier, from BPSK up to 1024-QAM, based on its measured SNR. One code rate per burst (½, ⅔, ¾, ⅚), whichever gives the highest throughput. Dead bands are switched off, which gives their power to the others.
  • Closed-loop margin. The receiver adds a safety margin (dB) after a burst that loses packets and lowers it after a clean one.
  • A K=7 convolutional code with soft-decision Viterbi. Each LLR is weighted by its carrier's SNR. The interleaver covers the whole burst, so a clap or click is spread thinly across all packets.
  • Fountain code (systematic random linear code over GF(2)): any ~K+2 packets rebuild the file, so lost packets never need to be resent one by one.
  • Sync: a chirp preamble with matched-filter detection. The receiver estimates the clock mismatch between the two devices and resamples every symbol to correct it. It tracks phase from the pilots and updates the channel estimate from decisions. Tested up to ±300 ppm.

Below the noise floor (js/css.js)

Each symbol is an up-chirp across 6 kHz, cyclically shifted by s/2^SF. The receiver multiplies by the conjugate chirp, which collapses a whole symbol into one FFT bin, a processing gain of 2^SF. A frame is 8 up-chirps, 2 down-chirps, then data. The down-chirps separate timing offset from frequency offset. Soft decisions use a log-I₀ metric and log-sum-exp over all 2^SF hypotheses, with a RAKE over echoes. Then deinterleaving, the K=7 rate-½ code, and CRC32. Each frame carries one fountain block.

Real audio paths are messier than the textbook. These are the parts that made it work over real air:

  • Clock mismatch comes from the phase step between the coherent preamble chirps, which is the carrier term −f_c·ε·T_s. Each symbol is then resampled at pos + n·(1+ε).
  • Offset wander. Adaptive resamplers make the offset drift around. A blind Viterbi search over a quarter-bin offset path follows it, and the CRC picks among whole-bin ambiguities.
  • Echoes. An echo as strong as the direct path (a desk or screen reflection, ~5 chips) is common. The RAKE allows taps on both sides of the sync reference.
  • Audio dropouts. A phone's output can underrun mid-frame and insert one buffer of silence (192 samples, 4 ms, in a real recording). Every symbol after it then reads a constant number of bins off while looking perfectly clean, so the frame fails its CRC at any SNR, and the longer SF11 frames catch it most. When a frame fails, the receiver looks for the jump (a hole in the envelope, a symbol split between two peaks, or for a strong frame, every shift up to a quarter symbol) and lets the CRC confirm. The app also asks for large output buffers so this happens less often.

Sensitivity, as in-band SNR (signal vs. noise in the same 6 kHz). Negative means quieter than the noise.

simulated (echoes, ±300 ppm) measured, laptop speaker → own mic
SF7, 127 bps ≈ −9 dB 8/8 frames at −1 … −2 dB
SF9, 34 bps ≈ −15 dB 6/6 at −6 … −8 dB
SF11, 7 bps ≈ −21 dB 2/2 at −14 and −17.5 dB

The measured column shows the quietest levels tested, not the limit. LoRa's datasheet floors at the same spreading factors are −7.5, −12.5 and −17.5 dB.

Tests

npm test                                  # the quick suite CI runs before deploying
node test/phy.js bits=6 snr=25 ppm=100   # one frame through a simulated room
node test/link.js snr=15 ppm=100         # two-way transfer (handshake, probe, bursts), both directions simulated
node test/twoway.js snr=20 duplex=1      # same in a shared real-time room, so full duplex can overlap
node test/calibrate.js                    # re-derive the SNR thresholds table (slow)
node test/acoustic.js gen out.wav         # make a probe + data frame to play
node test/acoustic.js dec rec.wav         # decode a recording, per-band SNR report
node test/deep.js sf=9 snr=-15 ppm=100 noise=1   # below-noise frames + false-alarm check
node test/deep.js sf=9 snr=0 slip=192             # same, with a 4 ms dropout in every frame
node test/deep-link.js sf=7 snr=-7               # below-noise message end to end
node test/deep-acoustic.js gen out.wav 9          # frames to play; `dec rec.wav` decodes

About

Send files and messages between devices over the speaker and mic: OFDM up to ~50 kbps, LoRa-style chirps below the noise floor, live spectrogram. Runs in the browser.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages