양손 악보를 피아노 독주로 통일하고 난이도 정렬과 곡 목록 통신 복구를 보완했다. 재생 위치에 맞춰 악보 커서와 화면이 이동하도록 고쳤으며 높은음과 낮은음에는 8va와 8vb 표기를 적용했다. 상단 브랜드와 저장소 링크를 ITOSM 피아노 학습실 기준으로 바꿨다. 검사: 프런트엔드 테스트 67건 통과, 백엔드 테스트 4건 통과, 타입 검사와 빌드 통과, Docker 재빌드와 운영 화면 확인 운영 반영: 완료 백업: /home/syskiller/piano/backups/bach-to-basics/20261003-1635-classification-scroll
This commit is contained in:
+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" />
|
||||
|
||||
Reference in New Issue
Block a user