3 Guide Audio Authoring
bryanthaboi edited this page 2026-10-20 17:01:40 -04:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Audio authoring

First workflow: one file-backed song

This route is for a musician who wants to hear a song in a map before learning the chip-program API. Start with an existing mod scaffold and a WAV or Ogg Vorbis file supported by the project's LÖVE desktop build. The WAV in the finished Tutorial 09 example is the repository's known-good starting sample. The engine passes the path to love.audio.newSource, so use that sample first when diagnosing a format problem; try another file only after the baseline works.

The bundled sample is examples/tutorial-09/finished/assets/town.wav; when copying it into a new mod, place it at mods/my_mod/assets/town.wav.

Keep the complete manifest at mods/my_mod/manifest.json, the entry file at mods/my_mod/main.lua, and the audio at mods/my_mod/assets/; see the manifest reference for the required fields.

From the engine repository root, place the file at mods/my_mod/assets/town.wav and use this complete main.lua:

Example status: runnable complete file; edit the marked id, map, and asset path.

return function(mod)
  mod.content.music:register("Music_ModTown", {
    file = mod.assets:path("assets/town.wav"),
  })
  mod.content.map_songs:override("PALLET_TOWN", "Music_ModTown")
end

Replace PALLET_TOWN with the map id you intend to test. Enable the mod, enter that map, and listen for the new cue. If the old song remains, check the mod is enabled for the selected game and that the map id and file path match; read the manager error feed before changing the song definition. A successful run keeps the game running and plays the replacement cue. If it does not, restore the original map-song entry, disable the mod, and restart before trying one change at a time.

modkit bounce is a separate chip-authoring tool. It renders chip-program songs to WAV; it does not preview a file-backed song or prove that a map cue is wired correctly.

Songs, sound effects, and cries are data. Every def is shape-dispatched per record (src/core/Music.lua, src/core/Sound.lua), so file-backed songs, authored chiptunes, and the vanilla chip soundtrack can coexist in one dataset. There is no global audio mode to select.

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 on Gen 1 and Gen 2 but is deprecated in favor of these; Gen 3 reports it as having no target.)

Song def shapes

Example status: outline; compare the three supported record shapes here.

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

Example status: outline; adapt the ids and asset paths for your mod.

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:

Example status: runnable insertion; place this ChipAsm registration in main.lua.

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

Example status: runnable insertion; place this map/hook code in main.lua.

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.

Gen 2 and Gen 3

The four registries are shared names on every game. Keys are the selected game's own ids, so a portable mod registers its own songs and maps them per game rather than reusing a Gen 1 map id.

Gen 1 Gen 2 (Gold, Silver, Crystal) Gen 3 (FRLG, RSE)
music, sfx, cries, map_songs yes yes, same Data.audio target yes, played by src/core/game3/mod_audio.lua
File-backed defs yes yes yes
ChipAsm defs yes yes yes, through the Game Boy chip engine
audio (v1) deprecated deprecated no target
music.select hook yes yes no Gen 3 call site

On Gen 3 a row keyed by a native name replaces the cart sound: a song id such as MUS_..., an effect such as SE_PIN, or a species id for a cry. map_songs keys are Gen 3 map ids, and a mod song wins over the map header's music. Gen 3 has no vanilla records in these tables to patch, so a cry retune like the PIKACHU example above is Gen 1 and Gen 2 only; use register with a file instead. Check mod:supports("hooks", "music.select") before relying on the hook in an api 3 mod.

Check and recover

The first successful checkpoint is the replacement cue playing in the chosen map while the game remains running. If it does not, restore the original map song entry, disable the mod, and restart with the known-good Tutorial 09 WAV before changing the path or registry id.