CHIP-8 in Common Lisp: Sound
Posted on December 26th, 2016.
In the previous posts we looked at how to emulate a CHIP-8 CPU with Common Lisp, added a screen to see the results, and added user input so we could play games. This is good enough for basic play, but if we want the full experience we'll need to add sound. Let's finish the emulator.
The full series of posts so far:
- CHIP-8 in Common Lisp: The CPU
- CHIP-8 in Common Lisp: Graphics
- CHIP-8 in Common Lisp: Input
- CHIP-8 in Common Lisp: Sound
- CHIP-8 in Common Lisp: Disassembly
- CHIP-8 in Common Lisp: Debugging Infrastructure
- CHIP-8 in Common Lisp: Menus
The full emulator source is on BitBucket and GitHub.
- CHIP-8 Sound
- The Emulation Layer
- Sound From Scratch
- Sine Waves
- Square Waves
- Sawtooth Waves
- Triangle Waves
- Playing Sound
- Angle Rate & Frequency
- Running the Sound Loop
- Threading Issues
The CHIP-8 has an extremely simple sound and timer system. See Cowgod's documentation for an overview.
In a nutshell there are two registers: the "sound timer" and "delay timer". Each of these is decremented sixty times per second whenever they are non-zero.
The delay timer has no special behavior beyond this, but ROMs can read its value and use it as a real-time clock.
The sound timer cannot be read by ROMs, only written. Whenever its value is positive the CHIP-8's buzzer will sound. That's the extent of the CHIP-8's sound: one buzzer that's either on or off.
The Emulation Layer
Let's add the required registers and instructions to the emulator.
First we'll add the registers into the
(defstruct chip ; ... (delay-timer 0 :type fixnum) (sound-timer 0 :type fixnum) ; ... )
These are of type
fixnum instead of
int8 for reasons we'll see later.
The CHIP-8 has three
LD instructions for dealing with these registers. We
actually saw them back in the first post, but now we know their purpose:
(macro-map ;; LD (NAME ARGLIST DESTINATION SOURCE) (; ... (op-ld-reg<dt (_ r _ _) (register r) delay-timer) (op-ld-dt<reg (_ r _ _) delay-timer (register r)) (op-ld-st<reg (_ r _ _) sound-timer (register r))) `(define-instruction ,name ,arglist (setf ,destination ,source)))
Next we'll need to decrement the timers at a rate of 60hz. We could do some math to figure out the number of cycles between each and do this in the main loop, but let's use a separate thread instead:
(defun run (rom-filename) (let ((chip (make-chip))) (setf *c* chip) (load-rom chip rom-filename) (bt:make-thread (curry #'run-cpu chip)) (bt:make-thread (curry #'run-timers chip)) ; NEW (chip8.gui.screen::run-gui chip)))
Now we just need to define the
run-timers function that will be run in that
(defun run-timers (chip) (iterate (while running) (decrement-timers chip) (sleep 1/60)))
Simple enough. Technically this will run slightly slower than 60hz, because it will take some time to actually decrement the timers, and our thread might not get woken up exactly 1/60 of a second later.
We could try to fix this by sleeping for less or using a timer wheel, but I think this is good enough for our needs here. We already have to suffer through GC pauses anyway, so trying to get absolute precision is going to be more trouble that it's worth.
Now to decrement the timers. The CPU thread might be executing a write instruction as this thread is updating, so we'll use SBCL's atomic compare-and-swap functionality to avoid losing decrements:
(defun decrement-timers (chip) (flet ((decrement (i) (if (plusp i) (1- i) 0))) (with-chip (chip) (sb-ext:atomic-update delay-timer #'decrement) (sb-ext:atomic-update sound-timer #'decrement))) nil)
This is why we declared the timer slots in the
chip struct to be
— the SBCL manual states that for
(atomic-update place function ...):
place can be any place supported by
Built-in CAS-able places are accessor forms whose car is one of the following:
or the name of a defstruct created accessor for a slot whose declared type is either fixnum or t. Results are unspecified if the slot has a declared type other than fixnum or t.
(emphasis mine). Remember that
(chip-delay-timer ...) by
with-chip, so it does refer to a "defstruct
We could have used a lock from bordeaux threads to manage this portably (though more slowly), but I wanted to play around with SBCL's concurrency stuff.
Sound From Scratch
Now that we've got the timers all set up, all that's left is to play a sound
sound-timer is positive. We could do this in a number of ways, for
example: loading a
WAV file and looping it. But that's boring and almost
cheating, so let's do it from scratch.
Sound is a pretty complicated beast. For this CHIP-8 emulator we'll only dip our toes into the water and work with the very basics. I'll explain some basics here but will simplify and gloss over a lot — there are plenty of resources online if you want to learn more.
For our purposes we'll think of "sound" as a pressure value over time. For example, a simple sound wave might look something like this:
The pressure starts at 0, gradually climbs until it hits 1, then falls and gradually hits -1, then returns to 0 and starts the process over again.
The distance from the highest pressure value to the lowest is called the "amplitude" of the wave (specifically "peak-to-peak amplitude"). For sound, this is what determines how loud the sound is. For the CHIP-8 emulator we'll always just be using pressure values between -1 and 1.
There are (infinitely) many different types of sound waves, each with their own distinct character. Let's look at a few of them that might fit well with the CHIP-8's retro aesthetic. This page has some example audio files so you can hear what they sound like.
The wave I used as the example above is a sine wave. It's based on the
sine function you might have learned about in trigonometry class.
We usually think of
sin as taking an angle as an argument, not a time. But we
can convert time to an appropriate angle value later, so let's not get hung up
on that. Sine performs one complete "wave" in exactly τ radians:
Common Lisp has this built in as the
sin function, so we don't have any work
to do for this one.
The next wave we'll look at is the square wave. Instead of gradually moving between -1 and 1 over time like sine, it stays at 1 for half its wave then immediately jumps straight to -1:
This "jump" gives the square wave kind of a "buzzy" character that you may have heard before if you've played many old computer games (or like to listen to chiptunes).
Common Lisp doesn't have this function built in, but we can make it pretty easily. First we'll define some useful constants:
(defconstant +pi+ (coerce pi 'single-float)) (defconstant +tau+ (* 2 +pi+)) (defconstant +1/4tau+ (* 1/4 +tau+)) (defconstant +1/2tau+ (* 1/2 +tau+)) (defconstant +3/4tau+ (* 3/4 +tau+))
We're using single-floats because our audio library is going to want those later.
Now we can define the square-wave function:
(defun sqr (angle) (if (< (mod angle +tau+) +1/2tau+) 1.0 -1.0))
I've called it
sqr because my utility library already has a function called
"square", and I like that it's three letters like the
The implementation is pretty simple. We just have to make sure to
angle by τ to make the results repeat properly, like this:
The sawtooth wave is next up in our little menagerie of waveforms. The name comes from what it looks like when you have a few in a row:
A single wave of it looks like this:
Sawtooth waves still have a bit of a "buzzy" feel to them because of the jump halfway through their period, but unlike square waves they have some gradual change, so they're often a nice happy medium.
To implement the
saw function, notice that for the first half of the wave's
life (0 to τ) it goes from 0 to 1, and for the second half (½τ to τ) it goes
from -1 to 0. We'll also remember to
mod by τ to proper repeating:
(defun saw (angle) (let ((a (mod angle +tau+))) (if (< a +1/2tau+) (map-range 0 +1/2tau+ 0.0 1.0 a) (map-range +1/2tau+ +tau+ -1.0 0.0 a))))
map-range is a really handy function from my utility library.
I wish I could think of a better name for it. It's kind of like a lerp
function, but instead of assuming the input value is between 0 and 1 it allows
you to specify the input range.
map-range takes five arguments:
(map-range source-start source-end dest-start dest-end value)
I think of it as taking a source number line with a value on it, stretching that number line to become the destination line, and finding the new location of the value:
2 3 4 5 6 2 3 4 5 6 ━━◉━━━━━━━━━━ ━━━━━━━━━◎━━━ ╱ │ ╲ ╱ │ ╲ ╱ │ ╲ ╱ │ ╲ ╱ ┌──┘ ╲ ╱ └───┐ ╲ ╱ │ ╲ ╱ │ ╲ ╱ ▼ ╲ ╱ ▼ ╲ ━━━━◉━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━◎━━━━━ 0 1 2 3 4 5 6 7 8 0 1 2 3 4 5 6 7 8
Let's look at one more kind of wave before moving on: the triangle wave. As you might expect, this wave looks like a big triangle:
Triangle waves are closer to sine waves than square or sawtooth waves were, but they've still got a bit of "sharpness" to them because they don't have that gradual rounding off at the peak like sine waves.
saw, we can define
tri by defining each half of the wave
(defun tri (angle) (let ((a (mod angle +tau+))) (if (< a +1/2tau+) (map-range 0 +1/2tau+ -1.0 1.0 a) (map-range +1/2tau+ +tau+ 1.0 -1.0 a))))
That's it for our whirlwind tour of simple sound waves.
We've got four functions (
tri) that take in angles
and spit out pressure values between -1 and 1, so the next step is to somehow
use them to make the computer play sound.
We'll be using PortAudio and cl-portaudio to handle the OS and sound
device interaction. If you're following along you'll need to install PortAudio
separately (before Quickloading
cl-portaudio). On OS X you can do this with
brew install portaudio, for Linux use your distro's package manager.
In the real world pressure and time vary continuously, but our computer handles audio differently. Modern digital audio uses the concept of sampling to split up the pressure-over-time graph into discrete pieces. Instead of trying to work with an infinite number of times, we look at a finite number of individual samples.
A "sample" is just a pressure value at a particular point in time. The number of times per second the computer reads or writes a sample is called the "sample rate". If the sampling rate is too low, we won't be able to tell much about the original wave, and playing it would sound like noise:
But a higher sample rate can get us nice and close to the original wave:
We'll stick with the most common sample rate, 44.1khz, because it's the most widely supported (even though it's overkill for our simple waves):
(defconstant +sample-rate+ 44100d0)
It would be wasteful to keep constantly calling back and forth between our code
and PortAudio's code, so instead we'll be giving PortAudio a buffer of samples
to play which will effectively contain a "chunk" of sound. This buffer will be
a vanilla Lisp array of
single-floats. The size of the buffer is
configurable, with larger buffers representing bigger chunks of sound.
There's a tradeoff between efficiency and responsiveness we
need to decide on. Larger buffers are more efficient (less switching back and
forth between us and PortAudio) but once we've sent a buffer it's going to play
to its end — we can't stop it midway. I've found
512 to be a happy medium:
(defconstant +audio-buffer-size+ 512 "The number of samples in the audio buffer.") (defconstant +audio-buffer-time+ (* +audio-buffer-size+ (/ +sample-rate+)) "The total time the information in the audio buffer represents, in seconds.") (defun make-audio-buffer () (make-array +audio-buffer-size+ :element-type 'single-float :initial-element 0.0))
A 512-sample buffer with a sample rate of 44100 samples per second means that each buffer will represent about 11.6 milliseconds of sound.
We're going to need a way to fill an audio buffer with sample values, so let's
(defun fill-buffer (buffer function rate start) (iterate (for i :index-of-vector buffer) (for angle :from start :by rate) (setf (aref buffer i) (funcall function angle)) (finally (return (mod angle +tau+)))))
fill-buffer will take one of our four waveform functions and fill the given
buffer with samples generated by it.
rate will be the rate at which the angle
should be incremented per-sample, which we'll figure out in a moment.
The one snag is that we can't just start from an angle of zero each time we fill a buffer (unless our buffer size happens to exactly match the period of our wave). If we did we'd only ever be sending the first chunk of our wave, and we'd end up with something like:
This is obviously not what we want. The solution is to return the angle we
ended at from
fill-buffer, and then pass it in as
start on the next round so
we can pick up where we left off.
Since we've gone to the trouble of writing four separate wave functions, each with their own character/timbre, let's make the sound the emulator plays configurable. First we'll make some helper functions for filling the audio buffer with a particular wave:
(defun fill-square (buffer rate start) (fill-buffer buffer #'sqr rate start)) (defun fill-sine (buffer rate start) (fill-buffer buffer #'sin rate start)) (defun fill-sawtooth (buffer rate start) (fill-buffer buffer #'saw rate start)) (defun fill-triangle (buffer rate start) (fill-buffer buffer #'tri rate start))
Then we'll add a slot to the
(defstruct chip ; ... (sound-type :sine :type keyword) ; ... )
And we'll make a helper function for retrieving the appropriate buffer-filling function:
(defun audio-buffer-filler (chip) (ecase (chip-sound-type chip) (:square #'fill-square) (:sine #'fill-sine) (:sawtooth #'fill-sawtooth) (:triangle #'fill-triangle)))
ecase instead of vanilla
case so we get a nicer error message if
the slot is set to something incorrect. I actually find myself reaching for
ecase more often than
case these days because a silent
nil result is
almost never what I want.
Angle Rate & Frequency
One last bit we need to figure out is how much to increment the angle for each sample.
All four of our wave functions assume that one wave happens in exactly
τ radians. So if we assume that we want one wave per second, and there are
+sample-rate+ samples in every second, we'd just use
(/ +tau+ +sample-rate+)
to get the angle increment.
But one wave per second is below the range of human hearing. We want something more like 440 waves per second (the "frequency" of the note A440), so our function to find the angle increment will looks like this:
(defun audio-rate (frequency) (coerce (* (/ +tau+ +sample-rate+) frequency) 'single-float))
We coerce it to a
single-float here to avoid having to do it on every addition
Running the Sound Loop
We've now got all the bits and pieces we need to make some noise. Let's build
run-sound function bit by bit. First we initialize the output stream with
(defun run-sound (chip) (portaudio:with-audio (portaudio:with-default-audio-stream (audio-stream 0 1 :sample-format :float :sample-rate +sample-rate+ :frames-per-buffer +audio-buffer-size+) ; ... )) nil)
0 1 arguments mean "zero input channels" (we don't need access to the
microphone!) and "one output channel".
Now we'll add our sound loop:
(defun run-sound (chip) (portaudio:with-audio (portaudio:with-default-audio-stream (audio-stream 0 1 :sample-format :float :sample-rate +sample-rate+ :frames-per-buffer +audio-buffer-size+) (with-chip (chip) ; NEW (iterate (with buffer = (make-audio-buffer)) ; NEW (with angle = 0.0) ; NEW (with rate = (audio-rate 440)) ; NEW (while running) ; NEW (if (plusp sound-timer) ; NEW ; ... ; NEW (sleep +audio-buffer-time+)))))) ; NEW nil)
We create a buffer and some extra variables, then we check if the sound timer is positive. If not, we just sleep for a while. I decided to sleep for the same amount of time as a single audio buffer would take so that each iteration of the loop represents roughly the same slice of time, but this isn't strictly necessary.
If the sound timer is positive we'll need to fill the buffer with pressure values and ship it off to PortAudio:
(defun run-sound (chip) (portaudio:with-audio (portaudio:with-default-audio-stream (audio-stream 0 1 :sample-format :float :sample-rate +sample-rate+ :frames-per-buffer +audio-buffer-size+) (with-chip (chip) (iterate (with buffer = (make-audio-buffer)) (with angle = 0.0) (with rate = (audio-rate 440)) (while running) (if (plusp sound-timer) (progn ; NEW (setf angle (funcall (audio-buffer-filler chip) ; NEW buffer rate angle)) ; NEW (portaudio:write-stream audio-stream buffer)) ; NEW (sleep +audio-buffer-time+)))))) nil)
Pretty simple: just fill the buffer and ship it. We keep track of the angle the buffer-filler returned so we can pass it in on the next iteration to avoid the "truncated waves" problem we talked about earlier.
In a perfect world we could just add one more thread like we did with the others and be done with it:
(defun run (rom-filename) (let ((chip (make-chip))) (setf *c* chip) (load-rom chip rom-filename) (bt:make-thread (curry #'run-cpu chip)) (bt:make-thread (curry #'run-timers chip)) (bt:make-thread (curry #'run-sound chip)) ; NEW (chip8.gui.screen::run-gui chip)))
Unfortunately there's one more snag we need to deal with. It turns out that Qt becomes very unhappy if we set up our threads this way. I'm not 100% sure what the problem is, but it has something to do with control of the main thread on OS X.
The solution is to let Qt take control of the main thread, and spawn all our
other threads from there. We'll update
run-gui to take a thunk and call it
once it's ready:
; NEW (defun run-gui (chip thunk) (with-main-window (window (make-screen chip)) (funcall thunk))) ; NEW
And now we can move all the thread spawning into the thunk:
(defun run (rom-filename &key start-paused) (let ((chip (make-chip))) (setf *c* chip) (load-rom chip rom-filename) (chip8.gui.screen::run-gui chip (lambda () ; NEW (bt:make-thread (curry #'run-cpu chip)) ; NEW (bt:make-thread (curry #'run-timers chip)) ; NEW (bt:make-thread (curry #'run-sound chip)))))) ; NEW
Technically only the sound thread needs to be handled this way, but I figured I'd treat them all the same.
That's it! Now you can play games and should get a nice loud buzz when the
sound should fire.
ufo.rom is a good game to test it out with — it should
buzz whenever you fire and whenever a ship gets hit. Turn down your speakers if
you don't want to scare the cat.
The sound type can be changed on the fly (e.g.
(setf (chip-sound-type *c*)
:sawtooth)), so play around and decide which type is your favorite. I'm
partial to sawtooth myself.
With that we've got a full-featured CHIP-8 emulator! It works, but there's still work left to be done. In the next few posts we'll look at things like:
- A menu system for runtime configuration
- Disassembling/debugging infrastructure
- A graphical debugger
Thanks to Joe Karl for reading a draft of this post.