package net.torvald.terrarum import net.torvald.terrarum.App.printdbg import net.torvald.terrarum.audio.AudioBank import net.torvald.terrarum.audio.AudioMixer.Companion.DEFAULT_FADEOUT_LEN import net.torvald.terrarum.audio.audiobank.MusicContainer import net.torvald.terrarum.transaction.Transaction import net.torvald.terrarum.transaction.TransactionListener import net.torvald.terrarum.transaction.TransactionState import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference /** * To play the music, create a transaction then pass it to the `runTransaction(Transaction)` * * Created by minjaesong on 2024-06-28. */ object MusicService : TransactionListener() { private val currentPlaylistReference = AtomicReference(null) val currentPlaylist: TerrarumMusicPlaylist?; get() = currentPlaylistReference.get() override fun getCurrentStatusForTransaction(): TransactionState { return TransactionState( hashMapOf( "currentPlaylist" to currentPlaylistReference.get() ) ) } override fun commitTransaction(state: TransactionState) { (state["currentPlaylist"] as TerrarumMusicPlaylist?).let { this.currentPlaylistReference.set(it) } } private const val STATE_INTERMISSION = 0 private const val STATE_FIREPLAY = 1 private const val STATE_PLAYING = 2 val currentPlaybackState = AtomicInteger(STATE_INTERMISSION) var waitAkku = 0f; private set var waitTime = 10f; private set private fun enterSTATE_INTERMISSION(waitFor: Float) { currentPlaybackState.set(STATE_INTERMISSION) waitTime = waitFor waitAkku = 0f playTransactionOngoing = false } private fun enterSTATE_FIREPLAY() { val state = currentPlaybackState.get() if (state == STATE_PLAYING) throw IllegalStateException("Cannot change state PLAYING -> FIREPLAY") waitAkku = 0f currentPlaybackState.set(STATE_FIREPLAY) } private fun enterSTATE_PLAYING() { val state = currentPlaybackState.get() if (state == STATE_INTERMISSION) throw IllegalStateException("Cannot change state INTERMISSION -> PLAYING") if (state == STATE_PLAYING) throw IllegalStateException("Cannot change state PLAYING -> PLAYING") currentPlaybackState.set(STATE_PLAYING) } fun getRandomMusicInterval() = 16f + Math.random().toFloat() * 4f // longer gap (16s to 20s) private fun enterIntermissionAndWaitForPlaylist() { val djmode = currentPlaylist?.diskJockeyingMode ?: "intermittent" val time = when (djmode) { "intermittent" -> getRandomMusicInterval() "continuous" -> 0f else -> getRandomMusicInterval() } enterSTATE_INTERMISSION(time) if (djmode == "continuous") enterSTATE_FIREPLAY() } fun enterIntermission() { enterSTATE_INTERMISSION(getRandomMusicInterval()) } fun onMusicFinishing(audio: AudioBank) { // printdbg(this, "onMusicFinishing ${audio.name}") enterIntermissionAndWaitForPlaylist() } private var playTransactionOngoing = false fun update(delta: Float) { when (currentPlaybackState.get()) { STATE_FIREPLAY -> { if (!playTransactionOngoing) { playTransactionOngoing = true MusicService.resumePlaylistPlayback( /* onSuccess: () -> Unit */ { runTransaction(object : Transaction { private lateinit var nextMusic: MusicContainer override fun start(state: TransactionState) { nextMusic = (state["currentPlaylist"] as TerrarumMusicPlaylist).queueNext() App.audioMixer.startMusic(nextMusic) } override fun onSuccess(state: TransactionState) { // printdbg(this, "FIREPLAY started music (${nextMusic.name})") currentPlaybackState.set(STATE_PLAYING) // force PLAYING } override fun onFailure(e: Throwable, state: TransactionState) { // printdbg(this, "FIREPLAY resume OK but startMusic failed, entering intermission") enterIntermissionAndWaitForPlaylist() // will try again } }) }, /* onFailure: (Throwable) -> Unit */ { // printdbg(this, "FIREPLAY resume failed, entering intermission") enterIntermissionAndWaitForPlaylist() // will try again }, // onFinally: () -> Unit { playTransactionOngoing = false } ) } else { // printdbg(this, "FIREPLAY no-op: playTransaction is ongoing") } } STATE_PLAYING -> { // onMusicFinishing() will be called when the music finishes; it's on the setOnCompletionListener } STATE_INTERMISSION -> { waitAkku += delta if (waitAkku >= waitTime && currentPlaylist != null) { // force FIREPLAY waitAkku = 0f currentPlaybackState.set(STATE_FIREPLAY) } } } } fun enterScene(id: String) { /*val playlist = when (id) { "title" -> getTitlePlaylist() "ingame" -> getIngameDefaultPlaylist() else -> getIngameDefaultPlaylist() } putNewPlaylist(playlist) { // after the fadeout, we'll... enterSTATE_FIREPLAY() }*/ stopPlaylistPlayback { } } fun leaveScene() { stopPlaylistPlayback {} } /** * Puts the given playlist to this object if the transaction successes. If the given playlist is same as the * current playlist, the transaction will successfully finish immediately; otherwise the given playlist will * be reset as soon as the transaction starts. Note that the resetting behaviour is NOT atomic. (the given * playlist will stay in reset state even if the transaction fails) * * When the transaction was successful, the old playlist gets disposed of, then the songFinishedHook of * the songs in the new playlist will be overwritten, before `onSuccess` is called. * * The old playlist will be disposed of if and only if the transaction was successful. * * @param playlist An instance of a [TerrarumMusicPlaylist] to be changed into * @param onSuccess What to do after the transaction */ private fun createTransactionPlaylistChange(playlist: TerrarumMusicPlaylist, onSuccess: () -> Unit): Transaction { return object : Transaction { var oldPlaylist: TerrarumMusicPlaylist? = null override fun start(state: TransactionState) { oldPlaylist = state["currentPlaylist"] as TerrarumMusicPlaylist? if (oldPlaylist == playlist) return playlist.reset() // request fadeout if (App.audioMixer.musicTrack.isPlaying) { var fadedOut = false App.audioMixer.requestFadeOut(App.audioMixer.musicTrack) { // put new playlist state["currentPlaylist"] = playlist fadedOut = true } waitUntil { fadedOut } } else { // put new playlist state["currentPlaylist"] = playlist } } override fun onSuccess(state: TransactionState) { oldPlaylist?.dispose() (state["currentPlaylist"] as TerrarumMusicPlaylist?)?.let { // set songFinishedHook for every song it.musicList.forEach { it.songFinishedHook = { onMusicFinishing(it) } } // set gaplessness of the Music track App.audioMixer.musicTrack.let { track -> track.doGaplessPlayback = (it.diskJockeyingMode == "continuous") if (track.doGaplessPlayback) { track.pullNextTrack = { track.currentTrack = MusicService.currentPlaylist!!.queueNext() } } } } onSuccess() } override fun onFailure(e: Throwable, state: TransactionState) { e.printStackTrace() } } } /** * Puts the given playlist to this object if the transaction successes. If the given playlist is same as the * current playlist, the transaction will successfully finish immediately; otherwise the given playlist will * be reset as soon as the transaction starts. Note that the resetting behaviour is NOT atomic. (the given * playlist will stay in reset state even if the transaction fails) * * When the transaction was successful, the old playlist gets disposed of, then the songFinishedHook of * the songs in the new playlist will be overwritten, before `onSuccess` is called. * * The old playlist will be disposed of if and only if the transaction was successful. * * @param playlist An instance of a [TerrarumMusicPlaylist] to be changed into * @param onSuccess What to do after the transaction */ private fun createTransactionPlaylistChangeAndPlayImmediately(playlist: TerrarumMusicPlaylist, fadeLen: Double, onSuccess: () -> Unit): Transaction { return object : Transaction { var oldPlaylist: TerrarumMusicPlaylist? = null var oldState = currentPlaybackState.get() var oldAkku = waitAkku var oldTime = waitTime override fun start(state: TransactionState) { oldPlaylist = state["currentPlaylist"] as TerrarumMusicPlaylist? if (oldPlaylist == playlist) return playlist.reset() // request fadeout if (App.audioMixer.musicTrack.isPlaying) { var fadedOut = false App.audioMixer.requestFadeOut(App.audioMixer.musicTrack, fadeLen) { // put new playlist state["currentPlaylist"] = playlist fadedOut = true } waitUntil { fadedOut } } else { // put new playlist state["currentPlaylist"] = playlist } oldState = currentPlaybackState.get() oldAkku = waitAkku oldTime = waitTime enterSTATE_INTERMISSION(0f) enterSTATE_FIREPLAY() } override fun onSuccess(state: TransactionState) { oldPlaylist?.dispose() (state["currentPlaylist"] as TerrarumMusicPlaylist?)?.let { // set songFinishedHook for every song it.musicList.forEach { it.songFinishedHook = { onMusicFinishing(it) } } // set gaplessness of the Music track App.audioMixer.musicTrack.let { track -> track.doGaplessPlayback = (it.diskJockeyingMode == "continuous") if (track.doGaplessPlayback) { track.pullNextTrack = { track.currentTrack = MusicService.currentPlaylist!!.queueNext() } } } } onSuccess() } override fun onFailure(e: Throwable, state: TransactionState) { e.printStackTrace() currentPlaybackState.set(oldState) waitAkku = oldAkku waitTime = oldTime } } } private fun createTransactionForNextMusicInPlaylist(fadeLen: Double, onSuccess: () -> Unit): Transaction { return object : Transaction { var oldState = currentPlaybackState.get() var oldAkku = waitAkku var oldTime = waitTime override fun start(state: TransactionState) { var fadedOut = false var err: Throwable? = null // request fadeout App.audioMixer.requestFadeOut(App.audioMixer.musicTrack, fadeLen) { try { // do nothing, really fadedOut = true } catch (e: Throwable) { err = e } } waitUntil { fadedOut || err != null } if (err != null) throw err!! oldState = currentPlaybackState.get() oldAkku = waitAkku oldTime = waitTime enterSTATE_INTERMISSION(0f) enterSTATE_FIREPLAY() } override fun onSuccess(state: TransactionState) { onSuccess() } override fun onFailure(e: Throwable, state: TransactionState) { e.printStackTrace() currentPlaybackState.set(oldState) waitAkku = oldAkku waitTime = oldTime } } } private fun createTransactionForPrevMusicInPlaylist(fadeLen: Double, onSuccess: () -> Unit): Transaction { return object : Transaction { var oldState = currentPlaybackState.get() var oldAkku = waitAkku var oldTime = waitTime override fun start(state: TransactionState) { var fadedOut = false var err: Throwable? = null // request fadeout App.audioMixer.requestFadeOut(App.audioMixer.musicTrack, fadeLen) { try { // unshift the playlist // FIREPLAY always pulls next track, that's why we need two prev() (state["currentPlaylist"] as TerrarumMusicPlaylist).queuePrev() (state["currentPlaylist"] as TerrarumMusicPlaylist).queuePrev() fadedOut = true } catch (e: Throwable) { err = e } } waitUntil { fadedOut || err != null } if (err != null) throw err!! oldState = currentPlaybackState.get() oldAkku = waitAkku oldTime = waitTime enterSTATE_INTERMISSION(0f) enterSTATE_FIREPLAY() } override fun onSuccess(state: TransactionState) { onSuccess() } override fun onFailure(e: Throwable, state: TransactionState) { // reshift the playlist (state["currentPlaylist"] as TerrarumMusicPlaylist).queueNext() e.printStackTrace() currentPlaybackState.set(oldState) waitAkku = oldAkku waitTime = oldTime } } } private fun createTransactionForNthMusicInPlaylist(index: Int, fadeLen: Double, onSuccess: () -> Unit): Transaction { return object : Transaction { var oldState = currentPlaybackState.get() var oldAkku = waitAkku var oldTime = waitTime override fun start(state: TransactionState) { var fadedOut = false var err: Throwable? = null // request fadeout App.audioMixer.requestFadeOut(App.audioMixer.musicTrack, fadeLen) { try { // callback: play prev song in the playlist // queue the nth song on the playlist, the actual playback will be done by the state machine update (state["currentPlaylist"] as TerrarumMusicPlaylist).queueNthSong(index) fadedOut = true } catch (e: Throwable) { err = e } } waitUntil { fadedOut || err != null } if (err != null) throw err!! oldState = currentPlaybackState.get() oldAkku = waitAkku oldTime = waitTime enterSTATE_INTERMISSION(0f) enterSTATE_FIREPLAY() } override fun onSuccess(state: TransactionState) { onSuccess() } override fun onFailure(e: Throwable, state: TransactionState) { e.printStackTrace() currentPlaybackState.set(oldState) waitAkku = oldAkku waitTime = oldTime } } } private fun createTransactionForPlaylistStop(onSuccess: () -> Unit): Transaction { return object : Transaction { override fun start(state: TransactionState) { var fadedOut = false // request fadeout App.audioMixer.requestFadeOut(App.audioMixer.musicTrack) { fadedOut = true } waitUntil { fadedOut } enterSTATE_INTERMISSION(Float.POSITIVE_INFINITY) } override fun onSuccess(state: TransactionState) { onSuccess() } override fun onFailure(e: Throwable, state: TransactionState) { e.printStackTrace() } } } private fun createTransactionForPlaylistResume(onSuccess: () -> Unit, onFailure: (Throwable) -> Unit): Transaction { return object : Transaction { var oldState = currentPlaybackState.get() var oldAkku = waitAkku var oldTime = waitTime override fun start(state: TransactionState) { enterSTATE_FIREPLAY() } override fun onSuccess(state: TransactionState) { onSuccess() } override fun onFailure(e: Throwable, state: TransactionState) { e.printStackTrace() currentPlaybackState.set(oldState) waitAkku = oldAkku waitTime = oldTime onFailure(e) } } } private fun createTransactionPausePlaylistForMusicalFixture( action: () -> Unit, musicFinished: () -> Boolean, onSuccess: () -> Unit, onFailure: (Throwable) -> Unit ): Transaction { return object : Transaction { override fun start(state: TransactionState) { var fadedOut = false var err: Throwable? = null // request fadeout App.audioMixer.requestFadeOut(App.audioMixer.musicTrack, DEFAULT_FADEOUT_LEN / 2.0) { try { // callback: let the caller actually take care of playing the audio action() fadedOut = true } catch (e: Throwable) { err = e e.printStackTrace() } } waitUntil { fadedOut || err != null } if (err != null) throw err!! // enter intermission state enterSTATE_INTERMISSION(Float.POSITIVE_INFINITY) // wait until the interjected music finishes waitUntil { musicFinished() } } override fun onSuccess(state: TransactionState) { onSuccess() enterSTATE_INTERMISSION(getRandomMusicInterval()) } override fun onFailure(e: Throwable, state: TransactionState) { e.printStackTrace() onFailure(e) enterSTATE_INTERMISSION(getRandomMusicInterval()) } } // note to self: wait() and notify() using a lock object is impractical as the Java thread can wake up // randomly regardless of the notify(), which results in the common pattern of // while (!condition) { lock.wait() } // and if we need extra condition (i.e. musicFinished()), it's just a needlessly elaborate way of spinning, // UNLESS THE THING MUST BE SYNCHRONISED WITH SOMETHING } private fun waitUntil(escapeCondition: () -> Boolean) { while (!escapeCondition()) { Thread.sleep(4L) } } fun putNewPlaylist(playlist: TerrarumMusicPlaylist, onSuccess: (() -> Unit)? = null) { if (onSuccess != null) runTransaction(createTransactionPlaylistChange(playlist, onSuccess)) else runTransaction(createTransactionPlaylistChange(playlist, {})) } fun putNewPlaylistAndResumePlayback(playlist: TerrarumMusicPlaylist, shortFade: Boolean = false, onSuccess: (() -> Unit)? = null) { if (onSuccess != null) runTransaction(createTransactionPlaylistChangeAndPlayImmediately(playlist, DEFAULT_FADEOUT_LEN / shortFade.toInt().plus(1), onSuccess)) else runTransaction(createTransactionPlaylistChangeAndPlayImmediately(playlist, DEFAULT_FADEOUT_LEN / shortFade.toInt().plus(1), {})) } fun putNewPlaylist(playlist: TerrarumMusicPlaylist, onSuccess: () -> Unit, onFinally: () -> Unit) { runTransaction(createTransactionPlaylistChange(playlist, onSuccess), onFinally) } /** Normal playlist playback will resume after the transaction, after the onSuccess/onFailure */ fun playMusicalFixture(action: () -> Unit, musicFinished: () -> Boolean, onSuccess: () -> Unit, onFailure: (Throwable) -> Unit) { runTransaction(createTransactionPausePlaylistForMusicalFixture(action, musicFinished, onSuccess, onFailure)) } /** Normal playlist playback will resume after the transaction, after the onSuccess/onFailure but before the onFinally */ fun playMusicalFixture(action: () -> Unit, musicFinished: () -> Boolean, onSuccess: () -> Unit, onFailure: (Throwable) -> Unit, onFinally: () -> Unit = {}) { runTransaction(createTransactionPausePlaylistForMusicalFixture(action, musicFinished, onSuccess, onFailure), onFinally) } fun playNextSongInPlaylist(shortFade: Boolean = false, onSuccess: () -> Unit) { runTransaction(createTransactionForNextMusicInPlaylist(DEFAULT_FADEOUT_LEN / shortFade.toInt().plus(1), onSuccess)) } fun playNextSongInPlaylist(shortFade: Boolean = false, onSuccess: () -> Unit, onFinally: () -> Unit) { runTransaction(createTransactionForNextMusicInPlaylist(DEFAULT_FADEOUT_LEN / shortFade.toInt().plus(1), onSuccess), onFinally) } fun playPrevSongInPlaylist(shortFade: Boolean = false, onSuccess: () -> Unit) { runTransaction(createTransactionForPrevMusicInPlaylist(DEFAULT_FADEOUT_LEN / shortFade.toInt().plus(1), onSuccess)) } fun playPrevSongInPlaylist(shortFade: Boolean = false, onSuccess: () -> Unit, onFinally: () -> Unit) { runTransaction(createTransactionForPrevMusicInPlaylist(DEFAULT_FADEOUT_LEN / shortFade.toInt().plus(1), onSuccess), onFinally) } fun playNthSongInPlaylist(index: Int, shortFade: Boolean = false, onSuccess: () -> Unit) { runTransaction(createTransactionForNthMusicInPlaylist(index, DEFAULT_FADEOUT_LEN / shortFade.toInt().plus(1), onSuccess)) } fun playNthSongInPlaylist(index: Int, shortFade: Boolean = false, onSuccess: () -> Unit, onFinally: () -> Unit) { runTransaction(createTransactionForNthMusicInPlaylist(index, DEFAULT_FADEOUT_LEN / shortFade.toInt().plus(1), onSuccess), onFinally) } fun stopPlaylistPlayback(onSuccess: () -> Unit) { runTransaction(createTransactionForPlaylistStop(onSuccess)) } fun stopPlaylistPlayback(onSuccess: () -> Unit, onFinally: () -> Unit) { runTransaction(createTransactionForPlaylistStop(onSuccess), onFinally) } fun resumePlaylistPlayback(onSuccess: () -> Unit, onFailure: (Throwable) -> Unit) { runTransaction(createTransactionForPlaylistResume(onSuccess, onFailure)) } fun resumePlaylistPlayback(onSuccess: () -> Unit, onFailure: (Throwable) -> Unit, onFinally: () -> Unit) { runTransaction(createTransactionForPlaylistResume(onSuccess, onFailure), onFinally) } }