mirror of
https://github.com/yairm210/Unciv.git
synced 2025-07-25 15:19:51 +07:00
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:
@ -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
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
10
core/src/com/unciv/ui/utils/AudioExceptionHelper.kt
Normal file
10
core/src/com/unciv/ui/utils/AudioExceptionHelper.kt
Normal 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)?
|
||||
)
|
||||
}
|
@ -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() {
|
||||
|
@ -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)
|
||||
|
135
desktop/src/com/unciv/app/desktop/HardenGdxAudio.kt
Normal file
135
desktop/src/com/unciv/app/desktop/HardenGdxAudio.kt
Normal 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)
|
||||
}
|
||||
*/
|
Reference in New Issue
Block a user