diff --git a/core/src/com/unciv/UncivGame.kt b/core/src/com/unciv/UncivGame.kt index 078b288058..1a49386d5c 100644 --- a/core/src/com/unciv/UncivGame.kt +++ b/core/src/com/unciv/UncivGame.kt @@ -37,6 +37,7 @@ class UncivGame(parameters: UncivGameParameters) : Game() { val consoleMode = parameters.consoleMode val customSaveLocationHelper = parameters.customSaveLocationHelper val limitOrientationsHelper = parameters.limitOrientationsHelper + private val audioExceptionHelper = parameters.audioExceptionHelper var deepLinkedMultiplayerGame: String? = null lateinit var gameInfo: GameInfo @@ -97,6 +98,10 @@ class UncivGame(parameters: UncivGameParameters) : Game() { settings = GameSaver.getGeneralSettings() // needed for the screen screen = LoadingScreen() // NOT dependent on any atlas or skin musicController = MusicController() // early, but at this point does only copy volume from settings + audioExceptionHelper?.installHooks( + musicController.getAudioLoopCallback(), + musicController.getAudioExceptionHandler() + ) ImageGetter.resetAtlases() ImageGetter.setNewRuleset(ImageGetter.ruleset) // This needs to come after the settings, since we may have default visual mods diff --git a/core/src/com/unciv/UncivGameParameters.kt b/core/src/com/unciv/UncivGameParameters.kt index e15628e552..830132f8f0 100644 --- a/core/src/com/unciv/UncivGameParameters.kt +++ b/core/src/com/unciv/UncivGameParameters.kt @@ -2,6 +2,7 @@ package com.unciv import com.unciv.logic.CustomSaveLocationHelper import com.unciv.ui.crashhandling.CrashReportSysInfo +import com.unciv.ui.utils.AudioExceptionHelper import com.unciv.ui.utils.LimitOrientationsHelper import com.unciv.ui.utils.NativeFontImplementation @@ -11,5 +12,6 @@ class UncivGameParameters(val version: String, val fontImplementation: NativeFontImplementation? = null, val consoleMode: Boolean = false, val customSaveLocationHelper: CustomSaveLocationHelper? = null, - val limitOrientationsHelper: LimitOrientationsHelper? = null -) { } + val limitOrientationsHelper: LimitOrientationsHelper? = null, + val audioExceptionHelper: AudioExceptionHelper? = null +) diff --git a/core/src/com/unciv/ui/audio/MusicController.kt b/core/src/com/unciv/ui/audio/MusicController.kt index ef5a924d42..c622e83675 100644 --- a/core/src/com/unciv/ui/audio/MusicController.kt +++ b/core/src/com/unciv/ui/audio/MusicController.kt @@ -2,12 +2,15 @@ package com.unciv.ui.audio import com.badlogic.gdx.Gdx import com.badlogic.gdx.Files.FileType +import com.badlogic.gdx.audio.Music import com.badlogic.gdx.files.FileHandle import com.unciv.UncivGame import com.unciv.models.metadata.GameSettings import com.unciv.logic.multiplayer.DropBox import java.util.* +import kotlin.concurrent.thread import kotlin.concurrent.timer +import kotlin.math.roundToInt /** @@ -19,15 +22,18 @@ class MusicController { companion object { /** Mods live in Local - but this file prepares for music living in External just in case */ private val musicLocation = FileType.Local - const val musicPath = "music" - const val modPath = "mods" - const val musicFallbackLocation = "/music/thatched-villagers.mp3" - const val maxVolume = 0.6f // baseVolume has range 0.0-1.0, which is multiplied by this for the API - private const val ticksPerSecond = 20 // Timer frequency defines smoothness of fade-in/out - private const val timerPeriod = 1000L / ticksPerSecond - const val defaultFadingStep = 0.08f // this means fade is 12 ticks or 0.6 seconds + private const val musicPath = "music" + private const val modPath = "mods" + private const val musicFallbackLocation = "/music/thatched-villagers.mp3" // Dropbox path + private const val musicFallbackLocalName = "music/Thatched Villagers - Ambient.mp3" // Name we save it to + private const val maxVolume = 0.6f // baseVolume has range 0.0-1.0, which is multiplied by this for the API + private const val ticksPerSecondGdx = 58.3f // *Observed* frequency of Gdx app loop + private const val ticksPerSecondOwn = 20f // Timer frequency when we use our own + private const val defaultFadeDuration = 0.9f // in seconds + private const val defaultFadingStepGdx = 1f / (defaultFadeDuration * ticksPerSecondGdx) + private const val defaultFadingStepOwn = 1f / (defaultFadeDuration * ticksPerSecondOwn) private const val musicHistorySize = 8 // number of names to keep to avoid playing the same in short succession - private val fileExtensions = listOf("mp3", "ogg") // flac, opus, m4a... blocked by Gdx, `wav` we don't want + private val fileExtensions = listOf("mp3", "ogg", "wav") // All Gdx formats internal const val consoleLog = false @@ -35,6 +41,20 @@ class MusicController { if (musicLocation == FileType.External && Gdx.files.isExternalStorageAvailable) Gdx.files.external(path) else Gdx.files.local(path) + + // These are replaced when we _know_ we're attached to Gdx.audio.update + private var needOwnTimer = true + private var ticksPerSecond = ticksPerSecondOwn + internal var defaultFadingStep = defaultFadingStepOwn // Used by MusicTrackController too + } + + init { + val oldFallbackFile = Gdx.files.local(musicFallbackLocation.removePrefix("/")) + if (oldFallbackFile.exists()) { + val newFallbackFile = Gdx.files.local(musicFallbackLocalName) + if (!newFallbackFile.exists()) + oldFallbackFile.moveTo(newFallbackFile) + } } //region Fields @@ -46,7 +66,8 @@ class MusicController { var silenceLength: Float get() = silenceLengthInTicks.toFloat() / ticksPerSecond set(value) { silenceLengthInTicks = (ticksPerSecond * value).toInt() } - private var silenceLengthInTicks = UncivGame.Current.settings.pauseBetweenTracks * ticksPerSecond + + private var silenceLengthInTicks = (UncivGame.Current.settings.pauseBetweenTracks * ticksPerSecond).roundToInt() private var mods = HashSet() @@ -57,23 +78,25 @@ class MusicController { private var musicTimer: Timer? = null private enum class ControllerState { - /** As the name says. Timer will stop itself if it encounters this state. */ + /** Own timer stopped, if using the HardenedGdxAudio callback just do nothing */ Idle, + /** As the name says. Loop will release everything and go [Idle] if it encounters this state. */ + Cleanup, /** Play a track to its end, then silence for a while, then choose another track */ Playing, - /** Play a track to its end, then go [Idle] */ + /** Play a track to its end, then [Cleanup] */ PlaySingle, /** Wait for a while in silence to start next track */ Silence, /** Music fades to pause or is paused. Continue with chooseTrack or resume. */ Pause, - /** Fade out then stop */ + /** Fade out then [Cleanup] */ Shutdown } /** Simple two-entry only queue, for smooth fade-overs from one track to another */ - var current: MusicTrackController? = null - var next: MusicTrackController? = null + private var current: MusicTrackController? = null + private var next: MusicTrackController? = null /** Keeps paths of recently played track to reduce repetition */ private val musicHistory = ArrayDeque(musicHistorySize) @@ -84,6 +107,18 @@ class MusicController { //endregion //region Pure functions + fun getAudioLoopCallback(): ()->Unit { + needOwnTimer = false + ticksPerSecond = ticksPerSecondGdx + defaultFadingStep = defaultFadingStepGdx + return { musicTimerTask() } + } + + fun getAudioExceptionHandler(): (Throwable, Music) -> Unit = { + ex: Throwable, music: Music -> + audioExceptionHandler(ex, music) + } + /** @return the path of the playing track or null if none playing */ private fun currentlyPlaying(): String = when(state) { ControllerState.Playing, ControllerState.PlaySingle, ControllerState.Pause -> @@ -100,29 +135,6 @@ class MusicController { onTrackChangeListener = listener fireOnChange() } - private fun fireOnChange() { - if (onTrackChangeListener == null) return - val fileName = currentlyPlaying() - if (fileName.isEmpty()) { - fireOnChange(fileName) - return - } - val fileNameParts = fileName.split('/') - val modName = if (fileNameParts.size > 1 && fileNameParts[0] == "mods") fileNameParts[1] else "" - var trackName = fileNameParts[if (fileNameParts.size > 3 && fileNameParts[2] == "music") 3 else 1] - for (extension in fileExtensions) - trackName = trackName.removeSuffix(".$extension") - fireOnChange(modName + (if (modName.isEmpty()) "" else ": ") + trackName) - } - private fun fireOnChange(trackLabel: String) { - try { - onTrackChangeListener?.invoke(trackLabel) - } catch (ex: Throwable) { - if (consoleLog) - println("onTrackChange event invoke failed: ${ex.message}") - onTrackChangeListener = null - } - } /** * Determines whether any music tracks are available for the options menu @@ -141,10 +153,31 @@ class MusicController { current?.clear() current = null } + private fun clearNext() { + next?.clear() + next = null + } + + private fun startTimer() { + if (!needOwnTimer || musicTimer != null) return + // Start background TimerTask which manages track changes - on desktop, we get callbacks from the app.loop instead + val timerPeriod = (1000f / ticksPerSecond).roundToInt().toLong() + musicTimer = timer("MusicTimer", daemon = true, period = timerPeriod ) { + musicTimerTask() + } + } + + private fun stopTimer() { + if (musicTimer == null) return + musicTimer?.cancel() + musicTimer = null + } private fun musicTimerTask() { - // This ticks [ticksPerSecond] times per second + // This ticks [ticksPerSecond] times per second. Runs on Gdx main thread in desktop only when (state) { + ControllerState.Idle -> return + ControllerState.Playing, ControllerState.PlaySingle -> if (current == null) { if (next == null) { @@ -181,15 +214,55 @@ class MusicController { ControllerState.Shutdown -> { // Fade out first, when all queue entries are idle set up for real shutdown if (current?.shutdownTick() != false && next?.shutdownTick() != false) - state = ControllerState.Idle + state = ControllerState.Cleanup } - ControllerState.Idle -> - shutdown() // stops timer so this will not repeat + ControllerState.Cleanup -> + shutdown() // stops timer/sets Idle so this will not repeat ControllerState.Pause -> current?.timerTick() } } + /** Forceful shutdown of music playback and timers - see [gracefulShutdown] */ + private fun shutdown() { + state = ControllerState.Idle + fireOnChange() + // keep onTrackChangeListener! OptionsPopup will want to know when we start up again + stopTimer() + clearNext() + clearCurrent() + musicHistory.clear() + if (consoleLog) + println("MusicController shut down.") + } + + private fun audioExceptionHandler(ex: Throwable, music: Music) { + // Should run only in exceptional cases when the Gdx codecs actually have trouble with a file. + // Most playback problems are caught by the similar handler in MusicTrackController + + // Gdx _will_ try to read more data from file in Lwjgl3Application.loop even for + // Music instances that already have thrown an exception. + // disposing as quickly as possible is a feeble attempt to prevent that. + music.dispose() + if (music == next?.music) clearNext() + if (music == current?.music) clearCurrent() + + if (consoleLog) { + println("${ex.javaClass.simpleName} playing music: ${ex.message}") + if (ex.stackTrace != null) ex.printStackTrace() + } else { + println("Error playing music: ${ex.message ?: ""}") + } + + // Since this is a rare emergency, go a simple way to reboot music later + thread(isDaemon = true) { + Thread.sleep(2000) + Gdx.app.postRunnable { + this.chooseTrack() + } + } + } + /** Get sequence of potential music locations */ private fun getMusicFolders() = sequence { yieldAll( @@ -208,8 +281,11 @@ class MusicController { /** Choose adequate entry from [getAllMusicFiles] */ private fun chooseFile(prefix: String, suffix: String, flags: EnumSet): FileHandle? { - if (flags.contains(MusicTrackChooserFlags.PlayDefaultFile)) - return getFile(musicFallbackLocation) + if (flags.contains(MusicTrackChooserFlags.PlayDefaultFile)) { + val defaultFile = getFile(musicFallbackLocalName) + // Test so if someone never downloaded Thatched Villagers, their volume slider will still play music + if (defaultFile.exists()) return defaultFile + } // Scan whole music folder and mods to find best match for desired prefix and/or suffix // get a path list (as strings) of music folder candidates - existence unchecked return getAllMusicFiles() @@ -230,6 +306,30 @@ class MusicController { // the latter worked with older JVM's, current ones *crash* you when a compare is not transitive. } + private fun fireOnChange() { + if (onTrackChangeListener == null) return + val fileName = currentlyPlaying() + if (fileName.isEmpty()) { + fireOnChange(fileName) + return + } + val fileNameParts = fileName.split('/') + val modName = if (fileNameParts.size > 1 && fileNameParts[0] == "mods") fileNameParts[1] else "" + var trackName = fileNameParts[if (fileNameParts.size > 3 && fileNameParts[2] == "music") 3 else 1] + for (extension in fileExtensions) + trackName = trackName.removeSuffix(".$extension") + fireOnChange(modName + (if (modName.isEmpty()) "" else ": ") + trackName) + } + private fun fireOnChange(trackLabel: String) { + try { + onTrackChangeListener?.invoke(trackLabel) + } catch (ex: Throwable) { + if (consoleLog) + println("onTrackChange event invoke failed: ${ex.message}") + onTrackChangeListener = null + } + } + //endregion //region State changing methods @@ -287,6 +387,9 @@ class MusicController { if (musicHistory.size >= musicHistorySize) musicHistory.removeFirst() musicHistory.addLast(musicFile.path()) + // This is what makes a track change fade _over_ current fading out and next fading in at the same time. + it.play() + val fadingStep = defaultFadingStep / (if (flags.contains(MusicTrackChooserFlags.SlowFade)) 5 else 1) it.startFade(MusicTrackController.State.FadeIn, fadingStep) @@ -301,15 +404,10 @@ class MusicController { // Yes while the loader is doing its thing we wait for it in a Playing state state = if (flags.contains(MusicTrackChooserFlags.PlaySingle)) ControllerState.PlaySingle else ControllerState.Playing - - // Start background TimerTask which manages track changes - if (musicTimer == null) - musicTimer = timer("MusicTimer", true, 0, timerPeriod) { - musicTimerTask() - } - + startTimer() return true } + /** Variant of [chooseTrack] that tries several moods ([suffixes]) until a match is chosen */ fun chooseTrack ( prefix: String = "", @@ -352,13 +450,13 @@ class MusicController { current!!.startFade(MusicTrackController.State.FadeIn, fadingStep) state = ControllerState.Playing // this may circumvent a PlaySingle, but, currently only the main menu resumes, and then it's perfect current!!.play() - } else if (state == ControllerState.Idle) { + } else if (state == ControllerState.Cleanup) { chooseTrack() } } - /** Fade out then shutdown with a given [duration] in seconds */ - fun fadeoutToSilence(duration: Float = 4.0f) { + /** Fade out then shutdown with a given [duration] in seconds, defaults to a 'slow' fade (4.5s) */ + fun fadeoutToSilence(duration: Float = defaultFadeDuration * 5) { val fadingStep = 1f / ticksPerSecond / duration current?.startFade(MusicTrackController.State.FadeOut, fadingStep) next?.startFade(MusicTrackController.State.FadeOut, fadingStep) @@ -374,36 +472,19 @@ class MusicController { /** Soft shutdown of music playback, with fadeout */ fun gracefulShutdown() { - if (state == ControllerState.Idle) shutdown() + if (state == ControllerState.Cleanup) shutdown() else state = ControllerState.Shutdown } - /** Forceful shutdown of music playback and timers - see [gracefulShutdown] */ - private fun shutdown() { - state = ControllerState.Idle - fireOnChange() - // keep onTrackChangeListener! OptionsPopup will want to know when we start up again - if (musicTimer != null) { - musicTimer!!.cancel() - musicTimer = null - } - if (next != null) { - next!!.clear() - next = null - } - if (current != null) { - current!!.clear() - current = null - } - musicHistory.clear() - if (consoleLog) - println("MusicController shut down.") - } - + /** Download Thatched Villagers */ fun downloadDefaultFile() { val file = DropBox.downloadFile(musicFallbackLocation) - getFile(musicFallbackLocation).write(file, false) + getFile(musicFallbackLocalName).write(file, false) } + /** @return `true` if Thatched Villagers is present */ + fun isDefaultFileAvailable() = + getFile(musicFallbackLocalName).exists() + //endregion } diff --git a/core/src/com/unciv/ui/audio/MusicTrackChooserFlags.kt b/core/src/com/unciv/ui/audio/MusicTrackChooserFlags.kt index 3a7f46db37..5571d6ebc0 100644 --- a/core/src/com/unciv/ui/audio/MusicTrackChooserFlags.kt +++ b/core/src/com/unciv/ui/audio/MusicTrackChooserFlags.kt @@ -25,5 +25,7 @@ enum class MusicTrackChooserFlags { val setSpecific: EnumSet = EnumSet.of(PrefixMustMatch, SuffixMustMatch) /** EnumSet.of([PrefixMustMatch], [SlowFade]) */ val setNextTurn: EnumSet = EnumSet.of(PrefixMustMatch, SlowFade) + /** EnumSet.noneOf() */ + val none: EnumSet = EnumSet.noneOf(MusicTrackChooserFlags::class.java) } } diff --git a/core/src/com/unciv/ui/audio/MusicTrackController.kt b/core/src/com/unciv/ui/audio/MusicTrackController.kt index ecf5572242..80d526dec3 100644 --- a/core/src/com/unciv/ui/audio/MusicTrackController.kt +++ b/core/src/com/unciv/ui/audio/MusicTrackController.kt @@ -3,10 +3,9 @@ package com.unciv.ui.audio import com.badlogic.gdx.Gdx import com.badlogic.gdx.audio.Music import com.badlogic.gdx.files.FileHandle -import com.unciv.ui.crashhandling.crashHandlingThread -/** Wraps one Gdx Music instance and manages threaded loading, playback, fading and cleanup */ -class MusicTrackController(private var volume: Float) { +/** Wraps one Gdx Music instance and manages loading, playback, fading and cleanup */ +internal class MusicTrackController(private var volume: Float) { /** Internal state of this Music track */ enum class State(val canPlay: Boolean) { @@ -23,24 +22,15 @@ class MusicTrackController(private var volume: Float) { private set var music: Music? = null private set - private var loaderThread: Thread? = null private var fadeStep = MusicController.defaultFadingStep private var fadeVolume: Float = 1f + //region Functions for MusicController + /** Clean up and dispose resources */ fun clear() { state = State.None - clearLoader() - clearMusic() - } - private fun clearLoader() { - if (loaderThread == null) return - loaderThread!!.interrupt() - loaderThread = null - } - private fun clearMusic() { if (music == null) return - music!!.stop() music!!.dispose() music = null } @@ -55,28 +45,23 @@ class MusicTrackController(private var volume: Float) { onError: ((MusicTrackController)->Unit)? = null, onSuccess: ((MusicTrackController)->Unit)? = null ) { - if (state != State.None || loaderThread != null || music != null) + if (state != State.None || music != null) throw IllegalStateException("MusicTrackController.load should only be called once") - loaderThread = crashHandlingThread(name = "MusicLoader") { - state = State.Loading - try { - music = Gdx.audio.newMusic(file) - if (state != State.Loading) { // in case clear was called in the meantime - clearMusic() - } else { - state = State.Idle - if (MusicController.consoleLog) - println("Music loaded: $file") - onSuccess?.invoke(this) - } - } catch (ex: Exception) { - println("Exception loading $file: ${ex.message}") + state = State.Loading + try { + music = Gdx.audio.newMusic(file) + if (state != State.Loading) { // in case clear was called in the meantime + clear() + } else { + state = State.Idle if (MusicController.consoleLog) - ex.printStackTrace() - state = State.Error - onError?.invoke(this) + println("Music loaded: $file") + onSuccess?.invoke(this) } - loaderThread = null + } catch (ex: Exception) { + audioExceptionHandler(ex) + state = State.Error + onError?.invoke(this) } } @@ -86,32 +71,6 @@ class MusicTrackController(private var volume: Float) { if (state == State.FadeOut) fadeOutStep() return state } - private fun fadeInStep() { - // fade-in: linearly ramp fadeVolume to 1.0, then continue playing - fadeVolume += fadeStep - if (fadeVolume < 1f && music != null && music!!.isPlaying) { - music!!.volume = volume * fadeVolume - return - } - music!!.volume = volume - fadeVolume = 1f - state = State.Playing - } - private fun fadeOutStep() { - // fade-out: linearly ramp fadeVolume to 0.0, then act according to Status (Playing->Silence/Pause/Shutdown) - // This needs to guard against the music backend breaking mid-fade away during game shutdown - fadeVolume -= fadeStep - try { - if (fadeVolume >= 0.001f && music != null && music!!.isPlaying) { - music!!.volume = volume * fadeVolume - return - } - fadeVolume = 0f - music!!.volume = 0f - music!!.pause() - } catch (_: Throwable) {} - state = State.Idle - } /** Starts fadeIn or fadeOut. * @@ -156,6 +115,42 @@ class MusicTrackController(private var volume: Float) { return false } + /** Adjust master volume without affecting a fade-in/out */ + fun setVolume(newVolume: Float) { + volume = newVolume + music?.volume = volume * fadeVolume + } + + //endregion + //region Helpers + + private fun fadeInStep() { + // fade-in: linearly ramp fadeVolume to 1.0, then continue playing + fadeVolume += fadeStep + if (fadeVolume < 1f && music != null && music!!.isPlaying) { + music!!.volume = volume * fadeVolume + return + } + music!!.volume = volume + fadeVolume = 1f + state = State.Playing + } + private fun fadeOutStep() { + // fade-out: linearly ramp fadeVolume to 0.0, then act according to Status (Playing->Silence/Pause/Shutdown) + // This needs to guard against the music backend breaking mid-fade away during game shutdown + fadeVolume -= fadeStep + try { + if (fadeVolume >= 0.001f && music != null && music!!.isPlaying) { + music!!.volume = volume * fadeVolume + return + } + fadeVolume = 0f + music!!.volume = 0f + music!!.pause() + } catch (_: Throwable) {} + state = State.Idle + } + private fun tryPlay(music: Music): Boolean { return try { music.volume = volume @@ -163,16 +158,20 @@ class MusicTrackController(private var volume: Float) { music.play() true } catch (ex: Throwable) { - println("Exception playing music: ${ex.message}") - if (MusicController.consoleLog) - ex.printStackTrace() + audioExceptionHandler(ex) false } } - /** Adjust master volume without affecting a fade-in/out */ - fun setVolume(newVolume: Float) { - volume = newVolume - music?.volume = volume * fadeVolume + private fun audioExceptionHandler(ex: Throwable) { + clear() + if (MusicController.consoleLog) { + println("${ex.javaClass.simpleName} playing music: ${ex.message}") + if (ex.stackTrace != null) ex.printStackTrace() + } else { + println("Error playing music: ${ex.message ?: ""}") + } } + + //endregion } diff --git a/core/src/com/unciv/ui/utils/AudioExceptionHelper.kt b/core/src/com/unciv/ui/utils/AudioExceptionHelper.kt new file mode 100644 index 0000000000..6ff5ad1f43 --- /dev/null +++ b/core/src/com/unciv/ui/utils/AudioExceptionHelper.kt @@ -0,0 +1,10 @@ +package com.unciv.ui.utils + +import com.badlogic.gdx.audio.Music + +interface AudioExceptionHelper { + fun installHooks( + updateCallback: (()->Unit)?, + exceptionHandler: ((Throwable, Music)->Unit)? + ) +} diff --git a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt index f4ab172dad..09ffa4a18c 100644 --- a/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt +++ b/core/src/com/unciv/ui/worldscreen/mainmenu/OptionsPopup.kt @@ -240,9 +240,10 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { addMusicVolumeSlider() addMusicPauseSlider() addMusicCurrentlyPlaying() - } else { - addDownloadMusic() } + + if (!previousScreen.game.musicController.isDefaultFileAvailable()) + addDownloadMusic() } private fun getMultiplayerTab(): Table = Table(BaseScreen.skin).apply { @@ -760,7 +761,9 @@ class OptionsPopup(val previousScreen: BaseScreen) : Popup(previousScreen) { label.setText("Currently playing: [$it]".tr()) } } - label.onClick { previousScreen.game.musicController.chooseTrack(flags = MusicTrackChooserFlags.setNextTurn) } + label.onClick(UncivSound.Silent) { + previousScreen.game.musicController.chooseTrack(flags = MusicTrackChooserFlags.none) + } } private fun Table.addDownloadMusic() { diff --git a/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt b/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt index f080da134f..c151796282 100644 --- a/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt +++ b/desktop/src/com/unciv/app/desktop/DesktopLauncher.kt @@ -5,13 +5,10 @@ import club.minnced.discord.rpc.DiscordRPC import club.minnced.discord.rpc.DiscordRichPresence import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration -import com.badlogic.gdx.files.FileHandle import com.badlogic.gdx.graphics.glutils.HdpiMode import com.sun.jna.Native -import com.unciv.JsonParser import com.unciv.UncivGame import com.unciv.UncivGameParameters -import com.unciv.logic.GameSaver import com.unciv.models.metadata.GameSettings import com.unciv.ui.utils.Fonts import java.util.* @@ -34,6 +31,10 @@ internal object DesktopLauncher { config.setHdpiMode(HdpiMode.Logical) config.setWindowSizeLimits(120, 80, -1, -1) + // We don't need the initial Audio created in Lwjgl3Application, HardenGdxAudio will replace it anyway. + // Note that means config.setAudioConfig() would be ignored too, those would need to go into the HardenedGdxAudio constructor. + config.disableAudio(true) + val settings = GameSettings.getSettingsForPlatformLaunchers() if (!settings.isFreshlyCreated) { config.setWindowedMode(settings.windowState.width.coerceAtLeast(120), settings.windowState.height.coerceAtLeast(80)) @@ -50,7 +51,8 @@ internal object DesktopLauncher { cancelDiscordEvent = { discordTimer?.cancel() }, fontImplementation = NativeFontDesktop(Fonts.ORIGINAL_FONT_SIZE.toInt(), settings.fontFamily), customSaveLocationHelper = CustomSaveLocationHelperDesktop(), - crashReportSysInfo = CrashReportSysInfoDesktop() + crashReportSysInfo = CrashReportSysInfoDesktop(), + audioExceptionHelper = HardenGdxAudio() ) val game = UncivGame(desktopParameters) diff --git a/desktop/src/com/unciv/app/desktop/HardenGdxAudio.kt b/desktop/src/com/unciv/app/desktop/HardenGdxAudio.kt new file mode 100644 index 0000000000..7b268c88c6 --- /dev/null +++ b/desktop/src/com/unciv/app/desktop/HardenGdxAudio.kt @@ -0,0 +1,135 @@ +package com.unciv.app.desktop + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.audio.Music +import com.badlogic.gdx.backends.lwjgl3.audio.OpenALLwjgl3Audio +import com.badlogic.gdx.backends.lwjgl3.audio.OpenALMusic +import com.badlogic.gdx.backends.lwjgl3.audio.mock.MockAudio +import com.badlogic.gdx.utils.Array +import com.unciv.ui.utils.AudioExceptionHelper + +/** + * Problem: Not all exceptions playing Music can be caught on the desktop platform using a try-catch around the play method. + * Unciv 3.17.13 to 4.0.5 all exacerbated the problem due to using Music from various threads - my current interpretation + * is that OpenALMusic isn't thread-safe on play, load, dispose or any other methods touching any AL10.al*Buffers* call. + * But even with that fixed, music streams can have codec failures _after_ the first buffer's worth of data, so the problem is only mitigated. + * + * Sooner or later some Exception will be thrown from the code under `Lwjgl3Application.loop` -> `OpenALLwjgl3Audio.update` -> + * `OpenALMusic.update` -> `OpenALMusic.fill`, where a Gdx app _cannot_ catch them and has no chance to recover gracefully. + * + * This catches those Exceptions and reports them through a callback mechanism, and also provides a callback from the app loop + * that allows MusicController to make its Music calls on a thread guaranteed to be safe for OpenALMusic. + * # + * ### Approach: + * - Subclass [OpenALLwjgl3Audio] overriding [update][OpenALLwjgl3Audio.update] with equivalent code catching any Exceptions and Errors + * - Replicate original update (a 2-liner) accessing underlying fields through reflection to avoid rewriting the _whole_ thing + * - Not super.update so failed music can be immediately stopped, disposed and removed + * - Replace the object running inside Gdx - Gdx.app.audio - through reflection + * - Replace the object Unciv talks to - overwriting Gdx.audio is surprisingly allowed + * + * ### Some exceptions so far seen: + * * Cannot store to object array because "this.mode_param" is null + * * BufferOverflowException from java.nio.DirectByteBuffer.put(DirectByteBuffer.java:409) + * * GdxRuntimeException: Error reading audio data from Mp3$Music.read(Mp3.java:90) + * * Unable to allocate audio buffers. AL Error: 40961 from OpenALMusic.play(OpenALMusic.java:83) + * * Unable to allocate audio buffers. AL Error: 40963 from OpenALMusic.play(OpenALMusic.java:83) + * * Unable to allocate audio buffers. AL Error: 40964 from OpenALMusic.play(OpenALMusic.java:83) + * * ArrayIndexOutOfBoundsException: arraycopy: last destination index 1733 out of bounds for byte[1732] from PushbackInputStream.unread(PushbackInputStream.java:232) + * * ArrayIndexOutOfBoundsException: arraycopy: length -109 is negative from OggInputStream.readPCM(OggInputStream.java:319) + * * IllegalArgumentException: newPosition > limit: (29308 > 4608) from java.nio.Buffer.position(Buffer.java:316) + * * IllegalArgumentException: newPosition < 0: (11520 < 0) + * * java.nio.BufferOverflowException at java.base/java.nio.ByteBuffer.put(ByteBuffer.java:1179) + * * [gdx-audio] Error reading OGG: Corrupt or missing data in bitstream. + * * ArithmeticException in LayerIIIDecoder:904 + * * javazoom.jl.decoder.BitstreamException: Bitstream errorcode 102 + * * NullPointerException: Cannot invoke "javazoom.jl.decoder.Bitstream.closeFrame()" because "this.bitstream" is null + */ +class HardenGdxAudio : AudioExceptionHelper { + + override fun installHooks( + updateCallback: (()->Unit)?, + exceptionHandler: ((Throwable, Music)->Unit)? + ) { + // Get already instantiated Audio implementation for cleanup + // (may be OpenALLwjgl3Audio or MockAudio at this point) + val badAudio = Gdx.audio + + val noAudio = MockAudio() + + Gdx.audio = noAudio // It's a miracle this is allowed. + // If it breaks in a Gdx update, go reflection instead as done below for Gdx.app.audio: + + // Access the reference stored in Gdx.app.audio via reflection + val appClass = Gdx.app::class.java + val audioField = appClass.declaredFields.firstOrNull { it.name == "audio" } + if (audioField != null) { + audioField.isAccessible = true + audioField.set(Gdx.app, noAudio) // kill it for a microsecond safely + } + + // Clean up allocated resources + (badAudio as? OpenALLwjgl3Audio)?.dispose() + + // Create replacement + val newAudio = HardenedGdxAudio(updateCallback, exceptionHandler) + + // Store in Gdx fields used throughout the app (Gdx.app.audio by Gdx internally, Gdx.audio by us) + audioField?.set(Gdx.app, newAudio) + Gdx.audio = newAudio + } + + class HardenedGdxAudio( + private val updateCallback: (()->Unit)?, + private val exceptionHandler: ((Throwable, Music)->Unit)? + ) : OpenALLwjgl3Audio() { + private val noDevice: Boolean + private val music: Array + + init { + val myClass = this::class.java + val noDeviceField = myClass.superclass.declaredFields.first { it.name == "noDevice" } + noDeviceField.isAccessible = true + noDevice = noDeviceField.getBoolean(this) + val musicField = myClass.superclass.declaredFields.first { it.name == "music" } + musicField.isAccessible = true + @Suppress("UNCHECKED_CAST") + music = musicField.get(this) as Array + } + + // This is just a kotlin translation of the original `update` with added try-catch and cleanup + override fun update() { + if (noDevice) return + var i = 0 // No for loop as the array might be changed + while (i < music.size) { + val item = music[i] + try { + item.update() + } catch (ex: Throwable) { + item.dispose() // this will call stop which will do audio.music.removeValue + exceptionHandler?.invoke(ex, item) + } + i++ + } + updateCallback?.invoke() + } + } +} + +/* + Getting Gdx to play other music formats might work along these lines: + (Note - this is Lwjgl3 only, one would have to repeat per platform with quite different actual + implementations, though DefaultAndroidAudio just calls the SDK's MediaPlayer so it likely + already supports m4a, flac, opus and others...) + + class AacMusic(audio: OpenALLwjgl3Audio?, file: FileHandle?) : OpenALMusic(audio, file) { + override fun read(buffer: ByteArray?): Int { + //... + } + override fun reset() { + //... + } + } + fun registerCodecs(audio: OpenALLwjgl3Audio) { + audio.registerMusic("m4a", AacMusic::class.java) + } +*/