Merge pull request #2750 from Nexhas28/fix/android-audio-focus-freeze-2742

Android: Prevent audio freeze on audio focus changes and add audio mode option (fixes #2742)
This commit is contained in:
bryanthaboi
2026-10-07 15:38:52 -04:00
committed by GitHub
16 changed files with 438 additions and 19 deletions
+12
View File
@@ -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
@@ -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();
@@ -181,6 +181,8 @@ bool createStorageDirectories();
bool hasBackgroundMusic();
bool setAudioMixWithSystem(bool mix);
bool hasRecordingPermission();
void requestRecordingPermission();
@@ -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;
@@ -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;
+26 -2
View File
@@ -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")
+1
View File
@@ -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.
+23 -7
View File
@@ -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
+14 -4
View File
@@ -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"
+2
View File
@@ -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,
+6
View File
@@ -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
+26 -1
View File
@@ -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" } },
+26
View File
@@ -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),
+19 -1
View File
@@ -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" } },
+200
View File
@@ -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")
+1 -1
View File
@@ -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")