mirror of
https://github.com/curioustorvald/Terrarum.git
synced 2026-03-16 08:36:07 +09:00
401 lines
15 KiB
Kotlin
401 lines
15 KiB
Kotlin
package net.torvald.terrarum.audio
|
|
|
|
import net.torvald.terrarum.*
|
|
import net.torvald.terrarum.audio.AudioMixer.Companion.DS_FLTIDX_LOW
|
|
import net.torvald.terrarum.audio.AudioMixer.Companion.DS_FLTIDX_PAN
|
|
import net.torvald.terrarum.audio.AudioMixer.Companion.SPEED_OF_SOUND_AIR
|
|
import net.torvald.terrarum.audio.TerrarumAudioMixerTrack.Companion.SAMPLING_RATE
|
|
import net.torvald.terrarum.audio.TerrarumAudioMixerTrack.Companion.SAMPLING_RATED
|
|
import net.torvald.terrarum.audio.dsp.BinoPan
|
|
import net.torvald.terrarum.audio.dsp.Lowpass
|
|
import net.torvald.terrarum.audio.dsp.NullFilter
|
|
import net.torvald.terrarum.gameactors.ActorWithBody
|
|
import net.torvald.terrarum.gameactors.ActorWithBody.Companion.GAME_TO_SI_VELO
|
|
import org.dyn4j.geometry.Vector2
|
|
import kotlin.math.absoluteValue
|
|
import kotlin.math.cosh
|
|
import kotlin.math.sqrt
|
|
|
|
/**
|
|
* Created by minjaesong on 2023-11-17.
|
|
*/
|
|
class MixerTrackProcessor(bufferSize: Int, val rate: Int, val track: TerrarumAudioMixerTrack): Runnable {
|
|
|
|
private var buffertaille = bufferSize
|
|
|
|
companion object {
|
|
fun getVolFun(x: Double): Double {
|
|
// https://www.desmos.com/calculator/blcd4s69gl
|
|
// val K = 1.225
|
|
// fun q(x: Double) = if (x >= 1.0) 0.5 else (K*x - K).pow(2.0) + 0.5
|
|
// val x2 = x.pow(q(x))
|
|
|
|
// method 1.
|
|
// https://www.desmos.com/calculator/uzbjw10lna
|
|
// val K = 512.0
|
|
// return K.pow(-sqrt(1.0+x.sqr())) * K
|
|
|
|
|
|
// method 2.
|
|
// https://www.desmos.com/calculator/3xsac66rsp
|
|
|
|
|
|
// method 3.
|
|
// comparison with method 1.
|
|
// https://www.desmos.com/calculator/rbteowef8v
|
|
val Q = 2.0
|
|
return 1.0 / cosh(Q * x).sqr()
|
|
}
|
|
}
|
|
|
|
@Volatile var running = true; private set
|
|
@Volatile var paused = false; private set
|
|
private val pauseLock = java.lang.Object()
|
|
|
|
|
|
private var emptyBuf = FloatArray(buffertaille)
|
|
|
|
|
|
internal var streamBuf: AudioProcessBuf? = null
|
|
|
|
// internal var jitterMode = 0
|
|
// internal var jitterIntensity = 0f
|
|
|
|
private var fout1 = listOf(emptyBuf, emptyBuf)
|
|
|
|
val maxSigLevel = arrayOf(0.0, 0.0)
|
|
val maxRMS = arrayOf(0.0, 0.0)
|
|
val hasClipping = arrayOf(false, false)
|
|
|
|
internal fun purgeBuffer() {
|
|
fout1.forEach { it.fill(0f) }
|
|
purgeStreamBuf()
|
|
}
|
|
|
|
private fun purgeStreamBuf() {
|
|
track.stop()
|
|
streamBuf = null
|
|
// printdbg("StreamBuf is now null")
|
|
}
|
|
|
|
private var breakBomb = false
|
|
|
|
private val distFalloff = 1600.0
|
|
|
|
private fun printdbg(msg: Any) {
|
|
if (true) App.printdbg("MixerTrackProcessor ${track.name}", msg)
|
|
}
|
|
|
|
private fun allocateStreamBuf(track: TerrarumAudioMixerTrack) {
|
|
// printdbg("Allocating a StreamBuf with rate ${track.currentTrack!!.samplingRate}")
|
|
streamBuf = AudioProcessBuf(track.currentTrack!!.samplingRate, { bufL, bufR ->
|
|
var samplesRead = track.currentTrack?.readSamples(bufL, bufR) ?: 0
|
|
|
|
// do gapless fetch if there is space in the buffer
|
|
if (track.doGaplessPlayback && samplesRead < bufL.size) {
|
|
track.currentTrack?.reset()
|
|
track.pullNextTrack()
|
|
|
|
samplesRead += read00(bufL, bufR, samplesRead)
|
|
}
|
|
|
|
samplesRead
|
|
}, { purgeStreamBuf() }).also {
|
|
// it.jitterMode = jitterMode
|
|
// it.jitterIntensity = jitterIntensity
|
|
}
|
|
}
|
|
|
|
private fun read00(bufL: FloatArray, bufR: FloatArray, samplesRead: Int): Int {
|
|
val bufSize = bufL.size - samplesRead
|
|
val tmpBufL = FloatArray(bufSize)
|
|
val tmpBufR = FloatArray(bufSize)
|
|
|
|
val newRead = track.currentTrack?.readSamples(tmpBufL, tmpBufR) ?: 0
|
|
|
|
System.arraycopy(tmpBufL, 0, bufL, samplesRead, tmpBufL.size)
|
|
System.arraycopy(tmpBufR, 0, bufR, samplesRead, tmpBufR.size)
|
|
|
|
return newRead
|
|
}
|
|
|
|
var bufEmpty = true; private set
|
|
|
|
override fun run() {
|
|
// while (running) { // uncomment to multithread
|
|
/*synchronized(pauseLock) { // uncomment to multithread
|
|
if (!running) { // may have changed while waiting to
|
|
// synchronize on pauseLock
|
|
breakBomb = true
|
|
}
|
|
if (paused) {
|
|
try {
|
|
pauseLock.wait() // will cause this Thread to block until
|
|
// another thread calls pauseLock.notifyAll()
|
|
// Note that calling wait() will
|
|
// relinquish the synchronized lock that this
|
|
// thread holds on pauseLock so another thread
|
|
// can acquire the lock to call notifyAll()
|
|
// (link with explanation below this code)
|
|
}
|
|
catch (ex: InterruptedException) {
|
|
breakBomb = true
|
|
}
|
|
if (!running) { // running might have changed since we paused
|
|
breakBomb = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if (breakBomb) break*/ // uncomment to multithread
|
|
// Your code here
|
|
|
|
|
|
// update panning and shits
|
|
if (track.trackType == TrackType.DYNAMIC_SOURCE && track.isPlaying) {
|
|
(track.filters[DS_FLTIDX_PAN] as BinoPan).earDist = App.audioMixer.listenerHeadSize
|
|
|
|
if (App.audioMixer.actorNowPlaying != null) {
|
|
val trackingTarget = track.trackingTarget
|
|
if (trackingTarget == null || trackingTarget == App.audioMixer.actorNowPlaying) {
|
|
// "reset" the track
|
|
track.volume = track.maxVolume
|
|
(track.filters[DS_FLTIDX_PAN] as BinoPan).pan = 0f
|
|
(track.filters[DS_FLTIDX_LOW] as Lowpass).setCutoff(SAMPLING_RATE / 2f)
|
|
}
|
|
else if (trackingTarget is ActorWithBody) {
|
|
val relativeXpos = relativeXposition(App.audioMixer.actorNowPlaying!!, trackingTarget as ActorWithBody)
|
|
val distFromActor = distBetweenActors(App.audioMixer.actorNowPlaying!!, trackingTarget as ActorWithBody)
|
|
val vol = track.maxVolume * getVolFun(distFromActor / distFalloff).coerceAtLeast(0.0)
|
|
track.volume = vol
|
|
(track.filters[DS_FLTIDX_PAN] as BinoPan).pan = (1.3f * relativeXpos / distFalloff).toFloat()
|
|
(track.filters[DS_FLTIDX_LOW] as Lowpass).setCutoff(
|
|
(SAMPLING_RATED*0.5) / (24.0 * (distFromActor / distFalloff).sqr() + 1.0)
|
|
)
|
|
|
|
val sourceVec = (trackingTarget as ActorWithBody).let { it.externalV + it.controllerV }
|
|
val listenerVec = App.audioMixer.actorNowPlaying!!.let { it.externalV + it.controllerV }
|
|
val distFromActorNext = distBetweenPoints(
|
|
App.audioMixer.actorNowPlaying!!.centrePosVector + listenerVec,
|
|
(trackingTarget as ActorWithBody).centrePosVector + sourceVec
|
|
)
|
|
val isApproaching = if (distFromActorNext <= distFromActor) 1.0 else -1.0
|
|
val relativeSpeed = (sourceVec - listenerVec).magnitude * GAME_TO_SI_VELO * isApproaching
|
|
val soundSpeed = SPEED_OF_SOUND_AIR * 4f // using an arbitrary value for "gamification"
|
|
val dopplerFactor = (soundSpeed + relativeSpeed) / soundSpeed // >1: speedup, <1: speeddown
|
|
|
|
track.processor.streamBuf?.playbackSpeed = dopplerFactor.toFloat()
|
|
|
|
// printdbg("dist=$distFromActor\tdopplerFactor=$dopplerFactor")
|
|
}
|
|
}
|
|
else {
|
|
// "reset" the track
|
|
track.volume = track.maxVolume
|
|
(track.filters[DS_FLTIDX_PAN] as BinoPan).pan = 0f
|
|
(track.filters[DS_FLTIDX_LOW] as Lowpass).setCutoff(SAMPLING_RATE / 2f)
|
|
}
|
|
}
|
|
|
|
|
|
// fetch deviceBufferSize amount of sample from the disk
|
|
if (track.playRequested.get()) {
|
|
track.play()
|
|
}
|
|
|
|
if (track.trackType != TrackType.MASTER && track.trackType != TrackType.BUS && track.streamPlaying.get()) {
|
|
if (streamBuf == null && track.currentTrack != null) {
|
|
allocateStreamBuf(track)
|
|
}
|
|
|
|
if (track.currentTrack == null) throw IllegalStateException("Track ${track.name} is playing but also has null music track")
|
|
|
|
streamBuf?.fetchBytes() ?: throw NullPointerException("Null StreamBuf for ${track.name}")
|
|
}
|
|
|
|
var samplesL1: FloatArray
|
|
var samplesR1: FloatArray
|
|
|
|
bufEmpty = false
|
|
|
|
// get samples and apply the fader
|
|
if (track.trackType == TrackType.MASTER || track.trackType == TrackType.BUS) {
|
|
// combine all the inputs
|
|
samplesL1 = FloatArray(buffertaille)
|
|
samplesR1 = FloatArray(buffertaille)
|
|
|
|
val sidechains = track.sidechainInputs
|
|
// add all up
|
|
sidechains.forEach { (side, mix) ->
|
|
for (i in samplesL1.indices) {
|
|
samplesL1[i] += side.processor.fout1[0][i] * mix.toFloat()
|
|
samplesR1[i] += side.processor.fout1[1][i] * mix.toFloat()
|
|
}
|
|
}
|
|
}
|
|
// source channel: skip processing if there's no active input
|
|
// else if (track.getSidechains().any { it != null && !it.isBus && !it.isMaster && !it.streamPlaying } && !track.streamPlaying) {
|
|
else if (!track.streamPlaying.get() || streamBuf == null || streamBuf!!.validSamplesInBuf < App.audioBufferSize) {
|
|
samplesL1 = emptyBuf
|
|
samplesR1 = emptyBuf
|
|
|
|
bufEmpty = true
|
|
}
|
|
else {
|
|
streamBuf!!.getLR().let {
|
|
samplesL1 = it.first
|
|
samplesR1 = it.second
|
|
}
|
|
}
|
|
|
|
if (!bufEmpty) {
|
|
// run the input through the stack of filters
|
|
val filterStack = track.filters.filter { !it.bypass && it !is NullFilter }
|
|
|
|
if (filterStack.isEmpty()) {
|
|
fout1 = listOf(samplesL1, samplesR1)
|
|
}
|
|
else {
|
|
var fin1 = listOf(samplesL1, samplesR1)
|
|
fout1 = listOf(FloatArray(buffertaille), FloatArray(buffertaille))
|
|
|
|
filterStack.forEachIndexed { index, it ->
|
|
it(fin1, fout1)
|
|
fin1 = fout1
|
|
if (index < filterStack.lastIndex) {
|
|
fout1 = listOf(FloatArray(buffertaille), FloatArray(buffertaille))
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// apply fader at post
|
|
fout1.forEach { ch ->
|
|
ch.forEachIndexed { index, sample ->
|
|
ch[index] = (sample * track.volume).toFloat()
|
|
}
|
|
}
|
|
|
|
|
|
// scan the finished sample for mapping signal level and clipping detection
|
|
fout1.map { it.maxOf { it.absoluteValue } }.forEachIndexed { index, fl ->
|
|
maxSigLevel[index] = fl.toDouble()
|
|
}
|
|
fout1.map { it.sumOf { it.sqr().toDouble() } }.forEachIndexed { index, fl ->
|
|
maxRMS[index] = sqrt(fl / (buffertaille))
|
|
}
|
|
hasClipping.fill(false)
|
|
fout1.forEachIndexed { index, floats ->
|
|
var lastSample = floats[0]
|
|
for (i in 1 until floats.size) {
|
|
val currentSample = floats[i]
|
|
if (lastSample * currentSample > 0.0 && lastSample.absoluteValue >= 1.0 && currentSample.absoluteValue >= 1.0) {
|
|
hasClipping[index] = true
|
|
break
|
|
}
|
|
lastSample = currentSample
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
fout1 = listOf(samplesL1, samplesR1) // keep pass the so that long-delay filters can empty out its buffer
|
|
maxSigLevel.fill(0.0)
|
|
maxRMS.fill(0.0)
|
|
hasClipping.fill(false)
|
|
}
|
|
|
|
|
|
// by this time, the output buffer is filled with processed results, pause the execution
|
|
if (track.trackType != TrackType.MASTER) {
|
|
this.pause()
|
|
}
|
|
else {
|
|
|
|
// spin until queue is sufficiently empty
|
|
/*while (track.pcmQueue.size >= BACK_BUF_COUNT && running) { // uncomment to multithread
|
|
Thread.sleep(1)
|
|
}*/
|
|
|
|
// printdbg("PUSHE; Queue size: ${track.pcmQueue.size}")
|
|
track.pcmQueue.addLast(fout1)
|
|
|
|
// spin
|
|
// Thread.sleep(((1000*bufferSize) / 8L / rate).coerceAtLeast(1L)) // uncomment to multithread
|
|
|
|
// wake sidechain processors
|
|
resumeSidechainsRecursively(track, track.name)
|
|
}
|
|
// } // uncomment to multithread
|
|
}
|
|
|
|
private fun FloatArray.applyVolume(volume: Float) = FloatArray(this.size) { (this[it] * volume) }
|
|
/*private fun FloatArray.applyVolumeInline(volume: Float): FloatArray {
|
|
for (i in this.indices) {
|
|
this[i] *= volume
|
|
}
|
|
return this
|
|
}*/
|
|
|
|
|
|
private fun resumeSidechainsRecursively(track: TerrarumAudioMixerTrack?, caller: String) {
|
|
track?.getSidechains()?.forEach {
|
|
if (it?.processor?.running == true) {
|
|
it.processor.resume()
|
|
it.getSidechains().forEach {
|
|
if (it?.processor?.running == true) {
|
|
it.processor.resume()
|
|
resumeSidechainsRecursively(it, caller + caller)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
fun stop() {
|
|
running = false
|
|
// you might also want to interrupt() the Thread that is
|
|
// running this Runnable, too, or perhaps call:
|
|
resume()
|
|
// to unblock
|
|
}
|
|
|
|
fun pause() {
|
|
// printdbg("PAUSE")
|
|
// you may want to throw an IllegalStateException if !running
|
|
paused = true
|
|
}
|
|
|
|
fun resume() {
|
|
// printdbg("RESUME")
|
|
synchronized(pauseLock) {
|
|
paused = false
|
|
pauseLock.notifyAll() // Unblocks thread
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
class FeedSamplesToAdev(val bufferSize: Int, val rate: Int, val track: TerrarumAudioMixerTrack) : Runnable {
|
|
init {
|
|
if (track.trackType != TrackType.MASTER) throw IllegalArgumentException("Track is not master")
|
|
}
|
|
|
|
override fun run() {
|
|
while (!Thread.currentThread().isInterrupted) {
|
|
try {
|
|
val writeQueue = track.pcmQueue
|
|
val queueSize = writeQueue.size
|
|
if (queueSize > 0) {
|
|
val samples = writeQueue.removeFirst()
|
|
track.adev!!.writeSamples(samples)
|
|
}
|
|
}
|
|
catch (_: InterruptedException) {
|
|
Thread.currentThread().interrupt()
|
|
}
|
|
}
|
|
}
|
|
}
|