diff --git a/backend/js/src/main/scala/eu/joaocosta/minart/backend/JsAudioPlayer.scala b/backend/js/src/main/scala/eu/joaocosta/minart/backend/JsAudioPlayer.scala index 8c065515..611c5273 100644 --- a/backend/js/src/main/scala/eu/joaocosta/minart/backend/JsAudioPlayer.scala +++ b/backend/js/src/main/scala/eu/joaocosta/minart/backend/JsAudioPlayer.scala @@ -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) } diff --git a/backend/jvm/src/main/scala/eu/joaocosta/minart/backend/JavaAudioPlayer.scala b/backend/jvm/src/main/scala/eu/joaocosta/minart/backend/JavaAudioPlayer.scala index d96bb0ef..d0d96a8e 100644 --- a/backend/jvm/src/main/scala/eu/joaocosta/minart/backend/JavaAudioPlayer.scala +++ b/backend/jvm/src/main/scala/eu/joaocosta/minart/backend/JavaAudioPlayer.scala @@ -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) } diff --git a/backend/native/src/main/scala/eu/joaocosta/minart/backend/SdlAudioPlayer.scala b/backend/native/src/main/scala/eu/joaocosta/minart/backend/SdlAudioPlayer.scala index 621668de..35f18cc9 100644 --- a/backend/native/src/main/scala/eu/joaocosta/minart/backend/SdlAudioPlayer.scala +++ b/backend/native/src/main/scala/eu/joaocosta/minart/backend/SdlAudioPlayer.scala @@ -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) } diff --git a/core/shared/src/main/scala/eu/joaocosta/minart/audio/AudioMix.scala b/core/shared/src/main/scala/eu/joaocosta/minart/audio/AudioMix.scala new file mode 100644 index 00000000..d7029d70 --- /dev/null +++ b/core/shared/src/main/scala/eu/joaocosta/minart/audio/AudioMix.scala @@ -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) diff --git a/core/shared/src/main/scala/eu/joaocosta/minart/audio/AudioPlayer.scala b/core/shared/src/main/scala/eu/joaocosta/minart/audio/AudioPlayer.scala index 9f845a38..79a00d2b 100644 --- a/core/shared/src/main/scala/eu/joaocosta/minart/audio/AudioPlayer.scala +++ b/core/shared/src/main/scala/eu/joaocosta/minart/audio/AudioPlayer.scala @@ -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 { diff --git a/core/shared/src/main/scala/eu/joaocosta/minart/audio/AudioQueue.scala b/core/shared/src/main/scala/eu/joaocosta/minart/audio/AudioQueue.scala index f2c2a83d..0aa959ea 100644 --- a/core/shared/src/main/scala/eu/joaocosta/minart/audio/AudioQueue.scala +++ b/core/shared/src/main/scala/eu/joaocosta/minart/audio/AudioQueue.scala @@ -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 @@ -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) @@ -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 } @@ -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) @@ -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 } diff --git a/core/shared/src/test/scala/eu/joaocosta/minart/audio/AudioQueueSpec.scala b/core/shared/src/test/scala/eu/joaocosta/minart/audio/AudioQueueSpec.scala index 5c8e32dd..574f9299 100644 --- a/core/shared/src/test/scala/eu/joaocosta/minart/audio/AudioQueueSpec.scala +++ b/core/shared/src/test/scala/eu/joaocosta/minart/audio/AudioQueueSpec.scala @@ -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)