Pangram verdict · v3.3
We believe that this entire text is human-written.
AI likelihood · overall
HumanArticle text · 1,668 words · 1 segments analyzed
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. NOP itself 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 HL is 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,n is 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. JP instructions are 10 cycles even when conditional which makes them much preferable to JR or DJNZ for code that has to make decisions in a precisely-timed loop. Those still have their place, though, because… LD B,n; LBL: DJNZ LBL is 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