Files
gen1recomp/src/core/ChipAudio.lua
T
Shane McGovern 795276935c Performance pass: cut per-frame allocations and draw calls, fix frame-rate and batch bugs
- Shared render: cache Font encode/width, skip unchanged palette/ShaderFX
  uniform sends, reuse quads and scratch tables, cheaper sprite palettes
- Mods: fast path for loaded requires, memoized sandbox verdicts, fewer
  Hooks:call allocations, drop emptied hook chains
- Audio: bulk PCM writes, cry/SFX prewarm on the worker, sliced sync
  fallback, cached Gen 3 SE sources
- Gen 1: per-frame SGB battle palettes, hoisted overworld FX, cached menus
- Gen 2: numeric attr grid, indexed anim cells, entity culling, memoized
  cache keys, palette shader set once per tile run
- Gen 3: reuse native tile batches, per-cell metatile rebuilds, partial
  FRLG tile anim uploads, near-view ghost ticking, packed FRLG font atlas
- Fixes: emergency quit timer, Gen 3 batch regression and stale sprite
  index, trainer card texture leak, missing pic re-reads, anim tint cache,
  Crystal ghost grass overdraw, refresh-rate dependent counters, pad swap
2026-09-30 14:53:18 +00:00

975 lines
34 KiB
Lua

-- Playback front end for the Game Boy audio synth (src/core/ChipSynth.lua).
--
-- Map/battle MUSIC is streamed from a background worker thread
-- (src/core/chip_worker.lua): the worker synthesizes the PCM buffers and this
-- module only queues finished SoundData onto a QueueableSource. That is the
-- fix for the map-transition stutter -- filling the deep (~6s) playback queue
-- from scratch when a song changes is ~200ms of Lua synthesis, and doing it on
-- the render thread dropped frames for the ~10 frames after every seam
-- crossing. Off-thread, a song change costs the main loop essentially
-- nothing.
--
-- When love.thread is unavailable (the headless test stub) or a worker fails
-- to start, music falls back to the original synchronous, amortized queue fill
-- so behavior is unchanged -- see the `threaded` branch in each entry point.
--
-- SFX and cries stay synchronous: they are short one-shots rendered once into
-- a static Source, not a per-frame streaming cost.
local Assets = require("src.render.Assets")
local ChipSynth = require("src.core.ChipSynth")
local ChipAudio = {}
local function sampleRate()
return ChipSynth.SAMPLE_RATE
end
local MUSIC_BUFFER_SAMPLES = ChipSynth.MUSIC_BUFFER_SAMPLES
local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT
-- ---------------------------------------------------------------------------
-- Per-channel mix (edit these)
-- Applied on load and whenever this file hot-reloads.
-- Runtime: ChipAudio.setChannelVolume / setChannelPitch.
-- [1] pulse 1 [2] pulse 2 [3] wave [4] noise / drums
-- Volume: 1 = authentic, 0 = mute, >1 boosts
-- Pitch: 1 = authentic, 2 = +1 octave, 0.5 = -1 octave
-- The shipped values stay at 1: 0.25 / 0.5 on the wave channel buried the Ch3
-- countermelodies an octave low (#429), and ChipSynth already applies the
-- wave channel's own hardware octave (frequency * 0.5).
-- ---------------------------------------------------------------------------
local CHANNEL_VOLUME = {
[1] = 1, -- pulse 1
[2] = 1, -- pulse 2
[3] = 1, -- wave
[4] = 1, -- noise / drums
}
local CHANNEL_PITCH = {
[1] = 1, -- pulse 1
[2] = 1, -- pulse 2
[3] = 1, -- wave
[4] = 1, -- noise / drums
}
ChipSynth.setChannelVolumes(CHANNEL_VOLUME)
ChipSynth.setChannelPitches(CHANNEL_PITCH)
-- currentMusic: { source, gen, threaded, started, finished, engine }
-- threaded songs stream from the worker (engine is nil here);
-- the fallback path owns a local engine and fills the source itself.
local currentMusic
local pendingBuf -- a current-gen buffer popped from the worker but not yet
-- queued because the Source was momentarily full
-- Music holds playback while a fanfare owns the music channels (#398).
-- Pausing the Source is not enough on its own: this module is what starts a
-- chip song (immediately on the sync path, on the first worker buffer on the
-- threaded one), so a song that begins during a jingle would come up
-- underneath it. Music.duckForFanfare sets the hold, Music releases it when
-- the jingle ends.
local musicHeld = false
local suspended = false
local STATS = os.getenv("POKEPORT_AUDIO_STATS") == "1"
local statFrames, statUnderruns, statRestarts = 0, 0, 0
local statDepthMin, statDepthSum, statDepthFrames = nil, 0, 0
local statWorkerJit, statWorkerXrt = nil, nil
local statXrtSum, statXrtCount, statXrtMax = 0, 0, nil
local lastCosted
-- ---------------------------------------------------------------------------
-- worker management
-- ---------------------------------------------------------------------------
local worker, cmdCh, outCh
local workerReady -- nil = untried, true = running, false = unavailable
-- SFX/cry prewarm state (see "one-shot effects" below): results come back on
-- their own channel so a music stop/clear never drops them
local fxCh
local fxEpoch = 0 -- bumped by invalidate: drops results from a stale def
local fxReady = {} -- key -> SoundData | false (renders to nothing)
local fxPending = {} -- key -> true while a request is in flight
local fxReadyCount = 0
local FX_READY_MAX = 64
local drainEffects, dropEffects
local function ensureWorker()
if workerReady ~= nil then return workerReady end
if not (love.thread and love.thread.newThread and love.audio) then
workerReady = false
return false
end
local ok, thread = pcall(love.thread.newThread, "src/core/chip_worker.lua")
if not ok or not thread then
workerReady = false
return false
end
cmdCh = love.thread.getChannel("chipaudio_cmd")
outCh = love.thread.getChannel("chipaudio_out")
local started = pcall(function() thread:start() end)
if not started then
workerReady = false
return false
end
worker = thread
workerReady = true
return true
end
-- only the tables ChipSynth.newEngine reads for ROM songs; sent with every
-- play so a hot-reloaded dataset (or a mod's audio) always reaches the worker
local function slimAudio(data)
local audio = data.audio or {}
return {
programFile = audio.programFile,
programPrefix = require("src.core.WorkerFs").prefix(),
bankOrder = audio.bankOrder,
waveBanks = audio.waveBanks,
noiseHeaders = audio.noiseHeaders,
generation = audio.generation,
drumkits = audio.drumkits,
}
end
-- test-only: expose slimAudio so the NX prefix hand-off is verifiable
function ChipAudio._slimAudioForTest(data)
return slimAudio(data)
end
-- If the worker died (a malformed def that errors mid-synth), fall back to the
-- synchronous path for the rest of the session instead of going silent.
local function workerAlive()
if not worker then return false end
local err = worker:getError()
if err then
require("src.core.Logger").warn("chip audio worker died: %s", tostring(err))
workerReady = false
worker = nil
return false
end
return true
end
-- ---------------------------------------------------------------------------
-- synchronous fallback (no love.thread): the original amortized queue fill
-- ---------------------------------------------------------------------------
-- The queue is deep (MUSIC_BUFFER_COUNT, ~6s) for stall tolerance, but
-- synthesizing all of it on the frame a song starts renders ~6s of audio at
-- once. A (re)start renders MUSIC_FILL_INITIAL whole buffers so playback has
-- lookahead; after that each update() renders a flat slice of samples into a
-- staging buffer and queues it once all MUSIC_BUFFER_SAMPLES are in. Whole
-- 8192-sample buffers rendered on the frame a queue slot freed (every ~11
-- frames) read as a periodic stutter; ~1024 samples per 60 Hz tick
-- (playback drains ~735) keeps the per-frame cost flat while the queue still
-- creeps up to full. Below MUSIC_FILL_LOW_WATER queued buffers (a long
-- stall, or update() called well under 60 Hz) a tick renders a whole buffer's
-- worth so the queue cannot run dry. The sample stream is unchanged: every
-- buffer is still MUSIC_BUFFER_SAMPLES consecutive engine samples, and a
-- buffer begun before the song ended is completed exactly as before.
local MUSIC_FILL_INITIAL = 4
local MUSIC_FILL_TICK_SAMPLES = 1024 -- at 44100 Hz; scaled with the rate
local MUSIC_FILL_LOW_WATER = 2
local function tickSamples()
return math.max(256,
math.floor(MUSIC_FILL_TICK_SAMPLES * sampleRate() / 44100 + 0.5))
end
-- render up to `budget` samples of the fallback song, queueing each staging
-- buffer as it fills. A new buffer is only begun while the Source has a free
-- slot and the song has not finished (the old whole-buffer loop's gates).
local function renderSync(music, budget)
while budget > 0 do
if not music.staging then
if music.engine:finished() then return end
local ok, free = pcall(music.source.getFreeBufferCount, music.source)
if not ok or type(free) ~= "number" or free <= 0 then return end
music.staging = ChipSynth.newBuffer(MUSIC_BUFFER_SAMPLES, 2)
music.stagingFill = 0
end
local count = math.min(budget, MUSIC_BUFFER_SAMPLES - music.stagingFill)
ChipSynth.renderInto(music.engine, music.staging, music.stagingFill,
count, 2)
music.stagingFill = music.stagingFill + count
budget = budget - count
if music.stagingFill >= MUSIC_BUFFER_SAMPLES then
local sd = music.staging
music.staging, music.stagingFill = nil, 0
if not pcall(music.source.queue, music.source, sd) then return end
end
end
end
-- `buffers`: render that many whole buffers now (song start / restart);
-- omitted: one per-frame tick
local function fillSync(buffers)
if suspended then return end
local music = currentMusic
if not music or not music.engine then return end
if not music.staging and music.engine:finished() then return end
local budget
if buffers then
budget = buffers * MUSIC_BUFFER_SAMPLES - (music.stagingFill or 0)
else
budget = tickSamples()
local ok, free = pcall(music.source.getFreeBufferCount, music.source)
if ok and type(free) == "number"
and MUSIC_BUFFER_COUNT - free < MUSIC_FILL_LOW_WATER then
budget = MUSIC_BUFFER_SAMPLES
end
end
renderSync(music, budget)
end
local function playMusicSync(data, header, allowLoops)
-- build before tearing down: a def that fails to compile must leave the
-- outgoing song sounding
local ok, engine = pcall(ChipSynth.newEngine, data, header,
{ allowLoops = allowLoops })
if not ok then return nil, engine end
local ok2, source = pcall(
love.audio.newQueueableSource, sampleRate(), 16, 2, MUSIC_BUFFER_COUNT)
if not ok2 then return nil, source end
ChipAudio.stopMusic()
currentMusic = { source = source, engine = engine, threaded = false,
started = true, finished = false }
fillSync(MUSIC_FILL_INITIAL)
if not musicHeld then pcall(source.play, source) end
return source
end
-- ---------------------------------------------------------------------------
-- threaded music
-- ---------------------------------------------------------------------------
local musicGen = 0
-- bumps when SOUND flips so already-queued PCM (old pan) is dropped rather
-- than playing out the ~6s stall-tolerance queue (#1471)
local stereoEpoch = 0
local MUSIC_PREROLL = 4
ChipAudio.MUSIC_PREROLL = MUSIC_PREROLL
local function queuedBuffers(source)
local ok, free = pcall(source.getFreeBufferCount, source)
if not ok or type(free) ~= "number" then return nil end
return MUSIC_BUFFER_COUNT - free
end
local function readyToStart(m)
local queued = queuedBuffers(m.source)
if not queued then return false end
return queued >= (m.preroll or 1) or (m.finished and queued > 0)
end
function ChipAudio.playMusic(data, header, allowLoops)
if not ensureWorker() then
return playMusicSync(data, header, allowLoops)
end
-- validate the def on this thread (cheap: engine construction, no synthesis)
-- so a broken def costs nothing but a log line and keeps the old song
local ok, engine = pcall(ChipSynth.newEngine, data, header,
{ allowLoops = allowLoops })
if not ok then return nil, engine end
-- build the new source before tearing the old song down
local ok2, source = pcall(
love.audio.newQueueableSource, sampleRate(), 16, 2, MUSIC_BUFFER_COUNT)
if not ok2 then return nil, source end
ChipAudio.stopMusic()
musicGen = musicGen + 1
local gen = musicGen
cmdCh:push({ cmd = "play", gen = gen, header = header,
allowLoops = allowLoops, audio = slimAudio(data),
channelVolumes = ChipSynth.getChannelVolumes(),
channelPitches = ChipSynth.getChannelPitches(),
stereo = ChipSynth.getStereo(),
sampleRate = sampleRate(),
stereoEpoch = stereoEpoch })
currentMusic = { source = source, gen = gen, threaded = true,
started = false, finished = false,
stereoEpoch = stereoEpoch,
preroll = (allowLoops ~= false) and MUSIC_PREROLL or 1 }
return source
end
local function pushChannelMix()
if workerReady and cmdCh then
cmdCh:push({ cmd = "channelMix",
volumes = ChipSynth.getChannelVolumes(),
pitches = ChipSynth.getChannelPitches(),
stereo = ChipSynth.getStereo() })
end
end
-- move finished buffers from the worker into the Source; start playback once
-- the first one lands
local function updateThreaded()
local m = currentMusic
if not m then return end
if not workerAlive() then
-- worker gone: nothing more will arrive; leave whatever is queued playing
return
end
while true do
local okFree, free = pcall(m.source.getFreeBufferCount, m.source)
if not okFree or type(free) ~= "number" then return end
local buf = pendingBuf
if buf then pendingBuf = nil else buf = outCh:pop() end
if not buf then break end
if buf.gen ~= m.gen then
-- stale buffer from a superseded song: drop it
elseif buf.stereoEpoch ~= nil and m.stereoEpoch ~= nil
and buf.stereoEpoch ~= m.stereoEpoch then
-- stale pan mix from before a live SOUND toggle (#1471)
elseif buf.done then
m.finished = true
elseif buf.error then
require("src.core.Logger").warn("chip audio: %s", tostring(buf.error))
m.finished = true
elseif buf.sd then
if buf.jit ~= nil then statWorkerJit = buf.jit end
if type(buf.xrt) == "number" and buf ~= lastCosted then
lastCosted = buf
statWorkerXrt = buf.xrt
statXrtSum = statXrtSum + buf.xrt
statXrtCount = statXrtCount + 1
if statXrtMax == nil or buf.xrt > statXrtMax then
statXrtMax = buf.xrt
end
end
if free > 0 then
if not pcall(m.source.queue, m.source, buf.sd) then return end
else
pendingBuf = buf -- Source full; hold this one for next frame
break
end
end
end
if not m.started and not musicHeld and readyToStart(m) then
pcall(function() m.source:play() end)
m.started = true
end
end
local function noteStats(m)
if not m.started or m.finished then return end
statFrames = statFrames + 1
local depth = m.source and queuedBuffers(m.source) or nil
if depth then
statDepthSum = statDepthSum + depth
statDepthFrames = statDepthFrames + 1
if statDepthMin == nil or depth < statDepthMin then statDepthMin = depth end
end
if depth == 0 then statUnderruns = statUnderruns + 1 end
if STATS and statFrames % 60 == 0 then
require("src.core.Logger").info(
"chipaudio: depth=%d/%d out=%d underruns=%d restarts=%d rate=%d",
depth or -1, MUSIC_BUFFER_COUNT,
(m.threaded and outCh) and outCh:getCount() or -1,
statUnderruns, statRestarts, sampleRate())
end
end
function ChipAudio.stats()
local m = currentMusic
local depth = (m and m.source) and queuedBuffers(m.source) or nil
local worker
if workerReady == nil then worker = "none"
elseif workerReady == false then worker = "sync"
elseif statWorkerJit == true then worker = "jit"
elseif statWorkerJit == false then worker = "interp"
else worker = "starting" end
local average = statDepthFrames > 0
and (statDepthSum / statDepthFrames) or nil
local stats = {
rate = sampleRate(),
worker = worker,
depth = depth,
depthMax = MUSIC_BUFFER_COUNT,
depthMin = statDepthMin,
depthAvg = average,
frames = statFrames,
underruns = statUnderruns,
restarts = statRestarts,
xrt = statWorkerXrt,
xrtAvg = statXrtCount > 0 and (statXrtSum / statXrtCount) or nil,
xrtMax = statXrtMax,
buffers = statXrtCount,
}
local function num(value, places)
if type(value) ~= "number" then return "-" end
return string.format("%." .. places .. "f", value)
end
stats.line = string.format(
"rate=%d worker=%s depth=%s/%d min=%s avg=%s underruns=%d restarts=%d "
.. "xrt=%s/%s/%s n=%d",
stats.rate, worker, depth and tostring(depth) or "-", MUSIC_BUFFER_COUNT,
statDepthMin and tostring(statDepthMin) or "-", num(average, 1),
statUnderruns, statRestarts,
num(statWorkerXrt, 3), num(stats.xrtAvg, 3), num(statXrtMax, 3),
statXrtCount)
return stats
end
function ChipAudio.update()
if suspended then return end
if fxCh then drainEffects() end
local m = currentMusic
if not m then return end
if m.threaded then
updateThreaded()
else
fillSync()
end
noteStats(m)
end
-- Recover from a queue underrun caused by a long render stall. Called after
-- Music has handled intentional fanfare pauses, so it never fights the normal
-- pause/resume behavior.
function ChipAudio.ensureMusicPlaying()
if suspended then return end
local m = currentMusic
if not m or m.finished or musicHeld then return end
if m.threaded then
if not m.started then return end
local ok, playing = pcall(function() return m.source:isPlaying() end)
if not ok or playing then return end
if readyToStart(m) then
pcall(function() m.source:play() end)
statRestarts = statRestarts + 1
end
else
if not m.engine or m.engine:finished() then return end
local ok, playing = pcall(m.source.isPlaying, m.source)
if ok and not playing then
fillSync(MUSIC_FILL_INITIAL)
pcall(m.source.play, m.source)
statRestarts = statRestarts + 1
end
end
end
-- Silence the song for the length of a fanfare and start whatever was held
-- back once it ends. Held state outlives a song change: Music.play may swap
-- songs while the jingle is still sounding.
function ChipAudio.holdMusic(held)
held = not not held
if held == musicHeld then return end
musicHeld = held
if held then return end
ChipAudio.update()
ChipAudio.ensureMusicPlaying()
end
-- Threaded playMusic returns an empty QueueableSource and only calls
-- Source:play once the first worker buffer lands (~1 frame later). Until
-- then Source:isPlaying is false -- callers that treat that as "song over"
-- (Music.oneShotPlaying / pendingRestore) must wait here instead, or a
-- playOnce jingle like Music_PkmnHealed is cut off before it starts.
local forceAwaitingFirstBuffer -- test-only override (see _simulate*)
function ChipAudio.awaitingFirstBuffer()
if suspended then return false end
if forceAwaitingFirstBuffer then return true end
local m = currentMusic
if not (m and m.threaded and not m.started and not m.finished) then
return false
end
-- a dead worker will never deliver the first buffer
if workerReady == false then return false end
if worker and worker.getError and worker:getError() then return false end
return true
end
function ChipAudio.stopMusic()
if currentMusic and currentMusic.source then
pcall(currentMusic.source.stop, currentMusic.source)
end
if workerReady and cmdCh then
cmdCh:push({ cmd = "stop" })
if outCh then outCh:clear() end
end
pendingBuf = nil
currentMusic = nil
forceAwaitingFirstBuffer = nil
end
-- hot reload: the next play re-reads programs.bin (a mod may have swapped the
-- file out from under the single-slot bank cache), on both threads
function ChipAudio.invalidate()
ChipAudio.stopMusic()
ChipSynth.invalidateBanks()
dropEffects()
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
end
-- End the worker thread. LOVE waits for every live love.thread before the
-- process exits and the worker's command loop only returns on "quit", so
-- skipping this leaves the process running after the window is gone (#339).
function ChipAudio.shutdown()
ChipAudio.stopMusic()
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
if worker then pcall(function() worker:wait() end) end
worker, cmdCh, outCh = nil, nil, nil
workerReady = nil
dropEffects()
fxCh = nil
end
function ChipAudio.currentSource()
return currentMusic and currentMusic.source
end
function ChipAudio.setSuspended(flag)
suspended = not not flag
end
function ChipAudio.isSuspended()
return suspended
end
function ChipAudio.rebuildPlayback()
local m = currentMusic
if not m then return true end
if not (love.audio and love.audio.newQueueableSource) then return false end
local ok, source = pcall(
love.audio.newQueueableSource, sampleRate(), 16, 2, MUSIC_BUFFER_COUNT)
if not ok or not source then return false end
pendingBuf = nil
local old = m.source
m.source = source
m.started = false
if old then pcall(old.stop, old) end
if not m.threaded then
fillSync(MUSIC_FILL_INITIAL)
if not musicHeld then pcall(source.play, source) end
m.started = true
end
return true
end
function ChipAudio.setStereo(enabled)
enabled = not not enabled
if ChipSynth.getStereo() == enabled then return end
ChipSynth.setStereo(enabled)
stereoEpoch = stereoEpoch + 1
local m = currentMusic
if m and m.engine then
ChipSynth.applyStereo(m.engine)
-- a half-rendered fallback buffer holds the previous pan mix (#1471)
m.staging, m.stagingFill = nil, 0
end
if workerReady and cmdCh then
cmdCh:push({ cmd = "channelMix",
volumes = ChipSynth.getChannelVolumes(),
pitches = ChipSynth.getChannelPitches(),
stereo = enabled,
stereoEpoch = stereoEpoch })
end
if not m then return end
pendingBuf = nil
if outCh then outCh:clear() end
m.stereoEpoch = stereoEpoch
-- QueueableSource cannot unqueue; swap so the ~6s stall-tolerance buffers
-- (mixed under the previous pan) do not have to play out first (#1471)
if not love.audio then return end
local ok, source = pcall(
love.audio.newQueueableSource, sampleRate(), 16, 2, MUSIC_BUFFER_COUNT)
if not ok or not source then return end
local old = m.source
m.source = source
m.started = false
if old then pcall(old.stop, old) end
if not m.threaded then
fillSync(MUSIC_FILL_INITIAL)
if not musicHeld then pcall(source.play, source) end
m.started = true
end
end
function ChipAudio.getStereo()
return ChipSynth.getStereo()
end
local envRate = os.getenv("POKEPORT_AUDIO_RATE")
function ChipAudio._setEnvRateForTest(value)
envRate = value
end
function ChipAudio.selectSampleRate(options)
local forced = tonumber(envRate)
if forced and forced >= 8000 and forced <= 48000 then
return math.floor(forced)
end
local tier = require("src.core.Performance")
.resolve(options and options.performance)
if tier == "low" then return 22050 end
local osName = love and love.system and love.system.getOS
and love.system.getOS() or nil
if osName == "Android" and tier ~= "high" then return 22050 end
return 44100
end
function ChipAudio.setSampleRate(rate)
local before = sampleRate()
if ChipSynth.setSampleRate(rate) == before then return false end
ChipAudio.stopMusic()
return true
end
function ChipAudio.applyOptions(options)
return ChipAudio.setSampleRate(ChipAudio.selectSampleRate(options))
end
-- Runtime mix for one hardware channel (1..4). Takes effect on the next
-- synthesized buffer (live music) and on any SFX/cry rendered after the call.
function ChipAudio.setChannelVolume(hw, scale)
ChipSynth.setChannelVolume(hw, scale)
pushChannelMix()
end
function ChipAudio.getChannelVolume(hw)
return ChipSynth.getChannelVolume(hw)
end
function ChipAudio.setChannelVolumes(volumes)
ChipSynth.setChannelVolumes(volumes)
pushChannelMix()
end
function ChipAudio.getChannelVolumes()
return ChipSynth.getChannelVolumes()
end
function ChipAudio.setChannelPitch(hw, scale)
ChipSynth.setChannelPitch(hw, scale)
pushChannelMix()
end
function ChipAudio.getChannelPitch(hw)
return ChipSynth.getChannelPitch(hw)
end
function ChipAudio.setChannelPitches(pitches)
ChipSynth.setChannelPitches(pitches)
pushChannelMix()
end
function ChipAudio.getChannelPitches()
return ChipSynth.getChannelPitches()
end
-- aliases for channel 4 (noise / drums)
function ChipAudio.setNoiseVolume(scale)
ChipAudio.setChannelVolume(4, scale)
end
function ChipAudio.getNoiseVolume()
return ChipAudio.getChannelVolume(4)
end
-- a stale song must not keep sounding past the flush that replaced its
-- program (20 §2 cache contract, chip music row)
Assets.register(ChipAudio.invalidate)
-- ---------------------------------------------------------------------------
-- one-shot effects (SFX, cries, low-health alarm): synchronous static Sources
-- ---------------------------------------------------------------------------
-- Prewarm (#first-cry hitch): the first play of an SFX/cry synthesizes its
-- whole PCM on the main thread (a cry is ~10-25 ms of Lua synthesis, a long
-- jingle 150-350 ms). ChipAudio.prewarmSfx / prewarmCry hand that render to
-- the music worker ahead of time -- e.g. while a battle transition runs, for
-- the two species about to cry -- and the next newSfx/newCry with the same
-- def, modifiers and channel mix takes the finished SoundData instead of
-- rendering. Playback timing is unchanged: a play never waits on the worker
-- (a request still in flight is rendered synchronously as before and its
-- late result dropped), and the worker runs the same ChipSynth code with the
-- same rate/mix, so the PCM is identical. No worker (headless, love.thread
-- unavailable or dead): prewarm is a no-op and plays render as before.
local function effectKey(data, header, options)
if type(header) ~= "table" then return nil end
local volumes = ChipSynth.getChannelVolumes()
local pitches = ChipSynth.getChannelPitches()
-- table identities: a reloaded def (or dataset) is a new table
return table.concat({
tostring(data and data.audio), tostring(header.chip or header),
tostring(options.frequencyOffset), tostring(options.frameTicks),
tostring(options.plainFrames), tostring(options.cryLength),
tostring(options.maxSeconds),
sampleRate(), ChipSynth.getStereo() and 1 or 0,
volumes[1], volumes[2], volumes[3], volumes[4],
pitches[1], pitches[2], pitches[3], pitches[4], fxEpoch,
}, "|")
end
function drainEffects()
if not fxCh then return end
while true do
local result = fxCh:pop()
if not result then return end
if result.epoch == fxEpoch and fxPending[result.key] then
fxPending[result.key] = nil
if result.error then
require("src.core.Logger").warn("chip audio prewarm: %s",
tostring(result.error))
elseif fxReadyCount < FX_READY_MAX then
fxReady[result.key] = result.sd or false
fxReadyCount = fxReadyCount + 1
end
end
end
end
function dropEffects()
fxEpoch = fxEpoch + 1
fxReady, fxPending, fxReadyCount = {}, {}, 0
if fxCh then fxCh:clear() end
end
local function requestEffect(data, header, options)
if not ensureWorker() or not workerAlive() then return false end
local key = effectKey(data, header, options)
if not key then return false end
drainEffects()
if fxReady[key] ~= nil or fxPending[key] then return true end
fxCh = fxCh or love.thread.getChannel("chipaudio_fx")
local pushed = pcall(cmdCh.push, cmdCh, {
cmd = "effect", key = key, epoch = fxEpoch, header = header,
options = {
frequencyOffset = options.frequencyOffset,
frameTicks = options.frameTicks,
plainFrames = options.plainFrames,
cryLength = options.cryLength,
maxSeconds = options.maxSeconds,
},
audio = slimAudio(data),
channelVolumes = ChipSynth.getChannelVolumes(),
channelPitches = ChipSynth.getChannelPitches(),
stereo = ChipSynth.getStereo(),
sampleRate = sampleRate(),
})
if not pushed then return false end
fxPending[key] = true
return true
end
-- a finished prewarm for exactly this render, or nil
local function takeEffect(data, header, options)
if not fxCh then return nil end
local key = effectKey(data, header, options)
if not key then return nil end
drainEffects()
local sd = fxReady[key]
if sd ~= nil then
fxReady[key] = nil
fxReadyCount = fxReadyCount - 1
return sd
end
-- about to render it here; the worker's copy would arrive unused
fxPending[key] = nil
return nil
end
local function renderEffect(data, header, options)
local sd = takeEffect(data, header, options)
if sd == nil then sd = ChipSynth.renderEffectData(data, header, options) end
if not sd then return nil end
return love.audio.newSource(sd, "static")
end
local function sfxOptions(pitch, tempo, plainFrames)
return {
frequencyOffset = pitch or 0,
frameTicks = 0x80 + (tempo or 0x80),
plainFrames = plainFrames,
}
end
local function cryDef(data, species, resolved)
return resolved or (data.audio.cries and data.audio.cries[species])
end
local function cryOptions(cry)
return { frequencyOffset = cry.pitch, cryLength = cry.length }
end
function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames)
header = header or data.audio.sfx[name]
return renderEffect(data, header, sfxOptions(pitch, tempo, plainFrames))
end
-- `resolved` is a {header|chip, pitch, length} def the caller already worked
-- out -- a derived cry borrowing another species' header with its own
-- modifiers, which no registry lookup under `species` could find
function ChipAudio.newCry(data, species, resolved)
local cry = cryDef(data, species, resolved)
if not cry then return nil end
return renderEffect(data, cry.chip and cry or cry.header, cryOptions(cry))
end
-- Same arguments as newSfx / newCry; returns true when a render was queued
-- on the worker (or is already queued / done), false when there is no worker
-- to do it. Never renders on the calling thread.
function ChipAudio.prewarmSfx(data, name, pitch, tempo, header, plainFrames)
header = header or (data.audio and data.audio.sfx and data.audio.sfx[name])
if not header then return false end
return requestEffect(data, header, sfxOptions(pitch, tempo, plainFrames))
end
function ChipAudio.prewarmCry(data, species, resolved)
local cry = data.audio and cryDef(data, species, resolved)
if not cry then return false end
local header = cry.chip and cry or cry.header
if not header then return false end
return requestEffect(data, header, cryOptions(cry))
end
-- test hook: prewarm bookkeeping
function ChipAudio._effectStateForTest()
local pending = 0
for _ in pairs(fxPending) do pending = pending + 1 end
return { ready = fxReadyCount, pending = pending, epoch = fxEpoch }
end
-- Two channels for the same reason ChipSynth.renderEffectData renders stereo:
-- a mono Source is spatialized by OpenAL at the listener position and spreads
-- over every output an interface has (#626). The siren itself is unchanged,
-- both channels carry the same sample.
-- PlayDanger (audio/engine.asm:531) counts one frame per call and resets with
-- `cp 30 / jr c, .noreset`, so the cycle is frames 0..29 and the buffer holds
-- exactly two of them. DangerSoundHigh goes in on the `and a / jr z, .begin`
-- frame 0 and DangerSoundLow on the `cp 16 / jr z, .halfway` frame 16, so the
-- high tone owns 0..15 and the low tone 16..29.
function ChipAudio.newLowHealthAlarm()
local SAMPLE_RATE = sampleRate()
local samples = math.floor(SAMPLE_RATE * 60 / 60)
local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 2)
local pointer = ChipSynth._int16Pointer(data)
local phase = 0
for index = 0, samples - 1 do
local frame = math.floor(index * 60 / SAMPLE_RATE) % 30
local register = frame < 16 and 0x750 or 0x6EE
local frequency = 131072 / (2048 - register)
phase = (phase + frequency / SAMPLE_RATE) % 1
local value = (phase < 0.5 and 1 or -1) * 0.25
if pointer then
-- same int16 setSample stores (ChipSynth bulk PCM writes)
pointer[index * 2] = value * 32767
pointer[index * 2 + 1] = value * 32767
else
data:setSample(index, 1, value)
data:setSample(index, 2, value)
end
end
return love.audio.newSource(data, "static")
end
-- ---------------------------------------------------------------------------
-- test hooks (headless): synchronous synthesis straight through ChipSynth
-- ---------------------------------------------------------------------------
function ChipAudio._setAudioStatsForTest(flag)
STATS = not not flag
statFrames, statUnderruns, statRestarts = 0, 0, 0
statDepthMin, statDepthSum, statDepthFrames = nil, 0, 0
statWorkerJit, statWorkerXrt = nil, nil
statXrtSum, statXrtCount, statXrtMax, lastCosted = 0, 0, nil, nil
end
function ChipAudio._audioStatsForTest()
return { frames = statFrames, underruns = statUnderruns,
restarts = statRestarts }
end
-- Force the "threaded, first buffer not yet queued" window so Music's
-- playOnce / pendingRestore race can be asserted without love.thread.
-- Returns a clear() that drops the override (call after the assertion).
function ChipAudio._simulateAwaitingFirstBufferForTest()
local m = currentMusic
if not m or not m.source then return nil end
m.threaded = true
m.started = false
m.finished = false
pcall(function() m.source.playing = false end)
forceAwaitingFirstBuffer = true
return function() forceAwaitingFirstBuffer = nil end
end
function ChipAudio._renderMusicForTest(data, header, seconds)
local engine = ChipSynth.newEngine(data, header, { allowLoops = true })
return ChipSynth.soundData(engine, math.floor(seconds * sampleRate()), 2)
end
function ChipAudio._renderMusicChannelForTest(data, header, seconds, number)
local SAMPLE_RATE = sampleRate()
local engine = ChipSynth.newEngine(data, header, { allowLoops = true })
local samples = math.floor(seconds * SAMPLE_RATE)
local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 1)
for index = 0, samples - 1 do
result:setSample(index, engine:sampleChannel(number))
end
return result
end
function ChipAudio._traceFirstMusicSampleForTest(data, header)
local engine = ChipSynth.newEngine(data, header, { allowLoops = true })
local result = {}
for _, channel in ipairs(engine.channels) do
local value = channel:sample()
local event = channel.event or {}
result[#result + 1] = {
number = channel.number,
value = value,
register = event.register,
duration = event.duration,
volume = event.volume,
duty = event.duty,
wave = event.wave,
waveInstrument = event.waveInstrument,
drumSegments = event.drum and #event.drum or nil,
noiseParameter = event.noiseParameter,
sweep = event.sweep,
}
end
return result
end
function ChipAudio._traceFirstSfxSampleForTest(data, header)
local engine = ChipSynth.newEngine(data, header, {
sfx = true,
allowLoops = false,
})
local result = {}
for _, channel in ipairs(engine.channels) do
local value = channel:sample()
local event = channel.event or {}
result[#result + 1] = {
number = channel.number,
value = value,
register = event.register,
duration = event.duration,
volume = event.volume,
fade = event.fade,
noiseParameter = event.noiseParameter,
sweep = event.sweep,
}
end
return result
end
function ChipAudio._renderSfxForTest(data, header, seconds)
local engine = ChipSynth.newEngine(data, header, {
sfx = true,
allowLoops = false,
})
return ChipSynth.soundData(engine, math.floor(seconds * sampleRate()), 1)
end
return ChipAudio