audio update and return to launcher

This commit is contained in:
bryanthaboi
2026-09-17 09:42:08 -04:00
parent a820875208
commit 84d0ea8616
10 changed files with 638 additions and 405 deletions
+1 -3
View File
@@ -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()
+1
View File
@@ -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
+60 -22
View File
@@ -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
+236 -147
View File
@@ -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
+61 -32
View File
@@ -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
+182 -97
View File
@@ -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 <op> <args…>. 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)
+4 -3
View File
@@ -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,
+2 -1
View File
@@ -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"
+90 -99
View File
@@ -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
+1 -1
View File
@@ -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