mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
1358 lines
46 KiB
Lua
1358 lines
46 KiB
Lua
-- 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
|