곡 보관함과 악보 학습 기능을 보완

업로드 형식을 MIDI와 MusicXML과 MXL 및 PDF로 확장하고 파일별 검증을 추가했다.

AlphaTab 악보 자산과 Nginx MIME 설정을 보완해 운영에서 오선과 음표가 표시되도록 수정했다.

공개 도메인 클래식과 난이도별 연습 편곡 7개를 같은 제목별로 묶고 출처와 운영 검증 결과를 문서화했다.

검사: TypeScript 검사와 Vitest 67개 및 Vite 운영 빌드 통과. 운영 브라우저 악보 표시와 네 가지 업로드 검증 형식 확인.
백업: /home/syskiller/piano/backups/bach-to-basics/20261003-1545-sheet-upload
운영 반영: 완료
This commit is contained in:
이창현
2026-10-03 16:15:00 +09:00
parent c918f02685
commit 0522e48013
35 changed files with 83181 additions and 123 deletions
@@ -29,7 +29,8 @@ for (let m = MIDI_MIN; m <= MIDI_MAX; m++) {
const TOTAL_WHITE_KEYS = wi;
// ── Theme palette ─────────────────────────────────────────────────────────────
// Each theme has: left hand, right hand, unknown, and black-key variant (slightly lighter)
// 검은 건반 음표는 같은 손 색상을 유지하면서 명도를 낮춘다. 흰 건반 음표와
// 나란히 내려올 때 폭뿐 아니라 색만으로도 즉시 구분할 수 있어야 한다.
interface ThemePalette {
left: number;
right: number;
@@ -44,45 +45,45 @@ const THEMES: Record<Exclude<ColorTheme, "custom">, ThemePalette> = {
left: 0x7c3aed,
right: 0x8b5cf6,
unknown: 0x8b5cf6,
leftBlack: 0xa78bfa,
rightBlack: 0xc4b5fd,
unknownBlack: 0xc4b5fd,
leftBlack: 0x4c1d95,
rightBlack: 0x5b21b6,
unknownBlack: 0x5b21b6,
},
classic: {
left: 0x2563eb,
right: 0xdc2626,
unknown: 0x7c3aed,
leftBlack: 0x60a5fa,
rightBlack: 0xf87171,
unknownBlack: 0xa78bfa,
leftBlack: 0x1e3a8a,
rightBlack: 0x991b1b,
unknownBlack: 0x581c87,
},
ocean: {
left: 0x0284c7,
right: 0x06b6d4,
unknown: 0x0ea5e9,
leftBlack: 0x38bdf8,
rightBlack: 0x67e8f9,
unknownBlack: 0x7dd3fc,
leftBlack: 0x075985,
rightBlack: 0x0e7490,
unknownBlack: 0x0369a1,
},
forest: {
left: 0x15803d,
right: 0x16a34a,
unknown: 0x22c55e,
leftBlack: 0x4ade80,
rightBlack: 0x86efac,
unknownBlack: 0xbbf7d0,
leftBlack: 0x14532d,
rightBlack: 0x166534,
unknownBlack: 0x15803d,
},
cascade: {
left: 0x9333ea,
right: 0x22d3ee,
unknown: 0xa855f7,
leftBlack: 0xb06af5,
rightBlack: 0x4dd5ec,
unknownBlack: 0xb87af8,
leftBlack: 0x581c87,
rightBlack: 0x0e7490,
unknownBlack: 0x6b21a8,
},
};
/** Build a ThemePalette from three base hex colors (black-key variants auto-lightened). */
/** 사용자 지정 색상도 검은 건반 음표는 원래 색상의 약 68% 명도로 낮춘다. */
function paletteFromCustom(c: CustomColors): ThemePalette {
const l = hexToNum(c.leftHand);
const r = hexToNum(c.rightHand);
@@ -91,9 +92,9 @@ function paletteFromCustom(c: CustomColors): ThemePalette {
left: l,
right: r,
unknown: u,
leftBlack: lighten(l, 1.55),
rightBlack: lighten(r, 1.55),
unknownBlack: lighten(u, 1.55),
leftBlack: lighten(l, 0.68),
rightBlack: lighten(r, 0.68),
unknownBlack: lighten(u, 0.68),
};
}
@@ -11,7 +11,7 @@ export function FallingNotesView() {
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<FallingNotesRenderer | null>(null);
const lastSecondsRef = useRef(0);
const { settings, document: doc, midiEnabled, status, seek } = useAppStore();
const { settings, document: doc, midiEnabled, status, currentSeconds, seek } = useAppStore();
// Accuracy scoring - active when MIDI device connected
const accuracy = useAccuracy(midiEnabled && (status === "playing" || status === "paused"));
@@ -181,6 +181,32 @@ export function FallingNotesView() {
>
<canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
{status === "playing" && currentSeconds < 0 && (
<div
className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none"
aria-live="polite"
aria-label={`${Math.ceil(-currentSeconds)}초 뒤 재생`}
>
<div
style={{
minWidth: 88,
padding: "12px 20px",
borderRadius: 18,
background: "rgba(15, 23, 42, 0.78)",
border: "1px solid rgba(255, 255, 255, 0.24)",
color: "#ffffff",
fontSize: 38,
fontWeight: 800,
lineHeight: 1,
textAlign: "center",
boxShadow: "0 10px 32px rgba(0, 0, 0, 0.28)",
}}
>
{Math.ceil(-currentSeconds)}
</div>
</div>
)}
{midiEnabled && accuracy.correct + accuracy.wrong + accuracy.missed > 0 && (
<div
className="absolute top-2 left-2 z-10 pointer-events-none text-xs px-2 py-0.5 rounded-full font-medium"
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
type User = {
export type User = {
id: number;
username: string;
display_name: string;
@@ -18,6 +18,7 @@ type MidiItem = {
artist: string;
description: string;
original_name: string;
file_type: string;
size_bytes: number;
visibility: "public" | "members" | "shared" | "private";
min_tier: number;
@@ -85,11 +86,13 @@ export function LibraryPanel({
open,
onClose,
onLoad,
onAuthChange,
initialTab = "browse",
}: {
open: boolean;
onClose: () => void;
onLoad: (file: File) => void;
onAuthChange?: (user: User | null) => void;
initialTab?: LibraryTab;
}) {
const [tab, setTab] = useState<LibraryTab>("browse");
@@ -110,8 +113,9 @@ export function LibraryPanel({
const result = await apiJson<AuthResult>("/api/library/auth/me");
setUser(result.user);
setCsrf(result.csrf_token);
onAuthChange?.(result.user);
return result;
}, []);
}, [onAuthChange]);
const refreshItems = useCallback(async () => {
const params = new URLSearchParams();
@@ -185,10 +189,10 @@ export function LibraryPanel({
const response = await fetch(`/api/library/midi/${item.id}/file`);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || "MIDI 파일을 불러오지 못했습니다");
throw new Error(data.detail || "곡 또는 악보 파일을 불러오지 못했습니다");
}
const blob = await response.blob();
onLoad(new File([blob], item.original_name, { type: "audio/midi" }));
onLoad(new File([blob], item.original_name, { type: blob.type || "application/octet-stream" }));
onClose();
});
@@ -301,6 +305,7 @@ export function LibraryPanel({
if (!authenticatedUser) throw new Error("로그인 정보를 확인하지 못했습니다");
setUser(authenticatedUser);
setCsrf(result.csrf_token);
onAuthChange?.(authenticatedUser);
setTab(
initialTab === "admin" && authenticatedUser.role === "admin" ? "admin" : "browse"
);
@@ -314,6 +319,7 @@ export function LibraryPanel({
onSignedOut={() => {
setUser(null);
setCsrf(null);
onAuthChange?.(null);
setTab("browse");
}}
/>
@@ -736,7 +742,11 @@ function UploadTab({
className="upload-form"
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
// React 이벤트의 currentTarget은 이벤트 처리기가 끝나면 null이 될 수 있다.
// 업로드 응답을 기다린 뒤에도 안전하게 초기화할 수 있도록 실제 폼 요소를
// 비동기 작업 시작 전에 별도로 보관한다.
const formElement = event.currentTarget;
const form = new FormData(formElement);
if (visibility === "public") form.set("min_tier", "0");
run(async () => {
const response = await fetch("/api/library/midi", {
@@ -746,19 +756,19 @@ function UploadTab({
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || "업로드하지 못했습니다");
event.currentTarget.reset();
formElement.reset();
setVisibility("private");
await onUploaded();
});
}}
>
<h3>MIDI 업로드</h3>
<h3>곡·악보 업로드</h3>
<p>
파일은 MIDI 헤더와 크기를 검사합니다. 저작권을 보유하거나 공유 허가를 받은 곡만 올려 주세요.
MIDI와 MusicXML과 MXL 및 PDF 악보를 지원합니다. 파일 형식과 크기를 검사하며 저작권을 보유하거나 공유 허가를 받은 자료만 올려 주세요.
</p>
<label className="wide">
MIDI 파일
<input type="file" name="file" accept=".mid,.midi" required />
MIDI 또는 악보 파일
<input type="file" name="file" accept=".mid,.midi,.xml,.musicxml,.mxl,.pdf" required />
</label>
<label>
곡 제목
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import bus from "../../engine/EventBus";
import { useAppStore } from "../../store/useAppStore";
import { syncEngine } from "../../engine/SyncEngine";
@@ -21,6 +21,8 @@ export function SheetMusicView() {
const containerRef = useRef<HTMLDivElement>(null); // inner AlphaTab host
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const apiRef = useRef<any>(null);
const [apiReady, setApiReady] = useState(false);
const [sheetError, setSheetError] = useState("");
const playerReady = useRef(false); // true once soundFont is loaded
const scoreLoaded = useRef(false); // true once score SVG is rendered
const lastScrollTop = useRef(-1);
@@ -35,9 +37,10 @@ export function SheetMusicView() {
useEffect(() => {
if (!containerRef.current) return;
let cleanupBus: (() => void) | null = null;
let disposed = false;
import("@coderline/alphatab").then(({ AlphaTabApi }) => {
if (!containerRef.current) return;
if (disposed || !containerRef.current) return;
// In the "all" view the sheet sits in a full-width band at the top and
// renders as a single horizontal strip (layoutMode 1) that scrolls
@@ -48,7 +51,20 @@ export function SheetMusicView() {
const horizontal = useAppStore.getState().settings.layoutMode === "all";
const api = new AlphaTabApi(containerRef.current, {
core: { engine: "svg", enableLazyLoading: false, logLevel: 0 },
core: {
engine: "svg",
enableLazyLoading: false,
logLevel: 0,
// 악보는 UI 스레드에서 동기 렌더링한다. Vite 번들과 AlphaTab의
// 별도 Worker가 서로 다른 렌더링 자원을 잡아 빈 악보가 되는 것을 막는다.
useWorkers: false,
// AlphaTab은 일반 글꼴이 아니라 SMuFL Bravura 글꼴로 오선과 음표를
// 그린다. 이 경로가 없으면 제목만 보이고 악보 기호는 빈 화면이 된다.
fontDirectory: "/font/",
// Vite로 묶인 본체 파일을 Worker로 다시 실행하면 DOM 참조 때문에
// 실패한다. AlphaTab이 제공하는 전용 Worker 진입점을 고정한다.
scriptFile: "/assets/alphaTab.worker.mjs?v=20261003-1",
},
display: {
layoutMode: horizontal ? 1 : 0, // 1 = horizontal strip, 0 = page (vertical)
// Smaller scale in the horizontal "all" band so the full grand staff
@@ -75,6 +91,19 @@ export function SheetMusicView() {
} as never);
apiRef.current = api;
setApiReady(true);
// AlphaTab의 파일 해석과 렌더링 오류는 비동기로 전달된다. 빈 화면만
// 남기지 않고 사용자가 원인을 알 수 있도록 화면에 오류를 표시한다.
try {
api.error.on((error: unknown) => {
if (disposed) return;
const message = error instanceof Error ? error.message : String(error);
setSheetError(`악보를 표시하지 못했습니다: ${message}`);
});
} catch {
/* 이전 AlphaTab 버전에서는 오류 이벤트가 없을 수 있다. */
}
// ── Mute AlphaTab AFTER soundFont finishes loading ───────────────────
try {
@@ -90,20 +119,12 @@ export function SheetMusicView() {
try {
api.scoreLoaded.on(() => {
scoreLoaded.current = true;
if (!disposed) setSheetError("");
});
} catch {
/* ignore */
}
// ── Load score if MusicXML is already available ──────────────────────
const initialDoc = useAppStore.getState().document;
if (initialDoc?.musicXml) {
api.load(new TextEncoder().encode(sanitizeMusicXml(initialDoc.musicXml)));
} else if (initialDoc?.mxlBuffer) {
// .mxl is ZIP-compressed MusicXML; AlphaTab handles it natively
api.load(new Uint8Array(initialDoc.mxlBuffer));
}
// ── Click note to play sound ─────────────────────────────────────────
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -202,6 +223,7 @@ export function SheetMusicView() {
});
return () => {
disposed = true;
cleanupBus?.();
if (rafScrollId.current !== null) {
cancelAnimationFrame(rafScrollId.current);
@@ -248,7 +270,7 @@ export function SheetMusicView() {
// ── Load score when MusicXML/mxlBuffer arrives ───────────────────────────
useEffect(() => {
if (!apiRef.current) return;
if (!apiReady || !apiRef.current) return;
// Apply the correct stretchForce before every load so AlphaTab uses it
// in the render it triggers internally.
try {
@@ -259,13 +281,17 @@ export function SheetMusicView() {
}
if (doc?.musicXml) {
scoreLoaded.current = false;
apiRef.current.load(new TextEncoder().encode(sanitizeMusicXml(doc.musicXml)));
setSheetError("");
const accepted = apiRef.current.load(new TextEncoder().encode(sanitizeMusicXml(doc.musicXml)));
if (!accepted) setSheetError("악보 파일을 읽기 시작하지 못했습니다");
} else if (doc?.mxlBuffer) {
// .mxl is ZIP-compressed MusicXML; AlphaTab handles it natively
scoreLoaded.current = false;
apiRef.current.load(new Uint8Array(doc.mxlBuffer));
setSheetError("");
const accepted = apiRef.current.load(new Uint8Array(doc.mxlBuffer));
if (!accepted) setSheetError("압축 악보 파일을 읽기 시작하지 못했습니다");
}
}, [doc?.musicXml, doc?.mxlBuffer]);
}, [apiReady, doc?.musicXml, doc?.mxlBuffer]);
// Background logic:
// dark + keep white: explicit #fff so black notation stays readable on white
@@ -311,6 +337,20 @@ export function SheetMusicView() {
>
<div ref={containerRef} className={invertSheet ? "sheet-dark-invert" : ""} />
{sheetError && (
<div
role="alert"
className="mx-auto my-8 max-w-xl rounded-lg border px-4 py-3 text-sm"
style={{
borderColor: "#f0a0a0",
background: "#fff4f4",
color: "#9b1c1c",
}}
>
{sheetError}
</div>
)}
{settings.showKeySignature && doc?.keySignature && settings.layoutMode !== "all" && (
<div
className="absolute top-2 right-4 z-10 pointer-events-none text-xs px-2 py-0.5 rounded"
@@ -94,7 +94,7 @@ export function TransportControls() {
};
const total = doc?.totalDuration ?? 0;
const progress = total > 0 ? Math.min(1, currentSeconds / total) : 0;
const progress = total > 0 ? Math.max(0, Math.min(1, currentSeconds / total)) : 0;
const loopActive = loopStart !== null && loopEnd !== null;
const isPlaying = status === "playing";
const isPaused = status === "paused";
@@ -144,7 +144,7 @@ export function TransportControls() {
style={{ color: "var(--color-text-muted)", paddingLeft: 12 }}
aria-label="current time"
>
{formatTime(currentSeconds)}
{formatTime(Math.max(0, currentSeconds))}
</span>
{/* Enlarged hit area (vertical padding) around the visual bar */}
+11 -2
View File
@@ -21,6 +21,10 @@ interface SpeedTrainerOpts {
// How many doc-seconds ahead to emit "note:upcoming" for FallingNotes pre-rendering.
const LOOKAHEAD_DOC_SECONDS = 5.0;
const YT_DRIFT_THRESHOLD = 0.25;
// 곡의 맨 처음을 재생할 때 음표가 이미 건반에 닿은 상태로 시작하지 않도록
// 음수 시간부터 시계를 움직인다. 이 구간에는 음표만 내려오며 실제 MIDI 음은
// 각 음표의 원래 시작 시각인 0초 이후에 같은 AudioEngine으로 재생된다.
const START_LEAD_IN_SECONDS = 3.0;
export interface SyncEngineState {
status: PlaybackStatus;
@@ -147,7 +151,12 @@ class SyncEngine {
bus.emit("speedTrainer:stepped", { currentPct: this.speedTrainer.startPct });
}
if (this.countInBars > 0) {
if (startFrom <= 0.01) {
this.seekTo(-START_LEAD_IN_SECONDS);
await this.clock.start(-START_LEAD_IN_SECONDS);
this.startAnimLoop();
this.emitState("playing");
} else if (this.countInBars > 0) {
await this._playWithCountIn(startFrom);
} else {
this.seekTo(startFrom);
@@ -558,7 +567,7 @@ class SyncEngine {
}
// ── YouTube sync ──────────────────────────────────────────────────────────
if (this.ytPlayer && this.doc.youtubeId) {
if (docSeconds >= 0 && this.ytPlayer && this.doc.youtubeId) {
const expectedYtTime = docSeconds + this.doc.youtubeSyncOffset;
try {
const actual = this.ytPlayer.getCurrentTime();
+33 -10
View File
@@ -6,7 +6,7 @@ import { TransportControls } from "../components/Transport/TransportControls";
import { SettingsPanel } from "../components/Transport/SettingsPanel";
import { DevicePanel } from "../components/DevicePanel/DevicePanel";
import { LibraryPanel } from "../components/Library/LibraryPanel";
import type { LibraryTab } from "../components/Library/LibraryPanel";
import type { LibraryTab, User } from "../components/Library/LibraryPanel";
import { useAppStore } from "../store/useAppStore";
import type { LayoutMode } from "../store/useAppStore";
import bus from "../engine/EventBus";
@@ -31,6 +31,26 @@ export function PracticeView() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [libraryOpen, setLibraryOpen] = useState(false);
const [libraryInitialTab, setLibraryInitialTab] = useState<LibraryTab>("browse");
const [signedInUser, setSignedInUser] = useState<User | null>(null);
const updateSignedInUser = useCallback((user: User | null) => {
setSignedInUser(user);
}, []);
useEffect(() => {
let active = true;
fetch("/api/library/auth/me")
.then((response) => (response.ok ? response.json() : Promise.reject()))
.then((result) => {
if (active) setSignedInUser(result.user ?? null);
})
.catch(() => {
if (active) setSignedInUser(null);
});
return () => {
active = false;
};
}, []);
const openLibrary = (tab: LibraryTab) => {
setLibraryInitialTab(tab);
@@ -260,16 +280,18 @@ export function PracticeView() {
aria-label="로그인 또는 회원 가입"
className="library-open-button library-account-button"
>
로그인·가입
</button>
<button
onClick={() => openLibrary("admin")}
title="관리자 패널 열기"
aria-label="관리자 패널 열기"
className="library-open-button library-admin-button"
>
관리자
{signedInUser ? "내 계정" : "로그인·회원가입"}
</button>
{signedInUser?.role === "admin" && (
<button
onClick={() => openLibrary("admin")}
title="관리자 패널 열기"
aria-label="관리자 패널 열기"
className="library-open-button library-admin-button"
>
관리자 패널
</button>
)}
<div className="b2b-midi-device">
<DevicePanel />
</div>
@@ -378,6 +400,7 @@ export function PracticeView() {
initialTab={libraryInitialTab}
onClose={() => setLibraryOpen(false)}
onLoad={loadFile}
onAuthChange={updateSignedInUser}
/>
</div>
);