diff --git a/frontend/index.html b/frontend/index.html
index ebd62e0..10b30c6 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,5 +1,5 @@
-
+
@@ -7,8 +7,8 @@
'unsafe-inline' for script-src is needed for the error overlay below;
tighten by moving it to a separate .js file if desired. -->
- Bach to Basics
-
+ Bach to Basics 피아노 학습
+
@@ -82,7 +82,7 @@
el.style.display = 'block';
el.textContent = isDev
? '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) {
var el = document.getElementById('error-overlay');
@@ -90,7 +90,7 @@
if (isDev) {
el.textContent = (el.textContent || '') + '\n\nUNHANDLED PROMISE:\n' + (e.reason && e.reason.stack ? e.reason.stack : String(e.reason));
} else if (!el.textContent) {
- el.textContent = 'Something went wrong. Please reload the page.';
+ el.textContent = '오류가 발생했습니다. 페이지를 새로고침해 주세요.';
}
});
diff --git a/frontend/src/i18n/ko.ts b/frontend/src/i18n/ko.ts
new file mode 100644
index 0000000..0294c2f
--- /dev/null
+++ b/frontend/src/i18n/ko.ts
@@ -0,0 +1,102 @@
+const translations: Record = {
+ 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));
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 6f975f5..2c65d49 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,6 +1,9 @@
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
+import { installKoreanUi } from "./i18n/ko";
+
+installKoreanUi();
// StrictMode deliberately double-mounts in dev, which conflicts with Pixi.js's
// async init - Application.destroy() fires before init() resolves.
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 54e23ae..20d12e1 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -18,10 +18,10 @@ export default defineConfig({
// App icons + display name shown on install / home-screen / dock.
manifest: {
- name: "Bach to Basics",
- short_name: "Bach to Basics",
+ name: "Bach to Basics 피아노 학습",
+ short_name: "Bach 피아노",
description:
- "Browser-based piano practice tool - falling notes, sheet music, MIDI, and practice tools",
+ "악보와 내려오는 음표를 보며 MIDI 피아노를 연습하는 한국어 학습 도구",
theme_color: "#9333ea", // matches the purple accent
background_color: "#0a0a0a",
display: "standalone",