-- 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 -- A Gen 2 sfx header declares how many of the four sfx channels it wants -- (`channel_count N` in audio/sfx.asm), and sfx channel N takes hardware -- channel N over from the music channel with the same number for as long as -- it sounds. So a FOUR-channel sfx silences the song outright -- that is what -- the cart does with every jingle: Sfx_RegisterPhoneNumber, Sfx_GetTm, -- Sfx_GetBadge, Sfx_GetEgg, Sfx_Item, Sfx_CaughtMon, the eight dex fanfares. -- The port's fanfare table was six hardcoded names, so the phone-number jingle -- (and a dozen others) played OVER the music instead of replacing it. -- -- Three-channel sfx are NOT ducked even though they too silence three quarters -- of the song: most of them are battle move sounds (Psychic, Hyper Beam, Surf) -- that fire several times a second, and pausing/resuming the song under each -- one would stutter far worse than letting them overlay. The handful of -- three-channel JINGLES are named below instead. local GEN2_JINGLES = { Sfx_Fanfare = true, Sfx_Fanfare2 = true, Sfx_3rdPlace = true, Sfx_TrainArrived = true, } local FULL_BAND = 4 local channelCounts = {} -- per sfx name; the header read is not free local function claimsEveryChannel(data, name, def) if type(def) ~= "table" or not def.address then return false end -- Gen 2 only. Gen 1's fanfare set is already listed by name and its sfx -- headers count channels differently; widening the rule there would change -- Red/Blue behaviour for no reported reason. if def.generation ~= 2 then return false end local known = channelCounts[name] if known == nil then local ok, channels = pcall( require("src.core.ChipSynth").effectChannels, data, def) -- effectChannels answers nil for "not knowable HERE" -- a file def, or the -- program banks not readable yet (src/core/ChipSynth.lua effectChannels). -- That is a "not yet", not a channel count: memoizing it as zero would -- stamp a four-channel jingle as non-ducking for the rest of the session, -- so it plays over the map music until the next launch. Only a header -- that actually read is cached; a failed read is retried on the next play. if not (ok and channels) then return false end known = #channels channelCounts[name] = known end return known >= FULL_BAND 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 if fanfares[name] then return true end if GEN2_JINGLES[name] then return true end return claimsEveryChannel(data, name, def) end -- Does playing this sfx stop the song? Exposed so a test can assert the rule -- without an audio device. function Sound.ducksMusic(data, name) local sfx = data and data.audio and data.audio.sfx name = Sound.resolve(data, name) return ducks(data or {}, name, sfx and sfx[name]) 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 -- The shared UI names its sounds the way pokered does; Gen 2's sfx table is -- keyed by pokegold's own labels, so a Gold session asking for "Press_AB" -- finds nothing and the menu goes silent -- which is exactly what happened to -- the A-press beep on every Gold dialogue. Only the shared modules a Gold -- session actually enters need a row here, and today that is src/render/ -- TextBox.lua and src/ui/ChoiceBox.lua, both playing "Press_AB": the cart -- sounds SFX_READ_TEXT_2 at both of those moments (home/joypad.asm -- PromptButton for the textbox wait, home/menu.asm PlayClickSFX for a menu -- pick). Every other shared player of a pokered sfx name sits in a module -- Gold replaces under src/world/gen2 or src/ui/gen2, and those name their -- sounds in pokegold's labels directly. So a row belongs here only once a -- shared module is reachable from Gold, and its target is whatever the cart -- plays at that same moment -- not the nearest-sounding Gen 2 label. Sound.GEN2_ALIASES = { Press_AB = "Sfx_ReadText2", } -- The hop Sound.resolve took for a raw name, so the argument-only entry points -- (stop, isPlaying) can reach a source Sound.play cached under the resolved -- key without a data table of their own. local aliased = {} function Sound.resolve(data, name) local sfx = data and data.audio and data.audio.sfx if not sfx then return name end if sfx[name] then return name end local alias = Sound.GEN2_ALIASES[name] if alias and sfx[alias] then aliased[name] = alias return alias end return name end -- the source a raw name plays through, whichever key it ended up cached under local function cached(name) local src = cache[name] if src == nil then local key = aliased[name] if key then src = cache[key] end end return src end -- Gen 2's overworld/menu entry point is a PRIORITY GATE, not a bare play -- (home/audio.asm PlaySFX). It asks CheckSFX whether any of the four sfx -- channels is still sounding, and when one is it compares the id that owns -- them: `ld a, [wCurSFX] / cp e / jr c, .done` DROPS the new sound outright -- while the playing id is numerically lower (constants/sfx_constants.asm -- orders the table highest priority first). Only an id at or below wCurSFX -- falls through, and _PlaySFX turns off and re-zeroes ch5-ch8 before it loads -- the new header (audio/engine.asm _PlaySFX), cutting the old sound dead. -- Either way sfx NEVER layer here. SproutTower3FRivalScene is the plain -- case: `playsound SFX_TACKLE` ($41) then `playsound SFX_ELEVATOR` ($6e) one -- command later, with the two-note tackle still sounding, so the cart never -- plays the elevator rumble at all -- the pillar sways to the thud alone. -- -- Battle ANIMATION sounds are a different entry point and must not come -- through here: anim_sound reaches PlayStereoSFX (engine/battle_anims/ -- anim_commands.asm), which has no gate at all and, with stereo on, does not -- even clear the channels another sfx holds. That is Sound.playStereo below. local sfxIds -- { label -> SFX_* id }, derived from data.audio.sfxOrder local curSfx -- { src, id } of the last gated sfx that started, i.e. wCurSFX local function sfxIdFor(data, name) local order = data and data.audio and data.audio.sfxOrder if not order then return nil end if not sfxIds then sfxIds = {} -- sfxOrder is audio/sfx_pointers.asm in table order, so id = index - 1 -- (RomExtractorGen2 extractAudio writes it from constants.sfxOrder). for index, label in ipairs(order) do sfxIds[label] = index - 1 end end return sfxIds[name] end -- Would PlaySFX start this sound now? Answers false for `jr c, .done`, which -- the caller honours by dropping the request whole: a discarded sfx neither -- sounds nor ducks the music. The second return is the id to remember as -- wCurSFX once the sound actually starts. local function sfxPriorityGate(data, name, def) -- Gen 1 keeps today's behaviour: pokered's PlaySound arbitrates by channel -- rather than by a single wCurSFX, and Sound.playMove already ports that. if type(def) ~= "table" or def.generation ~= 2 then return true end local id = sfxIdFor(data, name) if not id then return true end if curSfx then local ok, playing = pcall(curSfx.src.isPlaying, curSfx.src) if not (ok and playing) then curSfx = nil -- CheckSFX returns no carry; wCurSFX stops mattering elseif curSfx.id < id then return false -- the sound already going outranks this one else pcall(curSfx.src.stop, curSfx.src) -- _PlaySFX zeroes ch5-ch8 first curSfx = nil end end return true, id end -- CheckSFX (home/audio.asm): is a gated sfx still sounding on ch5-ch8? This -- is the state WaitSFX blocks on, and it is the gate's OWN wCurSFX rather -- than whatever the caller last held a source for, so a sound started -- somewhere else entirely (the A-press beep a textbox plays) still answers -- busy here. Phone_StartRinging (engine/phone/phone.asm:564) is the caller -- that needs it: SFX_CALL is $6a, low enough that any louder sound still on -- the channels makes sfxPriorityGate DROP the ring outright, where the cart -- merely waits for it. function Sound.sfxBusy() if not curSfx then return false end local ok, playing = pcall(curSfx.src.isPlaying, curSfx.src) if not (ok and playing) then curSfx = nil -- CheckSFX returns no carry; wCurSFX stops mattering return false end return true end local function startSfx(data, name, def) 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 -- returns the started source (nil headless, when the def failed to load, or -- when the priority gate dropped the sound) 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 name = Sound.resolve(data, name) local def = sfx and sfx[name] local allowed, id = sfxPriorityGate(data, name, def) if not allowed then return end local src = startSfx(data, name, def) if src and id then curSfx = { src = src, id = id } end return src end -- PlayStereoSFX (audio/engine.asm), the battle animation path: same sound, -- same fanfare duck, but no CheckSFX/wCurSFX gate, and it never writes -- wCurSFX either -- so an animation sound can neither be dropped by, nor -- become, the priority the overworld path compares against. function Sound.playStereo(data, name) local sfx = data.audio and data.audio.sfx name = Sound.resolve(data, name) return startSfx(data, name, sfx and sfx[name]) 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 = cached(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 = cached(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 -- Same for wCurSFX, and a reloaded table can repoint the id order. curSfx = nil sfxIds = nil -- A replaced def may claim a different set of channels. if name then channelCounts[name] = nil else channelCounts = {} end -- A mod that registers the raw name outright ends the alias hop, so the -- memo has to be re-derived from the reloaded sfx table too. if name then aliased[name] = nil else aliased = {} end 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