DEV Community

Aman Sachan
Aman Sachan

Posted on • Originally published at github.com

Stegachirp: Hide Messages in Audio Files Using Pure Python (No NumPy, No SciPy)

Hide a Message in a WAV File With Pure Python (No NumPy, No FFT)

I built stegachirp because I wanted to send a short secret message inside an audio file using nothing but the standard library. No numpy. No scipy. No fftpack. Just a bit of math and a WAV header.

Here's the full story — the design choices, the math, and the implementation.

The Goal

Take a .wav file. Hide a 200-byte text message in it. The file still plays normally. Anyone with the decoder can pull the message back out. Pure Python. Zero dependencies.

That last constraint forced the design.

Why FSK and Why Ultrasonic

The bit stream has to live in audio the listener can't hear, but the encoder/decoder can. The sweet spot is 18–20 kHz:

  • Below ~16 kHz: audible to most people, especially kids and people with good hearing.
  • Above ~22 kHz: most consumer speakers roll off, and many audio formats (MP3, AAC) filter it out entirely.
  • 18–20 kHz: above the listener's ear, below the speaker's physical limit, in-band for any 48 kHz sample rate.

Modulation choice was easy: FSK (Frequency Shift Keying). Bit 0 = 18 kHz sine, bit 1 = 20 kHz sine. Two pure tones, no phase tricks, easy to detect.

Why not LSB steganography like image stego tools use? Because LSB gets destroyed the moment you re-encode the audio. Save a .wav as .mp3 and your LSB pattern is gone. FSK survives WAV-to-WAV copies, sample-rate conversion, and most audio editors.

The Math: Generating a Bit

A bit is a window of audio samples, all the same sine wave at 18 kHz or 20 kHz:

def carrier_samples(bit: int, amplitude: float) -> list[float]:
    freq = 20_000.0 if bit else 18_000.0
    out = []
    for n in range(BIT_SAMPLES):  # 480 samples = 10 ms @ 48 kHz
        t = n / SAMPLE_RATE
        out.append(amplitude * math.sin(2 * math.pi * freq * t))
    return out
Enter fullscreen mode Exit fullscreen mode

That's it. No tricks. 480 samples at 48 kHz = 10 ms per bit = 100 bits/sec.

The Math: Detecting a Bit

Here's where it gets fun. To know whether a 10 ms window is "the 18 kHz tone" or "the 20 kHz tone," I use the Goertzel algorithm. It's a single-bin DFT — instead of computing an entire FFT, you compute the magnitude of one specific frequency in O(N) time with no arrays.

def _goertzel(samples, target_freq, sample_rate):
    N = len(samples)
    k = int(0.5 + (N * target_freq) / sample_rate)
    w = (2 * math.pi * k) / N
    coeff = 2 * math.cos(w)
    s1 = s2 = 0.0
    for s in samples:
        s0 = s + coeff * s1 - s2
        s2 = s1
        s1 = s0
    return math.sqrt(s1*s1 + s2*s2 - coeff*s1*s2)
Enter fullscreen mode Exit fullscreen mode

You run it twice per bit window — once for 18 kHz, once for 20 kHz. The bigger magnitude wins. That's your bit.

Why Goertzel and not FFT? Because for a single frequency, Goertzel is faster, uses zero memory, and is dead simple. FFT only wins when you need many frequencies at once.

Finding the Start: The Chirp Preamble

The decoder needs to know where the bit stream starts. The naive approach — "look for the 18/20 kHz pattern, then start decoding" — is fragile. Background music might have those frequencies too.

The solution: a 200 ms linear up-chirp from 17 kHz to 21 kHz right before the bits. The decoder does autocorrelation to find the chirp, marks its end, and starts reading bits from there.

def chirp_samples():
    out = []
    for n in range(int(SAMPLE_RATE * 0.2)):  # 200 ms
        t = n / SAMPLE_RATE
        # linear chirp from 17 kHz to 21 kHz
        freq = 17_000 + (4_000 * (n / int(SAMPLE_RATE * 0.2)))
        out.append(amplitude * math.sin(2 * math.pi * freq * t))
    return out
Enter fullscreen mode Exit fullscreen mode

The chirp is unique: nothing in normal music is a 200 ms linear ramp from 17 to 21 kHz. Autocorrelation locks onto it instantly. Once you know where the chirp ends, you know where the first bit starts.

Framing the Message

Now the bits are flowing, but how does the decoder know when the message ends and what to do with it?

Wrap the payload in a tiny frame:

[MAGIC: 4 bytes "STGA"] [length: 1 byte] [payload: ≤255 bytes] [crc16: 2 bytes]
Enter fullscreen mode Exit fullscreen mode
  • Magic rejects random noise that happens to look like FSK.
  • Length lets the decoder know how many bytes to read.
  • CRC-16 catches bit errors. If the checksum doesn't match, raise instead of returning garbage.
def build_frame(payload: bytes) -> bytes:
    body = MAGIC + bytes([len(payload)]) + payload
    body += crc16(body).to_bytes(2, "little")
    return body
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

Encode:

def encode_samples(message: bytes) -> list[float]:
    frame = build_frame(message)
    bits = bits_from_bytes(frame)
    out = []
    out.extend(chirp_samples())      # preamble
    for bit in bits:
        out.extend(carrier_samples(bit, amplitude))  # FSK data
    return out
Enter fullscreen mode Exit fullscreen mode

Decode:

def decode_samples(samples, sample_rate):
    # find chirp preamble (autocorrelation at 17-21 kHz ramp)
    start = find_chirp(samples, sample_rate)
    bits = []
    pos = start
    for _ in range(MAX_BITS):
        window = samples[pos:pos + BIT_SAMPLES]
        bits.append(_classify_bit(window, sample_rate))
        pos += BIT_SAMPLES
    frame_bytes = bytes_from_bits(bits)
    return parse_frame(frame_bytes)  # raises on bad magic/crc
Enter fullscreen mode Exit fullscreen mode

That's the whole codec. ~200 lines of Python, no dependencies.

Real-World Numbers

A 200-byte message at 100 bits/sec = 1,600 bits = 16,000 audio samples = ~0.33 seconds of FSK data. Add the 200 ms chirp preamble and you're at ~0.53 seconds of payload.

The full encoded WAV for a 200-byte message is ~10 seconds (you can pad with silence or cover audio), or you can mix the encoded signal into actual cover audio by adding the sample arrays element-wise.

What Doesn't Work

  • MP3/AAC: these codecs filter out content above ~16 kHz, so the message gets destroyed.
  • Loud cover audio with strong content above 16 kHz: the 18/20 kHz signal gets masked.
  • Heavy compression that re-encodes to lower sample rates: also destroys the message.

So: WAV in, WAV out, no transcoding. Document the chain in your threat model.

Try It

git clone https://github.com/AmSach/stegachirp
cd stegachirp
python3 -m stegachirp.cli encode -m "hello world" -o /tmp/secret.wav
python3 -m stegachirp.cli decode -i /tmp/secret.wav
# hello world
Enter fullscreen mode Exit fullscreen mode

15 tests cover round-trip, edge cases, and the "random audio must not falsely decode" negative path.

What's Next

  • Layer it with real encryption (libsodium secretbox) so the hidden message is also secret.
  • Add a recovery mode for partially corrupted frames (interleaving, Reed-Solomon).
  • Browser port using the Web Audio API — the math is the same, the I/O is different.

This is steganography, not cryptography. The point isn't to keep the message secret — it's to keep the fact that a message exists secret. For real secrecy, encrypt first, then hide.


stegachirp on GitHub — MIT licensed, ~250 lines, zero dependencies.

Top comments (0)