CI / Frontend - type-check & test (push) Canceled after 0s
직접 만든 연습곡 다섯 파일을 공개 보관함에 등록했다. 별빛 계단은 입문과 쉬움 두 단계로 제공한다. MIDI 파서 검사와 백엔드 단위 테스트 5개를 통과했다. 운영 백업은 /home/syskiller/piano/backups/bach-to-basics/20261009-beginner-midi 에 있다. 백엔드만 갱신했고 비회원 목록과 다운로드를 확인했다. 실제 USB MIDI 장치 검사는 남아 있다. 사전 훅은 로컬 pnpm 의존성 불일치로 실행되지 않아 관련 검사를 직접 수행했다.
181 lines
7.4 KiB
Python
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()
|