diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 48ec1d49f..a61a3b7b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ ktor = "2.3.11" jgit = "6.9.0.202403050737-r" javaosc = "0.9" jsoup = "1.17.2" +mockk = "1.13.11" [libraries] kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging", version.ref = "kotlinLogging" } @@ -49,6 +50,7 @@ kotest-assertions = { group = "io.kotest", name = "kotest-assertions-core", vers kotest-runner = { group = "io.kotest", name = "kotest-runner-junit5", version.ref = "kotest" } kotest-framework-engine = { group = "io.kotest", name = "kotest-framework-engine", version.ref = "kotest" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk"} openrndr-application = { group = "org.openrndr", name = "openrndr-application", version.ref = "openrndr" } openrndr-extensions = { group = "org.openrndr", name = "openrndr-extensions", version.ref = "openrndr" } diff --git a/orx-jvm/orx-midi/build.gradle.kts b/orx-jvm/orx-midi/build.gradle.kts index 6102050a7..bcc3c7275 100644 --- a/orx-jvm/orx-midi/build.gradle.kts +++ b/orx-jvm/orx-midi/build.gradle.kts @@ -9,4 +9,7 @@ dependencies { implementation(libs.kotlin.coroutines) implementation(project(":orx-property-watchers")) implementation(project(":orx-parameters")) -} \ No newline at end of file + + testImplementation(libs.mockk) + testImplementation(libs.kotest.assertions) +} diff --git a/orx-jvm/orx-midi/src/main/kotlin/MidiBindings.kt b/orx-jvm/orx-midi/src/main/kotlin/MidiBindings.kt index 5bf16eb9f..1e7660dc4 100644 --- a/orx-jvm/orx-midi/src/main/kotlin/MidiBindings.kt +++ b/orx-jvm/orx-midi/src/main/kotlin/MidiBindings.kt @@ -48,7 +48,7 @@ fun Program.bindMidiControl( val low = anno?.low ?: 0.0 val high = anno?.high ?: 1.0 transceiver.controlChanged.listen { - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channel && it.control == control) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channel && it.control == control) { val value = it.value.toDouble().map(0.0, 127.0, low, high, clamp = true) property.set(value) } @@ -83,7 +83,7 @@ fun Program.bindMidiControl( control: Int ) { transceiver.controlChanged.listen { - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channel && it.control == control) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channel && it.control == control) { property.set(it.value >= 64) } } @@ -126,12 +126,12 @@ fun Program.bindMidiControl( var y = v.y var changed = false - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channelX && it.control == controlX) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channelX && it.control == controlX) { changed = true x = it.value.toDouble().map(0.0, 127.0, low, high, clamp = true) } - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channelY && it.control == controlY) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channelY && it.control == controlY) { changed = true y = it.value.toDouble().map(0.0, 127.0, low, high, clamp = true) } @@ -187,17 +187,17 @@ fun Program.bindMidiControl( var z = v.z var changed = false - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channelX && it.control == controlX) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channelX && it.control == controlX) { changed = true x = it.value.toDouble().map(0.0, 127.0, low, high, clamp = true) } - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channelY && it.control == controlY) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channelY && it.control == controlY) { changed = true y = it.value.toDouble().map(0.0, 127.0, low, high, clamp = true) } - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channelZ && it.control == controlZ) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channelZ && it.control == controlZ) { changed = true z = it.value.toDouble().map(0.0, 127.0, low, high, clamp = true) } @@ -257,22 +257,22 @@ fun Program.bindMidiControl( var a = v.alpha var changed = false - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channelR && it.control == controlR) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channelR && it.control == controlR) { changed = true r = it.value.toDouble().map(0.0, 127.0, low, high, clamp = true) } - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channelG && it.control == controlG) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channelG && it.control == controlG) { changed = true g = it.value.toDouble().map(0.0, 127.0, low, high, clamp = true) } - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channelB && it.control == controlB) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channelB && it.control == controlB) { changed = true b = it.value.toDouble().map(0.0, 127.0, low, high, clamp = true) } - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channelA && it.control == controlA) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channelA && it.control == controlA) { changed = true a = it.value.toDouble().map(0.0, 127.0, low, high, clamp = true) } @@ -335,22 +335,22 @@ fun Program.bindMidiControl( var w = v.w var changed = false - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channelX && it.control == controlX) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channelX && it.control == controlX) { changed = true x = it.value.toDouble().map(0.0, 127.0, low, high, clamp = true) } - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channelY && it.control == controlY) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channelY && it.control == controlY) { changed = true y = it.value.toDouble().map(0.0, 127.0, low, high, clamp = true) } - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channelZ && it.control == controlZ) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channelZ && it.control == controlZ) { changed = true z = it.value.toDouble().map(0.0, 127.0, low, high, clamp = true) } - if (it.eventType == MidiEventType.CONTROL_CHANGED && it.channel == channelW && it.control == controlW) { + if (it.eventType == MidiEventType.CONTROL_CHANGE && it.channel == channelW && it.control == controlW) { changed = true w = it.value.toDouble().map(0.0, 127.0, low, high, clamp = true) } diff --git a/orx-jvm/orx-midi/src/main/kotlin/MidiEvent.kt b/orx-jvm/orx-midi/src/main/kotlin/MidiEvent.kt index feda17206..4f16a73fb 100644 --- a/orx-jvm/orx-midi/src/main/kotlin/MidiEvent.kt +++ b/orx-jvm/orx-midi/src/main/kotlin/MidiEvent.kt @@ -1,14 +1,47 @@ package org.openrndr.extra.midi -enum class MidiEventType { - NOTE_ON, - NOTE_OFF, - CONTROL_CHANGED, - PROGRAM_CHANGE, - CHANNEL_PRESSURE, - PITCH_BEND +import javax.sound.midi.MidiMessage +import javax.sound.midi.ShortMessage + +enum class MidiEventType(val status: Int) { + + MIDI_TIME_CODE(ShortMessage.MIDI_TIME_CODE), + SONG_POSITION_POINTER(ShortMessage.SONG_POSITION_POINTER), + SONG_SELECT(ShortMessage.SONG_SELECT), + TUNE_REQUEST(ShortMessage.TUNE_REQUEST), + END_OF_EXCLUSIVE(ShortMessage.END_OF_EXCLUSIVE), + TIMING_CLOCK(ShortMessage.TIMING_CLOCK), + START(ShortMessage.START), + CONTINUE(ShortMessage.CONTINUE), + STOP(ShortMessage.STOP), + ACTIVE_SENSING(ShortMessage.ACTIVE_SENSING), + SYSTEM_RESET(ShortMessage.SYSTEM_RESET), + NOTE_ON(ShortMessage.NOTE_ON), + NOTE_OFF(ShortMessage.NOTE_OFF), + CONTROL_CHANGE(ShortMessage.CONTROL_CHANGE), + PROGRAM_CHANGE(ShortMessage.PROGRAM_CHANGE), + CHANNEL_PRESSURE(ShortMessage.CHANNEL_PRESSURE), + PITCH_BEND(ShortMessage.PITCH_BEND); + + companion object { + + private val statusMap: Map = + entries.associateBy { it.status } + + fun fromStatus( + status: Int + ): MidiEventType = requireNotNull( + statusMap[if (status >= 0xf0) status else status and 0xf0] + ) { + "Invalid MIDI status: $status" + } + + } + } +val MidiMessage.eventType: MidiEventType get() = MidiEventType.fromStatus(status) + class MidiEvent(val eventType: MidiEventType) { var origin = Origin.DEVICE var control: Int = 0 @@ -34,15 +67,16 @@ class MidiEvent(val eventType: MidiEventType) { return midiEvent } - fun noteOff(channel: Int, note: Int): MidiEvent { + fun noteOff(channel: Int, note: Int, velocity: Int): MidiEvent { val midiEvent = MidiEvent(MidiEventType.NOTE_OFF) midiEvent.note = note midiEvent.channel = channel + midiEvent.velocity = velocity return midiEvent } fun controlChange(channel: Int, control: Int, value: Int): MidiEvent { - val midiEvent = MidiEvent(MidiEventType.CONTROL_CHANGED) + val midiEvent = MidiEvent(MidiEventType.CONTROL_CHANGE) midiEvent.channel = channel midiEvent.control = control midiEvent.value = value diff --git a/orx-jvm/orx-midi/src/main/kotlin/MidiTransceiver.kt b/orx-jvm/orx-midi/src/main/kotlin/MidiTransceiver.kt index 102af0980..386daa637 100644 --- a/orx-jvm/orx-midi/src/main/kotlin/MidiTransceiver.kt +++ b/orx-jvm/orx-midi/src/main/kotlin/MidiTransceiver.kt @@ -118,80 +118,80 @@ class MidiTransceiver(program: Program, val receiverDevice: MidiDevice?, val tra } } + private fun trigger(message: MidiMessage) { + val cmd = message.message + val channel = (cmd[0].toInt() and 0xff) and 0x0f + when (val eventType = message.eventType) { + + MidiEventType.NOTE_ON -> { + val key = cmd[1].toInt() and 0xff + val velocity = cmd[2].toInt() and 0xff + if (velocity > 0) { + noteOn.trigger(MidiEvent.noteOn(channel, key, velocity)) + } else { + noteOff.trigger(MidiEvent.noteOff(channel, key, velocity)) + } + } + + MidiEventType.NOTE_OFF -> noteOff.trigger( + MidiEvent.noteOff( + channel, + cmd[1].toInt() and 0xff, + cmd[2].toInt() and 0xff + ) + ) + + MidiEventType.CONTROL_CHANGE -> controlChanged.trigger( + MidiEvent.controlChange( + channel, + cmd[1].toInt() and 0xff, + cmd[2].toInt() and 0xff + ) + ) + + MidiEventType.PROGRAM_CHANGE -> programChanged.trigger( + MidiEvent.programChange( + channel, + cmd[1].toInt() and 0xff + ) + ) + + MidiEventType.CHANNEL_PRESSURE -> channelPressure.trigger( + MidiEvent.channelPressure( + channel, + cmd[1].toInt() and 0xff + ) + ) + + // https://sites.uci.edu/camp2014/2014/04/30/managing-midi-pitchbend-messages/ + // The next operation to combine two 7bit values + // was verified to give the same results as the Linux + // `midisnoop` program while using an `Alesis Vortex + // Wireless 2` device. This MIDI device does not provide a + // full range 14 bit pitch-bend resolution though, so + // a different device is needed to confirm the pitch bend + // values slide as expected from -8192 to +8191. + MidiEventType.PITCH_BEND -> pitchBend.trigger( + MidiEvent.pitchBend( + channel, + (cmd[2].toInt() shl 25 shr 18) + cmd[1].toInt() + ) + ) + + else -> { + logger.trace { "Unsupported MIDI event type: $eventType" } + } + + } + } + init { transmitter?.receiver = object : MidiDeviceReceiver { override fun getMidiDevice(): MidiDevice? { return null } - override fun send(message: MidiMessage, timeStamp: Long) { - val cmd = message.message - val channel = (cmd[0].toInt() and 0xff) and 0x0f - val velocity = cmd[2].toInt() and 0xff - when ((cmd[0].toInt() and 0xff) and 0xf0) { - - - - ShortMessage.NOTE_ON -> if (velocity > 0) { - noteOn.trigger( - MidiEvent.noteOn( - channel, - cmd[1].toInt() and 0xff, - velocity - ) - ) - } else { - noteOff.trigger( - MidiEvent.noteOff( - channel, - cmd[1].toInt() and 0xff - ) - ) - } - - ShortMessage.NOTE_OFF -> noteOff.trigger( - MidiEvent.noteOff( - channel, - cmd[1].toInt() and 0xff - ) - ) - - ShortMessage.CONTROL_CHANGE -> controlChanged.trigger( - MidiEvent.controlChange( - channel, - cmd[1].toInt() and 0xff, - cmd[2].toInt() and 0xff - ) - ) - - ShortMessage.PROGRAM_CHANGE -> programChanged.trigger( - MidiEvent.programChange( - channel, - cmd[1].toInt() and 0xff - ) - ) - - ShortMessage.CHANNEL_PRESSURE -> channelPressure.trigger( - MidiEvent.channelPressure( - channel, - cmd[1].toInt() and 0xff - ) - ) - // https://sites.uci.edu/camp2014/2014/04/30/managing-midi-pitchbend-messages/ - // The next operation to combine two 7bit values - // was verified to give the same results as the Linux - // `midisnoop` program while using an `Alesis Vortex - // Wireless 2` device. This MIDI device does not provide a - // full range 14 bit pitch-bend resolution though, so - // a different device is needed to confirm the pitch bend - // values slide as expected from -8192 to +8191. - ShortMessage.PITCH_BEND -> pitchBend.trigger( - MidiEvent.pitchBend( - channel, - (cmd[2].toInt() shl 25 shr 18) + cmd[1].toInt() - ) - ) - } + trigger(message) } override fun close() { } @@ -212,53 +212,38 @@ class MidiTransceiver(program: Program, val receiverDevice: MidiDevice?, val tra val pitchBend = Event("midi-transceiver::pitch-bend") fun controlChange(channel: Int, control: Int, value: Int) { - if (receiver != null && receiverDevice != null) { - try { - val msg = ShortMessage(ShortMessage.CONTROL_CHANGE, channel, control, value) - receiver.send(msg, receiverDevice.microsecondPosition) - } catch (e: InvalidMidiDataException) { - logger.warn { e.message } - } - } + send { ShortMessage(ShortMessage.CONTROL_CHANGE, channel, control, value) } } fun programChange(channel: Int, program: Int) { - if (receiver != null && receiverDevice != null) { - try { - val msg = ShortMessage(ShortMessage.PROGRAM_CHANGE, channel, program) - receiver.send(msg, receiverDevice.microsecondPosition) - } catch (e: InvalidMidiDataException) { - logger.warn { e.message } - } - } + send { ShortMessage(ShortMessage.PROGRAM_CHANGE, channel, program) } } fun noteOn(channel: Int, key: Int, velocity: Int) { - if (receiver != null && receiverDevice != null) { - try { - val msg = ShortMessage(ShortMessage.NOTE_ON, channel, key, velocity) - receiver.send(msg, receiverDevice.microsecondPosition) - } catch (e: InvalidMidiDataException) { - logger.warn { e.message } - } - } + send { ShortMessage(ShortMessage.NOTE_ON, channel, key, velocity) } + } + + fun noteOff(channel: Int, key: Int, velocity: Int) { + send { ShortMessage(ShortMessage.NOTE_OFF, channel, key, velocity) } } fun channelPressure(channel: Int, value: Int) { - if (receiver != null && receiverDevice != null) { - try { - val msg = ShortMessage(ShortMessage.CHANNEL_PRESSURE, channel, value) - receiver.send(msg, receiverDevice.microsecondPosition) - } catch (e: InvalidMidiDataException) { - logger.warn { e.message } - } - } + send { ShortMessage(ShortMessage.CHANNEL_PRESSURE, channel, value) } } fun pitchBend(channel: Int, value: Int) { + send { ShortMessage(ShortMessage.PITCH_BEND, channel, value) } + } + + fun destroy() { + receiverDevice?.close() + transmitterDevicer?.close() + } + + private fun send(block: () -> MidiMessage) { if (receiver != null && receiverDevice != null) { try { - val msg = ShortMessage(ShortMessage.PITCH_BEND, channel, value) + val msg = block() receiver.send(msg, receiverDevice.microsecondPosition) } catch (e: InvalidMidiDataException) { logger.warn { e.message } @@ -266,10 +251,6 @@ class MidiTransceiver(program: Program, val receiverDevice: MidiDevice?, val tra } } - fun destroy() { - receiverDevice?.close() - transmitterDevicer?.close() - } } /** diff --git a/orx-jvm/orx-midi/src/test/kotlin/MidiTests.kt b/orx-jvm/orx-midi/src/test/kotlin/MidiTests.kt new file mode 100644 index 000000000..2ba0ea6c6 --- /dev/null +++ b/orx-jvm/orx-midi/src/test/kotlin/MidiTests.kt @@ -0,0 +1,20 @@ +package org.openrndr.extra.midi + +import javax.sound.midi.Receiver +import javax.sound.midi.Transmitter + +class TestTransmitter : Transmitter { + + private var receiver: Receiver? = null + + override fun setReceiver(receiver: Receiver?) { + this.receiver = receiver + } + + override fun getReceiver(): Receiver? = receiver + + override fun close() { + receiver?.close() + } + +} \ No newline at end of file diff --git a/orx-jvm/orx-midi/src/test/kotlin/MidiTransceiverTest.kt b/orx-jvm/orx-midi/src/test/kotlin/MidiTransceiverTest.kt new file mode 100644 index 000000000..92473d4e2 --- /dev/null +++ b/orx-jvm/orx-midi/src/test/kotlin/MidiTransceiverTest.kt @@ -0,0 +1,118 @@ +package org.openrndr.extra.midi + +import io.kotest.matchers.should +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.beInstanceOf +import io.mockk.* +import org.openrndr.Program +import java.util.concurrent.atomic.AtomicReference +import javax.sound.midi.MidiDevice +import javax.sound.midi.MidiMessage +import javax.sound.midi.Receiver +import javax.sound.midi.ShortMessage +import kotlin.test.Test + +@Suppress("MemberVisibilityCanBePrivate") +class MidiTransceiverTest { + + // given + val program = mockk(relaxed = true) + val receiver = mockk() + val receiverDevice = mockk(relaxed = true) + val messageSlot = slot() + + val transmitter = TestTransmitter() + val transmitterDevice = mockk() + + init { + every { receiverDevice.receiver } returns receiver + every { receiver.send(capture(messageSlot), any()) } just runs + every { transmitterDevice.transmitter } returns transmitter + } + + val transceiver = MidiTransceiver( + program, + receiverDevice, + transmitterDevice + ) + + @Test + fun `should send out NOTE_ON message`() { + // when + transceiver.noteOn(5, 10, 100) + + // then + messageSlot.captured should beInstanceOf() + (messageSlot.captured as ShortMessage).apply { + command shouldBe ShortMessage.NOTE_ON + channel shouldBe 5 + data1 shouldBe 10 + data2 shouldBe 100 + } + + } + + @Test + fun `should send out NOTE_OFF message`() { + // when + transceiver.noteOff(1, 10, 62) + + // then + messageSlot.captured should beInstanceOf() + (messageSlot.captured as ShortMessage).apply { + command shouldBe ShortMessage.NOTE_OFF + channel shouldBe 1 + data1 shouldBe 10 + data2 shouldBe 62 + } + } + + @Test + fun `should receive NOTE_ON event on receiving NOTE_ON message`() { + // given + val eventSlot = AtomicReference() + transceiver.noteOn.listen { + eventSlot.set(it) + } + + // when + transmitter.receiver!!.send( + ShortMessage(ShortMessage.NOTE_ON, 1, 2, 3), 1042 + ) + val noteOnEvent = eventSlot.get() + + // then + noteOnEvent.apply { + eventType shouldBe MidiEventType.NOTE_ON + origin shouldBe MidiEvent.Origin.DEVICE + channel shouldBe 1 + note shouldBe 2 + velocity shouldBe 3 + } + } + + @Test + fun `should receive NOTE_OFF event on receiving NOTE_ON message with velocity 0`() { + // given + val eventSlot = AtomicReference() + transceiver.noteOff.listen { + eventSlot.set(it) + } + + // when + transmitter.receiver!!.send( + ShortMessage(ShortMessage.NOTE_ON, 2, 3, 0), 1042 + ) + val noteOnEvent = eventSlot.get() + + // then + noteOnEvent.apply { + eventType shouldBe MidiEventType.NOTE_OFF + origin shouldBe MidiEvent.Origin.DEVICE + channel shouldBe 2 + note shouldBe 3 + velocity shouldBe 0 + } + } + +}