-- Sound effects and cries synthesized from compact ROM channel programs, -- from def-local chip programs (ChipAsm), or loaded from file definitions -- -- the branch is chosen per definition, not by a global import flag. Sources -- are cached; a definition that fails to load caches as `false` so it is -- logged once and skipped, never disabling the rest of the audio. Headless -- use is a safe no-op. local Assets = require("src.render.Assets") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local Sound = {} local cache = {} -- port addition: 0-7 SFX volume from save.options.sfxVol (OptionsMenu), -- scaling the 0.8 base every source gets local BASE_VOLUME = 0.8 local volumeScale = 1 -- port addition (Yellow only): 0-7 trim from save.options.pikaVol on top of -- the SFX level, for Pikachu's voice clips alone. Yellow voices every -- Pikachu cry with PCM samples that are far louder and far more frequent -- than the chip cries around them (the follower alone talks on every -- interaction), so this is the one source players want to pull down -- without muting the rest of the SFX bus. 7 = untouched, 0 = silent. local pikaScale = 1 -- cache keys whose volume the Pikachu trim applies to: the PCM clips, plus -- the chip PIKACHU cry that a Yellow cache without extracted clips falls -- back to (playCry). Red/Blue never reach the second branch, so a shared -- options.lua carrying a low pikaVol cannot quiet their Pikachu. local function isPikaKey(key) if type(key) ~= "string" then return false end if key:sub(1, 8) == "pikacry:" then return true end return key == "cry:PIKACHU" and require("src.core.GameVersion").isYellow() end local function volumeFor(key) local scale = volumeScale if isPikaKey(key) then scale = scale * pikaScale end return BASE_VOLUME * scale end -- Fanfares occupy the music's tone channels on the Game Boy: their sfx -- headers claim channels 5-7 (= hardware channels 1-3), silencing the -- song until they finish (audio/headers/sfxheaders*.asm; the game also -- blocks on them via PlaySoundWaitForCurrent/WaitForSoundToFinish). -- The Poké Flute even issues SFX_STOP_ALL_MUSIC first -- (engine/items/item_effects.asm). Music.lua pauses the current song -- while one of these plays and resumes it afterwards. Ordinary short -- SFX (menu beeps, hits, cries) stay overlaid. -- data.audio.fanfares supersedes this; the copy stays as the fallback for -- caches built before the importer wrote the table, and a def may claim the -- behavior for itself with fanfare = true. local FANFARES = { Level_Up = true, Caught_Mon = true, Get_Item1 = true, Get_Item2 = true, Get_Key_Item = true, Pokedex_Rating = true, Dex_Page_Added = true, Pokeflute = true, } -- which mod put this key in the registry, for attributed failure logs local function owner(data, kind, key) local owners = data and data.audio and data.audio._owners local map = owners and owners[kind] return map and map[key] or "base" end -- one log line, plus an entry in the loader's error feed when a mod owns the -- def, so the manager's errors screen can flag that mod local function reportBadDef(kind, key, who, err) Logger.warn("audio: bad %s def %q (mod %s): %s", kind, key, who, tostring(err)) Runtime.reportError(who, ("audio: bad %s def %q: %s"):format(kind, key, tostring(err))) end local function isChipDef(def) return type(def) == "table" and (def.chip ~= nil or def.address ~= nil) end -- OpenAL only spatializes 1-channel Sources, and one left at the default -- (0,0,0) position sits on top of the listener, which OpenAL renders as an -- ambient sound spread over every output channel the device has: on an -- interface with more than two outputs the SFX also came out of outputs 5+6 -- while the 2-channel music stayed on 1+2 (#626). A Source cannot change its -- channel count after the fact, so a mono file def is re-decoded and its -- sample duplicated into a stereo buffer, which OpenAL never spatializes. -- 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. -- -- 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 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 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(), 16, 2) for index = 0, frames - 1 do local value = mono:getSample(index) stereo:setSample(index, 1, value) stereo:setSample(index, 2, value) end return love.audio.newSource(stereo, "static") 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 -- a file def carries an optional playback rate; a bare string is shorthand -- for { file = } local function newFileSource(def) local file = type(def) == "table" and def.file or def if type(file) ~= "string" then return nil, "no chip program and no file" end local ok, s = pcall(love.audio.newSource, file, "static") if not ok or not s then return nil, ok and "no source" or tostring(s) end s = widenMono(s, file) -- keep mono defs off the surround channels (#626) if type(def) == "table" and def.pitch then pcall(s.setPitch, s, def.pitch) end return s end local function newSfxSource(data, key, def, pitch, tempo) if isChipDef(def) then local ok, s = pcall(require("src.core.ChipAudio").newSfx, data, key:match("^([^@]+)") or key, pitch, tempo, def) if not ok then return nil, tostring(s) end if not s then return nil, "no source" end return s end return newFileSource(def) end local function playPath(data, key, def, pitch, tempo) if not love.audio or not def then return nil end local src = cache[key] if src == false then return nil end -- known bad, already logged if not src then local s, err = newSfxSource(data, key, def, pitch, tempo) if not s then cache[key] = false reportBadDef("sfx", key, owner(data, "sfx", key), err) return nil end s:setVolume(volumeFor(key)) cache[key] = s src = s end src:stop() src:play() return src end local function ducks(data, name, def) if type(def) == "table" and def.fanfare then return true end local fanfares = data.audio and data.audio.fanfares or FANFARES return fanfares[name] and true or false end local function played(kind, name, species) if not Runtime.wants("sound.played") then return end Runtime.emit("sound.played", { kind = kind, name = name, species = species }) end -- returns the started source (nil headless, or when the def failed to load) -- so callers that block on a fanfare like the original's -- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it function Sound.play(data, name) local sfx = data.audio and data.audio.sfx local def = sfx and sfx[name] local src = playPath(data, name, def) if not src then return end if ducks(data, name, def) then require("src.core.Music").duckForFanfare(src) end played("sfx", name) return src end -- Play a move's sound with its MoveSoundTable pitch/tempo modifiers -- (data/moves/sfx.asm; GetMoveSound loads them into wFrequencyModifier/ -- wTempoModifier and the battle sound engine applies them to every -- battle SFX -- audio/engine_2.asm Audio2_ApplyFrequencyModifier/ -- Audio2_SetSfxTempo). The extractor pre-synthesizes one WAV per -- distinct (sfx, pitch, tempo) as "@" keys in the -- sfx table; older audio.lua builds without the variants fall back to -- the unmodified sound. -- anim: a moves.lua anim table { sound, pitch, tempo }. -- -- Whether a row sound is heard at all is Audio2_PlaySound's channel gate -- (audio/engine_2.asm .playSfx/.sfxChannelLoop): for every channel the new -- sfx wants, a channel still busy with a LOWER sound id aborts the whole -- request (`cp [hl] / jr z,.playChannel / jr c,.playChannel / ret`), while -- an equal or lower id takes those channels over. A sound id is -- (header address - SFX_Headers_1) / 3 (constants/music_constants.asm -- music_const), so a def's header address orders ids inside one engine -- bank. Blizzard's animation is two rows, BLIZZARD then HYDRO_PUMP -- (data/moves/animations.asm BlizzardAnim), and SFX_BATTLE_29 (CHAN5+8) is -- still sounding when the second row starts, so the original never plays -- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running -- past the end of the animation (#844). local lastMoveSfx -- { src, rank, engine, channels } of the last row sound local function channelsOverlap(a, b) if not (a and b) then return false end for _, x in ipairs(a) do for _, y in ipairs(b) do if x == y then return true end end end return false end -- would PlaySound start this def now? Taking a channel over also stops the -- sound that held it, the way .playChannel resets the channel. local function sfxChannelGate(data, def) local cur = lastMoveSfx if not cur then return true end local ok, playing = pcall(cur.src.isPlaying, cur.src) if not (ok and playing) then lastMoveSfx = nil return true end -- an unrankable def (file asset, or another engine's bank) has no -- comparable sound id: leave it to the mixer, as before if type(def) ~= "table" or not def.address or def.engine ~= cur.engine then return true end local channels = require("src.core.ChipSynth").effectChannels(data, def) if not channelsOverlap(channels, cur.channels) then return true end if def.address > cur.rank then return false end pcall(cur.src.stop, cur.src) lastMoveSfx = nil return true end local function noteMoveSfx(data, def, src) if not src or type(def) ~= "table" or not def.address then lastMoveSfx = nil return end lastMoveSfx = { src = src, rank = def.address, engine = def.engine, channels = require("src.core.ChipSynth").effectChannels(data, def), } end function Sound.playMove(data, anim) if not anim or not anim.sound then return end local sfx = data.audio and data.audio.sfx if not sfx then return end local name = anim.sound local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80 local def = sfx[name] if not sfxChannelGate(data, def) then return end local src -- a chip program synthesizes the modified variant on demand; a file def -- can only reach for a pre-rendered one if isChipDef(def) then src = playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo), def, pitch, tempo) else local key = ("%s@%02x%02x"):format(name, pitch, tempo) if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then src = playPath(data, key, sfx[key]) else src = playPath(data, name, def) end end if src then played("move", name) noteMoveSfx(data, def, src) end end -- A derived cry ({ base = "RHYDON", pitch, length }) borrows another -- species' program and applies its own modifiers, so a new species needs no -- assets at all. Chains are followed; the modifiers nearest the caller win. local function resolveCry(data, def, depth) if type(def) ~= "table" or not def.base then return def end if depth > 8 then return nil, "cry base chain too deep" end local cries = data.audio and data.audio.cries local baseDef = cries and cries[def.base] if not baseDef then return nil, "unknown base cry " .. tostring(def.base) end local resolved, err = resolveCry(data, baseDef, depth + 1) if not resolved then return nil, err end if type(resolved) ~= "table" or not (resolved.header or resolved.chip) then return nil, "base cry " .. tostring(def.base) .. " is not a chip program" end return { header = resolved.header, chip = resolved.chip, pitch = def.pitch or resolved.pitch, length = def.length or resolved.length, } end local function newCrySource(data, species, def) local resolved, err = resolveCry(data, def, 0) if not resolved then return nil, err end if type(resolved) == "table" and (resolved.header or resolved.chip) then local ok, s = pcall( require("src.core.ChipAudio").newCry, data, species, resolved) if not ok then return nil, tostring(s) end if not s then return nil, "no source" end return s end return newFileSource(resolved) end -- Yellow's voiced Pikachu clips (audio/pikachu_pcm.asm -- PlayPikachuSoundClip): 1-bit PCM decoded to WAVs at import -- (data.audio.pikaCries = clip count). Returns the source, nil when the -- cache carries no clips (Red/Blue) or headless. function Sound.playPikaCry(data, n) if not love.audio then return nil end local count = data.audio and data.audio.pikaCries if not count then return nil end n = math.max(1, math.min(count, n or 1)) local key = "pikacry:" .. n local src = cache[key] if src == false then return nil end if not src then local path = ("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n) local ok, s = pcall(love.audio.newSource, path, "static") if not ok or not s then cache[key] = false return nil end -- 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 src = s end src:stop() src:play() played("cry", "PIKACHU_PCM_" .. n, "PIKACHU") return src end -- returns the source (nil headless) so callers that block on the cry -- like the original's PlayCry -> WaitForSoundToFinish can poll it function Sound.playCry(data, species, pikaClip) if not love.audio then return nil end -- Yellow voices every Pikachu cry with the PCM clips (the chip cry is -- never used for the species there). Which clip is a property of the -- call site in the original -- every caller of PlayPikachuSoundClip sets -- its own `ldpikacry e, PikachuCryN` -- so pikaClip carries that choice -- in; it is ignored for every other species. Clip 1 is the LONG -- title-screen "Pikachuuu" (engine/movie/title.asm:146), kept as the -- default only for the sites that have not been given their own clip -- yet; battle entrances pass 11/37 (#837). if species == "PIKACHU" then local src = Sound.playPikaCry(data, pikaClip or 1) if src then return src end end local cries = data.audio and data.audio.cries local def = cries and cries[species] if not def then return nil end local key = "cry:" .. tostring(species) local src = cache[key] if src == false then return nil end if not src then local s, err = newCrySource(data, species, def) if not s then cache[key] = false reportBadDef("cry", tostring(species), owner(data, "cries", species), err) return nil end s:setVolume(volumeFor(key)) cache[key] = s src = s end src:stop() src:play() played("cry", species, species) return src end -- GROWL/ROAR are the only two moves that play a cry (IsCryMove checks -- wAnimationID); GetMoveSound still adds their own MoveSoundTable pitch/ -- tempo bytes on top of the cry's species modifiers before the tempo -- register is set (Audio2_SetSfxTempo: tempo9bit = wTempoModifier+$80). -- $80 is the table's "no extra shift" tempo byte (every other move's -- entry defaults to it), so the two moves' own bytes -- Growl's $c0, -- Roar's $40 -- are the *extra* shift on top of whatever the species' -- cry already sounds like. The generated cry source already includes the -- species' pitch/tempo, so layer the move's extra shift on with -- Source:setPitch (pitch mod is left unmodeled: both moves set it $00). function Sound.playMoveCry(data, species, tempoMod) local src = Sound.playCry(data, species) if src and tempoMod and tempoMod ~= 0x80 then pcall(src.setPitch, src, 256 / (128 + tempoMod)) end return src end -- is a previously played one-shot still sounding? (ShakeElevator's -- .musicLoop polls wChannelSoundIDs+CHAN5 until SFX_SAFARI_ZONE_PA -- ends.) Headless / never-played names read as silent. function Sound.isPlaying(name) local src = cache[name] if not src then return false end local ok, playing = pcall(src.isPlaying, src) return ok and playing or false end -- cut a one-shot short (the SFX_STOP_ALL_MUSIC beats around the -- elevator shake stop the last collision thud mid-ring) function Sound.stop(name) local src = cache[name] if src then pcall(src.stop, src) end end -- Looping sources (the low-health alarm): started/stopped by game -- states. ChipAudio generates the two-tone siren used by runtime imports; -- legacy data can still provide a looping static source. local loopCache = {} local looping = {} function Sound.startLoop(data, name) if looping[name] then return end if not love.audio then return end local sfx = data.audio and data.audio.sfx local def = sfx and sfx[name] local alarm = not def and name == "Low_Health_Alarm" if not def and not alarm then return end local src = loopCache[name] if src == false then return end if not src then local s, err if alarm then -- the synthesized siren is the default, not the rule: a registered -- Low_Health_Alarm def of any shape replaces it local ok, generated = pcall(require("src.core.ChipAudio").newLowHealthAlarm) if ok then s = generated else err = tostring(generated) end else s, err = newSfxSource(data, name, def) end if not s then loopCache[name] = false reportBadDef("sfx", name, owner(data, "sfx", name), err or "no source") return end s:setLooping(true) s:setVolume(volumeFor(name)) loopCache[name] = s src = s end src:play() looping[name] = src end function Sound.stopLoop(name) local src = looping[name] if src then pcall(src.stop, src) looping[name] = nil end end -- is a looping source currently sounding? (drivers assert on this) function Sound.isLooping(name) return looping[name] ~= nil end -- 0-7 SFX volume level (0 mutes); cached sources (menu beeps, cries, -- the low-health alarm loop) update immediately so the change is heard -- on the next play local function reapplyVolumes() for key, src in pairs(cache) do if src then pcall(src.setVolume, src, volumeFor(key)) end end for key, src in pairs(loopCache) do if src then pcall(src.setVolume, src, volumeFor(key)) end end end function Sound.setVolumeLevel(level) volumeScale = math.max(0, math.min(7, level or 7)) / 7 reapplyVolumes() end -- 0-7 Pikachu-voice trim on top of the SFX level (7 = no trim, 0 mutes the -- clips while the rest of the SFX bus keeps its level). Yellow only: on -- Red/Blue no cached key answers isPikaKey, so this is inert there. function Sound.setPikaVolumeLevel(level) pikaScale = math.max(0, math.min(7, level or 7)) / 7 reapplyVolumes() end -- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo -- variants included) or all of them, so the next play re-resolves the def function Sound.invalidate(name) lastMoveSfx = nil -- its source is about to be dropped or stopped local function evict(store, key) local src = store[key] if src then pcall(src.stop, src) end store[key] = nil end for _, store in ipairs({ cache, loopCache }) do for key in pairs(store) do if not name or key == name or key:sub(1, #name + 1) == name .. "@" then evict(store, key) end end end for key, src in pairs(looping) do if not name or key == name then pcall(src.stop, src) looping[key] = nil end end end -- the flush fan-out calls with no key, dropping everything, so an edited -- def is re-resolved on the next play (20 §2 cache contract, audio row) Assets.register(Sound.invalidate) -- re-apply persisted audio options (Game calls this on boot and after -- loading a save) function Sound.applyOptions(opts) Sound.setVolumeLevel(opts and opts.sfxVol or 7) Sound.setPikaVolumeLevel(opts and opts.pikaVol or 7) end return Sound