2022년 9월 17일 토요일

python으로 mid file 분석, MTrk

 

지난번에는 MThd설명을 하였으며 MTrk에 가장 중요한 이벤트가 들어 있습니다.

MTrk는 그 구조가 상당히 복잡합니다.

time delta + 동작 코드 : 형태가 가장 기본 입니다.

MTrk + time delta + 동작 코드 + time delta + 동작 코드 + time delta + 동작 코드 + ....
위와 같은 형태가 반복됩니다.

일단 아래 그림을 살펴보도록 하겠습니다.


time delta

여기에서 time delta는 동작 코드를 얼마나 기다렸다가 동작을 시키는지에 대한 대기 시간입니다.

앞서서 MThd 설명에서 아래와 같은 time division이 있었습니다.
time division : 이 값은 4분 음표(4분 음표를 1박자라고 표현)를 여기 에서는 몇개의 틱으로 나타내는지를 나타내는 숫자 입니다.

(여기 값이 480이라고 한다면 나중에 음표를 연주할때 480틱이라는 시간 동안 연주를 하라는 명령이 내려오면 1박자 동안 연주하면 됩니다. )

time division이 480이고 time delta가 240이 들어있다고 한다면 0.5박자 후에 어떤 이벤트가 벌어진다는 얘기가 됩니다. 0.5박자라고 표현한것은 실제 tempo(템포)에 따라서 시간 개념이 다르기 때문입니다.

또한 time delta는 7비트만 이용하고 최상위 bit가 1인 경우 (127 보다 커지는 경우) 다음 byte를 더 읽도록 됩니다. 즉 time delta 의 데이터 크기가 가변적이라 읽을때 특별한 처리가 필요합니다.

아래와 같은 함수를 이용합니다. 리턴되는 값은 실제 읽은 data와 몇 바이트를 사용했는지 정보를 같이 리턴합니다.

def read_variable_int(self, byte_data, pos):
delta = 0
i = 0
while True:
byte = np.uint8(byte_data[i+pos])
delta = (delta << 7) | (byte & 0x7f)
if byte < 0x80:
return delta, i+1
i = i + 1


event type(status_byte)

event 라고 불리기도 하고 message로 불리기도 합니다. 여기에 1byte에 오는 형태의 값에 따라 여러가지 형태를 가지고 뒤쪽에 데이터의 길이도 달라지게 됩니다.

mido 라는 python 프로젝트의 아래 코드를 참고 해서 가져온 SPECS 부분을 참고했습니다. mido/messages/specs.py

SPECS = [
# https://www.recordingblogs.com/wiki/midi-voice-messages
# MIDI voice messages
_defmsg(0x80, 'note_off', ('channel', 'note', 'velocity'), 3),
_defmsg(0x90, 'note_on', ('channel', 'note', 'velocity'), 3),
_defmsg(0xa0, 'polytouch', ('channel', 'note', 'value'), 3),
_defmsg(0xb0, 'control_change', ('channel', 'control', 'value'), 3),
_defmsg(0xc0, 'program_change', ('channel', 'program',), 2),
_defmsg(0xd0, 'aftertouch', ('channel', 'value',), 2),
_defmsg(0xe0, 'pitchwheel', ('channel', 'pitch',), 3),

# https://www.recordingblogs.com/wiki/midi-system-common-messages
# System common messages.
# 0xf4 and 0xf5 are undefined.
_defmsg(0xf0, 'sysex', ('data',), float('inf')),
_defmsg(0xf1, 'quarter_frame', ('frame_type', 'frame_value'), 2),
_defmsg(0xf2, 'songpos', ('pos',), 3),
_defmsg(0xf3, 'song_select', ('song',), 2),
_defmsg(0xf6, 'tune_request', (), 1),

# https://www.recordingblogs.com/wiki/midi-system-realtime-messages
# System real time messages.
# 0xf9 and 0xfd are undefined.
_defmsg(0xf8, 'clock', (), 1),
_defmsg(0xfa, 'start', (), 1),
_defmsg(0xfb, 'continue', (), 1),
_defmsg(0xfc, 'stop', (), 1),
_defmsg(0xfe, 'active_sensing', (), 1),

# https://www.recordingblogs.com/wiki/midi-meta-messages
# MIDI meta messages
_defmsg(0xff, 'reset', (), 1),
]

이 list에서 마지막 숫자는 앞서 말한 동작 코드의 길이입니다.

메세지의 형태에 따라서 전체 길이가 달라집니다. 여기에서는 테스트 목적과 분석하는 목적이므로 note_on, note_off 만 처리할 예정입니다. 해당은 음표를 연주하거나 중지하는 가장 기본이 되는 부분이라 이 부분만 알면 연주하는 것에 문제가 없습니다.


meta messages

가장 이해가 쉬운 meta messages 부터 설명 합니다.

status_byte 값이 0xFF가 오면 뒤쪽에 command code와 데이터가 옵니다. 물론 command에 따라 데이터가 없을 수도 있습니다.

META_EVENT_SPEC = [
_meta_defmsg(0x00, "Sequence number"),
_meta_defmsg(0x01, "Text"),
_meta_defmsg(0x02, "Copyright notice"),
_meta_defmsg(0x03, "Track name"),
_meta_defmsg(0x04, "Instrument name"),
_meta_defmsg(0x05, "Lyrics"),
_meta_defmsg(0x06, "Marker"),
_meta_defmsg(0x07, "Cue point"),
_meta_defmsg(0x20, "Channel prefix"),
_meta_defmsg(0x2F, "End of track"),
_meta_defmsg(0x51, "Set tempo"),
_meta_defmsg(0x54, "SMPTE offset"),
_meta_defmsg(0x58, "Time signature"),
_meta_defmsg(0x59, "Key signature"),
_meta_defmsg(0x7F, "Sequencer specific")
]

command 코드는 META_EVENT_SPEC 에 정리되어있으며 해당 값도 mido 소스에서 가져온 내용입니다.

데이터 길이를 명시 하지 않은 이유는 meta message의 경우 다행히 뒤쪽 길이 정보가 곧장 붙어 있어서 그렇습니다.

코드 구현은 아래와 같습니다. 

여기에서 body와 pos가 있는데 body는 전체 np로 mid파일을 메모리로 모두 올린 데이터 입니다. 그중에서 다음 읽을 위치를 결정하는 변수를 pos로 관리합니다. 읽고 나면 다음을 읽기 위해 pos 변수를 읽은 만큼 증가 시켜서 관리를 합니다.

if status_byte == 0xFF:
# https://www.recordingblogs.com/wiki/midi-meta-messages
# Meta messages
command = np.uint8(body[pos])
if command == 0x2F:
print("End of track")
return
pos = pos + 1
data_delta, delta_size = self.read_variable_int(body, pos)
pos = pos + delta_size
data = body[pos:pos+data_delta]
pos = pos + data_delta
find = False
for spec_data in META_EVENT_SPEC:
if command == spec_data['Metatype']:
print(f"Meta message {spec_data['Message']} FF {command:02x} {data} {data_delta:02x} {delta_size:02x}")
find = True
break
if find == False:
print(f"error metatype:{command:02x}" )
#exit(-1)

mido 소스에 보면 몇개 처리를 안하는 부분도 존재합니다.

여기에서도 동일하게 처리 하지 않았습니다.

elif status_byte == 0xF0 or status_byte == 0xF7:
# 일단 처리 안함
data_delta, delta_size = self.read_variable_int(body, pos)
pos = pos + delta_size
data = body[pos:pos + data_delta]
pos = pos + data_delta
print(f"skip messages {data_delta:02x} {data} {delta_size:02x}")


midi messages

Midi message의 경우 하위 byte가 ?로 된 부분이 존재합니다.

0x8? ~ 0xE? : Midi messages

이런 부분인데, 하위 4bit에 채널 정보가 들어갑니다. 그리고 조건 비교시에도 상위 4bit만 midi message인지 비교를 합니다. ? 로 표시된 부분은 비교하지 않습니다.

note_on, note_off 처리하는 곳을 살펴보면 채널(ch)과 음정보(nn) 그리고 세기정보(vv)를 이용해서 음을 생성 하거나 끄도록 합니다.

if status_byte <= 0xEF and spec_data['status_byte'] == (status_byte & 0xF0):
# status_byte <= 0xEF 작은 조건은 Midi messages 가 됩니다.
# Midi messages
if spec_data['type'] == 'note_on' or spec_data['type'] == 'note_off' :
ch = status_byte & 0x0F
nn = body[pos]
vv = body[pos+1]
print(f"{spec_data['type']} {ch} {self.get_note_number_str(nn)} {vv}")
else:
print(f"{status_byte:02x} {spec_data['type']} {spec_data['length']}")
pos_len = spec_data['length'] - 1
find = True
break


System messages

여기에서는 단순 파일 분석이므로 system message부분도 처리해 주지 않는다

elif spec_data['status_byte'] == status_byte:
# status_byte > 0xEF 이면 System messages 가 된다.
# System messages
print(f"{status_byte:02x} {spec_data['type']} {spec_data['length']}")
pos_len = spec_data['length'] - 1
find = True
break



전체 소스

지금까지 구현된 전체 소스는 아래와 같습니다.

import numpy as np

# https://github.com/mido/mido

def _defmsg(status_byte, type_, value_names, length):
    return {
        'status_byte': status_byte,
        'type': type_,
        'value_names': value_names,
        'attribute_names': set(value_names) | {'type', 'time'},
        'length': length,
    }

# from modo mido/messages/specs.py
SPECS = [
    # https://www.recordingblogs.com/wiki/midi-voice-messages
    # MIDI voice messages
    _defmsg(0x80, 'note_off', ('channel', 'note', 'velocity'), 3),
    _defmsg(0x90, 'note_on', ('channel', 'note', 'velocity'), 3),
    _defmsg(0xa0, 'polytouch', ('channel', 'note', 'value'), 3),
    _defmsg(0xb0, 'control_change', ('channel', 'control', 'value'), 3),
    _defmsg(0xc0, 'program_change', ('channel', 'program',), 2),
    _defmsg(0xd0, 'aftertouch', ('channel', 'value',), 2),
    _defmsg(0xe0, 'pitchwheel', ('channel', 'pitch',), 3),

    # https://www.recordingblogs.com/wiki/midi-system-common-messages
    # System common messages.
    # 0xf4 and 0xf5 are undefined.
    _defmsg(0xf0, 'sysex', ('data',), float('inf')),
    _defmsg(0xf1, 'quarter_frame', ('frame_type', 'frame_value'), 2),
    _defmsg(0xf2, 'songpos', ('pos',), 3),
    _defmsg(0xf3, 'song_select', ('song',), 2),
    _defmsg(0xf6, 'tune_request', (), 1),

    # https://www.recordingblogs.com/wiki/midi-system-realtime-messages
    # System real time messages.
    # 0xf9 and 0xfd are undefined.
    _defmsg(0xf8, 'clock', (), 1),
    _defmsg(0xfa, 'start', (), 1),
    _defmsg(0xfb, 'continue', (), 1),
    _defmsg(0xfc, 'stop', (), 1),
    _defmsg(0xfe, 'active_sensing', (), 1),

    # https://www.recordingblogs.com/wiki/midi-meta-messages
    # MIDI meta messages
    _defmsg(0xff, 'reset', (), 1),
]

def _meta_defmsg(Meta_type,Message):
    return {
        'Message': Message,
        'Metatype': Meta_type,
    }

META_EVENT_SPEC = [
    _meta_defmsg(0x00, "Sequence number"),
    _meta_defmsg(0x01, "Text"),
    _meta_defmsg(0x02, "Copyright notice"),
    _meta_defmsg(0x03, "Track name"),
    _meta_defmsg(0x04, "Instrument name"),
    _meta_defmsg(0x05, "Lyrics"),
    _meta_defmsg(0x06, "Marker"),
    _meta_defmsg(0x07, "Cue point"),
    _meta_defmsg(0x20, "Channel prefix"),
    _meta_defmsg(0x2F, "End of track"),
    _meta_defmsg(0x51, "Set tempo"),
    _meta_defmsg(0x54, "SMPTE offset"),
    _meta_defmsg(0x58, "Time signature"),
    _meta_defmsg(0x59, "Key signature"),
    _meta_defmsg(0x7F, "Sequencer specific")
]

class mid_file():

    # https://faydoc.tripod.com/formats/mid.htm
    # https://github.com/mido/mido/blob/main/mido/midifiles/midifiles.py
    def __init__(self):
        self.ticks_per_beat = None
        self.trackcount = None
        self.type = None
        self.note_str = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']

    def get_note_number_str(self, nnumber):
        octave = int(nnumber / len(self.note_str))
        note_ = self.note_str[nnumber % len(self.note_str)]
        return note_+str(octave)

    def read_variable_int(self, byte_data, pos):
        delta = 0
        i = 0
        while True:
            byte = np.uint8(byte_data[i+pos])
            delta = (delta << 7) | (byte & 0x7f)
            if byte < 0x80:
                return delta, i+1
            i = i + 1

    def process_chunk(self, type, length, body):
        if type == 'MThd':
            ''' 
            * type (2)
              0:single-track
              1:multiple tracks, synchronous
              2:multiple tracks, asynchronous
            * trackcount (2)
            * ticks_per_beat (2)
              is the number of delta-time ticks per quarter note.
              4분 노트(음표)당 델타-타임 틱들의 갯수
              https://www.recordingblogs.com/wiki/header-chunk-of-a-midi-file
              16비트의 상위 비트가 0이면 "박자당 틱"(또는 "4분음표당 펄스(틱수)")입니다. 
              상위 비트가 1이면 시간 분할은 "초당 프레임 수"입니다.
            '''
            self.type = int.from_bytes(body[0:2].tobytes(), 'big')
            self.trackcount = int.from_bytes(body[2:4].tobytes(), 'big')
            self.ticks_per_beat = int.from_bytes(body[4:6].tobytes(), 'big')
            print(self.type, self.trackcount, self.ticks_per_beat)
        elif type == 'MTrk':
            # time delta + event type(status_byte) + data
            # time delta :
            #    - 가변길이로 127 보다 추가 byte 사용 : 7bit로 구성된 길이 사용(big endian)
            # event type (status_byte):
            #    - 0x00 ~ 0x7F : Running Status
            #         - same status_byte
            #    - 0x8? ~ 0xE? : Midi messages
            #         - SPECS
            #    - 0xF0, 0xF7 : 일단 처리 안함
            #         - data 가변 길이(7bit) + data
            #    - 0xF0 ~ 0xFE : System messages
            #         - SPECS
            #    - 0xFF : Meta messages (see, read_meta_message in mido )
            #         - command + data 가변 길이(7bit) + data
            pos = 0
            last_status_byte = None
            while True:
                time_delta, delta_size = self.read_variable_int(body, pos)
                pos = pos + delta_size
                print(f"delta {pos} {time_delta}(0x{time_delta:02x}) {delta_size}")

                status_byte = np.uint8(body[pos])
                pos = pos + 1

                if status_byte <= 0x7F:
                    status_byte = last_status_byte
                    pos = pos - 1

                if status_byte == 0xFF:
                    # https://www.recordingblogs.com/wiki/midi-meta-messages
                    # Meta messages
                    command = np.uint8(body[pos])
                    if command == 0x2F:
                        print("End of track")
                        return
                    pos = pos + 1
                    data_delta, delta_size = self.read_variable_int(body, pos)
                    pos = pos + delta_size
                    data = body[pos:pos+data_delta]
                    pos = pos + data_delta
                    find = False
                    for spec_data in META_EVENT_SPEC:
                        if command == spec_data['Metatype']:
                            print(f"Meta message {spec_data['Message']} FF {command:02x} {data} {data_delta:02x} {delta_size:02x}")
                            find = True
                            break
                    if find == False:
                        print(f"error metatype:{command:02x}" )
                        #exit(-1)

                elif status_byte == 0xF0 or status_byte == 0xF7:
                    # 일단 처리 안함
                    data_delta, delta_size = self.read_variable_int(body, pos)
                    pos = pos + delta_size
                    data = body[pos:pos + data_delta]
                    pos = pos + data_delta
                    print(f"skip messages {data_delta:02x} {data} {delta_size:02x}")
                else:
                    pos_len = 1
                    find = False
                    for spec_data in SPECS:
                        if status_byte <= 0xEF and spec_data['status_byte'] == (status_byte & 0xF0):
                            # status_byte <= 0xEF 작은 조건은 Midi messages 가 됩니다.
                            # Midi messages
                            if spec_data['type'] == 'note_on' or spec_data['type'] == 'note_off' :
                                ch = status_byte & 0x0F
                                nn = body[pos]
                                vv = body[pos+1]
                                print(f"{spec_data['type']} {status_byte:02x} {ch} {self.get_note_number_str(nn)} {vv}")
                            else:
                                print(f"{status_byte:02x} {spec_data['type']} {spec_data['length']}")
                            pos_len = spec_data['length'] - 1
                            find = True
                            break
                        elif spec_data['status_byte'] == status_byte:
                            # status_byte > 0xEF 이면 System messages 가 된다.
                            # System messages
                            print(f"{status_byte:02x} {spec_data['type']} {spec_data['length']}")
                            pos_len = spec_data['length'] - 1
                            find = True
                            break
                    if find == False:
                        print(f"error status_byte:{status_byte}" )
                        exit(-1)
                    pos = pos + pos_len
                last_status_byte = status_byte
                if pos >= length:
                    return

    def read(self, filename):
        with open(filename) as f:
            rectype = np.dtype(np.uint8)
            bdata = np.fromfile(f, dtype=rectype)
        # MID 파일은 chunk의 연속으로 되어 있습니다.
        # Chunk Type(4), chunk body length (4), body(chunk body length)
        read_pos = 0
        while True:
            chunk_type = bdata[read_pos:read_pos+4].tobytes()
            chunk_type = chunk_type.decode('utf-8')
            chunk_length = int.from_bytes(bdata[read_pos+4:read_pos+8].tobytes(), 'big')

            print(chunk_type, chunk_length)

            self.process_chunk(chunk_type, chunk_length, bdata[read_pos+8:read_pos+8+chunk_length])

            read_pos = read_pos + chunk_length + 8
            if len(bdata) <= read_pos:
                break


if __name__ == "__main__":
    print(SPECS)
    mid = mid_file()
    mid.read("../00_data/tests_for-elise.mid")


그리고 이것을 엘리제를 위하여 mid 파일로 실행한 결과 입니다.

[{'status_byte': 128, 'type': 'note_off', 'value_names': ('channel', 'note', 'velocity'), 'attribute_names': {'type', 'time', 'channel', 'note', 'velocity'}, 'length': 3}, {'status_byte': 144, 'type': 'note_on', 'value_names': ('channel', 'note', 'velocity'), 'attribute_names': {'type', 'time', 'channel', 'note', 'velocity'}, 'length': 3}, {'status_byte': 160, 'type': 'polytouch', 'value_names': ('channel', 'note', 'value'), 'attribute_names': {'type', 'time', 'channel', 'note', 'value'}, 'length': 3}, {'status_byte': 176, 'type': 'control_change', 'value_names': ('channel', 'control', 'value'), 'attribute_names': {'type', 'time', 'channel', 'control', 'value'}, 'length': 3}, {'status_byte': 192, 'type': 'program_change', 'value_names': ('channel', 'program'), 'attribute_names': {'channel', 'type', 'program', 'time'}, 'length': 2}, {'status_byte': 208, 'type': 'aftertouch', 'value_names': ('channel', 'value'), 'attribute_names': {'channel', 'type', 'value', 'time'}, 'length': 2}, {'status_byte': 224, 'type': 'pitchwheel', 'value_names': ('channel', 'pitch'), 'attribute_names': {'channel', 'type', 'time', 'pitch'}, 'length': 3}, {'status_byte': 240, 'type': 'sysex', 'value_names': ('data',), 'attribute_names': {'type', 'time', 'data'}, 'length': inf}, {'status_byte': 241, 'type': 'quarter_frame', 'value_names': ('frame_type', 'frame_value'), 'attribute_names': {'type', 'frame_value', 'frame_type', 'time'}, 'length': 2}, {'status_byte': 242, 'type': 'songpos', 'value_names': ('pos',), 'attribute_names': {'type', 'time', 'pos'}, 'length': 3}, {'status_byte': 243, 'type': 'song_select', 'value_names': ('song',), 'attribute_names': {'song', 'type', 'time'}, 'length': 2}, {'status_byte': 246, 'type': 'tune_request', 'value_names': (), 'attribute_names': {'type', 'time'}, 'length': 1}, {'status_byte': 248, 'type': 'clock', 'value_names': (), 'attribute_names': {'type', 'time'}, 'length': 1}, {'status_byte': 250, 'type': 'start', 'value_names': (), 'attribute_names': {'type', 'time'}, 'length': 1}, {'status_byte': 251, 'type': 'continue', 'value_names': (), 'attribute_names': {'type', 'time'}, 'length': 1}, {'status_byte': 252, 'type': 'stop', 'value_names': (), 'attribute_names': {'type', 'time'}, 'length': 1}, {'status_byte': 254, 'type': 'active_sensing', 'value_names': (), 'attribute_names': {'type', 'time'}, 'length': 1}, {'status_byte': 255, 'type': 'reset', 'value_names': (), 'attribute_names': {'type', 'time'}, 'length': 1}]
MThd 6
1 2 480
MTrk 3909
delta 1 0(0x00) 1
Meta message Time signature FF 58 [ 1  3 24  8] 04 01
delta 9 0(0x00) 1
Meta message Key signature FF 59 [0 0] 02 01
delta 15 0(0x00) 1
Meta message Set tempo FF 51 [ 12 183  53] 03 01
delta 22 0(0x00) 1
b0 control_change 3
delta 26 0(0x00) 1
c0 program_change 2
delta 29 0(0x00) 1
b0 control_change 3
delta 33 0(0x00) 1
b0 control_change 3
delta 36 0(0x00) 1
b0 control_change 3
delta 39 0(0x00) 1
b0 control_change 3
delta 42 0(0x00) 1
error metatype:21
delta 47 0(0x00) 1
note_on 90 0 E6 33
delta 51 113(0x71) 1
note_on 90 0 E6 0
delta 54 7(0x07) 1
note_on 90 0 D#6 33
delta 57 113(0x71) 1
note_on 90 0 D#6 0
delta 60 7(0x07) 1

양이 많아서 뒷부분을 생략하였습니다.

앞부분 약간 해석 해보면 다음과 같습니다.

소리를 만들어내는 note_on 만 주목해서 보도록 합니다.

 delta를 누적 해보면 첫번째 detla가 0에서 E6의 소리를 내고 113 delta뒤에 E6을 0으로 소리를 끄도록 되어 있습니다.

즉 note_on -> note_off로 제어하는 방식이 아니라 note_on 볼륨을 줄여서 소리를 끄는 방식을 사용하고 있습니다.


댓글 없음:

댓글 쓰기