So far, I've experimented with three methods for modulating audio onto RF output from the serial port. They're all pretty simple, and all cause a ton of spurious emissions up and down the band, but are interesting to play with. The first method uses the five selected bit sequences to create a type of pulse-density modulation:
In a normal pulse-density modulation scheme, like the output of a class-D amplifier or the 1-bit delta-sigma DAC inside your expensive CD player, the high frequencies of the pulses are filtered out and you are left with a faithful representation of the input signal. In this case, the high frequencies of the pulses are used as an RF carrier.
As shown in the figure above, the simplest way to modulate a signal onto the fundamental frequency is to sample the audio waveform and quantize the signal level to one of the five bit patterns previously discussed. High levels in the signal result in a higher density of pulses, while lower levels create fewer pulses. The AM receiver then reconstructs this into a representation of the audio signal.
Here are the bit sequences again:
0xff -_--------- 0xfd -_-_------- 0xf5 -_-_-_----- 0xd5 -_-_-_-_--- 0x55 -_-_-_-_-_- CODES = [0xff, 0xfd, 0xf5, 0xd5, 0x55] LEVELS = [1, 2, 3, 4, 5]
given these, the python code to create the serial data stream is easy - we simply quantize to five levels and choose the appropriate character to output for each sample:
def pdm(data): """Modulate using character-based pulse density modulation.""" chars =  err = 0 for val in data: err = 2 + 2*val idx = max(0, min(4, int(err))) code = CODES[idx] chars.append(code) return chars
The result is a quantization to 2.32 bits since there are 5 levels [log2(5) ~ 2.32]. A 2.32 bit DAC has around 14 dB SNR, so we shouldn't expect too much from this scheme. You can listen to the result as received on an upconverted RTL-SDR dongle with GQRX here:
As expected, the results aren't very good, but the signal is recognizable. If you zoom out on the receiver, you can see all of the spurious emissions created by this method (see below). Luckily, they are fairly widely separated from the fundamental (at 1MHz in this case), so could be removed with a bandpass filter if one really wanted to use this method.
1-Bit Delta-Sigma Modulation
I mentioned 1-bit DACs above. If you haven't seen them before, you'd think that a 1-bit DAC would be pretty useless, resulting in an abysmal 6dB of SNR. The trick to making a 1-bit DAC work is oversampling plus feedback. As shown in the python code below, you simply need to compare the input value to a threshold - if above, a "1" is output, and if below, a "0" is omitted. After each value is quantized this way, the error is saved and added to the next sample. This method is known as a delta-sigma converter.
In this case, a '1' is transformed into a character of value 0x55, while a "0" results in 0xff. These two characters represent the largest and smallest carrier magnitude, respectively.
def delta_sigma_1bit(data): """Modulate using 1-bit delta-sigma modulation.""" chars =  err = 0 for val in data: err += 2 + 2*val if err > 4: err -= 4 chars.append(0x55) else: chars.append(0xff) return chars
The way this scheme achieves better than a 6dB SNR is by oversampling. In the case of the experiments here, the audio is upsampled to 200000 samples/second (10 bit frames at 2Mbaud). This represents a 200000/11025 = 18.1x oversampling, which results in a gain of log2(18.1) = 4.18 bits. Adding the 1-bit from the quantizer, this method yields about 5.18 bits or 31.1 dB of SNR. You can listen to the results below, and hear how much better this method sounds.
There is one serious problem with this result, though - it generates noise all across the band which you can see in the image below. I'm still not exactly why this is so, but this noise would be more difficult to filter out than the single spikes of the simpler PDM-like method described above.
Multi-value Delta-Sigma Modulation
It turns out that we can combine elements of the two modulation methods above to create an even better one. In this case, we combine the 5-level quantizer and the feedback from the delta-sigma scheme, as shown in this snippet of python code:
def delta_sigma_multivalue(data): """Modulate using multilevel delta-sigma modulation.""" chars =  err = 0 for val in data: err += 2 + 2*val idx = max(0, min(4, int(err))) code = CODES[idx] chars.append(code) err -= LEVELS[idx] return chars
With the same 18.1x oversampling factor plus the original 5-level quantizer, this method should result in 2.32+4.18 = 6.5 bits or 39 dB of SNR. As you can hear in the test below, it definitely sounds better:
There is an added bonus to this method as well: the distributed noise between the sub-harmonics is greatly reduced relative to the 1-bit method as shown below. This signal looks easier to clean up with a bandpass filter (it's still not great, but it's likely possible).
In all of the outputs, you can hear a number of clicks and pops. I believe this is due to under-running the serial port. All of these tests were conducted on a relatively busy desktop PC, running the transmitter and the GQRX receiver at the same time (but on different USB busses). If the byte stream is first saved to a file, then sent to the port using the linux 'cat' command, these artifacts disappear.
I've determined that the CP2012N's clock stability is causing distortion in these signals. Results with the FT232RL are much cleaner, although the differences between the modulation methods are about the same.