Skip to content

Commit ab7bd42

Browse files
committed
Fix #103: handle realtime messages within SysEx chunks accordingly (Claude Code)
1 parent fea3735 commit ab7bd42

File tree

2 files changed

+130
-18
lines changed

2 files changed

+130
-18
lines changed

ktmidi/src/commonMain/kotlin/dev/atsushieno/ktmidi/Midi1SysExChunkProcessor.kt

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,72 @@ package dev.atsushieno.ktmidi
22

33
class Midi1SysExChunkProcessor {
44
val remaining = mutableListOf<Byte>()
5+
private fun isStatusByte(byte: Byte): Boolean = (byte.toInt() and 0x80) != 0
6+
7+
private fun isRealTimeMessage(byte: Byte): Boolean {
8+
val value = byte.toInt() and 0xFF
9+
return value >= 0xF8
10+
}
11+
12+
private fun findSysExTermination(input: List<Byte>, startPos: Int = 0): Int {
13+
for (i in startPos until input.size) {
14+
val byte = input[i]
15+
val value = byte.toInt() and 0xFF
16+
17+
if (value == 0xF7)
18+
return i
19+
20+
if (isStatusByte(byte) && !isRealTimeMessage(byte) && value != 0xF0)
21+
return i - 1
22+
}
23+
return -1
24+
}
25+
526
// Returns a sequence of "event list" that are "complete" i.e. not pending.
627
// Any incomplete sysex buffer is stored in `remaining`.
728
// If there is no buffer and the input is an incomplete SysEx buffer, then only an empty sequence is returned.
829
fun process(input: List<Byte>): Sequence<List<Byte>> = sequence {
930
if (remaining.isNotEmpty()) {
10-
val f7Pos = input.indexOf(0xF7.toByte())
11-
if (f7Pos < 0)
31+
val terminationPos = findSysExTermination(input)
32+
if (terminationPos < 0) {
1233
remaining.addAll(input)
13-
else {
14-
yield(remaining + input.take(f7Pos + 1))
15-
remaining.clear()
16-
// process the remaining recursively
17-
yieldAll(process(input.drop(f7Pos + 1)))
34+
} else {
35+
val endByte = input[terminationPos]
36+
val isEOX = (endByte.toInt() and 0xFF) == 0xF7
37+
38+
if (isEOX) {
39+
yield(remaining + input.take(terminationPos + 1))
40+
remaining.clear()
41+
yieldAll(process(input.drop(terminationPos + 1)))
42+
} else {
43+
yield(remaining + input.take(terminationPos + 1))
44+
remaining.clear()
45+
yieldAll(process(input.drop(terminationPos + 1)))
46+
}
1847
}
1948
} else {
2049
// If sysex is found then check if it is incomplete.
2150
// F0 must occur only as the beginning of SysEx, so simply check it by indexOf().
2251
val f0Pos = input.indexOf(0xF0.toByte())
23-
if (f0Pos < 0)
24-
yield(input)
25-
else {
26-
yield(input.take(f0Pos))
27-
val f7Pos = input.indexOf(0xF7.toByte())
28-
if (f7Pos < 0)
29-
remaining.addAll(input)
30-
else {
31-
yield(input.take(f7Pos + 1))
32-
// process the remaining recursively
33-
yieldAll(process(input.drop(f7Pos + 1)))
52+
if (f0Pos < 0) {
53+
if (input.isNotEmpty())
54+
yield(input)
55+
} else {
56+
yield(input.take(f0Pos))
57+
val terminationPos = findSysExTermination(input, f0Pos + 1)
58+
if (terminationPos < 0) {
59+
remaining.addAll(input.drop(f0Pos))
60+
} else {
61+
val endByte = input[terminationPos]
62+
val isEOX = (endByte.toInt() and 0xFF) == 0xF7
63+
64+
if (isEOX) {
65+
yield(input.subList(f0Pos, terminationPos + 1))
66+
yieldAll(process(input.drop(terminationPos + 1)))
67+
} else {
68+
yield(input.subList(f0Pos, terminationPos + 1))
69+
yieldAll(process(input.drop(terminationPos + 1)))
70+
}
3471
}
3572
}
3673
}

ktmidi/src/commonTest/kotlin/dev/atsushieno/ktmidi/Midi1SysExChunkProcessorTest.kt

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package dev.atsushieno.ktmidi
22

33
import kotlin.test.Test
44
import kotlin.test.assertContentEquals
5+
import kotlin.test.assertEquals
56
import kotlin.test.assertFalse
67

78
class Midi1SysExChunkProcessorTest {
@@ -22,4 +23,78 @@ class Midi1SysExChunkProcessorTest {
2223
assertContentEquals(sysex, seq2.toList(), "round $it: #2")
2324
}
2425
}
26+
27+
@Test
28+
fun sysexTerminatedByNonRealTimeStatusByte() {
29+
val processor = Midi1SysExChunkProcessor()
30+
val input = listOf(
31+
0xF0, 0x7E, 0x7F, 0x0D,
32+
0x90, 0x3C, 0x40
33+
).map { it.toByte() }
34+
35+
val result = processor.process(input).toList()
36+
assertEquals(2, result.size)
37+
assertContentEquals(listOf(0xF0, 0x7E, 0x7F, 0x0D).map { it.toByte() }, result[0])
38+
assertContentEquals(listOf(0x90, 0x3C, 0x40).map { it.toByte() }, result[1])
39+
}
40+
41+
@Test
42+
fun sysexTerminatedByNonRealTimeStatusByteInRemainingBuffer() {
43+
val processor = Midi1SysExChunkProcessor()
44+
val input1 = listOf(0xF0, 0x7E, 0x7F).map { it.toByte() }
45+
val input2 = listOf(0x0D, 0x90, 0x3C, 0x40).map { it.toByte() }
46+
47+
val result1 = processor.process(input1).toList()
48+
assertEquals(0, result1.size)
49+
50+
val result2 = processor.process(input2).toList()
51+
assertEquals(2, result2.size)
52+
assertContentEquals(listOf(0xF0, 0x7E, 0x7F, 0x0D).map { it.toByte() }, result2[0])
53+
assertContentEquals(listOf(0x90, 0x3C, 0x40).map { it.toByte() }, result2[1])
54+
}
55+
56+
@Test
57+
fun realTimeMessagesDuringIncompleteSysex() {
58+
val processor = Midi1SysExChunkProcessor()
59+
val input1 = listOf(0xF0, 0x7E, 0xF8, 0x7F).map { it.toByte() }
60+
val input2 = listOf(0x0D, 0xFE, 0x7E, 0xF7).map { it.toByte() }
61+
62+
val result1 = processor.process(input1).toList()
63+
assertEquals(0, result1.size)
64+
65+
val result2 = processor.process(input2).toList()
66+
assertEquals(1, result2.size)
67+
assertContentEquals(
68+
listOf(0xF0, 0x7E, 0xF8, 0x7F, 0x0D, 0xFE, 0x7E, 0xF7).map { it.toByte() },
69+
result2[0]
70+
)
71+
}
72+
73+
@Test
74+
fun realTimeMessagesInCompleteSysex() {
75+
val processor = Midi1SysExChunkProcessor()
76+
val input = listOf(0xF0, 0x7E, 0xF8, 0x7F, 0x0D, 0xFE, 0x7E, 0xF7).map { it.toByte() }
77+
78+
val result = processor.process(input).toList()
79+
assertEquals(1, result.size)
80+
assertContentEquals(input, result[0])
81+
}
82+
83+
@Test
84+
fun multipleSysexMessagesTerminatedByStatusBytes() {
85+
val processor = Midi1SysExChunkProcessor()
86+
val input = listOf(
87+
0xF0, 0x01, 0x02,
88+
0x90, 0x3C, 0x40,
89+
0xF0, 0x03, 0x04, 0xF7,
90+
0x80, 0x3C, 0x00
91+
).map { it.toByte() }
92+
93+
val result = processor.process(input).toList()
94+
assertEquals(4, result.size)
95+
assertContentEquals(listOf(0xF0, 0x01, 0x02).map { it.toByte() }, result[0])
96+
assertContentEquals(listOf(0x90, 0x3C, 0x40).map { it.toByte() }, result[1])
97+
assertContentEquals(listOf(0xF0, 0x03, 0x04, 0xF7).map { it.toByte() }, result[2])
98+
assertContentEquals(listOf(0x80, 0x3C, 0x00).map { it.toByte() }, result[3])
99+
}
25100
}

0 commit comments

Comments
 (0)