diff --git a/core/src/com/unciv/ui/audio/CityAmbiencePlayer.kt b/core/src/com/unciv/ui/audio/CityAmbiencePlayer.kt index b80853855b..d78311b469 100644 --- a/core/src/com/unciv/ui/audio/CityAmbiencePlayer.kt +++ b/core/src/com/unciv/ui/audio/CityAmbiencePlayer.kt @@ -1,72 +1,24 @@ package com.unciv.ui.audio -import com.badlogic.gdx.Gdx -import com.badlogic.gdx.audio.Music -import com.badlogic.gdx.files.FileHandle import com.badlogic.gdx.utils.Disposable import com.unciv.UncivGame import com.unciv.logic.city.City -import com.unciv.utils.Log -/** Must be [disposed][dispose]. Starts playing an ambience sound for the city when created. Stops playing the ambience sound when [disposed][dispose]. */ +/** Must be [disposed][dispose]. + * Starts playing an ambience sound for the city when created. + * Stops playing the ambience sound when [disposed][dispose]. */ class CityAmbiencePlayer( city: City ) : Disposable { - private var playingCitySound: Music? = null - init { - play(city) - } - - private fun getModsFolder(): FileHandle { - val path = "mods" - val internal = Gdx.files.internal(path) - if (internal.exists()) return internal - return Gdx.files.local(path) - } - - private fun getModSoundFolders(): Sequence { - val visualMods = UncivGame.Current.settings.visualMods - val mods = UncivGame.Current.gameInfo!!.gameParameters.getModsAndBaseRuleset() - return (visualMods + mods).asSequence() - .map { modName -> - getModsFolder() - .child(modName) - .child("sounds") - } - } - - private fun getSoundFile(fileName: String): FileHandle { - val fileFromMods = getModSoundFolders() - .filter { it.isDirectory } - .flatMap { it.list().asSequence() } - .filter { !it.isDirectory && it.extension() in MusicController.gdxSupportedFileExtensions } - .firstOrNull { it.nameWithoutExtension() == fileName } - - return fileFromMods ?: Gdx.files.internal("sounds/$fileName.ogg") - } - - private fun play(city: City) { - if (UncivGame.Current.settings.citySoundsVolume == 0f) return - - if (playingCitySound != null) - stop() - try { - playingCitySound = Gdx.audio.newMusic(getSoundFile(city.civ.getEra().citySound)) - playingCitySound?.volume = UncivGame.Current.settings.citySoundsVolume - playingCitySound?.isLooping = true - playingCitySound?.play() - } catch (ex: Throwable) { - playingCitySound?.dispose() - Log.error("Error while playing city sound: ", ex) + val volume = UncivGame.Current.settings.citySoundsVolume + if (volume > 0f) { + UncivGame.Current.musicController + .playOverlay("sounds", city.civ.getEra().citySound, volume) } } - private fun stop() { - playingCitySound?.dispose() - } - override fun dispose() { - stop() + UncivGame.Current.musicController.stopOverlay() } } diff --git a/core/src/com/unciv/ui/audio/MusicController.kt b/core/src/com/unciv/ui/audio/MusicController.kt index 9dc1b35e97..05fd7c07d1 100644 --- a/core/src/com/unciv/ui/audio/MusicController.kt +++ b/core/src/com/unciv/ui/audio/MusicController.kt @@ -9,6 +9,7 @@ import com.unciv.logic.multiplayer.storage.DropBox import com.unciv.models.metadata.GameSettings import com.unciv.utils.Concurrency import com.unciv.utils.Log +import java.io.File import java.util.EnumSet import java.util.Timer import kotlin.concurrent.thread @@ -20,6 +21,10 @@ import kotlin.math.roundToInt * Play, choose, fade-in/out and generally manage music track playback. * * Main methods: [chooseTrack], [pause], [resume], [setModList], [isPlaying], [gracefulShutdown] + * + * City ambience feature: [playOverlay], [stopOverlay] + * * This plays entirely independent of all other functionality as linked above. + * * Can load from internal (jar,apk) - music is always local, nothing is packaged into a release. */ class MusicController { companion object { @@ -27,16 +32,24 @@ class MusicController { private val musicLocation = FileType.Local 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 + /** Dropbox path of default download offer */ + private const val musicFallbackLocation = "/music/thatched-villagers.mp3" + /** Name we save the default download offer to */ + private const val musicFallbackLocalName = "music/Thatched Villagers - Ambient.mp3" + /** baseVolume has range 0.0-1.0, which is multiplied by this for the API */ + private const val maxVolume = 0.6f + /** *Observed* frequency of Gdx app loop - theoretically this should reach 60fps */ + private const val ticksPerSecondGdx = 58.3f + /** Timer frequency when we use our own */ + private const val ticksPerSecondOwn = 20f + /** Default fade duration in seconds used to calculate the step per tick */ + private const val defaultFadeDuration = 0.9f 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 - val gdxSupportedFileExtensions = listOf("mp3", "ogg", "wav") // All Gdx formats + /** Number of names to keep, to avoid playing the same in short succession */ + private const val musicHistorySize = 8 + /** All Gdx-supported sound formats (file extensions) */ + val gdxSupportedFileExtensions = listOf("mp3", "ogg", "wav") private fun getFile(path: String) = if (musicLocation == FileType.External && Gdx.files.isExternalStorageAvailable) @@ -75,9 +88,13 @@ class MusicController { if (fileName.isEmpty()) return MusicTrackInfo("", "", "") 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] - val type = gdxSupportedFileExtensions.firstOrNull {trackName.endsWith(".$it") } ?: "" + val modName = if (fileNameParts.size > 1 && fileNameParts[0] == modPath) + fileNameParts[1] else "" + var trackName = fileNameParts[ + if (fileNameParts.size > 3 && fileNameParts[2] == musicPath) 3 else 1 + ] + val type = gdxSupportedFileExtensions + .firstOrNull {trackName.endsWith(".$it") } ?: "" trackName = trackName.removeSuffix(".$type") return MusicTrackInfo(modName, trackName, type) } @@ -93,7 +110,8 @@ class MusicController { get() = silenceLengthInTicks.toFloat() / ticksPerSecond set(value) { silenceLengthInTicks = (ticksPerSecond * value).toInt() } - private var silenceLengthInTicks = (UncivGame.Current.settings.pauseBetweenTracks * ticksPerSecond).roundToInt() + private var silenceLengthInTicks = + (UncivGame.Current.settings.pauseBetweenTracks * ticksPerSecond).roundToInt() private var mods = HashSet() @@ -106,7 +124,7 @@ class MusicController { private enum class ControllerState { /** 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. */ + /** 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, @@ -124,6 +142,9 @@ class MusicController { private var current: MusicTrackController? = null private var next: MusicTrackController? = null + /** One entry only for 'overlay' tracks in addition to and independent of normal music */ + private var overlay: MusicTrackController? = null + /** Keeps paths of recently played track to reduce repetition */ private val musicHistory = ArrayDeque(musicHistorySize) @@ -198,7 +219,8 @@ class MusicController { 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 + // Start background TimerTask which manages track changes and fades - + // on desktop, we get callbacks from the app.loop instead val timerPeriod = (1000f / ticksPerSecond).roundToInt().toLong() musicTimer = timer("MusicTimer", daemon = true, period = timerPeriod ) { musicTimerTask() @@ -213,6 +235,9 @@ class MusicController { private fun musicTimerTask() { // This ticks [ticksPerSecond] times per second. Runs on Gdx main thread in desktop only + + overlay?.overlayTick() + when (state) { ControllerState.Idle -> return @@ -221,10 +246,12 @@ class MusicController { if (next == null) { // no music to play - begin silence or shut down ticksOfSilence = 0 - state = if (state == ControllerState.PlaySingle) ControllerState.Shutdown else ControllerState.Silence + state = if (state == ControllerState.PlaySingle) ControllerState.Shutdown + else ControllerState.Silence fireOnChange() } else if (next!!.state.canPlay) { - // Next track - if top slot empty and a next exists, move it to top and start + // Next track - + // if top slot empty and a next exists, move it to top and start current = next next = null if (!current!!.play()) { @@ -269,13 +296,14 @@ class MusicController { stopTimer() clearNext() clearCurrent() + clearOverlay() musicHistory.clear() Log.debug("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 + // 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. @@ -283,6 +311,7 @@ class MusicController { music.dispose() if (music == next?.music) clearNext() if (music == current?.music) clearCurrent() + if (music == overlay?.music) clearOverlay() Log.error("Error playing music", ex) @@ -295,35 +324,49 @@ class MusicController { } } - /** Get sequence of potential music locations */ - private fun getMusicFolders() = sequence { + /** Get sequence of potential music locations when called without parameters. + * @param folder a folder name relative to mod/assets/local root + * @param getDefault builds the default (not modded) `FileHandle`, + * allows fallback to internal assets + * @return a Sequence of `FileHandle`s describing potential existing directories + */ + private fun getMusicFolders( + folder: String = musicPath, + getDefault: () -> FileHandle = { getFile(folder) } + ) = sequence { yieldAll( (UncivGame.Current.settings.visualMods + mods).asSequence() - .map { getFile(modPath).child(it).child(musicPath) } + .map { getFile(modPath).child(it).child(folder) } ) - yield(getFile(musicPath)) - } + yield(getDefault()) + }.filter { it.exists() && it.isDirectory } /** Get a sequence of all existing music files */ private fun getAllMusicFiles() = getMusicFolders() - .filter { it.exists() && it.isDirectory } .flatMap { it.list().asSequence() } // ensure only normal files with common sound extension .filter { it.exists() && !it.isDirectory && it.extension() in gdxSupportedFileExtensions } /** Choose adequate entry from [getAllMusicFiles] */ - private fun chooseFile(prefix: String, suffix: String, flags: EnumSet): FileHandle? { + private fun chooseFile( + prefix: String, + suffix: String, + flags: EnumSet + ): FileHandle? { if (flags.contains(MusicTrackChooserFlags.PlayDefaultFile)) { val defaultFile = getFile(musicFallbackLocalName) - // Test so if someone never downloaded Thatched Villagers, their volume slider will still play music + // 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 + val prefixMustMatch = flags.contains(MusicTrackChooserFlags.PrefixMustMatch) + val suffixMustMatch = flags.contains(MusicTrackChooserFlags.SuffixMustMatch) return getAllMusicFiles() .filter { - (!flags.contains(MusicTrackChooserFlags.PrefixMustMatch) || it.nameWithoutExtension().startsWith(prefix)) - && (!flags.contains(MusicTrackChooserFlags.SuffixMustMatch) || it.nameWithoutExtension().endsWith(suffix)) + (!prefixMustMatch || it.nameWithoutExtension().startsWith(prefix)) + && (!suffixMustMatch || it.nameWithoutExtension().endsWith(suffix)) } // randomize .shuffled() @@ -332,10 +375,12 @@ class MusicController { { if (it.nameWithoutExtension().startsWith(prefix)) 0 else 1 } , { if (it.nameWithoutExtension().endsWith(suffix)) 0 else 1 } , { if (it.path() in musicHistory) 1 else 0 } - // Then just pick the first one. Not as wasteful as it looks - need to check all names anyway + // Then just pick the first one. + // Not as wasteful as it looks - need to check all names anyway )).firstOrNull() // Note: shuffled().sortedWith(), ***not*** .sortedWith(.., Random) - // the latter worked with older JVM's, current ones *crash* you when a compare is not transitive. + // the latter worked with older JVM's, + // current ones *crash* you when a compare is not transitive. } private fun fireOnChange() { @@ -368,12 +413,14 @@ class MusicController { /** * Chooses and plays a music track using an adaptable approach - for details see the wiki. - * Called without parameters it will choose a new ambient music track and start playing it with fade-in/out. + * Called without parameters it will choose a new ambient music track + * and start playing it with fade-in/out. * Will do nothing when no music files exist or the master volume is zero. * * @param prefix file name prefix, meant to represent **Context** - in most cases a Civ name - * @param suffix file name suffix, meant to represent **Mood** - e.g. Peace, War, Theme, Defeat, Ambient - * (Ambient is the default when a track ends and exists so War Peace and the others are not chosen in that case) + * @param suffix file name suffix, meant to represent **Mood** - + * e.g. Peace, War, Theme, Defeat, Ambient (Ambient is the default when + * a track ends and exists so War Peace and the others are not chosen in that case) * @param flags a set of optional flags to tune the choice and playback. * @return `true` = success, `false` = no match, no playback change */ @@ -388,10 +435,12 @@ class MusicController { if (musicFile == null) { // MustMatch flags at work or Music folder empty - Log.debug("No music found for prefix=%s, suffix=%s, flags=%s", prefix, suffix, flags) + Log.debug("No music found for prefix=%s, suffix=%s, flags=%s", + prefix, suffix, flags) return false } - Log.debug("Track chosen: %s for prefix=%s, suffix=%s, flags=%s", musicFile.path(), prefix, suffix, flags) + Log.debug("Track chosen: %s for prefix=%s, suffix=%s, flags=%s", + musicFile.path(), prefix, suffix, flags) return startTrack(musicFile, flags) } @@ -420,10 +469,12 @@ 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. + // 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) + val fadingStep = defaultFadingStep / + (if (flags.contains(MusicTrackChooserFlags.SlowFade)) 5 else 1) it.startFade(MusicTrackController.State.FadeIn, fadingStep) when (state) { @@ -436,7 +487,8 @@ 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 + state = if (flags.contains(MusicTrackChooserFlags.PlaySingle)) ControllerState.PlaySingle + else ControllerState.Playing startTimer() return true } @@ -470,8 +522,8 @@ class MusicController { */ fun pause(speedFactor: Float = 1f) { Log.debug("MusicTrackController.pause called") - val controller = current - if ((state != ControllerState.Playing && state != ControllerState.PlaySingle) || controller == null) return + val controller = current ?: return + if (state != ControllerState.Playing && state != ControllerState.PlaySingle) return val fadingStep = defaultFadingStep * speedFactor.coerceIn(0.001f..1000f) controller.startFade(MusicTrackController.State.FadeOut, fadingStep) if (next?.state == MusicTrackController.State.FadeIn) @@ -490,14 +542,17 @@ class MusicController { if (state == ControllerState.Pause && current != null) { val fadingStep = defaultFadingStep * speedFactor.coerceIn(0.001f..1000f) 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 + // this may circumvent a PlaySingle, but - + // currently only the main menu resumes, and then it's perfect: + state = ControllerState.Playing current!!.play() } else if (state == ControllerState.Cleanup) { chooseTrack() } } - /** Fade out then shutdown with a given [duration] in seconds, defaults to a 'slow' fade (4.5s) */ + /** Fade out then shutdown with a given [duration] in seconds, + * defaults to a 'slow' fade (4.5s) */ @Suppress("unused") // might be useful instead of gracefulShutdown fun fadeoutToSilence(duration: Float = defaultFadeDuration * 5) { val fadingStep = 1f / ticksPerSecond / duration @@ -529,5 +584,53 @@ class MusicController { fun isDefaultFileAvailable() = getFile(musicFallbackLocalName).exists() + //endregion + //region Overlay track + + /** Scans all mods [folder]s for [name] with a supported sound extension, + * with fallback to _internal_ assets [folder] */ + private fun getMatchingFiles(folder: String, name: String) = + getMusicFolders(folder) { Gdx.files.internal(folder) } + .flatMap { + it.list { file: File -> + file.nameWithoutExtension == name && file.exists() && !file.isDirectory && + file.extension in gdxSupportedFileExtensions + }.asSequence() + } + + /** Play [name] from any mod's [folder] or internal assets, + * fading in to [volume] then looping */ + fun playOverlay(folder: String, name: String, volume: Float) { + val file = getMatchingFiles(folder, name).firstOrNull() ?: return + playOverlay(file, volume) + } + + /** Play [file], fading in to [volume] then looping */ + @Suppress("MemberVisibilityCanBePrivate") // open to future use + fun playOverlay(file: FileHandle, volume: Float) { + clearOverlay() + MusicTrackController(volume, initialFadeVolume = 0f).load(file) { + it.music?.isLooping = true + it.play() + it.startFade(MusicTrackController.State.FadeIn) + overlay = it + } + } + + /** Fade out any playing overlay then clean up */ + fun stopOverlay() { + overlay?.startFade(MusicTrackController.State.FadeOut) + } + + private fun MusicTrackController.overlayTick() { + if (timerTick() == MusicTrackController.State.Idle) + clearOverlay() // means FadeOut finished + } + + private fun clearOverlay() { + overlay?.clear() + overlay = null + } + //endregion } diff --git a/core/src/com/unciv/ui/audio/MusicTrackController.kt b/core/src/com/unciv/ui/audio/MusicTrackController.kt index 2fad3ec847..72c788037a 100644 --- a/core/src/com/unciv/ui/audio/MusicTrackController.kt +++ b/core/src/com/unciv/ui/audio/MusicTrackController.kt @@ -7,7 +7,7 @@ import com.unciv.utils.Log import com.unciv.utils.debug /** Wraps one Gdx Music instance and manages loading, playback, fading and cleanup */ -internal class MusicTrackController(private var volume: Float) { +internal class MusicTrackController(private var volume: Float, initialFadeVolume: Float = 1f) { /** Internal state of this Music track */ enum class State(val canPlay: Boolean) { @@ -25,7 +25,7 @@ internal class MusicTrackController(private var volume: Float) { var music: Music? = null private set private var fadeStep = MusicController.defaultFadingStep - private var fadeVolume: Float = 1f + private var fadeVolume: Float = initialFadeVolume //region Functions for MusicController @@ -47,7 +47,9 @@ internal class MusicTrackController(private var volume: Float) { onError: ((MusicTrackController)->Unit)? = null, onSuccess: ((MusicTrackController)->Unit)? = null ) { - check(state == State.None && music == null) { "MusicTrackController.load should only be called once" } + check(state == State.None && music == null) { + "MusicTrackController.load should only be called once" + } state = State.Loading try { @@ -75,12 +77,13 @@ internal class MusicTrackController(private var volume: Float) { /** Starts fadeIn or fadeOut. * - * Note this does _not_ set the current fade "percentage" to allow smoothly changing direction mid-fade + * Note this does _not_ set the current fade "percentage" to allow smoothly + * changing direction mid-fade * @param step Overrides current fade step only if >0 */ fun startFade(fade: State, step: Float = 0f) { if (!state.canPlay) return - if (fadeStep > 0f) fadeStep = step + if (step > 0f) fadeStep = step state = fade } @@ -97,7 +100,8 @@ internal class MusicTrackController(private var volume: Float) { return timerTick() == State.Idle } - /** @return [Music.isPlaying] (Gdx music stream is playing) unless [state] says it won't make sense */ + /** @return [Music.isPlaying] (Gdx music stream is playing) + * unless [state] says it won't make sense */ fun isPlaying() = state.canPlay && music?.isPlaying == true /** Calls play() on the wrapped Gdx Music, catching exceptions to console. @@ -105,11 +109,14 @@ internal class MusicTrackController(private var volume: Float) { * @throws IllegalStateException if called on uninitialized instance */ fun play(): Boolean { - check(state.canPlay && music != null) { "MusicTrackController.play called on uninitialized instance" } + check(state.canPlay && music != null) { + "MusicTrackController.play called on uninitialized instance" + } // Unexplained observed exception: Gdx.Music.play fails with // "Unable to allocate audio buffers. AL Error: 40964" (AL_INVALID_OPERATION) - // Approach: This track dies, parent controller will enter state Silence thus retry after a while. + // Approach: This track dies, parent controller will enter state Silence thus + // retry after a while. if (tryPlay(music!!)) return true state = State.Error return false @@ -136,7 +143,8 @@ internal class MusicTrackController(private var volume: Float) { state = State.Playing } private fun fadeOutStep() { - // fade-out: linearly ramp fadeVolume to 0.0, then act according to Status (Playing->Silence/Pause/Shutdown) + // 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 { @@ -153,9 +161,9 @@ internal class MusicTrackController(private var volume: Float) { private fun tryPlay(music: Music): Boolean { return try { - music.volume = volume - if (!music.isPlaying) // for fade-over this could be called by the end of the previous track - music.play() + music.volume = volume * fadeVolume + // for fade-over this could be called by the end of the previous track: + if (!music.isPlaying) music.play() true } catch (ex: Throwable) { audioExceptionHandler(ex)