Skip to content

Commit

Permalink
Merge pull request #431 from JD557/sound-mixer
Browse files Browse the repository at this point in the history
Add realtime mixing operations
  • Loading branch information
JD557 authored Oct 8, 2023
2 parents 4a8c8d4 + a53faea commit 46f061d
Show file tree
Hide file tree
Showing 7 changed files with 86 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,10 @@ final class JsAudioPlayer() extends LowLevelAudioPlayer {
def stop(): Unit = playQueue.clear()

def stop(channel: Int): Unit = playQueue.clear(channel)

def getChannelMix(channel: Int): AudioMix =
playQueue.getChannelMix(channel)

def setChannelMix(mix: AudioMix, channel: Int): Unit =
playQueue.setChannelMix(mix, channel)
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,10 @@ final class JavaAudioPlayer() extends LowLevelAudioPlayer {
def stop(): Unit = playQueue.clear()

def stop(channel: Int): Unit = playQueue.clear(channel)

def getChannelMix(channel: Int): AudioMix =
playQueue.getChannelMix(channel)

def setChannelMix(mix: AudioMix, channel: Int): Unit =
playQueue.setChannelMix(mix, channel)
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,10 @@ final class SdlAudioPlayer() extends LowLevelAudioPlayer {
def stop(channel: Int): Unit = {
playQueue.clear(channel)
}

def getChannelMix(channel: Int): AudioMix =
playQueue.getChannelMix(channel)

def setChannelMix(mix: AudioMix, channel: Int): Unit =
playQueue.setChannelMix(mix, channel)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package eu.joaocosta.minart.audio

/** Definitions of how a channel should be mixed.
*
* @param volume the channel volume from 0.0 to 1.0
*/
final case class AudioMix(volume: Double = 1.0)
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,33 @@ trait AudioPlayer {
* @param channel channel to stop
*/
def stop(channel: Int): Unit

/** Gets the mixing definitions for a channel.
*
* @param channel channel to check
*/
def getChannelMix(channel: Int): AudioMix

/** Sets the mixing definitions for a channel.
*
* @param mix the new mixing definitions
* @param channel channel to update
*/
def setChannelMix(mix: AudioMix, channel: Int): Unit

/** Updates the mixing definitions for a channel based on the current definitions.
*
* @param f update function
* @param channel channel to update
* @return the new audio mix
*/
final def updateChannelMix(f: AudioMix => AudioMix, channel: Int): AudioMix = {
val currentMix = getChannelMix(channel)
val newMix = f(currentMix)
setChannelMix(newMix, channel)
newMix
}

}

object AudioPlayer {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package eu.joaocosta.minart.audio

import scala.jdk.CollectionConverters.*

/** Internal AudioQueue abstraction.
*
* This is not expected to be used by user code, but it's helpful to implement custom backends
Expand All @@ -24,10 +22,11 @@ object AudioQueue {

final class SingleChannelAudioQueue(sampleRate: Int) extends AudioQueue {
private val valueQueue = scala.collection.mutable.Queue[Double]()
private val clipQueue = new java.util.ArrayDeque[AudioClip]() // Use scala's ArrayDeque on 2.13+
private val clipQueue = scala.collection.mutable.ArrayDeque[AudioClip]()
var mix = AudioMix()

def isEmpty() = synchronized { valueQueue.isEmpty && clipQueue.isEmpty }
def size = clipQueue.iterator.asScala.foldLeft(valueQueue.size) { case (acc, clip) =>
def size = clipQueue.foldLeft(valueQueue.size) { case (acc, clip) =>
if (acc == Int.MaxValue || clip.duration.isInfinite) Int.MaxValue
else {
val newValue = acc + Sampler.numSamples(clip, sampleRate)
Expand All @@ -37,21 +36,21 @@ object AudioQueue {
}

def enqueue(clip: AudioClip): this.type = synchronized {
clipQueue.addLast(clip)
clipQueue.append(clip)
this
}
def dequeue(): Double = synchronized {
if (valueQueue.nonEmpty) {
valueQueue.dequeue()
} else if (!clipQueue.isEmpty()) {
val nextClip = clipQueue.removeFirst()
valueQueue.dequeue() * mix.volume
} else if (!clipQueue.isEmpty) {
val nextClip = clipQueue.removeHead()
if (nextClip.duration > maxBufferSize) {
valueQueue ++= Sampler.sampleClip(nextClip.take(maxBufferSize), sampleRate)
clipQueue.addFirst(nextClip.drop(maxBufferSize))
clipQueue.prepend(nextClip.drop(maxBufferSize))
} else {
valueQueue ++= Sampler.sampleClip(nextClip, sampleRate)
}
valueQueue.dequeue()
valueQueue.dequeue() * mix.volume
} else {
0.0
}
Expand All @@ -65,7 +64,14 @@ object AudioQueue {
}

final class MultiChannelAudioQueue(sampleRate: Int) extends AudioQueue {
private val channels = scala.collection.mutable.Map[Int, AudioQueue]()
private val channels = scala.collection.mutable.Map[Int, SingleChannelAudioQueue]()

private val channelMixes = scala.collection.mutable.Map[Int, AudioMix]()
def getChannelMix(channel: Int): AudioMix =
channelMixes.getOrElse(channel, AudioMix())
def setChannelMix(mix: AudioMix, channel: Int): Unit =
channelMixes.update(channel, mix)
channels.get(channel).foreach(_.mix = mix)

def isEmpty() = channels.values.forall(_.isEmpty())
def isEmpty(channel: Int) = channels.get(channel).map(_.isEmpty()).getOrElse(true)
Expand All @@ -77,6 +83,7 @@ object AudioQueue {
def enqueue(clip: AudioClip): this.type = enqueue(clip, 0)
def enqueue(clip: AudioClip, channel: Int): this.type = synchronized {
val queue = channels.getOrElseUpdate(channel, new SingleChannelAudioQueue(sampleRate))
queue.mix = getChannelMix(channel)
queue.enqueue(clip)
this
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ class AudioQueueSpec extends munit.FunSuite {
assert(queue.isEmpty() == true)
}

test("A multi channel audio queue correctly mixes audio with different mixing definitions") {
val clipA = AudioClip(x => x / 4.0, 2.0)
val clipB = AudioClip(_ => 0.25, 2.0)

val queue = new AudioQueue.MultiChannelAudioQueue(1)

queue.enqueue(clipA, 0)
queue.enqueue(clipB, 1)
queue.setChannelMix(AudioMix(0.5), 1)
assert(queue.size == 2)
assert(queue.isEmpty() == false)
assert(queue.dequeue() == 0.00 + 0.25 * 0.5)
assert(queue.dequeue() == 0.25 + 0.25 * 0.5)
assert(queue.isEmpty() == true)
}

test("A multi channel audio queue correctly clips audio") {
val clipA = AudioClip(x => (x * 10) - 5, 2.0)
val clipB = AudioClip(x => (x * 10) - 5, 1.0)
Expand Down

0 comments on commit 46f061d

Please sign in to comment.