mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 12:15:31 +02:00
@@ -0,0 +1,283 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local sources = {}
|
||||
|
||||
local Source = {}
|
||||
Source.__index = Source
|
||||
function Source:play() self.playing = true end
|
||||
function Source:stop() self.playing = false end
|
||||
function Source:pause() self.playing = false end
|
||||
function Source:isPlaying() return self.playing end
|
||||
function Source:setLooping(v) self.looping = v end
|
||||
function Source:setVolume(v) self.volume = v end
|
||||
function Source:setPitch(v) self.pitch = v end
|
||||
function Source:setFilter() self.filters = (self.filters or 0) + 1 end
|
||||
function Source:getDuration() return 1 end
|
||||
function Source:getFreeBufferCount() return self.free end
|
||||
function Source:queue() self.free = math.max(0, self.free - 1) end
|
||||
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
local BUFFERS = ChipSynth.MUSIC_BUFFER_COUNT
|
||||
|
||||
local function track(src)
|
||||
sources[#sources + 1] = src
|
||||
return src
|
||||
end
|
||||
|
||||
love.audio = {
|
||||
newSource = function(what, mode)
|
||||
return track(setmetatable({ file = what, mode = mode, free = 0 }, Source))
|
||||
end,
|
||||
newQueueableSource = function()
|
||||
return track(setmetatable({ queueable = true, free = BUFFERS }, Source))
|
||||
end,
|
||||
}
|
||||
|
||||
local channels = {}
|
||||
local lastGen, lastEpoch = 0, 0
|
||||
|
||||
local Channel = {}
|
||||
Channel.__index = Channel
|
||||
function Channel:push(msg)
|
||||
if type(msg) == "table" and msg.cmd == "play" then
|
||||
lastGen, lastEpoch = msg.gen, msg.stereoEpoch or 0
|
||||
end
|
||||
self.queue[#self.queue + 1] = msg
|
||||
end
|
||||
function Channel:pop() return table.remove(self.queue, 1) end
|
||||
function Channel:clear() self.queue = {} end
|
||||
function Channel:getCount() return #self.queue end
|
||||
|
||||
local function channel(name)
|
||||
channels[name] = channels[name] or setmetatable({ queue = {} }, Channel)
|
||||
return channels[name]
|
||||
end
|
||||
|
||||
local threadStub = {
|
||||
newThread = function()
|
||||
return {
|
||||
start = function() end,
|
||||
getError = function() return nil end,
|
||||
wait = function() end,
|
||||
}
|
||||
end,
|
||||
getChannel = channel,
|
||||
}
|
||||
|
||||
local function deliverBuffer(epoch)
|
||||
channel("chipaudio_out"):push({
|
||||
gen = lastGen, sd = true, stereoEpoch = epoch or lastEpoch })
|
||||
end
|
||||
|
||||
local ChipAsm = require("src.audio.ChipAsm")
|
||||
|
||||
local function chipSong(octave)
|
||||
return ChipAsm.song{
|
||||
channels = { { hw = 1, program = {
|
||||
{ notetype = { speed = 12, volume = 12, fade = 0 } },
|
||||
{ octave = octave },
|
||||
{ note = "C", len = 8 },
|
||||
{ loop = { count = 0, to = 1 } },
|
||||
} } },
|
||||
}
|
||||
end
|
||||
|
||||
local function fixtureData()
|
||||
return {
|
||||
audio = {
|
||||
songs = {
|
||||
Music_PalletTown = chipSong(4),
|
||||
Music_Streamed = { file = "assets/song.ogg",
|
||||
loopFile = "assets/song_loop.ogg" },
|
||||
},
|
||||
sfx = { Press_AB = "assets/beep.wav" },
|
||||
cries = {},
|
||||
mapSongs = { PALLET_TOWN = "Music_PalletTown" },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
local function freshChipAudio(threaded)
|
||||
love.thread = threaded and threadStub or nil
|
||||
package.loaded["src.core.ChipAudio"] = nil
|
||||
return require("src.core.ChipAudio")
|
||||
end
|
||||
|
||||
local function lastSource() return sources[#sources] end
|
||||
|
||||
local function clearSources()
|
||||
for i = #sources, 1, -1 do sources[i] = nil end
|
||||
end
|
||||
|
||||
local function audioSuspend()
|
||||
require("src.core.ChipAudio").setSuspended(true)
|
||||
end
|
||||
|
||||
local function audioReset()
|
||||
local ChipAudio = require("src.core.ChipAudio")
|
||||
ChipAudio.setSuspended(false)
|
||||
ChipAudio.rebuildPlayback()
|
||||
Music.onDeviceReset()
|
||||
Sound.onDeviceReset()
|
||||
end
|
||||
|
||||
local ChipAudio = freshChipAudio(true)
|
||||
local data = fixtureData()
|
||||
Sound.invalidate()
|
||||
Music.reload()
|
||||
clearSources()
|
||||
channel("chipaudio_out"):clear()
|
||||
|
||||
Music.playMap(data, "PALLET_TOWN", false, false)
|
||||
local src = lastSource()
|
||||
check(src and src.queueable, "the map theme streams through ChipAudio")
|
||||
src.playing = false
|
||||
|
||||
audioSuspend()
|
||||
deliverBuffer()
|
||||
ChipAudio.update()
|
||||
eq(src.free, BUFFERS, "a suspended update queues nothing onto the dead source")
|
||||
check(not src.playing, "a suspended update does not start playback")
|
||||
eq(channel("chipaudio_out"):getCount(), 1,
|
||||
"the worker buffer waits in the channel instead of being dropped")
|
||||
|
||||
ChipAudio.setSuspended(true)
|
||||
ChipAudio.setSuspended(false)
|
||||
ChipAudio.setSuspended(false)
|
||||
check(not ChipAudio.isSuspended(), "the suspend flag is idempotent both ways")
|
||||
|
||||
ChipAudio.update()
|
||||
eq(src.free, BUFFERS - 1, "the resumed update queues the waiting buffer")
|
||||
check(src.playing, "the resumed update starts playback")
|
||||
|
||||
src.playing = false
|
||||
audioSuspend()
|
||||
ChipAudio.ensureMusicPlaying()
|
||||
check(not src.playing, "ensureMusicPlaying stays silent while suspended")
|
||||
ChipAudio.setSuspended(false)
|
||||
ChipAudio.ensureMusicPlaying()
|
||||
check(src.playing, "ensureMusicPlaying recovers again once output is back")
|
||||
|
||||
Music.setVolumeLevel(7)
|
||||
Music.setFilterLevel(0)
|
||||
local before = #sources
|
||||
audioSuspend()
|
||||
audioReset()
|
||||
eq(#sources, before + 1, "the reset built exactly one replacement source")
|
||||
local fresh = ChipAudio.currentSource()
|
||||
check(fresh ~= nil and fresh ~= src, "rebuildPlayback swapped in a fresh source")
|
||||
check(not src.playing, "the source from the dead device was stopped")
|
||||
eq(fresh.free, BUFFERS, "the replacement source starts empty")
|
||||
|
||||
deliverBuffer()
|
||||
ChipAudio.update()
|
||||
eq(fresh.free, BUFFERS - 1,
|
||||
"worker PCM tagged with the pre-reset gen and stereo epoch still queues")
|
||||
check(fresh.playing, "playback resumes on the first requeued buffer")
|
||||
|
||||
deliverBuffer(lastEpoch + 1)
|
||||
ChipAudio.update()
|
||||
eq(fresh.free, BUFFERS - 1,
|
||||
"a buffer from another stereo epoch is still dropped after the reset")
|
||||
|
||||
check(fresh.volume ~= nil, "Music re-applied volume to the replacement source")
|
||||
check((fresh.filters or 0) > 0, "Music re-applied the filter to it as well")
|
||||
Music.setVolumeLevel(0)
|
||||
eq(fresh.volume, 0, "the replacement source is what Music now drives")
|
||||
check(src.volume ~= 0, "the dead source is no longer Music's source")
|
||||
Music.setVolumeLevel(7)
|
||||
|
||||
local first = Sound.play(data, "Press_AB")
|
||||
check(first ~= nil and first.playing, "the sfx played")
|
||||
Sound.onDeviceReset()
|
||||
local second = Sound.play(data, "Press_AB")
|
||||
check(second ~= nil and second ~= first,
|
||||
"the sfx cache is empty after onDeviceReset; the next play re-renders")
|
||||
|
||||
Music.stop()
|
||||
before = #sources
|
||||
audioReset()
|
||||
audioReset()
|
||||
eq(#sources, before, "an audioreset with no music playing builds nothing")
|
||||
check(ChipAudio.currentSource() == nil, "and leaves no music behind")
|
||||
|
||||
Music.play(data, "Music_Streamed")
|
||||
local intro, loop = sources[#sources - 1], sources[#sources]
|
||||
check(intro ~= nil and intro.playing, "the streamed intro is sounding")
|
||||
check(loop ~= nil and not loop.playing, "its loop body waits its turn")
|
||||
before = #sources
|
||||
audioSuspend()
|
||||
audioReset()
|
||||
eq(#sources, before + 2, "the reset rebuilt both file sources")
|
||||
local newIntro, newLoop = sources[#sources - 1], sources[#sources]
|
||||
check(newIntro ~= intro and newIntro.playing,
|
||||
"the streamed song plays again from the top")
|
||||
check(newLoop ~= loop and not newLoop.playing,
|
||||
"its rebuilt loop body still waits its turn")
|
||||
check(not intro.playing, "the streamed source from the dead device was stopped")
|
||||
eq(Music.current(), "Music_Streamed", "and the song label is unchanged")
|
||||
|
||||
ChipAudio = freshChipAudio(false)
|
||||
Music.reload()
|
||||
clearSources()
|
||||
Music.playMap(data, "PALLET_TOWN", false, false)
|
||||
local syncSrc = lastSource()
|
||||
check(syncSrc and syncSrc.queueable and syncSrc.free < BUFFERS,
|
||||
"the sync path fills its queue on the frame the song starts")
|
||||
local filled = syncSrc.free
|
||||
audioSuspend()
|
||||
ChipAudio.update()
|
||||
eq(syncSrc.free, filled, "a suspended update fills nothing on the sync path")
|
||||
|
||||
audioReset()
|
||||
local syncFresh = ChipAudio.currentSource()
|
||||
check(syncFresh ~= syncSrc, "the sync path swaps in a fresh source too")
|
||||
check(syncFresh.free < BUFFERS and syncFresh.playing,
|
||||
"and refills and starts it right away")
|
||||
check(not syncSrc.playing, "the sync source from the dead device was stopped")
|
||||
|
||||
local function source(path)
|
||||
local f = io.open(path, "rb")
|
||||
check(f ~= nil, path .. " is readable")
|
||||
local text = f and f:read("*a") or ""
|
||||
if f then f:close() end
|
||||
return text
|
||||
end
|
||||
|
||||
local mainSrc = source("main.lua")
|
||||
local suspendHook =
|
||||
mainSrc:match("\nfunction love%.handlers%.audiosuspend%(%).-\nend\n")
|
||||
check(suspendHook ~= nil, "main.lua defines love.handlers.audiosuspend")
|
||||
suspendHook = suspendHook or ""
|
||||
check(suspendHook:find("setSuspended, true", 1, true) ~= nil,
|
||||
"the suspend handler gates ChipAudio")
|
||||
|
||||
local resetHook =
|
||||
mainSrc:match("\nfunction love%.handlers%.audioreset%(%).-\nend\n")
|
||||
check(resetHook ~= nil, "main.lua defines love.handlers.audioreset")
|
||||
resetHook = resetHook or ""
|
||||
local unGate = resetHook:find("setSuspended, false", 1, true)
|
||||
local rebuild = resetHook:find("rebuildPlayback", 1, true)
|
||||
local music = resetHook:find("Music.onDeviceReset", 1, true)
|
||||
local sound = resetHook:find("Sound.onDeviceReset", 1, true)
|
||||
check(unGate and rebuild and unGate < rebuild,
|
||||
"the reset handler lifts the gate before rebuilding playback")
|
||||
check(rebuild and music and rebuild < music,
|
||||
"Music re-binds after ChipAudio swapped the source in")
|
||||
check(music and sound and music < sound, "and the sfx cache is flushed last")
|
||||
check(resetHook:find('package.loaded["src.core.ChipAudio"]', 1, true) ~= nil,
|
||||
"the handlers cost a session that never touched audio nothing")
|
||||
|
||||
local workerSrc = source("src/core/chip_worker.lua")
|
||||
check(workerSrc:find("outCh:getCount() < LOOKAHEAD", 1, true) ~= nil,
|
||||
"the chip worker bounds its look-ahead instead of free-running")
|
||||
|
||||
T.finish("audio device reset")
|
||||
Reference in New Issue
Block a user