피아노 학습 화면을 한국어로 제공한다

화면 문구와 도움말 및 PWA 정보를 별도 한국어 리소스로 구성한다.
This commit is contained in:
이창현
2026-10-03 07:32:36 +09:00
parent 8b6db270f3
commit 7b864afc49
4 changed files with 113 additions and 8 deletions
+5 -5
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="ko">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -7,8 +7,8 @@
'unsafe-inline' for script-src is needed for the error overlay below; 'unsafe-inline' for script-src is needed for the error overlay below;
tighten by moving it to a separate .js file if desired. --> 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'" /> <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>Bach to Basics 피아노 학습</title>
<meta name="description" content="Browser-based piano practice tool. Import MIDI, MusicXML, or PDF sheet music and learn pieces with falling notes, interactive piano, A/B loop, speed trainer, and wait mode." /> <meta name="description" content="MIDI와 MusicXML 악보를 불러와 건반과 내려오는 음표를 보며 연습하는 한국어 피아노 학습 도구" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon-light.png" /> <link rel="icon" type="image/png" href="/favicon-light.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
@@ -82,7 +82,7 @@
el.style.display = 'block'; el.style.display = 'block';
el.textContent = isDev el.textContent = isDev
? 'JS ERROR:\n' + msg + '\n\nFile: ' + src + ':' + line + '\n\n' + (err && err.stack ? err.stack : '') ? 'JS ERROR:\n' + msg + '\n\nFile: ' + src + ':' + line + '\n\n' + (err && err.stack ? err.stack : '')
: 'Something went wrong. Please reload the page.'; : '오류가 발생했습니다. 페이지를 새로고침해 주세요.';
}; };
window.addEventListener('unhandledrejection', function(e) { window.addEventListener('unhandledrejection', function(e) {
var el = document.getElementById('error-overlay'); var el = document.getElementById('error-overlay');
@@ -90,7 +90,7 @@
if (isDev) { if (isDev) {
el.textContent = (el.textContent || '') + '\n\nUNHANDLED PROMISE:\n' + (e.reason && e.reason.stack ? e.reason.stack : String(e.reason)); el.textContent = (el.textContent || '') + '\n\nUNHANDLED PROMISE:\n' + (e.reason && e.reason.stack ? e.reason.stack : String(e.reason));
} else if (!el.textContent) { } else if (!el.textContent) {
el.textContent = 'Something went wrong. Please reload the page.'; el.textContent = '오류가 발생했습니다. 페이지를 새로고침해 주세요.';
} }
}); });
</script> </script>
+102
View File
@@ -0,0 +1,102 @@
const translations: Record<string, string> = {
Piano: '건반', Notes: '내려오는 음표', Sheet: '악보', All: '전체',
Import: '가져오기', Settings: '설정', Appearance: '화면', Keyboard: '건반',
'Falling notes': '내려오는 음표', Overlays: '보조 표시', Playback: '재생',
'No file loaded': '불러온 파일이 없습니다', 'Import a file': '파일 가져오기',
'or drag & drop anywhere': '또는 파일을 화면 어디에나 놓으세요',
'No MIDI device': '연결된 MIDI 장치 없음', 'MIDI error': 'MIDI 오류',
Stop: '정지', Play: '재생', Pause: '일시정지', Resume: '계속 재생',
Loop: '구간 반복', Speed: '연주 속도', 'Wait mode': '따라치기 모드',
MIDI: 'MIDI', MusicXML: 'MusicXML', 'PDF sheet music': 'PDF 악보', MP3: 'MP3',
'Left hand': '왼손', 'Right hand': '오른손', Other: '기타',
Left: '왼손', Right: '오른손', Both: '양손',
Natural: '흰건반', Sharps: '검은건반', 'C only': '도만 표시', None: '표시 안 함',
Violet: '보라색', Classic: '기본', Ocean: '바다', Forest: '숲', Cascade: '물결', Custom: '사용자 지정',
White: '흰색', Ivory: '아이보리', Labels: '음이름', 'Key color': '건반 색상',
'Note filter': '음표 필터', 'Octave grid': '옥타브 구분선',
'Min note height': '최소 음표 높이', 'Note roundness': '음표 모서리', 'Note outline': '음표 테두리',
'White score background': '악보 배경을 흰색으로 표시',
'Finger numbers': '손가락 번호', Generate: '생성', Regenerate: '다시 생성',
'Also on falling notes': '내려오는 음표에도 표시', 'Measure numbers': '마디 번호',
'Beat lines': '박자선', 'Sustain pedal': '서스테인 페달',
'Sustained note ghost': '지속 음표 잔상', 'Impact effect': '타건 효과',
Off: '끄기', Bloom: '퍼짐', Side: '측면', Trail: '잔상',
'Note window': '입력 판정 범위', Transpose: '조옮김', 'Audio offset': '소리 지연 보정',
'Scroll to seek': '스크롤로 재생 위치 이동', Instrument: '악기', Practice: '연습',
'Wait hand': '따라칠 손', 'Hand volumes': '손별 음량', 'Speed trainer': '속도 훈련',
'Start speed': '시작 속도', 'End speed': '종료 속도', Step: '증가 폭',
'Reset to defaults': '기본값으로 되돌리기', 'Click again to confirm reset': '한 번 더 눌러 초기화 확인',
'Playing': '재생 중', 'current time': '현재 시간', 'total duration': '전체 재생 시간',
'Playback position': '재생 위치', 'Playback speed': '연주 속도',
'Close settings': '설정 닫기', 'Open settings': '설정 열기',
'Dismiss error': '오류 닫기',
'Switch to light mode': '밝은 화면으로 전환', 'Switch to dark mode': '어두운 화면으로 전환',
'Select MIDI input device': 'MIDI 입력 장치 선택',
'Show only the piano keyboard view': '건반만 표시',
'Show only the falling notes view': '내려오는 음표만 표시',
'Show only the sheet music view': '악보만 표시',
'Show all views at once': '모든 화면을 함께 표시',
'Stop and return to start': '정지하고 처음으로 이동',
'Pause until you play the correct note': '올바른 음을 누를 때까지 재생 대기',
'Clear loop': '반복 구간 지우기', 'Start loop': '반복 시작점 지정',
'Set loop start to current position': '현재 위치를 반복 시작점으로 지정',
'Set loop end to current position': '현재 위치를 반복 종료점으로 지정',
'Toggle metronome': '메트로놈 켜기 또는 끄기',
'Export current score': '현재 악보 내보내기',
'Load a file to enable export': '내보내려면 먼저 파일을 불러오세요',
'Save the current score in another format': '현재 악보를 다른 형식으로 저장',
'Reset all settings to their default values': '모든 설정을 기본값으로 되돌리기',
'Something went wrong. Please reload the page.': '오류가 발생했습니다. 페이지를 새로고침해 주세요.',
};
const fragments: Array<[RegExp, string]> = [
[/^Connected: (.+)$/, '$1 연결됨'],
[/^Mute (left|right) hand$/, '$1손 소리 끄기'],
[/^Unmute (left|right) hand$/, '$1손 소리 켜기'],
[/^No (MIDI|MusicXML) data in this document$/, '이 문서에는 $1 데이터가 없습니다'],
[/^Waiting for you to play the correct note$/, '올바른 음을 연주할 때까지 기다리는 중'],
[/^No file loaded - drag & drop or click ↑ Import$/, '불러온 파일이 없습니다. 파일을 놓거나 ↑ 가져오기를 누르세요'],
];
function translate(value: string): string {
if (translations[value]) return translations[value];
for (const [pattern, replacement] of fragments) {
if (pattern.test(value)) return value.replace(pattern, replacement).replace('left손', '왼손').replace('right손', '오른손');
}
return value;
}
function translateNode(root: Node): void {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
const original = node.nodeValue ?? '';
const trimmed = original.trim();
if (!trimmed) continue;
const translated = translate(trimmed);
if (translated !== trimmed) node.nodeValue = original.replace(trimmed, translated);
}
if (root instanceof Element) {
const elements = [root, ...root.querySelectorAll('*')];
for (const element of elements) {
for (const attribute of ['title', 'aria-label', 'placeholder']) {
const original = element.getAttribute(attribute);
if (!original) continue;
const translated = translate(original);
if (translated !== original) element.setAttribute(attribute, translated);
}
}
}
}
export function installKoreanUi(): void {
document.documentElement.lang = 'ko';
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) translateNode(node);
if (mutation.type === 'characterData' && mutation.target.parentNode) translateNode(mutation.target.parentNode);
}
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
requestAnimationFrame(() => translateNode(document.body));
}
+3
View File
@@ -1,6 +1,9 @@
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./index.css"; import "./index.css";
import App from "./App"; import App from "./App";
import { installKoreanUi } from "./i18n/ko";
installKoreanUi();
// StrictMode deliberately double-mounts in dev, which conflicts with Pixi.js's // StrictMode deliberately double-mounts in dev, which conflicts with Pixi.js's
// async init - Application.destroy() fires before init() resolves. // async init - Application.destroy() fires before init() resolves.
+3 -3
View File
@@ -18,10 +18,10 @@ export default defineConfig({
// App icons + display name shown on install / home-screen / dock. // App icons + display name shown on install / home-screen / dock.
manifest: { manifest: {
name: "Bach to Basics", name: "Bach to Basics 피아노 학습",
short_name: "Bach to Basics", short_name: "Bach 피아노",
description: description:
"Browser-based piano practice tool - falling notes, sheet music, MIDI, and practice tools", "악보와 내려오는 음표를 보며 MIDI 피아노를 연습하는 한국어 학습 도구",
theme_color: "#9333ea", // matches the purple accent theme_color: "#9333ea", // matches the purple accent
background_color: "#0a0a0a", background_color: "#0a0a0a",
display: "standalone", display: "standalone",