악보 진행과 연습곡 분류를 바로잡음
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
+2 -2
View File
@@ -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" />
+1 -1
View File
@@ -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",
+13
View File
@@ -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>
+13 -38
View File
@@ -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" />