Fade in and out for City Ambiance Sounds (#10230)

* Extend MusicController to allow one extra 'overlay' track

* Reduce CityAmbiencePlayer to a proxy for MusicController's 'overlay'

* Treat all the LongLine complaints
This commit is contained in:
SomeTroglodyte 2023-10-05 09:25:06 +02:00 committed by GitHub
parent a4e3617037
commit 5541407a3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 173 additions and 110 deletions

View File

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

View File

@ -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<String>()
@ -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<String>(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<FileHandle> {
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<MusicTrackChooserFlags>): FileHandle? {
private fun chooseFile(
prefix: String,
suffix: String,
flags: EnumSet<MusicTrackChooserFlags>
): 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
}

View File

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