This tutorial covers SNES audio including the SPC700 sound chip, BRR samples, and SNESMOD tracker playback.
The SNES has a dedicated SPC700 sound processor with:
| Method | Best For | Size | Complexity |
|---|---|---|---|
| Direct BRR samples | One-shot SFX, voice clips | per sample | Low |
| SNESMOD | Music + tracker SFX | ~5.5KB | Medium |
Reach for direct BRR samples when you want a jump, a hit, a coin, or a voice clip played on demand — you load a .brr into the SPC700 and trigger it from C (see soundboard — the audio v2 engine, driven entirely from C). Reach for SNESMOD when you want music, or SFX that share a tracker soundbank with it. Pick one per ROM: audio and snesmod both own the SPC700, so they are never linked together. To make a .brr from your own audio, see One-shot samples from WAV below.
SNESMOD is a tracker-based audio engine supporting Impulse Tracker (.it) modules.
SNESMOD is built around tracker modules. For a plain one-shot effect — a jump, a hit, a UI blip, a bit of speech — the lighter path is a direct BRR sample: convert an audio file to the SNES's BRR format once, bake it into the ROM, and trigger it from C. This is what soundboard — the audio v2 engine, driven entirely from C does.
1. Convert your WAV to BRR. wav2brr (built by make tools) turns a PCM .wav into a .brr using the same encoder smconv uses on its own samples:
Input is PCM WAV (8- or 16-bit, mono or stereo — stereo is downmixed). Keep the source at or below 32 kHz; that is the DSP's ceiling, and a higher rate just plays back sharp. Pass -v to see the block count, loop offset, and a ready-to-paste audioLoadSample() line.
2. Bake the .brr into the ROM. Put a settings file beside the WAV and the build does the rest, exactly as audio/sfx_from_wav does:
opensnes-sample encode runs before the first C object; it writes res/jump.brr, a res/jump_data.as the build links (its own ASSET_SECTION, so any bank but $00), and a res/jump.h that declares jump_brr[], jump_brr_end[] and the size. A looping sample adds loop = [start, end] to the table.
3. Load it once, play it on demand. The label becomes a C symbol; the size is the two labels subtracted, and the loop point is the byte offset wav2brr reported (0 for a one-shot):
See Sample Management in the API reference for the full sample API (audioLoadSample, audioPlaySample, audioUnloadSample).
The build converts a one-shot sample for you: when an assembly file .incbins res/jump.brr and res/jump.wav exists, make runs wav2brr on it. A looping sample needs its loop points, so run wav2brr --loop by hand and commit the .brr next to its source WAV.
You MUST call snesmodProcess() every frame! Failure to do so causes:
snesmodGetPosition() returns the playback position the driver reports (the current row), for syncing game events to the music:
Every snesmod* command goes through a queue that snesmodProcess() drains a little each frame. snesmodFlush() blocks until the queue is empty, for the rare moment you need every command applied now, such as before loading another module.
The queue holds 85 commands and the SPC700 takes about two per frame. With several queued, snesmodProcess() waits on the audio CPU for up to five scanlines (about 2 % of a frame) before leaving the rest to the next frame. A command sent while the queue is full is dropped, so a burst of dozens of effects in one frame loses the last ones rather than corrupting the queue.
(The 0.x names snesmodSetSoundTable and snesmodAllocateSoundRegion, which set up a stream no SDK call could start, were removed on 2026-10-05.) They prepare the driver's streamed-sample path, and no SDK call starts a stream. The allocation reorganises the SPC700's memory and stops a module already loaded, so a call left in old code must stay before snesmodLoadModule().
| Component | Size |
|---|---|
| SPC700 Driver | ~5.5KB |
| Sample Data | Up to ~58KB |
| Echo Buffer | ~4KB (at $D000-$FFFF) |
Total audio RAM: 64KB
For sound effects and sample playback, LIB_MODULES += audio gives you the full engine with no SPC700 code of your own: the lib ships a resident driver (built from source at lib build time) and audio.h's functions drive it — audioInit(), audioLoadSample() (BRR streamed into APU RAM at runtime), audioPlaySampleEx() (volume/pan/ pitch, 8-voice round-robin polyphony), per-voice ADSR/GAIN, and a configurable echo with FIR filter. Every call is bounded — the API returns AUDIO_ERR_TIMEOUT rather than hanging. Worked example: audio/soundboard. Main-thread only; one engine per ROM (don't link audio and snesmod together).
audioPlaySample() and audioPlaySampleEx() pick a voice round-robin and return it (or AUDIO_VOICE_NONE). audioPlaySampleOn(voice, sample, volume, pan, pitch) plays on the voice you name — reserve one for a long sound that must not be cut by the next effects, and shape its envelope before the key-on:
audioStopAll() keys off all eight voices. Passing AUDIO_VOICE_AUTO as the voice gives the round-robin choice.
Pitch is clamped to 0x3FFF, the S-DSP's 14-bit range.
audioSetADSR(voice, attack, decay, sustain, release) gives a voice the classic envelope (the AUDIO_ATTACK_*, AUDIO_DECAY_*, AUDIO_SUSTAIN_*, AUDIO_RELEASE_* constants). audioSetGain(voice, mode) switches the voice to GAIN instead and writes mode to its GAIN register as is: $00-$7F hold the envelope at a fixed level (value × 16); with bit 7 set, bits 5-6 choose linear decrease, exponential decrease, linear increase or bent increase, and bits 0-4 the rate (snesdev-wiki, S-DSP registers, VxGAIN; anomie's S-DSP doc). They shape the attack, so set them before playing on that voice — which is why audioPlaySampleOn() lets you choose it.
Echo takes three steps, in this order: the delay and levels, the filter, then the voices that feed it.
{ 0x7F, 0, 0, 0, 0, 0, 0, 0 } is the identity filter: the echo comes back unfiltered (snesdev-wiki, S-DSP registers, FIRx, which also says to set the filter before echo is enabled). audioSetEcho() clears the whole echo ring, which takes several milliseconds per delay step: call it when a scene starts, not every frame.
| Call | Returns |
|---|---|
| audioIsReady() | 1 once audioInit() has started the driver; 0 if the driver did not answer |
| audioGetFreeMemory() | bytes left for samples in the SPC700's RAM |
| audioGetVoiceState(voice, &state) | whether the voice's envelope is still sounding, and the sample, volume, pan and pitch last sent to it |
The 0.x audioUpdate was removed on 2026-10-05 (it did nothing): the engine sends each command as you call it, nothing is deferred to a per-frame call.
Choosing a path: snesmod for tracker music (IT modules), audio for C-driven samples and DSP effects, apu (below) for writing your own SPC700 program.
Since the SPC700 arc, the SDK has a second audio path: the apu module uploads a wla-spc700-assembled program straight through the IPL boot-ROM protocol — apuWaitBoot(), apuUpload(), apuExecute() — giving full DSP control (voices, ADSR, echo, pitch) with no tracker involved. Worked example: audio/speech_synth (phoneme-bank speech — upload, DSP config, per-phoneme sequencing). The two paths are exclusive: don't link apu and snesmod in one ROM.
APU-side memory layout matters: the flat binary is laid out by wlalink -b, and an .ORG section that overlaps your growing code OVERWRITES it silently (the SPC700-arc sequencer died exactly this way during development). Budget the code page before placing the sample directory.
apuWaitBoot() only works once — the IPL handshake is consumed at boot. To replace the running program later, use the cooperative reset protocol (worked example: audio/apu_switch):
Two contract points, both learned the hard way in apu_switch: apuReset() blocks forever on a program that never polls for the magic, and the next program receives the DSP dirty — a residual ADSR1 bit 7 from the previous program silently overrides GAIN, so every register a voice depends on must be written explicitly.
luna spc-dump runs a ROM and exports the complete APU state — 64 KB of ARAM plus all 128 DSP registers — as a standard playable .spc. Diffing two dumps (yours vs a reference, or two instants of your own ROM) answers in seconds what ears cannot localize: upload integrity, directory/loop addresses, per-voice ADSR/pitch/envelope state, phoneme/note schedules. --audio-out (WAV capture) complements it for spectral verification.