After months of writing a software modem in Rust, I can move data down a 30-foot mono guitar cable at 27.1 kbps. A dial-up modem from 1995 did 28.8. I very nearly reinvented dial-up, slightly slower.
The project started with a simpler question: what would it take to use an audio port as a network interface? A machine can have no Ethernet jack and no Wi-Fi, but still have a headphone port. Every computer with a speaker and a microphone has an unfiled network interface sitting right there in plain sight.
I called the modem JackRabbit. It uses orthogonal frequency-division multiplexing (OFDM), the same family of signal processing used in Wi-Fi, to carry data over audio. It has 16 protocol modes, channel equalization, and forward error correction. With a TUN interface on top, I also got TCP/IP working over a virtual audio loopback.
Fitting data into an audio waveform
A simple acoustic modem can choose a handful of frequencies and send data with on-off keying or frequency-shift keying (FSK), accepting a few hundred bits per second. I wanted to use more of the available bandwidth.
OFDM divides the channel into subcarriers. The encoder assigns constellation points to them, uses an inverse fast Fourier transform (IFFT) to turn those points into audio samples, and adds a cyclic prefix. The receiver has to find that symbol in the incoming samples before it can reverse the transform and recover the data.
JackRabbit runs at 48 kHz with a 960-point FFT. That makes adjacent subcarriers 50 Hz apart: the sample rate divided by the transform size. The figure below lets you change the window while holding the sample rate fixed.
Hold the sample rate at 48 kHz and vary the FFT window. The frequency-bin marks move closer together as the window grows; at the article’s 960-sample configuration, adjacent OFDM subcarriers are 50 Hz apart.
View source values
| FFT size | Subcarrier spacing | Status |
|---|---|---|
| 240 | 200 Hz | Derived |
| 480 | 100 Hz | Derived |
| 960 | 50 Hz | Article configuration |
| 1920 | 25 Hz | Derived |
| FFT size | Subcarrier spacing | Status |
|---|---|---|
| 240 | 200 Hz | Derived |
| 480 | 100 Hz | Derived |
| 960 | 50 Hz | Article configuration |
| 1920 | 25 Hz | Derived |
The modulation determines how much information each subcarrier carries. The modes range from binary phase-shift keying (BPSK) through 256-point quadrature amplitude modulation (256-QAM). Higher-order modulation packs more bits into a symbol, but the receiver has to distinguish more closely spaced constellation points.
These techniques aren’t tied to radio frequencies. The same math can shape and recover a waveform carried by a 2.4 GHz radio or a speaker cone. The difficulty is recovering it after the audio path has changed it.
Finding the symbol, then recovering the bits
Generating the waveform was the easier half. The receiver gets samples without a shared transmitter clock, through a channel whose response it doesn’t know in advance. Before it can decide which bits arrived, it has to establish timing and correct what happened along the way.
I used Schmidl-Cox preamble detection to find the signal. The preamble repeats half a symbol. Looking for that repeated structure gives the receiver a correlation peak to lock onto, even in noisy audio. That supplies the timing needed to locate the OFDM symbols.
Finding the symbol boundary doesn’t correct the channel. A guitar cable, a Bluetooth codec, and open air have different frequency responses, multipath, and noise floors. Every packet therefore carries pilot subcarriers. The receiver uses them to estimate the channel and equalize each subcarrier before attempting to recover its data.
Some uncertainty remains after equalization. Consumer hardware can clip, automatic gain control changes levels, and Bluetooth codecs are lossy. Forward error correction adds redundancy that the decoder can use to recover damaged data. JackRabbit uses two families: K=7 convolutional codes with Viterbi decoding, the construction used in 802.11a/g, and quasi-cyclic low-density parity-check (LDPC) codes borrowed from 802.11n for modes with high signal-to-noise ratios (SNR) and higher-order QAM.
Both decoders use soft decisions. Rather than immediately reducing a received constellation point to a hard 0/1 guess, they retain information about how confident that decision is. That gives error correction more to work with when the channel has moved a point away from where it should be.
The recording below checks this receiver path through a deterministic software channel. It runs the Rust encoder and streaming decoder with 10 Hz of carrier offset and 20 dB of seeded additive white Gaussian noise, then checks synchronization, correction, and exact payload recovery. It is a reproducible software check, not a hardware or throughput measurement.
A deterministic JackRabbit software loopback verifies synchronization, a 10 Hz carrier-offset estimate, seeded 20 dB noise, CRC success, and exact payload recovery.
The 16 modes trade throughput against channel margin. The AUDIBLE_* modes use BPSK or QPSK in the 1–4 kHz band, keeping the tones comfortable to run in a room through a phone’s speaker and microphone. The WIRED_* modes assume a cleaner cable path and use a shorter cyclic prefix and higher-order modulation. WIRED_TURBO uses 256-QAM with 3/4-rate LDPC.
Cable, speakers, and a virtual network
I measured modem throughput with a 4000-byte payload through a Focusrite Scarlett 2i2, looped back over a 30-foot mono guitar cable. The cable cost about $10 on Amazon. It is an unbalanced TS cable: one conductor and a shield, without the noise rejection of a balanced connection.
| Mode | Modulation | Throughput | Use case |
|---|---|---|---|
UT_FASTEST | 16-QAM | 27.1 kbps | Best overall — wired or good acoustic |
UT_FAST | QPSK | 12.8 kbps | Reliable wired connection |
MT_FAST | QPSK | 6.3 kbps | Medium bandwidth acoustic |
DT_FASTEST | QPSK | 4.6 kbps | Audible band, decent speed |
AUDIBLE_FASTEST | QPSK | 2.8 kbps | Works through phone speakers |
27.1 kbps
Fastest measured modem mode
A 4,000-byte payload through a Focusrite Scarlett 2i2 and a 30-foot mono guitar cable.
The comparison with dial-up needs a bandwidth qualification. V.34 got 28.8 kbps through about 3.4 kHz of usable phone-line bandwidth, roughly 8.5 bits per second per hertz. I had 24 kHz of Nyquist bandwidth available and reached 27.1 kbps. The dial-up engineers made much better use of their channel and operated much closer to the Shannon limit.
The speaker-to-microphone result was roughly 1 kbps, with room acoustics and ambient noise in the path. That is the relevant number for sending data through the air. It is also less of an improvement over a 300-baud acoustic coupler from the seventies than I’d hoped.
I also wanted to know whether the modem could carry traffic a normal computer would recognize as a network. I put a TUN interface on top and transferred a 10 KB file with netcat over a BlackHole 2ch virtual audio loopback:
| Protocol | Burst | Time | Throughput | Status |
|---|---|---|---|---|
WIRED_TURBO (256-QAM, LDPC 3/4) | 2 | 3.1s | 26.6 kbps | md5 pass |
WIRED_TURBO | 8 | 5.6s | 14.6 kbps | md5 pass |
WIRED_MAX (64-QAM, LDPC 3/4) | 2 | 5.6s | 14.6 kbps | md5 pass |
WIRED_MAX | 8 | 6.1s | 13.4 kbps | md5 pass |
Every run passed an md5 checksum on the transferred file. That checks the complete path through OFDM, error correction, MAC framing, and TCP/IP encapsulation. These are virtual-loopback results; the guitar-cable measurements above are a separate test.
Increasing the burst setting affected the modes differently. WIRED_MAX went from 14.6 to 13.4 kbps when the burst setting rose from 2 to 8. WIRED_TURBO went from 26.6 to 14.6 kbps, a drop of about 45%. Sending more packets in flight did not improve either result.
My best explanation for the larger drop is that WIRED_TURBO’s 256-QAM and minimal cyclic prefix leave less margin for the receive buffer to absorb bursty retransmissions before backpressure kicks in. The throughput measurements show the slowdown; they don’t isolate that cause. At 48 kHz, the 26.6 kbps result is already close to the practical ceiling for this configuration once OFDM overhead, error correction, MAC framing, and TCP/IP headers are accounted for.
Letting the better subcarriers carry more
The library uses static bit-loading: every subcarrier in a mode gets the same modulation order for the whole packet. I chose that for the first version because it gives the demodulator one code path. Pick an order conservative enough for the worst subcarrier in the band and the link can work, but the better subcarriers are held to that same limit.
That cost is particularly visible in an acoustic channel. Speakers and microphones have uneven frequency responses. Room reflections can null specific frequencies, while resonances and roll-off leave others stronger or weaker. A few bad subcarriers can limit the whole packet.
Adaptive per-subcarrier bit-loading would use the SNR estimates from the pilot tones to assign more bits to good subcarriers and fewer, or none, to those in a null. The receiver would also need a bit-allocation table in the packet header so it knew how to interpret each one. That table has to survive the same lossy channel as the payload.
OFDM systems such as LTE use adaptive loading; it is the change I would try next to improve acoustic throughput. The wired results have less room left under this configuration’s overhead. Improving the roughly 1 kbps speaker-to-microphone path would make the port useful in more of the situations that started the project.
The library keeps the signal processing separate from the audio device. rustfft and num-complex are its only required dependencies, so it compiles anywhere Rust does. The example binaries bring in cpal when they need to open a device. The modem itself takes samples from a sound card, a file, or a simulated channel in the same way:
use aqua_soundbin::{encode, decode, Protocol};
let data = b"hop the air gap";
let samples = encode(data, Protocol::UT_FASTEST);
// ... transmit samples over audio channel ...
let received = decode(&samples, Protocol::UT_FASTEST)?;
The cable carried 27.1 kbps, and the virtual loopback carried a checksum-valid TCP/IP transfer. The harder problem left is getting more data through speakers, microphones, and the room between them.