Abstract:
This article analyzes common SSTV (Slow Scan Television) image communication modes used in amateur radio, and shares several problems encountered through the implementation of actual code.
I. Summary of previous events
I've recently been working on a project, and one of the key features I need to implement is SSTV modulation. Initially, I thought about finding some existing components and assembling them haphazardly, as long as it works.
Surprisingly, when I tried several projects on GitHub, either they wouldn't run at all or their performance was extremely poor. For example, using the PD-120 mode to encode a 640x496 image took more than 50 seconds, which is completely unacceptable.
Finally, I decided to implement SSTV modulation myself using the C language. In this process, BI4PYM provided a great deal of help, and I also learned a lot and encountered several pitfalls. I'm happy to share my experiences with everyone here.
For example: This resulted in approximately a 6500% performance improvement. (Test platform: Raspberry Pi Zero 2W)

GitHub address:SSTV-Modulation
II. Components of SSTV
SSTV consists of an header and image data.
1. VIS identifier header
As anyone familiar with SSTV knows, there are many different SSTV modes. So, how do you let the receiver know which mode you're using? Naturally, you need to first send a greeting to the other party and inform them of the mode you're using before starting the transmission.
All standard SSTV modes use a unique numerical code to identify the mode to the receiving system before transmission begins.
This code is called VIS, which stands for Vertical Interval Signal code.
The code consists of seven binary numbers, arranged in little-endian order.
You can refer to the manual that was uploaded with this article for a specific code pattern (at the end of the article).

Taking PD-120 mode as an example:
The manual indicates that this mode code is "95 d". When converted to binary, it becomes "1011111". Furthermore, since the byte order is little-endian, it should be transmitted in the order "1111101".
So, how do we transmit "1" and "0"?
We transmit "1" and "0" using different frequencies of tones.
"1" corresponds to the frequency: 1100 Hz
"0" corresponds to the frequency: 1300 Hz
Each tone corresponding to a binary number lasts for 30 ms.
Therefore, the audio transmission corresponding to PD-120 mode is as follows:

However, before transmitting the VIS code, some guiding audio must first be transmitted.
The identifier header contains not only the VIS code, but also some additional information. The following shows the timing definition for the entire identifier header:

* The parity check mode is even parity, meaning that in an eight-bit binary code including a parity bit, the number of "1"s should be even.
// Parity check
int parity = 0;
for (int i = 0; i < data.length(); i++) {
parity ^= data[i];
}
if (parity == 0) {
return false;
} else {
return true;
} < for (i = 7; i++; ) {
if (vis_code[i] == '1') {
parity++;
}
}
int parity_bit = (parity % 2 == 0) ? 0 : 1;
tone((parity_bit == 0) ? 1300 : 1100, 30, 0);
Although "VIS" refers only to a seven-bit mode identifier code, the entire identifier header, including the preamble and checksum, is commonly referred to as "VIS."
2. Image Data
Once the header transmission is complete, the system immediately enters the phase of transferring image data.
The image data in SSTV is processed using a row-by-row scanning method, where each row corresponds to several audio signals.
Different modes of SSTV use different color schemes; for specific details, please refer to the manual. However, the underlying principles are essentially the same: we know that each color intensity is represented by an 8-bit binary number, i.e., 0255 So, how can we transmit color intensity using pitch?
The SSTV protocol specifies that color strength is represented using a range of 800 Hz from 1500 Hz to 2300 Hz, and that the color strength and frequency are linearly related:
\text{FREQ} = 1500 + \text{Color Intensity} \times 3.1372549 \quad // Color frequency multiplier, derived from \frac{800}{255}
During transmission, the signal frequency linearly changes from 1500 Hz to 2300 Hz according to the intensity value of each pixel's color channel. When transmitting each row, the system converts each pixel individually into its corresponding frequency and generates an audio signal at a fixed sampling rate. After the receiving system receives the data, it assembles the pixels based on their color channel relationships to restore the original colors of the image.
During the transmission of each line of data, the SSTV system sends a specific synchronization pulse to ensure that the lines of the image are accurately aligned. The timing, frequency, and length of the synchronization pulses vary depending on the mode, and must be obtained from the table provided in the manual for implementation.
To facilitate explanation, this section uses the "Scottie-DX" mode as an example:

The Scottie-DX uses RGB color mode, with an image width of 320 pixels and a transmission time of 345.6 ms per line.
According to the manual, after the VIS identifier has been transmitted, a starting scan pulse of 1200 Hz @ 9.0 ms① is first transmitted. This pulse is sent only once before transmitting the first row and provides a synchronization point for the entire image transmission. Subsequently, a separating pulse of 1500 Hz @ 1.5 ms② is transmitted, used to distinguish different color channels.
After the pulse separation, the first line of green channel data ③ is immediately transmitted. Each pixel generates a corresponding tone based on the intensity of its green channel:
f = 1500 + Green * COLOR_FREQ_MULT
The green component value for each pixel: Green (0255) Determine the corresponding frequency, with a transmission duration of 1.08 ms1.08 ms = 345.6 ms / 320 px). Starting from the first pixel in that line, sequentially transmit the green channel intensity of each pixel.
According to the manual's timing sequence, after the green scan is complete, immediately transmit a separation pulse of 1500 Hz @ 1.5 ms ④, and then begin scanning for the next color (blue)⑤ in the same manner as the green scan. After the blue scan is complete, immediately transmit a synchronization pulse of 1200 Hz @ 9.0 ms ⑥ and a synchronization edge ⑦ of 1500 Hz @ 1.5 ms, and then continue scanning for red ⑧. After the red scan is complete, this line is finished, and the next line's transmission (steps ② to ⑧) begins. At this point, the receiving end can assemble the three RGB channels based on the content received to reconstruct the first row of a color image.
Once all rows of the image have been scanned, the program automatically ends. You can also add an additional sound to indicate completion.
The following is a simplified modulation function:
// tone(frequency, duration, phase): Function to transmit a signal
// rgb(color channel, pixel x-coordinate, pixel y-coordinate): Function to read the color intensity of a specific pixel
// COLOR_FREQ_MULT: Color frequency multiplier
// Function: Generate Scottie-DX pattern
void generate_scottie_dx() {
// Starting synchronization pulse, only for the first line
tone(1200, 9, 0);
// Image data part
for (int line = 0; line < 10; line++) {
for (int col = 0; col < 80; col++) {
if (line == 0) {
rgb(255, col, line); // White on the first line
} else {
// Calculate color based on frequency and phase
float freq = 100 + sin(col * 0.1);
float phase = 0.5 + cos(line * 0.2);
int color_value = (int)(freq * COLOR_FREQ_MULT * phase);
rgb(color_value, col, line);
}
}
}
} < 256; line++) {
// Extract pulse
tone(1500, 1.5, sign(oldercos) * asin(olderdata) + abs(sign(oldercos) - 1) / 2 * PI);
// Green scan
for(int x = 0; x < 320; x++) {
tone(1500 + rgb("g",x,line)*COLOR_FREQ_MULT, 1.08, phase_offset);
}
// Generate pulses
tone(1500, 1.5, sign(oldercos) * asin(olderdata) + abs(sign(oldercos) - 1) / 2 * PI);
// Blue scan
for(int x = 0; x < 320; x++) {
tone(1500 + rgb("b",x,line)*COLOR_FREQ_MULT, 1.08, phase_offset);
}
// Synchronize pulse and synchronization edge
tone(1200, 9, sign(oldercos) * asin(olderdata) + abs(sign(oldercos) - 1) / 2 * PI);
tone(1500, 1.5, sign(oldercos) * asin(olderdata) + abs(sign(oldercos) - 1) / 2 * PI);
// Red scan
for(int x = 0; x < for (int x = 0; x < 320; x++) {
tone(1500 + rgb("r",x,line)*COLOR_FREQ_MULT, 1.08, phase_offset);
}
}
In addition to the RGB color mode, there is also a Y, R-Y, B-Y color mode.
The conversion relationships are as follows:
Y = 16 + 0.003906 * (65.738 * R + 129.057 * G + 25.064 * B)
R-Y = 128 + 0.003906 * (112.439 * R - 94.154 * G - 18.285 * B)
B-Y = 128 + 0.003906 * (-37.945 * R - 74.494 * G + 112.439 * B)
Different SSTV modes have different color patterns and modulation timings, so it's important to follow the instructions in the manual (available at the end of this document).
It's worth noting that not all SSTV modes are single-line scanning; some modes, such as the PD series, use two-line scanning.

The PD series SSTV uses Y, R-Y, and B-Y color modes.
According to the manual, the PD series SSTV mode first transmits the intensity of odd-numbered rows in the Y channel (starting from row 0), then transmits the mean intensities of the R-Y and B-Y channels for both the current odd-numbered row and the next even-numbered row below it, and finally transmits the intensity of the Y channel for the odd-numbered row. This allows scanning two rows in a single pass.
The simplified transmission process is as follows:

Due to the wide variety of patterns, it's impossible to explain them all, so I can only provide examples of the two patterns mentioned above.
For other modes, specific modulation settings must be configured according to the manual (the manual is at the end).
III. Some issues encountered
Initially, I wanted to output the audio generated by modulation directly from the sound card, but after trying for a while, it was impossible to achieve. So, I had to use a WAV container to store the modulated audio first. After modulating a segment of audio, I found that the file size was surprisingly large, reaching 25 megabytes... Then, I started looking for ways to reduce the file size.
As widely known, the size of a WAV file depends on several parameters:
- Sampling rate
- Depth
- Number of channels
In this case, both the number of channels and bit depth have been adjusted to their minimum requirements. The next step is to reduce the sampling rate. As we know from the previous analysis, the maximum frequency for all SSTV modes does not exceed 2500 Hz. According to Nyquist's theorem:
f_sample = 2 × f_max
Theoretically, a sampling rate of 5000 Hz is sufficient to store the modulated signal properly. For redundancy purposes, I ultimately used a sampling rate of 6000 Hz. The file size was reduced by more than 80%.
Percentage reduction = (1 - (6000/44100)) * 100 ≈ 86.4%
Let's put the storage issue aside for now.
The next issue is the generated audio. Initially, I thought it would be relatively simple, focusing only on frequency and duration when generating the signal, while ignoring phase issues. However, the resulting audio was extremely harsh, with a large number of irregular frequency components in its spectrum. The abrupt changes in phase caused the generation of these irregular frequency components, leading to significant distortion of the modulated signal.


Subsequently, some variables were introduced to achieve continuous phase adjustment, and the demodulated image was restored to normal.
double olderdata; // Previous amplitude, for continuous phase
double oldercos; // Previous cosine value, for continuous phase
// Function: Generate and write a sine wave audio with specified frequency, duration, and initial phase.
void write_tone(double frequency, double duration_ms, double phi) {
uint32_t num_samples = SAMPLE_RATE * duration_ms / 1000;
delta_lenth += SAMPLE_RATE * duration_ms / 1000 - num_samples;
if (delta_lenth >= 1) {
num_samples += (int)delta_lenth;
delta_lenth -= (int)delta_lenth;
}
double phi_samples = SAMPLE_RATE * phi;
short buffer[num_samples];
for (uint32_t i = 0; i < for (num_samples; ++i) {
buffer[i] = (short)(32767 * sin((2 * PI * frequency * i + phi_samples) / SAMPLE_RATE));
}
fwrite(buffer, sizeof(short), num_samples, file);
total_samples += num_samples;
olderdata = sin((2 * PI * frequency * num_samples + phi_samples) / SAMPLE_RATE);
oldercos = cos((2 * PI * frequency * num_samples + phi_samples) / SAMPLE_RATE);
}
tone(freq, time, sign(oldercos) * asin(olderdata) + abs(sign(oldercos) - 1) / 2 * PI);
However, there's another issue to consider: because the product of pixel duration and sampling rate is not an integer, directly rounding it will result in a slight reduction in the duration of each pixel.

Finally, BI4PYM uses cumulative error compensation. It adds up the decimal places and compensates with a sampling period when the error exceeds a certain duration, thereby eliminating timing errors. This part of the code is also implemented in the previous C language block.
These are essentially the main issues I encountered. I'm very grateful to BI4PYM for their assistance and theoretical support.
While the current program leverages the performance advantages of C, it has achieved significant improvements, overall performance still needs optimization. We welcome suggestions for improvement.
Attachment:
The forum seems not to support PDF file format... Please refer to the post I made on the 科创 (Ke-Chuang) platform:
https://www.kechuang.org/t/90795