양손 악보를 피아노 독주로 통일하고 난이도 정렬과 곡 목록 통신 복구를 보완했다. 재생 위치에 맞춰 악보 커서와 화면이 이동하도록 고쳤으며 높은음과 낮은음에는 8va와 8vb 표기를 적용했다. 상단 브랜드와 저장소 링크를 ITOSM 피아노 학습실 기준으로 바꿨다. 검사: 프런트엔드 테스트 67건 통과, 백엔드 테스트 4건 통과, 타입 검사와 빌드 통과, Docker 재빌드와 운영 화면 확인 운영 반영: 완료 백업: /home/syskiller/piano/backups/bach-to-basics/20261003-1635-classification-scroll
This commit is contained in:
+6
-1
@@ -17,12 +17,17 @@
|
||||
- 보관함 업로드 형식은 MIDI와 MusicXML과 MXL 및 PDF다.
|
||||
- 공개 샘플은 Mutopia Project 공개 도메인 자료와 그 단계별 연습 편곡 7개다.
|
||||
- 악보 표시에는 AlphaTab과 Bravura SMuFL 글꼴을 사용한다.
|
||||
- MIDI 변환 악보에서 C6 이상의 연속 고음은 8va로 표시한다. C2 이하의 연속 저음은 8vb로 표시해 덧줄을 줄인다. 실제 재생 음높이는 바꾸지 않는다.
|
||||
- 화면과 브라우저 제목의 서비스 이름은 `ITOSM 피아노 학습실`이다. 왼쪽 위 표식과 설정 화면은 전용 음표 표식을 사용하며 서비스 Gitea 저장소로 연결한다.
|
||||
|
||||
## 확인된 운영 결과
|
||||
|
||||
- 기존 회원 업로드 `20360.mid`는 `보글보글`이라는 제목으로 보존돼 있다.
|
||||
- 엘리제를 위하여는 입문과 쉬움과 고급 3개 버전으로 묶여 표시된다.
|
||||
- 두 미뉴에트는 각각 오른손 연습과 양손 원곡 2개 버전으로 묶여 표시된다.
|
||||
- 두 미뉴에트는 각각 단순화한 양손 연습과 양손 원곡 2개 버전으로 묶여 표시된다.
|
||||
- 단계별 공개 샘플은 모두 오른손과 왼손을 포함한 피아노 독주다. 손별 연습은 연습 화면에서 선택한다.
|
||||
- 곡 목록 조회는 일시적인 연결 실패 때 한 번 재시도한다. 정상 응답을 받으면 남아 있던 연결 오류를 지운다.
|
||||
- 악보 커서는 변환된 악보의 시간 위치와 원본 템포 지도를 환산한 AlphaTab tick을 함께 사용한다. 음소거된 내부 플레이어가 위치 이벤트를 보내지 않는 경우에도 악보 렌더가 끝나면 커서를 직접 갱신한다. 확인한 AlphaTab 1.8.3을 고정해 커서 경로 변경으로 인한 재발을 막는다.
|
||||
- 운영 브라우저에서 미뉴에트와 엘리제를 위하여 입문 악보의 오선과 음표 표시를 확인했다.
|
||||
- 운영 백엔드에서 MIDI와 MusicXML과 MXL 및 PDF 검증기를 모두 통과했다.
|
||||
- 프런트 타입 검사와 67개 테스트와 운영 빌드를 통과했다.
|
||||
|
||||
+11
-11
@@ -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",
|
||||
|
||||
@@ -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.
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
+11
-5
@@ -70,11 +70,11 @@ Bach to Basics의 기존 파일 가져오기 기능을 유지하면서 서버
|
||||
|
||||
Mutopia Project가 공개 도메인으로 배포하는 실제 클래식 MIDI를 전체 공개 샘플로 제공한다. 출처와 파일별 권리 표시는 `THIRD_PARTY_SAMPLES.md`에 기록한다.
|
||||
|
||||
- 미뉴에트 사장조 BWV Anh. 114: 입문 오른손과 쉬운 양손 원곡
|
||||
- 미뉴에트 내림마장조 WoO 82: 쉬운 오른손과 중급 양손 원곡
|
||||
- 엘리제를 위하여 WoO 59: 입문 오른손과 쉬운 양손 및 고급 원곡
|
||||
- 미뉴에트 사장조 BWV Anh. 114: 입문 양손 편곡과 쉬운 양손 원곡
|
||||
- 미뉴에트 내림마장조 WoO 82: 쉬운 양손 편곡과 중급 양손 원곡
|
||||
- 엘리제를 위하여 WoO 59: 입문 양손과 쉬운 양손 및 고급 원곡
|
||||
|
||||
같은 제목의 난이도와 편곡 형태는 한 묶음에서 선택한다. 공개 도메인 원곡에서 만든 단계별 연습 편곡은 `scripts/build_sample_variants.mjs`로 다시 만들 수 있다.
|
||||
같은 제목의 난이도별 버전은 한 묶음에서 선택한다. 기본 단계별 연습곡은 모두 오른손과 왼손을 포함한 피아노 독주로 제공한다. 손별 연습은 연습 화면에서 선택한다. 공개 도메인 원곡에서 만든 단계별 연습 편곡은 `scripts/build_sample_variants.mjs`로 다시 만들 수 있다.
|
||||
|
||||
## 악보 표시
|
||||
|
||||
@@ -86,6 +86,10 @@ MIDI를 선택하면 서버에서 MusicXML로 변환한 뒤 AlphaTab으로 오
|
||||
|
||||
운영 환경에서는 악보 렌더링을 UI 스레드에서 처리한다. Vite 번들과 별도 Worker가 렌더링 자원을 서로 다르게 초기화해 빈 악보가 되던 문제를 피하기 위한 설정이다.
|
||||
|
||||
전체 화면의 가로 악보는 외부 재생 시계의 시간과 AlphaTab tick을 함께 사용해 커서를 갱신한다. 커서가 화면 너비의 약 40퍼센트 지점에 오면 악보가 부드럽게 따라간다. 확인한 AlphaTab 1.8.3을 고정해 내부 커서 경로가 예고 없이 바뀌지 않게 한다.
|
||||
|
||||
MIDI 변환 악보에서 C6 이상의 음이 한 마디에 두 개 이상 이어지면 8va를 적용한다. C2 이하의 연속 저음에는 8vb를 적용한다. C7 이상과 C1 이하는 한 음만 있어도 적용한다. 옥타브 기호는 표기 위치만 바꾸며 실제 재생 음높이는 유지한다.
|
||||
|
||||
## 초기 관리자
|
||||
|
||||
초기 관리자 아이디는 Compose 환경 변수로 지정한다. 비밀번호는 `secrets/library_admin_password`에서만 읽는다. DB에 같은 아이디가 없을 때만 최초 관리자를 생성한다. 컨테이너 재시작 때 기존 비밀번호를 덮어쓰지 않는다.
|
||||
@@ -110,7 +114,9 @@ MIDI를 선택하면 서버에서 MusicXML로 변환한 뒤 AlphaTab으로 오
|
||||
3. 비공개 곡이 다른 회원과 비회원에게 보이지 않는지 확인한다.
|
||||
4. 회원 공개와 지정 공유 및 최소 등급 제한을 확인한다.
|
||||
5. MIDI와 MusicXML과 MXL 및 PDF 업로드와 한글 제목 및 태그 검색을 확인한다.
|
||||
6. 같은 제목의 멜로디와 반주 버전을 각각 불러온다.
|
||||
6. 같은 제목의 입문과 쉬움 및 원곡 버전을 각각 불러온다. 모든 단계에서 오른손과 왼손 악보가 함께 표시되는지 확인한다.
|
||||
7. 비밀번호 변경 후 기존 세션이 끝나는지 확인한다.
|
||||
8. 시험 회원 탈퇴 후 계정과 업로드가 제거되는지 확인한다.
|
||||
9. 모바일 화면에서 검색과 업로드 및 계정 화면이 가로로 넘치지 않는지 확인한다.
|
||||
10. 전체 화면에서 50초 이상 이동한 뒤 악보 커서와 가로 스크롤이 진행 막대를 따라가는지 확인한다.
|
||||
11. 고음과 저음이 많은 MIDI를 변환해 MusicXML에 8va와 8vb가 생성되는지 확인한다.
|
||||
|
||||
@@ -12,7 +12,7 @@ Mutopia Project의 배포 안내는 <https://www.mutopiaproject.org/legal.html>
|
||||
|
||||
## 난이도별 연습 편곡
|
||||
|
||||
같은 곡을 단계별로 익힐 수 있도록 위 공개 도메인 MIDI에서 다음 연습 편곡을 만들었다. 원곡의 제목과 작곡가는 유지한다. 곡 보관함에서는 같은 제목 아래에 난이도와 형태가 다른 버전으로 표시한다.
|
||||
같은 곡을 단계별로 익힐 수 있도록 위 공개 도메인 MIDI에서 다음 연습 편곡을 만들었다. 원곡의 제목과 작곡가는 유지한다. 모든 단계별 파일에는 오른손과 왼손을 함께 넣는다. 곡 보관함에서는 같은 제목 아래에 난이도가 다른 피아노 독주 버전으로 표시한다. 손별 연습은 연습 화면의 왼손과 오른손 선택 기능을 사용한다.
|
||||
|
||||
| 원곡 | 단계 | 편곡 내용 | 파일 |
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
+2
-2
@@ -7,9 +7,9 @@
|
||||
'unsafe-inline' for script-src is needed for the error overlay below;
|
||||
tighten by moving it to a separate .js file if desired. -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' https://gleitz.github.io https://danigb.github.io https://smpldsnds.github.io https://goldst.dev; worker-src 'self' blob:; object-src 'none'; base-uri 'self'" />
|
||||
<title>Bach to Basics 피아노 학습</title>
|
||||
<title>ITOSM 피아노 학습실</title>
|
||||
<meta name="description" content="MIDI와 MusicXML 악보를 불러와 건반과 내려오는 음표를 보며 연습하는 한국어 피아노 학습 도구" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/itosm-piano-mark.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon-light.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@bach-to-basics/shared": "workspace:*",
|
||||
"@coderline/alphatab": "^1.8.3",
|
||||
"@coderline/alphatab": "1.8.3",
|
||||
"@tonejs/midi": "^2.0.6",
|
||||
"mitt": "^3.0.1",
|
||||
"pixi.js": "^8.18.1",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
|
||||
<title id="title">ITOSM 피아노 학습실</title>
|
||||
<desc id="desc">보라색 음표와 피아노 건반을 조합한 표식</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="8" y1="6" x2="56" y2="58" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5b5cf0"/>
|
||||
<stop offset="1" stop-color="#8b3fd6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="60" height="60" rx="16" fill="url(#bg)"/>
|
||||
<path d="M18 19v20.5a7.5 7.5 0 1 0 5 7.1V28l22-4v11.5a7.5 7.5 0 1 0 5 7.1V14L18 19Z" fill="#fff"/>
|
||||
<path d="M13 51h38" stroke="#d9d8ff" stroke-width="3" stroke-linecap="round" opacity=".9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 720 B |
@@ -69,7 +69,22 @@ const VISIBILITY_LABELS: Record<MidiItem["visibility"], string> = {
|
||||
};
|
||||
|
||||
async function apiJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, init);
|
||||
} catch {
|
||||
if (init?.method && init.method !== "GET") {
|
||||
throw new Error("서버에 연결하지 못했습니다. 잠시 뒤 다시 시도해 주세요");
|
||||
}
|
||||
// 컨테이너를 갱신하는 짧은 순간에는 연결이 한 번 끊길 수 있다.
|
||||
// 조회 요청만 한 차례 다시 보내 사용자가 오래된 오류 문구를 보지 않게 한다.
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 500));
|
||||
try {
|
||||
response = await fetch(url, init);
|
||||
} catch {
|
||||
throw new Error("서버에 연결하지 못했습니다. 잠시 뒤 다시 시도해 주세요");
|
||||
}
|
||||
}
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.detail || "요청을 처리하지 못했습니다");
|
||||
return data as T;
|
||||
@@ -131,6 +146,9 @@ export function LibraryPanel({
|
||||
setItems(result.items);
|
||||
setGenres(result.facets.genres);
|
||||
setTags(result.facets.tags);
|
||||
setMessage((current) =>
|
||||
current === "서버에 연결하지 못했습니다. 잠시 뒤 다시 시도해 주세요" ? "" : current
|
||||
);
|
||||
}, [search, genre, difficulty, arrangement, activeTag]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -91,6 +91,10 @@ export function SheetMusicView() {
|
||||
} as never);
|
||||
|
||||
apiRef.current = api;
|
||||
// 플레이어가 매우 빨리 준비되면 아래 이벤트를 구독하기 전에
|
||||
// playerReady가 끝날 수 있다. 음소거 값은 생성 직후에도 저장되므로
|
||||
// 먼저 적용하고 이벤트에서는 한 번 더 보정한다.
|
||||
api.masterVolume = 0;
|
||||
setApiReady(true);
|
||||
|
||||
// AlphaTab의 파일 해석과 렌더링 오류는 비동기로 전달된다. 빈 화면만
|
||||
@@ -169,15 +173,32 @@ export function SheetMusicView() {
|
||||
// IMPORTANT: do NOT close over `doc` here - it would always be null
|
||||
// (stale closure from first render). Always read fresh state instead.
|
||||
const onTick = ({ seconds }: { seconds: number; tick: number }) => {
|
||||
if (!apiRef.current || !playerReady.current || !scoreLoaded.current) return;
|
||||
// playerReady는 생성 직후 이미 발생해 구독에서 놓칠 수 있다.
|
||||
// 이를 필수 조건으로 사용하면 시간 막대만 움직이고 악보 커서는
|
||||
// 첫 마디에 영구히 멈춘다. 악보 렌더 완료만 확인해 위치를 갱신한다.
|
||||
if (!apiRef.current || !scoreLoaded.current) return;
|
||||
|
||||
// Always-fresh doc - avoids stale closure bug
|
||||
const doc = useAppStore.getState().document;
|
||||
if (!doc) return;
|
||||
|
||||
const tick = secondsToTick(seconds, doc.tempoMap ?? [], doc.ppq ?? 480);
|
||||
try {
|
||||
apiRef.current.tickPosition = tick;
|
||||
// 변환된 MusicXML의 마디와 템포 정보는 원본 MIDI의 tick 구조와
|
||||
// 달라질 수 있다. 원본 tick을 다시 계산해 넣으면 재생 시간은
|
||||
// 진행해도 악보 커서는 첫 마디 근처에 머문다. AlphaTab이 현재
|
||||
// 악보의 템포 지도를 직접 사용하도록 밀리초 위치를 전달한다.
|
||||
apiRef.current.timePosition = Math.max(0, seconds * 1000);
|
||||
|
||||
// AlphaTab의 내부 플레이어는 이 화면에서 음소거 상태다. 브라우저와
|
||||
// 로딩 순서에 따라 timePosition 변경을 커서 이벤트로 돌려주지 않는
|
||||
// 경우가 있으므로 현재 고정 버전의 커서 갱신기를 함께 호출한다.
|
||||
// 다음 버전에서 내부 이름이 바뀌어도 시간 설정은 계속 작동하도록
|
||||
// 존재 여부를 확인한다.
|
||||
const updateCursor = apiRef.current._cursorUpdateTick;
|
||||
if (typeof updateCursor === "function") {
|
||||
const tick = secondsToAlphaTabTick(seconds, doc.tempoMap ?? [], doc.ppq ?? 480);
|
||||
updateCursor.call(apiRef.current, tick, false, 1, false, true);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -369,42 +390,30 @@ export function SheetMusicView() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
// AlphaTab 악보 내부는 사분음표당 960 tick을 사용한다. 원본 MIDI의
|
||||
// 템포 변화 구간을 초 단위로 따라가며 악보 커서용 tick으로 변환한다.
|
||||
const ALPHATAB_PPQ = 960;
|
||||
|
||||
// AlphaTab uses 960 ticks per quarter note internally, regardless of the MIDI file's PPQ.
|
||||
// This function converts playback seconds to AlphaTab internal ticks (960 PPQ output).
|
||||
// The tempo map uses MIDI-file ticks for segment boundaries, so we still need `ppq`
|
||||
// to convert those boundaries to seconds, but the output is always in 960-PPQ space.
|
||||
const AT_PPQ = 960;
|
||||
function secondsToAlphaTabTick(targetSeconds: number, tempoMap: TempoEvent[], ppq: number): number {
|
||||
if (!tempoMap.length) return Math.round(targetSeconds * 2 * ALPHATAB_PPQ);
|
||||
|
||||
function secondsToTick(targetSec: number, tempoMap: TempoEvent[], ppq: number): number {
|
||||
if (!tempoMap.length) return Math.round(targetSec * 2 * AT_PPQ);
|
||||
let atTick = 0; // accumulated output in AlphaTab 960-PPQ ticks
|
||||
let remainingSec = targetSec;
|
||||
|
||||
for (let i = 0; i < tempoMap.length; i++) {
|
||||
const ev = tempoMap[i];
|
||||
const next = tempoMap[i + 1];
|
||||
|
||||
// seconds per MIDI tick at this tempo
|
||||
const secPerMidiTick = 60 / (ev.bpm * ppq);
|
||||
// AlphaTab ticks per MIDI tick (scale factor between file PPQ and AT PPQ)
|
||||
const atPerMidi = AT_PPQ / ppq;
|
||||
|
||||
if (next) {
|
||||
const midiSpan = next.tick - ev.tick; // segment length in MIDI ticks
|
||||
const secSpan = midiSpan * secPerMidiTick;
|
||||
if (remainingSec <= secSpan) {
|
||||
atTick += Math.round((remainingSec / secPerMidiTick) * atPerMidi);
|
||||
return atTick;
|
||||
}
|
||||
remainingSec -= secSpan;
|
||||
atTick += Math.round(midiSpan * atPerMidi);
|
||||
} else {
|
||||
// Last segment - extends to end of piece
|
||||
atTick += Math.round((remainingSec / secPerMidiTick) * atPerMidi);
|
||||
return atTick;
|
||||
let result = 0;
|
||||
let remaining = Math.max(0, targetSeconds);
|
||||
for (let index = 0; index < tempoMap.length; index++) {
|
||||
const current = tempoMap[index];
|
||||
const next = tempoMap[index + 1];
|
||||
const secondsPerMidiTick = 60 / (current.bpm * ppq);
|
||||
const alphaTabPerMidiTick = ALPHATAB_PPQ / ppq;
|
||||
if (!next) {
|
||||
return result + Math.round((remaining / secondsPerMidiTick) * alphaTabPerMidiTick);
|
||||
}
|
||||
const midiSpan = next.tick - current.tick;
|
||||
const secondsSpan = midiSpan * secondsPerMidiTick;
|
||||
if (remaining <= secondsSpan) {
|
||||
return result + Math.round((remaining / secondsPerMidiTick) * alphaTabPerMidiTick);
|
||||
}
|
||||
remaining -= secondsSpan;
|
||||
result += Math.round(midiSpan * alphaTabPerMidiTick);
|
||||
}
|
||||
return atTick;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -402,23 +402,22 @@ function ResetButton() {
|
||||
{confirmed ? "Click again to confirm reset" : "Reset to defaults"}
|
||||
</button>
|
||||
|
||||
{/* Logo mark - links to GitHub repo */}
|
||||
{/* 서비스 표식과 저장소 연결 */}
|
||||
<div style={{ display: "flex", justifyContent: "center", paddingTop: 8, paddingBottom: 4 }}>
|
||||
<a
|
||||
href="https://github.com/gigliof/bach-to-basics"
|
||||
href="https://gitea.itosm.co.kr/syskiller/ITOSMPiano-BachToBasics"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ lineHeight: 0 }}
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Bach to Basics on GitHub"
|
||||
src="/itosm-piano-mark.svg"
|
||||
alt="ITOSM 피아노 학습실 Gitea 저장소"
|
||||
draggable={false}
|
||||
style={{
|
||||
width: 72,
|
||||
width: 56,
|
||||
height: "auto",
|
||||
opacity: isDark ? 0.35 : 0.18,
|
||||
filter: isDark ? "invert(1)" : "none",
|
||||
opacity: isDark ? 0.75 : 0.62,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
|
||||
@@ -187,39 +187,30 @@ export function PracticeView() {
|
||||
paddingRight: 10,
|
||||
}}
|
||||
>
|
||||
{/* Left: gradient logo + app name → links to GitHub repo */}
|
||||
{/* 왼쪽 브랜드 표식은 서비스 저장소로 연결한다. */}
|
||||
<a
|
||||
href="https://github.com/gigliof/bach-to-basics"
|
||||
href="https://gitea.itosm.co.kr/syskiller/ITOSMPiano-BachToBasics"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="b2b-brand flex items-center gap-2 shrink-0"
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
<div
|
||||
role="img"
|
||||
<img
|
||||
src="/itosm-piano-mark.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="flex items-center justify-center shrink-0 select-none"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
background: "linear-gradient(135deg, #7c3aed 0%, #9333ea 100%)",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 3px 10px rgba(147,51,234,0.45)",
|
||||
}}
|
||||
>
|
||||
<PianoKeysIcon />
|
||||
</div>
|
||||
{/* Typographic pun: "Bach" and "Basics" bold, "to" muted */}
|
||||
className="shrink-0 select-none"
|
||||
width="32"
|
||||
height="32"
|
||||
draggable={false}
|
||||
style={{ filter: "drop-shadow(0 3px 5px rgba(91,92,240,0.35))" }}
|
||||
/>
|
||||
<span
|
||||
className="b2b-brand-name"
|
||||
style={{ fontSize: 15, whiteSpace: "nowrap", letterSpacing: "-0.3px", lineHeight: 1 }}
|
||||
>
|
||||
<span style={{ fontWeight: 700, color: "var(--color-text)" }}>Bach</span>
|
||||
<span style={{ fontWeight: 400, color: "var(--color-text-muted)", margin: "0 3px" }}>
|
||||
to
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, color: "var(--color-text)" }}>Basics</span>
|
||||
<span className="sr-only"> on GitHub</span>
|
||||
<span style={{ fontWeight: 700, color: "var(--color-text)" }}>ITOSM 피아노 학습실</span>
|
||||
<span className="sr-only"> Gitea 저장소</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -781,22 +772,6 @@ const MusicNoteIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Piano keyboard icon: 5 white keys with 3 black keys (C-D-E-F-G pattern) */
|
||||
const PianoKeysIcon = () => (
|
||||
<svg width="18" height="14" viewBox="0 0 17 14" fill="none" aria-hidden="true">
|
||||
{/* White keys */}
|
||||
<rect x="0" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95" />
|
||||
<rect x="3.5" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95" />
|
||||
<rect x="7" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95" />
|
||||
<rect x="10.5" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95" />
|
||||
<rect x="14" y="0" width="3" height="13" rx="0.5" fill="white" fillOpacity="0.95" />
|
||||
{/* Black keys */}
|
||||
<rect x="2" y="0" width="2.5" height="8.5" rx="0.5" fill="rgba(0,0,0,0.5)" />
|
||||
<rect x="5.5" y="0" width="2.5" height="8.5" rx="0.5" fill="rgba(0,0,0,0.5)" />
|
||||
<rect x="12.5" y="0" width="2.5" height="8.5" rx="0.5" fill="rgba(0,0,0,0.5)" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SunIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2v-2H2v2zm18 0h2v-2h-2v2zM11 2v2h2V2h-2zm0 18v2h2v-2h-2zM5.64 6.35 4.22 4.93a1 1 0 0 0-1.41 1.41l1.41 1.42 1.42-1.41zM18.36 17.65l1.42 1.41a1 1 0 0 0 1.41-1.41l-1.41-1.42-1.42 1.42zM5.64 17.66l-1.42 1.41a1 1 0 0 1-1.41-1.41l1.41-1.42 1.42 1.42zM19.78 4.93l-1.42 1.42-1.41-1.42 1.42-1.41a1 1 0 0 1 1.41 1.41z" />
|
||||
|
||||
Generated
+2
-28
@@ -50,7 +50,7 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
'@coderline/alphatab':
|
||||
specifier: ^1.8.3
|
||||
specifier: 1.8.3
|
||||
version: 1.8.3
|
||||
'@tonejs/midi':
|
||||
specifier: ^2.0.6
|
||||
@@ -788,42 +788,36 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.1.2':
|
||||
resolution: {integrity: sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.1.2':
|
||||
resolution: {integrity: sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.1.2':
|
||||
resolution: {integrity: sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.1.2':
|
||||
resolution: {integrity: sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.1.2':
|
||||
resolution: {integrity: sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.1.2':
|
||||
resolution: {integrity: sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw==}
|
||||
@@ -934,79 +928,66 @@ packages:
|
||||
resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.62.2':
|
||||
resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.62.2':
|
||||
resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.62.2':
|
||||
resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.62.2':
|
||||
resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.62.2':
|
||||
resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.62.2':
|
||||
resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.62.2':
|
||||
resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.62.2':
|
||||
resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.62.2':
|
||||
resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.62.2':
|
||||
resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.62.2':
|
||||
resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.62.2':
|
||||
resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.62.2':
|
||||
resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==}
|
||||
@@ -1079,28 +1060,24 @@ packages:
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.3.1':
|
||||
resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.3.1':
|
||||
resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.3.1':
|
||||
resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.3.1':
|
||||
resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==}
|
||||
@@ -1289,6 +1266,7 @@ packages:
|
||||
'@xmldom/xmldom@0.8.13':
|
||||
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
@@ -2079,28 +2057,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
|
||||
@@ -65,10 +65,11 @@ const furElise = load("fur-elise.mid");
|
||||
write(
|
||||
"fur-elise-beginner.mid",
|
||||
createVariant(furElise, {
|
||||
name: "엘리제를 위하여 주제 오른손 연습",
|
||||
name: "엘리제를 위하여 입문 양손 연습",
|
||||
tempo: 60,
|
||||
endTime: 32,
|
||||
trackIndexes: [0],
|
||||
trackIndexes: [0, 1],
|
||||
leftHandRoots: true,
|
||||
}),
|
||||
);
|
||||
write(
|
||||
@@ -86,10 +87,11 @@ const bachMinuet = load("minuet-bwv-anh-114.mid");
|
||||
write(
|
||||
"minuet-bwv-anh-114-beginner.mid",
|
||||
createVariant(bachMinuet, {
|
||||
name: "미뉴에트 사장조 오른손 연습",
|
||||
name: "미뉴에트 사장조 입문 양손 연습",
|
||||
tempo: 100,
|
||||
endTime: bachMinuet.duration + 1,
|
||||
trackIndexes: [0],
|
||||
trackIndexes: [0, 1],
|
||||
leftHandRoots: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -97,11 +99,12 @@ const beethovenMinuet = load("menuett-woo-82.mid");
|
||||
write(
|
||||
"menuett-woo-82-easy.mid",
|
||||
createVariant(beethovenMinuet, {
|
||||
name: "미뉴에트 내림마장조 오른손 연습",
|
||||
name: "미뉴에트 내림마장조 쉬운 양손 연습",
|
||||
tempo: 88,
|
||||
endTime: beethovenMinuet.duration + 1,
|
||||
trackIndexes: [0],
|
||||
trackIndexes: [0, 1],
|
||||
leftHandRoots: true,
|
||||
}),
|
||||
);
|
||||
|
||||
console.log("난이도별 연습 MIDI 4개를 만들었습니다.");
|
||||
console.log("양손이 포함된 난이도별 연습 MIDI 4개를 만들었습니다.");
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
- 운영 악보 렌더링을 UI 스레드로 고정해 빈 악보 문제를 해결했다.
|
||||
- 임시 음형 샘플 4개를 제거했다.
|
||||
- Mutopia Project 공개 도메인 클래식 3곡과 단계별 편곡을 합쳐 7개 버전으로 등록했다.
|
||||
- 엘리제를 위하여는 입문 오른손과 쉬운 양손과 고급 원곡을 같은 제목 아래에 표시한다.
|
||||
- 엘리제를 위하여는 입문 양손과 쉬운 양손 및 고급 원곡을 같은 제목 아래에 표시한다.
|
||||
- 단계별 공개 샘플은 모두 오른손과 왼손을 포함한 피아노 독주로 통일했다. 손별 연습은 연습 화면에서 선택한다.
|
||||
- 서버 갱신 중 일시적인 조회 실패가 발생하면 한 번 재시도한다. 정상 응답 뒤에는 오래된 연결 오류를 지운다.
|
||||
- 왼쪽 위 이름과 브라우저 제목을 ITOSM 피아노 학습실로 바꾸고 전용 음표 표식을 적용했다.
|
||||
- 서비스 표식과 설정 화면의 저장소 연결을 ITOSM 피아노 학습실 Gitea 저장소로 변경했다.
|
||||
- MIDI에서 변환한 악보가 빠른 플레이어 준비 이벤트 누락과 음소거된 내부 플레이어의 위치 이벤트 누락 때문에 첫 마디에 머무르던 문제를 수정했다. 악보 시간과 AlphaTab tick을 함께 동기화하고 AlphaTab 1.8.3을 고정했다.
|
||||
- 연속된 고음과 저음 구간에 표준 8va와 8vb 옥타브 기호를 자동 적용해 덧줄을 줄였다. 한 음짜리 짧은 구간에는 기호를 남발하지 않으며 실제 재생 음높이는 유지한다.
|
||||
- 기존 회원 업로드 20360.mid가 유지되는 것을 확인했다.
|
||||
- 프런트 타입 검사와 67개 테스트와 운영 빌드를 통과했다.
|
||||
- 운영 백엔드에서 MIDI와 MusicXML과 MXL 및 PDF 검증을 통과했다.
|
||||
|
||||
Reference in New Issue
Block a user