From dc7f1f703a9ced200ff9c9df2a76c3a4eeabe93d Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Sat, 25 Nov 2023 19:10:24 +0100 Subject: [PATCH] Sound cache preloader (#10558) * A Preloader for sounds * Change SoundPlayer.play to never switch threads on desktop, and limit retries, but mostly better comments --- core/src/com/unciv/ui/audio/SoundPlayer.kt | 166 ++++++++++++++---- .../screens/mainmenuscreen/MainMenuScreen.kt | 3 + 2 files changed, 137 insertions(+), 32 deletions(-) diff --git a/core/src/com/unciv/ui/audio/SoundPlayer.kt b/core/src/com/unciv/ui/audio/SoundPlayer.kt index 43ea323ff3..2bdeca9e35 100644 --- a/core/src/com/unciv/ui/audio/SoundPlayer.kt +++ b/core/src/com/unciv/ui/audio/SoundPlayer.kt @@ -8,8 +8,11 @@ import com.unciv.UncivGame import com.unciv.models.UncivSound import com.unciv.utils.Concurrency import com.unciv.utils.debug -import kotlinx.coroutines.delay import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive /* * Problems on Android @@ -47,12 +50,18 @@ object SoundPlayer { @Suppress("EnumEntryName") private enum class SupportedExtensions { mp3, ogg, wav } // Per Gdx docs, no aac/m4a - private val soundMap = HashMap() + private val soundMap = Cache() private val separator = File.separator // just a shorthand for readability private var modListHash = Int.MIN_VALUE + private var preloader: Preloader? = null + // Starting an instance here is pointless - object initialization happens on-demand + + /** Ensure SoundPlayer has its cache initialized and can start preloading */ + fun initializeForMainMenu() = checkCache() + /** Ensure cache is not outdated */ private fun checkCache() { if (!UncivGame.isCurrentInitialized()) return @@ -60,6 +69,7 @@ object SoundPlayer { // Get a hash covering all mods - quickly, so don't map, cast or copy the Set types val gameInfo = game.gameInfo + @Suppress("IfThenToElvis") val hash1 = if (gameInfo != null) gameInfo.ruleset.mods.hashCode() else 0 val newHash = hash1.xor(game.settings.visualMods.hashCode()) @@ -70,12 +80,13 @@ object SoundPlayer { clearCache() modListHash = newHash debug("Sound cache cleared") + Preloader.restart() } /** Release cached Sound resources */ // Called from UncivGame.dispose() to honor Gdx docs fun clearCache() { - for (sound in soundMap.values) sound?.dispose() + Preloader.abort() soundMap.clear() modListHash = Int.MIN_VALUE } @@ -114,40 +125,43 @@ object SoundPlayer { */ private fun get(sound: UncivSound): GetSoundResult? { checkCache() + // Look for cached sound if (sound in soundMap) return if(soundMap[sound] == null) null else GetSoundResult(soundMap[sound]!!, false) // Not cached - try loading it - val fileName = sound.fileName - var file: FileHandle? = null - for ( (modFolder, extension) in getFolders().flatMap { - // This is essentially a cross join. To operate on all combinations, we pack both lambda - // parameters into a Pair (using `to`) and unwrap that in the loop using automatic data - // class deconstruction `(,)`. All this avoids a double break when a match is found. - folder -> SupportedExtensions.values().asSequence().map { folder to it } - } ) { - val path = "${modFolder}sounds$separator$fileName.${extension.name}" - file = Gdx.files.local(path) - if (file.exists()) break - file = Gdx.files.internal(path) - if (file.exists()) break - } + return createAndCacheResult(sound, getFile(sound)) + } - @Suppress("LiftReturnOrAssignment") + private fun getFile(sound: UncivSound): FileHandle? { + val fileName = sound.fileName + for (modFolder in getFolders()) { + for (extension in SupportedExtensions.values()) { + val path = "${modFolder}sounds$separator$fileName.${extension.name}" + val localFile = Gdx.files.local(path) + if (localFile.exists()) return localFile + val internalFile = Gdx.files.internal(path) + if (internalFile.exists()) return internalFile + } + } + return null + } + + private fun createAndCacheResult(sound: UncivSound, file: FileHandle?): GetSoundResult? { if (file == null || !file.exists()) { debug("Sound %s not found!", sound.fileName) // remember that the actual file is missing soundMap[sound] = null return null - } else { - debug("Sound %s loaded from %s", sound.fileName, file.path()) - val newSound = Gdx.audio.newSound(file) - // Store Sound for reuse - soundMap[sound] = newSound - return GetSoundResult(newSound, true) } + + debug("Sound %s loaded from %s", sound.fileName, file.path()) + val newSound = Gdx.audio.newSound(file) + // Store Sound for reuse + soundMap[sound] = newSound + return GetSoundResult(newSound, true) } /** @@ -171,13 +185,42 @@ object SoundPlayer { val volume = UncivGame.Current.settings.soundEffectsVolume if (sound == UncivSound.Silent || volume < 0.01) return val (resource, isFresh) = get(sound) ?: return - val initialDelay = if (isFresh && Gdx.app.type == Application.ApplicationType.Android) 40 else 0 + if (Gdx.app.type == Application.ApplicationType.Android) + playAndroid(resource, isFresh, volume) + else + playDesktop(resource, volume) + } - if (initialDelay > 0 || resource.play(volume) == -1L) { - Concurrency.run("DelayedSound") { - delay(initialDelay.toLong()) - while (resource.play(volume) == -1L) delay(20L) - } + // Android needs time for a newly created sound to become ready, but in turn AndroidSound.play seems thread-safe. + private fun playAndroid(resource: Sound, isFresh: Boolean, volume: Float) { + /** Tested with a 2022 Android "S" and libGdx 1.12.1 - still required */ + // See also: https://github.com/libgdx/libgdx/issues/694 + // Typical logcat (effectively means we tried play before even the codec got loaded): +/* + Unciv SoundPlayer com.unciv.app D [GLThread 8951] Sound click loaded from mods/Higher quality builtin sounds/sounds/click.ogg + SoundPool com.unciv.app W play soundID 1 not READY + MediaCodecList com.unciv.app D codecHandlesFormat: no format, so no extra checks + MediaCodecList com.unciv.app D codecHandlesFormat: no format, so no extra checks + CCodec com.unciv.app D allocate(c2.android.vorbis.decoder) + Codec2Client com.unciv.app I Available Codec2 services: "software" + CCodec com.unciv.app I Created component [c2.android.vorbis.decoder] + CCodecConfig com.unciv.app D read media type: audio/vorbis + */ + if (!isFresh && resource.play(volume) != -1L) return // If it's already cached we should be able to play immediately + Concurrency.run("DelayedSound") { + delay(40L) + var repeatCount = 0 + while (resource.play(volume) == -1L && ++repeatCount < 12) // quarter second tops + delay(20L) + } + } + + // Let's do just one silent retry on Desktop. In turn, OpenALSound.play is not thread-safe. + private fun playDesktop(resource: Sound, volume: Float) { + if (resource.play(volume) != -1L) return + Concurrency.runOnGLThread("SoundRetry") { + delay(20L) + resource.play(volume) } } @@ -187,13 +230,72 @@ object SoundPlayer { */ fun playRepeated(sound: UncivSound, count: Int = 2, delay: Long = 200) { Concurrency.runOnGLThread { - SoundPlayer.play(sound) + play(sound) if (count > 1) Concurrency.run { repeat(count - 1) { delay(delay) - Concurrency.runOnGLThread { SoundPlayer.play(sound) } + Concurrency.runOnGLThread { play(sound) } } } } } + + /** Manages background loading of sound files into Gdx.Sound instances */ + private class Preloader { + private fun UncivSound.Companion.getPreloadList() = + listOf(Click, Whoosh, Construction, Promote, Upgrade, Coin, Chimes, Choir) + + private val job: Job = Concurrency.run("SoundPreloader") { preload() }.apply { + invokeOnCompletion { preloader = null } + } + + private suspend fun CoroutineScope.preload() { + for (sound in UncivSound.getPreloadList()) { + delay(10L) + if (!isActive) break + // This way skips minor things as compared to get(sound) - the cache stale check and result instantiation on cache hit + if (sound in soundMap) continue + debug("Preload $sound") + createAndCacheResult(sound, getFile(sound)) + if (!isActive) break + } + } + + companion object { + // This Companion is only used as a "namespace" thing and to keep Preloader control in one place + + fun abort() { + preloader?.job?.cancel() + preloader = null + } + + fun restart() { + preloader?.job?.cancel() + preloader = Preloader() + } + } + } + + /** A wrapper for a HashMap with synchronized writes */ + private class Cache { + private val cache = HashMap(20) // Enough for all standard sounds + + operator fun contains(key: UncivSound) = cache.containsKey(key) + operator fun get(key: UncivSound) = cache[key] + + operator fun set(key: UncivSound, value: Sound?) { + synchronized(cache) { + cache[key] = value + } + } + + fun clear() { + val oldSounds: List + synchronized(cache) { + oldSounds = cache.values.toList() + cache.clear() + } + for (sound in oldSounds) sound?.dispose() + } + } } diff --git a/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt b/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt index f714037f3c..a893fc4705 100644 --- a/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt +++ b/core/src/com/unciv/ui/screens/mainmenuscreen/MainMenuScreen.kt @@ -22,6 +22,7 @@ import com.unciv.models.metadata.GameSetupInfo import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.RulesetCache import com.unciv.models.tilesets.TileSetCache +import com.unciv.ui.audio.SoundPlayer import com.unciv.ui.components.UncivTooltip.Companion.addTooltip import com.unciv.ui.components.extensions.center import com.unciv.ui.components.extensions.surroundWithCircle @@ -108,6 +109,8 @@ class MainMenuScreen: BaseScreen(), RecreateOnResize { } init { + SoundPlayer.initializeForMainMenu() + val background = skinStrings.getUiBackground("MainMenuScreen/Background", tintColor = clearColor) backgroundStack.add(BackgroundActor(background, Align.center)) stage.addActor(backgroundStack)