RtpMidiBox.kt
src/jvmMain/kotlin/afield/io/midi/rtp/RtpMidiBox.kt · 178 lines
@file:afield.runtime.completion.CompletionCategory("Networking")
package afield.io.midi.rtp
import afield.document.graph.Box
import afield.runtime.lifecycle.Runloop
import afield.io.osc.query.Mdns
import java.io.Closeable
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import javax.sound.midi.MidiMessage
import javax.sound.midi.ShortMessage
The box-facing side of RTP-MIDI: rtpMidi(...) opens or joins an AppleMIDI network session that
a DAW, an iPad app or macOS MIDI Network Setup can connect to, and rtpMidiDiscover() lists the
sessions already on the network.
Sessions are shared per port across boxes and outlive a stopped box; each box's listeners are
replaced when it re-runs. The wire protocol is in RtpMidiSession.kt and RtpMidiCodec.kt.
object RtpMidiSessions
Process-wide RTP-MIDI state behind Box.rtpMidi: one session per control port, its Bonjour
advertisement, the per-box callbacks and the latest controller and note values.
Sessions follow the house rule for launched things: they are keyed by port and survive a box being stopped, so a DAW stays connected while you edit. A box's callbacks are keyed by box and key, so a re-run replaces its callback, and deleting the box removes them. closeAll ends every session (each peer is sent BY).
internal class Entry(val session: RtpMidiSession, val advert: Closeable?)
val controllers = ConcurrentHashMap<Pair<Int, Int>, Int>()
val notes = ConcurrentHashMap<Pair<Int, Int>, Int>()
val slots = ConcurrentHashMap<String, (MidiMessage, RtpMidiSession.Peer) -> Unit>()
@Volatile var messages = 0L
private val entries = ConcurrentHashMap<Int, Entry>()
private val queue = ConcurrentLinkedQueue<Runnable>()
@Volatile private var drainInstalled = false
internal fun entry(name: String, port: Int, advertise: Boolean): Entry
internal fun entry(name: String, port: Int, advertise: Boolean): Entry {
entries[port]?.let { if (!it.session.closed) return it }
synchronized(this) {
entries[port]?.let { if (!it.session.closed) return it }
val s = RtpMidiSession(name, port)
val advert = if (advertise) runCatching { Mdns.advertise(name, "_apple-midi._udp", s.port) }.getOrNull() else null
val e = Entry(s, advert)
s.onMessage { m, _, peer ->
e.messages++
if (m is ShortMessage) when (m.command) {
ShortMessage.CONTROL_CHANGE -> e.controllers[m.channel to m.data1] = m.data2
ShortMessage.NOTE_ON -> if (m.data2 > 0) e.notes[m.channel to m.data1] = m.data2 else e.notes.remove(m.channel to m.data1)
ShortMessage.NOTE_OFF -> e.notes.remove(m.channel to m.data1)
}
for (h in e.slots.values) dispatch { h(m, peer) }
}
entries[s.port] = e
return e
}
}
internal fun dispatch(r: () -> Unit) {
Runs r on the main Runloop inside Field, directly where no Runloop turns (tests).
internal fun dispatch(r: () -> Unit) {
if (Runloop.mainThread == null) return r()
queue.add(Runnable(r))
if (!drainInstalled) synchronized(this) {
if (!drainInstalled) {
drainInstalled = true
Runloop.runLoop.preRun.setUnowned("afield.rtpmidi.dispatch", sequence {
while (true) {
var n = 0
while (n++ < 2000) {
val next = queue.poll() ?: break
try { next.run() } catch (t: Throwable) { afield.app.diagnostics.logging.lprintln("error,rtpmidi", "callback threw: $t") }
}
yield(null)
}
})
}
}
}
internal fun listen(box: Box, e: Entry, key: String, handler: (MidiMessage, RtpMidiSession.Peer) -> Unit) {
internal fun listen(box: Box, e: Entry, key: String, handler: (MidiMessage, RtpMidiSession.Peer) -> Unit) {
val k = "${box.id}|$key"
val wrapped: (MidiMessage, RtpMidiSession.Peer) -> Unit = { m, p ->
Box.withBoxContextNow(box) { try { handler(m, p) } catch (t: Throwable) { box.println("rtpMidi '$key': $t") } }
}
if (e.slots.put(k, wrapped) == null) {
box.runScopes.parent.own(afield.runtime.lifecycle.ReleasePolicy.KEYED) { e.slots.remove(k) }
}
}
val sessions: Map<Int, RtpMidiSession> get()
Sessions currently open, by control port.
val sessions: Map<Int, RtpMidiSession> get() = entries.filterValues { !it.session.closed }.mapValues { it.value.session }
fun close(port: Int) { entries.remove(port)?.let { it.advert?.close(); it.session.close() } }
fun closeAll() { for (p in entries.keys.toList()) close(p) }
fun discover(timeoutMs: Long = 1500): List<Mdns.Service>
AppleMIDI sessions on the local network (_apple-midi._udp), collected for timeoutMs.
fun discover(timeoutMs: Long = 1500): List<Mdns.Service> = Mdns.browse("_apple-midi._udp", timeoutMs)
}
class BoxRtpMidi internal constructor(private val box: Box, private val entry: RtpMidiSessions.Entry)
A box's view of an RTP-MIDI session: send to every connected peer, and react to what they send. Callbacks run on Field's main loop with this box as context.
val session: RtpMidiSession get() = entry.session
val port: Int get() = session.port
val peers: List<RtpMidiSession.Peer> get() = session.peers
val connected: Boolean get() = session.connectedPeers.isNotEmpty()
fun invite(host: String, port: Int = RtpMidiSession.DEFAULT_PORT) = session.invite(host, port)
Invite another session; port is its control port (5004 for macOS's default session).
fun noteOn(pitch: Int, velocity: Int = 100, channel: Int = 0)
fun noteOn(pitch: Int, velocity: Int = 100, channel: Int = 0) = session.noteOn(pitch, velocity, channel)
fun noteOff(pitch: Int, channel: Int = 0) = session.noteOff(pitch, 0, channel)
fun controlChange(controller: Int, value: Int, channel: Int = 0)
fun controlChange(controller: Int, value: Int, channel: Int = 0) = session.controlChange(controller, value, channel)
fun programChange(program: Int, channel: Int = 0) = session.programChange(program, channel)
fun pitchBend(value: Int, channel: Int = 0) = session.pitchBend(value, channel)
fun sysex(bytes: ByteArray) = session.sysex(bytes)
fun send(message: MidiMessage) = session.send(message)
fun allNotesOff() = session.allNotesOff()
fun onMessage(key: String = "message", block: (MidiMessage, RtpMidiSession.Peer) -> Unit) =
Every message received, with the peer that sent it. Re-running the box replaces it.
fun onMessage(key: String = "message", block: (MidiMessage, RtpMidiSession.Peer) -> Unit) =
RtpMidiSessions.listen(box, entry, key, block)
fun onNote(key: String = "note", block: (pitch: Int, velocity: Int, channel: Int) -> Unit) = onMessage(key) { m, _ ->
Note on (velocity > 0) and note off (velocity 0, including note-on with velocity 0).
fun onNote(key: String = "note", block: (pitch: Int, velocity: Int, channel: Int) -> Unit) = onMessage(key) { m, _ ->
if (m is ShortMessage) when (m.command) {
ShortMessage.NOTE_ON -> block(m.data1, m.data2, m.channel)
ShortMessage.NOTE_OFF -> block(m.data1, 0, m.channel)
}
}
fun onControl(key: String = "control", block: (controller: Int, value: Int, channel: Int) -> Unit) = onMessage(key) { m, _ ->
Control changes as (controller, value 0..127, channel).
fun onControl(key: String = "control", block: (controller: Int, value: Int, channel: Int) -> Unit) = onMessage(key) { m, _ ->
if (m is ShortMessage && m.command == ShortMessage.CONTROL_CHANGE) block(m.data1, m.data2, m.channel)
}
fun knob(controller: Int, channel: Int = 0, default: Double = 0.0): Double
The last value of controller on channel as 0..1, or default until it has moved.
fun knob(controller: Int, channel: Int = 0, default: Double = 0.0): Double =
entry.controllers[channel to controller]?.let { it / 127.0 } ?: default
fun raw(controller: Int, channel: Int = 0): Int? = entry.controllers[channel to controller]
Raw 0..127 value of controller, or null if untouched.
val held: Map<Pair<Int, Int>, Int> get() = entry.notes.toMap()
Notes held now, as (channel, pitch) to velocity.
val messageCount: Long get() = entry.messages
Messages received since the session opened.
fun stopListening(key: String? = null)
fun stopListening(key: String? = null) {
entry.slots.keys.removeIf { it.startsWith("${box.id}|") && (key == null || it == "${box.id}|$key") }
}
fun close() = RtpMidiSessions.close(port)
override fun toString() = session.toString()
fun Box.rtpMidi(name: String = "Field", port: Int = RtpMidiSession.DEFAULT_PORT, advertise: Boolean = true): BoxRtpMidi
An RTP-MIDI (AppleMIDI) network session named name on UDP port and port + 1, advertised
over Bonjour so macOS MIDI Network Setup, iPad apps and rtpMIDI list it. The first call for a
port opens it; later calls, from any box, share it.
fun Box.rtpMidi(name: String = "Field", port: Int = RtpMidiSession.DEFAULT_PORT, advertise: Boolean = true): BoxRtpMidi =
BoxRtpMidi(this, RtpMidiSessions.entry(name, port, advertise))
fun Box.rtpMidiDiscover(timeoutMs: Long = 1500): List<Mdns.Service>
AppleMIDI sessions on the local network, found over Bonjour for timeoutMs.
fun Box.rtpMidiDiscover(timeoutMs: Long = 1500): List<Mdns.Service> = RtpMidiSessions.discover(timeoutMs)