Est. read time: 3 minutes | Posted: July 19, 2026 by John Gentile


Once again, a neat challenge from DrSDR to decode a hidden string in a LoRa-like waveform. We are given some basic properties of the signal, namely the sampling frequency is 48kHz with 12khz bandwidth and spreading factor of 16. There are 4 chirps at start for sync, and each symbol is 4 bits, with 16 different symbols overall.

While this synthetic signal is not exactly LoRa, it is interesting to decode as LoRa comes up in a variety of chipsets and applications, such as the SX1262 which is used from drone control (ExpressLRS) to decentralized mesh networks (Meshtastic).

import numpy as np
import matplotlib.pyplot as plt
from scipy.io import wavfile
from scipy import signal
from rfproto import plot, sig_gen

from IPython.display import YouTubeVideo

First we read in the .wav file and verify the sampling frequency is 48kHz given in the file metadata.

fs, wav_data = wavfile.read("./lora_signals_neg_start.wav")
N = len(wav_data)
print(f"Read {N} samples with fs={fs}")

Read 9472 samples with fs=48000

Given we know a LoRa signal is made up of up and down chirps, lets generate a spectrogram to see if we can see the linear frequency sweeps.

input_iq = wav_data[:, 0] + 1j * wav_data[:, 1]

plot.samples(np.real(input_iq[:200]))
plt.show()

plt.figure(figsize=(12, 6))
plt.specgram(input_iq, NFFT=128, noverlap=120, pad_to=2048, Fs=fs, vmin=25)
plt.xlabel('Time (s)')
plt.ylabel('Frequency (Hz)')
plt.title('Input I/Q Spectrogram')
plt.show()

png

png

In LoRa modulation, a symbol is transmitted as a chirp that shifts in frequency across the entire channel bandwidth. The duration of a single symbol, TsT_{s}, is determined by the Spreading Factor (SF) and bandwidth, and is calculated as:

Ts=2SFBWT_{s} = \frac{2^{\text{SF}}}{\text{BW}}
bw = 12e3
sf = 16
t_s = (2**sf) / bw
print(f"Symbol period: {t_s} seconds")

Symbol period: 5.461333333333333 seconds

Note that the above symbol period is very large, due to the fact that our synthetic, LoRa-like symbol is at an audio sampling rate (48 kHz), so the standard equation doesn’t hold. Instead, we’ll have to analytically find the symbol period.

plt.figure(figsize=(12, 6))
# Get unwrapped phase of input signal
sig_phase = np.unwrap(np.angle(input_iq))
# Instantaneous frequency is simply derivative of phase
sig_freq = np.diff(sig_phase) / (2 * np.pi)
plt.plot(sig_phase[:500], 'b-', linewidth=0.5, label='Phase')
plt.margins(x=0)
plt.xlabel('Sample')
plt.ylabel('Phase (rad)')
plt.legend(loc=4)
plt.twinx()
plt.plot(sig_freq[:500], 'g-', linewidth=0.5, label='Frequency')
plt.margins(x=0)
plt.legend(loc=1)
plt.title('Instantaneous Phase & Frequency')
plt.show()

png

From the above, we can not only clearly see the 4x down chirps (LFM decreasing in frequency per period) signaling the start/sync, but that the period of each is fairly consistent. We can mathematically find this period by finding the “chirpiness” of the signal (which is just the derivative of the frequency) and measure the width between peaks:

n_phase = 200
sig_chirpiness = np.diff(sig_freq[:n_phase])
plt.plot(sig_chirpiness, 'o')
plt.margins(x=0)
plt.xlabel('Sample')
plt.ylabel('Chirpiness')
plt.show()

min_val = np.mean(sig_chirpiness)
chirp_peaks = []
for i in range(len(sig_chirpiness)):
    if sig_chirpiness[i] > min_val:
        chirp_peaks.append(i)

print(f"Chirp peaks: {chirp_peaks}")
print(f"Diff 1st pair: {chirp_peaks[1] - chirp_peaks[0]} samples")
print(f"Diff 2nd pair: {chirp_peaks[2] - chirp_peaks[1]} samples")

png

Chirp peaks: [63, 127, 191] Diff 1st pair: 64 samples Diff 2nd pair: 64 samples

Decoding: de-chirp + FFT, not a matched filter

Convolving with a time-reversed, conjugated chirp is a matched filter — it produces a correlation peak in time that tells us when a chirp occurs. That is exactly what we want for detecting the sync preamble, but it discards the information we need to decode: in LoRa every data symbol is the same base up-chirp cyclically shifted in starting frequency, and that shift is the symbol value.

The right operation is de-chirping: multiply each symbol block, sample-by-sample, by the conjugate of the base up-chirp (just conjugated — not time-reversed). This collapses the swept chirp down to a single tone whose frequency encodes the symbol. A per-symbol FFT then turns that tone into a sharp peak, and the peak bin index is the symbol.

image.png From How LoRa Modulation really works - long range communication using chirps (YouTube)

sps = 64             # samples/symbol: Ts = 2^SF / BW = 16 / 12e3 = 1.333 ms -> 64 @ 48 kHz
BW = 12e3
Nchip = 16           # 2^SF = 16 chips -> 4 bits, 16 possible symbols
osf = int(fs // BW)  # 4x oversampling (48 kHz / 12 kHz)

# Base up-chirp; the de-chirp reference is simply its conjugate (a down-chirp)
base_upchirp = sig_gen.cmplx_dt_lfm_chirp(1.0, -BW / 2, BW / 2, fs, sps)
dechirp = np.conj(base_upchirp)

# De-chirping the whole capture collapses each swept symbol into a constant tone,
# so the spectrogram becomes horizontal bars (one frequency per symbol)
dechirp_full = input_iq * np.tile(dechirp, len(input_iq) // sps)

plt.figure(figsize=(12, 6))
plt.specgram(dechirp_full, NFFT=64, noverlap=0, pad_to=1024, Fs=fs)
plt.xlabel('Time (s)')
plt.ylabel('Frequency (Hz)')
plt.title('De-chirped signal: each data symbol is now a single tone')
plt.show()

png

Now each symbol is a pure tone, so an FFT over each 64-sample block gives a sharp peak whose bin is the symbol. Two details finish the decode:

  • Oversampling fold: we sample at 48 kHz but the channel is only 12 kHz, so the signal is oversampled by 4. A 64-point FFT therefore spreads the 16 possible tones across 64 bins; summing the four aliased copies (b, b+16, b+32, b+48) folds them back into 16 bins.
  • Symbol → bits: the modulator’s zero-symbol reference sits half a band (8 chips) away from our de-chirp reference, so we add +8 (mod 16). There is no Gray coding here. Each symbol is 4 bits; packing two symbols per byte (high nibble first) yields ASCII.

Symbols 0–3 are the sync down-chirps, so the data starts at symbol 4.

def demod_symbol(k):
    """De-chirp symbol k, FFT, and fold the 4x oversampling into 16 bins."""
    block = input_iq[k * sps:(k + 1) * sps] * dechirp
    X = np.abs(np.fft.fft(block, sps))
    return sum(X[i * Nchip:(i + 1) * Nchip] for i in range(osf))

# Sharp peak for the first data symbol (symbol 4, just after the preamble)
folded = demod_symbol(4)
plt.stem(folded)
plt.xlabel('Symbol bin (0-15)')
plt.ylabel('|FFT|')
plt.title('Folded FFT of one de-chirped symbol: peak bin = symbol value')
plt.show()

# Decode every data symbol -> nibbles -> ASCII
n_sym = len(input_iq) // sps
raw = [int(np.argmax(demod_symbol(k))) for k in range(4, n_sym)]
syms = [(s + Nchip // 2) % Nchip for s in raw]  # +8: zero-symbol reference offset
msg = bytes((syms[i] << 4) | syms[i + 1] for i in range(0, len(syms) - 1, 2))
print(msg.decode())

png

the amazon code is: ASAY-SRCKND-XZRAU , sip some whiskey, end of message

References

YouTubeVideo('NoquBA7IMNc')