[ home ]

Silly Saturday serial sound-device

(tl;dr = this thing makes sound :-)

Decided to do nothing this Saturday afternoon and evening (2015-10-31 - Halloween!) and instead try to make a simple sound-device for streaming audio from a PC serial-port.

I didn't want to use a MCU or specific chips for this, mainly because recently I've come to enjoy and appreciate the simple and elegant things more and more.

The basic idea is to have the PC emit/stream a bit-stream out of its serial-port, and have the sound-device play it. And by 'serial-port' I mean an actual legacy serial-port, no USB.

Challenge: framing, D/A conversion

Since the PC serial-port communicates digital data, I wanted to be able to send the serial representation of simple wave-shapes to the device, which would then convert them back into analog voltages to drive a speaker.

The UART behind a (PC) serial-port is by definition ... asynchronous. That means there is no separate clock.

That also means that, when using dumb components without a concept of framing, some other way has to be used in order to reconstruct the original wave-shape.

I didn't want to bluntly turn the speaker on/off at each bit.

Solution: op-amp integrator

An op-amp integrator basically integrates its input-voltage over time.

For example, when changing the input-voltage (at the op-amp negative terminal) from 0 to 5 V, the output will ramp to its allowed minimum using a constant slope. When changing the input-voltage back to 0 V, the output will ramp back to its allowed maximum value.

TTL-/CMOS-level voltages of each incoming serial bit can thus be used as integrator-input; the integrator-output will then show the accumulated voltage resulting from integrating the recent bit-pattern history.

That is, each incoming bit is used to ramp the integrator-output either up or down a bit (no pun intended). A scope-picture of this is shown later on.

To make the up- and down-ramps of the integrator equally steep, the reference-voltage (at the op-amp positive terminal) is set between the minimum and maximum bit-voltage (2.5 V, in between 0 and 5 V).


Hey, this whole project is one big flaw :-) However, ...

In this scenario, it is not possible to not change the integrator's output; it either goes up or down at each incoming bit. (A '0'-bit ramps the integrator up, while a '1'-bit ramps it down.)

When receiving the same bit-value for a while, the op-amp will go into saturation; its output will then clamp to the negative or positive rail.

The start- and stop-bit in each serial frame are inconvenient, and cause an up- respectively down-ramp at fixed positions in each serial byte. However, this does not result in a cumulative error at the integrator-output, since up- and down-ramps are equally steep.

This laptop (Dell Latitude D830) running Linux and some apps was not able to output serial frames at 115k2 head-to-tail. Resulting gaps would effectively look like a '1'-bit at the TTL-/CMOS-side of the transceiver, and would make the integrator-output shoot to its negative rail. This makes emitting nice wave-shapes almost impossible. At 57k6, there was no problem whatsoever - start-bit of frame N sits snugly against the stop-bit of frame N-1.



Schematic using junkbin-parts is as follows:

As can be seen, besides the serial transceiver converting the incoming bit-stream from RS-232 to TTL-/CMOS-level and the op-amp itself, there is only an emitter-follower (BC547) acting as a simple amplifier.

This thing was breadboarded, fed with a 5 V PSU, and then connected to the PC serial-port. Speaker is a small tweeter - perhaps a normal PC-speaker would have been nicer.

Converting tunes for streaming

As test-tune, I used among others the intro-music from the C64 game Delta, composed by the legendary Rob Hubbard.

To convert from original (MP3) to headerless, 14400 bps, single-channel, 8-bit PCM data, I used...

    $ sox $in_mp3_file -r 14400 -c 1 -b 8 $out_raw_file

(where output-filename must end in '.raw')

To convert these raw samples into a bit-stream, I used the following Tcl program:

    #!/usr/bin/env tclsh
    proc die msg { puts "FATAL: $msg"; exit }
    proc loop { N code } { for { set i 0 } { $i < $N } { incr i } { uplevel $code } }
    proc emit_byte x { puts -nonewline [ uplevel #0 set outfile ] [ binary format cu $x ] }
    set nbit 0
    set accu 0
    proc emit_bit x { 
        upvar #0 nbit nbit
        upvar #0 accu accu
        incr accu [ expr { $x << $nbit } ]
        if { [ incr nbit ] == 8 } {
            emit_byte $accu
            set nbit 0
            set accu 0
    proc emit_converted_samples samples {
        set level 128
        set step   64
        foreach sample $samples {
            loop 3 {
                if { $sample > $level } { emit_bit 1; incr level         $step
                } else                  { emit_bit 0; incr level [ expr -$step ] }
    # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # 
    if { [ llength $argv ] < 2 } { die "need <infile> <outfile>" }
    set infile [ open [ lindex $argv 0 ] r ]
    fconfigure $infile -translation binary
    binary scan [ read $infile ] cu* samples
    close $infile
    set outfile [ open [ lindex $argv 1 ] w ]
    fconfigure $outfile -translation binary
    emit_converted_samples $samples
    close $outfile

It basically tracks each sample using discrete 'up' or 'down' ramps (emitting respectively '0'- or '1'-bits). It gives the integrator 3 bit-times to track the original sample, so that a 14400 bps tune sent at 57k6 will be played at almost the correct speed.

(the 14400 bps speed was used because I was planning on using 4 bit-times to track each sample, but forgot about start- and stop-bits... Using 3 bit-times instead makes the error pretty small. I'm too lazy to reconvert the tunes. :-)

To convert raw PCM-data to a bitstream using the above program:

    $ ./tcl.tcl $in_raw_file $out_stream_file

To configure a serial-port for 57k6 8N1:

    # stty -F /dev/ttyS0 57600

And finally, to play the bitstream:

    # cat $in_stream_file > /dev/ttyS0

In action

A snapshot when probing the TTL-/CMOS-level incoming serial bit-stream (yellow), the integrator-output (blue) and the speaker-voltage (purple) is shown here:

(The upper half of the screen is a macro-view; the region with black background in the center of the upper half is zoomed in the lower half of the screen.)

As can be seen, the blue trace (integrator-output) goes up and down in discrete steps, about 1 V per bit-time (following from the value of Vcc, R and C in the schematic).

The speaker-voltage (purple) is filtered in a sad way (using a 100 Ohm resistor and 10 uF electrolytic cap); capacitor-value was determined experimentally.

The resulting sound can be heard here. Needless to say this device will not replace any commercial solutions any time soon...

For reference, fade-in and -out and normalisation were done using:

    sox $in_playback_file fade 2 -0 norm $out_playback_file

That's all!