From 89f66c3beee3ec51d02c55c77fdb932c3973628f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=B0=BD=ED=98=84?= Date: Fri, 9 Oct 2026 23:06:17 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B4=88=EB=B3=B4=EC=9E=90=EC=9A=A9=201?= =?UTF-8?q?=EB=B6=84=20=EC=96=91=EC=86=90=20MIDI=20=EC=97=B0=EC=8A=B5?= =?UTF-8?q?=EA=B3=A1=20=EB=8B=A4=EC=84=AF=20=EA=B0=9C=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 직접 만든 연습곡 다섯 파일을 공개 보관함에 등록했다. 별빛 계단은 입문과 쉬움 두 단계로 제공한다. MIDI 파서 검사와 백엔드 단위 테스트 5개를 통과했다. 운영 백업은 /home/syskiller/piano/backups/bach-to-basics/20261009-beginner-midi 에 있다. 백엔드만 갱신했고 비회원 목록과 다운로드를 확인했다. 실제 USB MIDI 장치 검사는 남아 있다. 사전 훅은 로컬 pnpm 의존성 불일치로 실행되지 않아 관련 검사를 직접 수행했다. --- PROJECT_MEMORY.md | 10 + README.md | 2 +- backend/library_db.py | 50 +++++ .../black-key-pentatonic-beginner.mid | Bin 0 -> 1166 bytes .../sample_midi/evening-waltz-beginner.mid | Bin 0 -> 1814 bytes .../sample_midi/starlight-steps-beginner.mid | Bin 0 -> 1166 bytes backend/sample_midi/starlight-steps-easy.mid | Bin 0 -> 1598 bytes backend/sample_midi/sunrise-arpeggio-easy.mid | Bin 0 -> 986 bytes backend/tests/test_library_db.py | 20 ++ backend/tools/generate_beginner_midis.py | 180 ++++++++++++++++++ docs/LIBRARY-AUTH-KO.md | 2 +- docs/THIRD_PARTY_SAMPLES.md | 14 ++ scripts/check_beginner_midis.mjs | 32 ++++ 수정기록.txt | 11 ++ 14 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 backend/sample_midi/black-key-pentatonic-beginner.mid create mode 100644 backend/sample_midi/evening-waltz-beginner.mid create mode 100644 backend/sample_midi/starlight-steps-beginner.mid create mode 100644 backend/sample_midi/starlight-steps-easy.mid create mode 100644 backend/sample_midi/sunrise-arpeggio-easy.mid create mode 100644 backend/tools/generate_beginner_midis.py create mode 100644 scripts/check_beginner_midis.mjs diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index cb6afc5..cf0702d 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -15,6 +15,16 @@ - NoteRain은 최종 학습 서비스로 안내하지 않는다. 별도 종료 지시가 없으므로 기존 운영 상태 변경은 별도 작업으로 판단한다. - Discourse 포럼 작업방에 소개와 용도별 게시 공간 구성을 요청했다. 실제 포럼 반영 결과는 해당 방에서 확인한다. +## 2026-10-09 초보자용 MIDI 연습곡 + +- 직접 만든 양손 피아노 연습곡 다섯 MIDI를 공개 곡 보관함에 추가했다. `별빛 계단`은 입문과 쉬움 두 버전을 같은 제목 아래에서 선택한다. 나머지는 `저녁빛 왈츠`, `해돋이 아르페지오`, `검은 건반 산책`이다. +- 각 MIDI는 약 64~76초 길이다. 왼손 근음·5도 교대와 분산 화음 및 3박자 왈츠와 오른손 아르페지오와 검은 건반 5음 음계를 연습한다. +- 영상의 곡을 복제하지 않았다. 분산 화음과 아르페지오 기법 참고 강좌의 제작자와 링크를 `docs/THIRD_PARTY_SAMPLES.md`에 기록했다. +- `python backend/tools/generate_beginner_midis.py`로 파일을 재생성하고 `node scripts/check_beginner_midis.mjs`로 두 손 트랙과 길이 및 박자를 검사한다. +- 운영 DB와 수정 대상 소스의 백업은 `/home/syskiller/piano/backups/bach-to-basics/20261009-beginner-midi`에 있다. DB 변경은 서버 시작 시 새 기본 공개 곡 5건을 등록한 것뿐이며 회원 자료 이전은 없었다. +- 개발 환경에서 MIDI 파서 검사와 백엔드 단위 테스트 5개를 통과했다. 운영 HTTPS 첫 화면과 비회원 공개 목록 및 MIDI 파일 5개 다운로드를 확인했다. 기존 회원 업로드 2개를 보존했고 백엔드만 갱신했다. 프런트엔드는 재시작하지 않았다. +- 실제 USB MIDI 장치로 다섯 곡의 손별 따라치기를 확인하는 작업은 남아 있다. + ## 2026-10-03 현재 상태 - Docker의 `piano-bach-frontend`와 `piano-bach-backend`로 운영한다. diff --git a/README.md b/README.md index a37a3d1..4c49e1d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ A browser-based piano practice tool. Drop in a **MIDI file**, a **MusicXML score 이 저장소는 원본 Bach to Basics를 바탕으로 `learn.itosm.co.kr`에서 운영하는 한국어판이다. 독립 회원과 곡·악보 보관함을 제공한다. 비회원은 공개 곡을 연주할 수 있으며 회원은 MIDI와 MusicXML과 MXL 및 PDF 악보를 올려 공개 범위와 회원 등급을 지정할 수 있다. -기본 공개 자료는 Mutopia Project의 공개 도메인 MIDI와 그 원곡에서 만든 단계별 연습 편곡이다. `엘리제를 위하여 WoO 59`는 입문과 쉬움과 고급 버전을 같은 제목 아래에서 선택한다. 출처와 편곡 범위는 [기본 공개 샘플 출처](docs/THIRD_PARTY_SAMPLES.md)에 기록했다. 회원과 운영 구성은 [회원과 곡 보관함 운영 문서](docs/LIBRARY-AUTH-KO.md)를 따른다. +기본 공개 자료는 Mutopia Project의 공개 도메인 MIDI와 그 원곡에서 만든 단계별 연습 편곡 및 자체 제작한 약 1분 길이의 초보자용 양손 MIDI다. `엘리제를 위하여 WoO 59`와 `별빛 계단`은 난이도가 다른 버전을 같은 제목 아래에서 선택한다. 출처와 편곡 범위 및 연주 기법 참고 자료는 [기본 공개 샘플 출처](docs/THIRD_PARTY_SAMPLES.md)에 기록했다. 회원과 운영 구성은 [회원과 곡 보관함 운영 문서](docs/LIBRARY-AUTH-KO.md)를 따른다. [![Bach to Basics demo](docs/demo.gif)](https://raw.githubusercontent.com/gigliof/bach-to-basics/main/docs/demo.gif) diff --git a/backend/library_db.py b/backend/library_db.py index 638a8f4..4fa95f4 100644 --- a/backend/library_db.py +++ b/backend/library_db.py @@ -108,6 +108,56 @@ def delete_session(token: str | None) -> None: SAMPLES = ( + { + "storage_name": "starlight-steps-beginner.mid", + "title": "별빛 계단", + "artist": "ITOSM 피아노 학습실", + "description": "약 76초 분량의 직접 만든 입문곡입니다. 오른손은 간단한 선율을 치고 왼손은 근음과 5도를 번갈아 누릅니다. 분산 화음 기법 참고: Bill Hilton의 유튜브 강좌(8AMZph76kqM).", + "difficulty": "beginner", + "genre": "연습곡", + "arrangement": "piano_solo", + "tags": ["양손", "입문", "분산 화음", "근음", "5도", "자체 제작"], + }, + { + "storage_name": "starlight-steps-easy.mid", + "title": "별빛 계단", + "artist": "ITOSM 피아노 학습실", + "description": "같은 선율의 약 69초 분량 쉬운 버전입니다. 왼손 네 음을 차례로 펼쳐 소리를 풍성하게 만듭니다. 분산 화음 기법 참고: Bill Hilton의 유튜브 강좌(8AMZph76kqM).", + "difficulty": "easy", + "genre": "연습곡", + "arrangement": "piano_solo", + "tags": ["양손", "쉬움", "분산 화음", "단계별 연습", "자체 제작"], + }, + { + "storage_name": "evening-waltz-beginner.mid", + "title": "저녁빛 왈츠", + "artist": "ITOSM 피아노 학습실", + "description": "약 64초 분량의 직접 만든 왈츠입니다. 왼손은 낮은 음 하나와 같은 음 두 번을 반복하고 오른손은 한 박에 한 음씩 연주합니다.", + "difficulty": "beginner", + "genre": "왈츠", + "arrangement": "piano_solo", + "tags": ["양손", "입문", "3박자", "왈츠", "자체 제작"], + }, + { + "storage_name": "sunrise-arpeggio-easy.mid", + "title": "해돋이 아르페지오", + "artist": "ITOSM 피아노 학습실", + "description": "약 67초 분량의 직접 만든 연습곡입니다. 오른손 네 음을 차례로 누르는 동안 왼손은 낮은 근음 하나만 유지합니다. 아르페지오 기법 참고: Bill Hilton의 유튜브 강좌(8AMZph76kqM).", + "difficulty": "easy", + "genre": "연습곡", + "arrangement": "piano_solo", + "tags": ["양손", "쉬움", "아르페지오", "근음", "자체 제작"], + }, + { + "storage_name": "black-key-pentatonic-beginner.mid", + "title": "검은 건반 산책", + "artist": "ITOSM 피아노 학습실", + "description": "약 74초 분량의 직접 만든 입문곡입니다. 검은 건반 다섯 음만으로 선율을 만들고 왼손은 두 음을 번갈아 누릅니다.", + "difficulty": "beginner", + "genre": "연습곡", + "arrangement": "piano_solo", + "tags": ["양손", "입문", "검은 건반", "5음 음계", "자체 제작"], + }, { "storage_name": "minuet-bwv-anh-114-beginner.mid", "title": "미뉴에트 사장조 BWV Anh. 114", diff --git a/backend/sample_midi/black-key-pentatonic-beginner.mid b/backend/sample_midi/black-key-pentatonic-beginner.mid new file mode 100644 index 0000000000000000000000000000000000000000..c31ddf0b9033f840a6176e359fd2f98219f26335 GIT binary patch literal 1166 zcmeYb$w*;fU|?flWME=^;2Tnu&A`C4hT#AM!+&P3pv?4)5(SUMycCB2fy~@{&NKXv zU}0gB;9!{G6x8g|;KU#?!3D%{0W#cz+BF*7fDBKNh$mPC$N-7BfTTcb;JVyEY9NY% zQXs|NAQ5jM14%p7KqM>3(S>9wP8T|XTnO?GlEZ*@K^z8@0y)eN`#-NEWhX48ukTlD9 ogyA44&2svrra_bJL@nEPfkrI`gNcULK!zcZF)A{6Ljx3-0Euf=6#xJL literal 0 HcmV?d00001 diff --git a/backend/sample_midi/evening-waltz-beginner.mid b/backend/sample_midi/evening-waltz-beginner.mid new file mode 100644 index 0000000000000000000000000000000000000000..2ee38f21aa4a7a4b0800d5d95caf236939e61677 GIT binary patch literal 1814 zcmeH`L2ANa5QRr0rS$|Z-AXD35!_@E5lN7^sJL)ju&t%gMezU?djP#rdz0QIodNmy zAuzZsrMt(xH*enj$t+gejfh-HLz<26#p>-vB>Ip~kt%90pP#n7ZgKy*k?JmL-^Nm{ zTT!!fEywBdP`pp2bDSBNDJTpS3UUK^NFmOQ%9Nr)7d}vkC)PCy4e6R2HCKvC-G@AN zF|$(hNkbpRrBS6)R4}BSp~Rqz3L5sRQ_vqbh*<{;F;S_|<%loY!to&w zn<6I0AKy!=ft+t~Ctr4pH|4|Q`4mq{e#rKTbWh_{!8io5QL$ErMh!#IH>$6dkx`=% uIEFpz>Zv%fE*lDP44oLxEQfLoofwYc|6bt_;5@JMh7RQz{`T)>xAy}TKpgV` literal 0 HcmV?d00001 diff --git a/backend/sample_midi/starlight-steps-beginner.mid b/backend/sample_midi/starlight-steps-beginner.mid new file mode 100644 index 0000000000000000000000000000000000000000..6cf2f29395b65f7259cd57acf75112f16294c135 GIT binary patch literal 1166 zcmeHFF-ikb6r9}zQR4wpZfOX?B?Qu>*s#QWmfa#t3R@v+LLh~BfQUufKRlSPY0*S=bFTZ{EE3-_NG=8Gs{r@V)nJdcOqxXS@JK?KEH9%^w1Hy_%tz)Xo|o zD6Z;tzj=&pls~z3gyuFThz+=WbG~*4Mr1}+DWb&UA*ErpMy@fXaaD@xV&`wNQo2&B zaRmExb1_{^rE@81W_2DgYhW%(vZs{3L|7Ihw3k+vE2As3nuLQT1~kS`_EH~pR+c7I z!-QFkkV`dkX;Vphc|}N)>#yGn#Rc|%)_cP%|LjS6b6bAdU3j?(9zz7Xeq^9;phKo( nphu=>phc!-ASA=$Hkr18E}1T+U-db39Wwv(*z=ax;VnM_2gp_U literal 0 HcmV?d00001 diff --git a/backend/sample_midi/starlight-steps-easy.mid b/backend/sample_midi/starlight-steps-easy.mid new file mode 100644 index 0000000000000000000000000000000000000000..083503525d6ba0c28583537c53207aa41a85ae11 GIT binary patch literal 1598 zcmeHGF-ikb6rAh^(VQSdGp@ezr|v;1aN`|g2sEXc)SOK7rX*h*edUCSL=?udRU^Ghpmqb zRF}O5iA#9X3e&q;lWuq;MsFRd(BL04g#jDuwcG)A4h)JL6_r3uwA zV-_Ril146VDoU4EgebXw`}aZ>;qYd|HQxAU&+_Y=9(n1oz_rGRTP-UrP_ zF-10Z|2n_UJ9Ex)y>TBv3=t@iv+K>LfO5eB@T1AjWHer6oZhStza35Ob@*LXRnl>6 z&HCD0n#gV|O^J$x-IlN=98bcLDASZBOqv^}A+FKXBy<{`nC6CQh^sVJi4sjo!k{rE zEE-F~rs2?EeovTLVaz|D&$sS9%o)DF3;0Fc-#$(_^6e)YgW==p?=)-ET4~UB1x7(4 a$H+ None: + names = { + "starlight-steps-beginner.mid", + "starlight-steps-easy.mid", + "evening-waltz-beginner.mid", + "sunrise-arpeggio-easy.mid", + "black-key-pentatonic-beginner.mid", + } + with self.db.connect() as connection: + rows = connection.execute( + "SELECT seed_key, visibility, min_tier, owner_id FROM midi_items WHERE seed_key IS NOT NULL" + ).fetchall() + matching = {row["seed_key"]: row for row in rows if row["seed_key"] in names} + self.assertEqual(set(matching), names) + for name, row in matching.items(): + self.assertEqual(row["visibility"], "public") + self.assertEqual(row["min_tier"], 0) + self.assertIsNone(row["owner_id"]) + self.assertTrue(Path(self.temp.name, "midi", name).is_file()) + if __name__ == "__main__": unittest.main() diff --git a/backend/tools/generate_beginner_midis.py b/backend/tools/generate_beginner_midis.py new file mode 100644 index 0000000..a15c12d --- /dev/null +++ b/backend/tools/generate_beginner_midis.py @@ -0,0 +1,180 @@ +"""초보자용 양손 연습곡을 두 트랙 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() diff --git a/docs/LIBRARY-AUTH-KO.md b/docs/LIBRARY-AUTH-KO.md index e6008a0..399fe27 100644 --- a/docs/LIBRARY-AUTH-KO.md +++ b/docs/LIBRARY-AUTH-KO.md @@ -74,7 +74,7 @@ Mutopia Project가 공개 도메인으로 배포하는 실제 클래식 MIDI를 - 미뉴에트 내림마장조 WoO 82: 쉬운 양손 편곡과 중급 양손 원곡 - 엘리제를 위하여 WoO 59: 입문 양손과 쉬운 양손 및 고급 원곡 -같은 제목의 난이도별 버전은 한 묶음에서 선택한다. 기본 단계별 연습곡은 모두 오른손과 왼손을 포함한 피아노 독주로 제공한다. 손별 연습은 연습 화면에서 선택한다. 공개 도메인 원곡에서 만든 단계별 연습 편곡은 `scripts/build_sample_variants.mjs`로 다시 만들 수 있다. +같은 제목의 난이도별 버전은 한 묶음에서 선택한다. 기본 단계별 연습곡은 모두 오른손과 왼손을 포함한 피아노 독주로 제공한다. 손별 연습은 연습 화면에서 선택한다. 공개 도메인 원곡에서 만든 단계별 연습 편곡은 `scripts/build_sample_variants.mjs`로 다시 만들 수 있다. 자체 제작한 약 1분 길이의 초보자용 MIDI 다섯 파일은 `python backend/tools/generate_beginner_midis.py`로 다시 만들 수 있다. ## 악보 표시 diff --git a/docs/THIRD_PARTY_SAMPLES.md b/docs/THIRD_PARTY_SAMPLES.md index be5fbf8..27dcdbf 100644 --- a/docs/THIRD_PARTY_SAMPLES.md +++ b/docs/THIRD_PARTY_SAMPLES.md @@ -25,3 +25,17 @@ Mutopia Project의 배포 안내는 | 엘리제를 위하여 WoO 59 | 고급 | 전체 양손 원곡 | `fur-elise.mid` | 편곡 파일은 `node scripts/build_sample_variants.mjs`로 다시 만들 수 있다. 원본 MIDI가 바뀌면 생성 결과도 달라지므로 반영 전에 파일 크기와 음표 수와 실제 재생을 다시 검사한다. + +## 자체 제작 초보자 연습곡 + +아래 다섯 파일의 선율과 반주는 이 프로젝트에서 새로 구성했다. 기존 유튜브 영상의 곡과 MIDI 파일을 옮긴 자료가 아니다. 모든 파일은 오른손과 왼손을 나눈 양손 MIDI이며 한 곡이 약 1분 이상 이어진다. 손별 연습은 연습 화면에서 선택한다. + +| 표시 제목 | 난이도 | 익히는 기법 | 파일 | +| --- | --- | --- | --- | +| 별빛 계단 | 입문 | 왼손 근음과 5도 교대 | `starlight-steps-beginner.mid` | +| 별빛 계단 | 쉬움 | 왼손 네 음 분산 화음 | `starlight-steps-easy.mid` | +| 저녁빛 왈츠 | 입문 | 3박자 왼손 저음과 반복음 | `evening-waltz-beginner.mid` | +| 해돋이 아르페지오 | 쉬움 | 오른손 네 음 아르페지오와 왼손 긴 근음 | `sunrise-arpeggio-easy.mid` | +| 검은 건반 산책 | 입문 | 검은 건반 5음 음계와 왼손 5도 | `black-key-pentatonic-beginner.mid` | + +분산 화음과 아르페지오 기법을 정리할 때 참고한 강좌는 Bill Hilton의 [Six Piano Broken Chord And Arpeggio Techniques You Need To Know](https://www.youtube.com/watch?v=8AMZph76kqM)다. 이 링크는 기법 참고 출처이며 위 MIDI의 원곡이나 저작권 허가를 뜻하지 않는다. 새 MIDI는 `python backend/tools/generate_beginner_midis.py`로 같은 내용으로 다시 만들 수 있다. diff --git a/scripts/check_beginner_midis.mjs b/scripts/check_beginner_midis.mjs new file mode 100644 index 0000000..8a07b48 --- /dev/null +++ b/scripts/check_beginner_midis.mjs @@ -0,0 +1,32 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import midiPackage from "../frontend/node_modules/@tonejs/midi/build/Midi.js"; + +const { Midi } = midiPackage; +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "backend", "sample_midi"); +const expected = [ + ["starlight-steps-beginner.mid", 4], + ["starlight-steps-easy.mid", 4], + ["evening-waltz-beginner.mid", 3], + ["sunrise-arpeggio-easy.mid", 4], + ["black-key-pentatonic-beginner.mid", 4], +]; + +for (const [name, meter] of expected) { + const midi = new Midi(fs.readFileSync(path.join(root, name))); + const [right, left] = midi.tracks; + if (midi.tracks.length !== 2 || !right?.notes.length || !left?.notes.length) { + throw new Error(`${name}: 오른손과 왼손 트랙이 각각 필요합니다`); + } + if (midi.duration < 60 || midi.duration > 90) { + throw new Error(`${name}: 곡 길이가 1분에서 1분 30초 사이여야 합니다 (${midi.duration.toFixed(1)}초)`); + } + if (midi.header.timeSignatures[0]?.timeSignature[0] !== meter) { + throw new Error(`${name}: 박자가 일치하지 않습니다`); + } + if ([...right.notes, ...left.notes].some((note) => note.midi < 21 || note.midi > 108)) { + throw new Error(`${name}: 피아노 음역을 벗어났습니다`); + } + console.log(`${name}: ${midi.duration.toFixed(1)}초, 오른손 ${right.notes.length}음, 왼손 ${left.notes.length}음`); +} diff --git a/수정기록.txt b/수정기록.txt index fdfbf24..d796ac6 100644 --- a/수정기록.txt +++ b/수정기록.txt @@ -32,3 +32,14 @@ - 피아노 학습 서비스의 최종 사용 프로그램을 Bach to Basics로 확정했다. - Discourse 포럼 작업방에 서비스 소개와 사용법 및 곡 공유와 질문과 오류 신고 등의 게시 공간 구성을 요청했다. - 현재 작업은 프로젝트 결정 기록만 변경했다. DB와 운영 컨테이너 및 이용자 데이터는 변경하지 않았다. + +2026-10-09 + +- 초보자도 한 곡을 끝까지 연주할 수 있도록 약 64~76초 길이의 양손 MIDI 연습곡 5개를 직접 만들었다. +- 별빛 계단은 입문과 쉬움 두 버전으로 묶었다. 저녁빛 왈츠와 해돋이 아르페지오 및 검은 건반 산책도 공개 샘플로 등록했다. +- 분산 화음과 아르페지오 기법 참고 강좌의 제작자와 유튜브 주소를 샘플 출처 문서에 짧게 기록했다. 영상의 곡을 MIDI로 복제하지 않았다. +- 변경 파일은 backend/library_db.py와 backend/tools/generate_beginner_midis.py와 backend/sample_midi의 신규 파일 5개 및 검사 스크립트와 관련 문서다. +- MIDI 파서 검사에서 5개 파일의 길이와 박자 및 양손 트랙을 확인했다. 백엔드 단위 테스트 5개를 통과했다. +- 운영 백업은 /home/syskiller/piano/backups/bach-to-basics/20261009-beginner-midi 에 보관했다. 서버 시작 시 기본 공개 곡 5건이 DB에 추가됐고 회원 데이터 이전은 없었다. +- 백엔드 컨테이너만 갱신했다. HTTPS 첫 화면과 비회원 공개 목록 및 5개 파일 다운로드를 확인했다. 기존 회원 업로드 2개를 보존했고 프런트엔드는 재시작하지 않았다. +- 실제 USB MIDI 피아노를 연결한 손별 따라치기 검사는 남아 있다.