Skip navigation
NintendoAge
Welcome, Guest! Please Login or Join
Loading...

Nerdy Nights Sound: Part 7 Volume Envelopes

Nov 2, 2009 at 5:15:05 AM
MetalSlime (0)
avatar
(Thomas Hjelm) < Crack Trooper >
Posts: 140 - Joined: 08/14/2008
Japan
Profile
Last Week: Tempo, Note Lengths, Buffering and Rests

This Week: Volume Envelopes

Volume Envelopes


This 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 0

Then 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 F

Each 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 0

Here 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.

Channels
Volume 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 00

04 04 05 05 06 06 07 07 08 08 09 09 0A 0A 00

These 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 Off
On On On On On On On On On On On On On On Off

You 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 envelopes
First 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 $FF
se_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 $FF
se_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 $FF

Notice 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 variables
In 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 envelope
stream_ve_index .rs 6   ;current position within the volume envelope

stream_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?
    
Initializing
Whenever 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 streams
01+     | stream headers (one for each stream)

stream headers:
--------+----------------
byte #  | what it tells us
--------+----------------
00      | which stream (stream number)
01      | status byte
02      | which channel
03      | initial duty (for triangle, set the 7bit)
04      | volume envelope
05-06   | pointer to data stream
07      | initial tempo

To 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 tempo

Remember that you can always create descriptive aliases for your volume envelopes if you don't want to remember which number is which:

;volume envelope aliases
ve_short_staccato = $00
ve_fade_in = $01
ve_blip_echo = $02

song5_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 Envelopes

To 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 number
se_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 notes
The 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 Together
Download 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

-------------------------
MetalSlime runs away

My nesdev blog: http://tummaigames.com/blog...


Edited: 11/10/2009 at 07:14 AM by MetalSlime

Nov 2, 2009 at 9:02:55 AM
Mario's Right Nut (352)
avatar
(Cunt Punch) < Bowser >
Posts: 6634 - Joined: 11/21/2008
Texas
Profile
Wow...that's intense. You're doing a great job, dude, keep 'em comming!

-------------------------

This is my shiny thing, and if you try to take it off me, I may have to eat you.

Check out my dev blog.


Nov 3, 2009 at 2:15:18 AM
udisi (88)
avatar
< King Solomon >
Posts: 3270 - Joined: 11/15/2006
United States
Profile
well, this is neat, was able to add back the staccato sound I had in my splash page. A few things I was wondering though. will probably be covered in the next tutorial.

1) what if I don't want an envelope? We split the byte between Duty cycle and Volume in this tutorial. The Volume is controlled solely by the envelope byte now. since this is an expected byte in the header, I have to put some value in. Do you make an envelope with just OF, OF, OF, etc. or is there a simpler way?

2)This will probably be covered by Opcodes. Will opcodes allow my to change the Duty Cycle and envelope mid-stream? Say I want to start with a staccato then change to something else. or change the duty cycle. I would think I may want to change the tempo maybe also.


Nov 3, 2009 at 7:56:08 AM
MetalSlime (0)
avatar
(Thomas Hjelm) < Crack Trooper >
Posts: 140 - Joined: 08/14/2008
Japan
Profile
Originally posted by: udisi

well, this is neat, was able to add back the staccato sound I had in my splash page. A few things I was wondering though. will probably be covered in the next tutorial.

1) what if I don't want an envelope? We split the byte between Duty cycle and Volume in this tutorial. The Volume is controlled solely by the envelope byte now. since this is an expected byte in the header, I have to put some value in. Do you make an envelope with just OF, OF, OF, etc. or is there a simpler way?

2)This will probably be covered by Opcodes. Will opcodes allow my to change the Duty Cycle and envelope mid-stream? Say I want to start with a staccato then change to something else. or change the duty cycle. I would think I may want to change the tempo maybe also.


Good questions.

1) Right now, if you don't want an envelope and you don't want to change any code you could make a custom envelope for a constant volume.  It would only need to be two bytes long, like this:

ve_F:
    .byte $0F, $FF

Since the $FF automatically sustains the last value for the remainder of the note you get this pretty cheaply:  2 bytes for the envelope, plus 2 more bytes for the pointer table entry.

If you wanted to take volume control away from the envelopes, you could add in a flag (one of the bits in stream_status maybe) that you could use to turn volume envelopes off and on.  If off, you could tell the engine to use the default value in stream_duty_vol.  This would require a check of the flag in your se_set_volume code, and you'd have to code in a way to set and clear the flag. 

2) Yes, opcodes will allow you to pretty much do anything you want at any point in a stream.  Change volume envelope, change duty cycle, change tempo, change keys, trigger a sound effect, loop, jump.  Anything you can write code for you can make into an opcode and allow the sound engine to call it as needed.

-------------------------
MetalSlime runs away

My nesdev blog: http://tummaigames.com/blog...

Mar 15, 2011 at 4:11:42 PM
zi (73)
avatar
(Tom Rag) < King Solomon >
Posts: 3100 - Joined: 06/02/2008
New York
Profile
whilst i LOVE these, as a musician and a super user of both ppmck and famitracker, there is a slight addition necessary to bring the power of volume envelopes to their fullest power: loops.

now, i tried literally adding the loop code (found in future NNS) to no avail, so i'm guessing this has to be implemented as part of the sound engine. is there a way to set yr sound env and then a loop point, which, instead of looping the last byte, loop a set of bytes?

note: i'm aware that this is a tall order and this is also a year and a half late.

edit: i hope you and those you love are safe!!!!!!!!

-------------------------

I AM ZI, CHIPTUNE ARTIST FOR THE NINTENDO ENTERTAINMENT SYSTEM, COMPOSER OF BOTH BLEEPS AND BOPS, VIRTUOSO OF INSTRUMENT FABRICATION, MERCENARY OF THE RETRO MUSICAL SOUNDSCAPE! THE SEGA DEVELOPMENT GUYS KNOW ME AS KNUCKLES SPRINGSTEIN, THE LONG ISLANG GEEK SQUAD KNOW ME AS ABE ECKSTEIN'S BOY, AND I AM KNOWN IN CANADA AS THAT KEENER WHO ALWAYS GETS THE NUMBER TWO BREAKFAST COMBO AT TIMMIES... and there are other secret names you do not know of yet.


Edited: 03/15/2011 at 04:12 PM by zi

Mar 15, 2011 at 6:18:57 PM
Mario's Right Nut (352)
avatar
(Cunt Punch) < Bowser >
Posts: 6634 - Joined: 11/21/2008
Texas
Profile
It will be the same thing we did with the pitch envelope looping. but with the volume envelopes.

-------------------------

This is my shiny thing, and if you try to take it off me, I may have to eat you.

Check out my dev blog.


Jun 6, 2015 at 3:47:04 PM
zi (73)
avatar
(Tom Rag) < King Solomon >
Posts: 3100 - Joined: 06/02/2008
New York
Profile
and hello from the future!

I've searched and searched, but no soundop for changing tempo on the fly so I made one!


se_op_set_tempo:
lda [sound_ptr], y ;read the argument
sta stream_tempo, x ;store it in our tempo variable
lda #$00
rts

it works! granted, it was easy, almost too easy, I just ripped off the volume env change. but, BUT, this is sloppy slap-dash work and I want the opinion of the programmers to evaluate and crush any potential mistakes.

thanks!

-------------------------

I AM ZI, CHIPTUNE ARTIST FOR THE NINTENDO ENTERTAINMENT SYSTEM, COMPOSER OF BOTH BLEEPS AND BOPS, VIRTUOSO OF INSTRUMENT FABRICATION, MERCENARY OF THE RETRO MUSICAL SOUNDSCAPE! THE SEGA DEVELOPMENT GUYS KNOW ME AS KNUCKLES SPRINGSTEIN, THE LONG ISLANG GEEK SQUAD KNOW ME AS ABE ECKSTEIN'S BOY, AND I AM KNOWN IN CANADA AS THAT KEENER WHO ALWAYS GETS THE NUMBER TWO BREAKFAST COMBO AT TIMMIES... and there are other secret names you do not know of yet.