MusicController - Can catch exceptions from Gdx.app.loop and replaces Timer on desktop with a callback from OpenALLwjgl3Audio.update (#6526)

This commit is contained in:
SomeTroglodyte
2022-04-13 17:30:01 +02:00
committed by GitHub
parent b45a2f471e
commit 04c9be38c7
9 changed files with 391 additions and 152 deletions

View File

@ -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

View File

@ -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
)

View File

@ -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<String>()
@ -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<String>(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<MusicTrackChooserFlags>): 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
}

View File

@ -25,5 +25,7 @@ enum class MusicTrackChooserFlags {
val setSpecific: EnumSet<MusicTrackChooserFlags> = EnumSet.of(PrefixMustMatch, SuffixMustMatch)
/** EnumSet.of([PrefixMustMatch], [SlowFade]) */
val setNextTurn: EnumSet<MusicTrackChooserFlags> = EnumSet.of(PrefixMustMatch, SlowFade)
/** EnumSet.noneOf() */
val none: EnumSet<MusicTrackChooserFlags> = EnumSet.noneOf(MusicTrackChooserFlags::class.java)
}
}

View File

@ -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
}

View File

@ -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)?
)
}

View File

@ -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() {

View File

@ -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)

View File

@ -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<OpenALMusic>
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<OpenALMusic>
}
// 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)
}
*/