-- 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 SAMPLE_RATE = ChipSynth.SAMPLE_RATE 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 -- --------------------------------------------------------------------------- -- worker management -- --------------------------------------------------------------------------- local worker, cmdCh, outCh local workerReady -- nil = untried, true = running, false = unavailable 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, bankOrder = audio.bankOrder, waveBanks = audio.waveBanks, noiseHeaders = audio.noiseHeaders, } 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. Cap how many buffers each fill renders; playback drains ~1 buffer -- every ~11 frames while update() tops up a few per frame, so the deep queue -- still ramps to full within a fraction of a second. local MUSIC_FILL_INITIAL = 4 local MUSIC_FILL_PER_CALL = 3 local function fillSync(limit) local music = currentMusic if not music or not music.engine or music.engine:finished() then return end limit = limit or MUSIC_FILL_PER_CALL local free = music.source:getFreeBufferCount() while free > 0 and limit > 0 and not music.engine:finished() do music.source:queue(ChipSynth.soundData(music.engine, MUSIC_BUFFER_SAMPLES, 2)) free = free - 1 limit = limit - 1 end 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, SAMPLE_RATE, 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 source:play() end return source end -- --------------------------------------------------------------------------- -- threaded music -- --------------------------------------------------------------------------- local musicGen = 0 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, SAMPLE_RATE, 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() }) currentMusic = { source = source, gen = gen, threaded = true, started = false, finished = false } -- playback starts in update() once the first buffer arrives (~1 frame) return source end local function pushChannelMix() if workerReady and cmdCh then cmdCh:push({ cmd = "channelMix", volumes = ChipSynth.getChannelVolumes(), pitches = ChipSynth.getChannelPitches() }) 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 free = m.source:getFreeBufferCount() 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.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 free > 0 then m.source:queue(buf.sd) else pendingBuf = buf -- Source full; hold this one for next frame break end end end if not m.started and not musicHeld then if (MUSIC_BUFFER_COUNT - m.source:getFreeBufferCount()) > 0 then pcall(function() m.source:play() end) m.started = true end end end function ChipAudio.update() local m = currentMusic if not m then return end if m.threaded then updateThreaded() else fillSync() end 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() 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 ok and not playing and (MUSIC_BUFFER_COUNT - m.source:getFreeBufferCount()) > 0 then pcall(function() m.source:play() end) 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) 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 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() 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 = false 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 -- --------------------------------------------------------------------------- local function renderEffect(data, header, options) local sd = ChipSynth.renderEffectData(data, header, options) if not sd then return nil end return love.audio.newSource(sd, "static") end function ChipAudio.newSfx(data, name, pitch, tempo, header) header = header or data.audio.sfx[name] return renderEffect(data, header, { frequencyOffset = pitch or 0, frameTicks = 0x80 + (tempo or 0x80), }) 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 = resolved or (data.audio.cries and data.audio.cries[species]) if not cry then return nil end return renderEffect(data, cry.chip and cry or cry.header, { frequencyOffset = cry.pitch, cryLength = cry.length, }) end function ChipAudio.newLowHealthAlarm() local samples = math.floor(SAMPLE_RATE * 62 / 60) local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 1) local phase = 0 for index = 0, samples - 1 do local frame = math.floor(index * 60 / SAMPLE_RATE) % 31 local register = frame < 11 and 0x750 or 0x6EE local frequency = 131072 / (2048 - register) phase = (phase + frequency / SAMPLE_RATE) % 1 data:setSample(index, (phase < 0.5 and 1 or -1) * 0.25) end return love.audio.newSource(data, "static") end -- --------------------------------------------------------------------------- -- test hooks (headless): synchronous synthesis straight through ChipSynth -- --------------------------------------------------------------------------- -- 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 * SAMPLE_RATE), 2) end function ChipAudio._renderMusicChannelForTest(data, header, seconds, number) 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 * SAMPLE_RATE), 1) end return ChipAudio