Skip to content

RtpMidiCodec.kt

src/jvmMain/kotlin/afield/io/midi/rtp/RtpMidiCodec.kt · 257 lines

@file:afield.runtime.completion.CompletionCategory("Networking")

package afield.io.midi.rtp

import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer

Packet encoding and decoding for RTP-MIDI (RFC 6295) and the AppleMIDI session commands around it.

RtpMidiLoopbackTest covers running status, long headers, segmented SysEx, real-time bytes inside running status, skipped recovery journals and session-packet round trips.

object RtpMidiCodec

Wire formats for RTP-MIDI (RFC 6295) and the AppleMIDI session protocol that macOS, iOS, rtpMIDI on Windows and most network-MIDI hardware use around it.

A MIDI message here is its raw bytes, status byte first: [0x90, 60, 100], or a whole SysEx [0xF0, ..., 0xF7]. RtpMidiSession turns them into javax.sound.midi messages.

const val PAYLOAD_TYPE = 0x61

const val PROTOCOL_VERSION = 2

fun dataLength(status: Int): Int

Data bytes that follow status, or -1 for SysEx (runs to F7) and undefined bytes.

    fun dataLength(status: Int): Int = when (status and 0xF0) {
        0x80, 0x90, 0xA0, 0xB0, 0xE0 -> 2
        0xC0, 0xD0 -> 1
        0xF0 -> when (status) {
            0xF1, 0xF3 -> 1
            0xF2 -> 2
            0xF6, in 0xF8..0xFF -> 0
            else -> -1
        }
        else -> -1
    }

MIDI command section (RFC 6295 §3)

data class Command(val delta: Int, val bytes: ByteArray)

One command and its delta time, in RTP timestamp units, from the previous one (or the header timestamp).

override fun equals(other: Any?)

        override fun equals(other: Any?) = other is Command && other.delta == delta && other.bytes.contentEquals(bytes)

override fun hashCode() = 31 * delta + bytes.contentHashCode()

override fun toString()

        override fun toString() = "Command(delta=$delta, ${bytes.joinToString(" ") { "%02X".format(it) }})"
    }

fun encodeCommands(commands: List<Command>, runningStatus: Boolean = true): ByteArray

Encodes commands as a MIDI command section. The first delta is written only when non-zero (the Z flag). With runningStatus, a channel message that repeats the previous status omits it. No journal is written.

    fun encodeCommands(commands: List<Command>, runningStatus: Boolean = true): ByteArray {
        val list = ByteArrayOutputStream()
        var last = -1
        val z = commands.isNotEmpty() && commands[0].delta != 0
        commands.forEachIndexed { i, c ->
            if (i > 0 || z) writeDelta(list, c.delta)
            val status = c.bytes[0].toInt() and 0xFF
            if (runningStatus && status < 0xF0 && status == last) list.write(c.bytes, 1, c.bytes.size - 1)
            else list.write(c.bytes, 0, c.bytes.size)
            last = when {
                status < 0xF0 -> status
                status >= 0xF8 -> last                    // real-time does not touch running status
                else -> -1                                // system common and SysEx cancel it
            }
        }
        val body = list.toByteArray()
        require(body.size <= 0x0FFF) { "command section too long (${body.size} bytes)" }
        val out = ByteArrayOutputStream()
        val flags = (if (z) 0x20 else 0)
        if (body.size > 15) {
            out.write(0x80 or flags or (body.size shr 8)); out.write(body.size and 0xFF)
        } else out.write(flags or body.size)
        out.write(body)
        return out.toByteArray()
    }

data class Decoded(val commands: List<Command>, val journalPresent: Boolean, val sysexPending: ByteArray?)

The result of decodeCommands: the commands, whether a journal followed, and SysEx carried across packets.

    data class Decoded(val commands: List<Command>, val journalPresent: Boolean, val sysexPending: ByteArray?)

fun decodeCommands(data: ByteArray, offset: Int, length: Int = data.size - offset, sysexPending: ByteArray? = null): Decoded

Decodes a MIDI command section starting at offset of data. A journal (J flag) is skipped, since the header's length says where the command list ends. sysexPending is an unfinished segmented SysEx from the previous packet of the same stream.

body — 78 lines, from line 87
    fun decodeCommands(data: ByteArray, offset: Int, length: Int = data.size - offset, sysexPending: ByteArray? = null): Decoded {
        var p = offset
        val end = offset + length
        val h = data[p++].toInt() and 0xFF
        val long = h and 0x80 != 0
        val j = h and 0x40 != 0
        val z = h and 0x20 != 0
        val phantom = h and 0x10 != 0
        var len = h and 0x0F
        if (long) len = (len shl 8) or (data[p++].toInt() and 0xFF)
        val listEnd = p + len
        require(listEnd <= end) { "command list length $len overruns packet" }
        val out = ArrayList<Command>()
        var running = -1
        var pending = sysexPending
        var first = true
        // P: the first command's status was omitted and is the running status from before; we keep
        // none across packets, so such a command is dropped up to the next status byte.
        if (phantom) running = -1
        while (p < listEnd) {
            var delta = 0
            if (!first || z) {
                var n = 0
                while (true) {
                    val b = data[p++].toInt() and 0xFF
                    delta = (delta shl 7) or (b and 0x7F)
                    if (b and 0x80 == 0 || ++n == 4) break
                }
                if (p >= listEnd) break
            }
            first = false
            var b = data[p].toInt() and 0xFF
            if (b and 0x80 == 0) {
                if (running < 0) { while (p < listEnd && data[p].toInt() and 0x80 == 0) p++; continue }
                val n = dataLength(running)
                out += Command(delta, byteArrayOf(running.toByte()) + data.copyOfRange(p, minOf(p + n, listEnd)))
                p += n
                continue
            }
            when {
                b == 0xF0 || (b == 0xF7 && pending != null) -> {
                    // SysEx, whole (F0 .. F7) or a segment (F0 .. F0, F7 .. F0, F7 .. F7, F7 .. F4 cancel).
                    val start = p++
                    while (p < listEnd) {
                        val c = data[p].toInt() and 0xFF
                        if (c == 0xF7 || c == 0xF0 || c == 0xF4) break
                        p++
                    }
                    if (p >= listEnd) { pending = null; break }
                    val term = data[p++].toInt() and 0xFF
                    val body = data.copyOfRange(start + 1, p - 1)
                    val head = if (b == 0xF0) byteArrayOf(0xF0.toByte()) else (pending ?: byteArrayOf(0xF0.toByte()))
                    when (term) {
                        0xF7 -> { out += Command(delta, head + body + byteArrayOf(0xF7.toByte())); pending = null }
                        0xF0 -> pending = head + body
                        else -> pending = null                     // F4: cancelled
                    }
                    running = -1
                }
                else -> {
                    val n = dataLength(b)
                    if (n < 0) { p++; continue }
                    out += Command(delta, data.copyOfRange(p, minOf(p + 1 + n, listEnd)))
                    p += 1 + n
                    if (b < 0xF0) running = b else if (b < 0xF8) running = -1
                }
            }
        }
        return Decoded(out, j, pending)
    }

    private fun writeDelta(out: ByteArrayOutputStream, delta: Int) {
        val d = delta.coerceIn(0, 0x0FFFFFFF)
        var shift = 21
        while (shift > 0 && (d shr shift) == 0) shift -= 7
        while (shift > 0) { out.write(0x80 or ((d shr shift) and 0x7F)); shift -= 7 }
        out.write(d and 0x7F)
    }

RTP packet

data class RtpPacket(val sequence: Int, val timestamp: Long, val ssrc: Int, val commands: List<Command>, val journal: Boolean, val sysexPending: ByteArray? = nu

    data class RtpPacket(val sequence: Int, val timestamp: Long, val ssrc: Int, val commands: List<Command>, val journal: Boolean, val sysexPending: ByteArray? = null)

fun encodeRtp(sequence: Int, timestamp: Long, ssrc: Int, commands: List<Command>, runningStatus: Boolean = true): ByteArray

    fun encodeRtp(sequence: Int, timestamp: Long, ssrc: Int, commands: List<Command>, runningStatus: Boolean = true): ByteArray {
        val section = encodeCommands(commands, runningStatus)
        return ByteBuffer.allocate(12 + section.size).apply {
            put(0x80.toByte()); put(PAYLOAD_TYPE.toByte()); putShort(sequence.toShort())
            putInt(timestamp.toInt()); putInt(ssrc); put(section)
        }.array()
    }

fun isRtpMidi(data: ByteArray, length: Int)

    fun isRtpMidi(data: ByteArray, length: Int) =
        length >= 13 && (data[0].toInt() and 0xC0) == 0x80 && (data[1].toInt() and 0x7F) == PAYLOAD_TYPE

fun decodeRtp(data: ByteArray, length: Int, sysexPending: ByteArray? = null): RtpPacket

    fun decodeRtp(data: ByteArray, length: Int, sysexPending: ByteArray? = null): RtpPacket {
        val bb = ByteBuffer.wrap(data, 0, length)
        val b0 = bb.get().toInt() and 0xFF
        bb.get()
        val seq = bb.short.toInt() and 0xFFFF
        val ts = bb.int.toLong() and 0xFFFFFFFFL
        val ssrc = bb.int
        val csrc = b0 and 0x0F
        val start = 12 + 4 * csrc
        val d = decodeCommands(data, start, length - start, sysexPending)
        return RtpPacket(seq, ts, ssrc, d.commands, d.journalPresent, d.sysexPending)
    }

AppleMIDI session packets

sealed class Session

    sealed class Session {

data class Invitation(val command: String, val token: Int, val ssrc: Int, val name: String?) : Session()

IN, OK, NO; name is absent from NO on some peers.

        data class Invitation(val command: String, val token: Int, val ssrc: Int, val name: String?) : Session()

data class Bye(val token: Int, val ssrc: Int) : Session()

data class Sync(val ssrc: Int, val count: Int, val ts1: Long, val ts2: Long, val ts3: Long) : Session()

CK: count 0, 1, 2 and the three timestamps in 100 µs units.

        data class Sync(val ssrc: Int, val count: Int, val ts1: Long, val ts2: Long, val ts3: Long) : Session()

data class Feedback(val ssrc: Int, val sequence: Int) : Session()

RS: the peer has received everything up to sequence.

fun isSession(data: ByteArray, length: Int)

    fun isSession(data: ByteArray, length: Int) = length >= 4 && data[0] == 0xFF.toByte() && data[1] == 0xFF.toByte()

fun encode(s: Session): ByteArray

    fun encode(s: Session): ByteArray = when (s) {
        is Session.Invitation -> {
            val name = s.name?.toByteArray(Charsets.UTF_8)
            ByteBuffer.allocate(16 + (name?.size?.plus(1) ?: 0)).apply {
                putShort(-1); put(s.command.toByteArray(Charsets.US_ASCII)); putInt(PROTOCOL_VERSION); putInt(s.token); putInt(s.ssrc)
                if (name != null) { put(name); put(0) }
            }.array()
        }
        is Session.Bye -> ByteBuffer.allocate(16).apply {
            putShort(-1); put("BY".toByteArray()); putInt(PROTOCOL_VERSION); putInt(s.token); putInt(s.ssrc)
        }.array()
        is Session.Sync -> ByteBuffer.allocate(36).apply {
            putShort(-1); put("CK".toByteArray()); putInt(s.ssrc); put(s.count.toByte()); put(0); putShort(0)
            putLong(s.ts1); putLong(s.ts2); putLong(s.ts3)
        }.array()
        is Session.Feedback -> ByteBuffer.allocate(12).apply {
            putShort(-1); put("RS".toByteArray()); putInt(s.ssrc); putInt(s.sequence shl 16)
        }.array()
    }

fun decodeSession(data: ByteArray, length: Int): Session?

Decodes a session packet, or null for one this implementation does not know.

    fun decodeSession(data: ByteArray, length: Int): Session? {
        if (!isSession(data, length)) return null
        val bb = ByteBuffer.wrap(data, 0, length)
        bb.position(2)
        val cmd = String(byteArrayOf(bb.get(), bb.get()), Charsets.US_ASCII)
        return when (cmd) {
            "IN", "OK", "NO" -> {
                if (length < 16) return null
                bb.int
                val token = bb.int; val ssrc = bb.int
                var name: String? = null
                if (length > 16) {
                    var e = 16
                    while (e < length && data[e] != 0.toByte()) e++
                    name = String(data, 16, e - 16, Charsets.UTF_8)
                }
                Session.Invitation(cmd, token, ssrc, name)
            }
            "BY" -> if (length < 16) null else { bb.int; Session.Bye(bb.int, bb.int) }
            "CK" -> if (length < 36) null else {
                val ssrc = bb.int; val count = bb.get().toInt() and 0xFF; bb.get(); bb.short
                Session.Sync(ssrc, count, bb.long, bb.long, bb.long)
            }
            "RS" -> if (length < 12) null else Session.Feedback(bb.int, (bb.int ushr 16) and 0xFFFF)
            else -> null
        }
    }
}