April 29th, 2020

Five programs to generate simple sound effects on a PC speaker

Gemini recently uploaded a video featuring an adventure game from Sherwood Forest Software which, like most Sherwood Forest Software games, seems like it might be so bad it's good, but just ends up being so bad it's worse.

The game itself is not particularly remarkable, even given the fact that Sierra-style adventure games were rare as shareware; the only really notable shareware games of this type are the Hugo trilogy. What I found interesting, however, is how the game generates sound effects: As Gemini notes in the video, the game comes with five short .COM programs which generate sound effects all on their own, and the game actually generates sound effects by briefly interrupting itself to run these .COM files mid-game. This could be seen as bad form, but as one comment on the video notes, the programmer presumably did things this way because it was the way he or she knew how to do it. In any case, it works, but it also has the nice side bonus that the game comes with five very short programs which can be used as studies in how PC programs generate sounds using assembly-language or machine-language instructions.

Before studying the programs themselves, I'll provide a bit of basic information on how to use the PC speaker to play tones in assembly language. After that, I'll provide full assembler listings of each of the programs along with brief explanations of how they generate sounds using a variety of techniques.

You turn on the PC speaker by turning on "bit 1" (which is actually the second-last bit) of the byte at I/O address port 61h. After doing this, you need to gate the timer chip's output to the speaker, which is done by turning on "bit 0" (which is actually the last bit) of the same byte. Because these two functions are controlled by the same byte, it is usual to turn them both on at the same time. However, this byte contains six other bits which you don't want to flip, so the usual way of turning on the PC speaker is by doing the following:

1. Read in the value from I/O port 61h into some location (typically the CPU register AL).
2. Turn on the last 2 bits of this value, which can be done by ORing the value with 00000011 binary, which is 3 in decimal and hexadecimal.
3. Write the resulting value back to I/O port 61h.

Perhaps not surprisingly, the usual way to turn the speaker off is to follow this same process, except that on step 2, you turn off the last 2 bits instead of turning them on, which can be done by ANDing the value with 11111100 binary, which is FC in hexadecimal.

Besides turning the speaker on and off, the other important function in assembly-language programming of the PC speaker is changing the frequency of the tone which the speaker generates. The process for adjusting the speaker's tone is as follows:

1. Write the value B6 hexadecimal to I/O port 43h, which lets the timer chip know that you want to change its output frequency. (This step is specified in documents which explain how to play sounds on the PC speaker, but in practice, the process often works without this step. I would still recommend that you include this step in your programs, since this is how it's supposed to work, and omitting this step could result in undefined behavior.)
2. Write the low byte of a divisor word to I/O port 42h.
3. Write the high byte of a divisor word to I/O port 42h.

The "divisor word" here is a "word" in the sense of a 16-bit (2-byte) value. It's a divisor in the usual mathematical sense that it is what the dividend is "divided by". For example, in "4 divided by 2", 4 is the dividend, and 2 is the divisor.

You set the divisor yourself by sending it to port 42h, but where's the dividend? The dividend, in this context, is always exactly 1,193,180, because that is the clock frequency, in hertz, of the timer chip's input signal. The divisor word divides this clock, thus producing the final number of hertz of the output tone.

For example, to produce a tone of middle C, which is 261.63 hertz, you would use a divisor word of 4561 decimal, because 1,193,180 divided by 4561 equals 261.63, or close enough to it that even people with perfect pitch are unlikely to be able to tell the difference. If pitch deviations do occur, they are much more likely to be due to hardware limitations of the PC speaker itself than mathematical inaccuracies in the division operation, since the PC was not designed to produce note-perfect tones using its internal speaker. Note that historically, middle C was often defined as 256 hertz so that all the C notes could be easily defined as powers of 2. Today, however, most musicians use the A440 pitch standard in which A4 (the fourth A note on a piano keyboard) is 440 hertz, which results in middle C being 261.63 hertz.

Note that because it is a divisor, the value you send to port 42h is inversely proportional to the frequency of the generated tone: Sending a lower number to port 42h results in a higher-pitched tone, and vice-versa. Using divisors of around 100 decimal starts to approach the upper threshold of human hearing, and anything below that is unlikely to be heard. An interesting exception is if you use a divisor of 0. Technically, using a divisor of 0 should result in a divide-by-zero error, but instead of creating an error, it creates a ragged "buzzing" sound from the speaker. (This property is exploited by the BUZ.COM program below, which deliberately uses a divisor word of 0 to create a buzzing effect from the speaker.)

As an example, here's a program to generate middle C when the speaker is already turned on, based on the fact that 4561 decimal equals 11D1 hexadecimal:

mov al,0B6h
out 43h,al ;Let the timer know that we want to change its frequency
mov al,0D1h
out 42h,al ;Low byte of divisor word
mov al,11h
out 42h,al ;High byte of divisor word

mov ax,4C00h ;Terminate program
int 021h


So far, the above has been what you'll find explained countless times on countless websites all over the Internet. But what if you don't want to generate just one constant tone, but a series of sounds? That's where the art comes in.

There are many different ways to generate a variety of sounds from the PC speaker using the techniques above, and that's why I wanted to feature these five programs: Because they're a sort of "starter kit", featuring relatively simple ways to make relatively simple sounds, and many PC games didn't go much beyond these techniques for their music and sound effects. I will not be covering how to create digitized sound with the PC speaker, because that's a more complicated black art which, to be honest, I still don't understand myself, but if you've never made any sound on a PC speaker before and want to do so using assembly language, these five programs might not be a bad place to get started. I'll start with what I think is the simplest program and try to work up from there in terms of complexity.

First off, then, we have WHOOP.COM. At a high-level view, this program does the following:

Set BX to 1388h
loop:
Change the speaker frequency using BX as a divisor word
Turn on the speaker
Pause for a moment (technically for 32h short loops)
Decrement BX
Return to "loop" until BX reaches 0
Turn off the speaker

This program simply creates a loop using the CPU register BX. BX holds the initial value of the divisor word, which is 1388h when the program starts, and then the program goes through a loop that keeps decrementing BX until BX reaches 0. This program has just two timing loops: The "larger" loop is the one based on BX, but in the middle of the program, there's also a very short loop which uses CX as a loop timer to pause on each value of BX. Here, then, is the assembler source code for WHOOP.COM:

jmp short starter ;Absolutely pointless and could have been eliminated, since this just skips the following NOP.

nop ;Completely pointless and could have been eliminated.

starter:
mov bx,1388h
;This value sets the starting point for the divisor word: The higher this value starts,
;the lower the starting pitch will be, and thus the longer the sound effect will be.

mov al,0B6h
out 43h,al ;Let the timer know that we want to change its frequency

looper1:
;The following four lines set the frequency:
mov ax,bx
out 042h,al ;Low byte of the divisor word (88h when the program starts)
mov al,ah
out 042h,al ;High byte of the divisor word (13h when the program starts)

;The following three lines turn on the PC speaker:
in al,61h
or al,3 ;3 is 00000011 in binary, so this turns on the last 2 bits of AL
out 61h,al
;The speaker is now on!

mov cx,32h ;How many cycles looper2 loops for
looper2:
loop looper2

dec bx
jnz looper1 ;Keep looping until BX is zero

;The following three lines turn off the PC speaker:
in al,61h
and al,0FCh ;Turns off the last 2 bits of AL
out 61h,al
;The speaker is now off!

mov ax,4C00h ;Terminate program
int 021h


Now we move on to BUZ.COM, and this is a weird one that does some inexplicable things which don't really have any practical effect. At a high-level view, this program does the following:

Create a two-byte in-program-memory buffer and set it to 0BB8h
Set BP to 32h
loop:
Change the speaker frequency using BX as a divisor word
Turn on the speaker
Pause for 5DCh cycles of a wait loop
Swap the contents of DI and BX
Subtract 28h from DI
Turn off the speaker
Subtract 2 from BUFFER, and then pause for BUFFER number of cycles
Decrement BP
Return to "loop" until BP reaches 0

As mentioned above, this program uses a divisor word of 0 to create a "buzzing" effect from the speaker. Technically, it sets the divisor based on the contents of BX, but the program never initializes BX, and BX, like most other registers, is automatically initialized to 0 when a .COM program starts, meaning that unless you set BX to something, it will remain at zero. That part is clear enough, but after using an initial divisor of 0, the program then swap registers DI and BX, then subtracts 28h from DI. This theoretically could have an effect on the next instance of the loop, because since DI was already at 0, subtracting from it does make it loop around so that it's a very large number, but the resulting divisor is so large that it ends up creating a very low-frequency buzz from the speaker which, to my ears, is hardly distinguishable from the sound you get when using a divisor of 0. So you could skip the whole thing and just use 0 all the way.

Also, in this program, BUFFER is used only to store a pause length. Although a value of 2 is subtracted from BUFFER with each iteration of the loop, BUFFER starts off being 0BB8h, which is large enough that subtracting 2 from it hardly makes any perceptible difference in the overall resulting sound. If you comment out all lines about BUFFER and just manually set CX to a fixed value for each iteration of the loop, you're not likely to notice a difference in the sound.

Here, then, is the source for BUZ.COM:

jmp short starter

nop ;Completely pointless and could have been eliminated.

BUFFER db 00, 00 ;Could have been placed at the end to avoid the initial JMP.

starter:

;The original file uses a "cs:" instruction three times, which is technically called a
;"segment override prefix", to indicate that the next option on a pointer should take
;place within the code segment (the memory segment where this program is running).
;However, these instructions are actually unnecessary, because in a small program
;like this, unless you change the CSregister, pointers are referenced within the code
;segment anyway, so you could comment out the "cs:" instructions since they're superfluous.
cs:
mov WORD PTR BUFFER,0BB8h
;The above could have been avoided by just initializing BUFFER to this in the first place: "BUFFER db 0B8h, 0Bh"
;(Yes, the MOV instruction works backwards like that.)
mov bp,32h ;How many times the overall loop "looper1" repeats.
;Fundamentally, this program creates a brief sound 32h times, turning off the speaker in between each sound.

looper1:
;The below four lines adjust the speaker's pitch:
;Interestingly, this is the only one of the five programs which does not send the prescribed value of B6h to
;I/O port 43h, which results in implementation-specific behavior. It works in DOSBox, but not in Bochs, unless
;you add the two lines below, which I've commented out here since they're not part of the original program:
;mov al,0B6h
;out 43h,al
mov ax,bx ;The program does not initialize BX, but BX is automatically initialized to 0 when the program runs.
out 42h,al
mov al,ah
out 42h,al

;The following three lines turn on the PC speaker:
in al,61h
or al,3
out 61h,al

mov cx,5DCh ;How long looper2 loops for
looper2:
loop looper2

xchg di,bx ;The program also doesn't initialize DI, but DI also starts at 0 when the program runs.
sub di,28h

;The following three lines turn off the PC speaker:
in al,61h
and al,0FCh
out 61h,al

;Again, the two "cs:" instructions below are superfluous and could be eliminated.
;Here we pause for the length of BUFFER. BUFFER decreases by 2 each time, but again,
;that's a sufficiently small number that you're not likely to notice any effect
;from that subtraction.
cs:
sub WORD PTR BUFFER,2
cs:
mov cx,WORD PTR BUFFER
looper3:
loop looper3

;The sound effect has been made. Now we just loop 32h times, since BP was set to 32h.
dec bp
jnz looper1

mov ax,4C00h ;Terminate program
int 21h


This program does so many bizarre and useless things that I took the liberty of cleaning it up and providing an equivalent program which does the same thing below:

mov bp,32h ;How many times the overall loop "looper1" repeats.
;Fundamentally, this program creates a brief sound 32h times, turning off the speaker in between each sound.

looper1:
;The below six lines set the speaker frequency:
mov al,0B6h
out 43h,al
mov al,0
out 42h,al
mov al,0
out 42h,al

;The following three lines turn on the PC speaker:
in al,61h
or al,3 ;3 is 00000011 in binary.
out 61h,al

mov cx,5DCh ;How long looper2 loops for
looper2:
loop looper2

;The following three lines turn off the PC speaker:
in al,61h
and al,0FCh
out 61h,al

mov cx,0BB8h ;How long looper3 loops for
looper3:
loop looper3

dec bp
jnz looper1

mov ax,4C00h ;Terminate program
int 21h


Comparing the audio output of these two programs, I do notice a very slight increase in pitch toward the end of the first program which is absent in the "cleaned-up" program, which is caused by the repeated subtraction from the divisor word which creates higher frequencies as the program goes on, but the effect is so slight that it's hardly worth it; if it's worth it for you, you can leave it in.

From there, we move to ARCADE4.COM, which is kind of a hybrid of WHOOP.COM and BUZ.COM. Like WHOOP.COM, it sweeps from a lower frequency to a higher frequency, but it does this four times instead of once, so it has a loop within a loop: The "inner loop" does the sweep from a lower tone to a higher one, and then the "outer loop" performs this sweep four times. Like BUZ.COM, it does a weird value-swap and turns the speaker on and off to create a "choppy" effect; it sounds rather like the repeated rising tone of a car or motorcycle engine as it accelerates through its sequential gears. At a high-level view, this program does the following:

Create a two-byte in-program-memory buffer and set it to 2
Set SI to 4
loop1:
Set BP to 32h
Set BX to 2710h
loop2:
Change the speaker frequency using BX as a divisor word
Turn on the speaker
Pause for 3A98h cycles of a wait loop
Subtract 96h from BX
Subtract 78h from BUFFER
Swap the contents of BX and BUFFER
Turn off the speaker
Decrement BP
Return to "loop2" until BP reaches 0
Decrement SI
Return to "loop1" until SI reaches 0

This program sets SI, the source index register, to 4 because the "outer loop" repeats 4 times. It sets BP to 32h, because the "tone sweep" goes through 32h pitch increments. It also modulates the sound somewhat by switching between BX and a two-byte memory buffer. Oddly, the program does not reset this buffer between loops, which may lead to some irregularity in the tone. As in WHOOP.COM, BX is used as both a frequency divisor and a countdown timer. Here is the assembler source code to ARCADE4.COM:

jmp short starter

nop ;Completely pointless and could have been eliminated.

BUFFER db 02, 00 ;Could have been placed at the end to avoid the initial JMP.

starter:
mov si,4 ;How many times the tone increase repeats

looper1:
mov bp,32h ;The value here controls how high the tone will go; higher number = higher tone limit

mov al,0B6h
out 43h,al ;Let the timer know that we want to change its frequency

mov bx,2710h ;Initial value of the divisor word
looper2:
;The following four lines set the frequency:
mov ax,bx
out 42h,al
mov al,ah
out 42h,al

;The following three lines turn on the PC speaker:
in al,61h
or al,3
out 61h,al

mov cx,3A98h ;How long looper3 loops for
looper3:
loop looper3

sub bx,96h
;Again, the two "cs:" instructions below are superfluous and could be eliminated.
cs:
sub WORD PTR BUFFER,78h
cs:
xchg bx,WORD PTR BUFFER

;The following three lines turn off the PC speaker:
in al,61h
and al,0FCh
out 61h,al

dec bp
jnz looper2
dec si
jnz looper1

mov ax,04C00h ;Terminate program
int 021h


If you've been following along thus far, then we're done with new concepts. The last two programs fundamentally do what WHOOP.COM did, simply sweeping from one pitch to another; they just produce two different sounds. Both programs repeat one sound effect a few times, then repeat a different sound effect a few times, so they are both about twice as long as the previous three programs because they essentially do the same thing twice, just with different pitch ranges.

First off, let's look at PHASOR3.COM. This program makes a brief sound effect 4 times, then a longer sound effect 3 times. Both sound effects are really nothing more than just a falling tone. At a high-level view, this program does the following:

Set SI to 4
loop1:
Set BP to 0Ah
Set BX to 1
loop2:
Change the speaker frequency using BX as a divisor word
Turn on the speaker
Pause for 3A98h cycles of a wait loop
Add 64h to BX
Turn off the speaker
Decrement BP
Return to "loop2" until BP reaches 0
Decrement SI
Return to "loop1" until SI reaches 0
(At this point, the first sound effect is complete. Begin the second sound effect.)
Set SI to 3
loop3:
Set BP to 14h
Set BX to 0Ah
loop4:
Change the speaker frequency using BX as a divisor word
Turn on the speaker
Pause for 3A98h cycles of a wait loop
Add 96h to BX
Turn off the speaker
Decrement BP
Return to "loop4" until BP reaches 0
Decrement SI
Return to "loop3" until SI reaches 0

The first sound effect lasts for 10 tone increments and repeats 4 times. (0Ah is 10 in decimal.) BX starts off very low with a value of 1, but the pitch of the sound increases as BX gets 64h added to it each time. The second sound effect lasts for 20 tone increments and repeats 3 times. (14h is 20 in decimal.) Again, BX starts off low with a value of 0A, but gets 96h added to it each time. Here is the assembler source code to PHASOR3.COM:

jmp short starter ;Could have been eliminated without the pointless buffer below.

nop ;Completely pointless and could have been eliminated.

BUFFER db 00, 00 ;Not used in this program and could have been eliminated.

starter:
mov si,4 ;How many times the first sound effect repeats

looper1:
mov bp,0Ah

mov al,0B6h
out 043h,al ;Let the timer know that we want to change its frequency

mov bx,1
looper2:
;The following four lines set the frequency:
mov ax,bx
out 42h,al
mov al,ah
out 42h,al

;The following three lines turn on the PC speaker:
in al,61h
or al,3
out 61h,al

mov cx,3A98h ;How long looper3 loops for
looper3:
loop looper3

add bx,64h ;So the sound will be lower-pitched the next time this loop runs

;The following three lines turn off the PC speaker:
in al,61h
and al,0FCh
out 61h,al

dec bp
jnz looper2
dec si
jnz looper1

;The first sound effect is done; now begins the second sound effect.

mov si,3 ;How many times the second sound effect repeats
looper4:
mov bp,14h

mov al,0B6h
out 43h,al ;Let the timer know that we want to change its frequency

mov bx,0Ah
looper5:
;The following four lines set the frequency:
mov ax,bx
out 42h,al
mov al,ah
out 42h,al

;The following three lines turn on the PC speaker:
in al,61h
or al,3
out 61h,al

mov cx,3A98h ;How long looper6 loops for
looper6:
loop looper6

add bx,96h ;So the sound will be lower-pitched the next time this loop runs

;The following three lines turn off the PC speaker:
in al,61h
and al,0FCh
out 61h,al

dec bp
jnz looper5
dec si
jnz looper4

mov ax,4C00h ;Terminate program
int 021h


Finally, we come to ARCADE2.COM, which is nearly the same thing, just with different details. This program also makes two sound effects: A decreasing tone which repeats twice, then a rising tone which repeats four times. At a high-level view, this program does the following:

Set SI to 2
loop1:
Set BP to 14h
Set BX to 64h
loop2:
Change the speaker frequency using BX as a divisor word
Turn on the speaker
Pause for 7530h cycles of a wait loop
Add 32h to BX
Turn off the speaker
Decrement BP
Return to "loop2" until BP reaches 0
Decrement SI
Return to "loop1" until SI reaches 0
(At this point, the first sound effect is complete. Begin the second sound effect.)
Set DI to 4
loop3:
Set BP to 14h
Set BX to 3E8h
loop4:
Change the speaker frequency using BX as a divisor word
Turn on the speaker
Pause for 3A98h cycles of a wait loop
Subtract 32h from BX
Turn off the speaker
Decrement BP
Return to "loop4" until BP reaches 0
Decrement DI
Return to "loop3" until DI reaches 0

This program also twice issues a CLI instruction, which disables interrupts. This is potentially useful so that a stray interrupt doesn't cut off the sound somehow, but in DOS, it's unlikely that an interrupt would appreciably interfere with the sound playing, and so these CLI instructions are likely to be unnecessary. Here is the assembler source code to ARCADE2.COM:

jmp short starter ;Could have been eliminated without the pointless buffer below.

nop ;Completely pointless and could have been eliminated.

BUFFER db 00, 00 ;Not used in this program and could have been eliminated.

starter:
cli
;Disables interrupts. This is the only program out of the five which does this, but this
;is probably unnecessary and could have been eliminated.

mov si,2 ;How many times the first sound effect repeats

looper1:
mov bp,14h

mov al,0B6h
out 43h,al ;Let the timer know that we want to change its frequency

mov bx,64h
looper2:
;The following four lines set the frequency:
mov ax,bx
out 42h,al
mov al,ah
out 42h,al

;The following three lines turn on the PC speaker:
in al,61h
or al,3
out 61h,al

mov cx,7530h ;How long looper3 loops for
looper3:
loop looper3

add bx,32h ;So the sound will be lower-pitched the next time this loop runs

;The following three lines turn off the PC speaker:
in al,61h
and al,0FCh
out 61h,al

dec bp
jnz looper2
dec si
jnz looper1

;The first sound effect is done; now begins the second sound effect.

jmp short secondcli ;Absolutely pointless, as all it does is skip the following NOP.
nop ;Completely pointless and could have been eliminated.
secondcli:
cli ;Disable interrupts again. Again, likely unnecessary.

mov di,4 ;How many times the second sound effect repeats

looper4:
mov bp,14h

mov al,0B6h
out 43h,al ;Let the timer know that we want to change its frequency

mov bx,3E8h
looper5:
;The following four lines set the frequency:
mov ax,bx
out 42h,al
mov al,ah
out 42h,al

;The following three lines turn on the PC speaker:
in al,61h
or al,3
out 61h,al

mov cx,3A98h ;How long looper6 loops for
looper6:
loop looper6

sub bx,32h ;So the sound will be higher-pitched the next time this loop runs

;The following three lines turn off the PC speaker:
in al,61h
and al,0FCh
out 61h,al

dec bp
jnz looper5
dec di
jnz looper4

mov ax,4C00h ;Terminate program
int 021h


Making sounds in this way is a little like circuit bending, the practice of taking an electronic sound synthesizer and doing weird physical things to it to get different sounds out of it, like putting your fingers (or other objects) on various components or parts of the circuit to change their capacitance. Various little tricks like changing CPU registers and memory locations can result in subtly (or vastly) different sounds depending on the circumstances, and there is a strong element of experimentation and artistry to this kind of thing, mixed in with obvious aspects of technology and science. The five programs featured here are by no means especially long or complex, but they do cast a bit of light on what is something of a black art: Trying to coax meaningful, interesting, or appealing sounds out of a speaker which was fundamentally designed to play only square waves. If what you've seen here is interesting to you at all, I encourage you to play around with it a little bit and see what sounds you can get out of changing the various program values shown here. That was how sounds were often made for DOS games back in the 1980s, and it would be a shame if this art were lost to the sands of time. Have fun.