악보 진행과 연습곡 분류를 바로잡음
CI / Frontend - type-check & test (push) Canceled after 0s

양손 악보를 피아노 독주로 통일하고 난이도 정렬과 곡 목록 통신 복구를 보완했다.
재생 위치에 맞춰 악보 커서와 화면이 이동하도록 고쳤으며 높은음과 낮은음에는 8va와 8vb 표기를 적용했다.
상단 브랜드와 저장소 링크를 ITOSM 피아노 학습실 기준으로 바꿨다.

검사: 프런트엔드 테스트 67건 통과, 백엔드 테스트 4건 통과, 타입 검사와 빌드 통과, Docker 재빌드와 운영 화면 확인
운영 반영: 완료
백업: /home/syskiller/piano/backups/bach-to-basics/20261003-1635-classification-scroll
This commit is contained in:
이창현
2026-10-03 16:56:26 +09:00
parent 0522e48013
commit 92bc93c01b
20 changed files with 218 additions and 141 deletions
+11 -11
View File
@@ -112,11 +112,11 @@ SAMPLES = (
"storage_name": "minuet-bwv-anh-114-beginner.mid",
"title": "미뉴에트 사장조 BWV Anh. 114",
"artist": "크리스티안 페촐트",
"description": "오른손 선율을 느린 속도로 먼저 익히는 입문 편곡입니다. 공개 도메인 원곡에서 연습용으로 구성했습니다.",
"description": "오른손 선율과 단순화한 왼손을 느린 속도로 함께 익히는 입문 편곡입니다. 손별 연습은 연습 화면에서 선택할 수 있습니다.",
"difficulty": "beginner",
"genre": "바로크",
"arrangement": "melody",
"tags": ["미뉴에트", "사장조", "오른손", "입문", "공개 도메인"],
"arrangement": "piano_solo",
"tags": ["미뉴에트", "사장조", "양손", "입문", "공개 도메인"],
},
{
"storage_name": "minuet-bwv-anh-114.mid",
@@ -132,11 +132,11 @@ SAMPLES = (
"storage_name": "menuett-woo-82-easy.mid",
"title": "미뉴에트 내림마장조 WoO 82",
"artist": "루트비히 판 베토벤",
"description": "오른손 선율을 느린 속도로 익히는 쉬운 편곡입니다. 공개 도메인 원곡에서 연습용으로 구성했습니다.",
"description": "오른손 선율과 단순화한 왼손을 느린 속도로 함께 익히는 쉬운 편곡입니다. 손별 연습은 연습 화면에서 선택할 수 있습니다.",
"difficulty": "easy",
"genre": "고전",
"arrangement": "melody",
"tags": ["미뉴에트", "내림마장조", "오른손", "쉬움", "공개 도메인"],
"arrangement": "piano_solo",
"tags": ["미뉴에트", "내림마장조", "양손", "쉬움", "공개 도메인"],
},
{
"storage_name": "menuett-woo-82.mid",
@@ -152,11 +152,11 @@ SAMPLES = (
"storage_name": "fur-elise-beginner.mid",
"title": "엘리제를 위하여 WoO 59",
"artist": "루트비히 판 베토벤",
"description": "유명한 첫 주제를 오른손으로 천천히 익히는 입문 편곡입니다. 공개 도메인 원곡에서 연습용으로 구성했습니다.",
"description": "유명한 첫 주제의 오른손 선율과 단순화한 왼손을 천천히 함께 익히는 입문 편곡입니다. 손별 연습은 연습 화면에서 선택할 수 있습니다.",
"difficulty": "beginner",
"genre": "고전",
"arrangement": "melody",
"tags": ["오른손", "주제 연습", "입문", "베토벤", "공개 도메인"],
"arrangement": "piano_solo",
"tags": ["양손", "주제 연습", "입문", "베토벤", "공개 도메인"],
},
{
"storage_name": "fur-elise-easy.mid",
@@ -165,8 +165,8 @@ SAMPLES = (
"description": "첫 주제의 오른손 선율과 단순화한 왼손 근음을 함께 연습하는 쉬운 양손 편곡입니다.",
"difficulty": "easy",
"genre": "고전",
"arrangement": "accompaniment",
"tags": ["양손", "쉬운 반주", "주제 연습", "베토벤", "공개 도메인"],
"arrangement": "piano_solo",
"tags": ["양손", "쉬운 양손", "주제 연습", "베토벤", "공개 도메인"],
},
{
"storage_name": "fur-elise.mid",
+10 -1
View File
@@ -446,7 +446,16 @@ def list_midi(
SELECT m.*, u.display_name AS owner_name, u.username AS owner_username
FROM midi_items m LEFT JOIN users u ON u.id = m.owner_id
WHERE {' AND '.join(clauses)}
ORDER BY m.artist COLLATE NOCASE, m.title COLLATE NOCASE, m.difficulty, m.created_at
ORDER BY m.artist COLLATE NOCASE, m.title COLLATE NOCASE,
CASE m.difficulty
WHEN 'beginner' THEN 0
WHEN 'easy' THEN 1
WHEN 'intermediate' THEN 2
WHEN 'advanced' THEN 3
WHEN 'expert' THEN 4
ELSE 5
END,
m.created_at
""",
params,
).fetchall()
Binary file not shown.
Binary file not shown.
Binary file not shown.
+53
View File
@@ -177,6 +177,7 @@ def _apply_piano_split(score): # noqa: C901
import copy
from music21 import clef as m21clef, instrument as m21inst, layout as m21layout
from music21 import note as m21note, chord as m21chord
from music21 import spanner as m21spanner
from music21 import stream as m21s
parts = list(score.parts)
@@ -313,6 +314,58 @@ def _apply_piano_split(score): # noqa: C901
first_m.remove(c)
first_m.insert(0, clef_obj)
def _add_readable_ottavas(staff, *, upper: bool) -> None:
"""긴 덧줄 대신 연속된 고음과 저음 구간에 8va 또는 8vb를 붙인다.
한 음에만 기호를 붙이면 악보가 더 복잡해진다. 한 마디 안에 기준을
넘는 음이 두 개 이상 있거나 극단적인 음이 하나라도 있을 때만 그
마디를 대상으로 삼는다. 이어진 대상 마디는 하나의 옥타브 선으로
묶는다. non-transposing으로 내보내 원래 MIDI 음높이와 재생 결과는
바뀌지 않고 MusicXML 표기만 읽기 쉬운 위치로 이동한다.
"""
measures = list(staff.getElementsByClass("Measure"))
marked: list[int] = []
for index, measure in enumerate(measures):
pitched = list(measure.recurse().notes)
qualifying = 0
extreme = False
for element in pitched:
pitches = list(element.pitches) if hasattr(element, "pitches") else [element.pitch]
midi_values = [pitch.midi for pitch in pitches]
if upper:
qualifying += sum(value >= 84 for value in midi_values) # C6 이상
extreme = extreme or any(value >= 96 for value in midi_values) # C7 이상
else:
qualifying += sum(value <= 36 for value in midi_values) # C2 이하
extreme = extreme or any(value <= 24 for value in midi_values) # C1 이하
if qualifying >= 2 or extreme:
marked.append(index)
groups: list[list[int]] = []
for index in marked:
if groups and index == groups[-1][-1] + 1:
groups[-1].append(index)
else:
groups.append([index])
for group in groups:
elements = []
for index in group:
elements.extend(measures[index].recurse().notes)
if not elements:
continue
ottava = m21spanner.Ottava(
*elements,
type="8va" if upper else "8vb",
transposing=False,
placement="above" if upper else "below",
)
staff.insert(0, ottava)
_add_readable_ottavas(treble_staff, upper=True)
_add_readable_ottavas(bass_staff, upper=False)
# ── Assemble score with brace StaffGroup (= grand staff) ─────────────────
new_score = m21s.Score()
if score.metadata:
+7
View File
@@ -50,6 +50,13 @@ class LibraryDatabaseTest(unittest.TestCase):
self.assertTrue(Path(self.temp.name, "library.sqlite3").is_file())
self.assertTrue(Path(self.temp.name, "midi").is_dir())
def test_graded_samples_are_two_hand_piano_solos(self) -> None:
for sample in self.db.SAMPLES:
self.assertEqual(sample["arrangement"], "piano_solo")
self.assertIn("양손", sample["tags"])
self.assertNotIn("오른손", sample["tags"])
self.assertNotIn("쉬운 반주", sample["tags"])
if __name__ == "__main__":
unittest.main()