File size: 11,336 Bytes
6385a57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
#!/usr/bin/python3
"""
A simplified MIDI file parsing and generation module.

This module provides functions to convert between MIDI files and two list-based
representations: "opus" (delta-time events) and "score" (absolute-time notes).
Text events are kept as bytes to handle various encodings (e.g., Shift-JIS)
correctly, avoiding decoding errors noted in the original comment.

Core functions:
- midi2opus: Parse MIDI bytes to an opus structure.
- opus2midi: Convert an opus to MIDI bytes.
- score2opus: Convert a score to an opus.
- opus2score: Convert an opus to a score.
- midi2score: Parse MIDI bytes to a score.
- score2midi: Convert a score to MIDI bytes.

Event formats:
- Opus: ['event_type', delta_time, ...] (e.g., ['note_on', 5, 0, 60, 100])
- Score: ['note', start_time, duration, channel, pitch, velocity] for notes,
         other events retain their opus format with absolute times.

Dependencies: Python 3, struct module.
"""

import struct

# Version info
__version__ = "1.0"
__date__ = "2023-10-01"

# --- Helper Functions ---

def _read_vlq(data, offset):
    """Read a variable-length quantity from data at offset."""
    value = 0
    while True:
        byte = data[offset]
        offset += 1
        value = (value << 7) | (byte & 0x7F)
        if not (byte & 0x80):
            break
    return value, offset

def _write_vlq(value):
    """Write a variable-length quantity as bytes."""
    if value == 0:
        return b'\x00'
    parts = []
    while value > 0:
        parts.append(value & 0x7F)
        value >>= 7
    parts.reverse()
    for i in range(len(parts) - 1):
        parts[i] |= 0x80
    return bytes(parts)

def _write_14_bit(value):
    """Encode a 14-bit value into two bytes."""
    return bytes([value & 0x7F, (value >> 7) & 0x7F])

# --- Decoding Functions ---

def _decode_track(data):
    """Decode MIDI track bytes into a list of opus events."""
    events = []
    offset = 0
    running_status = None

    while offset < len(data):
        # Read delta time
        delta_time, offset = _read_vlq(data, offset)
        event_type = data[offset]
        offset += 1

        # Handle running status
        if event_type >= 0x80:
            status = event_type
            running_status = status
        else:
            if running_status is None:
                raise ValueError("Running status used without prior status")
            status = running_status
            offset -= 1  # Backtrack to use byte as parameter

        # Channel messages (0x80-0xEF)
        if 0x80 <= status <= 0xEF:
            channel = status & 0x0F
            command = status & 0xF0

            if command in (0x80, 0x90, 0xA0, 0xB0, 0xE0):  # Two parameters
                param1, param2 = data[offset], data[offset + 1]
                offset += 2
                if command == 0x80:
                    event = ['note_off', delta_time, channel, param1, param2]
                elif command == 0x90:
                    event = ['note_on' if param2 > 0 else 'note_off', delta_time, channel, param1, param2]
                elif command == 0xA0:
                    event = ['key_after_touch', delta_time, channel, param1, param2]
                elif command == 0xB0:
                    event = ['control_change', delta_time, channel, param1, param2]
                elif command == 0xE0:
                    pitch = ((param2 << 7) | param1) - 0x2000
                    event = ['pitch_wheel_change', delta_time, channel, pitch]
            elif command in (0xC0, 0xD0):  # One parameter
                param1 = data[offset]
                offset += 1
                event = ['patch_change' if command == 0xC0 else 'channel_after_touch', delta_time, channel, param1]
            events.append(event)

        # Meta events (0xFF)
        elif status == 0xFF:
            meta_type = data[offset]
            offset += 1
            length, offset = _read_vlq(data, offset)
            meta_data = data[offset:offset + length]
            offset += length

            if meta_type == 0x2F:
                event = ['end_track', delta_time]
            elif meta_type == 0x51 and length == 3:
                event = ['set_tempo', delta_time, int.from_bytes(meta_data, 'big')]
            elif 0x01 <= meta_type <= 0x0F:
                event = [f'text_event_{meta_type:02x}', delta_time, meta_data]  # Text as bytes
            else:
                event = ['raw_meta_event', delta_time, meta_type, meta_data]
            events.append(event)

        # System exclusive events (0xF0, 0xF7)
        elif status in (0xF0, 0xF7):
            length, offset = _read_vlq(data, offset)
            sysex_data = data[offset:offset + length]
            offset += length
            event = ['sysex_f0' if status == 0xF0 else 'sysex_f7', delta_time, sysex_data]
            events.append(event)

        else:
            raise ValueError(f"Unknown status byte: {status:02x}")

    return events

def midi2opus(midi_bytes):
    """Convert MIDI bytes to an opus structure: [ticks_per_quarter, [track_events, ...]]."""
    if not isinstance(midi_bytes, (bytes, bytearray)) or len(midi_bytes) < 14 or midi_bytes[:4] != b'MThd':
        return [1000, []]  # Default empty opus

    header_size = int.from_bytes(midi_bytes[4:8], 'big')
    if header_size != 6:
        raise ValueError("Invalid MIDI header size")

    ticks_per_quarter = int.from_bytes(midi_bytes[12:14], 'big')
    num_tracks = int.from_bytes(midi_bytes[10:12], 'big')
    opus = [ticks_per_quarter]
    offset = 14

    for _ in range(num_tracks):
        if offset + 8 > len(midi_bytes) or midi_bytes[offset:offset+4] != b'MTrk':
            break
        track_size = int.from_bytes(midi_bytes[offset+4:offset+8], 'big')
        track_data = midi_bytes[offset+8:offset+8+track_size]
        opus.append(_decode_track(track_data))
        offset += 8 + track_size

    return opus

# --- Encoding Functions ---

def _encode_track(events):
    """Encode a list of opus events into track bytes."""
    track_data = bytearray()
    running_status = None

    for event in events:
        event_type, delta_time = event[0], event[1]
        track_data.extend(_write_vlq(delta_time))

        if event_type == 'note_on':
            status = 0x90 | event[2]
            params = bytes([event[3], event[4]])
        elif event_type == 'note_off':
            status = 0x80 | event[2]
            params = bytes([event[3], event[4]])
        elif event_type == 'control_change':
            status = 0xB0 | event[2]
            params = bytes([event[3], event[4]])
        elif event_type == 'patch_change':
            status = 0xC0 | event[2]
            params = bytes([event[3]])
        elif event_type == 'pitch_wheel_change':
            status = 0xE0 | event[2]
            params = _write_14_bit(event[3] + 0x2000)
        elif event_type.startswith('text_event_'):
            meta_type = int(event_type.split('_')[-1], 16)
            text_data = event[2]
            track_data.extend(b'\xFF' + bytes([meta_type]) + _write_vlq(len(text_data)) + text_data)
            continue
        elif event_type == 'set_tempo':
            tempo = event[2]
            track_data.extend(b'\xFF\x51\x03' + struct.pack('>I', tempo)[1:])
            continue
        elif event_type == 'end_track':
            track_data.extend(b'\xFF\x2F\x00')
            continue
        else:
            continue  # Skip unsupported events

        if status != running_status:
            track_data.append(status)
            running_status = status
        track_data.extend(params)

    return track_data

def opus2midi(opus):
    """Convert an opus structure to MIDI bytes."""
    if len(opus) < 2:
        opus = [1000, []]
    ticks_per_quarter = opus[0]
    tracks = opus[1:]
    midi_bytes = b'MThd' + struct.pack('>IHHH', 6, 1 if len(tracks) > 1 else 0, len(tracks), ticks_per_quarter)

    for track in tracks:
        track_data = _encode_track(track)
        midi_bytes += b'MTrk' + struct.pack('>I', len(track_data)) + track_data

    return midi_bytes

# --- Score Conversion Functions ---

def score2opus(score):
    """Convert a score to an opus structure."""
    if len(score) < 2:
        return [1000, []]
    ticks = score[0]
    opus = [ticks]

    for track in score[1:]:
        time_to_events = {}
        for event in track:
            if event[0] == 'note':
                time_to_events.setdefault(event[1], []).append(
                    ['note_on', event[1], event[3], event[4], event[5]])
                time_to_events.setdefault(event[1] + event[2], []).append(
                    ['note_off', event[1] + event[2], event[3], event[4], 0])
            else:
                time_to_events.setdefault(event[1], []).append(event)

        sorted_times = sorted(time_to_events.keys())
        opus_track = []
        abs_time = 0
        for time in sorted_times:
            delta = time - abs_time
            abs_time = time
            for evt in time_to_events[time]:
                evt_copy = evt.copy()
                evt_copy[1] = delta
                opus_track.append(evt_copy)
            delta = 0  # Subsequent events at same time have delta 0
        opus.append(opus_track)

    return opus

def opus2score(opus):
    """Convert an opus to a score structure."""
    if len(opus) < 2:
        return [1000, []]
    ticks = opus[0]
    score = [ticks]

    for track in opus[1:]:
        score_track = []
        abs_time = 0
        pending_notes = {}  # (channel, pitch) -> note event

        for event in track:
            abs_time += event[1]
            if event[0] == 'note_on' and event[4] > 0:
                key = (event[2], event[3])
                pending_notes[key] = ['note', abs_time, 0, event[2], event[3], event[4]]
            elif event[0] in ('note_off',) or (event[0] == 'note_on' and event[4] == 0):
                key = (event[2], event[3])
                if key in pending_notes:
                    note = pending_notes.pop(key)
                    note[2] = abs_time - note[1]
                    score_track.append(note)
            else:
                event_copy = event.copy()
                event_copy[1] = abs_time
                score_track.append(event_copy)

        # Handle unterminated notes
        for note in pending_notes.values():
            note[2] = abs_time - note[1]
            score_track.append(note)

        score.append(score_track)

    return score

# --- Direct MIDI-Score Conversions ---

def midi2score(midi_bytes):
    """Convert MIDI bytes directly to a score."""
    return opus2score(midi2opus(midi_bytes))

def score2midi(score):
    """Convert a score directly to MIDI bytes."""
    return opus2midi(score2opus(score))

# --- Example Usage ---
if __name__ == "__main__":
    # Example opus
    test_opus = [
        96,
        [
            ['patch_change', 0, 1, 8],
            ['note_on', 5, 1, 60, 96],
            ['note_off', 96, 1, 60, 0],
            ['text_event_01', 0, b'Shift-JIS: \x83e\x83X\x83g'],  # Shift-JIS "テスト" (Test)
            ['end_track', 0]
        ]
    ]
    midi_data = opus2midi(test_opus)
    with open("test.mid", "wb") as f:
        f.write(midi_data)

    # Parse it back
    parsed_opus = midi2opus(midi_data)
    score = opus2score(parsed_opus)
    print("Parsed Score:", score)