🇬🇧 English | 🇹🇷 Türkçe
A pure-Python SSTV decoder that turns a WAV recording from the QO-100 satellite into a picture — and teaches you the SSTV format so thoroughly you could design your own mode by the end.
No hardware, no MMSSTV, no black boxes: numpy, scipy, and a WebSDR
recording are all you need.
Audio (WAV) ──► band-pass + Hilbert ──► instantaneous-frequency curve
──► leader detect (1900 Hz) + frequency-offset estimate
──► VIS code (mode ID, parity checked)
──► line-sync fit (linear regression, slant correction)
──► pixel sampling (1500 Hz = black … 2300 Hz = white) ──► PNG
- What is SSTV?
- SSTV on QO-100
- How to record (WebSDR, step by step)
- Usage and outputs
- The SSTV format — deep dive
- How the decoder works
- Files, supported modes, limitations
- References
SSTV (Slow-Scan Television) is a way of sending still pictures over a plain voice radio channel. It dates back to 1958 — ham radio operators wanted to exchange images long before the internet, using nothing but a 3 kHz audio channel. NASA even used a variant to send back pictures from early Moon missions.
The core idea is beautifully simple:
Brightness becomes pitch. A dark pixel is transmitted as a low audio tone, a bright pixel as a high tone. The transmitter scans the image line by line, turning each line into a little melody; the receiver listens and paints what it hears.
The standard frequency mapping used by every common mode:
1200 Hz sync pulse ("start a new line")
1500 Hz black
... greys / colour intensities in between (linear)
2300 Hz white
1900 Hz calibration leader tone (header)
That's why SSTV has that unmistakable "R2-D2 singing" sound: you are literally hearing the picture. One image takes anywhere from 8 seconds to 2 minutes depending on the mode — hence slow-scan.
Because the entire signal lives inside an ordinary audio channel, any receiver that produces audio can feed a decoder — including a WebSDR in your browser. That's what this project exploits.
QO-100 (Es'hail-2) is the first geostationary amateur-radio satellite; its narrow-band transponder relays signals from half the planet 24/7 (see our sister project qo100-beacon-decoder for the full introduction).
The band plan reserves an image segment around 10489.620–10489.640 MHz (downlink) for SSTV and similar picture modes. Activity is sporadic — an operator somewhere between Brazil and Thailand decides to send a picture and anyone pointing at the satellite (or at a WebSDR) can receive it.
On a waterfall an SSTV transmission is easy to spot: a single trace that wobbles rhythmically inside a ~1 kHz corridor for one or two minutes — each wobble cycle is one image line.
Connect to a QO-100 WebSDR, for example http://websdr.is0grb.it:8901/ (IS0GRB, Sardinia), and:
| Setting | Value | Why |
|---|---|---|
| Frequency | around 10489620.00 kHz (tune ±10 kHz across the image segment) | SSTV lives in 10489.620–640; tune so the signal's tones land in 500–2800 Hz audio |
| Mode | USB (USB2.7 or the dedicated SSTV filter) | SSTV is transmitted as USB audio |
| DSP Noise Reduction | Disabled | it warps the tone frequencies — the decoder reads frequency, don't touch it |
| Autonotch | Off | SSTV tones look like carriers to it; it would eat the picture |
| Squelch / Mute / High Boost | Off | nothing may gate or shape the audio |
| Audio AGC | Auto | fine |
| Volume | high, no clipping | recording SNR |
Screenshots of a real recording session (the wobbling trace on the waterfall is the picture being painted, line by line):
Then: wait until you actually see a transmission on the waterfall (SSTV is not continuous!), press Audio recording: start, let the whole transmission pass (Martin M1 takes ~114 s), press stop, download the WAV.
Don't worry about tuning precision: the decoder measures the frequency offset from the header and corrects for it automatically (our sample was off by −109.4 Hz and decoded perfectly).
The decoder doesn't care where the audio came from. A shortwave receiver on 14.230 MHz (the classic HF SSTV frequency), an ISS pass, a phone recording of another radio's speaker, or a WAV generated by an SSTV encoder app — if it contains an SSTV transmission with its header, it will decode. Mono WAV, any sample rate ≥ 8 kHz.
pip3 install numpy scipy matplotlib
python3 sstv_decoder.py misc/sstv_sample.wavReal output from the included sample (recorded from QO-100 via IS0GRB):
kayit: 158.7s @ 8000 Hz
t=34.0s: lider bulundu, ofset -109.4 Hz, VIS=44 -> Martin M1
256/256 satir sync ile hizalandi; 256 satir cozuldu
kayit : misc/sstv_sample.wav
sure : 158.7 s @ 8000 Hz
yayin : 1 baslik bulundu, 1 resim cozuldu
-- yayin @ t=34.0s --
frekans ofseti : -109.4 Hz
VIS : 44 (parite OK) -> Martin M1
boyut : 320x256, renk GBR
satir suresi : nominal 446.446 ms, olculen 446.446 ms (egiklik -0.000%)
hizalama : 256/256 satir sync ile hizalandi
video araligi : t=34.9 - 149.2 s
cikti : data/sstv_sample_sstv.png
frekans analizi: data/sstv_sample_freq_analysis.png
uretilen dosyalar:
data/sstv_sample_meta.txt
data/sstv_sample_sstv.png
data/sstv_sample_freq_analysis.png
Every run writes three files under data/, named after the input WAV:
| File | Content |
|---|---|
data/<name>_meta.txt |
everything the console printed: offset, VIS, mode, slant, timings |
data/<name>_sstv.png |
the decoded picture |
data/<name>_freq_analysis.png |
spectrogram with leader/VIS/video boundaries and the offset-corrected reference tones marked |
(If one WAV contains several transmissions, outputs are numbered
_sstv_01.png, _sstv_02.png, …)
The decoded picture from the sample — received from a satellite 36,000 km up, through a browser:
And its frequency analysis — you can literally see the anatomy of the transmission (leader, VIS, then 256 lines of video between the black and white reference lines):
This section is the heart of the README. Read it to the end and you will know the format well enough to invent a mode of your own.
Forget bits and packets. Analog SSTV is a single continuous curve: instantaneous frequency over time. Every feature of the transmission — header, mode ID, line syncs, every single pixel — is a shape on that curve. Decoding SSTV = reading that curve.
Every transmission starts with a fixed overture so receivers can find it and identify the mode:
freq
2300 ┤
1900 ┤ ████████████ ██ ████████████ ← leader tones
1500 ┤
1300 ┤ ▄▄ ▄▄ ▄▄ ← VIS "0" bits
1200 ┤ ▓▓ ▓▓ ▓▓ ← break, start, stop
1100 ┤ ▄▄▄▄ ← VIS "1" bits
└──────────────────────────────────────────────────────► time
300 ms 10 300 30 ms per bit
leader break leader VIS: start + 7 data + parity + stop
- Leader: 1900 Hz held for ~300 ms, a 10 ms break at 1200 Hz, then another ~300 ms of 1900 Hz. Purpose: "an SSTV picture is coming", and — crucially for us — a frequency reference. If the receiver is mistuned by X Hz, the leader appears at 1900+X; measuring it gives us X, and every later tone can be corrected by it.
- VIS code (Vertical Interval Signaling): a 1200 Hz start bit, then
7 data bits, LSB first (1100 Hz = 1, 1300 Hz = 0, 30 ms each), one
even-parity bit, and a 1200 Hz stop bit. The 7-bit value identifies the
mode:
44 = Martin M1,40 = Martin M2,60 = Scottie S1,76 = Scottie DX,95 = PD120,8 = Robot36…
The VIS is the only "digital" thing in the whole transmission — 8 bits of 1970s-style FSK, and even it comes with a parity check.
After the header the image flows as nlines identical lines. Here is one
Martin M1 line (VIS 44 — the mode in our sample, and the most popular
on QO-100):
│sync │ G channel scan │ B channel scan │ R channel scan │
│1200 │ 146.432 ms │ 146.432 ms │ 146.432 ms │
│4.862│ 1500–2300 Hz │ 1500–2300 Hz │ 1500–2300 Hz │
└─────┴─────────────────────┴─────────────────────┴─────────────────────┘
◄──────────────────────── 446.446 ms per line ────────────────────────►
× 256 lines = 114.3 s per image
- The sync pulse (1200 Hz, below black — unmistakable) marks the start of each line. It exists so the receiver can re-align 256 times per image; without it, a clock error of 0.01% would shear the picture into a slant.
- Each colour scan sweeps the line's pixels for that colour channel: during the G scan, the instantaneous frequency at time t IS the green value of the pixel at position x = t/146.432ms × width.
- Note the order: Martin (and Scottie) transmit G, then B, then R — GBR, not RGB. Green first because eyes are most sensitive to it.
Two facts that surprise everyone:
Horizontal resolution is not in the signal. The scan is continuous — an analog sweep, like a record groove. "320 pixels" is just the convention for how finely receivers sample the sweep. Our decoder chops each scan into 320 slices and averages each slice; a decoder that sliced it into 640 would get a (softer) 640-pixel-wide image from the same signal. Vertical resolution IS fixed by the mode: 256 transmitted lines is 256 lines, full stop.
Colour is a convention layered on the same grey mechanism. The signal only ever says "this bright, now this bright…". Modes agree on how to interpret consecutive scans:
- Martin/Scottie (GBR sequential): 3 scans per line = the G, B, R planes of that line.
- PD family (YCrCb): 4 scans per transmitted line —
Y0, Cr, Cb, Y1— carrying two image lines at once: Y0 and Y1 are the brightness of two consecutive lines, Cr/Cb their shared colour information (exactly the chroma subsampling trick JPEG and every video codec use — invented here out of the same need: brightness matters more than colour, so spend airtime on it). - Scottie's quirk: the sync pulse sits in the middle of the line
(before the R scan), not at the start — G and B are transmitted before
their own line's sync. Every decoder has a special case for this; ours
handles it with a single
sync_pos_msparameter.
An SSTV mode is completely defined by a handful of numbers — look at the
MODES table at the top of sstv_decoder.py and you'll recognise every
column:
1. Pick a free VIS code → say, 57
2. Line time → say, 300 ms
3. Sync: 1200 Hz, 5 ms, at line start
4. Scans: G at 6 ms (94 ms), B at 103 ms (94 ms), R at 200 ms (94 ms)
5. Lines: 240
=> a 320x240 colour image in 72 s. Congratulations: "Levent L1".
Add one dict entry to MODES and this decoder would decode it. That's the
entire spec surface of an SSTV mode — timing plus interpretation, riding on
three fixed tones (1200/1500/2300) and a shared header. This is also
exactly how the real modes proliferated: Martin Emmerson (G3OQD) tweaked
timings and made the Martin family; Eddie Murphy (GM3SBC) made Scottie;
each got a VIS code and the ecosystem absorbed them.
The chain in sstv_decoder.py, layer by layer:
- Instantaneous frequency (
inst_freq): band-pass 850–2700 Hz, then the Hilbert transform turns the real audio into an analytic signal whose phase derivative is the momentary frequency. A 7-tap median filter kills single-sample glitches. From here on, the decoder never touches audio again — only this curve. - Header search (
find_header): scan for ≥200 ms of stable tone near 1900 Hz (the leader) → the deviation from 1900.0 is the frequency offset; then find the VIS start bit (a ≥20 ms dip to ~1200 Hz within the next second), sample the 8 bit centres, check parity, look the value up inMODES. - Line-sync fit (
fit_sync): for each expected line, hunt the 1200 Hz dip near its predicted position, then fit a straight linesync_center = a + b·line_numberby linear regression (two passes with outlier rejection). The slopebis the measured line period; its deviation from nominal is the slant that plagues analog SSTV — here it is measured and cancelled exactly. Our sample: 256/256 syncs found, slant −0.000%. - Pixel sampling (
decode_image): for every line and colour scan, average the frequency curve over each pixel's time slice (done in O(1) per pixel with a cumulative sum), then map1500 Hz → 0, 2300 Hz → 255(offset-corrected). PD modes additionally convert YCrCb → RGB (BT.601 coefficients). - Reporting: meta text, the PNG, and a spectrogram with every detected landmark drawn on it — so you can see what the decoder saw.
Health indicators to read in the output: frequency offset (dial error — harmless, corrected), parity OK (VIS read correctly), N/256 lines sync-aligned (how much of the sync train was found), slant % (clock mismatch — measured and corrected).
| File | Role |
|---|---|
sstv_decoder.py |
the whole decoder (single file, ~460 lines, numpy/scipy only) |
misc/sstv_sample.wav, misc/sstv_sample_2.wav |
real QO-100 recordings (IS0GRB WebSDR) |
misc/websdr_sstv_01.png, misc/websdr_sstv_02.png |
recording-session screenshots |
data/ |
outputs: <name>_meta.txt, <name>_sstv.png, <name>_freq_analysis.png |
Supported modes: Martin M1/M2, Scottie S1/S2/DX, PD50/PD90/PD120. Recognised but not yet decoded: Robot36/72 (their line interleaves luma and chroma differently — a nice future exercise). Unknown VIS codes are reported and skipped.
- JL Barber N7CXI, Proposal for SSTV Mode Specifications (Dayton 2000) — the de-facto SSTV timing bible: http://www.barberdsp.com/downloads/Dayton%20Paper.pdf
- SSTV Handbook (comprehensive treatment of history and modes): https://www.sstv-handbook.com/
- Signal Identification Wiki — SSTV (spectrograms and audio samples of every mode): https://www.sigidwiki.com/wiki/SSTV
- AMSAT-DL, QO-100 band plan (the image segment): https://amsat-dl.org/en/qo-100-nb-transponder-bandplan-and-operating-guidelines/
- Tested WebSDR (IS0GRB): http://websdr.is0grb.it:8901/
- Sister projects: qo100-beacon-decoder · ft8-ft4-decoder



