diff --git a/main.lua b/main.lua index 5e220e44..4d13c0ea 100644 --- a/main.lua +++ b/main.lua @@ -208,6 +208,17 @@ local function applySavedOrientation() end) end +local function applySavedAudioMode() + local ok, savedOptions = pcall(function() + return require("src.core.SaveData").loadOptions() + end) + if not ok or type(savedOptions) ~= "table" then savedOptions = {} end + local mode = savedOptions.audioMode or "both" + if love and love.audio and love.audio.setMixWithSystem then + pcall(love.audio.setMixWithSystem, mode ~= "game_only") + end +end + local Game, EditorApp, Importer, TouchEditor, Studio, Prelaunch local launcherSplash @@ -861,6 +872,7 @@ function love.load(args) -- the launcher would rotate freely until options are applied at boot. -- No-op on desktop / iOS / when options.lua does not exist yet. applySavedOrientation() + applySavedAudioMode() -- Standalone editor. A bare `--editor` run has no launcher behind it, so -- Close quits; --save points it at a specific file, otherwise it opens the diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 49b52e44..7ad92472 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -906,6 +906,22 @@ bool hasBackgroundMusic() return result; } +bool setAudioMixWithSystem(bool mix) +{ + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jobject activity = (jobject) SDL_AndroidGetActivity(); + + jclass clazz(env->GetObjectClass(activity)); + jmethodID method_id = env->GetMethodID(clazz, "setAudioMixWithSystem", "(Z)Z"); + + jboolean result = env->CallBooleanMethod(activity, method_id, (jboolean) mix); + + env->DeleteLocalRef(activity); + env->DeleteLocalRef(clazz); + + return result; +} + bool hasRecordingPermission() { JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h index f6d2dc1b..a3c99cda 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -181,6 +181,8 @@ bool createStorageDirectories(); bool hasBackgroundMusic(); +bool setAudioMixWithSystem(bool mix); + bool hasRecordingPermission(); void requestRecordingPermission(); diff --git a/mobile/android/love/src/jni/love/src/modules/audio/Audio.cpp b/mobile/android/love/src/jni/love/src/modules/audio/Audio.cpp index 0724139f..54e01d6d 100644 --- a/mobile/android/love/src/jni/love/src/modules/audio/Audio.cpp +++ b/mobile/android/love/src/jni/love/src/modules/audio/Audio.cpp @@ -72,6 +72,8 @@ bool Audio::setMixWithSystem(bool mix) { #ifdef LOVE_IOS return love::ios::setAudioMixWithOthers(mix); +#elif defined(LOVE_ANDROID) + return love::android::setAudioMixWithSystem(mix); #else LOVE_UNUSED(mix); return false; diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 2a4ab702..b218a122 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -186,6 +186,7 @@ public class GameActivity extends SDLActivity { private static String initialGame = ""; private static String initialLaunchURI = ""; + private boolean audioMixWithSystem = true; private AudioManager.OnAudioFocusChangeListener audioFocusListener = null; private Object audioFocusRequest = null; private Object audioDeviceCallback = null; @@ -2274,10 +2275,63 @@ public class GameActivity extends SDLActivity { return freq; } + @Keep + public boolean setAudioMixWithSystem(final boolean mix) { + audioMixWithSystem = mix; + runOnUiThread(new Runnable() { + @Override + public void run() { + if (mix) { + abandonAudioFocus(); + try { + nativeAudioFocusGained(); + } catch (UnsatisfiedLinkError e) { + Log.d("GameActivity", "nativeAudioFocusGained failed in setAudioMixWithSystem", e); + } + } else { + requestGameAudioFocus(); + } + } + }); + return true; + } + private void requestGameAudioFocus() { - // Do not request exclusive AUDIOFOCUS_GAIN to allow background media - // (Spotify, YouTube, podcasts, etc.) to continue playing seamlessly. - // Android's native audio mixer will mix game audio with background apps. + if (audioMixWithSystem) { + // In mix-with-system mode, do not request exclusive AUDIOFOCUS_GAIN + // to allow background media (Spotify, YouTube, podcasts, etc.) to continue + // playing seamlessly. Android's native audio mixer mixes game audio with background apps. + if (audioFocusHeld) { + abandonAudioFocus(); + } + return; + } + + AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (audioManager == null) { + return; + } + + if (audioFocusListener == null) { + audioFocusListener = new AudioManager.OnAudioFocusChangeListener() { + @Override + public void onAudioFocusChange(int focusChange) { + handleAudioFocusChange(focusChange); + } + }; + } + + int result; + if (android.os.Build.VERSION.SDK_INT >= 26) { + result = requestGameAudioFocusModern(audioManager); + } else { + result = audioManager.requestAudioFocus(audioFocusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + } + + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + audioFocusHeld = true; + cancelAudioFocusRecovery(); + } } private int requestGameAudioFocusModern(AudioManager audioManager) { @@ -2326,6 +2380,11 @@ public class GameActivity extends SDLActivity { switch (focusChange) { case AudioManager.AUDIOFOCUS_LOSS: case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + audioFocusHeld = false; + if (!audioMixWithSystem) { + nativeAudioFocusLost(); + } + break; case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: // Allow mixing with other audio streams without pausing game audio break; diff --git a/src/core/Music.lua b/src/core/Music.lua index 6be8d797..b8e3940f 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -6,6 +6,8 @@ local Music = {} local VOLUME = 0.7 local volumeScale = 1 +local storedVolumeLevel = 7 +local currentAudioMode = "both" local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 } local filterLevel = 0 @@ -507,11 +509,28 @@ end -- 0-7 music volume (0 mutes), applied to the playing song and the -- queued loop body as well as everything played later function Music.setVolumeLevel(level) - volumeScale = math.max(0, math.min(7, level or 7)) / 7 + if level ~= nil then storedVolumeLevel = level end + if currentAudioMode == "external_only" then + volumeScale = 0 + else + volumeScale = math.max(0, math.min(7, storedVolumeLevel or 7)) / 7 + end applyVolume(state.source) applyVolume(state.loopSource) end +function Music.setAudioMode(mode) + currentAudioMode = mode or "both" + if love and love.audio and love.audio.setMixWithSystem then + pcall(love.audio.setMixWithSystem, currentAudioMode ~= "game_only") + end + Music.setVolumeLevel(storedVolumeLevel) +end + +function Music.audioMode() + return currentAudioMode +end + -- music low-pass filter level, 0 (OFF) to 3 function Music.setFilterLevel(level) filterLevel = math.max(0, math.min(3, level or 0)) @@ -528,7 +547,12 @@ end -- re-apply persisted audio options (Game calls this on boot and after -- loading a save) function Music.applyOptions(opts) - Music.setVolumeLevel(opts and opts.musicVol or 7) + if opts and opts.audioMode then + Music.setAudioMode(opts.audioMode) + else + Music.setAudioMode(currentAudioMode) + end + Music.setVolumeLevel(opts and opts.musicVol or storedVolumeLevel or 7) Music.setFilterLevel(opts and opts.musicFilter or 0) -- engine/menus/options_menu.asm SOUND row (wOptions STEREO bit) local ChipAudio = require("src.core.ChipAudio") diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index cd26417c..2e38cc63 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -610,6 +610,7 @@ function SaveData.defaultOptions() -- (the PIKACHU VOL row appears only on Yellow; see Sound.lua) pikaVol = 7, musicFilter = 0, + audioMode = "both", -- Per-category logic fast-forward multiplier (RFC 0007); audio is -- unaffected (GameSpeed.lua). Superseded from a single "speed" field -- -- mergeOptions migrates an old save's value into all three below. diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua index 0a8fc0d1..3934cbf5 100644 --- a/src/core/game3/audio.lua +++ b/src/core/game3/audio.lua @@ -368,13 +368,23 @@ end function Audio.applyEngineOptions(opts) if type(opts) ~= "table" then return end - Audio._bgmVolume = level_gain(opts.musicVol, 7) + local mode = opts.audioMode or "both" + Audio._audioMode = mode + if love and love.audio and love.audio.setMixWithSystem then + pcall(love.audio.setMixWithSystem, mode ~= "game_only") + end + if mode == "external_only" then + Audio._bgmVolume = 0 + else + Audio._bgmVolume = level_gain(opts.musicVol, 7) + end Audio._sfxVolume = level_gain(opts.sfxVol, 7) local filter = tonumber(opts.musicFilter) or 0 if filter < 0 then filter = 0 end if filter > 3 then filter = 3 end Audio._filterLevel = filter > 0 and filter or nil Audio.pushMixOptions() + Audio.applyGain() end function Audio.applyGain() @@ -1222,6 +1232,7 @@ function Audio.stopSe(id) end function Audio.isSePlaying(id) + if Audio._suspended then return false end if id == nil then local rse = rse_policy() ~= nil for _, src in ipairs(Audio._seSources) do @@ -1243,6 +1254,7 @@ end -- pokeemerald/src/sound.c:624 -- pokeruby/src/sound.c:554 function Audio.isSpecialSePlaying() + if Audio._suspended then return false end for _, src in ipairs(Audio._seSources) do local meta = Audio._seMeta[src] if meta and tonumber(meta.player) == 3 and src:isPlaying() then return true end @@ -1251,9 +1263,9 @@ function Audio.isSpecialSePlaying() end function Audio.waitSe(id, cb) - -- Poll in update via callback list + -- Poll in update via callback list with 180 frame (~3s) defensive timeout Audio._waitSe = Audio._waitSe or {} - Audio._waitSe[#Audio._waitSe + 1] = { id = id, cb = cb } + Audio._waitSe[#Audio._waitSe + 1] = { id = id, cb = cb, frames = 180 } end local function start_fanfare_source(id, mplay) @@ -1355,6 +1367,7 @@ function Audio.pumpFanfares() end function Audio.isFanfareFinished() + if Audio._suspended then return true end return not Audio._fanfareActive end @@ -1518,7 +1531,7 @@ function Audio.update(dt) -- Fanfare countdown (frame-exact) if Audio._fanfareActive then Audio._fanfareFrames = (Audio._fanfareFrames or 0) - dt * 60 - if Audio._fanfareFrames <= 0 then + if Audio._fanfareFrames <= 0 or Audio._suspended then Audio._fanfareActive = false Audio._fanfarePending = nil local deferred, restore = Audio._fanfareDeferred, Audio._fanfareRestore @@ -1611,10 +1624,13 @@ function Audio.update(dt) if Audio._waitSe then local pending = {} for _, w in ipairs(Audio._waitSe) do - if Audio.isSePlaying(w.id) then + w.frames = (w.frames or 180) - dt * 60 + if not Audio.isSePlaying(w.id) or w.frames <= 0 or Audio._suspended then + if w.cb then + pcall(w.cb) + end + else pending[#pending + 1] = w - elseif w.cb then - w.cb() end end Audio._waitSe = pending diff --git a/src/core/game3/scripting/ops_a.lua b/src/core/game3/scripting/ops_a.lua index 56220556..ae806bd0 100644 --- a/src/core/game3/scripting/ops_a.lua +++ b/src/core/game3/scripting/ops_a.lua @@ -1284,13 +1284,18 @@ local function dispatch(vm, row) if Audio.isFanfareFinished then return Audio.isFanfareFinished() end return true end - if isFinished() then + if isFinished() or Audio._suspended then return false end ctx.mode = "native" ctx.status = "waiting" local done = false - ctx.nativePoll = function() return done or isFinished() end + local frames = 0 + ctx.nativePoll = function() + frames = frames + 1 + if frames > 360 or Audio._suspended then return true end + return done or isFinished() + end local finish = function() done = true end if a.waitFanfare then a.waitFanfare(finish) @@ -1342,9 +1347,14 @@ local function dispatch(vm, row) ctx.mode = "native" ctx.status = "waiting" local done = false - ctx.nativePoll = function() return done end + local frames = 0 + ctx.nativePoll = function() + frames = frames + 1 + if frames > 180 or Audio._suspended then return true end + return done or not Audio.isSePlaying(row[1]) + end Audio.waitSe(row[1], function() done = true end) - if done or not Audio.isSePlaying(row[1]) then + if done or not Audio.isSePlaying(row[1]) or Audio._suspended then done = true ctx.mode = "bytecode" ctx.status = "running" diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index cff36494..572cdbf9 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -350,6 +350,7 @@ Save.DEFAULT_OPTIONS = { musicVol = 7, -- 0-7, like the GB's NR50 master volume sfxVol = 7, -- 0-7 musicFilter = 0, -- low-pass steps, 0 = off + audioMode = "both", haptics = "light", touchControls = { enabled = true }, screenPos = "center", @@ -374,6 +375,7 @@ end Save.OPTIONS_KEY = "gold" local SHARED_KEYS = { + audioMode = true, touchControls = true, haptics = true, screenPos = true, videoMode = true, faithfulRes = true, orientation = true, mods = true, modsByVersion = true, modsGen2 = true, diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index df075007..61504a65 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -286,6 +286,9 @@ local function coreRows(opts, hooks) opts.musicFilter = ((opts.musicFilter or 0) + dir) % #FILTERS return true end) + add(Strings("AUDIO MODE"), + ladder(opts, "audioMode", + { { "both", "BOTH" }, { "external_only", "EXT ONLY" }, { "game_only", "GAME ONLY" } }, "both")) local okPerf, Performance = pcall(require, "src.core.Performance") if okPerf then @@ -642,6 +645,9 @@ local function gen2Rows(opts, hooks, shared) opts.musicFilter = ((opts.musicFilter or 0) + dir) % #FILTERS return true end) + add(Strings("AUDIO MODE"), + ladder(opts, "audioMode", + { { "both", "BOTH" }, { "external_only", "EXT ONLY" }, { "game_only", "GAME ONLY" } }, "both")) local okPal, GbcPalette = pcall(require, "src.render.GbcPalette") if okPal then diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 7857f9dd..ac382080 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -63,6 +63,11 @@ local Rulesets = { modern_clean = require("src.battle.rulesets.modern_clean"), } local FILTERS = { "OFF", "1X", "2X", "3X" } +local AUDIO_MODES = { + { "both", "BOTH" }, + { "external_only", "EXT ONLY" }, + { "game_only", "GAME ONLY" }, +} local DATE_FORMATS = { { "device", "DEVICE" }, { "dmy", "DD-MM-YYYY" }, { "mdy", "MM-DD-YYYY" }, { "ymd", "YYYY-MM-DD" }, @@ -329,6 +334,26 @@ local function buildRows(game) require("src.core.Music").setFilterLevel(o.musicFilter) return true end }, + { id = "audioMode", label = Strings("AUDIO MODE"), + value = function(g) + local cur = g.save.options.audioMode or "both" + for _, entry in ipairs(AUDIO_MODES) do + if entry[1] == cur then return Strings(entry[2]) end + end + return Strings("BOTH") + end, + step = function(g, dir) + local o = g.save.options + local cur = o.audioMode or "both" + local idx = 1 + for i, entry in ipairs(AUDIO_MODES) do + if entry[1] == cur then idx = i break end + end + idx = wrapIndex(idx - 1 + (dir or 1), #AUDIO_MODES) + 1 + o.audioMode = AUDIO_MODES[idx][1] + require("src.core.Music").applyOptions(o) + return true + end }, -- Heads the port's display group: one tier that scales the heavy extras -- (TILT / survey ZOOM) and the FPS ceiling for weaker devices. -- AUTO picks a default from the hardware; every tier is overridable. @@ -718,7 +743,7 @@ local GROUPS = { members = { "animations", "battleStyle", "battleLayout", "battleFit", "battleHud", "battleBg" } }, { id = "group.audio", label = "AUDIO", - members = { "musicVol", "sfxVol", "pikaVol", "musicFilter" } }, + members = { "musicVol", "sfxVol", "pikaVol", "musicFilter", "audioMode" } }, { id = "group.video", label = "VIDEO", members = { "uiLayout", "videoMode", "orientation", "faithfulRes", "screenPos", "fpsCap", "vsync", "logicClock" } }, diff --git a/src/ui/game3/option_rows.lua b/src/ui/game3/option_rows.lua index 283fc7bd..825c6f03 100644 --- a/src/ui/game3/option_rows.lua +++ b/src/ui/game3/option_rows.lua @@ -129,6 +129,32 @@ function Rows.build(ctx, skip) return true end, }) + local AUDIO_MODES = { + { "both", "BOTH" }, + { "external_only", "EXT. ONLY" }, + { "game_only", "GAME ONLY" }, + } + add({ + id = "audioMode", label = Strings("AUDIO MODE"), + value = function(c) + local cur = c.options.audioMode or "both" + for _, m in ipairs(AUDIO_MODES) do + if m[1] == cur then return Strings(m[2]) end + end + return Strings("BOTH") + end, + step = function(c, dir) + local cur = c.options.audioMode or "both" + local idx = 1 + for i, m in ipairs(AUDIO_MODES) do + if m[1] == cur then idx = i break end + end + idx = ((idx - 1 + (dir < 0 and -1 or 1)) % #AUDIO_MODES) + 1 + c.options.audioMode = AUDIO_MODES[idx][1] + require("src.core.game3.audio").applyEngineOptions(c.options) + return true + end, + }) addCart("buttonMode", function() return { id = "buttonMode", label = cartName(4), diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index b86c8fef..97593025 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -62,6 +62,8 @@ local FINITE_VALUE_SOURCES = { Strings.source("ADAPTIVE"), Strings.source("UNAVAILABLE"), Strings.source("DISPLAY"), Strings.source("DISPLAY (%dHZ)"), Strings.source("ON"), Strings.source("4X"), + Strings.source("AUDIO MODE"), Strings.source("BOTH"), Strings.source("EXT ONLY"), + Strings.source("GAME ONLY"), } local function volLabel(v) @@ -202,6 +204,22 @@ local ROWS = { text = function(options) return Strings(FILTERS[(options.musicFilter or 0) + 1]) end }, + { label = Strings.source("AUDIO MODE"), key = "audioMode", port = true, + cycle = function(options, delta) + local cur = options.audioMode or "both" + local modes = { "both", "external_only", "game_only" } + local idx = 1 + for i, m in ipairs(modes) do if m == cur then idx = i break end end + idx = ((idx - 1 + (delta < 0 and -1 or 1)) % #modes) + 1 + options.audioMode = modes[idx] + require("src.core.Music").applyOptions(options) + end, + text = function(options) + local cur = options.audioMode or "both" + if cur == "external_only" then return Strings("EXT ONLY") end + if cur == "game_only" then return Strings("GAME ONLY") end + return Strings("BOTH") + end }, -- Heads the port's display group, same spot src/ui/OptionsMenu.lua's own -- PERFORMANCE row occupies relative to ZOOM/VOID FILL/TILT/SHADER FX below -- (the extras this tier scales). Gen 1 row shape (`value`/`step`, not this @@ -514,7 +532,7 @@ local GROUPS = { { id = "group.graphics", label = Strings.source("GRAPHICS"), members = { "color", "uiLetterbox", "shaderfx", "shaderfx2", "frame" } }, { id = "group.audio", label = Strings.source("AUDIO"), - members = { "sound", "musicVol", "sfxVol", "musicFilter" } }, + members = { "sound", "musicVol", "sfxVol", "musicFilter", "audioMode" } }, { id = "group.battle", label = Strings.source("BATTLE OPTIONS"), members = { "battleScene", "battleStyle", "battleLayout", "battleHud", "battleFit", "battleBg" } }, diff --git a/tests/engine/audio_focus_mode_test.lua b/tests/engine/audio_focus_mode_test.lua new file mode 100644 index 00000000..c6096649 --- /dev/null +++ b/tests/engine/audio_focus_mode_test.lua @@ -0,0 +1,200 @@ +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 mixWithSystemCalls = {} +love.audio = love.audio or {} +love.audio.setMixWithSystem = function(mix) + table.insert(mixWithSystemCalls, mix) + return true +end + +-- 1. Default options across generations +local SaveData = require("src.core.SaveData") +local Save2 = require("src.core.gen2.Save") + +local def1 = SaveData.defaultOptions() +eq(def1.audioMode, "both", "Gen 1 default audioMode is 'both'") + +local def2 = Save2.defaultOptions() +eq(def2.audioMode, "both", "Gen 2 default audioMode is 'both'") + +local merged = SaveData.mergeOptions({}) +eq(merged.audioMode, "both", "mergeOptions retains default 'both'") + +local mergedCustom = SaveData.mergeOptions({ audioMode = "external_only" }) +eq(mergedCustom.audioMode, "external_only", "mergeOptions retains custom audioMode") + +-- 2. Gen 1/2 Music module behavior +local Music = require("src.core.Music") + +mixWithSystemCalls = {} +Music.applyOptions({ audioMode = "both", musicVol = 7 }) +eq(Music.audioMode(), "both", "Music mode set to 'both'") +eq(mixWithSystemCalls[#mixWithSystemCalls], true, "audioMode 'both' enables mixWithSystem") + +mixWithSystemCalls = {} +Music.applyOptions({ audioMode = "external_only", musicVol = 7 }) +eq(Music.audioMode(), "external_only", "Music mode set to 'external_only'") +eq(mixWithSystemCalls[#mixWithSystemCalls], true, "audioMode 'external_only' enables mixWithSystem") + +mixWithSystemCalls = {} +Music.applyOptions({ audioMode = "game_only", musicVol = 7 }) +eq(Music.audioMode(), "game_only", "Music mode set to 'game_only'") +eq(mixWithSystemCalls[#mixWithSystemCalls], false, "audioMode 'game_only' disables mixWithSystem") + +-- 3. Gen 3 Audio module behavior +local Game3Audio = require("src.core.game3.audio") + +mixWithSystemCalls = {} +Game3Audio.applyEngineOptions({ audioMode = "both", musicVol = 7, sfxVol = 7 }) +eq(Game3Audio._audioMode, "both", "Game3 audioMode set to 'both'") +check(Game3Audio._bgmVolume > 0, "Game3 'both' keeps BGM volume active") +eq(mixWithSystemCalls[#mixWithSystemCalls], true, "Game3 'both' enables mixWithSystem") + +mixWithSystemCalls = {} +Game3Audio.applyEngineOptions({ audioMode = "external_only", musicVol = 7, sfxVol = 7 }) +eq(Game3Audio._audioMode, "external_only", "Game3 audioMode set to 'external_only'") +eq(Game3Audio._bgmVolume, 0, "Game3 'external_only' mutes in-game BGM") +check(Game3Audio._sfxVolume > 0, "Game3 'external_only' keeps SFX active") +eq(mixWithSystemCalls[#mixWithSystemCalls], true, "Game3 'external_only' enables mixWithSystem") + +mixWithSystemCalls = {} +Game3Audio.applyEngineOptions({ audioMode = "game_only", musicVol = 7, sfxVol = 7 }) +eq(Game3Audio._audioMode, "game_only", "Game3 audioMode set to 'game_only'") +check(Game3Audio._bgmVolume > 0, "Game3 'game_only' keeps BGM active") +eq(mixWithSystemCalls[#mixWithSystemCalls], false, "Game3 'game_only' disables mixWithSystem") + +-- 4. Defensive audio suspension checks in Game3 +Game3Audio.setSuspended(false) +check(Game3Audio.isFanfareFinished(), "Fanfare initially finished") + +Game3Audio.setSuspended(true) +eq(Game3Audio.isSePlaying(1), false, "isSePlaying returns false when suspended") +eq(Game3Audio.isSePlaying(nil), false, "isSePlaying(nil) returns false when suspended") +eq(Game3Audio.isSpecialSePlaying(), false, "isSpecialSePlaying returns false when suspended") +eq(Game3Audio.isFanfareFinished(), true, "isFanfareFinished returns true when suspended") +Game3Audio.setSuspended(false) + +-- 5. waitSe timeout resilience +local waitSeDone = false +Game3Audio._seSources = { { isPlaying = function() return true end } } +Game3Audio._seMeta = { [Game3Audio._seSources[1]] = { id = 42 } } +check(Game3Audio.isSePlaying(42), "Mock source is reporting playing") + +Game3Audio.waitSe(42, function() waitSeDone = true end) +check(not waitSeDone, "waitSe callback not called before update") + +-- Update for 10 frames - still waiting +for _ = 1, 10 do + Game3Audio.update(1 / 60) +end +check(not waitSeDone, "waitSe still waiting while sound plays") + +-- Update past 180 frames timeout +for _ = 1, 180 do + Game3Audio.update(1 / 60) +end +check(waitSeDone, "waitSe callback fired after defensive timeout") + +-- Test waitSe with suspended audio +local suspendedWaitDone = false +Game3Audio.setSuspended(true) +Game3Audio.waitSe(42, function() suspendedWaitDone = true end) +Game3Audio.update(1 / 60) +check(suspendedWaitDone, "waitSe immediately fires callback when suspended") +Game3Audio.setSuspended(false) +Game3Audio._seSources = {} +Game3Audio._seMeta = {} + +-- 6. ops_a.lua waitse resilience +local ops_a = require("src.core.game3.scripting.ops_a") +local ctx = { mode = "bytecode", status = "running" } +local row = { 99 } + +-- Simulate stuck playing sound +Game3Audio._seSources = { { isPlaying = function() return true end } } +Game3Audio._seMeta = { [Game3Audio._seSources[1]] = { id = 99 } } + +local vm = { ctx = ctx, store = {}, a = {} } +local row = { op = "waitse", 99 } + +local waiting = ops_a.dispatch(vm, row) +check(waiting, "waitse paused script execution") +eq(ctx.mode, "native", "ctx mode set to native") +eq(ctx.status, "waiting", "ctx status set to waiting") +check(type(ctx.nativePoll) == "function", "ctx nativePoll registered") + +-- Poll for 100 frames - still waiting +for _ = 1, 100 do + check(not ctx.nativePoll(), "nativePoll waiting") +end + +-- At 181 frames, timeout triggers +local timeoutFired = false +for _ = 1, 100 do + if ctx.nativePoll() then + timeoutFired = true + break + end +end +check(timeoutFired, "ops_a waitse nativePoll unblocks on timeout") + +-- If suspended, waitse immediately returns false (no freeze) +Game3Audio.setSuspended(true) +local ctx2 = { mode = "bytecode", status = "running" } +local vm2 = { ctx = ctx2, store = {}, a = {} } +local waiting2 = ops_a.dispatch(vm2, row) +check(not waiting2, "waitse does not block when suspended") +Game3Audio.setSuspended(false) +Game3Audio._seSources = {} +Game3Audio._seMeta = {} + +-- 7. Options Menu row checks +local OptionsMenu = require("src.ui.OptionsMenu") +local fakeGame = { + save = { options = SaveData.defaultOptions() }, + data = { audio = {} }, +} +local menu = OptionsMenu.new(fakeGame) +local rows = menu.rows +local audioModeRow = nil +for _, r in ipairs(rows) do + if r.id == "audioMode" then audioModeRow = r break end +end +check(audioModeRow ~= nil, "Gen 1 OptionsMenu contains audioMode row") +eq(audioModeRow.value(fakeGame), "BOTH", "Initial audioMode label is BOTH") +audioModeRow.step(fakeGame, 1) +eq(fakeGame.save.options.audioMode, "external_only", "Stepped to external_only") +eq(audioModeRow.value(fakeGame), "EXT ONLY", "Label is EXT ONLY") +audioModeRow.step(fakeGame, 1) +eq(fakeGame.save.options.audioMode, "game_only", "Stepped to game_only") +eq(audioModeRow.value(fakeGame), "GAME ONLY", "Label is GAME ONLY") + +-- Gen 3 option rows +local Game3Rows = require("src.ui.game3.option_rows") +local g3Ctx = { + options = { musicVol = 7, sfxVol = 7, audioMode = "both" }, +} +local skipCart = { + textSpeed = true, battleScene = true, battleStyle = true, + sound = true, buttonMode = true, frameType = true, +} +local builtG3Rows = Game3Rows.build(g3Ctx, skipCart) +local g3AudioModeRow = nil +for _, r in ipairs(builtG3Rows) do + if r.id == "audioMode" then g3AudioModeRow = r break end +end +check(g3AudioModeRow ~= nil, "Game3 option_rows contains audioMode row") +eq(g3AudioModeRow.value(g3Ctx), "BOTH", "Initial Game3 label is BOTH") +g3AudioModeRow.step(g3Ctx, 1) +eq(g3Ctx.options.audioMode, "external_only", "Game3 stepped to external_only") +eq(g3AudioModeRow.value(g3Ctx), "EXT. ONLY", "Game3 label is EXT. ONLY") +g3AudioModeRow.step(g3Ctx, 1) +eq(g3Ctx.options.audioMode, "game_only", "Game3 stepped to game_only") +eq(g3AudioModeRow.value(g3Ctx), "GAME ONLY", "Game3 label is GAME ONLY") + +T.finish("audio_focus_mode_test") diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index be6b3608..c28fc746 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -366,7 +366,7 @@ local options = OptionsMenu.new(optionsGame, { }) -- The cart's seven rows, then the port's: CONTROLS, audio, PERFORMANCE, -- speed, display, SHADER FX + SHADER FX 2 (the second slot added alongside -check("thirty-six row descriptors", #OptionsMenu.ROWS, 36) +check("thirty-seven row descriptors", #OptionsMenu.ROWS, 37) check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls") check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")