From f5354dcf1cc16aa19589d07a7b793529f6ad31cf Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Sun, 30 Aug 2026 17:59:34 +0100
Subject: [PATCH] Add Korean
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A tenth table, 474 keys. Nothing about the machinery needed changing; it was
built to take another language.
Korean does not inflect nouns for number, so every plural family here is .other
only, the way ja, zhHans and id already do it. Copying the English key set would
have produced a table full of .one keys the resolver never reads, and the
coverage check compares plural families rather than raw keys precisely so that
does not look like a gap.
The register is the plain 해요체 the interface wants rather than the stiffer
합니다체, and the audio loanwords a Korean musician already uses stay loanwords:
보컬, 드럼, 베이스, 루프. Translating those would read as odd to anyone who has
opened a DAW. Korean is verb-final, so several strings reorder around their
placeholders rather than substituting in place; every {placeholder} and every
HTML tag was checked to survive that.
The locale test caught what it was written to catch. Adding a language to
LANGUAGES without a matching branch in _detectDefault ships a table nobody's
browser can reach on its own, and that is exactly what the first run reported:
FAIL ko has locales covering it -- add this language's locales to LOCALES
So ko and ko-KR route to it, and the test knows about them.
Closes #501
---
static/js/i18n.js | 543 +++++++++++++++++++++++++++++++++-
tests/js/i18n-detect.test.mjs | 1 +
2 files changed, 543 insertions(+), 1 deletion(-)
diff --git a/static/js/i18n.js b/static/js/i18n.js
index 0598a962..0ec49ee8 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -25,6 +25,7 @@ export const LANGUAGES = [
{ code: "en", flag: "🇬🇧", name: "English" },
{ code: "pl", flag: "🇵🇱", name: "Polski" },
{ code: "ja", flag: "🇯🇵", name: "日本語" },
+ { code: "ko", flag: "🇰🇷", name: "한국어" },
{ code: "zh-Hans", flag: "🇨🇳", name: "简体中文" },
{ code: "de", flag: "🇩🇪", name: "Deutsch" },
{ code: "fr", flag: "🇫🇷", name: "Français" },
@@ -65,6 +66,7 @@ export function _detectDefault() {
const lower = nav.toLowerCase();
if (lower.startsWith("pl")) return "pl";
if (lower.startsWith("ja")) return "ja";
+ if (lower.startsWith("ko")) return "ko";
// Simplified Chinese: zh-CN, zh-Hans, zh-SG, or bare "zh" (assume simplified
// unless it's explicitly a traditional-script region, which we don't ship).
if (lower.startsWith("zh")) return "zh-Hans";
@@ -5206,4 +5208,543 @@ const es = {
"resetConfirm.failedConnection": "No se pudo restablecer — revisa tu conexión.",
};
-export const TRANSLATIONS = { en, pl, ja, "zh-Hans": zhHans, de, fr, es, pt, "pt-PT": ptPT, id };
+// Korean. Like ja, zhHans and id, Korean does not inflect nouns for
+// number, so every plural family here is .other only -- the buckets the
+// plural resolver actually reads for this language.
+//
+// Register is the plain 해요체 the interface wants, not the stiffer 합니다체.
+// The audio loanwords a Korean musician already uses (보컬, 드럼, 베이스,
+// 루프) stay loanwords; translating those would read as odd to anyone who
+// has opened a DAW.
+const ko = {
+
+ "sections.kind.intro": "인트로",
+ "sections.kind.outro": "아웃트로",
+ "sections.kind.break": "브레이크",
+ "sections.kind.bridge": "브리지",
+ "sections.kind.inst": "연주",
+ "sections.kind.solo": "솔로",
+ "sections.kind.verse": "벌스",
+ "sections.kind.chorus": "코러스",
+ "sections.kind.part": "구간",
+ "sections.kindNumbered": "{kind} {n}",
+ "sections.clear": "지우기",
+ "sections.clearAria": "모든 구간 지우기",
+ "sections.clearConfirm": "정말요?",
+ "structure.toggle": "곡 구조",
+ "structure.toggleTitle": "실험적 곡 구조 분석: 분리한 뒤 인트로, 벌스, 코러스를 자동으로 표시해요",
+ "structure.experimental": "실험적",
+ "doc.title": "StemDeck, 어떤 곡이든 스템으로 분리",
+
+ "topbar.urlPlaceholder": "검색하거나 YouTube 또는 SoundCloud 링크를 붙여넣거나 오디오 파일을 끌어다 놓으세요…",
+ "topbar.removeFile": "파일 제거",
+ "topbar.uploadFile": "오디오 파일 업로드",
+ "extract.label": "추출",
+ "extract.all": "전체",
+ "process.splitStems": "스템 분리",
+
+ "stem.original": "원본",
+ "stem.vocals": "보컬",
+ "stem.lead_vocals": "리드 보컬",
+ "stem.backing_vocals": "백 보컬",
+ "stem.drums": "드럼",
+ "stem.bass": "베이스",
+ "stem.guitar": "기타",
+ "stem.piano": "피아노",
+ "stem.other": "기타 소리",
+ "stem.others": "기타 소리",
+
+ "vocalMode.groupAria": "보컬 모드",
+ "vocalMode.all": "전체",
+ "vocalMode.split": "리드 + 백",
+
+ "aria.mute": "{name} 음소거",
+ "aria.solo": "{name} 솔로",
+ "aria.laneKeyUp": "{name} 반음 올리기",
+ "aria.laneKeyDown": "{name} 반음 내리기",
+ "mixer.key.title": "이 트랙 조옮김: {n} 반음",
+ "mixer.key.drumsLocked": "드럼은 조옮김하지 않아요",
+ "mixer.key.unavailable": "조옮김에는 Web Audio가 필요해요",
+ "aria.soloOnly": "{name}만 솔로",
+ "aria.download": "{name} 다운로드",
+ "aria.volume": "{name} 볼륨",
+
+ "notif.bell": "알림",
+ "notif.title": "알림",
+ "notif.clearAll": "모두 지우기",
+ "notif.close": "알림 닫기",
+ "notif.newRelease": "새 버전이 나왔어요",
+ "notif.dismiss": "알림 지우기",
+ "notif.empty": "새 알림이 없어요",
+
+ "nav.libraryNav": "라이브러리 탐색",
+ "nav.toggleLibrary": "라이브러리 접기/펼치기",
+ "nav.library": "라이브러리",
+ "nav.favorites": "즐겨찾기",
+ "nav.trash": "휴지통",
+ "nav.importQueue": "가져오기 대기열",
+ "nav.queue": "대기열",
+ "nav.settings": "설정",
+ "nav.weRecommend": "추천",
+ "nav.weRecommendLine1": "이런 곳도",
+ "nav.weRecommendLine2": "추천해요",
+ "nav.help": "도움말",
+
+ "search.ariaLabel": "라이브러리 검색",
+ "search.placeholder": "검색 또는 #태그…",
+ "search.tab.ytSongs": "YouTube 곡",
+ "search.tab.ytPlaylists": "YouTube 재생목록",
+ "search.tab.scSongs": "SoundCloud 곡",
+ "search.searching": "검색 중…",
+ "search.noResults": "결과가 없어요",
+ "search.failed": "검색에 실패했어요. 연결을 확인해 주세요.",
+ "search.tooLong": "{mins}분 초과",
+ "search.tooLongHint": "설정한 {mins}분 제한보다 길어요. 설정에서 바꿀 수 있어요.",
+ "search.preview": "미리 듣기",
+ "search.play": "재생",
+ "search.pause": "일시정지",
+ "search.seek": "이동",
+ "search.previewFailed": "미리 들을 수 없어요",
+ "search.tagSuggestions": "태그 추천",
+ "search.placeholderLibrary": "라이브러리 검색…",
+ "search.placeholderFavorites": "즐겨찾기 검색…",
+ "search.placeholderTrash": "휴지통 검색…",
+ "trash.empty": "휴지통 비우기",
+ "trash.isEmptyState": "휴지통이 비어 있어요",
+ "trash.noSearchMatch": "검색과 맞는 삭제된 트랙이 없어요",
+
+ "meta.key": "키",
+ "meta.bpm": "BPM",
+ "meta.lufs": "LUFS",
+ "meta.duration": "길이",
+ "meta.scale": "음계",
+ "meta.dynamicRange": "다이내믹 레인지",
+ "meta.tempoStability": "템포 안정성",
+
+ "presence.vocalGlobal": "전체 보컬",
+ "presence.drum": "드럼 강도",
+ "presence.bass": "베이스 두께",
+ "presence.guitar": "기타 비중",
+ "presence.piano": "피아노 비중",
+ "presence.other": "기타 소리",
+
+ "sections.title": "구간",
+ "sections.add": "추가",
+ "sections.addAria": "구간 추가",
+ "sections.savingAria": "구간 저장 중",
+
+ "mixer.title": "믹서",
+
+ "panels.analysis": "분석",
+
+ "panels.clickToCollapse": "눌러서 접기",
+
+ "panels.timeline": "타임라인",
+
+ "panels.analysisTitle": "트랙 분석 표시하거나 숨기기",
+
+ "panels.sectionsTitle": "구간 막대 표시하거나 숨기기",
+
+ "panels.timelineTitle": "타임라인 표시하거나 숨기기",
+ "mixer.hint": "페이더 드래그 · M/S",
+ "stemsPanel.ariaLabel": "스템",
+
+ "beatgrid.toolbarAria": "비트 그리드 편집기",
+ "beatgrid.gridLabel": "그리드",
+ "beatgrid.toolAria": "도구",
+ "beatgrid.move": "이동",
+ "beatgrid.moveTitle": "비트를 끌어서 옮겨요",
+ "beatgrid.add": "추가",
+ "beatgrid.addTitle": "눌러서 비트를 추가해요",
+ "beatgrid.delete": "삭제",
+ "beatgrid.deleteTitle": "비트를 누르면 지워져요",
+ "beatgrid.barLine": "마디선",
+ "beatgrid.barLineTitle": "비트를 누르면 첫박으로 표시해요",
+ "beatgrid.ripple": "연동",
+ "beatgrid.snap": "스냅",
+ "beatgrid.bar": "마디",
+ "beatgrid.barAria": "이 구간의 마디당 비트 수",
+ "beatgrid.undo": "실행 취소",
+ "beatgrid.undoTitle": "실행 취소 (Cmd/Ctrl+Z)",
+ "beatgrid.redo": "다시 실행",
+ "beatgrid.redoTitle": "다시 실행 (Cmd/Ctrl+Shift+Z)",
+ "beatgrid.reset": "되돌리기",
+ "beatgrid.resetTitle": "편집을 모두 버리고 감지된 그리드로 되돌려요",
+ "beatgrid.done": "완료",
+ "beatgrid.hint": "비트를 끌면 구간의 박자가 다시 맞춰져요. Alt를 누르고 끌면 한 비트만 움직여요.",
+
+ "wave.loadingAria": "파형 불러오는 중",
+ "job.cancelAria": "작업 취소",
+ "job.cancel": "취소",
+
+ "footer.stemsPlaceholder": "— 스템",
+ "footer.stemsCount.other": "스템 {count}개",
+
+ "footer.extractedLabel": "추출",
+ "footer.extracted": "{when} 추출",
+ "fav.toggleAria": "즐겨찾기 켜기/끄기",
+ "footer.seekAria": "이동",
+
+ "export.optionsAria": "내보내기 옵션",
+ "export.formatAria": "내보내기 형식",
+ "export.includeClick": "클릭 트랙 포함",
+ "export.videoFailed": "MP4로 내보낼 수 없어요. 이 트랙의 영상을 가져오지 못했어요. 오디오 스템에는 영향이 없어요.",
+ "export.includeClickTitle": "내보내는 파일에 클릭 트랙을 섞어요",
+ "export.addCountIn": "카운트인 추가 (1마디)",
+ "export.addCountInTitle": "오디오 앞에 카운트인 클릭을 한 마디 넣어요",
+ "export.mix": "믹스 내보내기",
+ "export.mixDesc": "믹스한 오디오를 내보내요",
+ "export.mixDescVideo": "원본 영상과 함께 믹스를 내보내요",
+ "export.failed": "내보내지 못했어요.",
+ "export.allMuted": "모든 스템이 음소거라 내보낼 것이 없어요.",
+ "export.noStems": "내보낼 스템이 없어요.",
+ "export.mixing": "내보내는 중…",
+ "export.allStems": "모든 스템 내보내기",
+ "export.allStemsDesc": "모든 스템을 .zip 하나로",
+ "export.currentRegion": "선택 구간 내보내기",
+ "export.currentRegionDesc": "선택한 구간을 내보내요",
+
+ "transport.group": "트랜스포트",
+ "transport.stop": "정지",
+ "transport.playPause": "재생 / 일시정지",
+
+ "position.group": "위치",
+ "position.loopTitle": "선택한 구간을 반복해요 (L)",
+ "position.loopLabel": "구간 반복",
+ "position.loopExactTitle": "반복 시작과 끝을 정확히 (mm:ss.mmm 또는 초)",
+ "position.loopStartAria": "반복 시작",
+ "position.loopEndAria": "반복 끝",
+
+ "speed.group": "속도",
+ "pitch.group": "전체 키",
+ "pitch.resetTitle": "모든 트랙을 원래 키로 되돌려요",
+ "pitch.downTitle": "반음 내리기",
+ "pitch.upTitle": "반음 올리기",
+ "speed.ariaLabel": "재생 속도",
+
+ "click.group": "클릭 트랙",
+ "click.alpha": "알파",
+ "click.toggleTitle": "클릭 트랙 (K)",
+ "click.toggleAria": "클릭 트랙",
+ "click.unavailableAria": "클릭 트랙을 쓸 수 없음",
+ "click.on": "켜기",
+ "click.optionsAria": "클릭 트랙 옵션",
+ "click.countIn": "카운트인",
+ "click.countInTitle": "재생을 누르면 곡이 시작하기 전에 클릭을 한 마디 세어요",
+ "click.barAccentAria": "N 비트마다 강세",
+ "click.auto": "자동 (감지됨)",
+ "click.autoNone": "자동 (찾지 못함)",
+ "click.off": "끄기",
+ "click.editTitle": "비트 그리드 편집",
+ "click.grid": "그리드",
+ "click.rateAria": "클릭 속도",
+ "click.rateTitle": "클릭 속도를 절반 또는 두 배로",
+ "click.half": "절반 속도",
+ "click.detected": "감지된 템포",
+ "click.double": "두 배 속도",
+ "click.volumeAria": "클릭 볼륨",
+ "click.volumeTitle": "클릭 볼륨: {pct}",
+ "click.reason.loadTrack": "클릭을 쓰려면 트랙을 먼저 열어 주세요",
+ "click.reason.playbackUnavailable": "이 트랙은 재생할 수 없어요",
+ "click.reason.noBeatGrid": "이 트랙에는 비트 그리드가 없어요",
+ "click.reason.unavailableOnPath": "이 재생 방식에서는 클릭 트랙을 쓸 수 없어요",
+ "click.reason.needsWebAudio": "클릭 트랙에는 Web Audio 엔진이 필요해요",
+
+ "about.title": "StemDeck",
+ "about.tagline": "오픈 소스. 구독 없음. 음악하는 사람이 음악하는 사람을 위해 만들었어요.",
+ "about.closeAria": "정보 창 닫기",
+ "about.website": "웹사이트",
+ "about.github": "GitHub",
+ "about.discord": "Discord",
+ "about.reddit": "Reddit",
+ "about.instagram": "Instagram",
+ "about.x": "X",
+
+ "friends.title": "추천",
+ "friends.tagline": "좋은 일을 하는 멋진 사람들이에요. 한번 만나 보세요 ❤️",
+ "friends.closeAria": "추천 창 닫기",
+
+ "release.title": "새 버전이 나왔어요",
+ "release.closeAria": "릴리스 창 닫기",
+ "release.updateServer": "서버 업데이트하기",
+ "release.pullImage": "새 이미지를 받고 컨테이너를 다시 시작하세요:",
+ "release.unraidNote": "Unraid에서는 Community Applications 템플릿으로 업데이트하세요.",
+ "release.download": "다운로드",
+ "release.allReleases": "모든 릴리스",
+ "release.updateNow": "지금 업데이트",
+ "release.downloading": "업데이트 받는 중…",
+ "release.restartUpdate": "다시 시작해서 업데이트",
+ "release.applying": "업데이트 적용 중…",
+ "release.updateFailed": "업데이트에 실패했어요",
+
+ "failure.title": "무언가 실패했어요",
+ "failure.closeAria": "오류 창 닫기",
+ "failure.hint": "아래 내용이 리포트에 들어가요. 트랙 제목과 원본 링크는 포함되지 않으니, 도움이 될 것 같으면 직접 적어 주세요. 아래 버튼을 누르면 클립보드에 복사돼요.",
+ "failure.includeLogs": "최근 로그 포함",
+ "failure.collecting": "정보 모으는 중…",
+ "failure.fetchingLogs": "로그 가져오는 중…",
+ "failure.logsIncluded": "로그가 포함됐어요",
+ "failure.reportGithub": "GitHub에 알리기",
+ "failure.reportDiscord": "Discord에 알리기",
+
+ "settings.title": "설정",
+ "settings.closeAria": "설정 닫기",
+ "settings.tab.general": "일반",
+ "settings.tab.network": "네트워크",
+ "settings.tab.export": "내보내기",
+ "settings.tab.logs": "로그",
+ "settings.tab.registry": "레지스트리",
+ "settings.readOnlyServer": "서버 모드에서는 이 설정을 바꿀 수 없어요. 바꾸려면 서버 설정(예: docker-compose.yml)을 수정하고 다시 시작하세요.",
+
+ "settings.language.title": "언어",
+ "settings.language.desc": "이 앱에서 쓸 표시 언어예요.",
+
+ "settings.maxDuration.title": "최대 트랙 길이",
+ "settings.maxDuration.desc": "처리할 수 있는 가장 긴 트랙 길이예요, 분 단위 (최대 {max}).",
+ "settings.playlistLimit.title": "재생목록 가져오기 제한",
+ "settings.playlistLimit.desc": "재생목록 한 번에 대기열에 넣을 수 있는 최대 트랙 수예요 (최대 200).",
+ "settings.cookies.title": "YouTube 쿠키",
+ "settings.cookies.desc": "선택 사항이에요. cookies.txt 파일 경로로, YouTube가 StemDeck에게 봇이 아님을 확인하라고 할 때만 써요. 가져오기가 실패하지 않는다면 비워 두세요.",
+ "settings.cookies.placeholder": "cookies.txt 경로",
+ "settings.cookies.invalid": "파일을 찾을 수 없거나 읽을 수 없어요.",
+ "settings.stemsLocation.title": "StemData 위치",
+ "settings.autoDelete.title": "완료된 트랙 자동 삭제",
+ "settings.autoDelete.desc": "켜지 않으면 꺼져 있어요. 분리한 트랙은 기본적으로 계속 보관해요. 삭제하면 되돌릴 수 없어요.",
+ "settings.autoDelete.daysTitle": "삭제까지",
+ "settings.autoDelete.daysDesc": "완료된 트랙을 삭제하기 전까지 보관하는 일수예요 (최대 {max}).",
+ "settings.stemsLocation.change": "변경…",
+ "settings.stemsLocation.resetting": "되돌리는 중…",
+ "settings.stemsLocation.resetFailed": "되돌리지 못했어요. 연결을 확인해 주세요.",
+ "settings.stemsLocation.syncing": "동기화 중…",
+ "settings.stemsLocation.syncFailed": "동기화하지 못했어요. 연결을 확인해 주세요.",
+ "settings.stemsLocation.inSync": "모든 트랙이 동기화됐어요.",
+ "settings.stemsLocation.unavailable": "사용할 수 없음",
+ "settings.stemsLocation.persistFailed": "위치는 바뀌었지만 저장되지 않았어요. 다시 시작하면 이전 폴더로 돌아가요.",
+ "settings.stemsLocation.restartNote": "StemDeck을 다시 시작하면 전환이 끝나요.",
+ "settings.stemsLocation.pickerFailed": "폴더 선택 창을 열지 못했어요.",
+ "settings.stemsLocation.moving": "스템을 옮기는 중이에요. 라이브러리가 크면 시간이 걸릴 수 있어요.",
+ "settings.stemsLocation.moveFailed": "스템 폴더를 옮기지 못했어요.",
+ "settings.stemsLocation.serverUnreachable": "서버에 연결하지 못했어요.",
+ "settings.stemsLocation.movedPersistFailed.other": "{count}개를 옮겼지만 새 위치로 저장하지 못했어요 (폴더에 쓰기 권한이 있는지 확인해 주세요). 지금 다시 시작하면 이전 위치로 돌아가요. 다시 설정해 보세요.",
+ "settings.stemsLocation.movedOk.other": "{count}개를 옮겼어요. StemDeck을 다시 시작하면 전환이 끝나요.",
+
+ "settings.device.title": "연산 장치",
+ "settings.device.desc": "스템 분리에 쓸 장치예요. 다음 트랙부터 적용돼요{resolved}.",
+ "settings.device.auto": "자동",
+ "settings.device.cuda": "CUDA (NVIDIA)",
+ "settings.device.mps": "MPS (Apple Silicon)",
+ "settings.device.cpu": "CPU",
+ "settings.device.currently": " (현재: {device})",
+ "settings.device.notAvailable": " (사용할 수 없음)",
+ "settings.quality.title": "분리 품질",
+ "settings.quality.desc": "최고 품질은 분리기를 두 번 돌려 무작위로 어긋낸 결과를 평균해요. 스템이 더 깨끗해지지만 시간은 두 배 걸려요.",
+ "settings.quality.standard": "표준",
+ "settings.quality.best": "최고 (2배 느림)",
+
+ "settings.outOfSync.subhead": "동기화되지 않은 트랙",
+ "settings.outOfSync.colName": "이름",
+ "settings.outOfSync.colSource": "원본",
+ "settings.outOfSync.colLocation": "위치",
+ "settings.outOfSync.resync": "동기화되지 않은 트랙 다시 맞추기",
+ "settings.outOfSync.allSynced": "모든 트랙이 동기화됨",
+ "settings.outOfSync.summary.other": "{count}개 트랙이 동기화되지 않았어요",
+ "library.importedFile": "가져온 파일",
+ "library.syncing": "동기화 중…",
+ "library.syncFailed": "동기화하지 못했어요. 연결을 확인해 주세요.",
+ "library.recent": "최근",
+ "library.stemCollections": "스템 모음",
+ "library.tags": "태그",
+ "library.newFolder": "새 폴더",
+ "queue.importQueue": "가져오기 대기열",
+ "queue.paused.other": "일시정지됨. 지난 세션에서 남은 트랙 {count}개예요.",
+ "queue.start": "시작",
+ "queue.starting": "시작하는 중…",
+ "release.viewDownload": "다운로드 보기",
+
+ "track.localFile": "로컬 파일",
+ "track.web": "웹",
+ "track.losslessWav": "무손실 (WAV)",
+ "track.compressedMp3": "압축 (MP3)",
+ "track.qualityHigh": "높음",
+ "track.untitled": "제목 없는 트랙",
+ "track.unknown": "알 수 없는 트랙",
+ "track.removed": "삭제됨",
+ "track.moveToTrash": "휴지통으로 옮기기",
+ "track.moveTitleToTrash": "{title}을(를) 휴지통으로 옮기기",
+ "folder.dragToReorder": "끌어서 순서 바꾸기",
+ "folder.newSubfolder": "새 하위 폴더",
+ "folder.deleteFolder": "폴더 삭제",
+ "folder.unsorted": "미분류",
+ "favorites.noSearchMatch": "검색과 맞는 즐겨찾기가 없어요",
+ "favorites.empty": "아직 즐겨찾기가 없어요. 트랙의 ♥를 누르면 저장돼요",
+ "queue.extractNext": "이 트랙을 다음으로 추출",
+ "queue.moveToFront": "{title}을(를) 대기열 맨 앞으로",
+ "queue.cancelImport": "이 가져오기 취소",
+ "queue.cancelImportOf": "{title} 가져오기 취소",
+ "queue.pausedStatus": "일시정지됨",
+ "queue.positionInLine": "대기 중. {position}번째예요",
+
+ "settings.exportLogs.title": "로그 내보내기",
+ "settings.exportLogs.desc": "모든 로그 파일을 zip 하나로 받아요. 버그 리포트에 첨부하기 좋아요. 파일이 어디에 있는지는 로그 탭에서 볼 수 있어요.",
+ "settings.exportLogs.button": "로그 내보내기",
+ "settings.exportLogs.preparing": "준비 중…",
+ "settings.exportLogs.error": "로그를 내보내지 못했어요.",
+ "status.unavailable": "사용할 수 없음",
+
+ "settings.resetData.title": "앱 데이터 초기화",
+ "settings.resetData.desc": "모든 트랙, 작업, 라이브러리 항목을 영구히 삭제해요. 공유 서버라면 쓰는 사람 모두에게 영향을 줘요. 되돌릴 수 없어요.",
+ "settings.resetData.button": "앱 데이터 초기화…",
+
+ "settings.network.allowTitle": "네트워크에서 StemDeck에 접속 허용",
+ "settings.network.allowDesc": "휴대폰 같은 다른 기기에서 아래 주소로 StemDeck을 열 수 있게 해요.",
+ "settings.network.lockNote": "StemDeck을 서버 모드로 켜면 바꿀 수 없어요. 이때 네트워크 접속은 서버 설정에서 정해져요.",
+ "settings.network.port.title": "포트",
+ "settings.network.port.desc": "StemDeck이 쓰는 포트예요. 다시 시작하면 적용돼요.",
+ "settings.network.noConnection": "로컬 네트워크 연결을 찾지 못했어요.",
+ "settings.network.qrHint": "카메라가 너무 신나지 않도록 흐리게 해 뒀어요. 눌러서 확인하세요.",
+ "settings.network.tapToUnblur": "눌러서 선명하게",
+ "settings.network.qrCodeFor": "{url} QR 코드",
+
+ "settings.export.sampleRate.title": "샘플레이트",
+ "settings.export.sampleRate.desc": "내보내는 믹스와 구간(WAV, FLAC, MP3)의 샘플레이트예요. 대부분의 DAW와 샘플러에는 44.1 kHz가 잘 맞고, 장비가 다른 값을 요구하면 바꾸세요.",
+ "settings.export.videoQuality.title": "MP4 영상 품질",
+ "settings.export.videoQuality.desc": "MP4 내보내기와 YouTube 영상의 최대 해상도예요.",
+
+ "settings.logs.locationTab": "위치",
+ "settings.logs.applicationTab": "애플리케이션 로그",
+ "settings.logs.backendTab": "백엔드 로그",
+ "settings.logs.setupTab": "설치 로그",
+ "settings.logs.refresh": "새로고침",
+ "settings.logs.loading": "불러오는 중…",
+ "settings.logs.location.title": "로그 위치",
+ "settings.logs.location.desc": "이 컴퓨터에서 StemDeck이 로그를 쓰는 곳이에요. 읽기 전용이니 파일 탐색기에서 열거나 로그 내보내기를 쓰세요.",
+ "settings.logs.application.title": "애플리케이션 로그",
+ "settings.logs.application.desc": "stemdeck.log의 최근 한 시간이에요. 파이프라인, API, 작업 기록이 담겨요. 읽기 전용이에요.",
+ "settings.logs.applicationAria": "애플리케이션 로그 (읽기 전용)",
+ "settings.logs.backend.title": "백엔드 로그",
+ "settings.logs.backend.desc": "backend.log의 최근 한 시간이에요. 내장 Python 프로세스의 원본 출력으로, 애플리케이션 로그가 남기기 전에 중단시킨 오류까지 들어 있어요. 데스크톱 앱 전용이고 읽기 전용이에요.",
+ "settings.logs.backendAria": "백엔드 로그 (읽기 전용)",
+ "settings.logs.setup.title": "설치 로그",
+ "settings.logs.setup.desc": "setup.log의 최근 한 시간이에요. 첫 실행 설정과 GPU 런타임 설치 기록이에요. 데스크톱 앱 전용이고 읽기 전용이에요.",
+ "settings.logs.setupAria": "설치 로그 (읽기 전용)",
+
+ "settings.registry.title": "작업 레지스트리",
+ "settings.registry.desc": "registry.json을 읽기 전용으로 본 거예요. 디스크에 저장된 완료 작업 목록이에요.",
+ "settings.registry.aria": "작업 레지스트리 (읽기 전용)",
+ "settings.logs.noFilesYet": "아직 로그 파일이 없어요{folderNote}. 실행 후 첫 메시지부터 기록이 시작돼요.",
+ "settings.logs.folderNotCreated": " (폴더가 아직 만들어지지 않았어요)",
+ "settings.logs.failedToLoad": "로그 정보를 불러오지 못했어요.",
+
+ "settings.done": "완료",
+
+ "settings.folder.rename": "폴더 이름 바꾸기",
+ "settings.folder.namePlaceholder": "폴더 이름",
+ "settings.folder.emptyError": "폴더 이름을 입력해 주세요.",
+ "settings.folder.charsError": "문자, 숫자, 공백 또는 - _ ' & ( ) . , 를 쓸 수 있어요",
+ "settings.folder.cancel": "취소",
+ "settings.folder.save": "저장",
+
+ "settings.dangerZone": "위험 구역",
+
+ "folderEditor.title": "폴더 편집",
+ "folderEditor.closeAria": "닫기",
+ "folderEditor.nameLabel": "이름",
+ "folderEditor.colorLabel": "색상",
+ "folderEditor.colorGroupAria": "폴더 색상",
+ "folderEditor.cancel": "취소",
+ "folderEditor.save": "저장",
+ "folderEditor.emptyError": "폴더 이름을 입력해 주세요.",
+ "folderEditor.tooLong": "폴더 이름이 너무 길어요 (최대 {max}).",
+ "folderEditor.charsError": "문자, 숫자, 공백 또는 - _ ' & ( ) . , 를 쓸 수 있어요",
+
+ "notifKind.importFailed": "가져오기 실패",
+ "notifKind.playbackFailed": "재생 실패",
+ "notifKind.exportFailed": "내보내기 실패",
+ "notifKind.updateCheckFailed": "업데이트 확인 실패",
+ "notifKind.default": "무언가 실패했어요",
+ "notif.somethingWentWrong": "문제가 생겼어요.",
+ "time.justNow": "방금",
+ "time.minAgo.other": "{count}분 전",
+ "time.hoursAgo.other": "{count}시간 전",
+
+ "job.processing": "처리 중",
+ "job.queuedTrack": "대기 중인 트랙",
+ "job.process": "처리",
+ "job.tryAgain": "다시 시도",
+ "job.dismiss": "닫기",
+ "job.peakDb": "피크 {value} dB",
+ "job.dr.compressed": "압축됨",
+ "job.dr.moderate": "보통",
+ "job.dr.high": "높음",
+ "job.dr.wide": "넓음",
+ "job.stability.veryStable": "매우 안정적",
+ "job.stability.stable": "안정적",
+ "job.stability.moderate": "보통",
+ "job.stability.variable": "변동 있음",
+ "job.bpmValue": "{bpm} BPM",
+ "job.cancelling": "취소하는 중…",
+ "job.processingTrackTitle": "트랙 처리 중",
+ "job.unknownError": "알 수 없는 오류",
+ "job.noLongerExists": "서버에 이 작업이 더는 없어요",
+ "job.audioProcessingFailed": "오디오 처리에 실패했어요.",
+ "job.working": "작업 중…",
+ "settings.device.changeFailed": "연산 장치를 바꾸지 못했어요.",
+ "update.checkFailed": "업데이트를 확인하지 못했어요.",
+ "track.unavailableReimport": "트랙을 쓸 수 없어요. 눌러서 다시 가져오세요",
+ "track.unavailableReupload": "트랙을 쓸 수 없어요. 다시 올리면 복구돼요",
+ "track.audioUnavailableError": "이 트랙의 오디오가 더는 없어요. 다시 올리면 복구할 수 있어요.",
+ "job.restoreFailed": "트랙을 복구하지 못했어요: {message}",
+ "job.startFailed": "작업을 시작하지 못했어요: {message}",
+ "upload.unsupportedFormat": "MP3, WAV, FLAC, MP4, M4A, OGG, Opus 파일만 쓸 수 있어요.",
+ "upload.fileTooLarge": "파일이 너무 커요 ({size}). 최대 {max}까지예요.",
+ "upload.skippedFiles.other": "{count}개 파일을 건너뛰었어요 ({reason}).",
+ "upload.reasonTooLarge": "너무 크거나 오디오가 아님",
+ "upload.reasonNotAudio": "오디오가 아님",
+
+ "player.readyToImport": "트랙을 가져올 준비가 됐어요",
+ "player.stillLoadingWaveform": "아직 파형을 불러오는 중이에요…",
+ "player.audioCouldNotLoad": "이 트랙의 오디오를 불러오지 못했어요.",
+ "player.playbackDisabledFull": "이 트랙은 재생할 수 없어요. 다른 트랙에는 영향이 없어요.",
+ "player.playbackDisabled": "이 트랙은 재생할 수 없어요.",
+
+ "metro.note.full": "{bpm} BPM으로 클릭 중이에요. 비트의 {conf}%가 드럼과 맞고, {accent}비트마다 강세를 줘요. 클릭이 절반이나 두 배로 느껴지면 /2 또는 x2를 쓰세요.",
+ "metro.note.noAccent": "{bpm} BPM으로 클릭 중이에요. 비트의 {conf}%가 드럼과 맞아요. 클릭이 절반이나 두 배로 느껴지면 /2 또는 x2를 쓰세요.",
+ "metro.note.detectedSingleBar": "{bpm} BPM으로 클릭 중이에요. 비트의 {conf}%가 드럼과 맞고, 감지된 첫박부터 {beats}/4로 강세를 줘요. 클릭이 절반이나 두 배로 느껴지면 /2 또는 x2를 쓰세요.",
+ "metro.note.detectedMultiBar": "{bpm} BPM으로 클릭 중이에요. 비트의 {conf}%가 드럼과 맞고, 감지된 박자 구간 {count}개에 강세를 줘요. 클릭이 절반이나 두 배로 느껴지면 /2 또는 x2를 쓰세요.",
+ "metro.note.fallback": "예비 추적기로 {bpm} BPM 클릭 중이에요. 클릭이 절반이나 두 배로 느껴지면 /2 또는 x2를 쓰세요.",
+
+ "sections.saving": "저장 중",
+ "sections.saved": "저장됨",
+ "sections.deleteAria": "구간 삭제",
+ "sections.defaultName": "구간",
+
+ "playlist.skip.unavailable.other": "사용할 수 없는 {count}개",
+ "playlist.skip.tooLong.other": "너무 긴 {count}개",
+ "playlist.skip.overflow.other": "지금 대기열에 들어가지 않는 {count}개",
+ "playlist.skip.truncated": "처음 {cap}개를 넘는 나머지",
+ "playlist.skippingPrefix": "{list}을(를) 건너뛰어요.",
+ "playlist.listSep": ", ",
+ "playlist.importAriaLabel": "재생목록 가져오기",
+ "playlist.couldNotRead": "그 재생목록을 읽지 못했어요: {message}",
+ "playlist.couldNotImport": "그 재생목록을 가져오지 못했어요: {message}",
+ "playlist.queueFull": "대기열이 가득 찼어요. 비워지길 기다리거나 먼저 하나를 취소해 주세요.",
+ "playlist.nothingImportable": "그 재생목록에서 지금 가져올 수 있는 것이 없어요.",
+ "playlist.confirmTitle": "이 재생목록을 가져올까요?",
+ "playlist.confirmBody.other": "트랙 {count}개를 하나씩 같은 이름의 폴더에 대기열로 넣어요.{skipped}",
+ "playlist.skippedNote": " (사용할 수 없는 {count}개는 건너뛰어요.)",
+ "playlist.cancel": "취소",
+ "playlist.import.other": "트랙 {count}개 가져오기",
+
+ "resetConfirm.title": "앱 데이터를 초기화할까요?",
+ "resetConfirm.ariaLabel": "앱 데이터 초기화",
+ "resetConfirm.body": "모든 트랙, 작업, 라이브러리 항목을 영구히 삭제해요. 공유 서버라면 쓰는 사람 모두에게 영향을 줘요. 되돌릴 수 없어요.",
+ "resetConfirm.hint": "확인하려면 RESET을 입력하세요.",
+ "resetConfirm.typeToConfirmAria": "확인하려면 RESET을 입력하세요",
+ "resetConfirm.cancel": "취소",
+ "resetConfirm.go": "앱 데이터 초기화",
+ "resetConfirm.resetting": "초기화하는 중…",
+ "resetConfirm.failed": "초기화하지 못했어요.",
+ "resetConfirm.failedConnection": "초기화하지 못했어요. 연결을 확인해 주세요.",
+};
+
+export const TRANSLATIONS = { en, pl, ja, ko, "zh-Hans": zhHans, de, fr, es, pt, "pt-PT": ptPT, id };
diff --git a/tests/js/i18n-detect.test.mjs b/tests/js/i18n-detect.test.mjs
index a9154fa1..d6f13ee9 100644
--- a/tests/js/i18n-detect.test.mjs
+++ b/tests/js/i18n-detect.test.mjs
@@ -40,6 +40,7 @@ const LOCALES = {
en: ["en", "en-US", "en-GB"],
pl: ["pl", "pl-PL"],
ja: ["ja", "ja-JP"],
+ ko: ["ko", "ko-KR"],
"zh-Hans": ["zh", "zh-CN", "zh-Hans", "zh-SG"],
de: ["de", "de-DE", "de-AT"],
fr: ["fr", "fr-FR", "fr-CA"],