Part 12: Synthesizing Basic Waveforms: Rectangle, Triangle, Saw
In this chapter, I'm going to explain how to synthesize different waveforms without the use of samples or wavetables.
For generating waveforms other than a rectangle/pulse on a 1-bit output, we need to be able to output multiple volume levels. In part 10, we have looked at some methods for outputting PCM samples and wavetables. We concluded that in the 1-bit domain, time is directly related to volume. The longer we keep our 1-bit output "on" within a fixed-length frame, the higher the volume produced by the speaker cone will be. We can use this knowledge to write a very efficient rendering loop that will generate 8 volume levels with just 3 output commands:
calculate 3-bit volume
output (volume & 1) for t cycles
output (volume & 2) for 2t cycles
output (volume & 4) for 4t cycles and loop
As you can see, the trick here is to double the amount of cycles taken after each consecutive output command in the loop. An implementation of this for the ZX Spectrum beeper could look like this:
ld c,#fe
ld hl,0
ld de,frequency_divider
loop
add hl,de ;11 ;update frequency counter as usual
;... ;y ;do some magic to calculate 3-bit volume
;... ;z ;put it in bit 4-6 of register A
;so now bit 4 of A = volume & 1
out (c),a ;12: x+10+11+y+z=64 ;output to beeper
rrca ;4 ;now bit 4 of A = volume & 2
out (c),a ;12: 4+12=16 ;output
ds 4 ;16 ;timing
rrca ;4 ;now bit 4 of A = volume & 4
out (c),a ;12: 16+4+12=32 ;output
;... ;x ;update timer etc.
jp loop ;10 ;loop
If you count the cycles, you'll notice that this loop takes exactly 112 cycles. Which means we can easily add a second channel in the same manner, which brings the total cycle count to 224 - perfect for a ZX beeper routine. Side note: If necessary, you can cheat a little and reduce the 64-cycle output to 56 cycles, without much impact on the sound.
Anyway, we will use this framework as the basis for our waveform generation. So let's talk about the "magic" part.
The easiest of the basic waveforms is the saw wave. How so, you may ask? Well, the saw wave is actually right in front of your nose. Look at the first command in the sound loop - ADD HL,DE. Say we set the frequency divider in DE to 0x100. What happens to the H register? It is incremented by 1 each sound loop iteration, before wrapping around to 0 eventually. Ok, by now you might have guessed where this is going. If you haven't, then plot it out on a piece of paper - the value of H goes on the y-axis, and the number of loop iterations goes on the x-axis. Any questions? As you can see, our saw wave is actually generated for free while we update our frequency counter (thanks to Shiru for pointing this out to me). We just need to put it into A, and rotate once to get it into the right position.
add hl,de ;update frequency counter
ld a,h ;now 3-bit volume is in bit 5-7 of A
rrca ;now it's in bit 4-6
out (c),a ;output as above
...
Doing a triangle wave is a little more tricky. In fact, being the lousy mathematician that I am, it took me quite a while to figure this out. Ok, here's how it's done. We've already got the first half of our triangle wave done - it's the same as the saw wave. The second half is where the trouble starts - instead of increasing the volume further as we do for the saw wave, we want to decrease it again. So we could do something ugly like
add hl,de
ld a,h ;check if we've passed the half-way point of the saw
rla ;aka H >= 0x80
jp c,_invert_volume
...
reentry
rrca
out (c),a
...
jp loop
_invert_volume
;A = -H
There's a more elegant way that does the same thing without the need for conditional jumps.
add hl,de
ld a,h
rla
sbc a,a ;if h >= 0x80, A = 0xff, else A = 0
xor h ;0 xor H = H, 0xff xor H = -H - 1
out (c),a ;result is already in bit 4-6, no need to rotate
...
We can simply ignore the off-by-one error on H >= 0x80, since we don't care about the lower 4 bits anyway.
Last but not least, a word about rectangle waves. Of course, rectangle waves happen naturally on a 1-bit output, unless you force it to do something else. Which we are doing in this case, so how do we get things back to "normal"? Well, to get a square wave, we simply have to remove the XOR H from the previous code example. Which means that with just two bytes of self-modifying code, we can create a routine that will render saw, triangle, or square waves on demand:
add hl,de
ld a,h
rla
;saw | tri | rect
rra | sbc a,a | sbc a,a
rrca | xor h | nop
out (c),a
...
You'll notice that even with timer updates, register swapping, etc. you'll still have some free cycles left. Which should, of course, be put to some good use - see part 11 if you need some inspiration.