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:
Andrew Quenehen
2026-08-03 16:35:18 -03:00
parent 9ef5c8dead
commit 0dd6ecc219
5 changed files with 90 additions and 32 deletions
+25 -11
View File
@@ -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
+21 -8
View File
@@ -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")
+21 -12
View File
@@ -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")
+19
View File
@@ -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,