Sound & Music

Game Boy audio architecture, hUGE music driver internals, sound effect playback, channel muting, and direct hardware register control for plugin development.

Game Boy Audio Architecture

The Game Boy has four dedicated sound channels, each with distinct synthesis capabilities. All channels share a 4-bit volume range (0–15) and a length counter. On the Game Boy Color, each channel can be individually panned left/right for stereo output.

ChannelTypeRegistersCapabilities
CH1PulseNR10–NR14Frequency sweep, 4 duty cycles, volume envelope
CH2PulseNR21–NR244 duty cycles, volume envelope (no sweep)
CH3WaveNR30–NR34Custom 32-sample 4-bit waveform, 4 volume levels
CH4NoiseNR41–NR44LFSR-based noise, adjustable frequency and width
Shared resource

Music and sound effects share these four channels. When a sound effect plays on a channel, it temporarily overrides the music on that channel. Plan your music arrangements and SFX channel usage to avoid unwanted cutoffs.

Channel Register Reference

Pulse Channels (CH1 / CH2)

RegisterAddressDescription
NR100xFF10CH1 sweep (period, direction, shift)
NR110xFF11CH1 duty cycle (bits 7–6) + length timer (bits 5–0)
NR120xFF12CH1 volume envelope
NR130xFF13CH1 frequency low 8 bits
NR140xFF14CH1 trigger (bit 7), length enable (bit 6), frequency high 3 bits
NR21–240xFF16–19CH2 (same layout, no sweep register)

Wave Channel (CH3)

RegisterAddressBitsDescription
NR300xFF1ABit 7DAC on/off. Must be OFF to write wave RAM.
NR310xFF1B7–0Length timer (0–255). Duration = (256−t)/256 seconds.
NR320xFF1C6–5Volume: 00=mute, 01=100%, 10=50%, 11=25%
NR330xFF1D7–0Frequency low 8 bits
NR340xFF1E7, 6, 2–0Trigger (bit 7), length enable (bit 6), frequency high 3 bits

Wave RAM (0xFF30–0xFF3F): 16 bytes = 32 four-bit samples. Each byte holds two samples (high nibble first). Write only when NR30 bit 7 = 0.

Frequency formula: f = 65536 / (2048 − period) Hz, where period is the 11-bit value in NR33/NR34.

No envelope on wave channel

CH3 has no volume envelope — only 4 fixed volume levels. Once triggered, it plays at constant volume indefinitely (unless length enable is set). You must explicitly silence it by writing NR32 = 0 or NR30 = 0.

Noise Channel (CH4)

RegisterAddressBitsDescription
NR410xFF205–0Length timer (0–63). Duration = (64−t)/256 seconds.
NR420xFF217–0Volume envelope (see format)
NR430xFF227–0Polynomial counter: shift (7–4), width (3), divisor (2–0)
NR440xFF237, 6Trigger (bit 7), length enable (bit 6)

NR43 polynomial counter: Bits 7–4 = clock shift (s), bit 3 = LFSR width (0 = 15-bit white noise, 1 = 7-bit metallic), bits 2–0 = divisor code (r). LFSR clock = 262144 / (r × 2s) Hz. If r=0, treat as r=0.5.

Key Hardware Behaviors

Volume Envelope Format

Registers NR12, NR22, and NR42 share the same envelope format:

NRx2 format: VVVV DNNN
V = initial volume (0-15)
D = direction (0 = decrease, 1 = increase)
N = envelope period (0 = disabled, 1-7 = speed)
ValueVolumeDirectionPaceEffect
0xF0150Constant full volume
0xF115Decrease1Fast fade out (~234ms to silence)
0xF715Decrease7Slow fade out (~1.6s to silence)
0x919Decrease1Medium start, fast decay (~140ms)
0x474Decrease7Quiet start, slow decay (~437ms)
0x0000Silence (also disables DAC)

hUGE Music Driver

GB Studio 4.2 uses the hUGE tracker driver for music playback (.uge files). The driver is not VBlank-driven — it runs from the timer interrupt at a fixed rate independent of frame rendering.

Timing

Timer ISR, not VBlank

A common misconception is that hUGE runs from VBlank. It runs from the timer ISR at 256 Hz (music processed every 4th call = 64 Hz). This means the music driver writes to sound registers between your VBlank code, not synchronized with it. Any direct register writes from your plugin can be overridden by the next ISR tick within milliseconds. See Hardware — Clock Architecture for why 64 Hz differs from VBlank’s ~59.7 Hz.

Pattern Row Format

DN(note, instrument, effect)
// note:       0-71 = C_3..B_8, 90 = ___ (no note)
// instrument: 0 = none, 1-15 = instrument index (1-indexed!)
// effect:     0xTVV where T = effect type, VV = value

Each row entry is 3 bytes (the DN macro splits the values across bytes). Note is in byte 0 bits 6–0, instrument is split across bytes 0–1, effect occupies bytes 1–2.

Instrument Structs

Wave Instrument (CH3)

typedef struct {
    unsigned char length;      // → NR31
    unsigned char volume;      // → NR32 (0x20=100%, 0x40=50%, 0x60=25%)
    unsigned char waveform;    // wave table index (0-15) into waves[] array
    unsigned char *subpattern; // NULL = none
    unsigned char highmask;    // bit 7 = trigger, bit 6 = length enable
} hUGEWaveInstr_t;

Noise Instrument (CH4)

typedef struct {
    unsigned char envelope;     // → NR42 directly
    unsigned char *subpattern;  // NULL = none
    unsigned char highmask;     // bits 5-0 → NR41, bit 6 → length enable, bit 7 → LFSR width
    unsigned char unused1, unused2;
} hUGENoiseInstr_t;

Noise highmask decoding:

Channel Struct Layout

Each channel uses a 16-byte struct. Layout for CH1–CH3:

OffsetFieldSizeDescription
0channel_period2Current period (CH1–3) or poly note (CH4)
2toneporta_target2Tone portamento target period
4channel_note1Stored note index (0–71)
5highmask1Trigger/length enable bits for NRx4
6vibrato_tremolo_phase1LFO phase for vibrato/tremolo
7envelope1Envelope value
8table2Subpattern pointer
10table_row1Current subpattern step (0–31)
11–15padding5Unused

CH4 has an extra step_width byte between highmask and vibrato_tremolo_phase (LFSR width for NR43 bit 3).

Processing Order

Tick 0 (Row Boundary) — when a note + instrument is present

For CH3 (wave):

  1. Look up note → store index in channel_note3, period in channel_period3
  2. Write NR31 (length), NR32 (volume) from instrument
  3. If waveform changed: turn off DAC, copy 16 bytes to wave RAM, turn DAC back on
  4. Store subpattern pointer, reset table_row3 to 0
  5. Process effect (portamento, etc.)
  6. Write NR33 (period low), NR34 = period_high | highmask (trigger)
  7. Subpattern runs last — can override NR33/NR34

For CH4 (noise):

  1. Store note, compute channel_period4 via get_note_poly(note)
  2. Write NR42 (envelope), NR41 (length from highmask)
  3. Store subpattern pointer, reset table_row4
  4. Process effect
  5. Write NR43 = channel_period4 | step_width4, NR44 = highmask (trigger)
  6. Subpattern runs last — overrides NR43/NR44

Non-zero ticks (between rows)

  1. Process effect (e.g., portamento: modifies channel_period, writes NRx3/NRx4)
  2. Subpattern: writes frequency registers if the step has a valid note

Subpattern always has the last write — it runs after all effects.

Subpatterns — Notes are Relative

Subpatterns use the same DN(note, instrument, effect) encoding as pattern rows, but with different semantics:

Subpattern note 36 (C_6) = zero offset

This is the most critical detail for understanding hUGE subpatterns. Note 36 means “same as the base note.” Note 37 = +1 semitone, 38 = +2, 40 = +4, 35 = −1, etc. The base note (channel_noteX) is only updated when the pattern row plays a new note — it does NOT change when the subpattern writes.

32 steps per subpattern (indices 0–31). Step 31 typically contains a jump instruction (e.g., DN(___, 1, 0x000) = jump to step 1), creating a loop: 0→1→2→…→31→1→2→… Step 0 only plays on the first pass or when an instrument retrigger resets table_row to 0.

hUGE Note Table

The driver uses a 72-entry lookup table mapping note indices to 11-bit period values. Period → frequency: f = 65536 / (2048 − period) Hz.

IndexNotePeriod~HzIndexNotePeriod~Hz
0C_34432.736C_61798262
1Cs315634.737Cs61812278
2D_326236.738D_61825294
3Ds336338.939Ds61837311
4E_345741.240E_61849329
5F_354743.741F_61860349
6Fs363146.342Fs61871370
7G_371049.043G_61881392
8Gs378651.944Gs61890415
9A_385454.945A_61899440
10As392358.246As61907465
11B_398661.747B_61915493
12C_4104665.448C_71923524
13Cs4110269.349Cs71930555
14D_4115573.450D_71936585
15Ds4120577.851Ds71943624
16E_4125382.552E_71949662
17F_4129787.353F_71954697
18Fs4133992.454Fs71959736
19G_4137998.055G_71964780
20Gs41417103.956Gs71969829
21A_41452110.057A_71974886
22As41486116.658As71978936
23B_41517123.459B_71982993
24C_51546130.660C_819851040
25Cs51575138.661Cs819881092
26D_51602147.062D_819921170
27Ds51627155.763Ds819951236
28E_51650164.764E_819981311
29F_51673174.765F_820011395
30Fs51694185.066Fs820041489
31G_51714196.267G_820061560
32Gs5173220868Gs820091680
33A_5175022069A_820111771
34As5176723370As820131872
35B_5178324771B_820151986

get_note_poly (NR43 from Note Index)

The hUGE driver converts a note index (0–71) to an NR43 polynomial counter value using this algorithm:

A = note + 192           // 8-bit add (wraps around)
A = ~A                   // bitwise complement
if A < 7:
    NR43 = A             // low notes: direct value
else:
    upper = (A >> 2) - 1
    lower = (A & 3) + 4
    NR43 = (upper << 4) | lower

Key values used in this project:

NoteNameNR43Usage
32Gs50x67Bike idle noise
36C_60x57Bike riding noise
40E_60x47

Pattern: every 4 notes, the upper nibble decreases by 1 and the lower nibble cycles 7, 6, 5, 4.

GB Studio Music Manager

The music manager (music_manager.c) mediates between GBVM scripts, the hUGE driver, and the SFX player. Understanding its ISR flow is essential for plugins that control sound channels directly.

Key Variables

VariableTypeDescription
music_current_track_bankvolatile uint8_tROM bank of current track. 0xFF (MUSIC_STOP_BANK) = stopped.
music_next_trackconst TRACK_T *Queued track pointer. Non-NULL triggers hUGE_init on next ISR.
music_mute_maskuint8_tSFX-driven channel mute (temporary, cleared when SFX ends).
music_global_mute_maskuint8_tPersistent mute set by plugins/scripts. Survives SFX completion.
music_effective_muteuint8_tWhat the manager believes hUGE_mute_mask currently is.
music_play_isr_pauseuint8_tWhen TRUE, ISR returns before processing any music.

music_play_isr Flow (Timer ISR, 256 Hz)

void music_play_isr(void) {
    // 1. SFX processing (only if SFX is active)
    if (sfx_play_bank != SFX_STOP_BANK) {
        // Re-sync mute mask if changed
        if (music_effective_mute != (global_mask | sfx_mask))
            driver_set_mute_mask(combined);
        // Process SFX sample
        if (!sfx_play_isr()) {
            // SFX finished: restore mutes, reset wave, clear SFX state
            driver_set_mute_mask(music_global_mute_mask);
            driver_reset_wave();   // reloads wave RAM from hUGE instrument!
        }
    }

    // 2. Early exits
    if (music_play_isr_pause) return;
    if (music_current_track_bank == MUSIC_STOP_BANK) return;
    if (++counter & 3) return;  // only process every 4th call (64 Hz)

    // 3. Song init or update
    if (music_next_track) {
        music_sound_cut();          // zeroes all channel volumes
        hUGE_init(music_next_track); // initializes driver, ZEROES MUTE MASK
        music_next_track = NULL;
    } else {
        hUGE_dosound();             // processes one tick: effects, subpatterns, register writes
    }
}

music_stop / music_load

// Stop: sets bank to STOP_BANK, cuts all channel sound.
inline void music_stop(void) {
    music_current_track_bank = MUSIC_STOP_BANK;
    music_sound_cut();  // NR12=NR22=NR32=NR42=0, retrigger
}

// Load: queues a track. Actual init happens in next music_play_isr call.
inline void music_load(uint8_t bank, const TRACK_T *data) {
    if (bank == music_current_track_bank && data == music_current_track) return;
    music_current_track_bank = MUSIC_STOP_BANK;  // safe: ISR won't process
    music_current_track = data;
    music_next_track = data;
    music_current_track_bank = bank;              // go: ISR will init on next tick
}
music_pause() is not BANKED

music_pause(uint8_t pause) is defined in music_manager.c (bank 255) without the BANKED qualifier. Calling it from a plugin in a different ROM bank will crash — the linker generates a near call to a garbage address. Instead, declare extern uint8_t music_play_isr_pause; and set it directly, or use music_current_track_bank = MUSIC_STOP_BANK to block the ISR.

hUGE_init Zeroes the Mute Mask

hUGE_init zero-fills a memory region that includes hUGE_mute_mask (= mute_channels). This means every call to hUGE_init clears all channel mutes.

Combined with a second bug — the mute re-sync check in the ISR is gated behind sfx_play_bank != SFX_STOP_BANK (only runs when SFX is playing) — this creates a permanent mute loss scenario:

  1. Plugin sets music_global_mute_mask |= CH3|CH4, calls driver_set_mute_mask → works
  2. Script starts a new song → music_load queues it
  3. ISR calls hUGE_inithUGE_mute_mask = 0
  4. No SFX playing → re-sync check never fires → mute permanently lost

Workaround: Instead of relying on mute masks, set music_current_track_bank = MUSIC_STOP_BANK to prevent the ISR from calling hUGE_dosound() entirely. This is simpler and immune to the mute reset bug.

Direct Channel Control from Plugins

For plugins that need precise control over audio (engine sounds, ambient effects), write directly to the Game Boy’s sound registers. But you must prevent the music ISR from overriding your writes.

Preventing ISR Interference

The safest approach is to stop the ISR from processing music entirely:

// In your state_init (runs BEFORE On Init script):
music_current_track_bank = MUSIC_STOP_BANK;
// ISR sees STOP_BANK → returns before calling hUGE_dosound()
// Your direct register writes are now safe from being overridden

This is necessary because state_init() runs before the On Init script. Even if the script has a “Stop Music” event, the persistent song’s ISR is still actively writing to all 4 channels during the window between state_init and the script executing.

Sound registers persist across scenes

Hardware sound registers are not cleared during scene transitions. If your plugin writes to CH3/CH4 and the player transitions to a non-plugin scene, those channels keep playing. Ensure the destination scene either plays music (which calls music_sound_cut() before hUGE_init) or explicitly stops the channels.

Loading Wave RAM

static void load_wave(const UBYTE *wave) {
    NR30_REG = 0x00;  // DAC off (required to write wave RAM)
    for (UBYTE i = 0; i < 16; i++) {
        *((volatile UBYTE *)(0xFF30 + i)) = wave[i];
    }
    NR30_REG = 0x80;  // DAC on (does NOT restart playback)
    // Must trigger (NR34 bit 7) after this to hear sound
}

Triggering a Wave Note

NR31_REG = 0x00;                          // no length
NR32_REG = 0x20;                          // 100% volume
NR33_REG = period & 0xFF;                 // frequency low
NR34_REG = 0x80 | ((period >> 8) & 0x07); // trigger + frequency high

Updating Frequency Without Retriggering

// Smooth portamento: just update frequency, no trigger bit
NR33_REG = new_period & 0xFF;
NR34_REG = (new_period >> 8) & 0x07;  // no bit 7 = no trigger

Silencing Channels

// Silence CH3 (wave) - set volume to 0
NR32_REG = 0x00;

// Silence CH4 (noise) - zero envelope + retrigger to apply
NR42_REG = 0x00;
NR44_REG = 0x80;

// Or use the engine helper for specific channels:
sfx_sound_cut_mask(SFX_CH_3 | SFX_CH_4);

Sound Effects System

Sound effects are played via a VGM-style sample player that runs inside the music ISR. An SFX temporarily takes over one or more channels; when the effect finishes, the channels return to the music driver automatically.

SFX Playback

// GBVM:
VM_SFX_PLAY bank, pointer, mask, priority

// C API:
music_play_sfx(bank, sample, mute_mask, priority);
driver_reset_wave on SFX completion

When an SFX finishes, the ISR calls driver_reset_wave() which reloads wave RAM from the current hUGE instrument. If your plugin has loaded custom wave data into wave RAM, an SFX ending will corrupt it. Guard against this by reloading your wave data on the next frame.

Channel Mute Masks

BitChannelConstantValue
0CH1 (Pulse 1)SFX_CH_1 / MUSIC_CH_10x01
1CH2 (Pulse 2)SFX_CH_2 / MUSIC_CH_20x02
2CH3 (Wave)SFX_CH_3 / MUSIC_CH_30x04
3CH4 (Noise)SFX_CH_4 / MUSIC_CH_40x08

FX Hammer Sound Effects (.sav Files)

GB Studio uses FX Hammer .sav files as sound effect banks. Each .sav contains multiple numbered effects (designed in the FX Hammer tool or GB Studio’s Sound Effects editor). A single .sav file can hold dozens of effects, each identified by a zero-based index.

Asset Structure

Each .sav file in assets/sounds/ has a companion .gbsres JSON that tells the compiler how to process it:

{
  "_resourceType": "sound",
  "id": "a1b2c3d4-...",
  "name": "Game SFX.sav",
  "symbol": "sound_game_sfx_sav",
  "type": "fxhammer",
  "numEffects": 20,
  "filename": "Game SFX.sav"
}

The symbol field is the base name. The compiler generates per-effect symbols by appending _NN (zero-padded to 2 digits). For example, a file named Game SFX.sav with symbol sound_game_sfx_sav produces symbols like sound_game_sfx_sav_00, sound_game_sfx_sav_01, etc.

Generated Symbols

For each effect index NN, the compiler generates (using sound_game_sfx_sav as an example base symbol):

SymbolTypeDescription
sound_game_sfx_sav_NNconst uint8_t[]SFX sample data array
BANK(sound_game_sfx_sav_NN)uint8_tROM bank containing the data
MUTE_MASK_sound_game_sfx_sav_NN#defineChannel bitmask (which channels the effect uses)
__mute_mask_sound_game_sfx_sav_NNlinker symbolAddress-as-value encoding of the mask (for GBVM assembly)

All symbols are declared in a generated header at build/src/include/data/, named after the base symbol (e.g., sound_game_sfx_sav.h).

Mute mask varies per effect

Each effect has its own mute mask based on which channels it writes to. For example, a noise-only SFX has mask 0b00001000 (CH4), while a dual-channel effect might be 0b00001010 (CH2+CH4). Always use the generated MUTE_MASK_ define — don’t hardcode the mask.

Playing FX Hammer SFX from C Code

Include the required headers:

#include "music_manager.h"
#include "data/sound_game_sfx_sav.h"

Call music_play_sfx() with the generated symbols:

// Play effect 03 (e.g., a jump sound)
music_play_sfx(
    BANK(sound_game_sfx_sav_03),      // ROM bank
    sound_game_sfx_sav_03,            // data pointer
    MUTE_MASK_sound_game_sfx_sav_03,  // channel mute mask
    MUSIC_SFX_PRIORITY_NORMAL         // priority
);

// Play effect 10 (e.g., an impact sound)
music_play_sfx(
    BANK(sound_game_sfx_sav_10),
    sound_game_sfx_sav_10,
    MUTE_MASK_sound_game_sfx_sav_10,
    MUSIC_SFX_PRIORITY_NORMAL
);

The SFX player runs inside the timer ISR. It temporarily mutes the music on the SFX’s channels, plays the effect, then unmutes automatically when it finishes.

Playing FX Hammer SFX from GBVM Assembly

In GBVM scripts (.s files), use VM_SFX_PLAY with the linker symbol variants (prefixed with underscores):

; Play effect 03
VM_SFX_PLAY  ___bank_sound_game_sfx_sav_03, _sound_game_sfx_sav_03, ___mute_mask_sound_game_sfx_sav_03, .SFX_PRIORITY_NORMAL

Note the naming convention differences between C and GBVM assembly:

PurposeC CodeGBVM Assembly
BankBANK(sound_game_sfx_sav_NN)___bank_sound_game_sfx_sav_NN
Datasound_game_sfx_sav_NN_sound_game_sfx_sav_NN
MaskMUTE_MASK_sound_game_sfx_sav_NN___mute_mask_sound_game_sfx_sav_NN
PriorityMUSIC_SFX_PRIORITY_NORMAL.SFX_PRIORITY_NORMAL

Priority Levels

Higher-priority effects cancel lower-priority ones currently playing:

C ConstantGBVM ConstantValueUse Case
MUSIC_SFX_PRIORITY_MINIMAL.SFX_PRIORITY_MINIMAL0Ambient, low-importance sounds
MUSIC_SFX_PRIORITY_NORMAL.SFX_PRIORITY_NORMAL4Standard gameplay SFX
MUSIC_SFX_PRIORITY_HIGH.SFX_PRIORITY_HIGH8Critical feedback (damage, menu confirm)
SFX and direct channel control don’t mix

If your plugin writes directly to sound registers (like the biking engine sound), playing an FX Hammer SFX on the same channel will interrupt your writes. When the SFX finishes, driver_reset_wave() reloads the hUGE wave instrument — not your custom wave data. Either avoid SFX on channels you control directly, or reload your wave/register state on the next frame after detecting the SFX has ended (sfx_play_bank == SFX_STOP_BANK).

Common Pitfalls

  1. Music ISR overrides direct register writes. The ISR fires at 256 Hz (music at 64 Hz). Any values you write to NRxx registers will be overwritten within milliseconds unless you stop the ISR from processing music (music_current_track_bank = MUSIC_STOP_BANK).
  2. music_pause() is not BANKED. Calling it from a different ROM bank crashes. Set music_play_isr_pause directly via extern, or just use STOP_BANK.
  3. hUGE_init zeroes the mute mask. Loading a new song clears all mutes. The re-sync check only runs during SFX playback, so without SFX the mute is permanently lost.
  4. Wave channel has no envelope. CH3 plays at constant volume forever once triggered. You must explicitly silence it (NR32=0 or NR30=0) when done.
  5. Subpattern notes are relative, not absolute. Note 36 in a subpattern = +0 offset from the base note. This is the source of many “wrong frequency” bugs when translating hUGE songs to C.
  6. state_init() runs before On Init script. If your plugin needs to own sound channels, block the ISR in state_init. Don’t rely on the On Init script to stop music first.
  7. Sound registers persist across scene transitions. There is no automatic sound cut. If your plugin writes to channels and the player leaves, those channels keep playing until something else writes to them.
  8. driver_reset_wave() on SFX completion. Reloads wave RAM from hUGE instrument data, corrupting any custom wave. Reload your wave on the next frame if SFX might play during your scene.