diff --git a/main.lua b/main.lua index 03b7abda..b9d3b4a9 100644 --- a/main.lua +++ b/main.lua @@ -521,9 +521,7 @@ function bootGame(version, cartId, opts) local loadOpts = { arena = arena, cartId = cartId, onExit = opts.onExit } if GameVersion.generation() == 3 then Game = require("src.core.Game3").new() - if arena then - Game.returnToLauncher = function(o) pendingLauncherReturn = o or {} end - end + Game.returnToLauncher = function(o) pendingLauncherReturn = o or {} end Game:load(loadOpts) elseif GameVersion.generation() == 2 then Game = require("src.core.Game2").new() diff --git a/src/core/Game3.lua b/src/core/Game3.lua index c747afd1..796ae541 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -227,6 +227,7 @@ function Game3:_handleBootAction(action) return end if action.action == "exit" then + Audio.stopAll() if self.returnToLauncher then self.returnToLauncher() elseif self.onExit then diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua index 1ae28c68..febd7c98 100644 --- a/src/core/game3/audio.lua +++ b/src/core/game3/audio.lua @@ -36,13 +36,9 @@ Audio._bgmQueuedAt = {} Audio._bgmBaseAt = 0 Audio._bgmVolume = 1 Audio._sfxVolume = 1 --- Baked SE sit under BGM: CGB voice scales are conservative for the --- shared mix bus, but doors/UI play as separate sources and need to cut through. --- SE bake is quieter than hardware/mGBA mix under battle BGM; 3.5 keeps --- move hits readable without hard-clipping typical effectiveness/move SE. -Audio._seBakeGain = 3.5 Audio._duck = 1 Audio._duckHold = 0 +Audio._seDuck = 1 Audio._mono = false Audio._warned = {} @@ -190,8 +186,42 @@ function Audio.applyOptions(session) -- volumes reserved for future option fields end -local function bgm_gain() - return (Audio._bgmVolume or 1) * (Audio._duck or 1) * (Audio._helpActive and 0.5 or 1) +local function bgm_gain(volume) + return (volume or Audio._bgmVolume or 1) + * math.min(Audio._duck or 1, Audio._seDuck or 1) + * (Audio._helpActive and 0.5 or 1) +end + +local function apply_bgm_gain() + if not Audio._bgmSource then return end + local gain = bgm_gain() + local f = Audio._fadeOut + if f and (f.gen == nil or f.gen == Audio._bgmGen) + and (f.songId == nil or (Audio._currentSong and f.songId == Audio._currentSong.id)) then + gain = bgm_gain(f.start) * (1 - math.min(1, f.t / math.max(f.dur, 0.01))) + elseif Audio._fadeIn then + f = Audio._fadeIn + gain = gain * math.min(1, f.t / math.max(f.dur, 0.01)) + end + Audio._bgmSource:setVolume(gain) +end + +-- Baked effects cannot steal a voice from already queued music. Until the +-- players share a live mixer, give one-shot effects headroom at playback time. +-- Looping alarms do not hold the music down; cry ducking takes precedence. +local function update_se_duck(startingSource) + local active = false + for _, src in ipairs(Audio._seSources) do + local meta = Audio._seMeta[src] + if meta and meta.duckBgm and (src == startingSource or src:isPlaying()) then + active = true + break + end + end + local target = active and 0.6 or 1 + local old = Audio._seDuck or 1 + Audio._seDuck = target + if target ~= old then apply_bgm_gain() end end -- Help lowers BGM without changing the user's volume or cry ducking state. @@ -328,7 +358,7 @@ function Audio.fadeOutBgm(speed) Audio._fadeOut = { t = 0, dur = seconds, - start = bgm_gain(), + start = Audio._bgmVolume or 1, songId = songId, gen = Audio._bgmGen, } @@ -443,7 +473,7 @@ function Audio.playSe(id, opts) local pan = Audio.normalizePan(opts.pan) local sd = Player.bakeSlot(slot, { - master = (Audio._sfxVolume or 1) * (opts.volume or 1) * (Audio._seBakeGain or 3.5), + master = (Audio._sfxVolume or 1) * (opts.volume or 1), mono = Audio._mono, maxSec = opts.maxSec or ((loop or id == SE.SE_EXP) and 2.5 or 2.0), stopOnGoto = loop and true or false, @@ -455,10 +485,14 @@ function Audio.playSe(id, opts) if loop then pcall(function() src:setLooping(true) end) end - src:play() Audio._seSources[#Audio._seSources + 1] = src Audio._seByPlayer[mplay] = src - Audio._seMeta[src] = { id = id, player = mplay } + Audio._seMeta[src] = { id = id, player = mplay, + duckBgm = not loop and id ~= SE.SE_SELECT + and (Audio._sfxVolume or 1) * (opts.volume or 1) > 0 } + update_se_duck(src) + src:play() + update_se_duck() while #Audio._seSources > 8 do local idx = 1 if Audio._seSources[idx] == Audio._fanfareSource then idx = 2 end @@ -491,6 +525,7 @@ function Audio.normalizePan(pan) end function Audio._songHasGoto(slot) + if slot and slot.info and slot.info.hasGoto ~= nil then return slot.info.hasGoto end local seq = slot and slot.seq if not seq or not seq.tracks then return false end for _, tr in ipairs(seq.tracks) do @@ -509,6 +544,7 @@ function Audio._forgetSeSource(src) Audio._seByPlayer[meta.player] = nil end Audio._seMeta[src] = nil + update_se_duck() end function Audio._stopSePlayer(mplay) @@ -706,17 +742,16 @@ function Audio.playCry(species, mode) rate = Mix.waveRate(meta.freq), }) if src then + -- pokefirered PlayCry_Normal lowers BGM before starting the cry. + Audio._duck = 85 / 256 + Audio._duckHold = 2 + apply_bgm_gain() src:play() Audio._crySource = src local dur = (meta.size or 4000) / Mix.waveRate(meta.freq) - Audio._cryUntil = (Audio._cryClock or 0) + math.max(16, dur * 60) + Audio._cryUntil = (Audio._cryClock or 0) + dur * 60 end end - -- Duck BGM to 85/256 - Audio._duck = 85 / 256 - Audio._duckHold = 2 - if Audio._cmdCh then Audio._cmdCh:push({ cmd = "volume", volume = bgm_gain() }) end - if Audio._bgmSource then Audio._bgmSource:setVolume(bgm_gain()) end return true end @@ -729,7 +764,7 @@ function Audio.isCryFinished() if Audio._cryUntil and (Audio._cryClock or 0) >= Audio._cryUntil then return true end - if Audio._crySource and Audio._crySource:isPlaying() then return false end + if Audio._crySource then return not Audio._crySource:isPlaying() end if not Audio._cryUntil then return true end return (Audio._cryClock or 0) >= Audio._cryUntil end @@ -739,6 +774,8 @@ function Audio.stopCry() Audio._crySource = nil Audio._cryUntil = nil Audio._duck = 1 + Audio._duckHold = 0 + apply_bgm_gain() end function Audio.currentSong() @@ -749,6 +786,7 @@ function Audio.stopAll() stop_bgm_source() Audio.stopSe() Audio.stopCry() + Audio._seDuck = 1 Audio._currentSong = nil Audio._fanfareActive = false end @@ -756,6 +794,7 @@ end function Audio.update(dt) dt = dt or 1 / 60 Audio.tickCry(dt) + update_se_duck() Audio.pumpFanfares() @@ -788,8 +827,7 @@ function Audio.update(dt) Audio._duckHold = Audio._duckHold - dt * 60 elseif Audio._duck and Audio._duck < 1 and Audio.isCryFinished() then Audio._duck = 1 - if Audio._cmdCh then Audio._cmdCh:push({ cmd = "volume", volume = bgm_gain() }) end - if Audio._bgmSource then Audio._bgmSource:setVolume(bgm_gain()) end + apply_bgm_gain() end -- Fade out/in @@ -800,7 +838,7 @@ function Audio.update(dt) local stillSame = (f.gen == nil or f.gen == Audio._bgmGen) and (f.songId == nil or (Audio._currentSong and Audio._currentSong.id == f.songId)) if stillSame and Audio._bgmSource then - local vol = f.start * (1 - u) * (Audio._duck or 1) + local vol = bgm_gain(f.start) * (1 - u) Audio._bgmSource:setVolume(vol) end if u >= 1 then @@ -832,7 +870,7 @@ function Audio.update(dt) if okFree and type(free) == "number" and free > 0 then local n = Player.BUFFER_SAMPLES local sd = Player.renderBuffered(Audio._bgmLocal, n, { - master = bgm_gain(), + master = 1, sampleRate = Mix.SAMPLE_RATE, }) if sd then diff --git a/src/core/game3/m4a_mix.lua b/src/core/game3/m4a_mix.lua index 886537ee..d9d95072 100644 --- a/src/core/game3/m4a_mix.lua +++ b/src/core/game3/m4a_mix.lua @@ -3,11 +3,12 @@ local Mix = {} --- FireRed m4aSoundMode: SOUND_MODE_FREQ_13379 → 224 PCM samples per VBlank. -Mix.GBA_MIX_RATE = 13379 +-- FireRed SampleFreqSet: timer 0 advances every 1254 CPU cycles. +Mix.GBA_CLOCK = 16777216 +Mix.GBA_FRAME_CYCLES = 280896 Mix.GBA_SAMPLES_PER_VBLANK = 224 --- True GBA refresh: 13379/224 ≈ 59.7275 Hz (not 60). -Mix.GBA_VBLANK_HZ = Mix.GBA_MIX_RATE / Mix.GBA_SAMPLES_PER_VBLANK +Mix.GBA_VBLANK_HZ = Mix.GBA_CLOCK / Mix.GBA_FRAME_CYCLES +Mix.GBA_MIX_RATE = Mix.GBA_VBLANK_HZ * Mix.GBA_SAMPLES_PER_VBLANK local function load_chip_synth() -- love.thread workers often cannot resolve package.path requires; mirror chip_worker. @@ -110,10 +111,10 @@ local DS_FREQ = { 3408917802, 3611622603, 3826380858, 4053909305, } --- ChipSynth-style GB capacitor HPF (restores brightness vs DC-coupled bus). +-- Remove DC from the unipolar GBA PSG without the DMG capacitor's bass loss. local GB_CLOCK = 4194304 local function hpf_charge() - return 0.999958 ^ (GB_CLOCK / Mix.SAMPLE_RATE) + return math.exp(-2 * math.pi * 5 / Mix.SAMPLE_RATE) end local function midi_to_hz(key) @@ -138,7 +139,7 @@ local function ds_scale_freq(key) return math.floor((DS_FREQ[s % 16] or 0) / (2 ^ math.floor(s / 16))) end ---- pret MidiKeyToFreq → Q10 sample rate (divide by 1024 for Hz). +--- pret MidiKeyToFreq returns playback Hz; only WaveData.freq is Q10. function Mix.midiKeyToFreq(wavFreq, key, fine) wavFreq = tonumber(wavFreq) or 0 key = math.floor(tonumber(key) or 60) @@ -159,7 +160,7 @@ function Mix.midiKeyToFreq(wavFreq, key, fine) end --- pret MidiKeyToCgbFreq for pulse/wave → 11-bit-ish period register. -function Mix.cgbPeriod(key, fine) +function Mix.cgbPeriod(key, fine, fixed) key = tonumber(key) or 60 fine = math.floor(tonumber(fine) or 0) if fine < 0 then fine = 0 end @@ -178,19 +179,22 @@ function Mix.cgbPeriod(key, fine) local val1 = math.floor((CGB_FREQ[s1 % 16] or 0) / (2 ^ math.floor(s1 / 16))) local s2 = CGB_SCALE[key + 1] or s1 local val2 = math.floor((CGB_FREQ[s2 % 16] or 0) / (2 ^ math.floor(s2 / 16))) - return val1 + math.floor((fine * (val2 - val1)) / 256) + 2048 + local period = val1 + math.floor((fine * (val2 - val1)) / 256) + 2048 + -- CgbSound quantizes FIX tones to the 65536 Hz PWM grid in FireRed's mode. + if fixed then period = math.floor((period + 1) / 2) * 2 % 2048 end + return period end -function Mix.cgbPulseHz(key, fine) - local p = Mix.cgbPeriod(key, fine) +function Mix.cgbPulseHz(key, fine, fixed) + local p = Mix.cgbPeriod(key, fine, fixed) local denom = 2048 - p if denom < 1 then denom = 1 end return 131072 / denom end -- Hardware wave clock is half of pulse → one octave lower for same period. -function Mix.cgbWaveHz(key, fine) - return Mix.cgbPulseHz(key, fine) * 0.5 +function Mix.cgbWaveHz(key, fine, fixed) + return Mix.cgbPulseHz(key, fine, fixed) * 0.5 end function Mix.periodToPulseHz(periodReg) @@ -215,12 +219,8 @@ function Mix.cgbNoisePeriod(key) local nr43 = Mix.cgbNoiseNr43(key) local shift = math.floor(nr43 / 16) % 16 local div = NOISE_DIV[nr43 % 8] or 8 - local hz = 524288 / (div * (2 ^ shift)) - if hz < 1 then hz = 1 end - local period = math.floor(Mix.SAMPLE_RATE / hz + 0.5) - if period < 1 then period = 1 end - if period > 4096 then period = 4096 end - return period + -- mGBA GB audio: divisor is already in 4 MHz clocks, not 524288 Hz units. + return Mix.SAMPLE_RATE * div * (2 ^ shift) / GB_CLOCK end --- Attach MP2K ADSR. CGB uses 0..15 sustain; DS uses 0..255. @@ -243,114 +243,137 @@ function Mix.attachAdsr(voice, tone, isCgb, opts) return voice end ---- One sequencer-tick envelope step (pret SoundMain cadence ≈ per MPlayMain). -function Mix.tickEnvelope(v) - if not v or not v.alive then return end +local function finish_envelope(v, cgb) local a = v.adsr - if not a then - v.env = 1 - return - end - local phase = v.envPhase or "attack" - if phase == "attack" then - if a.isCgb then - if (a.attack or 0) == 0 then - v.envVol = 15 - v.envPhase = "decay" - else - v.envVol = math.min(15, (v.envVol or 0) + 1) - if v.envVol >= 15 then v.envPhase = "decay" end - end - else - if (a.attack or 0) >= 255 then - v.envVol = 255 - v.envPhase = "decay" - else - v.envVol = (v.envVol or 0) + (a.attack or 0) + 1 - if v.envVol >= 255 then - v.envVol = 255 - v.envPhase = "decay" - end - end - end - elseif phase == "decay" then - if a.isCgb then - local sus = a.sustain or 0 - if (a.decay or 0) == 0 then - v.envVol = sus - v.envPhase = "sustain" - else - v.envVol = (v.envVol or 15) - 1 - if v.envVol <= sus then - v.envVol = sus - v.envPhase = "sustain" - end - end - else - local sus = a.sustain or 0 - if (a.decay or 0) == 0 then - v.envVol = sus - v.envPhase = "sustain" - else - v.envVol = math.floor(((v.envVol or 255) * (a.decay or 0)) / 256) - if v.envVol <= sus then - v.envVol = sus - v.envPhase = "sustain" - end - end - end - elseif phase == "sustain" then - -- hold until gate → release - elseif phase == "release" then - if a.isCgb then - if (a.release or 0) == 0 then - v.envVol = 0 - v.alive = false - else - v.envVol = (v.envVol or 0) - 1 - if v.envVol <= 0 then - v.envVol = 0 - v.alive = false - end - end - else - if (a.release or 0) == 0 then - v.envVol = 0 - v.alive = false - else - v.envVol = math.floor(((v.envVol or 0) * (a.release or 0)) / 256) - local peVol = a.pseudoEchoVolume or 0 - if peVol > 0 and (v.envVol or 0) <= peVol then - v.envVol = peVol - v.envPhase = "echo" - elseif (v.envVol or 0) <= 0 then - v.envVol = 0 - v.alive = false - end - end - end - elseif phase == "echo" then - a.pseudoEchoLength = (a.pseudoEchoLength or 0) - 1 - if a.pseudoEchoLength <= 0 then - v.envVol = 0 - v.alive = false - end - end - local maxv = a.isCgb and 15 or 255 - if maxv < 1 then maxv = 1 end - v.env = (v.envVol or 0) / maxv -end - -function Mix.releaseVoice(v) - if not v then return end - if v.adsr then - v.envPhase = "release" - v.gateTicks = nil + local echo = a.pseudoEchoVolume or 0 + if cgb then echo = math.floor(((v.envGoal or 0) * echo + 255) / 256) end + v.envVol = echo + if echo > 0 then + v.envPhase = "echo" else v.alive = false end end +-- pokefirered CgbModVol: PSG has routing bits and a 4-bit envelope, not +-- independent continuous left/right gains. Wave volume is quantized further. +function Mix.cgbVolume(v) + local l = math.floor((v.volL or 0) * 256) + local r = math.floor((v.volR or 0) * 256) + v.routeL, v.routeR = true, true + if r >= l and math.floor(r / 2) >= l then + v.routeL = false + elseif l > r and math.floor(l / 2) >= r then + v.routeR = false + end + v.envGoal = math.min(15, math.floor((l + r) / 16)) + v.sustainGoal = math.floor((v.envGoal * v.adsr.sustain + 15) / 16) +end + +local function cgb_decay(v) + local a = v.adsr + v.envVol = v.envGoal + v.envPhase = "decay" + v.envCounter = a.decay + if a.decay == 0 then + v.envVol = v.sustainGoal + v.envPhase = "sustain" + v.envCounter = 7 + if a.sustain == 0 then finish_envelope(v, true) end + end +end + +local function tick_cgb_envelope(v, extraClock) + local a = v.adsr + Mix.cgbVolume(v) + if not v.envStarted then + v.envStarted = true + if v.released then v.alive = false; return end + v.envCounter = a.attack + if a.attack == 0 then cgb_decay(v) end + elseif v.envPhase == "echo" then + a.pseudoEchoLength = a.pseudoEchoLength - 1 + if a.pseudoEchoLength <= 0 then v.alive = false end + return + elseif v.released and v.envPhase ~= "release" then + v.envPhase = "release" + v.envCounter = a.release + if a.release == 0 then finish_envelope(v, true) end + else + -- CgbSound counts down once per VBlank and twice every fifteenth frame. + if (v.envCounter or 0) == 0 then + if v.envPhase == "attack" then + v.envVol = v.envVol + 1 + v.envCounter = a.attack + if v.envVol >= v.envGoal then cgb_decay(v) end + elseif v.envPhase == "decay" then + v.envVol = v.envVol - 1 + v.envCounter = a.decay + if v.envVol <= v.sustainGoal then + v.envVol = v.sustainGoal + v.envPhase = "sustain" + v.envCounter = 7 + if a.sustain == 0 then finish_envelope(v, true) end + end + elseif v.envPhase == "sustain" then + v.envVol = v.sustainGoal + v.envCounter = 7 + elseif v.envPhase == "release" then + v.envVol = v.envVol - 1 + v.envCounter = a.release + if v.envVol <= 0 then finish_envelope(v, true) end + end + end + end + if v.alive and v.envPhase ~= "echo" then + v.envCounter = math.max(0, (v.envCounter or 0) - 1) + if extraClock then tick_cgb_envelope(v, false) end + end +end + +--- SoundMain envelope step, once per GBA frame regardless of song tempo. +function Mix.tickEnvelope(v, extraCgbClock) + if not v or not v.alive then return end + local a = v.adsr + if not a then v.env = 1; return end + if a.isCgb then + tick_cgb_envelope(v, extraCgbClock) + v.env = (v.envVol or 0) / 15 + return + end + if not v.envStarted then + v.envStarted = true + if v.released then v.alive = false; return end + end + local phase = v.envPhase + if phase == "echo" then + a.pseudoEchoLength = a.pseudoEchoLength - 1 + if a.pseudoEchoLength <= 0 then v.alive = false end + elseif v.released then + v.envPhase = "release" + v.envVol = math.floor(v.envVol * a.release / 256) + if v.envVol <= a.pseudoEchoVolume then finish_envelope(v, false) end + elseif phase == "attack" then + v.envVol = math.min(255, v.envVol + a.attack) + if v.envVol == 255 then v.envPhase = "decay" end + elseif phase == "decay" then + v.envVol = math.floor(v.envVol * a.decay / 256) + if v.envVol <= a.sustain then + v.envVol = a.sustain + v.envPhase = "sustain" + if a.sustain == 0 then finish_envelope(v, false) end + end + end + v.env = v.envVol / 255 +end + +function Mix.releaseVoice(v) + if not v or v.released then return end + v.released = true + v.gateTicks = nil + if not v.adsr then v.alive = false end +end + function Mix.newDsVoice(pcm, meta, opts) opts = opts or {} local rate = opts.rate or Mix.waveRate(meta and meta.freq) @@ -381,16 +404,18 @@ function Mix.waveRate(freqField) end -- Hardware: 4 exclusive CGB channels (pulse1, pulse2, wave, noise). -Mix.MAX_DS_CHANNELS = 12 +Mix.MAX_DS_CHANNELS = 5 function Mix.newCgbPulse(opts) opts = opts or {} local key = opts.key or 60 local fine = opts.fine or 0 - local periodReg = opts.periodReg or Mix.cgbPeriod(key, fine) + local fixed = opts.tone and math.floor((opts.tone.type or 0) / 8) % 2 == 1 + local periodReg = opts.periodReg or Mix.cgbPeriod(key, fine, fixed) local v = { kind = "cgb_pulse", cgbChan = opts.cgbChan or 1, -- 1 or 2 + cgbFixed = fixed, duty = opts.duty or 2, phase = 0, periodReg = periodReg, @@ -422,12 +447,14 @@ function Mix.newCgbWave(opts) local wave = opts.wave or {} local key = opts.key or 60 local fine = opts.fine or 0 + local fixed = opts.tone and math.floor((opts.tone.type or 0) / 8) % 2 == 1 local v = { kind = "cgb_wave", cgbChan = 3, + cgbFixed = fixed, wave = wave, phase = 0, - freq = opts.freq or Mix.cgbWaveHz(key, fine), + freq = opts.freq or Mix.cgbWaveHz(key, fine, fixed), volL = opts.volL or 0.35, volR = opts.volR or 0.35, env = opts.env or 1, @@ -443,6 +470,7 @@ function Mix.newCgbNoise(opts) kind = "cgb_noise", cgbChan = 4, lfsr = 0x7FFF, + shortNoise = opts.tone and (tonumber(opts.tone.wavParam) or 0) % 2 == 1, clock = 0, period = opts.period or Mix.cgbNoisePeriod(opts.key or 60), volL = opts.volL or 0.3, @@ -521,9 +549,10 @@ end local function render_voice(v, n, outL, outR) if not v or not v.alive then return end - local env = v.env - if env == nil then env = 1 end - if env <= 0 then return end + if v.lengthRemaining then + n = math.min(n, math.max(0, math.ceil(v.lengthRemaining))) + v.lengthRemaining = v.lengthRemaining - n + end if v.kind == "ds" then local pcm = v.pcm local size = v.size or #pcm @@ -532,8 +561,10 @@ local function render_voice(v, n, outL, outR) local loopLen = size - loopStart local step = v.step or 1 local pos = v.pos or 0 - local volL = (v.volL or 0.5) * 0.45 - local volR = (v.volR or 0.5) * 0.45 + -- SoundMain: masterVolume = 12, envelope gain = ((12 + 1) * env) >> 4. + local e = math.floor(13 * (v.envVol or 255) / 16) + local volL = math.floor((v.volL or 0.5) * e) / 256 + local volR = math.floor((v.volR or 0.5) * e) / 256 for i = 1, n do if pos >= size then if loop and loopLen > 0 then @@ -543,7 +574,7 @@ local function render_voice(v, n, outL, outR) break end end - local s = s8_lerp(pcm, pos, size, loop, loopStart) * env + local s = s8_lerp(pcm, pos, size, loop, loopStart) outL[i] = outL[i] + s * volL outR[i] = outR[i] + s * volR pos = pos + step @@ -557,12 +588,12 @@ local function render_voice(v, n, outL, outR) if not v.alive then break end local inc = v.freq / Mix.SAMPLE_RATE local s = (v.phase < thresh) and hi or lo - -- Was 0.12 — left CGB SE (doors/select) ~3× under DS; pret CGB is louder. - s = s * env * 0.28 - outL[i] = outL[i] + s * v.volL - outR[i] = outR[i] + s * v.volR + -- SOUNDCNT_L=0x77, PSG ratio=100%: one envelope unit is 16/512. + s = (s + 1) * (v.envVol or 15) / 64 + if v.routeL ~= false then outL[i] = outL[i] + s end + if v.routeR ~= false then outR[i] = outR[i] + s end v.phase = v.phase + inc - if v.phase >= 1 then v.phase = v.phase - 1 end + v.phase = v.phase % 1 end elseif v.kind == "cgb_wave" then local wave = v.wave @@ -570,28 +601,82 @@ local function render_voice(v, n, outL, outR) for i = 1, n do local idx = math.floor(v.phase * 32) % 32 local nibble = wave[idx + 1] or 8 - local s = ((nibble / 7.5) - 1) * 0.25 * env - outL[i] = outL[i] + s * v.volL - outR[i] = outR[i] + s * v.volR + -- gCgb3Vol: mute, 25%, 50%, 75%, 100% (hardware truncates nibbles). + local level = v.envVol or 15 + local sample = 0 + if level >= 14 then sample = nibble + elseif level >= 10 then sample = math.floor(nibble * 3 / 4) + elseif level >= 6 then sample = math.floor(nibble / 2) + elseif level >= 2 then sample = math.floor(nibble / 4) end + local s = sample / 32 + if v.routeL ~= false then outL[i] = outL[i] + s end + if v.routeR ~= false then outR[i] = outR[i] + s end v.phase = v.phase + inc - if v.phase >= 1 then v.phase = v.phase - 1 end + v.phase = v.phase % 1 end elseif v.kind == "cgb_noise" then for i = 1, n do v.clock = v.clock + 1 - if v.clock >= v.period then - v.clock = 0 + while v.clock >= v.period do + v.clock = v.clock - v.period local lo = v.lfsr % 2 local hi = math.floor(v.lfsr / 2) % 2 local bit = (lo == hi) and 0 or 1 v.lfsr = math.floor(v.lfsr / 2) + bit * 0x4000 + if v.shortNoise then + v.lfsr = v.lfsr - (math.floor(v.lfsr / 64) % 2) * 64 + bit * 64 + end end - local s = ((v.lfsr % 2) == 0) and 0.14 or -0.14 - s = s * env - outL[i] = outL[i] + s * v.volL - outR[i] = outR[i] + s * v.volR + local s = ((v.lfsr % 2) == 0) and (v.envVol or 15) / 32 or 0 + if v.routeL ~= false then outL[i] = outL[i] + s end + if v.routeR ~= false then outR[i] = outR[i] + s end end end + if v.lengthRemaining and v.lengthRemaining <= 0 then v.alive = false end +end + +-- SoundMainRAM feeds the sum of both PCM sides, from six and seven frames +-- ago, back into both sides at reverb/512. PSG never enters this buffer. +-- The delay is resampled to the host clock; native s8 wrapping is not modeled. +function Mix.newReverb(level, rate) + local frame = (rate or Mix.SAMPLE_RATE) / Mix.GBA_VBLANK_HZ + return { + level = math.max(0, math.min(127, tonumber(level) or 0)), + shortDelay = frame * 6, + longDelay = frame * 7, + size = math.ceil(frame * 7) + 1, + cursor = 0, left = {}, right = {}, energy = 0, + } +end + +function Mix.reverbActive(state) + return state and state.level > 0 and state.energy > 1e-8 +end + +local function reverb_tap(state, delay) + local at = (state.cursor - delay) % state.size + local i = math.floor(at) + local frac = at - i + local j = (i + 1) % state.size + local a = (state.left[i] or 0) + (state.right[i] or 0) + local b = (state.left[j] or 0) + (state.right[j] or 0) + return a + (b - a) * frac +end + +local function apply_reverb(state, left, right, n) + if not state or state.level <= 0 then return end + local gain = state.level / 512 + for i = 1, n do + local echo = (reverb_tap(state, state.shortDelay) + + reverb_tap(state, state.longDelay)) * gain + local l, r = left[i] + echo, right[i] + echo + local at = state.cursor + local oldL, oldR = state.left[at] or 0, state.right[at] or 0 + state.energy = math.max(0, state.energy + l * l + r * r - oldL * oldL - oldR * oldR) + state.left[at], state.right[at] = l, r + state.cursor = (at + 1) % state.size + left[i], right[i] = l, r + end end function Mix.render(voices, n, opts) @@ -600,14 +685,18 @@ function Mix.render(voices, n, opts) local outL, outR = {}, {} for i = 1, n do outL[i] = 0; outR[i] = 0 end for _, v in ipairs(voices) do - render_voice(v, n, outL, outR) + if v.kind == "ds" then render_voice(v, n, outL, outR) end + end + apply_reverb(opts.reverbState, outL, outR, n) + for _, v in ipairs(voices) do + if v.kind ~= "ds" then render_voice(v, n, outL, outR) end end local alive = {} for _, v in ipairs(voices) do if v.alive then alive[#alive + 1] = v end end - -- Capacitor HPF (ChipSynth model) — restores highs vs a DC-coupled bus. + -- DC blocker; state belongs to the rendered player slot. local master = opts.master or 1 local charge = hpf_charge() local capL = opts.hpfCapL diff --git a/src/core/game3/m4a_player.lua b/src/core/game3/m4a_player.lua index f1bff01f..42e96755 100644 --- a/src/core/game3/m4a_player.lua +++ b/src/core/game3/m4a_player.lua @@ -148,16 +148,11 @@ local function make_voice_from_tone(pack, tone, rawKey, absKey, volL, volR, fine if noteKey < 0 then noteKey = 0 end if noteKey > 178 then noteKey = 178 end - -- Rhythm pan override (if pan has bit 7 set) - if tone.isRhy and tone.pan and tone.pan >= 0x80 and tr then - local rpan = (tone.pan - 0xC0) * 2 -- -64..+62 - local totalPan = ((tr.pan or 0x40) - 0x40) + rpan - if totalPan < -64 then totalPan = -64 elseif totalPan > 63 then totalPan = 63 end - local p = totalPan / 64 - local vol = (tr.volume or 100) / 127 * ((tr.vel or 100) / 127) - volL = vol * (0.5 - p * 0.5) - volR = vol * (0.5 + p * 0.5) + local rhythmPan = 0 + if tone.isRhy and tone.pan and tone.pan >= 0x80 then + rhythmPan = (tone.pan - 0xC0) * 2 end + if tr then volL, volR = Seq.trackVolume(tr, tr.vel, rhythmPan) end local v if kind == 0 and tone.sampleId then @@ -166,9 +161,10 @@ local function make_voice_from_tone(pack, tone, rawKey, absKey, volL, volR, fine if not pcm or not meta then return nil end local fixed = math.floor((tone.type or 0) / 8) % 2 == 1 -- pret MidiKeyToFreq(wav, noteKey, fine) — returns playback Hz. - local rate = fixed and Mix.waveRate(meta.freq) or Mix.midiKeyToFreq(meta.freq, noteKey, fine) - if rate < 100 then rate = Mix.waveRate(meta.freq) end - local loop = (meta.loopStart or 0) > 0 and (meta.loopStart or 0) < (meta.size or 0) + local rate = fixed and Mix.GBA_MIX_RATE or Mix.midiKeyToFreq(meta.freq, noteKey, fine) + -- WaveData.flags is the high byte of cached status; loops may start at zero. + local loop = math.floor((meta.status or 0) / 16384) % 4 ~= 0 + and (meta.loopStart or 0) < (meta.size or 0) v = Mix.newDsVoice(pcm, meta, { rate = rate, loop = loop, @@ -216,6 +212,17 @@ local function make_voice_from_tone(pack, tone, rawKey, absKey, volL, volR, fine end if v then v.noteKey = baseKey + v.rhythmPan = rhythmPan + if v.adsr and tr then + v.adsr.pseudoEchoVolume = tr.pseudoEchoVolume or 0 + v.adsr.pseudoEchoLength = tr.pseudoEchoLength or 0 + end + if v.kind == "ds" then + v.pos = tr and tr.sampleStart or 0 + elseif (tone.length or 0) > 0 then + local maxLength = kind == 3 and 256 or 64 + v.lengthRemaining = (maxLength - tone.length % maxLength) * Mix.SAMPLE_RATE / 256 + end end return v end @@ -230,6 +237,12 @@ function Player.start(pack, cache, slot, songId, opts) slot.voices = {} slot.sampleOnly = false slot.done = false + slot.frameSamplesLeft = 0 + slot.frameSampleCarry = 0 + slot.hpfState = { l = 0, r = 0 } + local reverb = info and info.reverb or 0 + if reverb >= 128 then slot.reverb = reverb % 128 end + slot.reverbState = Mix.newReverb(slot.reverb or 0) -- Prefer sequencer whenever track data exists. sampleOnly is only for -- explicit one-shots (cries) — SE like SE_SELECT are CGB sequences, not voice0 PCM. @@ -271,6 +284,12 @@ function Player.start(pack, cache, slot, songId, opts) absKey = rawKey + keyM end local tone = resolve_tone(pack, vgId, voiceId, rawKey) + if tone and tr.toneOverrides then + local copy = {} + for k, value in pairs(tone) do copy[k] = value end + for k, value in pairs(tr.toneOverrides) do copy[k] = value end + tone = copy + end return make_voice_from_tone(pack, tone, rawKey, absKey, volL, volR, fine, tr) end, }) @@ -321,6 +340,7 @@ end function Player.renderSlot(slot, n, opts) opts = opts or {} + opts.reverbState = opts.reverbState or (slot and slot.reverbState) local voices = slot and slot.voices or {} if opts.raw then local outL, outR, alive = Mix.render(voices, n, opts) @@ -339,20 +359,33 @@ function Player.renderBuffered(slot, n, opts) opts = opts or {} n = math.floor(tonumber(n) or Player.BUFFER_SAMPLES or 8192) if n < 1 then n = 1 end - local quantum = opts.quantum or Player.mixQuantum() local rate = opts.sampleRate or Mix.SAMPLE_RATE local master = opts.master or 1 local L, R = {}, {} local produced = 0 while produced < n do - local chunk = math.min(quantum, n - produced) - Player.updateSlot(slot, Mix.vblanksForSamples(chunk)) + if (slot.frameSamplesLeft or 0) <= 0 then + Player.updateSlot(slot, 1) + local exact = rate / Mix.GBA_VBLANK_HZ + (slot.frameSampleCarry or 0) + slot.frameSamplesLeft = math.floor(exact) + slot.frameSampleCarry = exact - slot.frameSamplesLeft + end + local chunk = math.min(slot.frameSamplesLeft, n - produced) + slot.hpfState = slot.hpfState or { l = 0, r = 0 } local outL, outR, alive = Mix.render(slot.voices or {}, chunk, { raw = true, master = master, sampleRate = rate, + hpfCapL = slot.hpfState.l, + hpfCapR = slot.hpfState.r, + hpfState = slot.hpfState, + reverbState = slot.reverbState, }) - if slot then slot.voices = alive or slot.voices end + if slot then + slot.voices = alive or slot.voices + if slot.seq then slot.seq.voices = slot.voices end + slot.frameSamplesLeft = slot.frameSamplesLeft - chunk + end for i = 1, chunk do L[#L + 1] = outL[i] or 0 R[#R + 1] = outR[i] or 0 @@ -388,7 +421,8 @@ end function Player.snapshotSlot(slot, at) if not (slot and slot.seq) then return nil end - return { at = at, done = slot.done, seq = Seq.snapshot(slot.seq) } + return { at = at, done = slot.done, seq = Seq.snapshot(slot.seq), + frameSamplesLeft = slot.frameSamplesLeft, frameSampleCarry = slot.frameSampleCarry } end -- pokefirered/src/m4a.c:668 @@ -406,11 +440,13 @@ function Player.stopAt(slot, snaps, at, abs) slot.seq.voices = {} slot.voices = {} slot.done = snap.done + slot.frameSamplesLeft = snap.frameSamplesLeft or 0 + slot.frameSampleCarry = snap.frameSampleCarry or 0 local left = at - snap.at local q = Player.mixQuantum() while left > 0 do local n = math.min(q, left) - Player.updateSlot(slot, Mix.vblanksForSamples(n)) + Player.renderBuffered(slot, n, { raw = true }) left = left - n end for i = #snaps, 1, -1 do @@ -421,6 +457,8 @@ function Player.stopAt(slot, snaps, at, abs) -- pokefirered/src/m4a_1.s:1469 if slot.seq then slot.seq.voices = {} end slot.voices = {} + slot.hpfState = { l = 0, r = 0 } + slot.reverbState = Mix.newReverb(slot.reverb or 0) return abs end @@ -431,21 +469,16 @@ function Player.bakeSong(pack, cache, songId, opts) local rate = opts.sampleRate or Mix.SAMPLE_RATE local quantum = Player.mixQuantum() local maxN = math.floor(rate * (opts.maxSec or 8)) - local myL, myR = 0, 0 local L, R = {}, {} local total, idle, since = 0, 0, 0 while total < maxN do local n = math.min(quantum, maxN - total) - local capL, capR = Mix._hpfCapL, Mix._hpfCapR - Mix._hpfCapL, Mix._hpfCapR = myL, myR local outL, outR = Player.renderBuffered(slot, n, { raw = true, master = opts.master or 1, sampleRate = rate, quantum = quantum, }) - myL, myR = Mix._hpfCapL, Mix._hpfCapR - Mix._hpfCapL, Mix._hpfCapR = capL, capR for i = 1, n do L[#L + 1] = outL[i] or 0 R[#R + 1] = outR[i] or 0 @@ -460,7 +493,7 @@ function Player.bakeSong(pack, cache, songId, opts) for _, v in ipairs(slot.voices or {}) do if v.alive then any = true; break end end - if slot.done and not any then + if slot.done and not any and not Mix.reverbActive(slot.reverbState) then idle = idle + 1 if idle >= 2 then break end else @@ -493,8 +526,6 @@ function Player.bakeSlot(slot, opts) local L, R = {}, {} local total = 0 local idle = 0 - -- Fresh HPF state per bake so SE one-shots aren't coloured by BGM capacitors. - Mix._hpfCapL, Mix._hpfCapR = 0, 0 local stopOnGoto = opts.stopOnGoto == true local sawGoto = false while total < maxN do @@ -503,19 +534,17 @@ function Player.bakeSlot(slot, opts) if stopOnGoto and slot.seq and slot.seq.tracks then pcs = {} for i, tr in ipairs(slot.seq.tracks) do - pcs[i] = tr.pc or 0 + pcs[i] = tr.loopCount or 0 end end - Player.updateSlot(slot, Mix.vblanksForSamples(n)) + local outL, outR = Player.renderBuffered(slot, n, { raw = true, sampleRate = rate }) if pcs then for i, tr in ipairs(slot.seq.tracks) do - if (tr.pc or 0) < (pcs[i] or 0) then + if (tr.loopCount or 0) > (pcs[i] or 0) then sawGoto = true end end end - local outL, outR, alive = Mix.render(slot.voices or {}, n, { raw = true }) - slot.voices = alive or slot.voices for i = 1, n do L[#L + 1] = outL[i] or 0 R[#R + 1] = outR[i] or 0 @@ -528,7 +557,7 @@ function Player.bakeSlot(slot, opts) for _, v in ipairs(slot.voices or {}) do if v.alive then any = true; break end end - if slot.done and not any then + if slot.done and not any and not Mix.reverbActive(slot.reverbState) then idle = idle + 1 if idle >= 2 then break end else diff --git a/src/core/game3/m4a_seq.lua b/src/core/game3/m4a_seq.lua index 3a494948..779b57b7 100644 --- a/src/core/game3/m4a_seq.lua +++ b/src/core/game3/m4a_seq.lua @@ -43,6 +43,7 @@ function Seq.parseSongBin(blob) reverb = u8(blob, 3), voicegroup = u32le(blob, 4), trackData = {}, + trackEntries = {}, } if tracks == 0 then return out end @@ -50,12 +51,15 @@ function Seq.parseSongBin(blob) local packed = first > 0 and first < #blob and first < 0x01000000 if packed then + local stride = (out.blocks or 0) >= 128 and 12 or 8 + out.blocks = (out.blocks or 0) % 128 for t = 0, tracks - 1 do - local base = 8 + t * 8 + local base = 8 + t * stride local off = u32le(blob, base) or 0 local len = u32le(blob, base + 4) or 0 if off > 0 and len > 0 and off + len <= #blob then out.trackData[t + 1] = blob:sub(off + 1, off + len) + out.trackEntries[t + 1] = stride == 12 and (u32le(blob, base + 8) or 0) or 0 else out.trackData[t + 1] = "" end @@ -133,28 +137,33 @@ function Seq.trackPitch(tr) return math.floor(x / 256), (x % 256) end -local function calc_track_vol(tr, vel) +-- pokefirered TrkVolPitSet + ChnVolSetAsm, retaining the integer truncation +-- and the separate rhythm pan multiplier. Gains are channel bytes / 256. +function Seq.trackVolume(tr, vel, rhythmPan) vel = tonumber(vel) or tr.vel or 100 - local vol = (tr.volume or 100) / 127 * (vel / 127) - if (tr.modT or 0) == 1 and (tr.mod or 0) > 0 then - local modVol = math.max(0, math.min(255, (tr.modM or 0) + 128)) / 128 - vol = vol * modVol + local x = math.floor((tr.volume or 100) * 64 / 32) + if (tr.modT or 0) == 1 then + x = math.floor(x * ((tr.modM or 0) + 128) / 128) end - local pan = ((tr.pan or 0x40) - 0x40) / 64 - if (tr.modT or 0) == 2 and (tr.mod or 0) > 0 then - pan = pan + (tr.modM or 0) / 128 - end - if pan < -1 then pan = -1 elseif pan > 1 then pan = 1 end - local volL = vol * (0.5 - pan * 0.5) - local volR = vol * (0.5 + pan * 0.5) - return volL, volR + local pan = 2 * ((tr.pan or 0x40) - 0x40) + if (tr.modT or 0) == 2 then pan = pan + (tr.modM or 0) end + pan = math.max(-128, math.min(127, pan)) + local ml = math.floor((127 - pan) * x / 256) % 256 + local mr = math.floor((128 + pan) * x / 256) % 256 + local rp = rhythmPan or 0 + local l = math.min(255, math.floor((127 - rp) * vel * ml / 16384)) + local r = math.min(255, math.floor((128 + rp) * vel * mr / 16384)) + return l / 256, r / 256 end +local calc_track_vol = Seq.trackVolume + function Seq.newPlayer(song, opts) opts = opts or {} local tracks = {} for i = 1, (song and song.tracks) or 0 do local tr = new_track(song.trackData[i]) + tr.pc = song.trackEntries and song.trackEntries[i] or 0 tr.index = i tracks[i] = tr end @@ -163,6 +172,8 @@ function Seq.newPlayer(song, opts) tracks = tracks, tempo = 150, -- tempoD tempoC = 0, + frameC = 0, + c15 = 0, voices = {}, voiceResolver = opts.voiceResolver, muted = false, @@ -191,7 +202,7 @@ local function track_read32(tr) return b0 + b1 * 256 + b2 * 65536 + b3 * 16777216 end -local MAX_DS = 12 +local MAX_DS = Mix.MAX_DS_CHANNELS --- pret MPlayMain: after BEND/VOL/PAN/MOD, TrkVolPitSet + rewrite active channel freqs. local function refresh_track_voices(player, tr) @@ -201,7 +212,7 @@ local function refresh_track_voices(player, tr) if v.track == tr and v.alive ~= false then if tr._volDirty then local nvel = v.noteVel or tr.vel or 127 - local volL, volR = calc_track_vol(tr, nvel) + local volL, volR = calc_track_vol(tr, nvel, v.rhythmPan) v.volL = volL v.volR = volR end @@ -212,17 +223,15 @@ local function refresh_track_voices(player, tr) if absKey > 178 then absKey = 178 end if v.kind == "ds" and v.wavFreq then local rate = Mix.midiKeyToFreq(v.wavFreq, absKey, fine) - if rate >= 100 then - v.step = rate / Mix.SAMPLE_RATE - end + v.step = rate / Mix.SAMPLE_RATE elseif v.kind == "cgb_pulse" then - local period = Mix.cgbPeriod(absKey, fine) + local period = Mix.cgbPeriod(absKey, fine, v.cgbFixed) v.periodReg = period if not v.sweepEnabled then v.freq = Mix.periodToPulseHz(period) end elseif v.kind == "cgb_wave" then - v.freq = Mix.cgbWaveHz(absKey, fine) + v.freq = Mix.cgbWaveHz(absKey, fine, v.cgbFixed) elseif v.kind == "cgb_noise" then v.period = Mix.cgbNoisePeriod(absKey) end @@ -235,20 +244,19 @@ end local function start_note(player, tr, key, vel, gate) vel = vel or tr.vel or 100 - local volL, volR = calc_track_vol(tr, vel) if not player.voiceResolver then return end + if (tr.lfoDelay or 0) > 0 then + tr.lfoDelayC = tr.lfoDelay + tr.modM = 0 + tr._pitchDirty, tr._volDirty = true, true + end + local volL, volR = calc_track_vol(tr, vel) local rawKey = tonumber(key) or 60 local keyM, fine = Seq.trackPitch(tr) local absKey = rawKey + keyM if absKey < 0 then absKey = 0 end if absKey > 178 then absKey = 178 end - -- Reset LFO delay on new note per pret ply_note - if (tr.lfoDelay or 0) > 0 then - tr.lfoDelayC = tr.lfoDelay - tr.modM = 0 - end - -- Resolver gets standard args with absKey appended; voice stores base note key for live BEND. local voice = player.voiceResolver(tr.voice, rawKey, vel, tr, volL, volR, fine, absKey) if voice then @@ -263,6 +271,8 @@ local function start_note(player, tr, key, vel, gate) voice.noteKey = rawKey end voice.noteVel = vel + voice.midiKey = rawKey + voice.priority = math.min(255, (player.song.priority or 0) + (tr.priority or 0)) -- CGB: 4 physical hardware channels (1..4). -- pret MP2K (m4a_1.s lines 1648-1668): exactly one voice per CGB channel. @@ -277,9 +287,9 @@ local function start_note(player, tr, key, vel, gate) end end if active then - local oldPrio = (active.track and active.track.priority) or 0 - local newPrio = tr.priority or 0 - if newPrio > oldPrio then + local oldPrio = active.priority or 0 + local newPrio = voice.priority + if active.released or newPrio > oldPrio then active.alive = false elseif newPrio < oldPrio then return @@ -294,16 +304,30 @@ local function start_note(player, tr, key, vel, gate) end end elseif voice.kind == "ds" then - -- DirectSound: pret maxChans (FireRed typically ≤12). Steal oldest. - local ds = {} + -- ply_note prefers a released voice, then the lowest priority and latest + -- track. A lower-priority note cannot evict a higher-priority active voice. + local count, victim = 0, nil for _, v in ipairs(player.voices) do if v.alive ~= false and v.kind == "ds" then - ds[#ds + 1] = v + count = count + 1 + local eligible = v.released or (v.priority or 0) < voice.priority + or ((v.priority or 0) == voice.priority + and ((v.track and v.track.index) or 0) >= tr.index) + if eligible then + local vp, bp = v.priority or 0, victim and victim.priority or 0 + local vi = (v.track and v.track.index) or 0 + local bi = victim and victim.track and victim.track.index or 0 + if not victim or (v.released and not victim.released) + or ((not not v.released) == (not not victim.released) + and (vp < bp or (vp == bp and vi >= bi))) then + victim = v + end + end end end - while #ds >= MAX_DS do - local victim = table.remove(ds, 1) - if victim then victim.alive = false end + if count >= MAX_DS then + if not victim then return end + victim.alive = false end end @@ -347,23 +371,40 @@ end local function exec_cmd(player, tr, cmd) if cmd == 0xB1 then tr.done = true + for _, v in ipairs(player.voices) do + if v.track == tr then Mix.releaseVoice(v) end + end elseif cmd == 0xB2 then local addr = track_read32(tr) - tr.pc = (addr < #tr.data) and addr or 0 + if addr < #tr.data then + if addr < tr.pc then tr.loopCount = (tr.loopCount or 0) + 1 end + tr.pc = addr + else + exec_cmd(player, tr, 0xB1) + end elseif cmd == 0xB3 then local addr = track_read32(tr) - tr.callStack[#tr.callStack + 1] = tr.pc - if addr < #tr.data then tr.pc = addr end + if addr < #tr.data and #tr.callStack < 3 then + tr.callStack[#tr.callStack + 1] = tr.pc + tr.pc = addr + else + exec_cmd(player, tr, 0xB1) + end elseif cmd == 0xB4 then if #tr.callStack > 0 then tr.pc = table.remove(tr.callStack) end elseif cmd == 0xB5 then - track_read(tr) + local count = track_read(tr) or 0 local addr = track_read32(tr) - tr.callStack[#tr.callStack + 1] = tr.pc - if addr < #tr.data then tr.pc = addr end + tr.repN = (tr.repN or 0) + 1 + if count == 0 or tr.repN < count then + if addr < #tr.data then tr.pc = addr end + else + tr.repN = 0 + end + elseif cmd == 0xBA then + tr.priority = track_read(tr) or 0 elseif cmd == 0xBB then local t = track_read(tr) or 75 - if t < 1 then t = 75 end player.tempo = t * 2 elseif cmd == 0xBC then tr.keyShift = track_read(tr) or 0 @@ -371,6 +412,7 @@ local function exec_cmd(player, tr, cmd) tr._pitchDirty = true elseif cmd == 0xBD then tr.voice = track_read(tr) or 0 + tr.toneOverrides = nil elseif cmd == 0xBE then tr.volume = track_read(tr) or 100 tr._volDirty = true @@ -410,40 +452,75 @@ local function exec_cmd(player, tr, cmd) tr.tune = (track_read(tr) or 0x40) - 0x40 tr._pitchDirty = true elseif cmd == 0xCD then - -- Extended commands: CD . Arity from pret gXcmdTable. local xop = track_read(tr) or 0 - local xargs = ({ - [0] = 0, -- xxx - [1] = 4, -- xwave (pointer) - [2] = 1, -- xtype - [3] = 0, -- unused - [4] = 1, -- xatta - [5] = 1, -- xdeca - [6] = 1, -- xsust - [7] = 1, -- xrele - [8] = 1, -- xiecv - [9] = 1, -- xiecl - [10] = 1, -- xleng - [11] = 1, -- xswee - [12] = 2, -- xwait (u16) - [13] = 4, -- xcmd_0D - })[xop] or 1 - for _ = 1, xargs do track_read(tr) end - elseif cmd == 0xCE then -- endtie: release this track's notes only - for _, v in ipairs(player.voices) do - if v.track == tr then + local fields = { [2] = "type", [4] = "attack", [5] = "decay", + [6] = "sustain", [7] = "release", [10] = "length", [11] = "pan" } + if fields[xop] then + tr.toneOverrides = tr.toneOverrides or {} + tr.toneOverrides[fields[xop]] = track_read(tr) or 0 + elseif xop == 8 then + tr.pseudoEchoVolume = track_read(tr) or 0 + elseif xop == 9 then + tr.pseudoEchoLength = track_read(tr) or 0 + elseif xop == 12 then + local low = track_read(tr) or 0 + local high = track_read(tr) or 0 + tr.wait = low + high * 256 + elseif xop == 13 then + tr.sampleStart = track_read32(tr) + elseif xop == 1 then + -- Raw ROM pointer is not represented by the existing sample cache. + track_read32(tr) + else + exec_cmd(player, tr, 0xB1) + end + elseif cmd == 0xCE then + local key = tr.data:byte(tr.pc + 1) + if key and key < 0x80 then + tr.pc = tr.pc + 1 + tr.key = key + else + key = tr.key + end + -- ply_endtie releases the newest matching MIDI key, not the whole track. + for i = #player.voices, 1, -1 do + local v = player.voices[i] + if v.track == tr and v.midiKey == key and v.alive and not v.released then Mix.releaseVoice(v) + break end end elseif cmd >= 0xCF then ply_note(player, tr, cmd) elseif cmd >= 0x80 and cmd <= 0xB0 then tr.wait = clock_at(cmd - 0x80) - elseif cmd == 0xBA or cmd == 0xC6 or cmd == 0xC7 - or cmd == 0xC9 or cmd == 0xCA or cmd == 0xCB or cmd == 0xCC - or cmd == 0xB9 then - -- PRIO/… (1 arg); runningStatus already set if >= 0xBD + elseif cmd == 0xCC then -- PORT register offset and value track_read(tr) + track_read(tr) + elseif cmd == 0xB9 then + local op = track_read(tr) or 0 + local addr = track_read(tr) or 0 + local data = track_read(tr) or 0 + player.mem = player.mem or {} + local value = player.mem[addr] or 0 + local operand = op >= 3 and op <= 5 or op >= 12 + operand = operand and (player.mem[data] or 0) or data + if op <= 5 then + local action = op % 3 + if action == 0 then value = operand + elseif action == 1 then value = value + operand + else value = value - operand end + player.mem[addr] = value % 256 + elseif op <= 17 then + local target = track_read32(tr) + local cond = (op - 6) % 6 + local take = (cond == 0 and value == operand) or (cond == 1 and value ~= operand) + or (cond == 2 and value > operand) or (cond == 3 and value >= operand) + or (cond == 4 and value <= operand) or (cond == 5 and value < operand) + if take and target < #tr.data then tr.pc = target end + end + else + exec_cmd(player, tr, 0xB1) end end @@ -490,15 +567,15 @@ local function tick_track(player, tr) end local function seq_tick(player) - -- Age gates / envelopes at the start of the vblank (then new notes are added after). - for _, v in ipairs(player.voices) do - if v.gateTicks then - v.gateTicks = v.gateTicks - 1 - if v.gateTicks <= 0 then - Mix.releaseVoice(v) + -- MPlayMain ages each track's gates immediately before its commands. + for _, tr in ipairs(player.tracks) do + for _, v in ipairs(player.voices) do + if v.track == tr and v.gateTicks then + v.gateTicks = v.gateTicks - 1 + if v.gateTicks <= 0 then Mix.releaseVoice(v) end end end - Mix.tickEnvelope(v) + tick_track(player, tr) end -- Tick LFO modulation per active track @@ -530,39 +607,32 @@ local function seq_tick(player) end end - for _, tr in ipairs(player.tracks) do - tick_track(player, tr) - end - for _, tr in ipairs(player.tracks) do if tr._pitchDirty or tr._volDirty then refresh_track_voices(player, tr) end end - -- New notes get one envelope step so attack=instant voices are audible this frame. - for _, v in ipairs(player.voices) do - if v.adsr and v.envPhase == "attack" and (v.envVol or 0) == 0 then - Mix.tickEnvelope(v) - end - end end ---- Advance by GBA VBlank units (one MPlayMain ≈ one vblank at 13379 Hz / 224 spv). +--- Advance complete SoundMain frames. Tempo only governs MPlayMain ticks. function Seq.update(player, vblanks) vblanks = tonumber(vblanks) or 1 if not player or player.muted then return player and player.voices or {} end if vblanks <= 0 then return player.voices end - - local tempoI = player.tempo or 150 - player.tempoC = (player.tempoC or 0) + tempoI * vblanks - local guard = 0 - while player.tempoC >= 150 and guard < 64 do - guard = guard + 1 - player.tempoC = player.tempoC - 150 - seq_tick(player) + player.frameC = (player.frameC or 0) + vblanks + while player.frameC >= 1 do + player.frameC = player.frameC - 1 + player.tempoC = (player.tempoC or 0) + (player.tempo or 150) + while player.tempoC >= 150 do + player.tempoC = player.tempoC - 150 + seq_tick(player) + end + player.c15 = (player.c15 == 0) and 14 or player.c15 - 1 + for _, v in ipairs(player.voices) do + Mix.tickEnvelope(v, player.c15 == 0) + end end - local alive = {} for _, v in ipairs(player.voices) do if v.alive ~= false then alive[#alive + 1] = v end @@ -581,13 +651,19 @@ function Seq.snapshot(player) local cs = {} for j, pc in ipairs(v) do cs[j] = pc end t.callStack = cs + elseif k == "toneOverrides" then + t.toneOverrides = {} + for field, value in pairs(v) do t.toneOverrides[field] = value end elseif type(v) ~= "table" then t[k] = v end end tracks[i] = t end - return { tracks = tracks, tempo = player.tempo, tempoC = player.tempoC } + local mem = {} + for k, v in pairs(player.mem or {}) do mem[k] = v end + return { tracks = tracks, tempo = player.tempo, tempoC = player.tempoC, + frameC = player.frameC, c15 = player.c15, mem = mem } end function Seq.restore(player, snap) @@ -604,10 +680,19 @@ function Seq.restore(player, snap) local cs = {} for j, pc in ipairs(t.callStack or {}) do cs[j] = pc end tr.callStack = cs + tr.toneOverrides = nil + if t.toneOverrides then + tr.toneOverrides = {} + for k, v in pairs(t.toneOverrides) do tr.toneOverrides[k] = v end + end end end player.tempo = snap.tempo player.tempoC = snap.tempoC + player.frameC = snap.frameC or 0 + player.c15 = snap.c15 or 0 + player.mem = {} + for k, v in pairs(snap.mem or {}) do player.mem[k] = v end end function Seq.allDone(player) diff --git a/src/core/game3/m4a_worker.lua b/src/core/game3/m4a_worker.lua index 0bf5c1f0..6a66eb69 100644 --- a/src/core/game3/m4a_worker.lua +++ b/src/core/game3/m4a_worker.lua @@ -90,7 +90,8 @@ local function apply_cmd(msg) priority = tonumber(msg.id) elseif msg.cmd == "play" then if not pack then return end - bgm = { voices = {}, seq = nil, songId = msg.id, muted = false, volume = bgm.volume or 1, abs = 0, epoch = msg.epoch } + bgm = { voices = {}, seq = nil, songId = msg.id, muted = false, + volume = bgm.volume or 1, reverb = bgm.reverb, abs = 0, epoch = msg.epoch } snaps = {} Player.start(pack, cache, bgm, msg.id, { forceSeq = true }) elseif msg.cmd == "stop" then @@ -160,14 +161,14 @@ while running do if #snaps > SNAP_KEEP then table.remove(snaps, 1) end end local sd = Player.renderBuffered(bgm, BUFFER, { - master = bgm.volume or 1, + master = 1, sampleRate = sampleRate, }) bgm.abs = at + BUFFER if sd then outCh:push({ gen = bgm.songId, epoch = bgm.epoch, at = at, n = BUFFER, data = sd, rate = sampleRate }) end - if bgm.done and #(bgm.voices or {}) == 0 then + if bgm.done and #(bgm.voices or {}) == 0 and not Mix.reverbActive(bgm.reverbState) then outCh:push({ gen = bgm.songId, epoch = bgm.epoch, diff --git a/src/import/CacheContract.lua b/src/import/CacheContract.lua index d3e1c103..1088de26 100644 --- a/src/import/CacheContract.lua +++ b/src/import/CacheContract.lua @@ -19,7 +19,8 @@ CacheContract.VERSION_FORMAT = { crystal = "rom-cache-v12-crystal4:", -- engine/overworld/map_sprites.asm:181 yellow = "rom-cache-v11-yellow1:", - firered = "rom-cache-v7-firered:", + -- v8: M4A tracks retain reachable patterns and explicit entry offsets. + firered = "rom-cache-v8-firered:", } CacheContract.MARKER_PATH = "rom-cache.complete" diff --git a/src/import/gba/extract_audio.lua b/src/import/gba/extract_audio.lua index 1d90736f..32f52fcb 100644 --- a/src/import/gba/extract_audio.lua +++ b/src/import/gba/extract_audio.lua @@ -342,68 +342,84 @@ local function dump_voicegroup(data, vgOff, sampleMap, sampleParts, sampleCursor return id end ---- Rewrite GOTO/PATT absolute ROM pointers to track-relative offsets. -local function relocate_track_pointers(body, romBase) - if type(body) ~= "string" or #body == 0 or not romBase then return body end - local b = { body:byte(1, #body) } - local i = 1 - local function u32_at(idx) - return b[idx] + b[idx + 1] * 256 + b[idx + 2] * 65536 + b[idx + 3] * 16777216 - end - local function set_u32(idx, v) - v = math.max(0, math.floor(v)) - b[idx] = v % 256 - b[idx + 1] = math.floor(v / 256) % 256 - b[idx + 2] = math.floor(v / 65536) % 256 - b[idx + 3] = math.floor(v / 16777216) % 256 - end - local function reloc(idx) - if idx + 3 > #b then return end - local ptr = u32_at(idx) - local off = gba_off(ptr) - if off and off >= romBase and off < romBase + #b then - set_u32(idx, off - romBase) +-- Walk reachable bytecode, including patterns beyond FINE or before the entry. +-- Operands may contain command-valued bytes, so only instruction boundaries +-- can identify FINE or a pointer. Note/running-status operands are all < 0x80. +local function collect_track(data, entry) + local pending, visited, pointers = { { pc = entry, pattern = false } }, {}, {} + local first, last = entry, entry + local budget = 65536 + local hasGoto = false + local xargs = { [0]=0, 4, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 2, 4 } + while #pending > 0 do + local branch = table.remove(pending) + local pc, pattern = branch.pc, branch.pattern + while pc and pc >= 0 and pc < #data do + local state = pc * 2 + (pattern and 1 or 0) + if visited[state] then break end + budget = budget - 1 + if budget < 0 then error("M4A track exceeds bytecode limit") end + visited[state] = true + local cmd = ru8(data, pc) + local length, pointer, stop = 1, nil, false + if cmd == 0xB2 or cmd == 0xB3 then + length, pointer = 5, pc + 1 + stop = cmd == 0xB2 + if stop then hasGoto = true end + elseif cmd == 0xB5 then + length, pointer = 6, pc + 2 + elseif cmd == 0xB4 then + -- Inline patterns fall through PEND on their first, uncalled pass. + stop = pattern + elseif cmd == 0xB9 then + length = 4 + local op = ru8(data, pc + 1) or 0 + if op >= 6 and op <= 17 then length, pointer = 8, pc + 4 end + elseif cmd == 0xCD then + local op = ru8(data, pc + 1) or 0 + length = 2 + (xargs[op] or 0) + stop = op == 0 or op == 3 or xargs[op] == nil + elseif cmd == 0xCC then -- PORT: register offset, value + length = 3 + elseif cmd >= 0xBA and cmd <= 0xC5 or cmd == 0xC8 then + length = 2 + elseif cmd >= 0xB1 and cmd <= 0xB8 or cmd == 0xC6 or cmd == 0xC7 + or cmd >= 0xC9 and cmd <= 0xCB then + stop = true -- FINE, PEND, or an unused command mapped to ply_fine + end + if pc + length > #data then error("Truncated M4A instruction") end + first = math.min(first, pc) + last = math.max(last, pc + length) + if last - first > 1024 * 1024 then error("M4A track span exceeds limit") end + if pointer then + local target = gba_off(ru32(data, pointer)) + if not target or target >= #data then error("Invalid M4A branch pointer") end + pointers[pointer] = target + pending[#pending + 1] = { pc = target, pattern = cmd == 0xB3 or pattern } + end + if stop then break end + pc = pc + length end end - while i <= #b do - local cmd = b[i] - if cmd == 0xB2 or cmd == 0xB3 then -- GOTO / PATT - reloc(i + 1) - i = i + 5 - elseif cmd == 0xB5 then -- REPT count, ptr - if i + 5 <= #b then reloc(i + 2) end - i = i + 6 - elseif cmd == 0xB1 or cmd == 0xB4 then - i = i + 1 - elseif cmd == 0xCF then -- EOT - i = i + 1 - elseif cmd == 0xCE then -- XCMD + 2 args - i = i + 3 - elseif cmd >= 0xD0 then - i = i + 1 - elseif cmd >= 0x80 and cmd <= 0xB0 then - i = i + 1 - elseif cmd < 0x80 then - i = i + 2 -- key + vel - elseif cmd >= 0xB9 and cmd <= 0xCC then - -- most take 1 data byte (TEMPO/VOICE/VOL/PAN/BEND/BENDR/LFO/…) - i = i + 2 - else - i = i + 1 - end + local function le32(value) + return string.char(value % 256, math.floor(value / 256) % 256, + math.floor(value / 65536) % 256, math.floor(value / 16777216) % 256) end - local parts = {} - local CHUNK = 512 - for i = 1, #b, CHUNK do - local j = math.min(i + CHUNK - 1, #b) - parts[#parts + 1] = string.char(unpack(b, i, j)) + local ordered = {} + for pointer in pairs(pointers) do ordered[#ordered + 1] = pointer end + table.sort(ordered) + local parts, cursor = {}, first + for _, pointer in ipairs(ordered) do + parts[#parts + 1] = data:sub(cursor + 1, pointer) + parts[#parts + 1] = le32(pointers[pointer] - first) + cursor = pointer + 4 end - return table.concat(parts) + parts[#parts + 1] = data:sub(cursor + 1, last) + return table.concat(parts), entry - first, hasGoto end ---- Packed song bin: header + (offset,length)×tracks + tight track bodies. --- Track table uses file-relative offsets (always < 1MiB), never ROM pointers. --- GOTO/PATT targets are relocated to offsets within each track body. +--- Packed song bin: header + (offset,length,entry) per track. Bit 7 of blocks +-- marks the extended table; branch offsets and entries are relative to each body. local function dump_song_tracks(data, headerOff, songId, cache, root) local tracks = ru8(data, headerOff) or 0 if tracks == 0 or tracks > 16 then tracks = 0 end @@ -417,52 +433,25 @@ local function dump_song_tracks(data, headerOff, songId, cache, root) romOffs[t] = gba_off(ru32(data, headerOff + 8 + t * 4)) end - local sorted = {} - for t = 0, tracks - 1 do - if romOffs[t] then - sorted[#sorted + 1] = { t = t, off = romOffs[t] } - end - end - table.sort(sorted, function(a, b) return a.off < b.off end) - - local lenByTrack = {} - for i, e in ipairs(sorted) do - local nextOff = sorted[i + 1] and sorted[i + 1].off - local len - if nextOff and nextOff > e.off then - len = nextOff - e.off - else - len = 0 - for j = 0, 16383 do - if e.off + j >= #data then break end - if ru8(data, e.off + j) == 0xB1 then -- FINE - len = j + 1 - break - end - end - if len == 0 then len = math.min(4096, #data - e.off) end - end - lenByTrack[e.t] = math.max(1, len) - end - - local headerSize = 8 + tracks * 8 + local headerSize = 8 + tracks * 12 local cursor = headerSize - local trackMeta = {} - local bodyParts = {} + local trackMeta, bodyParts = {}, {} + local hasGoto = false for t = 0, tracks - 1 do local off = romOffs[t] - local len = (off and lenByTrack[t]) or 0 - trackMeta[t] = { offset = cursor, length = len } - if off and len > 0 then - local body = data:sub(off + 1, off + len) - body = relocate_track_pointers(body, off) - bodyParts[#bodyParts + 1] = body - cursor = cursor + len + local body, entry = "", 0 + if off then + local loop + body, entry, loop = collect_track(data, off) + hasGoto = hasGoto or loop end + trackMeta[t] = { offset = cursor, length = #body, entry = entry } + bodyParts[#bodyParts + 1] = body + cursor = cursor + #body end local hdr = { - string.char(tracks % 256, blocks % 256, priority % 256, reverb % 256), + string.char(tracks % 256, blocks % 128 + 128, priority % 256, reverb % 256), string.char( vgPtr % 256, math.floor(vgPtr / 256) % 256, @@ -472,15 +461,16 @@ local function dump_song_tracks(data, headerOff, songId, cache, root) } for t = 0, tracks - 1 do local m = trackMeta[t] or { offset = 0, length = 0 } - local o, l = m.offset, m.length + local o, l, e = m.offset, m.length, m.entry or 0 hdr[#hdr + 1] = string.char( o % 256, math.floor(o / 256) % 256, math.floor(o / 65536) % 256, math.floor(o / 16777216) % 256, - l % 256, math.floor(l / 256) % 256, math.floor(l / 65536) % 256, math.floor(l / 16777216) % 256 + l % 256, math.floor(l / 256) % 256, math.floor(l / 65536) % 256, math.floor(l / 16777216) % 256, + e % 256, math.floor(e / 256) % 256, math.floor(e / 65536) % 256, math.floor(e / 16777216) % 256 ) end local blob = table.concat(hdr) .. table.concat(bodyParts) write(cache, string.format("%s/songs/%d.bin", root, songId), blob) - return #blob, tracks + return #blob, tracks, hasGoto end local function build_map_songs(rom, data) @@ -576,7 +566,8 @@ function ExtractAudio.run(rom, cache, opts) info.priority = ru8(data, headerOff + 2) info.reverb = ru8(data, headerOff + 3) info.loop = (info.kind == "bgm") - local nbytes = dump_song_tracks(data, headerOff, id, cache, root) + local nbytes, _, hasGoto = dump_song_tracks(data, headerOff, id, cache, root) + info.hasGoto = hasGoto info.songBytes = nbytes local sid = song_primary_sample(data, headerOff, sampleMap, sampleParts, sampleCursor) info.sampleId = sid diff --git a/src/import/gba/versions.lua b/src/import/gba/versions.lua index 2a2bbb4b..0803f023 100644 --- a/src/import/gba/versions.lua +++ b/src/import/gba/versions.lua @@ -28,7 +28,7 @@ Versions.NATIVE_VERSION = 5 Versions.OW_VERSION = 1 Versions.ANIM_VERSION = 1 -- Audio pack (M4A banks / DirectSound samples / cries). -Versions.AUDIO_VERSION = 5 +Versions.AUDIO_VERSION = 6 -- FireRed USA 1.0 (BPRE) — located by structural scan (entry0 ms=me=0, SE_SELECT ms=me=2). Versions.AUDIO = { song_table = 0x4A32CC, -- gSongTable file offset