Generating Signals With Numpy

You don’t need GNU Radio, a DSP toolkit, or a signal generator to make real radio signals. With NumPy and a HackRF, you can write a short Python script that builds a signal sample by sample, save it to a file, and transmit it. This article walks through that workflow step by step, from a single tone up to a framed 2FSK data packet.

Every waterfall and plot below is a real over-the-air capture. Each signal was generated with the scripts in this article, transmitted with a HackRF One, and received on an RTL-SDR Blog V4 sitting on the same table.

What you’ll build:

  1. A pure carrier (the “hello world” of SDR)
  2. Morse code (CW), with and without edge shaping
  3. An OOK (on-off keying) data packet
  4. A 2FSK (frequency-shift keying) data packet

IQ Samples

An SDR doesn’t transmit “a waveform.” It transmits a stream of complex numbers called IQ samples. Each sample has a real part (I, in-phase) and an imaginary part (Q, quadrature). The HackRF takes these samples at a fixed rate (the sample rate) and shifts them up to your chosen RF frequency.

A tone is just a single, steady whistle at one frequency. In radio terms:

  • Silence = transmitter off, no energy.
  • Tone = transmitter on, holding one frequency with nothing changing. No dots, no dashes, no data bits.

Think of it like a musical note. If you press one piano key and hold it, that’s a tone. Morse is that same note tapped on and off. OOK is that note tapped very fast to send 1s and 0s. 2FSK alternates between two nearby notes.

NumPy can build a tone in a single line:

iq = np.exp(2j * np.pi * f * t)      # = cos(2πft) + j·sin(2πft)

A 100 kHz tone at 2 MS/s. Each dot is one sample: the blue dots are the real part, the orange dots the imaginary part. With 20 samples per cycle, the shape is easy to see.

A positive f puts the tone above the frequency you tune the HackRF to, and a negative f puts it below. This is how every signal in this article gets placed. We build it at “baseband” around 0 Hz and multiply by np.exp(2j*np.pi*offset*t) to slide it wherever we want within the captured bandwidth.

The Pipeline

Every signal follows the same four steps:

 bits / message ──► baseband waveform ──► shift to offset ──► int8 IQ file ──► hackrf_transfer
   (Python)            (NumPy)             (× e^{j2πft})       (.cs8)              (RF!)

The HackRF’s native file format is CS8. Signed 8-bit integers, interleaved I0 Q0 I1 Q1 I2 Q2 …. That’s all hackrf_transfer -t needs. No header, no metadata.

Hardware and Software Used

ItemRole
HackRF OneTransmitter (hackrf_transfer)
RTL-SDR Blog V4Receiver for verification (rtl_sdr)
Python 3 + NumPySignal generation
MatplotlibThe plots in this article (optional)
pip install numpy matplotlib
brew install hackrf librtlsdr     # or: sudo apt install hackrf rtl-sdr

Step 0: A Tiny Helper Module

These four helpers are shared by every script. The one that matters most is save_cs8(), which turns NumPy’s complex floats into the HackRF’s byte format.

# sdr_helpers.py
import numpy as np

SAMPLE_RATE = 2_000_000   # 2 MS/s: the lowest rate the HackRF is happy with
OFFSET_HZ = 100_000       # park the signal 100 kHz away from the tuned center


def bytes_to_bits(data: bytes) -> np.ndarray:
    """b'A' -> array([0,1,0,0,0,0,0,1]) (MSB first)."""
    return np.unpackbits(np.frombuffer(data, dtype=np.uint8))


def shift_to_offset(baseband: np.ndarray, offset_hz: float = OFFSET_HZ,
                    fs: float = SAMPLE_RATE) -> np.ndarray:
    """Multiply by a complex exponential to move a signal up by offset_hz."""
    t = np.arange(len(baseband)) / fs
    return baseband * np.exp(2j * np.pi * offset_hz * t)


def pad_silence(iq: np.ndarray, seconds: float = 0.25,
                fs: float = SAMPLE_RATE) -> np.ndarray:
    """Add quiet time before and after so the burst is easy to spot."""
    z = np.zeros(int(seconds * fs), dtype=np.complex64)
    return np.concatenate([z, iq.astype(np.complex64), z])


def save_cs8(iq: np.ndarray, path: str, peak: int = 100) -> None:
    """Scale complex float IQ to signed 8-bit, interleave I/Q, write to disk.

    This is the exact format `hackrf_transfer -t` expects:
    I0 Q0 I1 Q1 I2 Q2 ... each one signed byte.
    """
    iq = iq / np.max(np.abs(iq)) * peak            # normalise to +/- peak
    out = np.empty(2 * len(iq), dtype=np.int8)
    out[0::2] = np.clip(np.round(iq.real), -127, 127)
    out[1::2] = np.clip(np.round(iq.imag), -127, 127)
    out.tofile(path)
    print(f"wrote {path}: {len(iq):,} samples, "
          f"{len(iq) / SAMPLE_RATE:.2f} s, {out.nbytes / 1e6:.1f} MB")

Step 1: A Pure Carrier

# gen_tone.py
import numpy as np
from sdr_helpers import SAMPLE_RATE, OFFSET_HZ, save_cs8

DURATION_S = 2.0

t = np.arange(int(DURATION_S * SAMPLE_RATE)) / SAMPLE_RATE
iq = np.exp(2j * np.pi * OFFSET_HZ * t)          # cos + j*sin, spinning at +100 kHz

save_cs8(iq, "tone.cs8")

Generate and transmit it:

python3 gen_tone.py
hackrf_transfer -t tone.cs8 -f 433920000 -s 2000000 -x 20 -a 0
FlagMeaning
-t tone.cs8transmit this file
-f 433920000tune to 433.92 MHz (the center)
-s 2000000sample rate. Must match the rate you generated at
-x 20TX VGA gain, 0–47 dB. Start low
-a 0RF amplifier off

To see it, record with the RTL-SDR at the same time in a second terminal:

rtl_sdr -f 433920000 -s 1024000 -g 38.6 -n 4096000 tone_rx.cu8

The RTL-SDR’s view of the transmission: one sharp carrier about 55 dB above the noise floor, exactly where we asked for it.

Two things to notice:

  1. The tone is at +102.2 kHz, not +100.0 kHz. That 2.2 kHz error isn’t a bug in the code. The crystal oscillators in the HackRF and the RTL-SDR each drift by a few parts per million, and at 434 MHz that adds up to a couple of kHz. Real receivers track this error. Your decoder should too.
  2. Why the offset at all? The tuned center (0 Hz) is where receivers pick up DC offset and local-oscillator (LO) leakage. Parking the signal 100 kHz to the side keeps it clear of that region. The small spikes elsewhere are hardware spurs, roughly 40 dB below our carrier.

Step 2: Morse Code (CW)

Morse is the simplest form of amplitude modulation. You simply turn the carrier on and off. International Morse timing is defined in units. A dot is 1 unit on, a dash is 3 units on, gaps within a letter are 1 unit, gaps between letters are 3 units. At 15 words per minute, one unit is 80 ms.

# gen_morse.py
import numpy as np
from sdr_helpers import SAMPLE_RATE, shift_to_offset, pad_silence, save_cs8

MESSAGE = "NUMPY"
WPM = 15
DOT_S = 1.2 / WPM                  # PARIS standard: 15 WPM -> 80 ms dot
RAMP_S = 0.005                     # 5 ms soft edges (prevents "key clicks")

MORSE = {"N": "-.", "U": "..-", "M": "--", "P": ".--.", "Y": "-.--"}


def morse_keying(message: str) -> np.ndarray:
    """Return a list of 1/0 'units' (1 unit = one dot length)."""
    units = []
    for i, letter in enumerate(message):
        if i:
            units += [0, 0, 0]                    # 3-unit gap between letters
        for j, symbol in enumerate(MORSE[letter]):
            if j:
                units += [0]                      # 1-unit gap inside a letter
            units += [1] if symbol == "." else [1, 1, 1]
    return np.array(units, dtype=np.float32)


units = morse_keying(MESSAGE)
samples_per_unit = int(DOT_S * SAMPLE_RATE)
envelope = np.repeat(units, samples_per_unit)     # square on/off keying

# Smooth the hard edges with a raised-cosine window (convolution = smoothing)
ramp = int(RAMP_S * SAMPLE_RATE)
window = np.hanning(ramp)
envelope = np.convolve(envelope, window / window.sum(), mode="same")

iq = shift_to_offset(envelope.astype(np.complex64))
save_cs8(pad_silence(iq), "morse.cs8")

The two NumPy tricks that do all the work:

  • np.repeat(units, samples_per_unit) stretches each 1 or 0 into 160,000 identical samples. Nearly every digital modulation starts with this “symbols to samples” step.
  • envelope * e^{j2πft}: multiplying the on/off envelope by the carrier is amplitude modulation.

python3 gen_morse.py
hackrf_transfer -t morse.cs8 -f 433920000 -s 2000000 -x 20 -a 0

The top plot is the RTL-SDR waterfall, with time running from left to right. Each bright bar is the carrier switched on. The bottom plot shows the strength of the same signal, with every dot, dash and letter of N-U-M-P-Y labeled.

Why the np.convolve line matters (key clicks)

Switching a carrier on or off instantly produces a sharp edge, and sharp edges in time spread energy over a wide range of frequencies. Radio operators call the result key clicks. Your signal becomes audible to receivers on nearby frequencies. The fix is to round off each edge. Convolving the envelope with a Hann window gives every transition a smooth 5 ms raised-cosine ramp.

To compare, we transmitted the same message twice, once with that line removed:

Averaged spectra of the two real captures. Hard keying (orange) produces wide “skirts” that stay above the noise floor out to about ±10 kHz from the carrier. Shaped keying (blue) falls to the noise within about 1.5 kHz. Same message, same power; the only difference is one line of NumPy.

This lesson applies to every modulation that follows. Avoid instant jumps in amplitude or phase.

Step 3: An OOK Data Packet

On-off keying (OOK) sends binary data the same way Morse does. The carrier is on for a 1 and off for a 0. It’s what most cheap 433 MHz remotes and sensors use. The difference from Morse is structure. A receiver needs to know when a packet starts, so we add framing:

FieldBytesPurpose
PreambleAA AA10101010… lets the receiver measure bit timing and set its on/off threshold
Sync word2D D4a distinctive pattern that marks “payload starts here“
PayloadHI!the actual data
# gen_ook.py
import numpy as np
from sdr_helpers import (SAMPLE_RATE, bytes_to_bits, shift_to_offset,
                         pad_silence, save_cs8)

BIT_RATE = 500                                   # 500 bits/s -> 2 ms per bit
PREAMBLE = b"\xaa\xaa"                           # 1010... lets a receiver lock on
SYNC = b"\x2d\xd4"                               # marks "payload starts here"
PAYLOAD = b"HI!"

bits = bytes_to_bits(PREAMBLE + SYNC + PAYLOAD)
samples_per_bit = SAMPLE_RATE // BIT_RATE        # 4000 samples per bit
envelope = np.repeat(bits, samples_per_bit).astype(np.float32)

# 50 us raised-cosine edges so the spectrum stays tidy
ramp = int(50e-6 * SAMPLE_RATE)
window = np.hanning(ramp)
envelope = np.convolve(envelope, window / window.sum(), mode="same")

packet = shift_to_offset(envelope.astype(np.complex64))
gap = np.zeros(int(0.15 * SAMPLE_RATE), dtype=np.complex64)
iq = np.concatenate([packet, gap, packet, gap, packet])   # send it 3 times
save_cs8(pad_silence(iq), "ook.cs8")

np.unpackbits turns bytes into bits in one call, and the rest is the same repeat → shape → shift recipe used for Morse. Sending the packet three times is standard practice. If one copy is corrupted, the receiver can use another.

python3 gen_ook.py
hackrf_transfer -t ook.cs8 -f 433920000 -s 2000000 -x 20 -a 0

A classic falling waterfall of one received packet (time runs downward). Each horizontal bar is a run of 1-bits. The evenly spaced bars at the top are the AA AA preamble, and the two thicker bars near 0.06 s are the runs of two and three 1s in the 2D D4 sync word.

Here is the same packet as the receiver sees it. The signal’s magnitude, sampled in the middle of each 2 ms bit.

The received envelope from the real capture. The receiver finds the packet start by matching the known preamble and sync word, then samples each bit at its center (black dots) against the dashed threshold. The recovered bits spell AA AA 2D D4 'H' 'I' '!', an exact match for what we transmitted.

Step 4: A 2FSK Data Packet

Frequency-shift keying keeps the carrier on the whole time and encodes bits in frequency instead. A 1 is sent a little above the center frequency, a 0 a little below. It’s much more robust to noise and fading than OOK, and many sensors, pagers and IoT radios use some form of it.

The naive approach generates two tones and splices them together. That creates a sudden phase jump at every bit boundary, and a phase jump splatters energy just like hard keying does. The better approach is continuous-phase FSK. Describe the frequency at every sample, then add up (integrate) the frequency to get the phase. np.cumsum does the integration:

# gen_2fsk.py
import numpy as np
from sdr_helpers import (SAMPLE_RATE, OFFSET_HZ, bytes_to_bits,
                         pad_silence, save_cs8)

BIT_RATE = 500
DEVIATION_HZ = 20_000                            # 1 -> +20 kHz, 0 -> -20 kHz
PREAMBLE = b"\xaa\xaa"
SYNC = b"\x2d\xd4"
PAYLOAD = b"HI!"

bits = bytes_to_bits(PREAMBLE + SYNC + PAYLOAD)
samples_per_bit = SAMPLE_RATE // BIT_RATE

# 1) bits -> symbols (+1 / -1) -> an instantaneous-frequency track (Hz)
symbols = 2.0 * bits - 1.0
freq = OFFSET_HZ + DEVIATION_HZ * np.repeat(symbols, samples_per_bit)

# 2) integrate frequency to get phase. cumsum keeps the phase continuous,
#    so there are no jumps (and no splatter) at bit boundaries.
phase = 2 * np.pi * np.cumsum(freq) / SAMPLE_RATE
packet = np.exp(1j * phase).astype(np.complex64)

# Constant envelope, so ramp just the start and end of each burst
ramp = np.hanning(2 * 200)
packet[:200] *= ramp[:200]
packet[-200:] *= ramp[200:]

gap = np.zeros(int(0.15 * SAMPLE_RATE), dtype=np.complex64)
iq = np.concatenate([packet, gap, packet, gap, packet])
save_cs8(pad_silence(iq), "2fsk.cs8")

Notice there’s no separate shift_to_offset() call. The 100 kHz offset is built directly into the frequency track (OFFSET_HZ + …), so the tones land at 100 ± 20 kHz.

python3 gen_2fsk.py
hackrf_transfer -t 2fsk.cs8 -f 433920000 -s 2000000 -x 20 -a 0

One received 2FSK packet. The signal alternates between two tones, one per bit value. Both tones appear about 2 kHz above their nominal 80 and 120 kHz because of the same oscillator error seen in Step 1.

To recover the bits, a receiver measures how much the phase advances from one sample to the next, which is the instantaneous frequency. In NumPy that’s one line: np.angle(x[1:] * np.conj(x[:-1])).

The top plot shows the same packet with time running from left to right, so you can see the carrier hop between the two tones. The bottom plot shows the measured frequency, checked in the middle of each bit. Anything above the dashed line is a 1, and anything below it is a 0. All 56 bits match the packet we transmitted.

Summary

That is how you generate signals using NumPy. A tone is a single line of code. Morse and OOK switch that tone on and off, and 2FSK moves it between two frequencies. Each script turns the signal into a file of numbers, and the HackRF plays that file over the air. The RTL-SDR captures show that every signal landed where we put it, and both data packets were received without a single wrong bit.

Troubleshooting Checklist
SymptomLikely cause
Signal is at the wrong frequency or the wrong speedThe -s rate in hackrf_transfer doesn’t match the SAMPLE_RATE you generated at
Signal shows up mirrored (e.g. at −100 kHz)I and Q swapped, or the sign of your offset is flipped
Lots of spurs and harmonicsClipping: lower peak in save_cs8, or lower the TX gain -x
Wide splatter around the signalHard edges or phase jumps: shape the amplitude, use cumsum for FSK
Nothing receivedTX gain too low, antenna mismatched for the band, or -a 1 needed for longer range (be careful)
Choppy playback / underrunsUSB bandwidth: close other USB devices, or keep the sample rate at 2–8 MS/s
Where to Go Next

The same recipe (bits → np.repeat → shape → modulate → save_cs8 → hackrf_transfer) extends to almost anything:

  • Manchester encoding: replace each bit with two half-bits (01 / 10) before np.repeat, so the signal carries its own clock
  • Checksums: append a CRC to the payload so the receiver can detect errors
  • GFSK: run the freq track through a Gaussian filter (np.convolve) before cumsum for an even tighter spectrum, as used in Bluetooth LE
  • FM audio: use an audio waveform as the freq track instead of bits
  • Images in the waterfall: give each image row a set of tone amplitudes, and a picture appears on the receiver’s waterfall

These are just arrays of numbers, and NumPy is very good at arrays of numbers.

Scroll to Top