mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-09 01:59:15 -04:00
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:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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" } },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user