직접 만든 연습곡 다섯 파일을 공개 보관함에 등록했다. 별빛 계단은 입문과 쉬움 두 단계로 제공한다. MIDI 파서 검사와 백엔드 단위 테스트 5개를 통과했다. 운영 백업은 /home/syskiller/piano/backups/bach-to-basics/20261009-beginner-midi 에 있다. 백엔드만 갱신했고 비회원 목록과 다운로드를 확인했다. 실제 USB MIDI 장치 검사는 남아 있다. 사전 훅은 로컬 pnpm 의존성 불일치로 실행되지 않아 관련 검사를 직접 수행했다.
This commit is contained in:
@@ -15,6 +15,16 @@
|
|||||||
- NoteRain은 최종 학습 서비스로 안내하지 않는다. 별도 종료 지시가 없으므로 기존 운영 상태 변경은 별도 작업으로 판단한다.
|
- NoteRain은 최종 학습 서비스로 안내하지 않는다. 별도 종료 지시가 없으므로 기존 운영 상태 변경은 별도 작업으로 판단한다.
|
||||||
- Discourse 포럼 작업방에 소개와 용도별 게시 공간 구성을 요청했다. 실제 포럼 반영 결과는 해당 방에서 확인한다.
|
- 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 현재 상태
|
## 2026-10-03 현재 상태
|
||||||
|
|
||||||
- Docker의 `piano-bach-frontend`와 `piano-bach-backend`로 운영한다.
|
- Docker의 `piano-bach-frontend`와 `piano-bach-backend`로 운영한다.
|
||||||
|
|||||||
@@ -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 악보를 올려 공개 범위와 회원 등급을 지정할 수 있다.
|
이 저장소는 원본 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)를 따른다.
|
||||||
|
|
||||||
[](https://raw.githubusercontent.com/gigliof/bach-to-basics/main/docs/demo.gif)
|
[](https://raw.githubusercontent.com/gigliof/bach-to-basics/main/docs/demo.gif)
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,56 @@ def delete_session(token: str | None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
SAMPLES = (
|
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",
|
"storage_name": "minuet-bwv-anh-114-beginner.mid",
|
||||||
"title": "미뉴에트 사장조 BWV Anh. 114",
|
"title": "미뉴에트 사장조 BWV Anh. 114",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -57,6 +57,26 @@ class LibraryDatabaseTest(unittest.TestCase):
|
|||||||
self.assertNotIn("오른손", sample["tags"])
|
self.assertNotIn("오른손", sample["tags"])
|
||||||
self.assertNotIn("쉬운 반주", sample["tags"])
|
self.assertNotIn("쉬운 반주", sample["tags"])
|
||||||
|
|
||||||
|
def test_self_composed_beginner_midis_are_seeded_as_public(self) -> 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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -74,7 +74,7 @@ Mutopia Project가 공개 도메인으로 배포하는 실제 클래식 MIDI를
|
|||||||
- 미뉴에트 내림마장조 WoO 82: 쉬운 양손 편곡과 중급 양손 원곡
|
- 미뉴에트 내림마장조 WoO 82: 쉬운 양손 편곡과 중급 양손 원곡
|
||||||
- 엘리제를 위하여 WoO 59: 입문 양손과 쉬운 양손 및 고급 원곡
|
- 엘리제를 위하여 WoO 59: 입문 양손과 쉬운 양손 및 고급 원곡
|
||||||
|
|
||||||
같은 제목의 난이도별 버전은 한 묶음에서 선택한다. 기본 단계별 연습곡은 모두 오른손과 왼손을 포함한 피아노 독주로 제공한다. 손별 연습은 연습 화면에서 선택한다. 공개 도메인 원곡에서 만든 단계별 연습 편곡은 `scripts/build_sample_variants.mjs`로 다시 만들 수 있다.
|
같은 제목의 난이도별 버전은 한 묶음에서 선택한다. 기본 단계별 연습곡은 모두 오른손과 왼손을 포함한 피아노 독주로 제공한다. 손별 연습은 연습 화면에서 선택한다. 공개 도메인 원곡에서 만든 단계별 연습 편곡은 `scripts/build_sample_variants.mjs`로 다시 만들 수 있다. 자체 제작한 약 1분 길이의 초보자용 MIDI 다섯 파일은 `python backend/tools/generate_beginner_midis.py`로 다시 만들 수 있다.
|
||||||
|
|
||||||
## 악보 표시
|
## 악보 표시
|
||||||
|
|
||||||
|
|||||||
@@ -25,3 +25,17 @@ Mutopia Project의 배포 안내는 <https://www.mutopiaproject.org/legal.html>
|
|||||||
| 엘리제를 위하여 WoO 59 | 고급 | 전체 양손 원곡 | `fur-elise.mid` |
|
| 엘리제를 위하여 WoO 59 | 고급 | 전체 양손 원곡 | `fur-elise.mid` |
|
||||||
|
|
||||||
편곡 파일은 `node scripts/build_sample_variants.mjs`로 다시 만들 수 있다. 원본 MIDI가 바뀌면 생성 결과도 달라지므로 반영 전에 파일 크기와 음표 수와 실제 재생을 다시 검사한다.
|
편곡 파일은 `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`로 같은 내용으로 다시 만들 수 있다.
|
||||||
|
|||||||
@@ -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}음`);
|
||||||
|
}
|
||||||
@@ -32,3 +32,14 @@
|
|||||||
- 피아노 학습 서비스의 최종 사용 프로그램을 Bach to Basics로 확정했다.
|
- 피아노 학습 서비스의 최종 사용 프로그램을 Bach to Basics로 확정했다.
|
||||||
- Discourse 포럼 작업방에 서비스 소개와 사용법 및 곡 공유와 질문과 오류 신고 등의 게시 공간 구성을 요청했다.
|
- Discourse 포럼 작업방에 서비스 소개와 사용법 및 곡 공유와 질문과 오류 신고 등의 게시 공간 구성을 요청했다.
|
||||||
- 현재 작업은 프로젝트 결정 기록만 변경했다. DB와 운영 컨테이너 및 이용자 데이터는 변경하지 않았다.
|
- 현재 작업은 프로젝트 결정 기록만 변경했다. 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 피아노를 연결한 손별 따라치기 검사는 남아 있다.
|
||||||
|
|||||||
Reference in New Issue
Block a user