Files
ITOSMPiano-BachToBasics/backend/tools/generate_beginner_midis.py
T
이창현 89f66c3bee
CI / Frontend - type-check & test (push) Canceled after 0s
초보자용 1분 양손 MIDI 연습곡 다섯 개를 추가함
직접 만든 연습곡 다섯 파일을 공개 보관함에 등록했다. 별빛 계단은 입문과 쉬움 두 단계로 제공한다.

MIDI 파서 검사와 백엔드 단위 테스트 5개를 통과했다. 운영 백업은 /home/syskiller/piano/backups/bach-to-basics/20261009-beginner-midi 에 있다. 백엔드만 갱신했고 비회원 목록과 다운로드를 확인했다. 실제 USB MIDI 장치 검사는 남아 있다. 사전 훅은 로컬 pnpm 의존성 불일치로 실행되지 않아 관련 검사를 직접 수행했다.
2026-10-09 23:06:17 +09:00

181 lines
7.4 KiB
Python

"""초보자용 양손 연습곡을 두 트랙 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()