I Nearly Hit Dial-Up Speeds Over a Guitar Cable

A from-scratch OFDM modem in Rust: almost 28.8k over a 30-foot guitar cable, plus a checksum-valid TCP/IP link over virtual audio.

Signals#rust#dsp#ofdm#modems#audio

The result, up front: after months of writing a software modem in Rust, I can move data down a 30-foot mono guitar cable — the $10 Amazon special — at 27.1 kbps. If that number rings a bell, it’s because a dial-up modem from 1995 did 28.8. Modern hardware, a systems language, a 48 kHz audio interface, the full toolbox of Wi-Fi-era signal processing — and I very nearly reinvented dial-up, slightly slower.

That’s the joke, and I want it on the record before the acronyms start. But the itch that started the project is real, and it’s the same one every network engineer gets staring at an air-gapped machine: no Ethernet jack, no Wi-Fi — but there’s a headphone port. Sound is a channel. Every computer with a speaker and a microphone has an unfiled network interface sitting right there in plain sight.

That itch became JackRabbit — a from-scratch OFDM audio modem, written in Rust, that turns any audio path into a data link. Not a proof-of-concept squeal that moves ten bytes over thirty seconds. A real PHY layer: 16 protocol modes, forward error correction, channel equalization, and — at the fast end — enough throughput to carry actual TCP/IP traffic over a TUN interface. At, again, roughly the speed your family’s phone line managed thirty years ago.

Why audio, and why this is harder than it sounds

The obvious approach to “send data as sound” is what acoustic modems have done for years: pick a handful of frequencies, use on-off keying or simple FSK, and accept a few hundred bits per second. That works, but it leaves almost all of the channel’s capacity on the table.

The techniques that make Wi-Fi and LTE fast — orthogonal frequency-division multiplexing, quadrature amplitude modulation, forward error correction — aren’t tied to radio frequencies. They’re just signal processing. If you can shape a waveform and demodulate it, the same math works whether the carrier is a 2.4 GHz radio wave or a puff of air moving a speaker cone. So I built the audio-band equivalent of a Wi-Fi PHY: OFDM with a 960-point FFT at 48 kHz, 50 Hz subcarrier spacing, modulation from BPSK up through 256-QAM, and two families of forward error correction — K=7 convolutional codes with Viterbi soft-decision decoding (the same construction as 802.11a/g), and quasi-cyclic LDPC codes borrowed from 802.11n for the high-SNR, high-order-QAM modes.

Resolve OFDM subcarrier spacing
A deterministic model of Δf = Fs / N, not a throughput measurement. The 960-sample state is the modem configuration reported here.

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
Representative subcarrier spacings derived from a fixed 48 kHz sample rate.
FFT sizeSubcarrier spacingStatus
240200 HzDerived
480100 HzDerived
96050 HzArticle configuration
192025 HzDerived
Representative subcarrier spacings derived from a fixed 48 kHz sample rate.
FFT sizeSubcarrier spacingStatus
240200 HzDerived
480100 HzDerived
96050 HzArticle configuration
192025 HzDerived

Before anyone feels too smug about matching 1995, it’s worth saying who actually wins this comparison. A V.34 modem squeezed 28.8 kbps out of a phone line with about 3.4 kHz of usable bandwidth — roughly 8.5 bits per second per hertz, achieved on mid-nineties silicon by people who had to care about every last decibel. I had 24 kHz of Nyquist bandwidth to play with and landed in the same place. The dial-up engineers were operating much closer to the Shannon limit than I am. My number only sounds bad until you remember theirs was a miracle.

The part that actually took the time: finding the signal at all

Anyone can build an OFDM encoder in an afternoon — IFFT the constellation points, add a cyclic prefix, done. The demodulator is where all the real work lives, because unlike a radio receiver locked to a carrier frequency, an audio channel gives you almost nothing for free:

  • No clock. The transmitter and receiver don’t share a sample clock, so you need to find the exact start of each OFDM symbol from the waveform alone. I used Schmidl-Cox preamble detection — a repeated-half-symbol structure that produces a sharp, reliable correlation peak even in noisy audio, which is what the receiver locks onto to establish timing.
  • No fixed channel. A guitar cable, a Bluetooth codec, and open air are three wildly different channels — different frequency response, different multipath, different noise floor. So every packet carries pilot subcarriers, and the receiver does per-subcarrier channel estimation and equalization before it even tries to demodulate data.
  • No guarantee the channel is linear or clean. Consumer audio hardware clips, AGC kicks in, Bluetooth codecs are lossy. That’s what the FEC is for — Viterbi and LDPC both do soft-decision decoding, using the actual constellation confidence rather than a hard 0/1 guess, which buys back a lot of the margin the channel eats.

The receiver path below is the smaller, reproducible check behind that signal-acquisition work. It runs the Rust encoder and streaming decoder through a deterministic software channel with 10 Hz of carrier offset and 20 dB of seeded additive white Gaussian noise. This verifies synchronization, correction, and payload recovery; it is deliberately not a hardware or throughput measurement.

Verify OFDM carrier-offset recovery under deterministic noise

A deterministic JackRabbit software loopback verifies synchronization, a 10 Hz carrier-offset estimate, seeded 20 dB noise, CRC success, and exact payload recovery.

Sixteen protocol modes exist because there’s no single right answer to “how aggressive should the modem be.” A phone’s built-in speaker and microphone are a terrible, band-limited, echo-y channel, so the AUDIBLE_* modes stay conservative — BPSK/QPSK, confined to 1–4 kHz so the tones stay comfortable to have running in the room. A literal audio cable between two machines is a near-ideal channel, so the WIRED_* modes can be reckless — minimal cyclic prefix, 256-QAM, 3/4-rate LDPC (WIRED_TURBO).

The numbers

Measured with a 4000-byte payload through a Focusrite Scarlett 2i2, looped back over — and I mean this literally — a 30-foot mono guitar cable that cost about $10 on Amazon. That’s an unbalanced TS cable: one conductor, one shield, no noise rejection, and thirty feet of it acting as a modest antenna for whatever hum the room offers. It is not a cable anyone would specify for a data link, which is part of the point:

ModeModulationThroughputUse case
UT_FASTEST16-QAM27.1 kbpsBest overall — wired or good acoustic
UT_FASTQPSK12.8 kbpsReliable wired connection
MT_FASTQPSK6.3 kbpsMedium bandwidth acoustic
DT_FASTESTQPSK4.6 kbpsAudible band, decent speed
AUDIBLE_FASTESTQPSK2.8 kbpsWorks 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.

So: 27.1 against dial-up’s 28.8. Close enough to sting. Over the air — actual speakers to an actual microphone, with room acoustics and ambient noise in the loop — throughput drops to roughly 1 kbps, which is the honest number for the scenario people actually picture when they hear “data over sound.” That one doesn’t even beat the 300-baud acoustic couplers from the seventies by as much as I’d like.

The more interesting test, to me, was proving the PHY layer could carry something a normal computer would actually recognize as a network. I put a TUN interface on top of the modem and pushed real TCP/IP over it — a 10 KB file transfer via netcat, over a BlackHole 2ch loopback:

ProtocolBurstTimeThroughputStatus
WIRED_TURBO (256-QAM, LDPC 3/4)23.1s26.6 kbpsmd5 pass
WIRED_TURBO85.6s14.6 kbpsmd5 pass
WIRED_MAX (64-QAM, LDPC 3/4)25.6s14.6 kbpsmd5 pass
WIRED_MAX86.1s13.4 kbpsmd5 pass

Every run passed an md5 checksum on the transferred file — end-to-end correctness through OFDM, FEC, MAC framing, and TCP/IP encapsulation, not just “the demodulator produced plausible-looking bits.” One thing that surprised me: WIRED_MAX’s burst 2 and burst 8 numbers land within a few percent of each other (14.6 vs. 13.4 kbps) — the link is already close to saturated at burst 2, so pushing more packets in flight barely moves the needle. WIRED_TURBO tells a different story: burst 2 holds 26.6 kbps, but burst 8 falls to 14.6 kbps, a genuine ~45% drop. My best explanation is that WIRED_TURBO’s aggressive settings (256-QAM, minimal cyclic prefix) leave less margin for the receive buffer to absorb bursty retransmissions before backpressure kicks in — a mode that fast has less room to be sloppy about flow control. At 48 kHz, 26.6 kbps is close to the practical ceiling once OFDM overhead, FEC, MAC framing, and TCP/IP headers are all accounted for.

What I’d still change

The library does static bit-loading — every subcarrier in a given mode uses the same modulation order, chosen once for the whole packet. Real OFDM systems, LTE included, do adaptive per-subcarrier bit-loading: measure the SNR on each subcarrier from the pilot tones, then assign more bits per symbol (higher QAM order) to the subcarriers with good SNR and fewer bits — or none — to the ones sitting in a null. I skipped this for the first version because static bit-loading is a known-good starting point: pick one modulation order conservative enough that the worst subcarrier in the band still decodes, and you get a working link with a single code path for the demodulator. The cost is that every subcarrier is held to the standard of the worst one.

Acoustic channels are exactly the case where that cost is highest. A speaker-and-microphone path has a wildly uneven frequency response — resonances, roll-off at the extremes, room reflections that null out specific frequencies entirely — so a handful of subcarriers are usually much worse than the rest of the band. Static bit-loading means those few bad subcarriers cap what every other subcarrier is allowed to carry. Adaptive bit-loading would mean measuring per-subcarrier SNR from the channel-estimation pass I already do, then encoding a bit-allocation table into the packet header so the receiver knows how to interpret each subcarrier before demodulating it. That’s real complexity — the table has to survive the same lossy channel as the payload — but it’s the single change most likely to move the needle on real acoustic throughput, as opposed to the wired numbers, which are already close to what the OFDM overhead allows. It’s also, not coincidentally, the change most likely to finally get me past 28.8.

The library itself is pure DSP — rustfft and num-complex are the only required dependencies, no platform-specific audio code — so it compiles anywhere Rust does, with cpal pulled in only for the example binaries that actually open an audio device. That separation is deliberate: the PHY layer shouldn’t care whether the samples came from a sound card, a file, or a simulated channel in a test.

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)?;

Until then: 27.1 kbps over a $10 guitar cable. Nearly dial-up. I’ll take it.