OpenSNES
Modern Open-Source SNES Development SDK
Loading...
Searching...
No Matches
Audio & Music Tutorial

This tutorial covers SNES audio including the SPC700 sound chip, BRR samples, and SNESMOD tracker playback.

SNES Audio Architecture

The SNES has a dedicated SPC700 sound processor with:

  • 8 audio channels
  • 64KB dedicated audio RAM
  • BRR (Bit Rate Reduction) sample compression
  • Hardware ADSR envelopes
  • Echo/reverb effects

Audio Options in OpenSNES

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 (Recommended)

SNESMOD is a tracker-based audio engine supporting Impulse Tracker (.it) modules.

Setup

  1. Create music in a tracker (OpenMPT recommended)
  2. Export as Impulse Tracker (.it) format
  3. Convert with smconv tool
  4. Link soundbank with your ROM

Makefile Configuration

# Enable SNESMOD
USE_SNESMOD := 1
USE_LIB := 1
LIB_MODULES := console sprite input
# Soundbank source files
SOUNDBANK_SRC := music/mymusic.it sfx/effects.it

Basic Music Playback

#include <snes.h>
#include <snes/snesmod.h>
#include "soundbank.h"
int main(void) {
// Initialize SNESMOD (uploads SPC driver)
// Set soundbank location
snesmodSetSoundbank(SOUNDBANK_BANK);
// Load a module (defined in soundbank.h)
snesmodLoadModule(MOD_MYMUSIC);
// Start playback
snesmodPlay(0); // 0 = start from beginning
while (1) {
// MUST call every frame!
}
return 0;
}
int main(void)
Definition main.c:66
void consoleInit(void)
Initialize SNES hardware.
void WaitForVBlank(void)
Wait for next VBlank period.
void setScreenOn(void)
Enable screen display.
void snesmodSetSoundbank(u8 bank)
Set the soundbank bank number.
void snesmodInit(void)
Initialize SNESMOD audio engine.
void snesmodPlay(u8 startPosition)
Start module playback.
void snesmodLoadModule(u16 moduleId)
Load a module from the soundbank.
void snesmodProcess(void)
Process audio commands.
OpenSNES common-case master header.
SNESMOD - Tracker-based Audio Engine.

Sound Effects

#include <snes/snesmod.h>
#include "soundbank.h"
// Load effects at startup
void init_audio(void) {
snesmodSetSoundbank(SOUNDBANK_BANK);
// Load sound effects (indices from soundbank.h)
snesmodLoadEffect(0); // Jump sound
snesmodLoadEffect(1); // Coin sound
snesmodLoadEffect(2); // Hit sound
}
// Play effect when needed
void play_jump_sound(void) {
// snesmodPlayEffect(effectId, volume, pan, pitch)
// volume: 0-127
// pan: 0=left, 128=center, 255=right
// pitch: SNESMOD_PITCH_LOW/NORMAL/HIGH (4/8/12)
}
void play_coin_sound(void) {
// Higher pitch for coins
}
#define SNESMOD_PITCH_NORMAL
Definition snesmod.h:348
#define SNESMOD_PITCH_HIGH
Definition snesmod.h:349
u8 snesmodLoadEffect(u16 sfxIndex)
Load a sound effect from the soundbank.
u8 snesmodPlayEffect(u16 effectId, u8 volume, u8 pan, u16 pitch)
Play a sound effect.

One-shot samples from WAV (wav2brr)

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:

# one-shot effect
wav2brr res/jump.wav res/jump.brr
# a looping sample (loop between sample indices 2048 and 8192)
wav2brr --loop 2048 8192 res/cello.wav res/cello.brr

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:

# res/jump.wav.toml
tool = "opensnes-sample"
[encode]

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):

extern u8 brr_jump[], brr_jump_end[];
audioLoadSample(0, brr_jump, (u16)(brr_jump_end - brr_jump), 0);
// ...later, when the player jumps:
u8 audioPlaySample(u8 sampleId)
Play a sample with default settings.
u8 audioLoadSample(u8 id, const u8 *brrData, u16 size, u16 loopPoint)
Load a BRR sample into SPC700 RAM.
unsigned short u16
16-bit unsigned integer (0 to 65535)
Definition types.h:53
unsigned char u8
8-bit unsigned integer (0 to 255)
Definition types.h:47

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.

Volume Control

// Set module volume (0-255; a module starts at 255 — 127 is half)
// Fade out over time
snesmodFadeVolume(0, 4); // target=0, speed=4
// Pause/Resume
// Stop completely
void snesmodStop(void)
Stop module playback.
void snesmodPause(void)
Pause module playback.
void snesmodResume(void)
Resume module playback.
void snesmodSetModuleVolume(u8 volume)
Set module volume.
void snesmodFadeVolume(u8 targetVolume, u8 speed)
Fade module volume.

Creating Music with OpenMPT

  1. Download OpenMPT (free)
  2. Create new Impulse Tracker module
  3. Keep within SNES limits:
    • Max 8 channels
    • Total samples < 58KB
    • 32kHz max sample rate
  4. Export as .it file

smconv Tool

# Convert music for soundbank
smconv -s -o soundbank -b 1 music.it effects.it
# Output files:
# - soundbank.asm (link with ROM)
# - soundbank.h (module/effect IDs)
# - soundbank.bnk (binary data)

Pitch Constants

#define SNESMOD_PITCH_LOW 4 // 16kHz - lower pitch
#define SNESMOD_PITCH_NORMAL 8 // 32kHz - normal pitch
#define SNESMOD_PITCH_HIGH 12 // 48kHz - higher pitch

Important: Call snesmodProcess()!

You MUST call snesmodProcess() every frame! Failure to do so causes:

  • Audio glitches and stuttering
  • Command buffer overflow
  • Desynchronization
while (1) {
snesmodProcess(); // CRITICAL!
// ... game logic
}

Following the music and managing the queue

snesmodGetPosition() returns the playback position the driver reports (the current row), for syncing game events to the music:

if (snesmodGetPosition() == 32) {
start_boss_flash(); /* on the beat of row 32 */
}
u8 snesmodGetPosition(void)
Get current module position.

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().

Example: Music + SFX

#include <snes.h>
#include <snes/snesmod.h>
#include "soundbank.h"
int main(void) {
u16 pad, pad_prev = 0, pad_pressed;
// Initialize audio
snesmodSetSoundbank(SOUNDBANK_BANK);
// Load music module
snesmodLoadModule(MOD_LEVEL1);
// Load sound effects
snesmodLoadEffect(0); // Jump
snesmodLoadEffect(1); // Coin
// Start music
while (1) {
// Read input
while (REG_HVBJOY & 0x01) {}
u16 pad = REG_JOY1L | (REG_JOY1H << 8);
pad_pressed = pad & ~pad_prev;
pad_prev = pad;
if (pad == 0xFFFF) pad_pressed = 0;
// Play SFX on button press
if (pad_pressed & KEY_A) {
}
if (pad_pressed & KEY_B) {
}
}
return 0;
}
#define REG_JOY1H
Joypad 1 data high (R).
Definition registers.h:334
#define REG_JOY1L
Joypad 1 data low (R).
Definition registers.h:331
#define REG_HVBJOY
H/V blank and joypad status (R).
Definition registers.h:307
#define KEY_A
Definition input.h:84
#define KEY_B
Definition input.h:74

Memory Usage

Component Size
SPC700 Driver ~5.5KB
Sample Data Up to ~58KB
Echo Buffer ~4KB (at $D000-$FFFF)

Total audio RAM: 64KB

Tips

  1. Keep samples small - Use lower sample rates for less important sounds
  2. Reuse samples - Same sample at different pitches for variety
  3. Test on hardware - Emulator timing may differ
  4. Use echo sparingly - Takes 4KB+ of audio RAM

Example ROMs

  • examples/audio/snesmod_music/ - Music playback demo
  • examples/audio/snesmod_sfx/ - Sound effects demo

Next Steps

The audio module (v2) — samples and effects from pure C

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

Choosing the voice

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:

#define VOICE_ENGINE 7
audioSetADSR(VOICE_ENGINE, 4, 7, 7, 0); /* slow attack */
audioPlaySampleOn(VOICE_ENGINE, SFX_ENGINE, 90, AUDIO_PAN_CENTER, 0x1000);
/* ... */
audioStopVoice(VOICE_ENGINE); /* key-off */
u8 audioSetADSR(u8 voice, u8 attack, u8 decay, u8 sustain, u8 release)
Set ADSR envelope for a voice.
#define AUDIO_PAN_CENTER
Definition audio.h:99
u8 audioStopVoice(u8 voice)
Stop a specific voice.
u8 audioPlaySampleOn(u8 voice, u8 sampleId, u8 volume, u8 pan, u16 pitch)
Play a sample on a voice YOU choose.

audioStopAll() keys off all eight voices. Passing AUDIO_VOICE_AUTO as the voice gives the round-robin choice.

Volume and pitch

audioSetVolume(100); /* master, 0-127 */
u8 v = audioGetVolume(); /* the value last set */
audioSetVoiceVolume(3, 127, 40); /* voice 3, left/right */
audioSetVoicePitch(3, 0x1800); /* 0x1000 = the sample's own rate */
u8 audioGetVolume(void)
Get current master volume.
u8 audioSetVoiceVolume(u8 voice, u8 volumeL, u8 volumeR)
Set volume for a specific voice.
u8 audioSetVoicePitch(u8 voice, u16 pitch)
Set pitch for a specific voice.
u8 audioSetVolume(u8 volume)
Set master volume.

Pitch is clamped to 0x3FFF, the S-DSP's 14-bit range.

Shaping the envelope: ADSR or GAIN

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.

audioSetGain(2, 0x7F); /* voice 2 at a steady full level */
audioSetGain(2, 0xC0 | 20); /* voice 2 fades in linearly */
u8 audioSetGain(u8 voice, u8 mode)
Set GAIN mode for a voice (alternative to ADSR).

Echo

Echo takes three steps, in this order: the delay and levels, the filter, then the voices that feed it.

static const s8 fir_flat[8] = { 0x7F, 0, 0, 0, 0, 0, 0, 0 };
audioSetEcho(4, 40, 50, 50); /* 64 ms, feedback 40, volume L/R */
audioEnableEcho(0x03); /* voices 0 and 1 */
/* ... */
audioDisableEcho(); /* mutes the echo and stops the ring writes */
u8 audioEnableEcho(u8 voiceMask)
Enable echo for specific voices.
u8 audioDisableEcho(void)
Disable echo for all voices.
u8 audioSetEcho(u8 delay, s8 feedback, s8 volumeL, s8 volumeR)
Configure echo parameters.
u8 audioSetEchoFilter(const s8 fir[8])
Set FIR filter coefficients for echo.
signed char s8
8-bit signed integer (-128 to 127)
Definition types.h:44

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

Asking the engine

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.

The raw APU path (no snesmod)

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.

Hot-swapping APU programs

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):

  1. Build the APU program with APU_CHECK_RESET (from templates/memmap_spc700.inc) inside its wait loops. The idiom polls CPUIO0 for APU_RESET_MAGIC; on match it silences the DSP, acks, re-enables the IPL ROM and jumps to $FFC0 — the boot ROM is re-entrant.
  2. On the 65816, call apuReset(), then apuUpload()/apuExecute() for the next program — but NOT apuWaitBoot() (the reset already consumed the ready signal).

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.

Debugging audio: luna's spc-dump

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.