Thursday, November 5, 2020

Adding music to a NES game

Petris now has a theme song, courtesy of my niece. :)



I asked for some help from my nieces to come up with a small bit of music for the game, since I'm not much of a composer myself. After I described the constraints of the NES to them (two square waves, one triangle, one noise), my oldest niece was able to get it to me as a recording she put together on an iPad; I just had to get it transcribed into a format the NES would accept and a music engine to play it.

Enter FamiStudio.

FamiStudio

FamiStudio is a nifty combination of tool and framework for constructing NES music and sound effects. It consists of a couple pieces:

  • A desktop application for composing music or sounds against the NES hardware
  • Several audio engines in assembly that can be embedded in an existing NES source code
The system supports both the in-hardware features of the chip and a few software-implemented effects (such as arpeggio). Music is broken down into "patterns" and "instruments," where patterns allow for saving memory by repeating the same musical phrase and instruments combine "voicing" rules with notes to create various timbres the sound circuitry can support. I put together a FamiStudio project and dove in.

My niece gave me a fairly simple piano-and-drum piece, so I only needed two instruments for it. For the drum, I used a bassy frequency and tweaked the volume envelope to give a sharp hit with a quick die-off. The volume is quiet for one frame to give a "squishy" feel, which sounds more bass (soft mallet hitting a big membrane, maybe? I don't know much music physics ;) ).


To get the sound a bit more "drummy," I used the arpeggio effect to tweak the noise frequency higher for two frames, then back down to the selected frequency. It puts a bit of hiss on the front, like a metal rattle that dies off; not quite snare drum, but a bit more color than a flat "boomph" sound.



The square wave carries the two-note melody and only has a few tweaks. Duty cycle selects what percent of time the square wave is "on" and can vary between 12.5%, 25%, 50%, or 75% (which is just 25% again; human ear can't tell the difference). 50% gives me a good old-fashioned "pure" square wave with no flavor, which is very piano. For volume, I went with a sharp attack and a long-tail decay; no deep thought on this I just doodled on the waveform until it "felt" good.



Now that the song exists, I can export it as FamiStudio Music Code using the NESASM format, since Petris is written in nbasic and nbasic includes an escape hatch to NESASM. The result is a data file containing a representation of the music, but not the commands to play it.


Halfway there

The nbasic language doesn't include a "file import" operator, so I copied-and-pasted the content of that file between asm and endasm directives. Now, I just needed some code to play it.

FamiStudio Sound Engine

FamiStudio's GitHub repository includes an implementation of the audio engine in several different NES assembly languages. I grabbed the one for NESASM and pasted it directly into Petris's source file. The audio engine is quite featureful, and quite large; everything I'd written for Petris to that point now composes 19% of the soruce file. So I compiled and..... The assembler choked.

So I'm not sure if I'm using an older version of NESASM (version 3.1) or if FamiStudio was built against a newer version, but NESASM itself has a hard limit of about 32 characters worth of label that the FamiStudio Sound Engine completely ignores. I made and documented 14 search-replaces to trim the label lengths down to something acceptable to NESASM, and we were off to the races.

To connect the audio engine to my game, I had to run some procedures exposed by the engine. The engine documents these pretty well, but a quick summary follows. Since we're operating at assembly level and don't have a standard function-call protocol, each function documents what the a, x, and y registers have to be set to for the function to work.

  • famistudio_init sets everyting up. It accepts a selector of 0 for PAL timing and 1 for NTSC timing (a), and a pointer to the label at the top of the music data spit out by FamiStudio (low-byte x, high-byte y; in my case, that's untitled_music_data.
  • famistudio_music_play begins playing a song. It takes the index of the song to play (a); I only have one, so I just had to load a 0 into the accumulator.
  • famistudio_music_stop kills playback and takes no input params
  • famistudio_update is a "meat and potatoes" function that must be called once per animation frame (i.e. once per non-maskable interrupt cycle) to allow the engine to move the music forward. More on this later.
The nbasic language is very good about bridging to assembly, so calling these was no more complex than throwing down a small wrapper subroutine to make juggling variables easy.

It's almost certainly overkill to save all the registers, but no harm done

I added gosubs to these routines to the relevant locations, through a call to the updater into my nmi interrupt logic, and... Started an earthquake.


Here's what happened. I made the mistake of adding the update function into my code here, after drawing is completed:

You feel like you're gonna have a bad time...

Problem is, that means I'm running the audio update loop inside the vertical blanking period (the NMI fires at the start of vertical blank. This is, generally, setting yourself up for failure; if the audio processing takes so long that the vblank period ends, any more manipulation of the PPU before the next NMI fires is going to fight the PPU itself for control of state, as the PPU uses its memory latches to generate a frame of video output. Specifically in my case, vwait_end does some cleanup and sets the scroll register correctly, so firing it after vblank ends causes offset artifacts as video rendering continues with the wrong scroll value.

Fortunately, the solution is real straightforward: don't do that.


Now we're safely setting up audio while the PPU is doing the heavy-lifting of emitting one frame of video, and life goes on.

Lessons Learned

This was a fun exercise to finish, and I'm honestly very surprised at how good the public tooling is for NES audio. The audio engine available under the MIT license was a huge win, and I want to extend a huge thank you to Mathieu Gauthier (a.k.a. BleuBleu) for making that exist. He doesn't appear to have a Patreon, so drop a note on one of his video tutorials and tell him what a cool guy he is.

No comments:

Post a Comment