Skip to content

Guide Audio Authoring

bryanthaboi edited this page Jul 19, 2026 · 1 revision

Audio authoring

Songs, sound effects, and cries are data. Every def is shape-dispatched per record (src/core/Music.lua, src/core/Sound.lua), so one file-backed song, one authored chiptune, and the whole vanilla chip soundtrack coexist in a single dataset — there is no global "audio mode" to flip and no way for one def to brick the rest.

The four registries

Registry Target Keyed by
music Data.audio.songs song id (Music_...)
sfx Data.audio.sfx effect id (Save, Trade_Machine, ...)
cries Data.audio.cries species id
map_songs Data.audio.mapSongs map id → music id

(The v1 audio registry still works for whole-key swaps of Data.audio but is deprecated in favor of these.)

Song def shapes

-- rom chip reference (what the importer writes)
{ address = 0x822E, bank = 0x02 }

-- file-backed: file plays once as the intro, loopFile loops after
{ file = "mods/my_mod/assets/town.ogg",
  loopFile = "mods/my_mod/assets/town_loop.ogg" }

-- authored chip program
ChipAsm.song({ tempo = 140, channels = { { program = { ... } } } })

A def with only file loops that file; a missing loopFile degrades to the intro alone. A def that fails to load is skipped with a logged, mod-attributed error when its cue fires — the rest of the soundtrack plays on.

SFX and cries

mod.content.sfx:register("SFX_MOD_CHIME", { file = mod.assets:path("assets/chime.ogg") })
mod.content.cries:register("MODMON", { file = mod.assets:path("assets/cry.wav") })
mod.content.cries:patch("PIKACHU", { pitch = 200 })   -- retune a vanilla cry

Cry shapes: { file }, a { base = "<species>", pitch?, length? } derivation borrowing another cry's header, a raw { header, pitch, length }, or a chip program via ChipAsm.sfx.

ChipAsm — authentic channel programs

src.audio.ChipAsm (a supported require) assembles note-event Lua tables into the same channel bytecode the ROM's own songs run on:

local ChipAsm = require("src.audio.ChipAsm")

local song = ChipAsm.song({
  tempo = 140,                     -- rides the first channel
  channels = {
    { program = {                  -- channel 1 (squares 1-2, wave 3, noise 4)
        { label = "top" },
        { notetype = { speed = 12, volume = 15, fade = 2 } },
        { octave = 4 },
        { note = "C", len = 4 }, { note = "E", len = 4 },
        { rest = 4 },
        { loop = { count = 0, to = "top" } },
    } },
    { hw = 4, program = {          -- drums live on channel 4
        { drum = 3, len = 8 },
        { loop = { count = 0, to = 1 } },
    } },
  },
})
mod.content.music:register("Music_ModSong", song)

Event vocabulary (one key per event, src/audio/ChipAsm.lua): note (+len 1–16), pitch (explicit 0–11), rest, octave (1–8), notetype (speed, volume, fade; wave channel takes waveLevel/ waveInstrument), drum (channel 4 only), label / call / ret / loop = { count, to } (count 0 = forever), vibrato, slide, duty, dutyPattern, tempo, pan, perfectPitch, squareNote/noiseNote (effect programs), pitchSweep. subroutines = { name = { rows } } on a channel spec serves call. Assembly errors name the channel and event index. ChipAsm.sfx builds effect programs the same way for the sfx registry.

Render without booting the game:

python3 tools/modkit.py bounce Music_ModSong --seconds 30

Retargeting and the hook

mod.content.map_songs:override("PALLET_TOWN", "Music_ModSong")

mod.hooks:wrap("music.select", function(next, song, ctx)
  if ctx.reason == "battle" and ctx.trainerId == "OPP_BROCK" then
    return "Music_ModSong"
  end
  return next(song, ctx)
end)

Every song choice — map themes, battle themes, jingles, scenes — passes through music.select (ctx.reason: "map", "battle", "victory", "once", "direct"; plus mapId, mapSong, onBike, surfing, kind, trainerId). music.started / music.stopped / sound.played announce what actually played.

Worked path: Tutorial 09.

Clone this wiki locally