Last Week:
Sound Engine Basics, Skeleton sound engineThis Week: Sound Data, Pointer Tables, Headers
Designing Sound DataWe
have a skeleton sound engine in place. Time to pack it with flesh and
organs. Before we can play a song, we will have to load a song.
Before we can load a song, we will need song data. So our next step is
to decide how our sound data will look. We'll need to design our data
format, create some test data and then build our engine to read and
play that data.
Data FormatsSo how do we go about
designing a sound data format? A good place to start would be to look
at what we are aiming to play. We know that our sound engine will have
two basic types of sound data:
1. Music
2. Sound Effects (SFX)
Music plays in the background. It uses the first 4 channels, has a tempo, and usually loops over and over again.
Sound Effects are triggered by game events (eg, ball hitting a paddle) and don't loop indefinitely.
Sound
effects have the job of communicating to the player what is going on
right now, so they have priority over music. If there is music playing
on the Square 2 channel, and a sound effect is also using the Square 2
channel, the sound effect should play instead of the music.
Depending
on the game, some sound effects may have higher priority than others.
For example, in a Zelda-like game the sound of the player taking damage
would have priority over the sound of the player swinging their sword.
The former communicates critical information to the player while the
latter is just for effect.
StreamsAs mentioned above,
a sound effect will have to share a channel (or channels) with the
music. This is unavoidable because music typically uses all the
channels at once, all the time. So when a sound effect starts playing,
it has to steal a channel (or more) away from the music. The music
will continue to play on the other channels, but the shared channel
will go to the sound effect. This creates an interesting problem: if
we stop music on a channel to play a sound effect, how do we know where
to resume the music on that channel when the sound effect is finished?
The
answer is that we don't actually stop the music on the shared channel.
We still advance it frame by frame in time with the other music
channels. We just don't write its data to the APU ports when a sound
effect is playing.
To do this, we will need to keep track of multiple streams of sound data. A data
stream
is a sequence of bytes stored in ROM that the sound engine will read
and translate into APU writes. Each stream corresponds to one
channel. Music will have 4 data streams - one for each channel. Sound
effects will have 2 streams and the sfx themselves will choose which
channel(s) they use. So 6 streams total that could potentially be
running at the same time. We will number them like this:
MUSIC_SQ1 = $00 ;these are stream number constantsMUSIC_SQ2 = $01 ;stream number is used to index into stream variables (see below)MUSIC_TRI = $02MUSIC_NOI = $03SFX_1 = $04SFX_2 = $05Each
stream will need it's own variables in RAM. An easy way to organize
this is to reserve RAM space in blocks and use the stream number as an
index:
;reserve 6 bytes each, one for each streamstream_curr_sound .rs 6 ;what song/sfx # is this stream currently playing? stream_channel .rs 6 ;what channel is it playing on?stream_vol_duty .rs 6 ;volume/duty settings for this streamstream_note_LO .rs 6 ;low 8 bits of period for the current note playing on the streamstream_note_HI .rs 6 ;high 3 bits of the note period;..etcHere we have 6 bytes reserved for each variable. Each stream gets its own byte, for example:
stream_vol_duty+0: MUSIC_SQ1's volume/duty settings
stream_vol_duty+1: MUSIC_SQ2's volume/duty
stream_vol_duty+2: MUSIC_TRI's on/off
stream_vol_duty+3: MUSIC_NOI's volume
stream_vol_duty+4: SFX_1's volume/duty
stream_vol_duty+5: SFX_2's volume/duty
In our sound_play_frame code we will loop through all of the streams using the stream number as an index:
ldx #$00 ;start at stream 0 (MUSIC_SQ1).loop: ;read from data stream in ROM if necessary ;update stream variables based on what we read lda stream_vol_duty, x ;the value in x determines which stream we are working with ;do stuff with volume lda stream_note_LO, x ;do stuff with note periods ;do more stuff with other variables inx ;next stream cpx #$06 ;loop through all six streams bne .loopThe
music streams will always be running, updating the APU ports with their
data frame by frame. When a sound effect starts playing, one or both
of the sfx streams will start running. Because our loop processes the
SFX streams last, they will write to the APU last and thus overwrite
the shared-channel music streams. Our channel conflict is taken care
of automatically by the order of our loop!
MusicWe
now have an idea of how our stream data will be stored in RAM, but
there are still many unanswered questions. How do we load a song? How
do we know where to find the data streams in ROM? How do we read from
those data streams? How do we interpret what we read from those
streams?
To answer these questions, we need to make a data
format. Let's start with music. What should our music data look
like? Most NES music data is divided into three types:
1.
Note - what note to play: A3, G#5, C2, etc
2.
Note Length - how long to play the notes: eighth note, quarter note, whole note, etc
3.
Opcodes - opcodes tell the engine to perform specific tasks: loop, adjust volume, change Duty Cycle for squares, etc
*3.5.
Arguments - some opcodes will take arguments as input (e.g. how many times to loop, where to loop to).
RangesWe
will need to design our data format to make it easy for the sound
engine to differentiate between these three types of data. We do this
by specifying ranges. For example, we might say that byte values of
$00-$7F represent Notes. $80-$9F are Note Lengths, and $A0-$FF are
opcodes. I just made those numbers up. It really doesn't matter what
values we use. The important thing is that we have ranges to test
against to determine whether a byte is a note, note length or opcode.
In our engine code we will have something like this:
fetch_byte: lda [sound_pointer], y ;read a byte from the data stream bpl .note ;if < #$80, it's a Note cmp #$A0 bcc .note_length ;else if < #$A0, it's a Note Length.opcode: ;else it's an opcode ;do Opcode stuff.note_length: ;do Note Length stuff.note: ;do Note stuff This
code reads a byte from the sound data and then tests that byte to see
which range it falls into. It jumps to a different section of code for
each possible range. Almost any data format you create will be divided
into ranges like this, whether it be sound data, map data, text data,
whatever.
BPL and BMIThese two branch instructions
are worth learning if you don't know them already. After BEQ, BNE, BCS
and BCC they are the most common branch instructions. They are often
used in range-testing.
BPL tests the Negative (N) flag
and will branch if it is clear. Think of BPL as Branch if PLus. The N
flag will be clear if the last instruction executed resulted in a value
less than #$80 (ie,
bit7 clear).
lda #%01101011 ; | ; +-------- bit7 is clear. This will clear the N flag. bpl .somewhere ;N flag is clear, so this will branch lda #%10010101 ; | ; +-------- bit7 is set. This will set the N flag bpl .somewhere ;N flag is set, so this will not branch.BMI
is the opposite. It tests the N flag and will branch if it is set.
Think of BMI as Branch if MInus. The N flag will be set if the last
instruction executed resulted in a value greater than or equal to #$80
(ie,
bit7 set).
lda #%01101011 ; | ; +-------- bit7 is clear. This will clear the N flag. bmi .somewhere ;N flag is clear, so this will not branch lda #%10010101 ; | ; +-------- bit7 is set. This will set the N flag bmi .somewhere ;N flag is set, so this will branch to the label .somewhere.In the range-testing code above, I used BPL to check if a byte fell into the Note range (00-7F). Go back and check it.
Song HeadersMusic on the NES is typically composed of
four parts: a Square 1 part, a Square 2 part, a Triangle part and a
Noise part. When you want to play a song, you will have the main
program issue a command to the sound engine telling it what song you
want to play. It will look something like this:
lda #$02 jsr sound_load ;load song 2 Somehow
our sound_load subroutine will have to take that "2" and translate it
into a whole song, complete with Square 1, Square 2, Triangle and Noise
parts. How does that little number become 4 streams of data? Well,
that number is an index into a pointer table, a table of pointers to
song headers. The song headers themselves will contain pointers to the
individual channels' data streams.
Pointer TablesA pointer table is a special kind of lookup table. Only instead of holding regular old numerical data a pointer table holds
addresses.
These addresses "point" to the start of data. Addresses on the NES are
16-bit ($0000-$FFFF), so pointer tables are always tables of words.
Let's look at an example:
pointer_table: .word $8000, $ABCD, $CC10, $DF1B Here
we have a pointer table. It's four entries long. Each entry is a
16-bit address. Presumably there is data at these four addresses that
we will want to read sometime in our program. To read this data we
will need to index into the pointer table, grab the address and store
it in a zero-page pointer variable and then read using indirect mode:
.rsset $0000 ptr1 .rs 2 ;a 2-byte pointer variable. ;The first byte will hold the LO byte of an address ;The second byte will hold the HI byte of an address .org $E000 ;somewhere in ROM lda #$02 ;the third entry in the pointer table ($CC10) asl a ;multiply by 2 because we are indexing into a table of words tay lda pointer_table, y ;#$10 - little endian, so words are stored LO-byte first sta ptr1 lda pointer_table+1, y ;#$CC sta ptr1+1 ;now our pointer is setup in ptr1. It "points" to address $CC10. Let's read data from there. ldy #$00 lda [ptr1], y ;indirect mode. reads the byte at $CC10 sta some_variable iny lda [ptr1], y ;reads the byte at $CC11 sta some_other_variable iny ;... etc This
code takes an index and uses it to read an address from our
pointer_table. It stores this address in a variable called ptr1 (LO
byte first). Then it reads from this address by using indirect mode.
We specify indirect mode by putting []'s around our pointer variable.
Look at this instruction:
lda [ptr1], yIt means "Find the address ptr1 is pointing to. Add Y to that address. Load the value at that address into A".
This
is very versatile because we can stick any address we want into our
ptr1 variable and read from anywhere! A pointer table is just a lookup
table of places we want to read from.
Of course you usually
won't know where exactly in the ROM your data will be. So instead of
declaring addresses explicitely ($8000, $ABCD, $CC10, etc), you will
use labels instead:
pointer_table: .word data1, data2, data3 ;these entries will evaluate to the 16-bit addresses of the labels belowdata1: .byte $FF, $16, $82, $44 ;some random data data2: .byte $0E, $EE, $EF, $16, $23 data3: .byte $00, $01
Song Header Pointer TableOur songs work the same way. When
the main program tells the sound engine to play a song, it will send
the song number with it. This song number is actually an index into a
pointer table of song headers:
song_headers: .word song0_header .word song1_header .word song2_header ;..etc sound_load: asl a ;multiply by 2. we are indexing into a table of pointers (words) tay lda song_headers, y ;read LO byte of a pointer from the pointer table. sta sound_ptr ;sound_ptr is a zero page pointer variable lda song_headers+1, y ;read HI byte sta sound_ptr+1 ldy #$00 lda [sound_ptr], y sta some_variable iny ;...read the rest of the header data Header DataSo what will our song header data look like? At the very least it should tell us:
How many data streams we have (songs will usually have 4, but sfx will have fewer)
Which streams those are (which stream index to use)
Which channels those streams use
Where to find those streams (ie, pointers to the beginning of each stream).
Initial values for those streams (for example, initial volume)
As
we add more features to our sound engine, we may expand our headers to
initialize those features. Let's start simple. Our headers will look
like this:
main header:--------+----------------byte # | what it tells us--------+----------------00 | number of streams01+ | stream headers (one for each stream)stream headers:--------+----------------byte # | what it tells us--------+----------------00 | which stream (stream number)01 | status byte (see below)02 | which channel03 | initial volume (and duty for squares)04-05 | pointer to data streamThe
status byte
will be a bit-flag that tells us special information about the stream.
For now we will just use bit0 to mark a stream as enabled or disabled.
In the future we may use other bits to store other information, such as
stream priority.
Stream Status Byte76543210 | +- Enabled (0: stream disabled; 1: enabled)
Sample HeaderHere is some code showing a sample header:
SQUARE_1 = $00 ;these are channel constantsSQUARE_2 = $01TRIANGLE = $02NOISE = $03MUSIC_SQ1 = $00 ;these are stream # constantsMUSIC_SQ2 = $01 ;stream # is used to index into stream variablesMUSIC_TRI = $02MUSIC_NOI = $03SFX_1 = $04SFX_2 = $05song0_header: .byte $04 ;4 streams .byte MUSIC_SQ1 ;which stream .byte $01 ;status byte (stream enabled) .byte SQUARE_1 ;which channel .byte $BC ;initial volume (C) and duty (10) .word song0_square1 ;pointer to stream .byte MUSIC_SQ2 ;which stream .byte $01 ;status byte (stream enabled) .byte SQUARE_2 ;which channel .byte $38 ;initial volume (8) and duty (00) .word song0_square2 ;pointer to stream .byte MUSIC_TRI ;which stream .byte $01 ;status byte (stream enabled) .byte TRIANGLE ;which channel .byte $81 ;initial volume (on) .word song0_tri ;pointer to stream .byte MUSIC_NOI ;which stream .byte $00 ;disabled. We will have our load routine skip the ; rest of the reads if the status byte disables the stream. ; We are disabling Noise because we haven't covered it yet.
;these are the actual data streams that are pointed to in our stream headers. song0_square1: .byte A3, C4, E4, A4, C5, E5, A5 ;some notes. A minor song0_square2: .byte A3, A3, A3, E4, A3, A3, E4 ;some notes to play on square 2 song0_tri: .byte A3, A3, A3, A3, A3, A3, A3 ;triangle data Sound Engine VariablesThe
last thing we need before we can write our sound_load routine is some
variables. As mentioned, our sound engine will have several streams
running simultaneously. Four will be used for music (one for each
tonal channel). Two will be used for sound effects. So we will
declare all variables in blocks of 6. Based on our header data, we
will need the following variables:
stream_curr_sound .rs 6 ;reserve 6 bytes, one for each streamstream_status .rs 6stream_channel .rs 6stream_vol_duty .rs 6stream_ptr_LO .rs 6stream_ptr_HI .rs 6sound_loadNow
let's write some code to read our header. Pay special attention to the
X register. I recommend tracing through the code using the sample
header above. Here is our sound_load routine:
;-------------------------------------; load_sound will prepare the sound engine to play a song or sfx.; input:; A: song/sfx number to playsound_load: sta sound_temp1 ;save song number asl a ;multiply by 2. We are indexing into a table of pointers (words) tay lda song_headers, y ;setup the pointer to our song header sta sound_ptr lda song_headers+1, y sta sound_ptr+1 ldy #$00 lda [sound_ptr], y ;read the first byte: # streams sta sound_temp2 ;store in a temp variable. We will use this as a loop counter iny.loop: lda [sound_ptr], y ;stream number tax ;stream number acts as our variable index iny lda [sound_ptr], y ;status byte. 1= enable, 0=disable sta stream_status, x beq .next_stream ;if status byte is 0, stream disabled, so we are done iny lda [sound_ptr], y ;channel number sta stream_channel, x iny lda [sound_ptr], y ;initial duty and volume settings sta stream_vol_duty, x iny lda [sound_ptr], y ;pointer to stream data. Little endian, so low byte first sta stream_ptr_LO, x iny lda [sound_ptr], y sta stream_ptr_HI, x.next_stream: iny lda sound_temp1 ;song number sta stream_curr_sound, x dec sound_temp2 ;our loop counter bne .loop rts Now our sound_load routine is ready. If the main program calls it, like this:
lda #$00 ;song 0 jsr sound_load Our
sound_load routine will take the value in the A register and use it to
fill our music RAM with everything we need to get our song running!
Reading StreamsOnce
we have our header loaded, we are ready to rock. All of our active
streams have pointers to their data stored in their stream_ptr_LO and
stream_ptr_HI variables. That's all we need to start reading data from
them.
To read data from our data stream, we will first copy the
stream pointer into a zero-page pointer variable. Then we will read a
byte using indirect mode and range-test it to determine whether it is a
note, note length or opcode. If it's a note, we will read from our
note_table and store the 11-bit period in RAM. Finally, we will update
our stream pointer to point to the next byte in the stream.
First we will need to declare some new variable blocks for the note periods:
stream_note_LO .rs 6 ;low 8 bits of periodstream_note_HI .rs 6 ;high 3 bits of periodHere is our se_fetch_byte routine (se_ stands for "sound engine"):
;--------------------------; se_fetch_byte reads one byte from a sound data stream and handles it; input: ; X: stream numberse_fetch_byte: lda stream_ptr_LO, x ;copy stream pointer into a zero page pointer variable sta sound_ptr lda stream_ptr_HI, x sta sound_ptr+1 ldy #$00 lda [sound_ptr], y ;read a byte using indirect mode bpl .note ;if <#$80, we have a note cmp #$A0 ;else if <#$A0 we have a note length bcc .note_length.opcode: ;else we have an opcode ;nothing here yet jmp .update_pointer.note_length: ;nothing here yet jmp .update_pointer.note: asl ;multiply by 2 because we are index into a table of words sty sound_temp1 ;save our Y register because we are about to destroy it tay lda note_table, y ;pull low 8-bits of period and store it in RAM sta stream_note_LO, x lda note_table+1, y ;pull high 3-bits of period from our note table sta stream_note_HI, x ldy sound_temp1 ;restore the Y register ;update our stream pointers to point to the next byte in the data stream .update_pointer: iny ;set index to the next byte in the data stream tya clc adc stream_ptr_LO, x ;add Y to the LO pointer sta stream_ptr_LO, x bcc .end inc stream_ptr_HI, x ;if there was a carry, add 1 to the HI pointer..end: rtsLook
at the part that updates the stream pointer. After we finish all our
reads, Y will hold the index of the last byte read. To be ready for
the next frame, we will want to update our pointer to point to the next
byte in the data stream. To do this, we increment Y and add it to the
pointer. But we have to be careful here. What if our current position
is something like this:
stream_ptr: $C3FF
Y: 1
The
next position here should be $C400. But ADC only works on the 8-bit
level, so if we add 1 to the low byte of the pointer we will get this
instead:
stream_ptr: $C300
The FF in the low byte becomes
00, but the high byte remains the same. We need to increment the high
byte manually. But how do we know when to increment it and when to
leave it alone? Lucky for us, ADC sets the carry flag whenever it
makes a FF->00 transition. So we can just check the carry flag
after our addition. If it is set, increment the high byte of the
pointer. If it is clear, don't increment it. That's what our code
above does.
Playing MusicWe've
loaded our header. We've set up our stream pointers in RAM. We've
written a routine that will read bytes from the streams and turn them
into notes. Now we need to update sound_play_frame. sound_play_frame
will loop through all 6 streams. It will check the status byte to see
if they are enabled. If enabled, it will advance the stream by one
frame. Here's the code:
sound_play_frame: lda sound_disable_flag bne .done ;if sound engine is disabled, don't advance a frame inc sound_frame_counter lda sound_frame_counter cmp #$08 ;***change this compare value to make the notes play faster or slower*** bne .done ;only take action once every 8 frames. ldx #$00 ;our stream index. start at MUSIC_SQ1 stream.loop: lda stream_status, x ;check bit 0 to see if stream is enabled and #$01 beq .next_stream ;if disabled, skip to next stream jsr se_fetch_byte ;read from the stream and update RAM jsr se_set_apu ;write volume/duty, sweep, and note periods of current stream to the APU ports .next_stream: inx cpx #$06 ;loop through all 6 streams. bne .loop lda #$00 sta sound_frame_counter ;reset frame counter so we can start counting to 8 again. .done: rtsAnd here is se_set_apu which will write a stream's data to the APU ports:
se_set_apu: lda stream_channel, x ;which channel does this stream write to? asl a asl a ;multiply by 4 so Y will index into the right set of APU ports (see below) tay lda stream_vol_duty, x sta $4000, y lda stream_note_LO, x sta $4002, y lda stream_note_HI, x sta $4003, y lda stream_channel, x cmp #TRIANGLE bcs .end ;if Triangle or Noise, skip this part lda #$08 ;else, set negate flag in sweep unit to allow low notes on Squares sta $4001, y.end: rts Writing to the APU ports directly like this is actually bad form. We'll learn why in a later lesson.
One
thing to pay attention to is how we get our APU port index. We take
the channel and multiply it by 4. Recall that we declared constants
for our channels:
SQUARE_1 = $00 ;these are channel constantsSQUARE_2 = $01TRIANGLE = $02NOISE = $03If our stream_channel is $00 (SQUARE_1), we multiply by 4 to get $00. y = 0
$4000, y = $4000
$4001, y = $4001
$4002, y = $4002
$4003, y = $4003
If our stream_channel is $01 (SQUARE_2), we multiply by 4 to get $04. y = 4
$4000, y = $4004
$4001, y = $4005
$4002, y = $4006
$4003, y = $4007
If our stream_channel is $02 (TRIANGLE), we multiply by 4 to get $08. y = 8
$4000, y = $4008
$4001, y = $4009 (unused)
$4002, y = $400A
$4003, y = $400B
If our stream_channel is $03 (NOISE), we multiply by 4 to get $0C. y = C
$4000, y = $400C
$4001, y = $400D
$4002, y = $400E
$4003, y = $400F
See how everything lines up nicely?
Putting It All TogetherDownload and unzip the
headers.zip sample files. Make sure the following files are in the same folder as NESASM3:
headers.asm
sound_engine.asm
headers.chr
note_table.i
song0.i
song1.i
song2.i
song3.i
headers.bat
Double click headers.bat. That will run NESASM3 and should produce the headers.nes file. Run that NES file in FCEUXD SP.
Use the controller to select songs and play them. Controls are as follows:
Up: Play
Down: Stop
Right: Next Song
Left: Previous Song
Song0
is a silence song. It is not selectable. headers.asm "plays" song0 to
stop the music when you press down. See song0.i to find out how it
works.
Song1 is an evil sounding series of minor thirds.
Song2 is
a short sound effect on the Sq2 channel. It uses the SFX_1 stream.
Try playing it over the other songs to see how it steals the channel
from the music.
Song3 is a simple descending chord progression.
Try
creating your own songs and sound effects and add them into the mix.
To add a new song you will need to take the following steps:
1) create a song header and song data (use the included songs as reference). Note that data streams are terminated with $FF
2) add your header to the song_headers pointer table at the bottom of sound_engine.asm
3) update the constant NUM_SONGS to reflect the new song number total (also at the bottom of sound_engine.asm)
Although
not necessary, I recommend keeping your song data in a separate file
like I've done with song0.i, song1.i, song2.i and song3.i. This makes
it easier to find the data if you need to edit your song later. If you
do this, don't forget to .include your file.
Next Week:
Timing, Note Lengths, Buffering and Rests