Files
Terrarum/src/net/torvald/terrarum/MusicService.kt
2024-07-09 14:28:51 +09:00

640 lines
25 KiB
Kotlin

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<TerrarumMusicPlaylist?>(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)
}
}