mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-04 08:21:40 -04:00
audio update and return to launcher
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user