✨ Discover this insightful post from Hacker News 📖
📂 **Category**:
✅ **What You’ll Learn**:
As part of the ZX Spectrum tour, I implemented some routines that would let you play simple tones out of the 1-bit beeper. These were in large part inspired by earlier work I did on the Apple II. We know that’s not the limit, though, because we got some improbably good results out of the IBM PC’s 1-bit speaker as well. My original plan for this week was to replicate some of the advanced PC speaker techniques on the ZX Spectrum. Unfortunately, that didn’t work out as well as I’d hoped, but that’s OK because I ended up getting a bunch of other things working instead.
The main things I accomplished this week revolve around multichannel sound through the 1-bit speaker—basically, playing chords. On the side, I also recreated the simple 1-bit PCM playback from the PC Speaker article and experimented briefly with the more advanced PWM technique. Both of those end up informing some of the work with chord playback as well.
This won’t be a comprehensive guide this week; I record my failures as well as my successes on this blog, and this week racked up more than its fair share of failures.
Pulse Code Modulation and Pulse Width Modulation
Pulse code modulation is a pretty simple concept: a waveform is sampled as a series of values between 0 and some maximum value, and those values are sent to the hardware to be converted into speaker voltages. The larger the maximum value, the more finely-grained your control of the amplitude, and the more rapidly you send samples, the more finely-grained your control of the frequency. (One fundamental rule of signal processing is that you cannot accurately sample a waveform with a frequency more than half your sample rate.) PCM waves are generally described by the number of bits used to express each sample and then the sample rate—a high quality sample might be 16-bit 44kHz, while most use cases might be served by an 8-bit sample at sampling rates as low as 8 kHz.
The Spectrum can access its memory at approximately 1MHz, so we will be able to hit a respectable 16kHz playback even under direct, cycle-counted CPU control. However, the speaker is only ever on or off. That means that this is 1-bit PCM, which we should expect to sound pretty bad; we’ll be getting the kind of distortion that you’d get when blowing a speaker out with too much amplification, but all the time and even at low volumes.
Different models of Spectrum had slightly different CPU speeds; I’ll be using the 48K’s clock which ran at a flat 3.5 MHz for my cycle counting. Dividing that by 16,000 samples per second reveals that we’ll have to wait 219 cycles between writes. That’s a pretty cozy amount of time; we can pack a 1-bit PCM recording 8 samples to a byte and easily be able to consume it. Samples will consume about 2KB per second, which is tight on a 48K system but not disastrously so. The main problem, as we will see, will be the distortion of the sound. The implementation of this technique poses no special challenges.
Pulse width modulation is a little trickier but it promises much higher audio quality. At the electrical-signal level, PWM data sends a 1-bit pulse once every sample, and the length of the pulse indicates the intended strength of the audio signal at that sample point. (Compare PCM, which effectively sends a multi-bit digital value over the wire to accomplish this. PWM is more analog than PCM, despite being more aggressively 1-bit.) At the physical level, this manifests as a consequence of the fact that while electrical signals can change from 1 to 0 and back in a matter of nanoseconds, the physical speaker attached to the device will require tens of microseconds to actually make the journey between its “in” and “out” states. By switching the signal off at various points in its journey, the speaker’s total strength varies in a far more precisely-controllable manner than 1-bit PCM provides.
On the IBM PC, the 1-bit speaker is tied to a hardware timer, and high-quality audio may be generated by feeding that timer 7-bit PCM data. The Spectrum is not so helpful, and we’ll have to manage it with cycle counting. I did not manage to get a PWM system working to my satisfaction on the Spectrum; I do however have some leads and am convinced that the technique overall is sound.
Cycle-Exact Delays on the Z80
Before we dig into any code in detail, we should nail down what it takes to do precise timing delays. The Z80’s “T-State” count is a lot larger and a lot slipperier than the CPU cycles on the 6502, so while I could throw together instructions for trivially waiting any number of cycles on the 6502, my Z80 guidance is necessarily a bit more contingent. For this work I found myself mostly relying on rules of thumb, assembling delay sequences in a more organic way.
NOPitself is 4 cycles, as is basically every one-byte 8-bit operation (of which there are many). Once the remaining delay is both short and a multiple 4 cycles, we are basically done.INC HLis 6 cycles, and similarly-structured one-byte 16-bit instructions are too. These can round away a 2-cycle discrepancy, or be paired to wait 12 cycles with two bytes of code instead of 3.LD A,nis 7 cycles alongside the other 8-bit immediate instructions. As long as the total wait is long enough these will let us get down to cycle accuracy.JPinstructions are 10 cycles even when conditional which makes them much preferable toJRorDJNZfor code that has to make decisions in a precisely-timed loop. Those still have their place, though, because…LD B,n; LBL: DJNZ LBLis the shortest loop you can write and it expends 13n+2 cycles. For long waits, this loop will be the bulk of it, and the other instructions above will round away any inconveniences.
This isn’t exactly systematic, but it’s enough for our purposes.
A PCM Playback Routine
The overall plan will be to pack 8 samples into each byte, with the high bit played first. With a 16-bit sample, we’ll want 219 cycles between each output. Our input will put a pointer to the input data in HL and the number of bytes in the sample in DE. (We’ll assume the total number of samples is a multiple of 8. Our encoder will just need to pad the end, and that’s the encoder’s problem, not ours.) We usually use BC for our counters, but we’ll be needing B in particular for our internal time delay counters, so DE will have to pick up the slack.
We begin the routine, funnily enough, mostly at the end. When we enter the main playback loop, we will be in the middle of a 219-cycle sequence, and in order to know what our timing constraints are, we have to write the end of the loop to go with it. It also turns out that our overall function prologue and epilogue are so short that we can dispose of them right away too. All they have to do is disable and re-enable interrupts.
pcmout: di
.lp: ????????????? ; Play 8 samples from a byte, ending with...
out ($fe),a ; + 11 (219) ...the final bit output
;; Adjust byte counter and loop back
dec de ; + 6 ( 6)
ld a,d ; + 4 ( 10)
or e ; + 4 ( 14)
jp nz,.lp ; + 10 ( 24)
;; We're done; re-enable interrupts and return
ei
ret
Normally when consuming a byte a bit at a time, we keep shifting off one end or the other and consult the carry bit to decide what to do. We can be a little cute, here; we need to copy the bit we’re consuming into the $10 bit of the output byte. Given that fact, it’s more effective to take our initial byte, rotate it right three bits, and then just work with the $10 bit directly. We’ll need to make sure we use the 8-bit RRC and RLC instructions instead of the 9-bit RR and RL ones.
We know, from above, that we enter the loop at cycle 24. Reading the byte, preparing it, and outputting the first bit at the proper time will thus look like this:
.lp: ld a,(hl) ; + 7 ( 31) Load a byte
inc hl ; + 6 ( 37) Advance pointer
rrca ; + 4 ( 41) Rotate $80 bit to $10
rrca ; + 4 ( 45) with 8-bit rotations
rrca ; + 4 ( 49)
ld c,a ; + 4 ( 53) Stash byte in C
and $10 ; + 7 ( 60) Isolate sound bit
or $01 ; + 7 ( 67) Blue border because why not
????????????? ; +141 (208)
out ($fe),a ; + 11 (219)
Now we have to delay 141 cycles. Here’s what I came up with for that.
inc hl ; + 6 ( 73)
dec hl ; + 6 ( 79)
ld b,9 ; + 7 ( 86)
jp 1F ; + 10 ( 96)
1 djnz 1B ; +112 (208)
That lands us right where we need to be.
One bit down, seven to go. I considered making this an inner loop, but I’m out of registers and spilling to memory could get ugly. Much simpler to just let sjasm copy-paste my code for me.
repeat 7
rlc c ; + 8 ( 8) Next bit
ld c,a ; + 4 ( 12)
and $10 ; + 7 ( 19) Isolate sound bit
or $01 ; + 7 ( 26) Blue border still
ld b,1 ; + 7 ( 33) Delay 182 cycles...
dec b ; + 4 ( 37)
ld b,13 ; + 7 ( 44)
1 djnz 1B ; +164 (208)
out ($fe),a ; + 11 (219) ... and output this bit
endrepeat
That’s all we needed; beyond that we just have the final six instructions from our initial skeleton. The function works great! The actual sound quality, however, is awful. I even tried a bit of preprocessing to clean up some of the noise in parts where there isn’t really a meaningful signal and even that didn’t help much. Still, the function works just fine for what it is, and it will be a handy thing to keep in our back pocket. I’m not particularly inspired to tune the repeat macro into a proper loop, though.
Playing Chords
Multichannel music was apparently reasonably common even through the beeper, back in the day. My own adventures over the years suggested two approaches to getting reasonably good results:
- Arpeggiation. It was very common on both the C64 and the Amiga to give a chord to a single output channel and just change the frequency every frame to produce the chord effect. This game them a very distinctive sort of “buzz” sound. This should be trivial to implement given what I’ve already written so far.
- Software Mixing. The usual way to do multiple sound channels on a monaural system is simply to sum up all the incoming signals and output that value on its own. We’ll have to cut some corners if we try that here, but the basic principle should be sound.
Implementing Arpeggiation
This should be really easy. I already have a routine that plays one tone for some amount of time. I can just make that time really short and put it in an outer loop. Something like this:
ld b,$18
1 push bc
ld bc,$0d0
ld de,$0367
call sound
ld bc,$0d0
ld de,$0441
call sound
ld bc,$0d0
ld de,$051a
call sound
pop bc
djnz 1B
We did learn last time that we’re not allowed to touch memory between $4000 and $7FFF if we want consistent timing, so I set the origin to $8100 here to make sure we don’t get stalled by the PUSH, POP and CALL instructions.
The results, overall, are plausible. It’s a bit scratchy but it’s noticably a chord. One thing I did notice was that if I made the intervals too short, the chord got detuned. I’m pretty sure that what was happening there was that changing notes not only took some extra time but also reset the pulse counter, which could result in some seriously out-of-spec waveforms at the transition points. Things did seem to improve a bit when I moved the initialization of HL and A to the top-level chord function instead of the original sound routine. I then also passed the three frequencies in registers all at once and loaded them into the arguments of the immediate instructions. The chord function ended up like this:
chord: ld (.f1),hl
ld (.f2),de
ld (.f3),bc
ld a,($5c48) ; BORDCR
and $38
rrca
rrca
rrca
or $08
di
ld b,$18
ld hl,0
1 push bc
ld bc,$0d0
.f1 equ $+1
ld de,$0000
call sound
ld bc,$0d0
.f2 equ $+1
ld de,$0000
call sound
ld bc,$0d0
.f3 equ $+1
ld de,$0000
call sound
pop bc
djnz 1B
ei
ret
Finally I put together a little macro to make chord progressions easier to specify, too, so I could give it a little chord progression.
macro play 3
ld hl,@1
ld de,@2
ld bc,@3
call chord
endmacro
;; Main program
play $0367,$0441,$051a ; I
play $0367,$048b,$05ba ; IV
play $0367,$0441,$051a ; I
play $0336,$03d2,$051a ; V
play $0367,$0441,$051a ; I
ret
Unlike the PCM code above, the sound quality here wouldn’t really be out of place in anything. Like the PCM code above, I am getting real mileage out of Sjasm’s macro facilities.
Software Mixing
I’ve actually built a cycle-counted polyphonic synthesizer before: it was a wave-table system for the Dragon. The basic principles behind that still apply, but with a 1-bit output we can simplify it a bit.
- Execute the same frequency-counter based sound system as we’ve been doing, but maintain three counters and frequency steps instead of just one.
- After each counter is updated, sum the top bits of each one and set the speaker based on whether we are in the top or bottom half of the possible range.
This is much easier if there’s only ever odd numbers of active channels; I think for an even number of channels I would want the middle value to leave the speaker output as-is. That would be easier to accomplish on the Apple II (where the speaker control is a toggle) than on the Spectrum (where we directly write the speaker value). With exactly three voices, as here, it’s even easier because with a possible range of 0-3 we can just look at the top bit of the 2-bit sum.
The logic for each channel is very similar to what I wrote in the sound tour. There are two major differences: I need to sync the counter and the frequency code with memory for each code (since we don’t have enough registers for all three at once) and I need to use a 16-bit counter instead of my previous in-effect 17-bit counter involving the carry bit. In the old code, I’d flip and hold the value being output when the carry bit was set; now I need to take the top bit of the counter and use it as the value I add to the running total. I wrap it up in a macro, with timings.
macro count_channel count,freq
ld hl,(count) ; +16
ld de,(freq) ; +20
add hl,de ; +11
bit 7,h ; + 8
jp m,1F ; +10
jp 2F ; +10
1 inc a ; + 4
dec de ; + 6
2 ld (count),hl ; +16
endmacro
There’s some branching here so I can’t just do a straight sum, but every path through this code is 91 cycles exactly.
The frequency codes will be public variables (we’ll pass arguments that way), but the channel counters can be private.
freq1 # 2
freq2 # 2
freq3 # 2
chord: ld hl,0
ld (.counter1),hl
ld (.counter2),hl
ld (.counter3),hl
di
.lp: xor a ; + 4 Clear the sum value
count_channel .counter1,freq1 ; +91 Process each voice in turn
count_channel .counter2,freq2 ; +91
count_channel .counter3,freq3 ; +91
add a ; + 4 Multiply A by 8, moving the
add a ; + 4 $02 bit into the $10 place
add a ; + 4
and $10 ; + 7 Isolate that bit
or $0f ; + 7 White background
out ($fe),a ; +11 Output summed bit
dec bc ; + 6 Decrease counter and proceed
ld a,b ; + 4
or c ; + 4
jp nz,.lp ; +10
ei
ret
.counter1 # 2
.counter2 # 2
.counter3 # 2
At 338 cycles per loop, and a “flip” counter value of $8000 instead of $10000, I need to recompute my scales.
B C D E F G A $061b $0678 $0743 $0826 $08a2 $09b1 $0ae1
The main program is a bit more verbose this time, because we’ve delegated more memory work to the call.
macro play 4
ld hl,@2
ld (freq1),hl
ld hl,@3
ld (freq2),hl
ld hl,@4
ld (freq3),hl
ld bc,@1
call chord
endmacro
play $2000,$0678,$0826,$09b1 ; I
play $2000,$0678,$08a2,$0ae1 ; IV
play $2000,$0678,$0826,$09b1 ; I
play $2000,$061b,$0743,$09b1 ; V
play $4000,$0678,$0826,$09b1 ; I
ret
However, the logic isn’t much different at the high level, and the overall sound results for this approach are much better than the arpeggiated version; the sound feels much richer and it doesn’t really even sound like a 1-bit system much. I was quite impressed with this one.
At this point I’ve reached the limit of the play routines that actually worked. I’ve uploaded a collection of programs to their own directory in my Github Repo; you can build and run them with the Makefile, or check out the audio file there to hear the arpeggiation and channel-summing techniques alongside a PCM waveform. From here on out I’ll be covering approaches that either didn’t work or didn’t make it all the way into a real implementation.
Other Approaches
I had a few other things I wanted to experiment with, particularly as they related to the arpeggiation system, but getting the code right was a bit of a pain, and it was not always obvious whether a bad sound would be because the technique was bad, or because my implementation was buggy. After awhile I realized something important: I don’t need to write custom sound engines to test these. I can just pregenerate the wave files I want and then run them through the PCM player.
The main thing I wanted to try was to look into the issue where it detuned if we swapped frequencies too fast. My theory here was that it sounded wrong because we weren’t getting complete waveforms out. If that’s so, then if we’re generating the waveform in advance we can fix that: we can just do exactly one wave of each frequency, one after the other, in order.
This also didn’t sound right, but looking at the PCM data gave a very good potential explanation for why: lower frequencies take longer to play. Imagine playing a simple chord of two notes separated by an octave. The low note has half the frequency of the high note, but that means that two-thirds of the waveform is spent on the lower note, and this seems to unbalance the resulting waveform.
Similarly, dividing up waveforms by timeslice produced very similar distortions to my initial tests. The shorter the timeslice, the fewer actual pulses have the correct length.
But… what if we didn’t preserve the pulse waves at all?
The Apple IIgs’s sound system is very unusual. It boasts 15 stereo wavetable channels, a completely unreasonable number for 1986. The way it accomplishes this is that “arpeggiates” all its digital sound channels; if three channels are active, then the sound chip plays one sample from each channel in turn, so any given channel only appears on every third sample. Instead of interleaving like we’ve been doing with our pulse waves, what if we took a page from the IIgs and shuffled the waveforms together a sample at a time intead of a pulse?
The answer is: not bad at all. It’s markedly better than any of my previous attempts at arpeggiation, but it’s not quite as good to my ear as the sample-addition approach. I suspect this is due to the way that the channel-addition approach will try to minimize transitions, so the overall wave ends up feeling “cleaner”. The sample-shuffling approach will have all the same frequency components in the signal, but the larger number of transitions will make it feel a touch “scratchier” overall.
Pulse Wave Modulation
My previous work with PWM relied on being able to set a hardware timer to control when the speaker turns on and off. This was precise enough that I could treat the timer’s configuration register like a port receiving 7-bit PCM data. No such luck here, but it shouldn’t be that bad, nevertheless.
Assuming the Spectrum’s speaker is roughly equivalent to the PC’s, it takes it 50 microseconds (or 175 cycles, or 43.75 NOP instructions) to achieve full travel. Delivering a pulse of precise width should be a matter of the instructions OUT ($FE),A, XOR $10, and then a string of 45 NOPs. We may then edit another OUT instruction into that stream of no-ops at the right point, and then turn it back into a pair of NOPs afterwards. The end result should be a chunk of code that always runs in constant time and which which gives us slightly worse than microsecond control over pulse width. Then we repeat the process once per sample.
This should be fine. However, I could not get this to work in my experiments. I walked away from this part of the project with some instructive failures.
One disadvantage of PWM-based systems is that every pulse we send resets the speaker state once it is done. The pulses themselves will produce a regular series of signals that will be perceptible if the sample rate is low enough. On my test programs, this high-pitched squeal was all I could hear at all. If I listened very carefully, I could maybe pick out a tiny piece of the original sample from it.
That, at least, suggests that it isn’t—or isn’t entirely—an emulator issue. But it doesn’t otherwise give me much to work with.
Getting By With a Little Help From Our Friends
Fortunately for me, there are a lot of extremely knowledgable Sinclair experts out there who can point me where I need to go. Conversations both on social media and on the Spectrum Computing site forums gave me quite a lot to chew on:
- Pulse width modulation absolutely works; there are demos.
- The Fuse emulator is good enough to handle these—if anything, it’s too reliable, as the results from real hardware are apparently near-inaudible without an amplifier attached.
- L Break Into Program, whose work I’ve linked before as part of my tour of the graphics system, worked with the legendary Follin brothers on several of the systems they targeted, including the Spectrum. With the permission of their estate, he has published some of their sound drivers on GitHub.
I haven’t done much with these yet—I always like to face these systems with as fresh a perspective as I can so that I’m not just rehashing earlier work—but I’ve hit enough of a wall here beyond the things I have accomplished that I think it’s time for me to take a closer look at prior work in detail.
As I wrap up this week, I haven’t had much of a chance to actually do that. I have at least identified where Fuse keeps its beeper simulator and can see that it implements one of three possible lowpass filters over the output. That’s a plausible implementation mechanism for simulating the physicality of the speaker, and so I am quite convinced that this ought to work. I won’t be able to say anything definitive about it until I get a chance to borrow some time from various people with different kinds of hardware though. You can only get so far in a pure-software lab.
⚡ **What’s your take?**
Share your thoughts in the comments below!
#️⃣ **#Spectrum #Experimenting #1Bit #Sound**
🕒 **Posted on**: 1788879967
🌟 **Want more?** Click here for more info! 🌟
