Fix/midi transceiver fails on short/system messages (#347)

This commit is contained in:
Kazik Pogoda
2025-09-01 20:34:36 +02:00
committed by GitHub
parent df4c6fad71
commit df2a596ec0
7 changed files with 293 additions and 133 deletions

View File

@@ -28,6 +28,7 @@ ktor = "3.2.3"
jgit = "7.3.0.202506031305-r"
javaosc = "0.9"
jsoup = "1.21.1"
mockk = "1.13.11"
processing = "4.4.6"
[libraries]
@@ -49,7 +50,10 @@ 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"}
processing-core = { group = "org.processing", name = "core", version.ref = "processing"}
openrndr-application = { group = "org.openrndr", name = "openrndr-application", version.ref = "openrndr" }
openrndr-extensions = { group = "org.openrndr", name = "openrndr-extensions", version.ref = "openrndr" }
openrndr-math = { group = "org.openrndr", name = "openrndr-math", version.ref = "openrndr" }

View File

@@ -9,4 +9,7 @@ dependencies {
implementation(libs.kotlin.coroutines)
implementation(project(":orx-property-watchers"))
implementation(project(":orx-parameters"))
testImplementation(libs.mockk)
testImplementation(libs.kotest.assertions)
}

View File

@@ -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)
}

View File

@@ -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<Int, MidiEventType> =
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

View File

@@ -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,64 +212,45 @@ class MidiTransceiver(program: Program, val receiverDevice: MidiDevice?, val tra
val pitchBend = Event<MidiEvent>("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) {
if (receiver != null && receiverDevice != null) {
try {
val msg = ShortMessage(ShortMessage.PITCH_BEND, channel, value)
receiver.send(msg, receiverDevice.microsecondPosition)
} catch (e: InvalidMidiDataException) {
logger.warn { e.message }
}
}
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 = block()
receiver.send(msg, receiverDevice.microsecondPosition)
} catch (e: InvalidMidiDataException) {
logger.warn { e.message }
}
}
}
}
/**

View File

@@ -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()
}
}

View File

@@ -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<Program>(relaxed = true)
val receiver = mockk<Receiver>()
val receiverDevice = mockk<MidiDevice>(relaxed = true)
val messageSlot = slot<MidiMessage>()
val transmitter = TestTransmitter()
val transmitterDevice = mockk<MidiDevice>()
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<ShortMessage>()
(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<ShortMessage>()
(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<MidiEvent>()
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<MidiEvent>()
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
}
}
}