"""초보자용 양손 연습곡을 두 트랙 MIDI로 다시 만든다.""" from __future__ import annotations from pathlib import Path OUTPUT = Path(__file__).resolve().parents[1] / "sample_midi" TICKS = 480 Note = tuple[int, int, int, int] def variable_length(value: int) -> bytes: if value < 0: raise ValueError("MIDI 시간 간격은 음수일 수 없습니다") data = [value & 0x7F] value >>= 7 while value: data.insert(0, (value & 0x7F) | 0x80) value >>= 7 return bytes(data) def make_track(name: str, notes: list[Note], channel: int, tempo: int | None = None, meter: int = 4) -> bytes: events: list[tuple[int, int, bytes]] = [] if tempo is not None: events.append((0, 0, b"\xff\x51\x03" + tempo.to_bytes(3, "big"))) events.append((0, 0, bytes((0xFF, 0x58, 0x04, meter, 2, 24, 8)))) label = name.encode("utf-8") events.append((0, 0, bytes((0xFF, 0x03, len(label))) + label)) events.append((0, 0, bytes((0xC0 | channel, 0)))) for start, duration, pitch, velocity in notes: if start < 0 or duration <= 0 or not 21 <= pitch <= 108: raise ValueError("연습곡의 음표 범위가 올바르지 않습니다") events.append((start, 2, bytes((0x90 | channel, pitch, velocity)))) # 같은 음을 연달아 연주할 때 앞 음을 먼저 끝내야 브라우저에서 구분된다. events.append((start + duration, 1, bytes((0x80 | channel, pitch, 0)))) events.sort(key=lambda event: (event[0], event[1], event[2])) body = bytearray() previous = 0 for tick, _, message in events: body.extend(variable_length(tick - previous)) body.extend(message) previous = tick body.extend(b"\x00\xff\x2f\x00") return b"MTrk" + len(body).to_bytes(4, "big") + body def write(name: str, right: list[Note], left: list[Note], bpm: int, meter: int = 4) -> None: if not right or not left: raise ValueError("양손 연습곡에는 양쪽 손의 음표가 모두 필요합니다") header = b"MThd" + (6).to_bytes(4, "big") + (1).to_bytes(2, "big") + (2).to_bytes(2, "big") + TICKS.to_bytes(2, "big") song = header + make_track("Right Hand", right, 0, round(60_000_000 / bpm), meter) + make_track("Left Hand", left, 1) (OUTPUT / name).write_bytes(song) def melody_bars(bars: list[list[tuple[int, float]]], meter: int) -> list[Note]: notes: list[Note] = [] for index, bar in enumerate(bars): offset = 0 for pitch, beats in bar: duration = round(beats * TICKS) notes.append((index * meter * TICKS + offset, max(1, duration - 24), pitch, 82)) offset += duration if offset != meter * TICKS: raise ValueError("한 마디의 박자가 맞지 않습니다") return notes def starlight(easy: bool) -> tuple[list[Note], list[Note]]: opening = [ [(64, 1), (67, 1), (72, 2)], [(69, 1), (67, 1), (64, 2)], [(65, 1), (69, 1), (72, 2)], [(71, 1), (69, 1), (67, 2)], [(64, 1), (67, 1), (72, 2)], [(76, 1), (72, 1), (69, 2)], [(72, 1), (71, 1), (69, 2)], [(67, 1), (64, 1), (60, 2)], ] middle = [ [(67, 1), (72, 1), (76, 2)], [(72, 1), (69, 1), (64, 2)], [(69, 1), (72, 1), (77, 2)], [(74, 1), (71, 1), (67, 2)], [(64, 1), (67, 1), (72, 2)], [(72, 1), (69, 1), (64, 2)], [(67, 1), (71, 1), (74, 2)], [(72, 1), (67, 1), (64, 2)], ] ending = opening[:-1] + [[(67, 1), (64, 1), (60, 2)]] right = melody_bars(opening + middle + ending, 4) chords = [ (48, 52, 55), (45, 48, 52), (41, 45, 48), (43, 47, 50), (48, 52, 55), (45, 48, 52), (41, 45, 48), (48, 52, 55), ] * 3 left: list[Note] = [] for bar, (root, third, fifth) in enumerate(chords): start = bar * 4 * TICKS if easy: for beat, pitch in enumerate((root, fifth, third, fifth)): left.append((start + beat * TICKS, TICKS - 32, pitch, 65)) else: left.extend(((start, 2 * TICKS - 32, root, 70), (start + 2 * TICKS, 2 * TICKS - 32, fifth, 64))) return right, left def evening_waltz() -> tuple[list[Note], list[Note]]: opening = [ [(67, 1), (69, 1), (72, 1)], [(71, 1), (69, 1), (67, 1)], [(64, 1), (67, 1), (72, 1)], [(69, 1), (67, 1), (64, 1)], [(65, 1), (69, 1), (72, 1)], [(74, 1), (72, 1), (69, 1)], [(67, 1), (71, 1), (74, 1)], [(72, 1), (67, 1), (60, 1)], ] middle = [ [(72, 1), (74, 1), (76, 1)], [(74, 1), (71, 1), (67, 1)], [(69, 1), (72, 1), (76, 1)], [(72, 1), (69, 1), (64, 1)], [(72, 1), (69, 1), (65, 1)], [(69, 1), (72, 1), (74, 1)], [(74, 1), (71, 1), (67, 1)], [(72, 1), (67, 1), (60, 1)], ] right = melody_bars(opening + middle + opening + opening, 3) basses = [(48, 55), (43, 50), (45, 52), (48, 55), (41, 48), (45, 52), (43, 50), (48, 55)] * 4 left: list[Note] = [] for bar, (root, fifth) in enumerate(basses): start = bar * 3 * TICKS left.extend(((start, TICKS - 32, root, 71), (start + TICKS, TICKS - 32, fifth, 60), (start + 2 * TICKS, TICKS - 32, fifth, 60))) return right, left def sunrise_arpeggio() -> tuple[list[Note], list[Note]]: chords = [(60, 64, 67, 72), (57, 60, 64, 69), (53, 57, 60, 65), (55, 59, 62, 67), (60, 64, 67, 72), (57, 60, 64, 69), (53, 57, 60, 65), (60, 64, 67, 72)] * 2 + [ (53, 57, 60, 65), (55, 59, 62, 67), (57, 60, 64, 69), (60, 64, 67, 72), ] right: list[Note] = [] left: list[Note] = [] for bar, tones in enumerate(chords): start = bar * 4 * TICKS # 한 손은 네 음을 차례로 누르고 다른 손은 긴 근음 하나만 유지한다. for beat, pitch in enumerate(tones): right.append((start + beat * TICKS, TICKS - 36, pitch, 75 + beat * 3)) left.append((start, 4 * TICKS - 48, tones[0] - 12, 62)) return right, left def black_keys() -> tuple[list[Note], list[Note]]: opening = [ [(66, 1), (68, 1), (70, 2)], [(73, 1), (70, 1), (68, 2)], [(66, 1), (70, 1), (73, 2)], [(75, 1), (73, 1), (70, 2)], [(68, 1), (70, 1), (73, 2)], [(75, 1), (73, 1), (70, 2)], [(68, 1), (66, 1), (70, 2)], [(73, 1), (70, 1), (66, 2)], ] middle = [ [(70, 1), (73, 1), (75, 2)], [(78, 1), (75, 1), (73, 2)], [(70, 1), (68, 1), (66, 2)], [(68, 1), (70, 1), (73, 2)], [(75, 1), (73, 1), (70, 2)], [(68, 1), (70, 1), (75, 2)], [(73, 1), (70, 1), (68, 2)], [(66, 1), (68, 1), (70, 2)], ] right = melody_bars(opening + middle + opening, 4) left: list[Note] = [] for bar in range(24): start = bar * 4 * TICKS left.extend(((start, 2 * TICKS - 48, 42, 61), (start + 2 * TICKS, 2 * TICKS - 48, 49, 59))) return right, left def main() -> None: OUTPUT.mkdir(parents=True, exist_ok=True) for name, song, bpm, meter in ( ("starlight-steps-beginner.mid", starlight(False), 76, 4), ("starlight-steps-easy.mid", starlight(True), 84, 4), ("evening-waltz-beginner.mid", evening_waltz(), 90, 3), ("sunrise-arpeggio-easy.mid", sunrise_arpeggio(), 72, 4), ("black-key-pentatonic-beginner.mid", black_keys(), 78, 4), ): write(name, *song, bpm, meter) if __name__ == "__main__": main()