mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
fix(switch): widen Pikachu PCM to 16-bit stereo without Source channel probe
Source:getChannelCount could skip the #626 widen on love-nx, and keeping 8-bit depth into a stereo buffer still sounded wrong on audren. Decode the file via newSoundData, always emit 16-bit stereo like ChipSynth, and write fresh pika-cry WAVs as stereo at extract time so re-imports skip the hop. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+25
-11
@@ -92,19 +92,30 @@ end
|
||||
-- Chip SFX and cries are already stereo at the source (ChipSynth
|
||||
-- renderEffectData); this covers file defs, i.e. Yellow's 8-bit mono PCM
|
||||
-- Pikachu clips (RomExtractor extractPikachuCries) and mod-supplied wav/ogg
|
||||
-- SFX. Every step is guarded: a headless love stub without love.sound, or a
|
||||
-- decoder that will not hand back SoundData, keeps the original Source.
|
||||
-- SFX.
|
||||
--
|
||||
-- Decode the FILE (not Source:getChannelCount): love-nx/audren has reported
|
||||
-- channel counts that skip this widen silently, and preserving 8-bit depth
|
||||
-- into a stereo buffer also sounds wrong on that backend. Always emit
|
||||
-- 16-bit stereo like ChipSynth. Failure keeps the original Source and logs.
|
||||
local function widenMono(source, file)
|
||||
if not (source and love.sound and love.sound.newSoundData) then
|
||||
if type(file) ~= "string" then return source end
|
||||
if not (love.sound and love.sound.newSoundData and love.audio
|
||||
and love.audio.newSource) then
|
||||
return source
|
||||
end
|
||||
-- Quiet skip when the path is unreadable (headless stub SFX keys, missing
|
||||
-- files). On NX, overlay-wrapped getInfo makes the yellow|blue copy visible
|
||||
-- at the bare assets/generated path so the widen still runs.
|
||||
local fs = love.filesystem
|
||||
if not (fs and fs.getInfo and fs.getInfo(file)) then
|
||||
return source
|
||||
end
|
||||
local ok, channels = pcall(function() return source:getChannelCount() end)
|
||||
if not ok or channels ~= 1 then return source end
|
||||
local built, widened = pcall(function()
|
||||
local mono = love.sound.newSoundData(file)
|
||||
if mono:getChannelCount() ~= 1 then return source end
|
||||
local frames = mono:getSampleCount()
|
||||
local stereo = love.sound.newSoundData(frames, mono:getSampleRate(),
|
||||
mono:getBitDepth(), 2)
|
||||
local stereo = love.sound.newSoundData(frames, mono:getSampleRate(), 16, 2)
|
||||
for index = 0, frames - 1 do
|
||||
local value = mono:getSample(index)
|
||||
stereo:setSample(index, 1, value)
|
||||
@@ -112,7 +123,10 @@ local function widenMono(source, file)
|
||||
end
|
||||
return love.audio.newSource(stereo, "static")
|
||||
end)
|
||||
if built and widened then return widened end
|
||||
if built and widened and widened ~= source then return widened end
|
||||
if not built then
|
||||
Logger.warn("sound: widenMono failed for %s: %s", file, tostring(widened))
|
||||
end
|
||||
return source
|
||||
end
|
||||
|
||||
@@ -274,9 +288,9 @@ function Sound.playPikaCry(data, n)
|
||||
cache[key] = false
|
||||
return nil
|
||||
end
|
||||
-- the importer writes these clips as 8-bit mono (RomExtractor
|
||||
-- extractPikachuCries), so they need the same widening as the chip
|
||||
-- effects to stay off a multi-output device's surround channels (#626)
|
||||
-- importer historically wrote these as 8-bit mono (RomExtractor
|
||||
-- extractPikachuCries); widenMono re-decodes to 16-bit stereo so they
|
||||
-- stay off surround outputs (#626). Fresh extracts are already stereo.
|
||||
s = widenMono(s, path)
|
||||
s:setVolume(volumeFor(key))
|
||||
cache[key] = s
|
||||
|
||||
@@ -2023,21 +2023,23 @@ end
|
||||
-- PikachuCriesPointerTable, 42 `dba` rows; each clip is `dw length` then
|
||||
-- 1-bit PCM, MSB first -- home/pikachu_cries.asm PlayPikachuPCM toggles
|
||||
-- rAUD3LEVEL per bit at roughly 190 CPU cycles a sample). Decoded to
|
||||
-- plain 8-bit mono WAVs; returns the clip count for data.audio.pikaCries,
|
||||
-- 16-bit stereo WAVs (identical L/R) so OpenAL never spatializes them as
|
||||
-- ambient surround (#626); returns the clip count for data.audio.pikaCries,
|
||||
-- or nil when the manifest has no pointer table (Red/Blue).
|
||||
function RomExtractor:extractPikachuCries()
|
||||
if not self.symbols["PikachuCriesPointerTable"] then return nil end
|
||||
local NUM = 42 -- NUM_PIKA_CRIES
|
||||
local RATE = 22050 -- ~4.19 MHz / ~190 cycles per sample
|
||||
-- byte -> 8 samples, MSB first (LoadNextSoundClipSample: `and $80`)
|
||||
-- byte -> 8 mono sample levels, MSB first (LoadNextSoundClipSample: `and $80`)
|
||||
-- levels match the old unsigned-8 WAV (on=0xE0, off=0x20) as floats in [-1,1]
|
||||
local lut = {}
|
||||
for byte = 0, 255 do
|
||||
local out = {}
|
||||
for bit = 7, 0, -1 do
|
||||
local on = math.floor(byte / 2 ^ bit) % 2 == 1
|
||||
out[#out + 1] = string.char(on and 0xE0 or 0x20)
|
||||
out[#out + 1] = on and ((0xE0 - 128) / 128) or ((0x20 - 128) / 128)
|
||||
end
|
||||
lut[byte] = table.concat(out)
|
||||
lut[byte] = out
|
||||
end
|
||||
local function u16(v)
|
||||
return string.char(v % 256, math.floor(v / 256) % 256)
|
||||
@@ -2046,6 +2048,12 @@ function RomExtractor:extractPikachuCries()
|
||||
return string.char(v % 256, math.floor(v / 256) % 256,
|
||||
math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256)
|
||||
end
|
||||
local function i16le(f)
|
||||
local v = math.floor(f * 32767 + (f >= 0 and 0.5 or -0.5))
|
||||
if v > 32767 then v = 32767 elseif v < -32768 then v = -32768 end
|
||||
if v < 0 then v = v + 65536 end
|
||||
return string.char(v % 256, math.floor(v / 256) % 256)
|
||||
end
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local pointers = self:symbol("PikachuCriesPointerTable")
|
||||
for index = 0, NUM - 1 do
|
||||
@@ -2054,11 +2062,16 @@ function RomExtractor:extractPikachuCries()
|
||||
local header = self.rom:bytes(bank, address, 2)
|
||||
local length = header[1] + header[2] * 256
|
||||
local raw = self.rom:bytes(bank, address + 2, length)
|
||||
local samples = {}
|
||||
for i, byte in ipairs(raw) do samples[i] = lut[byte] end
|
||||
local pcm = table.concat(samples)
|
||||
local parts = {}
|
||||
for _, byte in ipairs(raw) do
|
||||
for _, level in ipairs(lut[byte]) do
|
||||
local s = i16le(level)
|
||||
parts[#parts + 1] = s .. s -- identical L/R (#626)
|
||||
end
|
||||
end
|
||||
local pcm = table.concat(parts)
|
||||
local wav = "RIFF" .. u32(36 + #pcm) .. "WAVEfmt " .. u32(16)
|
||||
.. u16(1) .. u16(1) .. u32(RATE) .. u32(RATE) .. u16(1) .. u16(8)
|
||||
.. u16(1) .. u16(2) .. u32(RATE) .. u32(RATE * 4) .. u16(4) .. u16(16)
|
||||
.. "data" .. u32(#pcm) .. pcm
|
||||
local ok, err = CacheFs.write(
|
||||
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(index + 1),
|
||||
|
||||
@@ -74,8 +74,11 @@ eq(type(chunk) == "function" and chunk() or nil, 42,
|
||||
"wrapped filesystem.load resolves the versioned chunk")
|
||||
|
||||
local sd = love.sound.newSoundData(PNG)
|
||||
eq(sd.samples, "yellow/" .. PNG,
|
||||
eq(sd.path, "yellow/" .. PNG,
|
||||
"wrapped newSoundData receives the yellow/ path (widenMono's re-read)")
|
||||
eq(sd:getChannelCount(), 1,
|
||||
"path-form newSoundData stays mono so widenMono has work to do")
|
||||
eq(sd:getBitDepth(), 8, "path-form stub mimics the 8-bit pika-cry WAVs")
|
||||
|
||||
local fnt = love.graphics.newFont(14)
|
||||
check(fnt ~= nil, "wrapped newFont ignores non-path arguments")
|
||||
|
||||
@@ -92,15 +92,18 @@ function Source:play() self.playing = true end
|
||||
function Source:stop() self.playing = false end
|
||||
function Source:setVolume() end
|
||||
function Source:isPlaying() return self.playing end
|
||||
-- Mono so Sound.widenMono actually reaches newSoundData (the stub's path
|
||||
-- form then fails inside the pcall and widenMono keeps this Source --
|
||||
-- enough to prove the overlay rewrote the re-read path).
|
||||
function Source:getChannelCount() return 1 end
|
||||
function Source:getChannelCount() return self.channels or 1 end
|
||||
|
||||
love.audio = {
|
||||
newSource = function(path, mode)
|
||||
record("source", path)
|
||||
return setmetatable({ path = path, mode = mode }, Source)
|
||||
newSource = function(pathOrData, mode)
|
||||
record("source", pathOrData)
|
||||
local channels = 1
|
||||
if type(pathOrData) == "table" and pathOrData.getChannelCount then
|
||||
channels = pathOrData:getChannelCount()
|
||||
end
|
||||
return setmetatable({
|
||||
path = pathOrData, mode = mode, channels = channels,
|
||||
}, Source)
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -227,20 +230,26 @@ eq(pre and pre.nidoFrames and pre.nidoFrames[3]
|
||||
|
||||
-- Yellow's voiced Pikachu clip: a FORMATTED path (cry_%02d.wav), invisible
|
||||
-- to the static guard. playPikaCry also runs widenMono, which re-reads the
|
||||
-- same bare path via newSoundData -- that second hop is what the full-surface
|
||||
-- overlay exists to cover.
|
||||
-- same bare path via newSoundData and must emit a 16-bit STEREO Source
|
||||
-- (#626) -- path rewrite alone is not enough on Switch audren.
|
||||
local cry = Sound.playPikaCry({ audio = { pikaCries = 1 } }, 1)
|
||||
check(cry ~= nil, "playPikaCry returns a source on NX Yellow")
|
||||
eq(cry and cry.path, "yellow/assets/generated/audio/pika_cries/cry_01.wav",
|
||||
"the formatted pika-cry path resolves to the yellow/ copy")
|
||||
eq(cry and cry:getChannelCount(), 2,
|
||||
"playPikaCry widens the mono PCM clip to stereo")
|
||||
local sawCrySoundData = false
|
||||
local sawCrySource = false
|
||||
for _, r in ipairs(recorded) do
|
||||
if r.kind == "sounddata"
|
||||
and r.path == "yellow/assets/generated/audio/pika_cries/cry_01.wav" then
|
||||
sawCrySoundData = true
|
||||
break
|
||||
end
|
||||
if r.kind == "source"
|
||||
and r.path == "yellow/assets/generated/audio/pika_cries/cry_01.wav" then
|
||||
sawCrySource = true
|
||||
end
|
||||
end
|
||||
check(sawCrySource,
|
||||
"newSource loaded the cry through the yellow/ prefix")
|
||||
check(sawCrySoundData,
|
||||
"widenMono re-read the cry via newSoundData with the yellow/ path")
|
||||
|
||||
|
||||
@@ -278,6 +278,25 @@ function SoundData:getDuration() return self.samples / self.rate end
|
||||
|
||||
stub.sound = {
|
||||
newSoundData = function(samples, rate, bits, channels)
|
||||
-- Path form (love.sound.newSoundData(filename)): only succeed when the
|
||||
-- stub FS has the file, matching real LÖVE. Synthesize a short mono
|
||||
-- 8-bit buffer so Sound.widenMono can run headless for seeded paths
|
||||
-- (pika cries); missing files must error so widenMono keeps the
|
||||
-- original Source (give_item_jingle identity checks, etc.).
|
||||
if type(samples) == "string" then
|
||||
if not stub.filesystem.getInfo(samples) then
|
||||
error("Could not open file " .. samples .. ". Does not exist.")
|
||||
end
|
||||
local n = 32
|
||||
local sd = setmetatable({
|
||||
samples = n, rate = rate or 22050, bits = bits or 8,
|
||||
channels = channels or 1, data = {}, path = samples,
|
||||
}, SoundData)
|
||||
for i = 0, n - 1 do
|
||||
sd:setSample(i, (i % 2 == 0) and 0.5 or -0.5)
|
||||
end
|
||||
return sd
|
||||
end
|
||||
return setmetatable({ samples = samples, rate = rate or 44100,
|
||||
bits = bits or 16, channels = channels or 1, data = {} }, SoundData)
|
||||
end,
|
||||
|
||||
Reference in New Issue
Block a user