- Rust 82.2%
- JavaScript 10.5%
- Shell 2.5%
- HTML 2.3%
- Dockerfile 1.4%
- Other 1.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The maintainer reported a working transfer between two devices with the fast profile: a laptop sending (with both the CLI and the web page) and an iPhone receiving through the web page. The received file was identical to the original. This is also the first time the web page ran in a real browser, so the status no longer claims it never did. The robust profile across two devices remains unconfirmed. Documentation only. The checks were not run: this environment has no Rust toolchain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
| docs | ||
| src | ||
| tests | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| Containerfile | ||
| README.md | ||
| SECURITY.md | ||
airgap
File transfer over sound, in near-ultrasound (18–19.85 kHz), using a fountain code: the sender broadcasts in an endless loop, the receiver can start at any time and rebuilds the file piece by piece, whatever frames it missed.
Written in Rust (FFT, filters, error correction and the fountain code are implemented in-tree). Linux command-line tool, plus a web version (WebAssembly) that can send or receive from a browser, phones included: see web/README.md.
sender: airgap send document.pdf
receiver: airgap recv
[##########----------] 41/80 blocks (51%), ~40s left | band level -52 dBFS | frames ok 43 / bad 2 | quality 18 dB
Received "document.pdf" (10.0 KiB) -> ./document.pdf: 82 valid packets for 80 needed, after 1m19s of listening.
Is it really inaudible?
Yes, for most adults, with some caveats:
- The whole signal sits between 18.0 and 19.85 kHz. A Kaiser FIR band-pass (511 taps) removes
everything below ~17.4 kHz at −80 dB. Measured: energy emitted in the audible range
(<17 kHz) is 130 dB below the useful signal (
airgap simprints it on every run). - Bursts start and end with smooth ramps, so there are no clicks.
- Caveats: children and teenagers sometimes hear up to 19–20 kHz. Dogs and cats hear it. A speaker driven too hard can produce audible distortion. Keep the volume reasonable, and if you hear anything (hiss, ticking), lower it.
Install
cargo build --release # Rust >= 1.87
cp target/release/airgap ~/.local/bin/
Without Rust on the machine, build everything in a container (podman or docker):
podman build -o site . # static web site in ./site, ready to host
podman build --target cli -o bin . # static Linux binary in ./bin/airgap
See the Containerfile for a local test server (--target serve).
Playback and capture go through pacat (package pulseaudio-utils, works with PulseAudio
and PipeWire) or aplay/arecord (alsa-utils). Otherwise use --player/--recorder
or the --raw pipes.
Usage
# Broadcast in a loop (Ctrl-C stops cleanly at the end of the current frame)
airgap send file.zip [--profile fast] [--volume 0.5] [--device NAME]
# Receive (stops as soon as the file is complete, writes it under its original name)
airgap recv [--dir dir/] [-o output.zip] [--keep]
# Test the whole chain without hardware: sender -> simulated room -> receiver
airgap sim file.zip --snr 8 --ppm 150 --drop 0.3 --skip 40
Other ways to connect audio:
airgap send f.txt --wav tx.wav --count 100 # make a WAV to play with anything
airgap recv --wav recording.wav # decode a recording (any sample rate)
airgap send f.txt --raw | aplay -t raw -f S16_LE -r 48000 -c 1
arecord -t raw -f S16_LE -r 48000 -c 1 | airgap recv --raw
airgap send f.txt --raw --count 50 | airgap recv --raw # perfect digital loopback
airgap --help lists every option.
Throughput
Two speed profiles, chosen on the sender (--profile); the receiver recognizes each frame's
profile by itself, so recv needs no option.
| profile | speed | use it when |
|---|---|---|
robust (default) |
~83 bytes/s | always works first: echoing rooms, distance, speakers facing a wall |
fast |
~254 bytes/s (3x) | short range, receiving microphone facing the speaker, quality above ~20 dB |
| file | robust | fast |
|---|---|---|
| 1 KiB | ~17 s | ~6 s |
| 10 KiB | ~2 min | ~45 s |
| 100 KiB | ~21 min | ~7 min |
| ~1 MiB (max) | ~3.5 h | ~70 min |
Start with robust, look at the receiver's quality and packet counts, then try fast. If the
receiver shows many bad packets with fast, go back to robust.
Compress before sending (zstd -19, xz).
Tips for real audio
- Start with the system volume around 50% and watch the receiver's status line:
band level: energy captured in 17.6–20.3 kHz. If it does not move while the sender runs, the microphone or the speaker does not reproduce these frequencies (see point 3).quality: frames get through above ~10 dB. Below ~8 dB, move the devices closer or raise the volume.
- Disable microphone processing: noise suppression, echo cancellation (PipeWire/PulseAudio
echo-cancelmodule, "noise suppression" filters) erase ultrasound. Disable "audio enhancements" on the speaker side too. - Built-in laptop microphones and small speakers (including monitor speakers) often lose sensitivity above 18 kHz. An external speaker and a decent USB microphone give several meters of range.
- Losses are fine: the fountain code fills the gaps with any other frames. Someone walking by, a door slamming or a conversation in the room do not stop the transfer.
Troubleshooting the audio path
When nothing is received, check the devices, find out where the sound really goes, then measure what survives the trip. docs/choosing-devices.md explains how to pick a speaker and a microphone and how to read every output below.
airgap devices # which speakers/microphones can work, with a verdict for each
airgap send f.txt --device 27g2g4 # --device: ID, part of the name, or full name
# send/recv print the device the stream is REALLY routed to
airgap recv -v # same for the input, plus one log line per detected frame
send, recv, probe and monitor look up their own stream in PulseAudio/PipeWire (with
pactl) and print the real route. This matters because the sound server remembers a device
per application and can ignore the default. They also warn about known traps: muted device,
low volume, HDMI/DisplayPort monitor speakers (often cut above 16–18 kHz), Bluetooth (codecs
low-pass around 16 kHz), a monitor source (records what the computer plays, not the
microphone), echo cancellation or noise suppression sources.
Then measure the frequency response of the whole chain, speaker -> air -> microphone:
# sender: test tones from 15 to 21 kHz; --audible adds a 1 kHz double beep per cycle
# so you can hear which speaker is playing
airgap probe --audible
# receiver: live spectrum, best level of each tone above the noise floor, and a verdict
airgap monitor
tone best level above noise floor (* = data band) now
16.0 kHz ##################################...... 52 dB 50
17.0 kHz ###########################............. 41 dB 40
18.0 kHz * #####................................... 8 dB 6
19.0 kHz * ........................................ 0 dB 0
FAIL: the path carries tones up to about 17.0 kHz but the data band is lost (worst 0 dB). ...
A full-range sweep (--from 1000 --to 21000 --step 1000, audible) checks that sound flows at
all. Note: a loopback test on a single computer may succeed through a monitor source without
the microphone being involved. recv prints the input route, so check it.
Security
Audio is a broadcast medium. Anyone within earshot with a microphone receives the file. There is
no encryption and no authentication: the CRC32 detects transmission errors, not tampering.
Encrypt and sign beforehand if needed (e.g. age, minisign). See SECURITY.md.
How it works
file -> object (name + data) -> K blocks of 128 B -> fountain code -> 144 B packets (CRC32)
-> scrambler -> K=7 r=1/2 convolutional code -> interleaver -> OFDM DQPSK -> 18-20 kHz filter -> speaker
Fountain code (src/fountain.rs): random linear code over GF(2). Packet number esi is the
XOR of a subset of blocks. For esi < K it is the block itself (systematic part: with no loss,
exactly K frames suffice). Beyond that, each block is included with probability 1/2, drawn by a
PRNG seeded with (file id, esi), so the receiver rebuilds the mix from the header. Any K
independent packets are enough. With K+n packets received, failure probability is ~2⁻ⁿ, so about
K+1.6 are needed on average. Decoding is incremental Gaussian elimination: every useful packet
moves the progress bar one step. The file id is the CRC32 of the content, checked at the end.
Modem (src/modem.rs):
| silence | chirp | guard | 2 ref symbols | data symbols | silence |
| profile | chirp | symbol (FFT + cyclic prefix) | carriers | modulation | code | packets per frame |
|---|---|---|---|---|---|---|
| robust | up, 18 -> 19.85 kHz | 2048 + 2048 (85 ms) | 80 x 23.4 Hz | DQPSK | 1/2 | 1 |
| fast | down, 19.85 -> 18 kHz | 1024 + 512 (32 ms) | 40 x 46.9 Hz | D8PSK | 2/3 | 4 |
- Detection: matched filter on the chirp, normalized by local energy (correlation between 0 and 1). The received signal is band-pass filtered first, so voice, music and low-frequency noise do not interfere.
- Profile detection: the receiver runs one matched filter per chirp direction and decodes each frame with the profile of the strongest one.
- Robust: the 43 ms cyclic prefix absorbs room reverberation, even when the microphone hears more echo than direct sound (speakers facing a wall, screen speakers firing backwards).
- Fast: shorter guard, 8 phases instead of 4, less redundancy (punctured code) and 4 packets per frame, for setups where the direct sound dominates.
- Differential PSK in time: no channel estimation needed, and timing offsets cancel out.
- Clock drift between sound cards: the 2 identical reference symbols measure the phase rotation per symbol, which is then tracked symbol by symbol (M-th power estimator). Tested up to ±250 ppm.
- Error correction: K=7 (171/133) convolutional code with soft-decision Viterbi, and a pseudo-random interleaver over the whole frame. A CRC32 per packet rejects corrupted ones, and the fountain code makes up for them.
Simulation results (airgap sim). Robust: no frame lost down to ~7 dB in-band SNR, in a
room with RT60 300 ms and 5 dB more echo than direct sound (--reverb 300 --drr -5), and with
±200 ppm clock mismatch. Fast: no loss at 15 dB SNR, in a room with RT60 150 ms and direct sound
10 dB above the echoes, and at ±250 ppm; it degrades quickly with more echo. Beyond that, frames get lost, but the transfer still completes as long
as some get through (the fountain code compensates).
Layout
| file | role |
|---|---|
src/main.rs |
CLI: send, recv, sim, devices, probe, monitor |
src/modem.rs |
OFDM modulation / demodulation, synchronization |
src/fountain.rs |
fountain code (encoder, Gaussian elimination decoder) |
src/fec.rs |
CRC32, scrambler, interleaver, convolutional code + Viterbi |
src/packet.rs |
packet and transmitted object formats |
src/receiver.rs |
per-transfer packet collection |
src/dsp.rs |
complex numbers, FFT, FIR filters, streaming convolution, spectrum |
src/audio.rs |
WAV, raw streams, driving pacat / aplay / arecord |
src/sim.rs |
simulated acoustic channel (echoes, clock drift, noise) |
src/diag.rs |
pactl parsing: devices, real stream routes, warnings |
web/ |
WebAssembly build and browser page (sender and receiver) |
src/probe.rs |
probe tones and spectrum analyzer for probe / monitor |
cargo test runs the unit tests and two end-to-end tests through the simulated channel.
Contributing
See AGENTS.md for the architecture, the invariants that must not break, the checks to run and the commit conventions. Security issues: SECURITY.md.