-- Pure Game Boy audio synthesis (the DMG/GBC channel-program interpreter and -- PCM renderer), factored out of ChipAudio so it can run on EITHER the main -- thread (SFX/cries, and the synchronous music fallback) or the ChipAudio -- worker thread (src/core/chip_worker.lua), which is where map/battle music is -- synthesized so a song change never stutters the render thread. -- -- Deliberately depends ONLY on `bit`, love.sound and love.filesystem: no -- love.audio (Sources are a playback concern the caller owns) and no -- src.render.Assets (hot-reload registration stays in ChipAudio). Both of -- those are unavailable or main-thread-only inside a love.thread worker, so -- keeping them out is what lets the same synth code run in the worker. local bit = require("bit") local ChipSynth = {} local SAMPLE_RATE = 44100 local TICKS_PER_SECOND = 15360 local FRAME_TICKS = 256 local GB_CLOCK = 4194304 -- one 8192-sample stereo SoundData is the unit both the worker hands off and -- the synchronous fallback queues; the source keeps MUSIC_BUFFER_COUNT of them -- (~6s at 44100) for stall tolerance (window resize, a long GC pause) local MUSIC_BUFFER_SAMPLES = 8192 local MUSIC_BUFFER_COUNT = 32 ChipSynth.SAMPLE_RATE = SAMPLE_RATE ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT -- Gen 2 SOUND option (MONO/STEREO): gates Music_StereoPanning's per-song -- panning byte (audio/engine.asm:1987 wOptions STEREO bit). local stereoEnabled = false function ChipSynth.setStereo(enabled) stereoEnabled = not not enabled end function ChipSynth.getStereo() return stereoEnabled end -- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise). -- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave, -- 0.5 = -1 octave. Applied at sample time so a live change reaches the next -- buffer on both the sync path and the worker (via ChipAudio). local channelVolume = { 1, 1, 1, 1 } local channelPitch = { 1, 1, 1, 1 } local function clampScale(scale) return math.max(0, tonumber(scale) or 0) end local function setChannelTable(table, hw, scale) hw = tonumber(hw) if not hw or hw < 1 or hw > 4 then return end table[hw] = clampScale(scale) end local function setChannelTables(table, values) if type(values) ~= "table" then return end for hw = 1, 4 do if values[hw] ~= nil then table[hw] = clampScale(values[hw]) end end end function ChipSynth.setChannelVolume(hw, scale) setChannelTable(channelVolume, hw, scale) end function ChipSynth.getChannelVolume(hw) return channelVolume[tonumber(hw) or 0] or 1 end function ChipSynth.setChannelVolumes(volumes) setChannelTables(channelVolume, volumes) end function ChipSynth.getChannelVolumes() return { channelVolume[1], channelVolume[2], channelVolume[3], channelVolume[4] } end function ChipSynth.setChannelPitch(hw, scale) setChannelTable(channelPitch, hw, scale) end function ChipSynth.getChannelPitch(hw) return channelPitch[tonumber(hw) or 0] or 1 end function ChipSynth.setChannelPitches(pitches) setChannelTables(channelPitch, pitches) end function ChipSynth.getChannelPitches() return { channelPitch[1], channelPitch[2], channelPitch[3], channelPitch[4] } end -- aliases for the noise/drum layer function ChipSynth.setNoiseVolume(scale) ChipSynth.setChannelVolume(4, scale) end function ChipSynth.getNoiseVolume() return ChipSynth.getChannelVolume(4) end local PITCHES = { 0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23, 0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA, } -- Gen 2 FrequencyTable (audio/notes.asm): index 0 = rest, then C_..B_ twice -- so transpose can walk into the next octave without an octave command. local GEN2_FREQUENCY = { 0x0000, 0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23, 0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA, 0xFC16, 0xFC4E, 0xFC83, 0xFCB5, 0xFCE5, 0xFD11, 0xFD3B, 0xFD63, 0xFD89, 0xFDAC, 0xFDCD, 0xFDED, } -- LuaGB / DMG 8-step duty tables (index 0-3); stored on channels as that index local WAVE_PATTERN_TABLES = { [0] = {0, 0, 0, 0, 0, 0, 0, 1}, [1] = {1, 0, 0, 0, 0, 0, 0, 1}, [2] = {1, 0, 0, 0, 0, 1, 1, 1}, [3] = {0, 1, 1, 1, 1, 1, 1, 0}, } local WAVE_LEVEL = { [0] = 0, [1] = 1, [2] = 0.5, [3] = 0.25 } local NOISE_DIVISORS = { [0] = 8, [1] = 16, [2] = 32, [3] = 48, [4] = 64, [5] = 80, [6] = 96, [7] = 112, } local HPF_CHARGE = 0.999958 ^ (GB_CLOCK / SAMPLE_RATE) local LPF_ALPHA = 0.8 local MIX_SCALE = 0.5 local function snapTicks(ticks) return math.floor((ticks * 1470 + 256) / 512) end local cachedProgramFile local cachedBanks local function loadBanks(data) local audio = data.audio if cachedProgramFile == audio.programFile and cachedBanks then return cachedBanks end local raw, readError -- The chip worker runs in a separate Lua state without the NX overlay; -- ChipAudio hands it the versioned cache prefix explicitly. On the main -- thread the NX overlay (or desktop mountVersion) makes the plain read -- resolve, so no platform branching belongs here. local prefix = audio.programPrefix if prefix and prefix ~= "" then raw, readError = love.filesystem.read(prefix .. audio.programFile) end if not raw then raw, readError = love.filesystem.read(audio.programFile) end if not raw then error("could not read sound programs: " .. tostring(readError)) end local banks = {} for index, bank in ipairs(audio.bankOrder) do local first = (index - 1) * 0x4000 + 1 banks[bank] = raw:sub(first, first + 0x3FFF) end cachedProgramFile, cachedBanks = audio.programFile, banks return banks end -- drop the single-slot bank cache; the worker keeps its own copy of this -- module's state, so ChipAudio.invalidate must reach it via a worker message function ChipSynth.invalidateBanks() cachedProgramFile, cachedBanks = nil, nil end -- test-only: exercise loadBanks without building a full engine function ChipSynth._loadBanksForTest(data) return loadBanks(data) end -- A def-local program (ChipAsm output) is mounted as pseudo-bank 0 next to -- the ROM banks, so the 0x4000-window byte reader and every call/loop -- target work unchanged. The ROM's own cached bank table is never touched -- because bank 0 differs per def, and a blob that carries its own waves and -- drums renders even where programs.bin is unreadable. local function engineBanks(data, chip) if not chip then return loadBanks(data) end local banks = {} local ok, romBanks = pcall(loadBanks, data) if ok then for bank, bytes in pairs(romBanks) do banks[bank] = bytes end end banks[0] = chip.blob return banks end local function romByte(banks, bank, address) local bytes = assert(banks[bank], "uncached audio bank " .. tostring(bank)) local value = bytes:byte(address - 0x4000 + 1) if not value then error(("audio read outside bank %02X:%04X"):format(bank, address)) end return value end local function romWord(banks, bank, address) return romByte(banks, bank, address) + romByte(banks, bank, address + 1) * 0x100 end local function headerChannels(banks, header) local channels = {} local address = header.address local first = romByte(banks, header.bank, address) local count = bit.rshift(bit.band(first, 0xF0), 6) + 1 for _ = 1, count do local descriptor = romByte(banks, header.bank, address) channels[#channels + 1] = { number = bit.band(descriptor, 0x0F) + 1, address = romWord(banks, header.bank, address + 1), } address = address + 3 end return channels end -- Which software channels (CHAN5-8) an sfx occupies: its header carries one -- 3-byte descriptor per channel. Audio2_PlaySound walks exactly this list to -- decide whether a new sfx may start at all (audio/engine_2.asm -- .sfxChannelLoop), so Sound.playMove needs the set to reproduce that gate. -- nil = not knowable here (a file def, or the banks are not readable yet), -- which callers read as "no conflict". function ChipSynth.effectChannels(data, def) if type(def) ~= "table" then return nil end local chip = def.chip local specs = chip and chip.channels if not specs then if not def.address then return nil end local ok, banks = pcall(engineBanks, data, chip) if not ok then return nil end local read ok, read = pcall(headerChannels, banks, def) if not ok then return nil end specs = read end local channels = {} for _, spec in ipairs(specs) do channels[#channels + 1] = spec.number end return channels end local function fadeValue(nibble) if bit.band(nibble, 8) ~= 0 then return -bit.band(nibble, 7) end return nibble end local Channel = {} Channel.__index = Channel function Channel.new(engine, spec, options) options = options or {} local hardware = (spec.number - 1) % 4 + 1 local isSfxChannel = spec.number > 4 -- Default LR tracks match pokegold MonoTracks / StereoTracks ($11/$22/…). local trackBit = bit.lshift(1, hardware - 1) local tracks = bit.bor(bit.lshift(trackBit, 4), trackBit) return setmetatable({ engine = engine, bank = options.bank, address = spec.address, number = spec.number, hardware = hardware, wave = hardware == 3, noise = hardware == 4, sfx = isSfxChannel, executeMusic = not isSfxChannel, allowLoops = options.allowLoops ~= false, frequencyOffset = options.frequencyOffset or 0, frameTicks = options.frameTicks or FRAME_TICKS, speed = 12, noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type) durationModifier = 0, -- Gen 2 fractional-frame carry volume = 12, fade = 0, duty = 2, octave = 4, transposition = 0, -- Gen 2: hi=octaves, lo=pitches pitchOffset = 0, -- Gen 2 pitch_offset (signed word add to freq) noiseKit = 0, noiseSampling = false, -- Gen 2 toggle_noise condition = 0, -- Gen 2 set_condition / sound_jump_if tracks = tracks, -- Gen 2 CHANNEL_TRACKS (NR51 bits for this channel) waveInstrument = 0, waveLevel = 1, perfectPitch = false, vibrato = nil, pendingSlide = nil, sweep = nil, callStack = {}, loopCounts = {}, event = nil, ended = false, phase = 0, noiseLfsr = 0x7FFF, noiseClock = 0, drumTail = nil, timeTicks = 0, }, Channel) end function Channel:byte() local value = romByte(self.engine.banks, self.bank, self.address) self.address = self.address + 1 return value end function Channel:word() local value = romWord(self.engine.banks, self.bank, self.address) self.address = self.address + 2 return value end function Channel:frequency(note, octave) local signed = PITCHES[note + 1] - 0x10000 local register = bit.band( bit.arshift(signed, math.max(0, (octave or self.octave) - 1)), 0x7FF) if self.perfectPitch then register = bit.band(register + 1, 0x7FF) end return bit.band(register + self.frequencyOffset, 0x7FF) end -- pokegold GetFrequency: FrequencyTable[pitch+transpose] with asr while -- CHANNEL_OCTAVE (+ transpose hi) < 7, then optional pitch_offset. function Channel:frequencyGen2(note, octave) local trans = self.transposition or 0 local pitch = note + bit.band(trans, 0x0F) local oct = (octave or self.octave) + bit.rshift(trans, 4) local tableVal = GEN2_FREQUENCY[pitch + 1] or 0 local signed = tableVal - 0x10000 local shifts = 0 while oct < 7 do shifts = shifts + 1 oct = oct + 1 end local register = bit.band(bit.arshift(signed, shifts), 0x7FF) register = bit.band(register + (self.pitchOffset or 0), 0x7FF) return bit.band(register + self.frequencyOffset, 0x7FF) end function Channel:durationTicks(length) local tempo = self.sfx and self.frameTicks or self.engine.tempo local speed = self.sfx and (self.executeMusic and self.speed or 1) or self.speed return length * speed * tempo end -- Gen 2 SetNoteDuration (audio/engine.asm). Two eight-bit multiplies, and -- BOTH of them throw the overflow away -- which is the whole character of the -- routine and the reason it cannot be written as one product: -- -- low = LOW((length + 1) * NoteLength) `ld a, l` after .Multiply -- product = tempo * low + DurationModifier 16-bit, wraps -- frames = HIGH(product) `ld [hl], d`, one byte -- modifier= LOW(product) carries into the next note -- -- Keeping the full product instead is what made a cry run for seconds: a cry -- sets CHANNEL_TEMPO to its length word (up to 576), so tempo * low routinely -- runs past 16 bits and the truncation is load bearing rather than incidental. -- -- NoteLength defaults to 1 and tempo to $100 -- LoadChannel's own defaults -- -- so a channel that never issues note_type or tempo still times correctly. -- After toggle_sfx (executeMusic), fanfares like Sfx_CaughtMon use the -- channel's tempo command, not the SFX frameTicks seed. function Channel:durationTicksGen2(length) local tempo = (self.sfx and not self.executeMusic) and self.frameTicks or self.engine.tempo local low = bit.band((length + 1) * (self.noteLength or 1), 0xFF) local product = bit.band(tempo * low + (self.durationModifier or 0), 0xFFFF) self.durationModifier = bit.band(product, 0xFF) local frames = math.floor(product / 256) return frames * FRAME_TICKS end function Channel:timedEvent(event, ticks) local first = snapTicks(self.timeTicks) self.timeTicks = self.timeTicks + ticks event.duration = ticks / TICKS_PER_SECOND event.samples = snapTicks(self.timeTicks) - first event.sample = 0 event.elapsed = 0 return event end function Channel:pan() local mask = bit.lshift(1, self.hardware - 1) if self.engine.generation == 2 then local tracks = self.tracks or 0xFF return bit.band(bit.rshift(tracks, 4), mask) ~= 0, bit.band(tracks, mask) ~= 0 end return bit.band(bit.rshift(self.engine.pan, 4), mask) ~= 0, bit.band(self.engine.pan, mask) ~= 0 end function Channel:tone(ticks, register, volume, fade) if register >= 0x800 then return self:timedEvent({ silence = true }, ticks) end local duration = ticks / TICKS_PER_SECOND local panLeft, panRight = self:pan() local slide if self.pendingSlide then slide = { target = self.pendingSlide.target, frames = math.max(1, duration * 60 - self.pendingSlide.length), } self.pendingSlide = nil end return self:timedEvent({ register = register, volume = volume == nil and self.volume or volume, fade = fade == nil and self.fade or fade, duty = self.duty, wave = self.wave, waveInstrument = self.waveInstrument, waveLevel = self.waveLevel, vibrato = slide and nil or self.vibrato, slide = slide, sweep = self.sfx and self.hardware == 1 and self.sweep or nil, panLeft = panLeft, panRight = panRight, }, ticks) end function Channel:noiseEvent(ticks, volume, fade, parameter) local panLeft, panRight = self:pan() return self:timedEvent({ noise = true, volume = volume or self.volume, fade = fade or 0, noiseParameter = parameter, panLeft = panLeft, panRight = panRight, }, ticks) end function Channel:drumEvent(ticks, instrument) local panLeft, panRight = self:pan() local drum if self.engine.generation == 2 then drum = self.engine:drumInstrumentGen2(self.noiseKit or 0, instrument) else drum = self.engine:noiseInstrument(instrument) end return self:timedEvent({ noise = true, drum = drum, panLeft = panLeft, panRight = panRight, }, ticks) end function Channel:silenceEvent(ticks) return self:timedEvent({ silence = true }, ticks) end function Channel:nextEvent() if self.engine.generation == 2 then return self:nextEventGen2() end if self.ended then return nil end for _ = 1, 100000 do local commandAddress = self.address local command = self:byte() if (self.executeMusic or not self.sfx) and command < 0xC0 then local note = bit.rshift(command, 4) local length = bit.band(command, 0x0F) + 1 if self.noise then local instrument = note if command >= 0xB0 then instrument = self:byte() end return self:drumEvent(self:durationTicks(length), instrument) end return self:tone(self:durationTicks(length), self:frequency(note)) elseif command >= 0xC0 and command < 0xD0 then local length = bit.band(command, 0x0F) + 1 return self:silenceEvent(self:durationTicks(length)) elseif command >= 0xD0 and command < 0xE0 then self.speed = bit.band(command, 0x0F) if not self.noise then local packed = self:byte() if self.wave then self.waveLevel = WAVE_LEVEL[bit.band(bit.rshift(packed, 4), 3)] self.waveInstrument = bit.band(packed, 0x0F) else self.volume = bit.rshift(packed, 4) self.fade = fadeValue(bit.band(packed, 0x0F)) end end elseif command >= 0xE0 and command <= 0xE7 then self.octave = 8 - bit.band(command, 7) elseif command == 0xE8 then self.perfectPitch = not self.perfectPitch elseif command == 0xE9 then -- Unused command. elseif command == 0xEA then local delay, packed = self:byte(), self:byte() local depth = bit.rshift(packed, 4) if depth == 0 then self.vibrato = nil else self.vibrato = { delay = delay, above = bit.rshift(depth, 1) + bit.band(depth, 1), below = bit.rshift(depth, 1), rate = bit.band(packed, 0x0F), } end elseif command == 0xEB then local length, packed = self:byte(), self:byte() local octave = 8 - bit.rshift(packed, 4) self.pendingSlide = { length = length, target = self:frequency(bit.band(packed, 0x0F), octave), } elseif command == 0xEC then self.duty = bit.band(self:byte(), 3) elseif command == 0xED then local high = self:byte() local low = self:byte() -- a header carrying its own tempo is one of audio/alternate_tempo.asm's -- Music_*AlternateTempo entry points, which re-point channel 1 at a -- stub that sets the tempo and jumps into the normal body -- the body's -- own tempo command never runs there, so ignore it here (#847) if not self.engine.tempoLocked then self.engine.tempo = high * 0x100 + low end elseif command == 0xEE then self.engine.pan = self:byte() elseif command == 0xEF or command == 0xF0 then self:byte() elseif command == 0xF8 then self.executeMusic = not self.executeMusic elseif command == 0xFC then local packed = self:byte() self.duty = { bit.band(bit.rshift(packed, 6), 3), bit.band(bit.rshift(packed, 4), 3), bit.band(bit.rshift(packed, 2), 3), bit.band(packed, 3), } elseif command == 0xFD then self.callStack[#self.callStack + 1] = self.address + 2 self.address = self:word() elseif command == 0xFE then local count, target = self:byte(), self:word() if count == 0 then if self.allowLoops then self.address = target else self.ended = true return nil end else local remaining = self.loopCounts[commandAddress] if remaining == nil then remaining = count end remaining = remaining - 1 if remaining > 0 then self.loopCounts[commandAddress] = remaining self.address = target else self.loopCounts[commandAddress] = nil end end elseif command == 0xFF then local returnAddress = table.remove(self.callStack) if returnAddress then self.address = returnAddress else self.ended = true return nil end elseif self.sfx and command >= 0x20 and command < 0x30 then local length = bit.band(command, 0x0F) + 1 local packed = self:byte() local volume = bit.rshift(packed, 4) local fade = fadeValue(bit.band(packed, 0x0F)) if self.noise then -- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the -- frequency low byte for every channel at or past CHAN5, the noise -- channel included (audio/engine_2.asm Audio2_ApplyFrequencyModifier). -- On CHAN8 that byte is the polynomial counter, so the modifier moves -- the noise pitch; it wraps at 8 bits, the carry landing in the high -- byte that noise does not use for frequency. Dropping it left the -- battle hit sounds at their unmodified pitches, where super effective -- reads as the duller of the two (#826). local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF) return self:noiseEvent( self:durationTicks(length), volume, fade, parameter) end local register = bit.band(self:word() + self.frequencyOffset, 0x7FF) return self:tone(self:durationTicks(length), register, volume, fade) elseif command == 0x10 then local packed = self:byte() self.sweep = { pace = bit.band(bit.rshift(packed, 4), 7), subtract = bit.band(packed, 8) ~= 0, shift = bit.band(packed, 7), } else self.ended = true return nil end end self.ended = true return nil end -- Gen 2 music bytecode (pokegold macros/scripts/audio.asm, FIRST_MUSIC_CMD=$d0). -- Notes share the Gen 1 packing; rest is pitch 0. Call/loop opcodes are -- swapped vs Gen 1 ($fe call, $fd loop) and $fc is sound_jump. function Channel:nextEventGen2() if self.ended then return nil end for _ = 1, 100000 do local commandAddress = self.address local command = self:byte() if command < 0xD0 and self.sfx and not self.executeMusic then -- ParseSFXOrCry. On a channel carrying SOUND_SFX or SOUND_CRY a byte -- under $d0 is not a packed note at all: it is a `square_note` / -- `noise_note` row, and SetNoteDuration is handed the WHOLE byte rather -- than its low nibble. What follows is the volume envelope and then -- the raw frequency register -- two bytes on a tone channel, one on -- noise, where it is the polynomial counter instead. -- -- Parsing these as music notes is what made every Gold cry and sound -- effect wrong: the envelope byte was read as a second note and the -- frequency low byte ($d8 for 1752, say) as a note_type command that -- then ate the next two bytes. local ticks = self:durationTicksGen2(command) local packed = self:byte() local volume = bit.rshift(packed, 4) local fade = fadeValue(bit.band(packed, 0x0F)) if self.noise then local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF) return self:noiseEvent(ticks, volume, fade, parameter) end -- CHANNEL_PITCH_OFFSET is wCryPitch for a cry and the SFX pitch -- modifier otherwise; both land in frequencyOffset. The add is 16-bit -- on hardware and only 11 bits reach the register, so a negative pitch -- stored as its unsigned word still comes out right. local register = bit.band(self:word() + self.frequencyOffset, 0x7FF) return self:tone(ticks, register, volume, fade) elseif command < 0xD0 then local note = bit.rshift(command, 4) local length = bit.band(command, 0x0F) local ticks = self:durationTicksGen2(length) if note == 0 then return self:silenceEvent(ticks) end if self.noise and self.noiseSampling then return self:drumEvent(ticks, note) end if self.noise then return self:silenceEvent(ticks) end return self:tone(ticks, self:frequencyGen2(note)) elseif command >= 0xD0 and command <= 0xD7 then -- octave 8 → $d0 (stored 0); octave 1 → $d7 (stored 7) self.octave = bit.band(command, 7) elseif command == 0xD8 then -- note_type / drum_speed self.noteLength = self:byte() if not self.noise then local packed = self:byte() if self.wave then self.waveLevel = WAVE_LEVEL[bit.band(bit.rshift(packed, 4), 3)] self.waveInstrument = bit.band(packed, 0x0F) else self.volume = bit.rshift(packed, 4) self.fade = fadeValue(bit.band(packed, 0x0F)) end end elseif command == 0xD9 then -- transpose self.transposition = self:byte() elseif command == 0xDA then -- tempo (big-endian) local high, low = self:byte(), self:byte() if not self.engine.tempoLocked then self.engine.tempo = high * 0x100 + low end self.durationModifier = 0 elseif command == 0xDB then -- duty_cycle self.duty = bit.band(self:byte(), 3) elseif command == 0xDC then -- volume_envelope local packed = self:byte() if self.wave then self.waveLevel = WAVE_LEVEL[bit.band(bit.rshift(packed, 4), 3)] self.waveInstrument = bit.band(packed, 0x0F) else self.volume = bit.rshift(packed, 4) self.fade = fadeValue(bit.band(packed, 0x0F)) end elseif command == 0xDD then -- pitch_sweep (SFX; keep for completeness) local packed = self:byte() self.sweep = { pace = bit.band(bit.rshift(packed, 4), 7), subtract = bit.band(packed, 8) ~= 0, shift = bit.band(packed, 7), } elseif command == 0xDE then -- duty_cycle_pattern local packed = self:byte() self.duty = { bit.band(bit.rshift(packed, 6), 3), bit.band(bit.rshift(packed, 4), 3), bit.band(bit.rshift(packed, 2), 3), bit.band(packed, 3), } elseif command == 0xDF then -- toggle_sfx self.executeMusic = not self.executeMusic elseif command == 0xE0 then -- pitch_slide local length, packed = self:byte(), self:byte() local octave = bit.rshift(packed, 4) self.pendingSlide = { length = length, target = self:frequencyGen2(bit.band(packed, 0x0F), octave), } elseif command == 0xE1 then -- vibrato local delay, packed = self:byte(), self:byte() local depth = bit.rshift(packed, 4) if depth == 0 then self.vibrato = nil else self.vibrato = { delay = delay, above = bit.rshift(depth, 1) + bit.band(depth, 1), below = bit.rshift(depth, 1), rate = bit.band(packed, 0x0F), } end elseif command == 0xE2 then -- unknownmusic0xe2 self:byte() elseif command == 0xE3 then -- toggle_noise if self.noiseSampling then self.noiseSampling = false else self.noiseSampling = true self.noiseKit = self:byte() end elseif command == 0xE4 then -- force_stereo_panning local packed = self:byte() local mask = bit.lshift(1, self.hardware - 1) local default = bit.bor(bit.lshift(mask, 4), mask) self.tracks = bit.band(packed, default) elseif command == 0xE5 then -- volume (global master; ignored for mix) self:byte() elseif command == 0xE6 then -- pitch_offset (big-endian) local high, low = self:byte(), self:byte() local value = high * 0x100 + low if value >= 0x8000 then value = value - 0x10000 end self.pitchOffset = value elseif command == 0xE7 or command == 0xE8 then -- unused self:byte() elseif command == 0xE9 then -- tempo_relative local adj = self:byte() if adj >= 0x80 then adj = adj - 0x100 end self.engine.tempo = bit.band(self.engine.tempo + adj, 0xFFFF) elseif command == 0xEA then -- restart_channel self.address = self:word() elseif command == 0xEB then -- new_song (unused in music streams) self:word() elseif command == 0xEC or command == 0xED then -- sfx priority on/off -- no-op for the PCM renderer elseif command == 0xEE then -- unknownmusic0xee self:word() elseif command == 0xEF then -- audio/engine.asm:1987 Music_StereoPanning: apply only when STEREO is on local packed = self:byte() if stereoEnabled then local mask = bit.lshift(1, self.hardware - 1) local default = bit.bor(bit.lshift(mask, 4), mask) self.tracks = bit.band(packed, default) end elseif command == 0xF0 then -- sfx_toggle_noise if self.noiseSampling then self.noiseSampling = false else self.noiseSampling = true self.noiseKit = self:byte() end elseif command >= 0xF1 and command <= 0xF9 then -- music0xf1-f9 / unused: no params elseif command == 0xFA then -- set_condition self.condition = self:byte() elseif command == 0xFB then -- sound_jump_if local want, target = self:byte(), self:word() if self.condition == want then self.address = target end elseif command == 0xFC then -- sound_jump self.address = self:word() elseif command == 0xFD then -- sound_loop (Gen 2; Gen 1 used $fe) local count, target = self:byte(), self:word() if count == 0 then if self.allowLoops then self.address = target else self.ended = true return nil end else local remaining = self.loopCounts[commandAddress] if remaining == nil then remaining = count end remaining = remaining - 1 if remaining > 0 then self.loopCounts[commandAddress] = remaining self.address = target else self.loopCounts[commandAddress] = nil end end elseif command == 0xFE then -- sound_call self.callStack[#self.callStack + 1] = self.address + 2 self.address = self:word() elseif command == 0xFF then -- sound_ret local returnAddress = table.remove(self.callStack) if returnAddress then self.address = returnAddress else self.ended = true return nil end else self.ended = true return nil end end self.ended = true return nil end local function envelopeVolume(volume, fade, elapsed) if fade == 0 then return volume end local steps = math.floor(elapsed / (math.abs(fade) / 64)) if fade > 0 then return math.max(0, volume - steps) end return math.min(15, volume + steps) end local function envelopeRingSamples(volume, fade) if not fade or fade <= 0 or not volume or volume <= 0 then return 0 end return math.floor(volume * (fade / 64) * SAMPLE_RATE + 0.5) end local function extendDrumEnvelope(segments) local last = segments and segments[#segments] if not last then return segments end local ringEnd = last.startSample + envelopeRingSamples(last.volume, last.fade) if ringEnd > last.endSample then last.endSample = ringEnd end return segments end local function drumAudioEnd(drum) local last = drum and drum[#drum] return last and last.endSample or 0 end function Channel:resetNoise() self.noiseLfsr = 0x7FFF self.noiseClock = 0 end function Channel:clockNoise(width7) local feedback = bit.bxor( bit.band(self.noiseLfsr, 1), bit.band(bit.rshift(self.noiseLfsr, 1), 1)) self.noiseLfsr = bit.bor( bit.rshift(self.noiseLfsr, 1), bit.lshift(feedback, 14)) if width7 then self.noiseLfsr = bit.bor( bit.band(self.noiseLfsr, bit.bnot(0x40)), bit.lshift(feedback, 6)) end end function Channel:sampleNoise(parameter) parameter = parameter or 0 local divisor = NOISE_DIVISORS[bit.band(parameter, 7)] local shift = bit.rshift(parameter, 4) if shift < 14 then local pitch = channelPitch[self.hardware] or 1 local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE * pitch local width7 = bit.band(parameter, 8) ~= 0 local remaining = cycles while remaining > 0 do local untilClock = 1 - self.noiseClock local span = math.min(remaining, untilClock) self.noiseClock = self.noiseClock + span remaining = remaining - span if self.noiseClock >= 1 - 1e-12 then self.noiseClock = 0 self:clockNoise(width7) end end end return bit.band(self.noiseLfsr, 1) == 0 and 1 or 0 end local function sweepCalculation(register, sweep) local delta = math.floor(register / (2 ^ sweep.shift)) if sweep.subtract then return register - delta end return register + delta end local function sweptRegister(register, sweep, elapsed) if not sweep or sweep.shift == 0 then return register end local nextRegister = sweepCalculation(register, sweep) if nextRegister > 0x7FF or nextRegister < 0 then return nil end if sweep.pace == 0 then return register end local iterations = math.floor(elapsed * 128 / sweep.pace) for _ = 1, iterations do register = nextRegister nextRegister = sweepCalculation(register, sweep) if nextRegister > 0x7FF or nextRegister < 0 then return nil end end return register end function Channel:sampleDrum(event, sampleIndex) local index = event.drumSegmentIndex or 1 local segment = event.drum[index] while segment and sampleIndex >= segment.endSample do index = index + 1 segment = event.drum[index] end if not segment or sampleIndex < segment.startSample then return 0 end if event.drumSegmentIndex ~= index then event.drumSegmentIndex = index self:resetNoise() end local elapsed = (sampleIndex - segment.startSample) / SAMPLE_RATE local volume = envelopeVolume(segment.volume, segment.fade, elapsed) return self:sampleNoise(segment.parameter) * volume / 15 end function Channel:sample() while not self.ended and (not self.event or self.event.sample >= self.event.samples) do local prev = self.event self.event = self:nextEvent() self.phase = 0 if self.event and self.event.drum then self.drumTail = nil self:resetNoise() elseif prev and prev.drum and prev.sample < drumAudioEnd(prev.drum) then -- ..(audio/engine_1.asm ln 197) self.drumTail = prev elseif not (self.event and self.event.silence and self.drumTail) then self.drumTail = nil self:resetNoise() end end local event = self.event local gain = channelVolume[self.hardware] or 1 if not event then local tail = self.drumTail if not tail then return 0 end local sampleIndex = tail.sample tail.sample = sampleIndex + 1 if sampleIndex >= drumAudioEnd(tail.drum) then self.drumTail = nil return 0 end return self:sampleDrum(tail, sampleIndex) * gain end local sampleIndex = event.sample event.elapsed = sampleIndex / SAMPLE_RATE event.sample = sampleIndex + 1 if event.silence then local tail = self.drumTail if not tail then return 0 end local tailIndex = tail.sample tail.sample = tailIndex + 1 if tailIndex >= drumAudioEnd(tail.drum) then self.drumTail = nil return 0 end return self:sampleDrum(tail, tailIndex) * gain end if event.drum then return self:sampleDrum(event, sampleIndex) * gain end self.drumTail = nil local volume = envelopeVolume( event.volume or 0, event.fade or 0, event.elapsed) if event.noise then return self:sampleNoise(event.noiseParameter) * volume / 15 * gain end local register = event.register local frame = math.floor(event.elapsed * 60) if event.sweep then register = sweptRegister(register, event.sweep, event.elapsed) if not register then return 0 end elseif event.slide then local amount = math.min(1, frame / event.slide.frames) register = register + (event.slide.target - register) * amount elseif event.vibrato and frame >= event.vibrato.delay then local vibrato = event.vibrato local toggles = math.floor( (frame - vibrato.delay + 1) / (vibrato.rate + 1)) if toggles > 0 then local low = bit.band(register, 0xFF) local high = bit.band(register, 0x700) if bit.band(toggles, 1) ~= 0 then register = high + math.min(0xFF, low + vibrato.above) else register = high + math.max(0, low - vibrato.below) end end end local pitch = channelPitch[self.hardware] or 1 local frequency = 131072 / (2048 - math.min(register, 2047)) * pitch if event.wave then frequency = frequency * 0.5 end local phase = self.phase self.phase = (phase + frequency / SAMPLE_RATE) % 1 if event.wave then local wave = self.engine.waves[ math.min(event.waveInstrument + 1, #self.engine.waves)] -- a def-local program may omit its wave table entirely if not wave then return 0 end local index = math.min(32, math.floor(phase * 32) + 1) local nibble = math.max(0, math.min(15, wave[index] * 8 + 8)) return (nibble / 15) * event.waveLevel * gain end local duty = event.duty if type(duty) == "table" then duty = duty[frame % 4 + 1] end local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2] local step = math.floor(phase * 8) % 8 if pattern[step + 1] == 0 then return 0 end return volume / 15 * gain end local Engine = {} Engine.__index = Engine function Engine:noiseInstrument(number) -- a def-local drum wins over the ROM engine's table for that id local custom = self.customDrums and self.customDrums[number] if custom then return extendDrumEnvelope(custom) end local cached = self.noiseInstruments[number] if cached then return cached end local header = self.noiseHeaders[tostring(number)] local segments = {} if header then local spec = headerChannels(self.banks, header)[1] local address = spec and spec.address local ticks = 0 for _ = 1, 64 do local command = romByte(self.banks, header.bank, address) address = address + 1 if command == 0xFF then break end if command < 0x20 or command >= 0x30 then error(("unsupported drum command %02X at %02X:%04X") :format(command, header.bank, address - 1)) end local packed = romByte(self.banks, header.bank, address) local parameter = romByte(self.banks, header.bank, address + 1) address = address + 2 local duration = (bit.band(command, 0x0F) + 1) * FRAME_TICKS segments[#segments + 1] = { startSample = snapTicks(ticks), endSample = snapTicks(ticks + duration), volume = bit.rshift(packed, 4), fade = fadeValue(bit.band(packed, 0x0F)), parameter = parameter, } ticks = ticks + duration end end extendDrumEnvelope(segments) self.noiseInstruments[number] = segments return segments end -- Gen 2 Drumkits → kit pointer → instrument noise_note script (ReadNoiseSample). function Engine:drumInstrumentGen2(kit, pitch) local key = kit * 256 + pitch local cached = self.noiseInstruments[key] if cached then return cached end local segments = {} local spec = self.drumkits if spec and pitch and pitch > 0 then local kitAddr = romWord(self.banks, spec.bank, spec.address + kit * 2) local instrAddr = romWord(self.banks, spec.bank, kitAddr + pitch * 2) local address = instrAddr local ticks = 0 for _ = 1, 64 do local command = romByte(self.banks, spec.bank, address) address = address + 1 if command == 0xFF then break end local packed = romByte(self.banks, spec.bank, address) local parameter = romByte(self.banks, spec.bank, address + 1) address = address + 2 -- ReadNoiseSample: delay = (length & $f) + 1 frames local duration = (bit.band(command, 0x0F) + 1) * FRAME_TICKS segments[#segments + 1] = { startSample = snapTicks(ticks), endSample = snapTicks(ticks + duration), volume = bit.rshift(packed, 4), fade = fadeValue(bit.band(packed, 0x0F)), parameter = parameter, } ticks = ticks + duration end end self.noiseInstruments[key] = segments return segments end local function readWaves(banks, audio, engineNumber) local spec = audio.waveBanks[tostring(engineNumber)] local waves = {} for wave = 0, 4 do local values = {} for byteIndex = 0, 15 do local packed = romByte( banks, spec.bank, spec.address + wave * 16 + byteIndex) values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8 values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8 end waves[#waves + 1] = values end local values = {} for byteIndex = 0, 15 do local packed = romByte( banks, spec.bank, spec.address + 5 * 16 + byteIndex) values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8 values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8 end for _ = 1, 4 do waves[#waves + 1] = values end return waves end -- Gen 2 WaveSamples: 10 patterns × 16 bytes (instruments 0-9). local function readWavesGen2(banks, audio) local spec = audio.waveBanks and audio.waveBanks["1"] if not spec then return {} end local waves = {} for wave = 0, 9 do local values = {} for byteIndex = 0, 15 do local packed = romByte( banks, spec.bank, spec.address + wave * 16 + byteIndex) values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8 values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8 end waves[#waves + 1] = values end return waves end -- def-local waves are authored either as raw 0-15 nibbles (the ROM's own -- units) or as the -1..1 samples readWaves produces; the synth wants the -- latter (LuaGB: (nibble - 8) / 8) local function normalizeWaves(source) local waves = {} for index, values in ipairs(source) do local nibbles = false for _, value in ipairs(values) do if value > 1 or value < -1 then nibbles = true break end end local wave = {} for position, value in ipairs(values) do wave[position] = nibbles and (value - 8) / 8 or value end waves[index] = wave end return waves end function Engine.new(data, header, options) options = options or {} local audio = data.audio or {} -- shape dispatch: a def-local chip program supplies its own channels and -- may supply its own waves/drums, falling back to a ROM engine's tables local chip = header.chip local banks = engineBanks(data, chip) local generation = header.generation or audio.generation or 1 local engineNumber = chip and (chip.engine or 1) or header.engine or 1 local waves if chip and chip.waves then waves = normalizeWaves(chip.waves) elseif generation == 2 then if chip then local ok, romWaves = pcall(readWavesGen2, banks, audio) waves = ok and romWaves or {} else waves = readWavesGen2(banks, audio) end elseif chip then local ok, romWaves = pcall(readWaves, banks, audio, engineNumber) waves = ok and romWaves or {} else waves = readWaves(banks, audio, engineNumber) end local engine = setmetatable({ banks = banks, generation = generation, tempo = 0x100, pan = 0xFF, waves = waves, noiseHeaders = audio.noiseHeaders and audio.noiseHeaders[tostring(engineNumber)] or {}, drumkits = audio.drumkits, customDrums = chip and chip.drums or nil, noiseInstruments = {}, channels = {}, hpfCap = 0, hpfCapLeft = 0, hpfCapRight = 0, lpf = 0, lpfLeft = 0, lpfRight = 0, }, Engine) -- header.tempo: the Music_*AlternateTempo override Music.play stamps onto -- a copy of the song def (audio/alternate_tempo.asm) (#847) if header.tempo then engine.tempo = header.tempo engine.tempoLocked = true end local channels = chip and chip.channels or headerChannels(banks, header) if header.startChannels then local byNumber = {} for _, start in ipairs(header.startChannels) do byNumber[start.number] = start.address end for _, spec in ipairs(channels) do spec.address = byNumber[spec.number] or spec.address end end for _, spec in ipairs(channels) do local frameTicks = options.frameTicks local hardware = (spec.number - 1) % 4 + 1 if hardware == 4 then frameTicks = FRAME_TICKS elseif options.cryLength then -- Gen 1: Audio_SetSfxTempo builds a 9-bit tempo out of $80 plus the -- cry's length BYTE. Gen 2: _PlayCry writes wCryLength -- a full word, -- and its own comment says "Tempo is effectively length" -- straight -- into CHANNEL_TEMPO, with no $80 base. Adding one anyway stretched -- every Gold cry by a third on top of the parse bug above. frameTicks = generation == 2 and options.cryLength or (0x80 + options.cryLength) end engine.channels[#engine.channels + 1] = Channel.new(engine, spec, { bank = chip and 0 or header.bank, sfx = options.sfx, allowLoops = options.allowLoops, frequencyOffset = options.frequencyOffset, frameTicks = frameTicks, }) end return engine end function Engine:finished() for _, channel in ipairs(self.channels) do if not channel.ended or channel.event then return false end end return true end local function analogOut(engine, input, hpfField, lpfField) local cap = engine[hpfField] local hp = input - cap engine[hpfField] = input - hp * HPF_CHARGE local prev = engine[lpfField] local lp = prev + LPF_ALPHA * (hp - prev) engine[lpfField] = lp return math.max(-1, math.min(1, lp * MIX_SCALE)) end function Engine:sample() local value = 0 for _, channel in ipairs(self.channels) do value = value + channel:sample() end return analogOut(self, value, "hpfCap", "lpf") end function Engine:sampleStereo() local left, right = 0, 0 for _, channel in ipairs(self.channels) do local value = channel:sample() local event = channel.event if not event or event.panLeft ~= false then left = left + value end if not event or event.panRight ~= false then right = right + value end end return analogOut(self, left, "hpfCapLeft", "lpfLeft"), analogOut(self, right, "hpfCapRight", "lpfRight") end function Engine:sampleChannel(number) local selected = 0 for _, channel in ipairs(self.channels) do local value = channel:sample() if channel.number == number then selected = value end end return analogOut(self, selected, "hpfCap", "lpf") end -- render `samples` frames into a fresh SoundData (mono or stereo). love.sound -- is available on worker threads, so this is the hand-off unit the worker -- produces and the main thread queues. local function soundData(engine, samples, channels) local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels) for index = 0, samples - 1 do if channels == 2 then local left, right = engine:sampleStereo() result:setSample(index, 1, left) result:setSample(index, 2, right) else result:setSample(index, engine:sample()) end end return result end local function renderEffectData(data, header, options) if not header then return nil end options = options or {} options.sfx = true options.allowLoops = false local engine = Engine.new(data, header, options) local maximum = SAMPLE_RATE * 5 local values = {} local count = 0 while count < maximum and not engine:finished() do count = count + 1 values[count] = engine:sample() end if count < math.floor(SAMPLE_RATE / 100) then return nil end local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 2) for index = 1, count do local value = values[index] result:setSample(index - 1, 1, value) result:setSample(index - 1, 2, value) end return result end ChipSynth.newEngine = Engine.new ChipSynth.soundData = soundData ChipSynth.renderEffectData = renderEffectData return ChipSynth