Draft notice: this page is incomplete and will be updated relatively soon.

The full source code for the SSTV portion of the SkyPi telemetry package is available here.

The Java code below is one class of several from a real-time telemetry program I wrote in early 2013 for a high altitude ballooning project. This particular class takes a raw image buffer (typically from an attached camera) and then turns it into an audio waveform buffer which would be transmitted back to a ground station using a standard amateur radio transmitter.

This class is itself a loose port of a now-lost Objective-C program I wrote around 2010 while making an unpublished SSTV iPhone app.
/** We'll use BufferedImages almost exclusively, import them here. */
import java.awt.image.BufferedImage;

/** Using a native byte buffer grants some improvements over using an array. */
import java.nio.ByteBuffer;

/** 
 * Generates an SSTV waveform buffer. SSTV documentation mostly from JL Barber's (N7CXI) paper (http://www.barberdsp.com/files/Dayton%20Paper.pdf).
 *
 * @author Stephen Gibbel
 * @version 0.1
 */
public class SSTVWaveformGenerator {
	/** The decimal code that specifies the SSTV mode to be used, also used in the Vertical Interval Signal code. */
	private int mode;

	/** The soundcard sample rate to use for the audio buffer. */
	private int sampleRate;

	/** 1 / sampleRate, the amount of time between samples. Might as well precalculate this. */
	private double samplePeriod;

	/** An index for the buffer, can probably be merged with audioBufferIndex. */
	private double time;

	/** The image that will be turned into a waveform buffer. */
	private BufferedImage image;

	/** The waveform buffer that will get sent to the soundcard. */
	public ByteBuffer audioBuffer;

	/** An index for the current location in the waveform buffer array. */
	private int audioBufferIndex;


	/** 
	 * Constructor. Doesn't throw any exceptions yet, nothing here is really error resistant at all...
	 *
	 * @param mode 				The decimal code that specifies the SSTV mode to be used, also used in the Vertical Interval Signal code.
	 * @param sampleRate 		The soundcard sample rate to use for the audio buffer.
	 * @param image		 		The BufferedImage that will be turned into a waveform buffer.
	 */
	public SSTVWaveformGenerator(int newMode, int newSampleRate, BufferedImage newImage) {
		//Set all of the instance variables.
		mode = newMode;
		sampleRate = newSampleRate;
		samplePeriod = 1.0 / sampleRate;
		image = newImage;
		time = 0; //As good a place to start as any.
		audioBuffer = ByteBuffer.allocate(sampleRate * 2 * 117); //The length of the audio buffer really shouldn't be a fixed value. Fix this as soon as possible.
		audioBufferIndex = 0;
	}

	/**
	 * Returns the waveform buffer as a byte[].
	 */
	public byte[] getAudioBuffer() {
		return audioBuffer.array();
	}

	/**
	 * Starts the image generation. May take a while, should probably be threaded in the future. Takes no arguments and returns nothing.
	 */
	public void generateImage() {
		oldGenerateCalibrationHeaderAndVISBuffer();	//Start by adding the vertical interval signal
		switch (mode) {								//Then select and generate the appropriate waveform
			case 40:	generateMartin2Buffer();
						break;
			case 44:	generateMartin1Buffer();
						break;
			case 48:	generateScotty4Buffer();
						break;
			case 52:	generateScotty3Buffer();
						break;
			case 55:	generateWrasseSC2180Buffer();
						break;
			case 56:	generateScotty2Buffer();
						break;
			case 60:	generateScotty1Buffer();
						break;
			case 76:	generateScottyDXBuffer();
						break;
			case 80:	generateScottyDX2Buffer();
						break;
		}
	}

	/**
	 * Clever recursive string reverse by polygenelubricants (http://stackoverflow.com/a/2441557). Watch out, easy to crash with wrong input.
	 */
	private static String reverse(String in, String out) {
		return (in.isEmpty()) ? out : (in.charAt(0) == ' ') ? out + ' ' + reverse(in.substring(1), "") : reverse(in.substring(1), in.charAt(0) + out);
	}

	/**
	 * Generates the calibration header and vertical interval signal code. Also broken, fix whenever.
	 */
	private void generateCalibrationHeaderAndVISBuffer() {
		//Calibration header:
		addTone(300, 1900); //Leader tone
		addTone(10, 1200); //Break
		addTone(300, 1900); //Leader tone

		//VIS code:
		addTone(30, 1200); //VIS start bit
		int parity = 0;
		String binaryMode = new String(Integer.toBinaryString(mode)); //This seems to add the correct number of leading 0s. Be careful though...
		binaryMode = reverse(binaryMode, "9"); //The 0 will get added to the end preventing an OOB at the end of the loop
		for (int i = 0; i <= 6; i++) { //Get first 7 binary digits of mode, xmit LSB 1st (1100hz==1, 1300hz==0)
			if (Integer.parseInt(binaryMode.substring(i, i+1)) == 1) {
				addTone(30, 1100);
				parity++;
			}
			else if (Integer.parseInt(binaryMode.substring(i, i+1)) == 0) {
				addTone(30, 1300);
			}
		}

		if (parity % 2 == 0) //Even parity bit
			addTone(30, 1300);
		else
			addTone(30, 1100);

		addTone(30, 1200); //VIS stop bit
	}

	/**
	 * Generates the calibration header and vertical interval signal code. Eww, hardcoded options are bad.
	 */
	private void oldGenerateCalibrationHeaderAndVISBuffer() {
		//Calibration header:
		addTone(300, 1900); //Leader tone
		addTone(10, 1200); //Break
		addTone(300, 1900); //Leader tone
		//VIS code: (1100hz=1, 1300hz=0)
		addTone(30, 1200); //VIS start bit
		addTone(30, 1300); //For now, Scotty 1 is hardcoded. 60d = 0111100, xmit LSB 1st parity bit even
		addTone(30, 1300);
		addTone(30, 1100);
		addTone(30, 1100);
		addTone(30, 1100);
		addTone(30, 1100);
		addTone(30, 1300);
		addTone(30, 1300);//parity bit
		addTone(30, 1200); //VIS stop bit
	}

	/**
	 * Maps a number in a given range to another given range. Standard algorithm, there should really be a method in java.lang for this.
	 *
	 * @param number			The number to be mapped from one range to the next.
	 * @param floorOne			The floor of the first range.
	 * @param ceilingOne		The ceiling of the first range.
	 * @param floorTwo			The floor of the second range.
	 * @param ceilingTwo		The ceiling of the second range.
	 * @return					Returns the number mapped from the first range to the second.
	 */
	private int map(int number, int floorOne, int ceilingOne, int floorTwo, int ceilingTwo) {
		return (int) ((double)floorTwo + ((double)number - (double)floorOne) * ((double)ceilingTwo - (double)floorTwo) / ((double)ceilingOne - (double)floorOne) );
	}

	/**
	 * Adds a single frequency tone lasting for a specified duration to the end of the audio buffer. Partly from a post by brasszero on stackoverflow (http://stackoverflow.com/a/376209)
	 *
	 * @param duration			The duration of the tone in milliseconds.
	 * @param frequency			The frequency of the tone in Hertz.
	 */
	private void addTone(double duration, int frequency) {
		double scale = 1.1;
		duration = duration * Math.pow(10, -3);
		int durationInSamples = (int)Math.ceil(duration * (double)sampleRate);
		for (int i = audioBufferIndex; i < audioBufferIndex + durationInSamples; i++) {
			short newValue = (short)(Short.MAX_VALUE * Math.sin(2 * Math.PI * frequency * time));
			//System.out.println(newValue);
			if (Math.abs(newValue) > scale * audioBuffer.getShort(i))
				audioBuffer.putShort((short) ((double) newValue / scale) );
			else
				audioBuffer.putShort(newValue);
			time += samplePeriod;
		}
	}

	/**
	 * Generates a Martin 1 waveform buffer.
	 */
	private void generateMartin1Buffer() {
		for (int y = 0; y < image.getHeight(); y++) {
			addTone(4.862, 1200); //Sync pulse
			addTone(.572, 1500); //Sync porch
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(.4576, map((image.getRGB(x, y) >> 8) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Green scan
			}
			addTone(.572, 1500); //Separator pulse
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(.4576, map(image.getRGB(x, y) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Blue scan
			}
			addTone(.572, 1500); //Separator pulse
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(.4576, map((image.getRGB(x, y) >> 16) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Red scan
			}
			addTone(.572, 1500); //Separator pulse
		}
	}

	/**
	 * Generates a Martin 2 waveform buffer.
	 */
	private void generateMartin2Buffer() {
		for (int y = 0; y < image.getHeight(); y++) {
			addTone(4.862, 1200); //Sync pulse
			addTone(.572, 1500); //Sync porch
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(.2288, map((image.getRGB(x, y) >> 8) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Green scan
			}
			addTone(.572, 1500); //Separator pulse
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(.2288, map(image.getRGB(x, y) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Blue scan
			}
			addTone(.572, 1500); //Separator pulse
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(.2288, map((image.getRGB(x, y) >> 16) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Red scan
			}
			addTone(.572, 1500); //Separator pulse
		}
	}

	/**
	 * Generates a Scotty 1 waveform buffer.
	 */
	private void generateScotty1Buffer() {
		addTone(9, 1200); //Starting sync pulse
		for (int y = 0; y < image.getHeight(); y++) {
			addTone(1.5, 1500); //Separator pulse
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(.432, map((image.getRGB(x, y) >> 8) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Green scan
			}
			addTone(1.5, 1500); //Separator pulse
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(.432, map(image.getRGB(x, y) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Blue scan
			}
			addTone(9, 1200); //Sync pulse
			addTone(1.5, 1500); //Sync porch
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(.432, map((image.getRGB(x, y) >> 16) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Red scan
			}
		}
	}

	/**
	 * Generates a Scotty 2 waveform buffer.
	 */
	private void generateScotty2Buffer() {
		addTone(9, 1200); //Starting sync pulse
		for (int y = 0; y < image.getHeight(); y++) {
			addTone(1.5, 1500); //Separator pulse
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(.2752, map((image.getRGB(x, y) >> 8) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Green scan
			}
			addTone(1.5, 1500); //Separator pulse
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(.2752, map(image.getRGB(x, y) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Blue scan
			}
			addTone(9, 1200); //Sync pulse
			addTone(1.5, 1500); //Sync porch
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(.2752, map((image.getRGB(x, y) >> 16) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Red scan
			}
		}
	}

	/**
	 * Method stub, generates a Scotty 3 waveform buffer.
	 */
	private void generateScotty3Buffer() {}

	/**
	 * Method stub, generates a Scotty 4 waveform buffer.
	 */
	private void generateScotty4Buffer() {}

	/**
	 * Generates a Scotty DX waveform buffer.
	 */
	private void generateScottyDXBuffer() {
		addTone(9, 1200); //Starting sync pulse
		for (int y = 0; y < image.getHeight(); y++) {
			addTone(1.5, 1500); //Separator pulse
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(1.08, map((image.getRGB(x, y) >> 8) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Green scan
			}
			addTone(1.5, 1500); //Separator pulse
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(1.08, map(image.getRGB(x, y) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Blue scan
			}
			addTone(9, 1200); //Sync pulse
			addTone(1.5, 1500); //Sync porch
			for (int x = 0; x < image.getWidth(); x++) {
				addTone(1.08, map((image.getRGB(x, y) >> 16) & 0x000000FF, 0x00, 0xFF, 1500, 2300)); //Red scan
			}
		}
	}

	/**
	 * Method stub, generates a Scotty DX2 waveform buffer.
	 */
	private void generateScottyDX2Buffer() {}

	/**
	 * Method stub, generates a Wrasse SC2-180 waveform buffer.
	 */
	private void generateWrasseSC2180Buffer() {}

	/**
	 * Method stub, does absolutely nothing.
	 */
	private void doNothing() {
		System.out.println("Nothing.");
	}

}