Last Week: Tempo, Note Lengths, Buffering and RestsThis Week: Volume Envelopes
Volume EnvelopesThis week we will
add volume envelopes to our engine. A
volume envelope is a series of
volume values that are applied to a note one frame at a time. For
example, if we had a volume envelope that looked like this:
F E D C 9 5 0Then
whenever we played a note, it would have a volume of F on the first
frame, a volume of E on the second frame, then D, then C, then 9, then
5 until it is finally silenced with a volume of 0 on the 7th frame.
Applying this volume envelope on our notes would give them a sharp,
short staccato feel. Conversely, if we had a volume envelope that
looked like this:
1 1 2 2 3 3 4 4 7 7 8 8 A A C C D D E E F F FEach note would start very quietly and fade in to full volume. Look at this volume envelope:
D D D C B 0 0 0 0 0 0 0 0 6 6 6 5 4 0Here
we start at a high volume (D) and let it ring for 5 frames. Then we
silence the note for 8 frames. Then the note comes back at a very low
volume for 5 frames. Notes using this volume envelope would sound like
they had an faint echo.
As you can see, volume envelopes are pretty cool. We can get a lot of different sounds out of them. Let's add them in.
ChannelsVolume
envelopes are best suited for the square and noise channels where we
have full control of the volume. The triangle channel on the other
hand doesn't allow much volume control. It only has two settings: full
blast and off. We can still apply volume envelopes in a limited way
though. Consider these two volume envelopes:
0F 0E 0D 0C 09 05 0004 04 05 05 06 06 07 07 08 08 09 09 0A 0A 00These two envelopes would have a vastly different sound on the square channels, but to the triangle they look like this:
On On On On On On OffOn On On On On On On On On On On On On On OffYou
don't get the subtle shifts in volume, but you do get a different
length. We can use volume envelopes that end in 00 to control when the
triangle key-off occurs. Not as cool as full volume control, but still
useful.
Defining volume envelopesFirst let's define some volume envelopes so we have some data to work with. We'll use some of the examples from above:
se_ve_1: .byte $0F, $0E, $0D, $0C, $09, $05, $00 .byte $FFse_ve_2: .byte $01, $01, $02, $02, $03, $03, $04, $04, $07, $07 .byte $08, $08, $0A, $0A, $0C, $0C, $0D, $0D, $0E, $0E .byte $0F, $0F .byte $FFse_ve_3: .byte $0D, $0D, $0D, $0C, $0B, $00, $00, $00, $00, $00 .byte $00, $00, $00, $00, $06, $06, $06, $05, $04, $00 .byte $FFNotice
that I terminated each envelope with $FF. We need some terminator
value so the engine will know when we've reached the end of the
envelope. We could have used any value, but $FF is pretty common.
Next we will make a pointer table that holds the addresses of our volume envelopes:
volume_envelopes: .word se_ve_1, se_ve_2, se_ve_3 Declaring variablesIn
order to apply a volume envelope to a particular stream, we will need a
variable that tells us which one to use. We will also need an index
variable that tells us our current position within the volume envelope:
stream_ve .rs 6 ;current volume envelopestream_ve_index .rs 6 ;current position within the volume envelopestream_ve
will tell us which volume envelope to use. Code-wise, it will act as
an index into our pointer table so we know where to read from. Sound
familiar? It works the same way as "song number" did for loading a
song. We aren't there yet, but here's a peek at how we will use these
variables to read from the volume envelopes. (x holds the stream
number):
sty sound_temp1 ;save y because we are about to destroy it. lda stream_ve, x ;which volume envelope? asl a ;multiply by 2 because we are indexing into a table of addresses (words) tay lda volume_envelopes, y ;get the low byte of the address from the pointer table sta sound_ptr lda volume_envelopes+1, y ;get the high byte of the address sta sound_ptr+1 ldy stream_ve_index, x ;our current position within the volume envelope. lda [sound_ptr], y ;grab the value. ;check against $FF (our termination value) ;set the volume ;increment stream_ve_index ;etc Compare this code to the beginning of the sound_load routine. Are you starting to see a pattern?
InitializingWhenever
we add a new feature, we need to consider how we should initialize it.
Every stream in our music data will potentially have a different volume
envelope, so we should add a volume envelope field to our header.
Volume envelopes will deprecate our old "initial volume" field, but we
will still need to have duty cycle info, so we'll just rename that
field:
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 byte02 | which channel03 | initial duty (for triangle, set the 7bit)04 | volume envelope05-06 | pointer to data stream07 | initial tempoTo
read this data from the header, we will have to insert the following
code into our sound_load routine (after reading the duty):
lda [sound_ptr], y ;the stream's volume envelope sta stream_ve, x iny Notes will always start from the beginning of the volume envelope, so we can just initialize stream_ve_index to 0:
lda #$00 sta stream_ve_index, x Now we just need to make sure to assign volume envelopes to all the streams in our song data and we're ready to go:
song5_header: .byte $01 ;1 stream .byte SFX_1 ;which stream .byte $01 ;status byte (stream enabled) .byte SQUARE_2 ;which channel .byte $70 ;initial duty (01). Initial volume deprecated. .byte $00 ;the first volume envelope (se_ve_1) .word song5_square2 ;pointer to stream .byte $FF ;tempo..very fast tempoRemember
that you can always create descriptive aliases for your volume
envelopes if you don't want to remember which number is which:
;volume envelope aliasesve_short_staccato = $00ve_fade_in = $01ve_blip_echo = $02song5_header: .byte $01 ;1 stream .byte SFX_1 ;which stream .byte $01 ;status byte (stream enabled) .byte SQUARE_2 ;which channel .byte $7F ;initial duty (01). Initial volume deprecated. .byte ve_short_staccato ;the first volume envelope (se_ve_1) .word song5_square2 ;pointer to stream .byte $FF ;tempo..very fast tempo Using
aliases is a good idea because the assembler will give you an error if
you mistype your alias. If you mistype your number, and it is still a
valid number, the assembler won't know there's a problem and will
assemble it. This kind of bug in your data can be hard to trace.
Implementing Volume EnvelopesTo
implement volume envelopes, we need to modify the code where we set the
volume. Instead of using a fixed value like we were doing before, we
need to read from our current position in the volume envelope and use
that value instead. Our volume code is starting to get a little
complicated, so let's pull it out into its own subroutine. This will
make our code easier to follow:
;----------------------------------------------------; se_set_temp_ports will copy a stream's sound data to the temporary apu variables; input:; X: stream numberse_set_temp_ports: lda stream_channel, x asl a asl a tay jsr se_set_stream_volume ;let's stick all of our volume code into a new subroutine ;less cluttered that way lda #$08 sta soft_apu_ports+1, y ;sweep lda stream_note_LO, x sta soft_apu_ports+2, y ;period LO lda stream_note_HI, x sta soft_apu_ports+3, y ;period HI rts What
should our new subroutine se_set_stream_volume do? First it needs to
read a value from our stream's volume envelope. Then it needs to
modify the stream's volume using that value. Then we need to update
our position within the volume envelope. Finally it needs to check to
see if we are resting, and silence the stream if we are (we wrote this
code last week). It looks something like this (new code in red):
se_set_stream_volume: sty sound_temp1 ;save our index into soft_apu_ports (we are about to destroy y) lda stream_ve, x ;which volume envelope? asl a ;multiply by 2 because we are indexing into a table of addresses (words) tay lda volume_envelopes, y ;get the low byte of the address from the pointer table sta sound_ptr ;put it into our pointer variable lda volume_envelopes+1, y ;get the high byte of the address sta sound_ptr+1 .read_ve: ldy stream_ve_index, x ;our current position within the volume envelope. lda [sound_ptr], y ;grab the value. cmp #$FF bne .set_vol ;if not FF, set the volume dec stream_ve_index, x ;else if FF, go back one and read again jmp .read_ve ; FF essentially tells us to repeat the last ; volume value for the remainder of the note.set_vol: sta sound_temp2 ;save our new volume value (about to destroy A) lda stream_vol_duty, x ;get current vol/duty settings and #$F0 ;zero out the old volume ora sound_temp2 ;OR our new volume in. ldy sound_temp1 ;get our index into soft_apu_ports sta soft_apu_ports, y ;store the volume in our temp port inc stream_ve_index, x ;set our volume envelop index to the next position.rest_check: ;check the rest flag. if set, overwrite volume with silence value lda stream_status, x and #%00000010 beq .done ;if clear, no rest, so quit lda stream_channel, x cmp #TRIANGLE ;if triangle, silence with #$80 beq .tri ;else, silence with #$30 lda #$30 bne .store ;this always branches. bne is cheaper than a jmp.tri: lda #$80.store: sta soft_apu_ports, y.done: rts After
we read a value from our volume envelope, we AND stream_vol_duty with
#$F0. This has the nice effect of clearing the old volume while
preserving our squares' duty cycle settings. But we need to be careful
here. Recall that the triangle channel's on/off status is controlled
by the low 7 bits of the port:
TRI_CTRL ($4008)76543210|||||||||+++++++- Value+-------- Control Flag (0: use internal counters; 1: disable internal counters)If
any of those Value bits are set, the triangle channel will be
considered on. Consider what happens if bit 4, 5 or 6 happen to be
set. In this case, ANDing with #$F0 won't turn the triangle channel
off. If the volume we pull from the volume envelope is 0, it won't
silence our triangle channel because bit 4, 5 or 6 will still be set.
If we are careful not to set these bits in our song headers, the
problem should never come up. But for completeness we should fix it:
se_set_stream_volume: sty sound_temp1 ;save our index into soft_apu_ports (we are about to destroy y) lda stream_ve, x ;which volume envelope? asl a ;multiply by 2 because we are indexing into a table of addresses (words) tay lda volume_envelopes, y ;get the low byte of the address from the pointer table sta sound_ptr ;put it into our pointer variable lda volume_envelopes+1, y ;get the high byte of the address sta sound_ptr+1 .read_ve: ldy stream_ve_index, x ;our current position within the volume envelope. lda [sound_ptr], y ;grab the value. cmp #$FF bne .set_vol ;if not FF, set the volume dec stream_ve_index, x ;else if FF, go back one and read again jmp .read_ve ; FF essentially tells us to repeat the last ; volume value for the remainder of the note.set_vol: sta sound_temp2 ;save our new volume value (about to destroy A) cpx #TRIANGLE bne .squares ;if not triangle channel, go ahead lda sound_temp2 bne .squares ;else if volume not zero, go ahead (treat same as squares) lda #$80 bmi .store_vol ;else silence the channel with #$80.squares: lda stream_vol_duty, x ;get current vol/duty settings and #$F0 ;zero out the old volume ora sound_temp2 ;OR our new volume in..store_vol: ldy sound_temp1 ;get our index into soft_apu_ports sta soft_apu_ports, y ;store the volume in our temp port inc stream_ve_index, x ;set our volume envelop index to the next position.rest_check: ;check the rest flag. if set, overwrite volume with silence value lda stream_status, x and #%00000010 beq .done ;if clear, no rest, so quit lda stream_channel, x cmp #TRIANGLE ;if triangle, silence with #$80 beq .tri ;else, silence with #$30 lda #$30 bne .store ;this always branches. bne is cheaper than a jmp.tri: lda #$80.store: sta soft_apu_ports, y.done: rts New notesThe
last thing we need to consider is new notes. When an old note finishes
and we start playing a new note, we will want to reset the volume
envelope back to the beginning. This is as easy as setting
stream_ve_index to 0 when we read a new note:
se_fetch_byte: ;...snip... (setup pointers, read byte, test range, etc).note: ;do Note stuff sty sound_temp1 ;save our index into the data stream asl a tay lda note_table, y sta stream_note_LO, x lda note_table+1, y sta stream_note_HI, x ldy sound_temp1 ;restore data stream index lda #$00 sta stream_ve_index, x ;reset the volume envelope. ;check if it's a rest and modify the status flag appropriately jsr se_check_rest ;...snip... (update pointer) And now we have volume envelopes.
Putting It All TogetherDownload and unzip the
envelopes.zip sample files. Make sure the following files are in the same folder as NESASM3:
envelopes.asm
sound_engine.asm
envelopes.chr
note_table.i
note_length_table.i
vol_envelopes.i
song0.i
song1.i
song2.i
song3.i
song4.i
song5.i
envelopes.bat
Double click envelopes.bat. That will run NESASM3 and should produce the envelopes.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/SFX
Left : Previous Song/SFX
Song0 is a silence song. Not selectable.
Song1 is a boss song from The Guardian Legend, almost the same as the original.
Song2 is the same short sound effect from last week.
Song3 is a song from Dragon Warrior, very close to the original.
Song4 is the same song4 as last week, but volume envelopes allow us to save some bytes by reducing rests.
Song5 is a short sound effect, same as last week.
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). Don't forget to select a volume envelope for each stream
in your header. 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)
Try
making your own volume envelopes too. To do so you will need to modify
vol_envelopes.i. Remember that volume envelopes are terminated with
$FF.
Next Week:
Opcodes, Looping