diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -22,6 +22,7 @@ jobs:
     # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
     uses: storytold/craft-common-actions/.github/workflows/release.yml@main
     with:
+      craft-fonts-ref: 4fcc4b2672a2a6765052c2f59fa3021485d32269
       app-name: soundcraft
       app-title: SoundCraft
       version: ${{ inputs.version }}
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -45,3 +45,17 @@ and, at runtime, the operating system's own UI fonts when available (never redis
 | `assets/app-icon/soundcraft.ico` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
 | `assets/app-icon/hicolor/` | SoundCraft contributors | original, rendered from `icon.svg` (16–512 px PNGs and the scalable SVG) | MIT OR Apache-2.0 |
 | `docs/images/` | SoundCraft contributors | screenshots of SoundCraft rendered from the synthesised demo session | MIT OR Apache-2.0 |
+
+
+## Korean interface
+The Korean display catalog in crates/ui-egui/src/i18n/ko.rs contains original,
+contributor-supplied and reviewed translations of this repository's English UI,
+integrated with Codex under MIT OR Apache-2.0. Identifiers, document values and
+user names retain their original values.
+
+Korean glyphs use Nanum Gothic Regular/Bold from
+[craft-fonts 4fcc4b2](https://github.com/arksky1/craft-fonts/commit/4fcc4b2672a2a6765052c2f59fa3021485d32269).
+The font source is Google Fonts ofl/nanumgothic; its OFL-1.1 notice and original
+binaries are preserved in that shared input. No vendor UI localization is used.
+
+Generic alignment captions and undo/redo templates also reuse the contributor-reviewed PdfCraft and DesignCraft Korean catalogs where their meanings agree.
diff --git a/apps/soundcraft/src/native_menu.rs b/apps/soundcraft/src/native_menu.rs
--- a/apps/soundcraft/src/native_menu.rs
+++ b/apps/soundcraft/src/native_menu.rs
@@ -2,13 +2,14 @@
 use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
 use objc2::MainThreadMarker;
 use objc2_app_kit::NSApplication;
-use soundcraft_ui_egui::{SoundApp, menus};
+use soundcraft_ui_egui::{SoundApp, menus, i18n};
 use std::sync::mpsc::{Receiver, channel};
 
 struct Entry {
     item: CheckMenuItem,
     command: Option<String>,
     path: String,
+    label: String,
     enabled: bool,
     checked: bool,
 }
@@ -16,6 +17,8 @@ struct Entry {
 pub struct NativeMenu {
     menu: Menu,
     entries: Vec<Entry>,
+    branches: Vec<(Submenu, String)>,
+    languages: Vec<(CheckMenuItem, i18n::Language)>,
     events: Receiver<MenuEvent>,
     /// When `sync` last walked the entries; see `SYNC_EVERY`.
     last_sync: Option<std::time::Instant>,
@@ -28,10 +31,17 @@ const SYNC_EVERY: std::time::Duration = std::time::Duration::from_millis(250);
 impl NativeMenu {
     /// Called on the UI thread after eframe has created NSApplication.
     pub fn new(app: &SoundApp, ctx: &egui::Context) -> muda::Result<Self> {
+        i18n::set_current(app.ui.language);
         let menu = Menu::new();
         let (tx, events) = channel();
-        let mut native = Self { menu, entries: Vec::new(), events, last_sync: None };
+        let mut native = Self { menu, entries: Vec::new(), branches: Vec::new(), languages: Vec::new(), events, last_sync: None };
         let app_menu = Submenu::new("SoundCraft", true);
+        for language in i18n::Language::ALL {
+            let item = CheckMenuItem::new(language.name(), true, language == app.ui.language, None);
+            app_menu.append(&item)?;
+            native.languages.push((item, language));
+        }
+        app_menu.append(&PredefinedMenuItem::separator())?;
         native.add_item(app, &app_menu, "About SoundCraft", "", Some("window.about".into()))?;
         native.add_item(app, &app_menu, "Session Info", "", Some("window.session_info".into()))?;
         native.add_item(app, &app_menu, "Session Audio Health", "", Some("window.audio_health".into()))?;
@@ -61,7 +71,8 @@ impl NativeMenu {
     }
 
     fn add_branch(&mut self, app: &SoundApp, node: &menus::MenuNode, aliases: &[(&str, &str)]) -> muda::Result<Submenu> {
-        let submenu = Submenu::new(&node.label, true);
+        let submenu = Submenu::new(i18n::tr(&node.label), true);
+        self.branches.push((submenu.clone(), node.label.clone()));
         for child in &node.children {
             if child.children.is_empty() {
                 self.add_item(app, &submenu, &child.label, &child.path, menus::command_for_path(&child.path, aliases))?;
@@ -75,14 +86,19 @@ impl NativeMenu {
     fn add_item(&mut self, app: &SoundApp, parent: &Submenu, label: &str, path: &str, command: Option<String>) -> muda::Result<()> {
         let (enabled, checked) = menus::item_state(app, path, command.as_deref());
         // egui owns keyboard shortcuts, so a key press is dispatched only once.
-        let item = CheckMenuItem::new(label, enabled, checked, None);
+        let item = CheckMenuItem::new(i18n::command(command.as_deref().unwrap_or(""), label), enabled, checked, None);
         parent.append(&item)?;
-        self.entries.push(Entry { item, command, path: path.into(), enabled, checked });
+        self.entries.push(Entry { item, command, path: path.into(), label: label.into(), enabled, checked });
         Ok(())
     }
 
     pub fn process(&mut self, app: &mut SoundApp) {
         while let Ok(event) = self.events.try_recv() {
+            if let Some((_, language)) = self.languages.iter().find(|(item, _)| item.id() == &event.id) {
+                let _ = app.run("ui.language", serde_json::json!({"language": language.code()}));
+                self.last_sync = None;
+                continue;
+            }
             // A click changes state (and Cocoa flips the item's check mark): sync on the next pass.
             self.last_sync = None;
             if (event.id == "hide" || event.id == "hide_others")
@@ -106,6 +122,9 @@ impl NativeMenu {
 
     /// Refresh after engine/control changes; avoid touching Cocoa items with unchanged state.
     pub fn sync(&mut self, app: &SoundApp) {
+        i18n::set_current(app.ui.language);
+        for (branch, english) in &self.branches { branch.set_text(i18n::tr(english)); }
+        for (item, language) in &self.languages { item.set_checked(*language == app.ui.language); }
         let now = std::time::Instant::now();
         if self.last_sync.is_some_and(|t| now.duration_since(t) < SYNC_EVERY) {
             return;
@@ -113,7 +132,7 @@ impl NativeMenu {
         self.last_sync = Some(now);
         for entry in &mut self.entries {
             let (enabled, checked) = menus::item_state(app, &entry.path, entry.command.as_deref());
-            if let Some(label) = menus::live_label(app, entry.command.as_deref())
+            if let Some(label) = Some(menus::live_label(app, entry.command.as_deref()).unwrap_or_else(|| i18n::command(entry.command.as_deref().unwrap_or(""), &entry.label).to_string()))
                 && entry.item.text() != label
             {
                 entry.item.set_text(label);
diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs
--- a/crates/ui-egui/build.rs
+++ b/crates/ui-egui/build.rs
@@ -13,6 +13,10 @@ use std::fmt::Write as _;
 use serde_json::Value;
 
 fn main() {
+    if let Err(error) = korean_ui_fonts() {
+        let level = if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() { "error" } else { "warning" };
+        println!("cargo::{level}=Korean UI fonts: {error}");
+    }
     let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors");
     let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| {
         serde_json::from_str::<Value>(t).map_err(|e| e.to_string())
@@ -103,3 +107,32 @@ fn generate(json: &Value, people: &toml::Table) -> String {
     let _ = writeln!(o, "];");
     o
 }
+
+fn korean_ui_fonts() -> Result<(), String> {
+    println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
+    println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
+    let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("korean_ui_fonts.rs");
+    let empty = "static KOREAN_UI_FONTS: &[(&str, &[u8])] = &[];\n";
+    std::fs::write(&out, empty).map_err(|e| e.to_string())?;
+    let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(std::path::PathBuf::from) else { return Ok(()) };
+    let manifest = dir.join("fonts/manifest.txt");
+    println!("cargo::rerun-if-changed={}", manifest.display());
+    let text = std::fs::read_to_string(manifest).map_err(|e| e.to_string())?;
+    let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|arch| arch == "wasm32");
+    let mut src = String::from("static KOREAN_UI_FONTS: &[(&str, &[u8])] = &[\n");
+    let mut regular = false;
+    for line in text.lines().map(str::trim).filter(|line| !line.is_empty() && !line.starts_with('#')) {
+        let fields: Vec<_> = line.split(" | ").map(str::trim).collect();
+        let [family, style, file, scripts, ..] = fields.as_slice() else { return Err(format!("malformed manifest row: {line}")) };
+        if *family != "Nanum Gothic" || !scripts.split(',').any(|script| script.trim() == "Kore") || (wasm && *style != "Regular") {
+            continue;
+        }
+        let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
+        println!("cargo::rerun-if-changed={}", path.display());
+        regular |= *style == "Regular";
+        let _ = writeln!(src, "    ({style:?}, include_bytes!({:?})),", path.display().to_string());
+    }
+    if !regular { return Err("shared font input has no Nanum Gothic Regular Korean face".into()) }
+    src.push_str("];\n");
+    std::fs::write(out, src).map_err(|e| e.to_string())
+}
diff --git a/crates/ui-egui/src/bounce.rs b/crates/ui-egui/src/bounce.rs
--- a/crates/ui-egui/src/bounce.rs
+++ b/crates/ui-egui/src/bounce.rs
@@ -70,10 +70,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
         ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0);
         ui.set_width(320.0);
         let name = job.path().file_name().unwrap_or_default().to_string_lossy();
-        ui.label(egui::RichText::new(format!("Bouncing {name}")).font(bold(13.0)));
+        ui.label(egui::RichText::new(if crate::i18n::is_korean() {  crate::i18n::format_fields("Bouncing {name}", &[("name", format!("{}", &name))]) } else { format!("Bouncing {name}") }).font(bold(13.0)));
         ui.add(egui::ProgressBar::new(job.progress()));
         ui.horizontal(|ui| {
-            cancel |= ui.button("Cancel").clicked();
+            cancel |= ui.button(crate::i18n::tr("Cancel")).clicked();
             ui.label(egui::RichText::new(format!("{:.0}%", job.progress() * 100.0)).color(Tokens::current().text_dim));
         });
     });
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -115,7 +115,7 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
             };
             if let Some(arr) = list.as_array_mut() {
                 for (id, label, path, sc) in crate::menus::UI_COMMANDS.iter().filter(|c| matches(c.0, c.1)) {
-                    arr.push(json!({"id": id, "label": label, "menu": path, "shortcut": sc, "enabled": true, "params": "{}"}));
+                    arr.push(json!({"id": id, "label": label, "menu": path, "shortcut": sc, "enabled": true, "params": if *id == "ui.language" { r#"{"language":"en|ko"}"# } else { "{}" }}));
                 }
             }
             ok(list)
diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs
--- a/crates/ui-egui/src/credits.rs
+++ b/crates/ui-egui/src/credits.rs
@@ -215,14 +215,14 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
     let id = egui::Id::new("credits_view");
     let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
     ui.horizontal_wrapped(|ui| {
-        ui.label("Show");
+        ui.label(crate::i18n::tr("Show"));
         for m in NameMode::ALL {
             if ui.selectable_label(v.names == m, m.label()).clicked() {
                 v.names = m;
             }
         }
         ui.separator();
-        ui.label("Sort");
+        ui.label(crate::i18n::tr("Sort"));
         egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
             for k in SortKey::ALL {
                 if ui.selectable_label(v.key == k, k.label().0).clicked() {
@@ -231,23 +231,23 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
                 }
             }
         });
-        if ui.button(if v.ascending { "⏶" } else { "⏷" }).on_hover_text("Reverse the order").clicked() {
+        if ui.button(if v.ascending { "⏶" } else { "⏷" }).on_hover_text(crate::i18n::tr("Reverse the order")).clicked() {
             v.ascending = !v.ascending;
         }
         ui.separator();
-        if ui.selectable_label(!v.table, "Grab bag").clicked() {
+        if ui.selectable_label(!v.table, crate::i18n::tr("Grab bag")).clicked() {
             v.table = false;
         }
-        if ui.selectable_label(v.table, "Table").clicked() {
+        if ui.selectable_label(v.table, crate::i18n::tr("Table")).clicked() {
             v.table = true;
         }
     });
     let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
-    ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
+    ui.label(RichText::new(if crate::i18n::is_korean() { let __i18n_0 = &(list.len()); let __i18n_1 = &(group(TOTAL_COMMITS)); crate::i18n::format_fields("{} contributors · {} commits", &[("", format!("{}", __i18n_0)), ("", format!("{}", __i18n_1))]) } else { format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS)) }).small().weak());
     ui.separator();
     egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
         if list.is_empty() {
-            ui.label("No contributor data was built into this copy.");
+            ui.label(crate::i18n::tr("No contributor data was built into this copy."));
         } else if v.table {
             table(ui, &list, &mut v);
         } else {
@@ -297,7 +297,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
 /// About ▸ Models: AI models credited in Co-Authored-By trailers.
 pub fn models_ui(ui: &mut egui::Ui) {
     if MODELS.is_empty() {
-        ui.label("No model credits were built into this copy.");
+        ui.label(crate::i18n::tr("No model credits were built into this copy."));
         return;
     }
     let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -344,7 +344,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
             match &mut d {
                 Dialog::NewTracks { count, format, kind, timebase, name } => {
                     ui.horizontal(|ui| {
-                        ui.label("Create");
+                        ui.label(crate::i18n::tr("Create"));
                         ui.add(egui::DragValue::new(count).range(1..=128));
                         ui.label("new");
                         egui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| {
@@ -353,7 +353,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
                             }
                         });
                         egui::ComboBox::from_id_salt("nt_kind")
-                            .selected_text(soundcraft_model::TrackKind::from_id(kind).map_or("Audio Track", |k| k.label()))
+                            .selected_text(soundcraft_model::TrackKind::from_id(kind).map_or(crate::i18n::tr("Audio Track"), |k| k.label()))
                             .show_ui(ui, |ui| {
                                 for k in soundcraft_model::TrackKind::ALL {
                                     if ui.selectable_label(kind == k.id(), k.label()).clicked() {
@@ -363,14 +363,14 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
                                 }
                             });
                         ui.label("in");
-                        egui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui(
+                        egui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { crate::i18n::tr("Ticks") } else { crate::i18n::tr("Samples") }).show_ui(
                             ui,
                             |ui| {
                                 ui.selectable_value(timebase, "samples".to_string(), "Samples");
                                 ui.selectable_value(timebase, "ticks".to_string(), "Ticks");
                             },
                         );
-                        ui.label("Name:");
+                        ui.label(crate::i18n::tr("Name:"));
                         ui.add(egui::TextEdit::singleline(name).desired_width(140.0));
                     });
                     if buttons(ui, "Create", enter) {
@@ -380,12 +380,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
                 }
                 Dialog::NewSession { name, sample_rate, demo } => {
                     ui.horizontal(|ui| {
-                        ui.label("Name");
+                        ui.label(crate::i18n::tr("Name"));
                         ui.text_edit_singleline(name);
                     });
-                    egui::ComboBox::from_label("Sample Rate").selected_text(format!("{sample_rate} Hz")).show_ui(ui, |ui| {
+                    egui::ComboBox::from_label("Sample Rate").selected_text(if crate::i18n::is_korean() {  crate::i18n::format_fields("{sample_rate} Hz", &[("sample_rate", format!("{}", &sample_rate))]) } else { format!("{sample_rate} Hz") }).show_ui(ui, |ui| {
                         for r in soundcraft_time::SampleRate::COMMON {
-                            ui.selectable_value(sample_rate, r, format!("{r} Hz"));
+                            ui.selectable_value(sample_rate, r, if crate::i18n::is_korean() {  crate::i18n::format_fields("{r} Hz", &[("r", format!("{}", &r))]) } else { format!("{r} Hz") });
                         }
                     });
                     crate::widgets::checkbox(ui, demo, "Start from the demo session");
@@ -401,7 +401,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
                         ui.label("File");
                         ui.add(egui::TextEdit::singleline(path).desired_width(420.0));
                         if let Some(pick) = &app.services.pick_save
-                            && ui.button("Browse…").clicked()
+                            && ui.button(crate::i18n::tr("Browse…")).clicked()
                             && let Some(p) = pick("file.bounce_mix", path)
                         {
                             *path = p;
@@ -419,15 +419,15 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
                                 ui.selectable_value(format, f.to_string(), f.to_uppercase());
                             }
                         });
-                        egui::ComboBox::from_label("Bit Depth").selected_text(format!("{bit_depth}-bit")).show_ui(ui, |ui| {
+                        egui::ComboBox::from_label("Bit Depth").selected_text(if crate::i18n::is_korean() {  crate::i18n::format_fields("{bit_depth}-bit", &[("bit_depth", format!("{}", &bit_depth))]) } else { format!("{bit_depth}-bit") }).show_ui(ui, |ui| {
                             for b in ["16", "24", "32f"] {
-                                ui.selectable_value(bit_depth, b.to_string(), format!("{b}-bit"));
+                                ui.selectable_value(bit_depth, b.to_string(), if crate::i18n::is_korean() {  crate::i18n::format_fields("{b}-bit", &[("b", format!("{}", &b))]) } else { format!("{b}-bit") });
                             }
                         });
                     });
                     crate::widgets::checkbox(ui, normalize, "Normalize to -0.1 dBFS");
                     ui.label(
-                        egui::RichText::new("Bounces the edit selection, or the whole session when nothing is selected.")
+                        egui::RichText::new(crate::i18n::tr("Bounces the edit selection, or the whole session when nothing is selected."))
                             .small()
                             .color(Tokens::current().text_dim),
                     );
@@ -493,7 +493,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
                     let file = soundcraft_engine::io::session_file(path);
                     let replaces = soundcraft_engine::io::replaces(&app.engine, cmd, &file);
                     if replaces {
-                        ui.label(format!("“{}” already exists. Do you want to replace it?", file.file_name().unwrap_or_default().to_string_lossy()));
+                        ui.label(if crate::i18n::is_korean() { let __i18n_0 = &(file.file_name().unwrap_or_default().to_string_lossy()); crate::i18n::format_fields("“{}” already exists. Do you want to replace it?", &[("", format!("{}", __i18n_0))]) } else { format!("“{}” already exists. Do you want to replace it?", file.file_name().unwrap_or_default().to_string_lossy()) });
                     }
                     if buttons(ui, if replaces { "Replace" } else { "OK" }, enter && !replaces) {
                         let mut v = json!({});
@@ -540,10 +540,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
                     }
                 }
                 Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms } => {
-                    ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB"));
-                    ui.add(egui::Slider::new(min_ms, 0.0..=2000.0).text("Min strip duration ms"));
-                    ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms"));
-                    ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms"));
+                    ui.add(egui::Slider::new(threshold, -96.0..=0.0).text(crate::i18n::tr("Threshold dB")));
+                    ui.add(egui::Slider::new(min_ms, 0.0..=2000.0).text(crate::i18n::tr("Min strip duration ms")));
+                    ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text(crate::i18n::tr("Clip start pad ms")));
+                    ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text(crate::i18n::tr("Clip end pad ms")));
                     if buttons(ui, "Strip", enter) {
                         action = Some((
                             "edit.strip_silence".into(),
@@ -560,7 +560,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
                         crate::widgets::checkbox(ui, edit, "Edit group");
                         crate::widgets::checkbox(ui, mix, "Mix group");
                     });
-                    ui.label("Tracks");
+                    ui.label(crate::i18n::tr("Tracks"));
                     egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
                         for (_, n, on) in members.iter_mut() {
                             crate::widgets::checkbox(ui, on, n.as_str());
@@ -597,13 +597,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
                 }
                 Dialog::ScoreSetup { title, composer, bars_per_system, show_track_names } => {
                     egui::Grid::new("score_setup").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
-                        ui.label("Title");
+                        ui.label(crate::i18n::tr("Title"));
                         ui.text_edit_singleline(title);
                         ui.end_row();
-                        ui.label("Composer");
+                        ui.label(crate::i18n::tr("Composer"));
                         ui.text_edit_singleline(composer);
                         ui.end_row();
-                        ui.label("Bars per system");
+                        ui.label(crate::i18n::tr("Bars per system"));
                         ui.add(egui::DragValue::new(bars_per_system).range(1..=16));
                         ui.end_row();
                     });
@@ -620,19 +620,19 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
                     let revert = then.0 == "session.revert";
                     if revert || cfg!(target_arch = "wasm32") {
                         let (question, ok) = if revert {
-                            (format!("Revert “{name}” to its last saved version?"), "Revert")
+                            (save_changes_question(name, true), "Revert")
                         } else {
-                            (format!("Close “{name}” without saving?"), "Don't Save")
+                            (save_changes_question(name, false), "Don't Save")
                         };
-                        ui.label(format!("{question} Your changes will be lost."));
+                        ui.label(if crate::i18n::is_korean() {  crate::i18n::format_fields("{question} Your changes will be lost.", &[("question", format!("{}", &question))]) } else { format!("{question} Your changes will be lost.") });
                         if buttons(ui, ok, false) {
                             action = Some(then.clone());
                         }
                     } else {
-                        ui.label(format!("Save changes to “{name}” before closing?"));
+                        ui.label(if crate::i18n::is_korean() {  crate::i18n::format_fields("Save changes to “{name}” before closing?", &[("name", format!("{}", &name))]) } else { format!("Save changes to “{name}” before closing?") });
                         ui.separator();
                         ui.horizontal(|ui| {
-                            if ui.button("Don't Save").clicked() {
+                            if ui.button(crate::i18n::tr("Don't Save")).clicked() {
                                 action = Some(then.clone());
                             }
                             if buttons(ui, "Save", enter) {
@@ -748,22 +748,22 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
             match process.as_str() {
                 "normalize" => {
                     let v = params.entry("target_db".into()).or_insert(-0.1);
-                    ui.add(egui::Slider::new(v, -24.0..=0.0).text("Peak dB"));
+                    ui.add(egui::Slider::new(v, -24.0..=0.0).text(crate::i18n::tr("Peak dB")));
                 }
                 "time_stretch" => {
                     let v = params.entry("ratio".into()).or_insert(1.0);
-                    ui.add(egui::Slider::new(v, 0.25..=4.0).text("Length ratio"));
+                    ui.add(egui::Slider::new(v, 0.25..=4.0).text(crate::i18n::tr("Length ratio")));
                 }
                 "pitch_shift" => {
                     let v = params.entry("semitones".into()).or_insert(0.0);
-                    ui.add(egui::Slider::new(v, -24.0..=24.0).text("Semitones"));
+                    ui.add(egui::Slider::new(v, -24.0..=24.0).text(crate::i18n::tr("Semitones")));
                 }
                 "varispeed" => {
                     let v = params.entry("speed".into()).or_insert(1.0);
-                    ui.add(egui::Slider::new(v, 0.25..=4.0).text("Speed"));
+                    ui.add(egui::Slider::new(v, 0.25..=4.0).text(crate::i18n::tr("Speed")));
                 }
                 _ => {
-                    ui.label("No parameters.");
+                    ui.label(crate::i18n::tr("No parameters."));
                 }
             }
         }
@@ -863,3 +863,33 @@ mod tests {
         );
     }
 }
+
+fn save_changes_question(name: &str, revert: bool) -> String {
+    if crate::i18n::is_korean() {
+        let source = if revert { "Revert “{name}” to its last saved version?" } else { "Close “{name}” without saving?" };
+        crate::i18n::format_fields(source, &[("name", name.to_owned())])
+    } else if revert {
+        format!("Revert “{name}” to its last saved version?")
+    } else {
+        format!("Close “{name}” without saving?")
+    }
+}
+
+#[cfg(test)]
+mod korean_question_tests {
+    use super::save_changes_question;
+    use crate::i18n::{Language, set_current};
+
+    #[test]
+    fn save_questions_keep_session_names_literal() {
+        set_current(Language::Korean);
+        let name = "Session {name}";
+        let question = save_changes_question(name, true);
+        assert_eq!(question, "“Session {name}”을(를) 마지막으로 저장한 버전으로 되돌릴까요?");
+        assert_eq!(crate::i18n::format_fields("{question} Your changes will be lost.", &[("question", question)]), "“Session {name}”을(를) 마지막으로 저장한 버전으로 되돌릴까요? 변경 내용이 사라집니다.");
+        assert_eq!(save_changes_question(name, false), "“Session {name}”을(를) 저장하지 않고 닫을까요?");
+        set_current(Language::English);
+        assert_eq!(save_changes_question(name, true), "Revert “Session {name}” to its last saved version?");
+        assert_eq!(save_changes_question(name, false), "Close “Session {name}” without saving?");
+    }
+}
diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs
--- a/crates/ui-egui/src/edit_window.rs
+++ b/crates/ui-egui/src/edit_window.rs
@@ -198,7 +198,7 @@ fn status_bar(app: &mut SoundApp, ui: &mut Ui) {
     ui.painter().text(
         pos2(r.max.x - 10.0, r.center().y),
         Align2::RIGHT_CENTER,
-        if app.frame_ms < 50.0 { format!("{undo}   {:.1} ms/frame", app.frame_ms) } else { undo },
+        if app.frame_ms < 50.0 { if crate::i18n::is_korean() { let __i18n_0 = &(app.frame_ms); crate::i18n::format_fields("{undo}   {:.1} ms/frame", &[("undo", format!("{}", &undo)), (":.1", format!("{:.1}", __i18n_0))]) } else { format!("{undo}   {:.1} ms/frame", app.frame_ms) } } else { undo },
         regular(11.0),
         t.text_dim,
     );
@@ -555,7 +555,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[
             "key" => {
                 painter.rect_filled(row, 0.0, Color32::from_rgb(64, 52, 88));
                 if s.key_signatures.is_empty() {
-                    painter.text(pos2(tl.min.x + 6.0, row.center().y), Align2::LEFT_CENTER, "C major", bold(11.0), Color32::from_rgb(220, 214, 236));
+                    painter.text(pos2(tl.min.x + 6.0, row.center().y), Align2::LEFT_CENTER, crate::i18n::tr("C major"), bold(11.0), Color32::from_rgb(220, 214, 236));
                 }
                 for (at, k) in &s.key_signatures {
                     let x = x_of(&s, tl, *at);
@@ -788,7 +788,7 @@ fn views_button(app: &mut SoundApp, ui: &mut Ui, rulers: Rect) {
     let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r));
     let resp = crate::widgets::selector_box(&mut child, r.width(), r.height(), "Views", Tokens::current().text);
     resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, child.is_enabled(), "Views"));
-    let resp = resp.on_hover_text("Columns beside the track headers: inserts, sends, I/O…");
+    let resp = resp.on_hover_text(crate::i18n::tr("Columns beside the track headers: inserts, sends, I/O…"));
     egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
         let shown = edit_columns(app.engine.session());
         for (id, label, _, _) in COLUMNS {
@@ -903,7 +903,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai
                 let _ = app.run("track.playlist_promote", json!({"track": track.id.0, "playlist": i, "start": a, "end": b}));
             }
         }
-        resp.on_hover_text("Click a take (or the selection) to promote it to the main playlist");
+        resp.on_hover_text(crate::i18n::tr("Click a take (or the selection) to promote it to the main playlist"));
         y += LANE_H;
     }
 }
@@ -955,11 +955,11 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
     ui.painter().circle_filled(round.center(), 5.0, shade(strip, if rresp.hovered() { 0.6 } else { 0.45 }));
     arrow(ui.painter(), round.center(), !track.is_folder() || track.folder_open, ink);
     if track.is_folder() {
-        if rresp.on_hover_text(if track.folder_open { "Close folder" } else { "Open folder" }).clicked() {
+        if rresp.on_hover_text(if track.folder_open { crate::i18n::tr("Close folder") } else { crate::i18n::tr("Open folder") }).clicked() {
             let _ = app.run("track.folder_toggle", json!({"track": id.0}));
         }
     } else {
-        egui::Popup::menu(&rresp.on_hover_text("Track options")).show(|ui| track_context_menu(app, ui, id));
+        egui::Popup::menu(&rresp.on_hover_text(crate::i18n::tr("Track options"))).show(|ui| track_context_menu(app, ui, id));
     }
     if h >= 40.0 && track.kind.has_playlist() {
         let ar = at(3.0, h - 12.0, 9.0, 9.0);
@@ -970,7 +970,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
         ui.painter().line_segment([a, b], Stroke::new(1.0, ink));
         ui.painter().circle_filled(a, 1.3, ink);
         ui.painter().circle_filled(b, 1.3, ink);
-        if aresp.on_hover_text(if showing { "Hide automation" } else { "Show volume automation" }).clicked() {
+        if aresp.on_hover_text(if showing { crate::i18n::tr("Hide automation") } else { crate::i18n::tr("Show volume automation") }).clicked() {
             let view = if !showing {
                 "volume"
             } else if track.kind.is_midi() {
@@ -1037,14 +1037,14 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
     let presp = ui.interact(pl_r, ui.id().with(("pl", id.0)), Sense::click());
     ui.painter().rect_filled(pl_r, 1.5, shade(head_bg, if presp.hovered() { -0.2 } else { -0.35 }));
     arrow(ui.painter(), pos2(pl_r.center().x, pl_r.min.y + 5.75), true, t.text);
-    egui::Popup::menu(&presp.on_hover_text("Playlists")).show(|ui| {
+    egui::Popup::menu(&presp.on_hover_text(crate::i18n::tr("Playlists"))).show(|ui| {
         if ui.button("New...").clicked() {
             let _ = app.run("track.playlist_new", json!({"track": id.0}));
         }
         if ui.button("Duplicate...").clicked() {
             let _ = app.run("track.playlist_duplicate", json!({"track": id.0}));
         }
-        if ui.button("Delete Unused...").clicked() {
+        if ui.button(crate::i18n::tr("Delete Unused...")).clicked() {
             let _ = app.run("track.playlist_delete_unused", json!({"track": id.0}));
         }
         ui.separator();
@@ -1083,14 +1083,14 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
                 let d = vec2(a.to_radians().sin(), a.to_radians().cos()) * 3.5;
                 ui.painter().line_segment([r.center() - d, r.center() + d], Stroke::new(1.5, t.text));
             }
-            if resp.on_hover_text(if track.frozen { "Unfreeze" } else { "Freeze" }).clicked() {
+            if resp.on_hover_text(if track.frozen { crate::i18n::tr("Unfreeze") } else { "Freeze" }).clicked() {
                 let _ = app.run("track.freeze", json!({"tracks": [id.0]}));
             }
         }
         if !matches!(track.kind, TrackKind::Master | TrackKind::Vca | TrackKind::Folder) {
             let (voice, color) = if track.inactive { ("off", t.text_dim) } else { ("dyn", t.text) };
             let resp = header_selector(ui, at(31.0, 57.0, 31.0, 17.0), ui.id().with(("voice", id.0)), voice, color, false);
-            egui::Popup::menu(&resp.on_hover_text("Voice: dyn plays the track, off makes it inactive")).show(|ui| {
+            egui::Popup::menu(&resp.on_hover_text(crate::i18n::tr("Voice: dyn plays the track, off makes it inactive"))).show(|ui| {
                 for (label, inactive) in [("dyn", false), ("off", true)] {
                     if ui.selectable_label(track.inactive == inactive, label).clicked() {
                         let _ = app.run("track.make_inactive", json!({"tracks": [id.0], "inactive": inactive}));
@@ -1307,7 +1307,7 @@ fn io_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
     let row = |y: f32, h: f32| (r.height() >= y + h + 1.0).then_some(Rect::from_min_size(r.min + vec2(7.0, y), vec2(r.width() - 14.0, h)));
     if let Some(input) = row(4.0, 15.0) {
         let resp = header_selector(&ui, input, ui.id().with(("in", id.0)), &crate::mix_window::input_name(app, track), t.text, true);
-        crate::mix_window::route_popup(app, &resp.on_hover_text("Input"), id, true);
+        crate::mix_window::route_popup(app, &resp.on_hover_text(crate::i18n::tr("Input")), id, true);
     }
     if let Some(out) = row(21.0, 17.0) {
         let (sel, fader) = (out.with_max_x(out.max.x - 11.0), out.with_min_x(out.max.x - 11.0));
@@ -1320,8 +1320,8 @@ fn io_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
         let c = fader.center() + vec2(0.5, 0.0);
         ui.painter().vline(c.x, c.y - 5.0..=c.y + 5.0, Stroke::new(1.0, t.text));
         ui.painter().rect_filled(Rect::from_center_size(c + vec2(0.0, -1.5), vec2(5.0, 2.5)), 0.5, t.text);
-        crate::mix_window::route_popup(app, &resp.on_hover_text("Output"), id, false);
-        if fresp.on_hover_text("Show in the Mix window").clicked() {
+        crate::mix_window::route_popup(app, &resp.on_hover_text(crate::i18n::tr("Output")), id, false);
+        if fresp.on_hover_text(crate::i18n::tr("Show in the Mix window")).clicked() {
             let _ = app.run("edit.select", json!({"tracks": [id.0]}));
             let _ = app.run("window.mix", json!({}));
         }
@@ -1637,7 +1637,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
                     }
                 }
             } else {
-                painter.text(body_r.center(), Align2::CENTER_CENTER, "media offline", regular(10.0), Color32::from_rgb(220, 120, 120));
+                painter.text(body_r.center(), Align2::CENTER_CENTER, crate::i18n::tr("media offline"), regular(10.0), Color32::from_rgb(220, 120, 120));
             }
         }
         ClipContent::Video { .. } => {} // thumbnails: video_track::draw_lane
@@ -1707,7 +1707,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
         let col = Color32::from_rgba_unmultiplied(255, 255, 255, 200);
         painter.line_segment([pos2(icon.center().x, icon.min.y), pos2(icon.center().x, icon.max.y)], Stroke::new(1.0, col));
         painter.rect_filled(Rect::from_center_size(pos2(icon.center().x, icon.center().y + 2.0), vec2(icon.width(), 3.0)), 1.0, col);
-        painter.text(pos2(icon.max.x + 3.0, icon.center().y), Align2::LEFT_CENTER, format!("{:+.1} dB", clip.gain_db), regular(9.5), col);
+        painter.text(pos2(icon.max.x + 3.0, icon.center().y), Align2::LEFT_CENTER, if crate::i18n::is_korean() { let __i18n_0 = &(clip.gain_db); crate::i18n::format_fields("{:+.1} dB", &[(":+.1", format!("{:+.1}", __i18n_0))]) } else { format!("{:+.1} dB", clip.gain_db) }, regular(9.5), col);
     }
     let fade_col = Color32::from_rgba_unmultiplied(255, 255, 255, 90);
     for (side, fade) in [(true, fade_in), (false, fade_out)] {
diff --git a/crates/ui-egui/src/extra_windows.rs b/crates/ui-egui/src/extra_windows.rs
--- a/crates/ui-egui/src/extra_windows.rs
+++ b/crates/ui-egui/src/extra_windows.rs
@@ -102,10 +102,10 @@ fn renderer(app: &mut SoundApp, ctx: &egui::Context) {
             (tr.name.clone(), sp.x, sp.y, sp.z)
         })
         .collect();
-    egui::Window::new(egui::RichText::new("Renderer").font(bold(13.0))).open(&mut open).default_size(vec2(380.0, 440.0)).resizable(true).show(
+    egui::Window::new(egui::RichText::new(crate::i18n::tr("Renderer")).font(bold(13.0))).open(&mut open).default_size(vec2(380.0, 440.0)).resizable(true).show(
         ctx,
         |ui| {
-            ui.label(egui::RichText::new(format!("Main output: {}", fmt.label())).font(regular(11.0)).color(t.text));
+            ui.label(egui::RichText::new(if crate::i18n::is_korean() { let __i18n_0 = &(fmt.label()); crate::i18n::format_fields("Main output: {}", &[("", format!("{}", __i18n_0))]) } else { format!("Main output: {}", fmt.label()) }).font(regular(11.0)).color(t.text));
             let side = ui.available_width().min(360.0);
             let (r, _) = ui.allocate_exact_size(vec2(side, side), Sense::hover());
             let p = ui.painter_at(r);
@@ -133,12 +133,12 @@ fn renderer(app: &mut SoundApp, ctx: &egui::Context) {
             ui.add_space(4.0);
             if objects.is_empty() {
                 ui.label(
-                    egui::RichText::new("No object tracks. Use the Object view in the Mix window to route tracks as objects.")
+                    egui::RichText::new(crate::i18n::tr("No object tracks. Use the Object view in the Mix window to route tracks as objects."))
                         .small()
                         .color(t.text_dim),
                 );
             } else {
-                ui.label(egui::RichText::new(format!("{} object track(s)", objects.len())).small().color(t.text_dim));
+                ui.label(egui::RichText::new(if crate::i18n::is_korean() { let __i18n_0 = &(objects.len()); crate::i18n::format_fields("{} object track(s)", &[("", format!("{}", __i18n_0))]) } else { format!("{} object track(s)", objects.len()) }).small().color(t.text_dim));
             }
         },
     );
@@ -150,17 +150,17 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) {
     if !open {
         return;
     }
-    egui::Window::new(egui::RichText::new("Appearance").font(bold(13.0))).open(&mut open).default_size(vec2(320.0, 180.0)).resizable(false).show(
+    egui::Window::new(egui::RichText::new(crate::i18n::tr("Appearance")).font(bold(13.0))).open(&mut open).default_size(vec2(320.0, 180.0)).resizable(false).show(
         ctx,
         |ui| {
             egui::Grid::new("ui_custom").num_columns(2).spacing(vec2(12.0, 8.0)).show(ui, |ui| {
-                ui.label("Interface scale");
+                ui.label(crate::i18n::tr("Interface scale"));
                 ui.add(egui::Slider::new(&mut app.extra.ui_scale, 0.75..=1.5).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0)));
                 ui.end_row();
-                ui.label("Reduce motion");
+                ui.label(crate::i18n::tr("Reduce motion"));
                 crate::widgets::checkbox(ui, &mut app.extra.reduce_motion, "");
                 ui.end_row();
-                ui.label("Theme");
+                ui.label(crate::i18n::tr("Theme"));
                 let mut chosen = None;
                 egui::ComboBox::from_id_salt("theme_mode").selected_text(app.ui.theme.label()).show_ui(ui, |ui| {
                     for mode in crate::theme::ThemeMode::ALL {
@@ -173,11 +173,11 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) {
                     let _ = app.run("ui.theme", json!({"mode": mode.id()}));
                 }
                 ui.end_row();
-                ui.label("Auto-update configuration");
+                ui.label(crate::i18n::tr("Auto-update configuration"));
                 crate::widgets::checkbox(ui, &mut app.extra.auto_update_config, "");
                 ui.end_row();
             });
-            if ui.button("Reset").clicked() {
+            if ui.button(crate::i18n::tr("Reset")).clicked() {
                 app.extra.ui_scale = 1.0;
                 app.extra.reduce_motion = false;
                 let _ = app.run("ui.theme", json!({"mode": crate::theme::ThemeMode::default().id()}));
diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs
--- a/crates/ui-egui/src/fonts.rs
+++ b/crates/ui-egui/src/fonts.rs
@@ -108,6 +108,8 @@ fn load(_: &[(&str, u32)], _: &str, _: Option<&str>) -> Option<FontData> {
     None
 }
 
+include!(concat!(env!("OUT_DIR"), "/korean_ui_fonts.rs"));
+
 pub fn definitions() -> FontDefinitions {
     let mut fonts = FontDefinitions::default();
     let base: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
@@ -125,6 +127,14 @@ pub fn definitions() -> FontDefinitions {
         bold_family.insert(0, "system-regular".into());
     }
     fonts.families.insert(FontFamily::Name("bold".into()), bold_family);
+    for (style, bytes) in KOREAN_UI_FONTS {
+        fonts.font_data.insert(format!("nanum-{style}"), Arc::new(FontData::from_static(bytes)));
+    }
+    for (family, stack) in &mut fonts.families {
+        let bold = *family == FontFamily::Name("bold".into()) && fonts.font_data.contains_key("nanum-Bold");
+        let name = if bold { "nanum-Bold" } else { "nanum-Regular" };
+        if fonts.font_data.contains_key(name) { stack.push(name.into()); }
+    }
     if let Some(h) = load(HANGUL, "sans-serif:lang=ko", Some("ko")) {
         fonts.font_data.insert("system-hangul".into(), Arc::new(h));
         for family in fonts.families.values_mut() {
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/i18n.rs
@@ -0,0 +1,116 @@
+//! Display-only translations. Commands, document data and user text keep their values.
+use std::{cell::Cell, collections::HashMap, sync::OnceLock};
+use serde::{Deserialize, Serialize};
+
+mod ko;
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Language {
+    #[default]
+    #[serde(rename = "en")]
+    English,
+    #[serde(rename = "ko")]
+    Korean,
+}
+
+impl Language {
+    pub const ALL: [Self; 2] = [Self::English, Self::Korean];
+    pub fn code(self) -> &'static str { match self { Self::English => "en", Self::Korean => "ko" } }
+    pub fn name(self) -> &'static str { match self { Self::English => "English", Self::Korean => "한국어" } }
+    pub fn from_code(code: &str) -> Option<Self> {
+        match code { "en" => Some(Self::English), "ko" => Some(Self::Korean), _ => None }
+    }
+}
+
+thread_local! { static CURRENT: Cell<Language> = const { Cell::new(Language::English) }; }
+pub fn set_current(language: Language) { CURRENT.set(language); }
+pub fn is_korean() -> bool { CURRENT.get() == Language::Korean }
+
+fn catalog() -> &'static HashMap<&'static str, &'static str> {
+    static KO: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
+    KO.get_or_init(|| ko::TEXT.iter().copied().collect())
+}
+
+pub fn tr(source: &str) -> &str {
+    if is_korean() { catalog().get(source).copied().unwrap_or(source) } else { source }
+}
+
+/// Look up a command's label without changing the command identifier.
+pub fn command<'a>(id: &str, english: &'a str) -> &'a str {
+    if is_korean() {
+        ko::COMMANDS.iter().find(|(key, _, _)| *key == id).map_or_else(|| tr(english), |(_, _, label)| *label)
+    } else { english }
+}
+
+pub fn path(source: &str) -> String {
+    let separator = if source.contains(" > ") { " > " } else { " › " };
+    source.split(separator).map(tr).collect::<Vec<_>>().join(separator)
+}
+
+/// Render preformatted values in one pass, so braces inside a user value stay literal.
+/// The caller uses Rust's original format! in English and formats each field in Korean.
+pub fn format_fields(source: &str, args: &[(&str, String)]) -> String {
+    let template = tr(source);
+    let mut output = String::new();
+    let mut rest = template;
+    let mut anonymous = 0;
+    while let Some(at) = rest.find(['{', '}']) {
+        output.push_str(&rest[..at]);
+        rest = &rest[at..];
+        if rest.starts_with("{{") { output.push('{'); rest = &rest[2..]; continue; }
+        if rest.starts_with("}}") { output.push('}'); rest = &rest[2..]; continue; }
+        if rest.starts_with('{') && let Some(end) = rest.find('}') {
+            let key = &rest[1..end];
+            let value = if key.is_empty() || key.starts_with(':') {
+                let value = args.iter().filter(|(k, _)| k.is_empty() || k.starts_with(':')).nth(anonymous);
+                anonymous += 1;
+                value
+            } else { args.iter().find(|(k, _)| *k == key) };
+            if let Some((_, value)) = value { output.push_str(value); }
+            else { output.push_str(&rest[..=end]); }
+            rest = &rest[end + 1..];
+        } else { output.push_str(&rest[..1]); rest = &rest[1..]; }
+    }
+    output.push_str(rest);
+    output
+}
+
+pub fn english_ending(value: &str) -> &str { if is_korean() { "" } else { value } }
+
+pub fn chooser(ui: &mut egui::Ui, language: Language) -> Option<Language> {
+    let mut selected = language;
+    egui::ComboBox::from_id_salt("interface_language")
+        .selected_text(language.name())
+        .show_ui(ui, |ui| {
+            for option in Language::ALL { ui.selectable_value(&mut selected, option, option.name()); }
+        });
+    (selected != language).then_some(selected)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    #[test]
+    fn language_round_trips_without_touching_user_values() {
+        assert_eq!(serde_json::from_str::<Language>("\"ko\"").unwrap(), Language::Korean);
+        assert_eq!(serde_json::to_string(&Language::English).unwrap(), "\"en\"");
+        assert!(Language::from_code("unknown").is_none());
+        set_current(Language::English);
+        assert_eq!(tr("File"), "File");
+        set_current(Language::Korean);
+        assert_eq!(tr("File"), "파일");
+        assert_eq!(tr("user {name}.txt"), "user {name}.txt");
+        assert_eq!(format_fields("{} contributors · {} commits", &[("", "A {x}".into()), ("", "2".into())]), "기여자 A {x}명 · 커밋 2개");
+        assert_eq!(english_ending("s"), "");
+        set_current(Language::English);
+        assert_eq!(english_ending("s"), "s");
+    }
+    #[test]
+    fn reviewed_catalog_has_unique_keys() {
+        let mut seen = std::collections::HashSet::new();
+        for (source, translated) in ko::TEXT {
+            assert!(seen.insert(source), "duplicate source {source}");
+            assert!(!translated.trim().is_empty());
+        }
+    }
+}
diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/i18n/ko.rs
@@ -0,0 +1,1076 @@
+//! Contributor-reviewed Korean strings.
+pub(super) const TEXT: &[(&str, &str)] = &[
+    ("Revert “{name}” to its last saved version?", "“{name}”을(를) 마지막으로 저장한 버전으로 되돌릴까요?"),
+    ("Close “{name}” without saving?", "“{name}”을(를) 저장하지 않고 닫을까요?"),
+    ("unsaved changes", "저장되지 않은 변경 내용"),
+    ("unsaved changes and an unfinished recording", "저장되지 않은 변경 내용과 완료되지 않은 녹음"),
+    ("recently", "최근"),
+    ("less than a minute ago", "1분 미만 전"),
+    ("{n} {unit}{} ago", "{n}{unit}{} 전"),
+    ("minute", "분"),
+    ("hour", "시간"),
+    ("day", "일"),
+    ("Redo {action}", "{action} 다시 실행"),
+    ("Undo {action}", "{action} 실행 취소"),
+    ("Clear Clips", "클립 지우기"),
+    ("Undo", "실행 취소"),
+    ("Edit", "편집"),
+    ("Redo", "다시 실행"),
+    ("Restore Last Selection", "마지막 선택 영역 복원"),
+    ("Cut", "잘라내기"),
+    ("Copy", "복사"),
+    ("Paste", "붙여넣기"),
+    ("Clear", "지우기"),
+    ("Clip Gain", "클립 게인"),
+    ("Cut Special", "선택하여 잘라내기"),
+    ("Clear Special", "선택하여 지우기"),
+    ("All Automation", "모든 오토메이션"),
+    ("Pan Automation", "팬 오토메이션"),
+    ("Plugin Automation", "플러그인 오토메이션"),
+    ("Repeat to Fill Selection", "선택 영역을 채우도록 반복"),
+    ("Paste Special", "선택하여 붙여넣기"),
+    ("Select All", "모두 선택"),
+    ("Set Edit Selection", "편집 선택 영역 설정"),
+    ("Deselect All", "모두 선택 해제"),
+    ("Change Timeline to Match Edit", "타임라인을 편집 영역에 맞추기"),
+    ("Selection", "선택 영역"),
+    ("Change Edit to Match Timeline", "편집 영역을 타임라인에 맞추기"),
+    ("Move Edit Left", "편집 영역을 왼쪽으로 이동"),
+    ("Move Edit Right", "편집 영역을 오른쪽으로 이동"),
+    ("Halve Edit", "편집 영역을 절반으로"),
+    ("Double Edit", "편집 영역을 두 배로"),
+    ("Extend Edit Up", "편집 영역을 위로 확장"),
+    ("Extend Edit Down", "편집 영역을 아래로 확장"),
+    ("Remove Edit from Top", "편집 영역의 위쪽 제거"),
+    ("Remove Edit from Bottom", "편집 영역의 아래쪽 제거"),
+    ("Duplicate", "복제"),
+    ("Repeat...", "반복..."),
+    ("Shift...", "이동..."),
+    ("Insert Silence", "무음 삽입"),
+    ("At Selection", "선택 위치에서"),
+    ("Separate", "분리"),
+    ("On Grid", "격자에서"),
+    ("At Transients", "트랜지언트에서"),
+    ("Heal Separation", "분리 복구"),
+    ("To Selection", "선택 영역으로"),
+    ("Trim", "트림"),
+    ("Start to Insertion", "시작부터 삽입점까지"),
+    ("End to Insertion", "끝부터 삽입점까지"),
+    ("To File Boundaries", "파일 경계까지"),
+    ("To Fill Selection", "선택 영역을 채우도록"),
+    ("Mute", "음소거"),
+    ("New Playlist", "새 플레이리스트"),
+    ("Copy Selection to...", "선택 영역 복사 대상..."),
+    ("Strip Silence", "무음 제거"),
+    ("Create...", "만들기..."),
+    ("Fades", "페이드"),
+    ("Delete", "삭제"),
+    ("Fade to Start", "시작까지 페이드"),
+    ("Fade to End", "끝까지 페이드"),
+    ("Nudge", "넛지"),
+    ("Edit Mode", "편집 모드"),
+    ("Snap to Grid", "격자에 스냅"),
+    ("Shuffle Lock", "셔플 잠금"),
+    ("Edit Tool", "편집 도구"),
+    ("Move Clips", "클립 이동"),
+    ("Trim Clip", "클립 트림"),
+    ("Consolidate", "통합"),
+    ("Copy Special", "선택하여 복사"),
+    ("Paste Clip Gain", "클립 게인 붙여넣기"),
+    ("Clip Effects", "클립 효과"),
+    ("Paste Clip Effects", "클립 효과 붙여넣기"),
+    ("Merge MIDI", "MIDI 병합"),
+    ("Merge Markers", "마커 병합"),
+    ("To Current Automation Type", "현재 오토메이션 유형에"),
+    ("Play Timeline", "타임라인 재생"),
+    ("Duplicate and Extend Edit", "복제하고 편집 영역 확장"),
+    ("Extend Edit Up to Members", "편집 영역을 위쪽 구성원까지 확장"),
+    ("Extend Edit Down to Members", "편집 영역을 아래쪽 구성원까지 확장"),
+    ("Move Edit Selection To Marker Lane Above", "편집 선택 영역을 위쪽 마커 레인으로 이동"),
+    ("Move Edit Selection To Marker Lane Below", "편집 선택 영역을 아래쪽 마커 레인으로 이동"),
+    ("Extend Edit Selection To Marker Lane Above", "편집 선택 영역을 위쪽 마커 레인까지 확장"),
+    ("Extend Edit Selection To Marker Lane Below", "편집 선택 영역을 아래쪽 마커 레인까지 확장"),
+    ("Space Clips…", "클립 간격 설정…"),
+    ("Next", "다음"),
+    ("Snap to", "스냅 대상"),
+    ("Previous", "이전"),
+    ("Target Playlist", "대상 플레이리스트"),
+    ("Duplicate of Main Playlist", "기본 플레이리스트의 복제본"),
+    ("Move Selection to...", "선택 영역 이동 대상..."),
+    ("TCE Edit to Timeline Selection", "타임라인 선택 영역에 맞게 편집 영역 시간 압축/확장"),
+    ("Coalesce Volume Automation to Clip Gain", "볼륨 오토메이션을 클립 게인에 통합"),
+    ("Automation", "오토메이션"),
+    ("Coalesce Clip Gain to Volume Automation", "클립 게인을 볼륨 오토메이션에 통합"),
+    ("Trim to All Enabled", "활성화된 모든 항목에 트림"),
+    ("Glide to All Enabled", "활성화된 모든 항목에 글라이드"),
+    ("Glide Pan Only", "팬만 글라이드"),
+    ("Time Operations Window", "시간 작업 창"),
+    ("Event", "이벤트"),
+    ("Time Operations", "시간 작업"),
+    ("Tempo Operations Window", "템포 작업 창"),
+    ("Tempo Operations", "템포 작업"),
+    ("MIDI Operations Window", "MIDI 작업 창"),
+    ("MIDI Operations", "MIDI 작업"),
+    ("Move Song Start...", "곡 시작 위치 이동..."),
+    ("Linear...", "선형..."),
+    ("Parabolic...", "포물선..."),
+    ("S-Curve...", "S자 곡선..."),
+    ("Stretch...", "늘이기..."),
+    ("Select/Split Notes...", "음표 선택/분할..."),
+    ("Input Quantize...", "입력 퀀타이즈..."),
+    ("Step Input...", "스텝 입력..."),
+    ("Restore Performance...", "연주 복원..."),
+    ("Flatten Performance...", "연주 확정..."),
+    ("MIDI Track Offsets", "MIDI 트랙 오프셋"),
+    ("MIDI Real-Time Properties", "MIDI 실시간 속성"),
+    ("Extract Chords from Selection", "선택 영역에서 코드 추출"),
+    ("Beat Detective", "비트 감지기"),
+    ("Identify Beat...", "비트 식별..."),
+    ("Retrospective Record", "소급 녹음"),
+    ("Edit Lock/Unlock", "편집 잠금/해제"),
+    ("Clip", "클립"),
+    ("Time Lock/Unlock", "시간 잠금/해제"),
+    ("Send to Back", "맨 뒤로 보내기"),
+    ("Bring to Front", "맨 앞으로 가져오기"),
+    ("Rating", "평점"),
+    ("Group", "그룹화"),
+    ("Ungroup", "그룹 해제"),
+    ("Ungroup All", "모든 그룹 해제"),
+    ("Loop...", "루프..."),
+    ("Rename...", "이름 변경..."),
+    ("Nudge Clip Gain Up", "클립 게인 조금 올리기"),
+    ("Nudge Clip Gain Down", "클립 게인 조금 내리기"),
+    ("Render", "렌더링"),
+    ("Bypass", "바이패스"),
+    ("Identify Sync Point", "동기화 지점 지정"),
+    ("Remove Sync Point", "동기화 지점 제거"),
+    ("Quantize to Grid", "격자에 퀀타이즈"),
+    ("Clip Color", "클립 색상"),
+    ("Elastic Properties", "탄력 오디오 속성"),
+    ("Remove Warp", "워프 제거"),
+    ("Place Audio File", "오디오 파일 배치"),
+    ("Capture...", "캡처..."),
+    ("Audio as MIDI", "오디오를 MIDI로"),
+    ("New...", "새로 만들기..."),
+    ("File", "파일"),
+    ("Open Session...", "세션 열기..."),
+    ("Close Session", "세션 닫기"),
+    ("Save Session", "세션 저장"),
+    ("Save Session As...", "세션을 다른 이름으로 저장..."),
+    ("Save Session Copy In...", "세션 사본 저장 위치..."),
+    ("Save As Template...", "템플릿으로 저장..."),
+    ("Revert Session to Saved...", "세션을 저장된 상태로 되돌리기..."),
+    ("Audio...", "오디오..."),
+    ("Import", "가져오기"),
+    ("MIDI...", "MIDI..."),
+    ("Video...", "비디오..."),
+    ("Bounce Mix...", "믹스 바운스..."),
+    ("Bounce Stems...", "스템 바운스..."),
+    ("Export", "내보내기"),
+    ("Session Info as Text...", "세션 정보를 텍스트로..."),
+    ("Export Clips as Files...", "클립을 파일로 내보내기..."),
+    ("Selected Tracks as New Session...", "선택한 트랙을 새 세션으로..."),
+    ("Sibelius...", "Sibelius..."),
+    ("Sibelius", "Sibelius"),
+    ("Send To...", "보내기 대상..."),
+    ("Print Score...", "악보 인쇄..."),
+    ("Score Setup...", "악보 설정..."),
+    ("Export Clip Groups...", "클립 그룹 내보내기..."),
+    ("Clip Groups...", "클립 그룹..."),
+    ("Get Info...", "정보 가져오기..."),
+    ("Quit", "종료"),
+    ("Regroup", "다시 그룹화"),
+    ("Unloop...", "루프 해제..."),
+    ("Set Clip Effects", "클립 효과 설정"),
+    ("Conform to Tempo", "템포에 맞추기"),
+    ("Remove Pitch Shift", "피치 이동 제거"),
+    ("Tempo Change", "템포 변경"),
+    ("Remove Tempo Change", "템포 변경 제거"),
+    ("Change Meter...", "박자 변경..."),
+    ("Scale...", "비율 조절..."),
+    ("Constant...", "고정값..."),
+    ("Insert Time...", "시간 삽입..."),
+    ("Cut Time...", "시간 잘라내기..."),
+    ("Quantize...", "퀀타이즈..."),
+    ("Transpose...", "조옮김..."),
+    ("Change Velocity...", "벨로시티 변경..."),
+    ("Change Duration...", "길이 변경..."),
+    ("Remove Duplicate Notes", "중복 음표 제거"),
+    ("Add Key Change...", "조성 변경 추가..."),
+    ("Renumber Bars...", "마디 번호 다시 매기기..."),
+    ("All MIDI Notes Off", "모든 MIDI 음표 끄기"),
+    ("Add MIDI Note", "MIDI 음표 추가"),
+    ("New Memory Location", "새 메모리 위치"),
+    ("Delete Memory Location", "메모리 위치 삭제"),
+    ("Edit Memory Location", "메모리 위치 편집"),
+    ("Recall Memory Location", "메모리 위치 불러오기"),
+    ("Go to Next Marker", "다음 마커로 이동"),
+    ("Go to Previous Marker", "이전 마커로 이동"),
+    ("List CLAP Plugins", "CLAP 플러그인 목록"),
+    ("AudioSuite Process", "AudioSuite 처리"),
+    ("Store Plugin State", "플러그인 상태 저장"),
+    ("Destructive Record", "파괴적 녹음"),
+    ("Options", "옵션"),
+    ("TrackPunch", "트랙 펀치"),
+    ("Edit Window Scrolling", "편집 창 스크롤"),
+    ("Solo Mode", "솔로 모드"),
+    ("Keyboard Focus", "키보드 포커스"),
+    ("Pre-Roll Amount", "프리롤 길이"),
+    ("Post-Roll Amount", "포스트롤 길이"),
+    ("Session", "세션"),
+    ("Setup", "설정"),
+    ("Preferences...", "환경 설정..."),
+    ("Reset Edit Settings", "편집 설정 초기화"),
+    ("List MIDI Notes", "MIDI 음표 목록"),
+    ("New MIDI Clip", "새 MIDI 클립"),
+    ("Add Note", "음표 추가"),
+    ("Edit Note", "음표 편집"),
+    ("Delete Notes", "음표 삭제"),
+    ("Move Notes", "음표 이동"),
+    ("Load Sampler Sample", "샘플러 샘플 불러오기"),
+    ("Clear Sampler Sample", "샘플러 샘플 지우기"),
+    ("Session Audio Health", "세션 오디오 상태"),
+    ("Set Instrument", "악기 설정"),
+    ("Set Instrument Parameter", "악기 매개 변수 설정"),
+    ("Set Volume", "볼륨 설정"),
+    ("Set Pan", "팬 설정"),
+    ("Solo", "솔로"),
+    ("Solo Safe", "솔로 세이프"),
+    ("Clear All Solos", "모든 솔로 해제"),
+    ("Clear All Mutes", "모든 음소거 해제"),
+    ("Record Enable", "녹음 대기"),
+    ("Input Monitoring", "입력 모니터링"),
+    ("Phase Invert", "위상 반전"),
+    ("Input Trim", "입력 트림"),
+    ("Automation Mode", "오토메이션 모드"),
+    ("Insert Plugin", "플러그인 삽입"),
+    ("Remove Insert", "인서트 제거"),
+    ("Bypass Insert", "인서트 바이패스"),
+    ("Set Plugin Parameter", "플러그인 매개 변수 설정"),
+    ("Set Plugin Parameters", "플러그인 매개 변수 설정"),
+    ("Move Insert", "인서트 이동"),
+    ("Assign Send", "센드 할당"),
+    ("Remove Send", "센드 제거"),
+    ("Send Level", "센드 레벨"),
+    ("New Bus", "새 버스"),
+    ("Add Automation Breakpoint", "오토메이션 제어점 추가"),
+    ("Write Automation", "오토메이션 기록"),
+    ("Clear Automation", "오토메이션 지우기"),
+    ("Thin", "간소화"),
+    ("Thin All", "모두 간소화"),
+    ("Write to Current", "현재 항목에 기록"),
+    ("Write to All Enabled", "활성화된 모든 항목에 기록"),
+    ("Convert Volume Automation to Clip Gain", "볼륨 오토메이션을 클립 게인으로 변환"),
+    ("Convert Clip Gain to Volume Automation", "클립 게인을 볼륨 오토메이션으로 변환"),
+    ("Copy to Send...", "센드로 복사..."),
+    ("Inspect Session", "세션 검사"),
+    ("Inspect Track", "트랙 검사"),
+    ("Inspect Clip", "클립 검사"),
+    ("List Commands", "명령 목록"),
+    ("Undo History", "실행 취소 기록"),
+    ("Window", "창"),
+    ("List Plugins", "플러그인 목록"),
+    ("Parity Report", "기능 동등성 보고서"),
+    ("Format Time", "시간 서식"),
+    ("Set Surround Pan", "서라운드 팬 설정"),
+    ("Set Send Surround Pan", "센드 서라운드 팬 설정"),
+    ("Main Output Format", "기본 출력 형식"),
+    ("Prepare DPE Tracks", "DPE 트랙 준비"),
+    ("Hardware...", "하드웨어..."),
+    ("Playback Engine...", "재생 엔진..."),
+    ("Disk Allocation...", "디스크 할당..."),
+    ("Peripherals...", "주변 장치..."),
+    ("I/O...", "I/O..."),
+    ("Current Timecode Position...", "현재 타임코드 위치..."),
+    ("Current Feet+Frames Position...", "현재 피트+프레임 위치..."),
+    ("External Timecode Offset...", "외부 타임코드 오프셋..."),
+    ("Video Sync Offset...", "비디오 동기화 오프셋..."),
+    ("Click/Countoff...", "클릭/카운트인..."),
+    ("Keyboard Shortcuts...", "키보드 단축키..."),
+    ("MIDI Studio...", "MIDI 스튜디오..."),
+    ("MIDI", "MIDI"),
+    ("MIDI Beat Clock...", "MIDI 비트 클록..."),
+    ("MIDI Input Filter...", "MIDI 입력 필터..."),
+    ("MIDI Input Devices...", "MIDI 입력 장치..."),
+    ("Transcription Settings...", "전사 설정..."),
+    ("Session Data...", "세션 데이터..."),
+    ("Track", "트랙"),
+    ("Group...", "그룹..."),
+    ("Delete Group", "그룹 삭제"),
+    ("Toggle Group Active", "그룹 활성 상태 전환"),
+    ("Modify Group", "그룹 수정"),
+    ("Duplicate...", "복제..."),
+    ("Split into Mono", "모노로 분할"),
+    ("Make Inactive", "비활성화"),
+    ("Move to New Folder...", "새 폴더로 이동..."),
+    ("Move to Folder", "폴더로 이동"),
+    ("Remove from Folder", "폴더에서 제거"),
+    ("Change Track Width", "트랙 채널 수 변경"),
+    ("Rename Track", "트랙 이름 변경"),
+    ("Track Comments", "트랙 댓글"),
+    ("Track Color", "트랙 색상"),
+    ("Track Height", "트랙 높이"),
+    ("Increase Track Height", "트랙 높이 늘리기"),
+    ("Decrease Track Height", "트랙 높이 줄이기"),
+    ("Track View", "트랙 보기"),
+    ("Hide Track", "트랙 숨기기"),
+    ("Pin Track", "트랙 고정"),
+    ("Move Track", "트랙 이동"),
+    ("Freeze", "프리즈"),
+    ("Commit...", "커밋..."),
+    ("Bounce...", "바운스..."),
+    ("All", "모두"),
+    ("Bypass Inserts", "인서트 바이패스"),
+    ("Mute Sends", "센드 음소거"),
+    ("Set Record Tracks to Input Only", "녹음 트랙을 입력 전용으로 설정"),
+    ("Scroll to Track...", "트랙으로 스크롤..."),
+    ("Clear All Clip Indicators", "모든 클리핑 표시기 지우기"),
+    ("Clear Trim Automation", "트림 오토메이션 지우기"),
+    ("Create Click Track", "클릭 트랙 만들기"),
+    ("Duplicate Playlist", "플레이리스트 복제"),
+    ("Select Playlist", "플레이리스트 선택"),
+    ("Delete Unused Playlists", "사용하지 않는 플레이리스트 삭제"),
+    ("Open/Close Folder", "폴더 열기/닫기"),
+    ("Assign to VCA", "VCA에 할당"),
+    ("Promote to Main Playlist", "기본 플레이리스트로 승격"),
+    ("Track Input", "트랙 입력"),
+    ("Track Output", "트랙 출력"),
+    ("Track Timebase", "트랙 시간 기준"),
+    ("Elastic Audio", "탄력 오디오"),
+    ("Show", "표시"),
+    ("Sort", "정렬"),
+    ("Reverse the order", "순서 뒤집기"),
+    ("Grab bag", "모아 보기"),
+    ("Table", "표"),
+    ("{} contributors · {} commits", "기여자 {}명 · 커밋 {}개"),
+    ("No contributor data was built into this copy.", "이 사본에는 기여자 데이터가 포함되어 있지 않습니다."),
+    ("{}{arrow}", "{}{arrow}"),
+    ("No model credits were built into this copy.", "이 사본에는 모델 기여 정보가 포함되어 있지 않습니다."),
+    ("List VST3 Plugins", "VST3 플러그인 목록"),
+    ("Zoom In", "확대"),
+    ("Zoom Out", "축소"),
+    ("Zoom By", "확대/축소 배율"),
+    ("Set Zoom", "확대/축소 설정"),
+    ("Zoom Preset", "확대/축소 사전 설정"),
+    ("Zoom to Selection", "선택 영역에 맞춰 확대/축소"),
+    ("Fill Window With Session", "세션을 창에 맞추기"),
+    ("Scroll Timeline", "타임라인 스크롤"),
+    ("Waveform Zoom", "파형 확대/축소"),
+    ("Rulers", "눈금자"),
+    ("View", "보기"),
+    ("Main Counter", "기본 카운터"),
+    ("Sub Counter", "보조 카운터"),
+    ("Grid Value", "격자 값"),
+    ("Show Grid Lines", "격자선 표시"),
+    ("Nudge Value", "넛지 값"),
+    ("Zoom Toggle", "확대/축소 전환"),
+    ("Tab", "탭"),
+    ("Video Track Online", "비디오 트랙 온라인"),
+    ("Set Video Start", "비디오 시작 설정"),
+    ("Movies", "동영상"),
+    ("Bouncing {name}", "{name} 바운스 중"),
+    ("Cancel", "취소"),
+    ("Edit Window Views", "편집 창 보기"),
+    ("Minimal", "최소"),
+    ("Timecode 2", "타임코드 2"),
+    ("Markers 2", "마커 2"),
+    ("Markers 3", "마커 3"),
+    ("Markers 4", "마커 4"),
+    ("Markers 5", "마커 5"),
+    ("All Marker Rulers", "모든 마커 눈금자"),
+    ("Expanded Sends", "확장된 센드"),
+    ("None", "없음"),
+    ("View Flags", "표시 플래그"),
+    ("Object / Bed", "개체 / 베드"),
+    ("Convert Aux to Routing Folder", "보조 트랙을 라우팅 폴더로 변환"),
+    ("Extract MIDI to New Track", "MIDI를 새 트랙으로 추출"),
+    ("Save Track Preset...", "트랙 사전 설정 저장..."),
+    ("Load Track Preset", "트랙 사전 설정 불러오기"),
+    ("Inserts A-E", "인서트 A-E"),
+    ("Inserts F-J", "인서트 F-J"),
+    ("EQ", "EQ"),
+    ("Dynamics", "다이내믹스"),
+    ("Reverb", "리버브"),
+    ("Delay", "지연"),
+    ("Modulation", "모듈레이션"),
+    ("Sends A-E", "센드 A-E"),
+    ("Sends F-J", "센드 F-J"),
+    ("Write MIDI Real-Time Properties", "MIDI 실시간 속성 기록"),
+    ("Write Trim Automation", "트림 오토메이션 기록"),
+    ("Coalesce Trim Automation", "트림 오토메이션 통합"),
+    ("Coalesce VCA Controller Automation", "VCA 컨트롤러 오토메이션 통합"),
+    ("Designate as Target Playlist", "대상 플레이리스트로 지정"),
+    ("Show Target Playlist", "대상 플레이리스트 표시"),
+    ("Toggle Recent Playlist", "최근 플레이리스트 전환"),
+    ("Play", "재생"),
+    ("Stop", "중지"),
+    ("Play/Stop", "재생/중지"),
+    ("Record", "녹음"),
+    ("Pause", "일시 정지"),
+    ("Half-Speed Playback", "절반 속도 재생"),
+    ("Play Edit", "편집 영역 재생"),
+    ("Return to Zero", "처음으로 돌아가기"),
+    ("Go to End", "끝으로 이동"),
+    ("Rewind", "되감기"),
+    ("Fast Forward", "빨리 감기"),
+    ("Locate", "위치 이동"),
+    ("Transport Status", "트랜스포트 상태"),
+    ("Search", "검색"),
+    ("Search commands…", "명령 검색…"),
+    ("MIDI EDITOR", "MIDI 편집기"),
+    ("{} · {} notes · {} selected · Pencil: add · drag: move/select · arrows: move · Delete: remove", "{} · 음표 {}개 · {}개 선택됨 · 연필: 추가 · 드래그: 이동/선택 · 화살표: 이동 · Delete: 제거"),
+    ("Legato", "레가토"),
+    ("Vel −10", "벨로시티 −10"),
+    ("Vel +10", "벨로시티 +10"),
+    ("Quantize", "퀀타이즈"),
+    ("Quantize to the grid", "격자에 퀀타이즈"),
+    ("Velocity", "벨로시티"),
+    ("Create", "만들기"),
+    ("Audio Track", "오디오 트랙"),
+    ("Ticks", "틱"),
+    ("Samples", "샘플"),
+    ("Name:", "이름:"),
+    ("Name", "이름"),
+    ("{sample_rate} Hz", "{sample_rate}Hz"),
+    ("{r} Hz", "{r}Hz"),
+    ("Browse…", "찾아보기…"),
+    ("{bit_depth}-bit", "{bit_depth}비트"),
+    ("{b}-bit", "{b}비트"),
+    ("Bounces the edit selection, or the whole session when nothing is selected.", "편집 선택 영역을 바운스합니다. 선택 영역이 없으면 전체 세션을 바운스합니다."),
+    ("“{}” already exists. Do you want to replace it?", "“{}”이(가) 이미 있습니다. 바꾸시겠습니까?"),
+    ("Threshold dB", "임계값 dB"),
+    ("Min strip duration ms", "최소 무음 제거 길이 ms"),
+    ("Clip start pad ms", "클립 시작 여유 ms"),
+    ("Clip end pad ms", "클립 끝 여유 ms"),
+    ("Tracks", "트랙"),
+    ("Title", "제목"),
+    ("Composer", "작곡가"),
+    ("Bars per system", "단당 마디 수"),
+    ("{question} Your changes will be lost.", "{question} 변경 내용이 사라집니다."),
+    ("Save changes to “{name}” before closing?", "닫기 전에 “{name}”의 변경 내용을 저장하시겠습니까?"),
+    ("Don't Save", "저장 안 함"),
+    ("AudioSuite · {title}", "AudioSuite · {title}"),
+    ("Peak dB", "피크 dB"),
+    ("Length ratio", "길이 비율"),
+    ("Semitones", "반음"),
+    ("Speed", "속도"),
+    ("No parameters.", "매개 변수가 없습니다."),
+    ("Analyses the edit selection on the selected tracks.", "선택한 트랙의 편집 선택 영역을 분석합니다."),
+    ("Sensitivity", "민감도"),
+    ("Analyze", "분석"),
+    ("Apply", "적용"),
+    ("Identify Beat: selection is", "비트 식별: 선택 영역"),
+    ("bar(s)", "마디"),
+    ("Set Tempo", "템포 설정"),
+    ("start bpm ", "시작 BPM "),
+    ("end bpm ", "끝 BPM "),
+    ("Applies across the edit selection (constant: at the insertion point; scale: factor = end / start; stretch: factor = start / end).", "편집 선택 영역 전체에 적용합니다(고정값: 삽입점, 비율 조절: 끝/시작, 늘이기: 시작/끝)."),
+    (" s", " 초"),
+    ("Uses the edit selection.", "편집 선택 영역을 사용합니다."),
+    ("Strength %", "강도 %"),
+    ("Swing %", "스윙 %"),
+    ("Add", "추가"),
+    ("Applies to the selected MIDI clips (or those under the selection).", "선택한 MIDI 클립 또는 선택 영역 아래의 클립에 적용합니다."),
+    ("Non-destructive, applied at playback to the selected MIDI tracks.", "비파괴적으로 선택한 MIDI 트랙의 재생에 적용합니다."),
+    ("Velocity +", "벨로시티 +"),
+    ("Transpose", "조옮김"),
+    ("Duration %", "길이 %"),
+    ("Delay (ticks)", "지연(틱)"),
+    ("Write to Notes", "음표에 기록"),
+    ("SoundCraft", "SoundCraft"),
+    ("About SoundCraft", "SoundCraft 정보"),
+    ("Session Info", "세션 정보"),
+    ("File > Open", "파일 > 열기"),
+    ("Track > New...", "트랙 > 새로 만들기..."),
+    ("AudioSuite", "AudioSuite"),
+    ("Processes selected clips offline", "선택한 클립을 오프라인 처리합니다"),
+    ("Mix in Separate Window", "별도 창에 믹스 표시"),
+    ("Show Edit and Mix at once, e.g. one per monitor", "편집 및 믹스를 동시에 표시합니다(예: 모니터마다 하나씩)"),
+    ("Instrument", "악기"),
+    ("Select an instrument track", "악기 트랙을 선택하세요"),
+    (" dB", " dB"),
+    ("MIDI in: all", "MIDI 입력: 모두"),
+    ("OBJECT", "개체"),
+    ("BED", "베드"),
+    ("Route as an immersive object or to the bed", "몰입형 개체 또는 베드로 라우팅"),
+    ("Click: open instrument · right-click: change", "클릭: 악기 열기 · 오른쪽 클릭: 변경"),
+    ("no insert", "인서트 없음"),
+    ("{c:?}", "{c:?}"),
+    ("Edit Parameters…", "매개 변수 편집…"),
+    ("Close Plugin Editor", "플러그인 편집기 닫기"),
+    ("Open Plugin Editor", "플러그인 편집기 열기"),
+    ("Built-in", "기본 제공"),
+    ("No matches", "일치 항목 없음"),
+    ("Scan CLAP", "CLAP 검색"),
+    ("Scan VST3", "VST3 검색"),
+    ("CLAP", "CLAP"),
+    ("No CLAP plugins found", "CLAP 플러그인을 찾지 못함"),
+    ("VST3", "VST3"),
+    ("No VST3 plugins found", "VST3 플러그인을 찾지 못함"),
+    ("Audio Units", "Audio Units"),
+    ("No Audio Units found", "Audio Units를 찾지 못함"),
+    ("Unknown vendor", "알 수 없는 제조사"),
+    ("Send {which} to {name}: {} dB\nClick for the send fader · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options", "{name}(으)로 보내는 센드 {which}: {}dB\n클릭: 센드 페이더 · 세로 드래그: 조정 · Shift+드래그: 미세 조정 · Alt+클릭: 0dB · 오른쪽 클릭: 옵션"),
+    ("Send {}", "센드 {}"),
+    ("DESTINATION", "대상"),
+    ("Remove send", "센드 제거"),
+    ("Send {} · Choose destination", "센드 {} · 대상 선택"),
+    ("{name} · Send {}", "{name} · 센드 {}"),
+    ("Send {} · {}", "센드 {} · {}"),
+    ("Level", "레벨"),
+    ("0 dB", "0dB"),
+    ("Pan", "팬"),
+    ("Rename bus…", "버스 이름 변경…"),
+    ("new bus...", "새 버스..."),
+    ("no input", "입력 없음"),
+    ("no output", "출력 없음"),
+    ("In {i}", "입력 {i}"),
+    ("Bus", "버스"),
+    ("{undo}   {:.1} ms/frame", "{undo}   {:.1}ms/프레임"),
+    ("{hi:.0}", "{hi:.0}"),
+    ("{lo:.0}", "{lo:.0}"),
+    ("C major", "다장조"),
+    ("Columns beside the track headers: inserts, sends, I/O…", "트랙 헤더 옆의 열: 인서트, 센드, I/O 등"),
+    ("Click a take (or the selection) to promote it to the main playlist", "테이크 또는 선택 영역을 클릭하여 기본 플레이리스트로 승격"),
+    ("Close folder", "폴더 닫기"),
+    ("Open folder", "폴더 열기"),
+    ("Track options", "트랙 옵션"),
+    ("Hide automation", "오토메이션 숨기기"),
+    ("Show volume automation", "볼륨 오토메이션 표시"),
+    ("Playlists", "플레이리스트"),
+    ("Delete Unused...", "사용하지 않는 항목 삭제..."),
+    ("Unfreeze", "프리즈 해제"),
+    ("Voice: dyn plays the track, off makes it inactive", "보이스: dyn은 트랙 재생, off는 비활성화"),
+    ("Input", "입력"),
+    ("Output", "출력"),
+    ("Show in the Mix window", "믹스 창에 표시"),
+    ("plugin:{}:{}", "plugin:{}:{}"),
+    ("media offline", "미디어 오프라인"),
+    ("{:+.1} dB", "{:+.1}dB"),
+    ("FX", "FX"),
+    ("{db:+.1}", "{db:+.1}"),
+    ("Renderer", "렌더러"),
+    ("Main output: {}", "기본 출력: {}"),
+    ("{sp:?}", "{sp:?}"),
+    ("No object tracks. Use the Object view in the Mix window to route tracks as objects.", "개체 트랙이 없습니다. 믹스 창의 개체 보기에서 트랙을 개체로 라우팅하세요."),
+    ("{} object track(s)", "개체 트랙 {}개"),
+    ("Appearance", "모양"),
+    ("Interface scale", "인터페이스 배율"),
+    ("Reduce motion", "동작 줄이기"),
+    ("Theme", "테마"),
+    ("Auto-update configuration", "구성 자동 업데이트"),
+    ("Reset", "초기화"),
+    ("Shuffle is locked ({cmd}-click to unlock)", "셔플이 잠겨 있습니다({cmd}+클릭하여 해제)"),
+    ("Shuffle mode ({fkey}); {cmd}-click locks it out", "셔플 모드({fkey}); {cmd}+클릭하여 사용 잠금"),
+    ("Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes", "격자 모드({fkey}, 다시 누르면 상대 모드); Shift+클릭: 다른 모드에서 격자에 스냅"),
+    ("{} mode ({fkey}); Shift-click adds Snap to Grid", "{} 모드({fkey}); Shift+클릭하여 격자에 스냅 추가"),
+    ("Slip", "슬립"),
+    ("Spot", "스팟"),
+    ("Smart Tool (F6+F7 / F7+F8)", "스마트 도구(F6+F7 / F7+F8)"),
+    ("Sub", "보조"),
+    ("{st} · {dev}", "{st} · {dev}"),
+    ("Grid", "격자"),
+    ("Show grid lines", "격자선 표시"),
+    ("Stop (Space)", "중지(Space)"),
+    ("Play (Space)", "재생(Space)"),
+    ("Record (Cmd+Space)", "녹음(Cmd+Space)"),
+    ("Count Off", "카운트인"),
+    ("{bars} bars", "{bars}마디"),
+    ("Meter", "박자"),
+    ("Tempo", "템포"),
+    ("Click", "클릭"),
+    ("Drag to change the tempo (Shift: fine steps), or click to type it", "드래그하여 템포 변경(Shift: 미세 조정), 또는 클릭하여 입력"),
+    ("Recover Unsaved Work", "저장하지 않은 작업 복구"),
+    ("“{}” has {what} from an earlier run, autosaved {age}.", "“{}”에 이전 실행의 {what}이(가) 남아 있습니다. {age} 자동 저장되었습니다."),
+    ("Discard", "삭제"),
+    ("Recover", "복구"),
+    ("Stop recording first", "먼저 녹음을 중지하세요"),
+    ("Load Sample…", "샘플 불러오기…"),
+    ("Drop an audio file on this window", "오디오 파일을 이 창에 놓으세요"),
+    ("Drop an audio file here", "오디오 파일을 여기에 놓으세요"),
+    ("Time Scale", "시간 척도"),
+    ("Duration", "길이"),
+    ("Original Time Stamp", "원본 타임스탬프"),
+    ("Use", "사용"),
+    ("Spot to where it was recorded", "녹음된 위치에 배치"),
+    ("Open Recent", "최근 항목 열기"),
+    ("No Recent Sessions", "최근 세션 없음"),
+    ("{}  (missing)", "{}  (누락됨)"),
+    ("{path}\nnot found", "{path}\n찾지 못함"),
+    ("Clear Recent", "최근 목록 지우기"),
+    ("{path}: {e}", "{path}: {e}"),
+    ("Could not open {path}: {e}", "{path}을(를) 열지 못했습니다: {e}"),
+    ("<ALL>", "<모두>"),
+    ("New Group…", "새 그룹…"),
+    ("Select Tracks in Group", "그룹의 트랙 선택"),
+    ("Disable", "사용 안 함"),
+    ("Enable", "사용"),
+    ("Modify Group…", "그룹 수정…"),
+    ("🔍 Find clips", "🔍 클립 찾기"),
+    ("Place on New Track", "새 트랙에 배치"),
+    ("Place on Selected Track", "선택한 트랙에 배치"),
+    ("Clear…", "지우기…"),
+    ("Transport", "트랜스포트"),
+    ("Start", "시작"),
+    ("Pre-roll", "프리롤"),
+    ("{:.2} s", "{:.2}초"),
+    ("End", "끝"),
+    ("Post-roll", "포스트롤"),
+    ("♩ = {bpm:.2}   {}/{}", "♩ = {bpm:.2}   {}/{}"),
+    ("Big Counter", "큰 카운터"),
+    ("+ Marker", "+ 마커"),
+    ("+ Selection", "+ 선택 영역"),
+    ("Recall", "불러오기"),
+    ("{}  {h}", "{}  {h}"),
+    ("(redo) {r}", "(다시 실행) {r}"),
+    ("{tname} · {place} · {}", "{tname} · {place} · {}"),
+    ("Presets ▾", "사전 설정 ▾"),
+    ("This plugin has no editor of its own, or it is not loaded", "이 플러그인에는 자체 편집기가 없거나 아직 불러오지 않았습니다"),
+    ("Load and edit sounds in the plugin's own editor.", "플러그인의 자체 편집기에서 소리를 불러오고 편집하세요."),
+    ("Version {}", "버전 {}"),
+    ("CARGO_PKG_VERSION", "CARGO_PKG_VERSION"),
+    ("An open-source digital audio workstation from the ArtCraft team.", "ArtCraft 팀의 오픈 소스 디지털 오디오 워크스테이션입니다."),
+    ("Dual-licensed MIT OR Apache-2.0. Made with Rust and egui.", "MIT OR Apache-2.0 이중 라이선스. Rust와 egui로 제작했습니다."),
+    ("<factory default>", "<공장 기본값>"),
+    ("User presets need the desktop app.", "사용자 사전 설정에는 데스크톱 앱이 필요합니다."),
+    ("Save", "저장"),
+    ("movie missing from session", "세션에서 동영상 누락"),
+    ("Video Track Offline", "비디오 트랙 오프라인"),
+    ("media offline: {why}", "미디어 오프라인: {why}"),
+    ("media offline: {e}", "미디어 오프라인: {e}"),
+    ("Video", "비디오"),
+    ("Video Universe", "비디오 전체 보기"),
+    ("No Video track — File › Import › Video…", "비디오 트랙 없음 — 파일 › 가져오기 › 비디오…"),
+    ("Score Editor", "악보 편집기"),
+    ("Select a MIDI clip or MIDI track.", "MIDI 클립 또는 MIDI 트랙을 선택하세요."),
+    ("{title} — {}", "{title} — {}"),
+    ("Notation view of the MIDI clip; edit notes in the MIDI Editor or Event List.", "MIDI 클립의 악보 보기입니다. 음표는 MIDI 편집기 또는 이벤트 목록에서 편집하세요."),
+    ("Refresh Report", "보고서 새로 고침"),
+    ("{} audio clips checked · {} issues", "오디오 클립 {}개 검사 · 문제 {}개"),
+    ("Active audio ends at {seconds:.2} seconds", "활성 오디오가 {seconds:.2}초에서 끝납니다"),
+    ("Active audio end time is unknown (invalid session sample rate)", "활성 오디오 종료 시간을 알 수 없습니다(잘못된 세션 샘플 속도)"),
+    ("Includes alternate takes and muted clips. Refresh after editing.", "대체 테이크와 음소거된 클립을 포함합니다. 편집 후 새로 고침하세요."),
+    ("All audio clips have loaded media within source bounds.", "모든 오디오 클립의 미디어가 불러와져 있으며 원본 범위 안에 있습니다."),
+    (" (alternate playlist)", " (대체 플레이리스트)"),
+    ("Checks loaded media only, not files on disk, signal levels, plugins or routing.", "불러온 미디어만 검사하며 디스크 파일, 신호 레벨, 플러그인 또는 라우팅은 검사하지 않습니다."),
+    ("Write automation for the selected tracks:", "선택한 트랙에 오토메이션 기록:"),
+    ("Enabled parameters", "활성화된 매개 변수"),
+    ("Apply to selected tracks (Shift: selected clips)", "선택한 트랙에 적용(Shift: 선택한 클립)"),
+    ("Track tint in the Edit window", "편집 창의 트랙 색상"),
+    ("Session media: {} files, {:.1} MB in memory (32-bit float)", "세션 미디어: 파일 {}개, 메모리 {:.1}MB(32비트 부동소수점)"),
+    ("Recording at 24-bit uses {per_min:.1} MB per mono track-minute", "24비트 녹음은 모노 트랙 1분당 {per_min:.1}MB를 사용합니다"),
+    ("Session file: {p}", "세션 파일: {p}"),
+    ("UI frame time: {:.1} ms", "UI 프레임 시간: {:.1}ms"),
+    ("Audio: {}", "오디오: {}"),
+    ("no engine", "엔진 없음"),
+    ("{} @ {} Hz", "{} @ {}Hz"),
+    ("Active plugin instances: {plugins}", "활성 플러그인 인스턴스: {plugins}개"),
+    ("Tracks: {}", "트랙: {}"),
+    ("Undo steps: {}", "실행 취소 단계: {}"),
+    ("Bouncing {} ({:.0}%)", "{} 바운스 중({:.0}%)"),
+    ("No background tasks are running.", "실행 중인 백그라운드 작업이 없습니다."),
+    ("Renders and AudioSuite processes run to completion before returning.", "렌더링 및 AudioSuite 처리는 완료된 후 반환됩니다."),
+    ("Session comments", "세션 댓글"),
+    ("Select a MIDI clip.", "MIDI 클립을 선택하세요."),
+    ("Click keys to step-enter notes into the selected MIDI clip at the insertion point.", "건반을 클릭하여 선택한 MIDI 클립의 삽입점에 음표를 스텝 입력하세요."),
+    ("Workspace", "작업 공간"),
+    ("Parent folder", "상위 폴더"),
+    ("Double-click to import audio/MIDI at the insertion point or open a session.", "두 번 클릭하여 삽입점에 오디오/MIDI를 가져오거나 세션을 여세요."),
+    ("New Configuration…", "새 구성…"),
+    ("{}  {name}", "{}  {name}"),
+    ("Default ({default})", "기본값({default})"),
+    ("{} ({} ch)", "{}({}채널)"),
+    ("Device default", "장치 기본값"),
+    ("{v} {unit}", "{v} {unit}"),
+    ("Audio system", "오디오 시스템"),
+    ("Default ({def})", "기본값({def})"),
+    ("Output device", "출력 장치"),
+    ("Input device", "입력 장치"),
+    ("Sample rate", "샘플 속도"),
+    ("Buffer size", "버퍼 크기"),
+    ("Refresh devices", "장치 새로 고침"),
+    ("Reopen the audio devices with these settings", "이 설정으로 오디오 장치 다시 열기"),
+    ("Revert", "되돌리기"),
+    ("no device (silent clock)", "장치 없음(무음 클록)"),
+    ("default buffer", "기본 버퍼"),
+    ("{b} samples", "{b}샘플"),
+    ("{} · {} Hz · {} ch · {buf}", "{} · {}Hz · {}채널 · {buf}"),
+    ("opened on first record or monitor", "첫 녹음 또는 모니터링 시 열림"),
+    ("{} · {} Hz · {} ch", "{} · {}Hz · {}채널"),
+    ("Session rate", "세션 샘플 속도"),
+    ("{sr} Hz{note}", "{sr}Hz{note}"),
+    ("The mix runs in 512-sample blocks; the buffer size sets the device's own latency.", "믹스는 512샘플 블록으로 실행됩니다. 버퍼 크기는 장치 자체의 지연 시간을 설정합니다."),
+    ("Reconnect audio device", "오디오 장치 다시 연결"),
+    ("Busses", "버스"),
+    ("Outputs", "출력"),
+    ("Main output", "기본 출력"),
+    ("{} ch", "{}채널"),
+    ("{} — {} from channel {}", "{} — {} · 시작 채널 {}"),
+    ("Select an audio clip.", "오디오 클립을 선택하세요."),
+    ("Clip effects gain (dB)", "클립 효과 게인(dB)"),
+    ("Divergence (0 = point source)", "확산도(0 = 점 음원)"),
+    ("Elevation (height speakers)", "고도(높이 스피커)"),
+];
+
+pub(super) const COMMANDS: &[(&str, &str, &str)] = &[
+    ("clip.clear", "Clear Clips", "클립 지우기"),
+    ("edit.undo", "Undo", "실행 취소"),
+    ("edit.redo", "Redo", "다시 실행"),
+    ("edit.restore_last_selection", "Restore Last Selection", "마지막 선택 영역 복원"),
+    ("edit.cut", "Cut", "잘라내기"),
+    ("edit.copy", "Copy", "복사"),
+    ("edit.paste", "Paste", "붙여넣기"),
+    ("edit.clear", "Clear", "지우기"),
+    ("edit.cut_clip_gain", "Clip Gain", "클립 게인"),
+    ("edit.cut_all_automation", "All Automation", "모든 오토메이션"),
+    ("edit.clear_pan_automation", "Pan Automation", "팬 오토메이션"),
+    ("edit.clear_plugin_automation", "Plugin Automation", "플러그인 오토메이션"),
+    ("edit.paste_repeat_to_fill", "Repeat to Fill Selection", "선택 영역을 채우도록 반복"),
+    ("edit.select_all", "Select All", "모두 선택"),
+    ("edit.select", "Set Edit Selection", "편집 선택 영역 설정"),
+    ("edit.select_none", "Deselect All", "모두 선택 해제"),
+    ("edit.selection_to_timeline", "Change Timeline to Match Edit", "타임라인을 편집 영역에 맞추기"),
+    ("edit.timeline_to_selection", "Change Edit to Match Timeline", "편집 영역을 타임라인에 맞추기"),
+    ("edit.move_selection_left", "Move Edit Left", "편집 영역을 왼쪽으로 이동"),
+    ("edit.move_selection_right", "Move Edit Right", "편집 영역을 오른쪽으로 이동"),
+    ("edit.halve_selection", "Halve Edit", "편집 영역을 절반으로"),
+    ("edit.double_selection", "Double Edit", "편집 영역을 두 배로"),
+    ("edit.extend_selection_up", "Extend Edit Up", "편집 영역을 위로 확장"),
+    ("edit.extend_selection_down", "Extend Edit Down", "편집 영역을 아래로 확장"),
+    ("edit.remove_selection_top", "Remove Edit from Top", "편집 영역의 위쪽 제거"),
+    ("edit.remove_selection_bottom", "Remove Edit from Bottom", "편집 영역의 아래쪽 제거"),
+    ("edit.duplicate", "Duplicate", "복제"),
+    ("edit.repeat", "Repeat...", "반복..."),
+    ("edit.shift", "Shift...", "이동..."),
+    ("edit.insert_silence", "Insert Silence", "무음 삽입"),
+    ("edit.separate", "At Selection", "선택 위치에서"),
+    ("edit.separate_on_grid", "On Grid", "격자에서"),
+    ("edit.separate_at_transients", "At Transients", "트랜지언트에서"),
+    ("edit.heal", "Heal Separation", "분리 복구"),
+    ("edit.trim_to_selection", "To Selection", "선택 영역으로"),
+    ("edit.trim_start_to_insertion", "Start to Insertion", "시작부터 삽입점까지"),
+    ("edit.trim_end_to_insertion", "End to Insertion", "끝부터 삽입점까지"),
+    ("edit.trim_to_file_boundaries", "To File Boundaries", "파일 경계까지"),
+    ("edit.trim_to_fill_selection", "To Fill Selection", "선택 영역을 채우도록"),
+    ("edit.mute_clips", "Mute", "음소거"),
+    ("edit.copy_to_new_playlist", "New Playlist", "새 플레이리스트"),
+    ("edit.strip_silence", "Strip Silence", "무음 제거"),
+    ("edit.fades_create", "Create...", "만들기..."),
+    ("edit.fades_delete", "Delete", "삭제"),
+    ("edit.fade_to_start", "Fade to Start", "시작까지 페이드"),
+    ("edit.fade_to_end", "Fade to End", "끝까지 페이드"),
+    ("edit.nudge", "Nudge", "넛지"),
+    ("edit.mode", "Edit Mode", "편집 모드"),
+    ("edit.snap_to_grid", "Snap to Grid", "격자에 스냅"),
+    ("edit.shuffle_lock", "Shuffle Lock", "셔플 잠금"),
+    ("edit.tool", "Edit Tool", "편집 도구"),
+    ("edit.move_clips", "Move Clips", "클립 이동"),
+    ("edit.trim_clip", "Trim Clip", "클립 트림"),
+    ("edit.consolidate", "Consolidate", "통합"),
+    ("edit.paste_clip_gain", "Paste Clip Gain", "클립 게인 붙여넣기"),
+    ("edit.copy_clip_effects", "Clip Effects", "클립 효과"),
+    ("edit.paste_clip_effects", "Paste Clip Effects", "클립 효과 붙여넣기"),
+    ("edit.paste_merge_midi", "Merge MIDI", "MIDI 병합"),
+    ("edit.paste_merge_markers", "Merge Markers", "마커 병합"),
+    ("edit.paste_to_current_automation", "To Current Automation Type", "현재 오토메이션 유형에"),
+    ("edit.play_timeline", "Play Timeline", "타임라인 재생"),
+    ("edit.duplicate_extend", "Duplicate and Extend Edit", "복제하고 편집 영역 확장"),
+    ("edit.extend_up_to_members", "Extend Edit Up to Members", "편집 영역을 위쪽 구성원까지 확장"),
+    ("edit.extend_down_to_members", "Extend Edit Down to Members", "편집 영역을 아래쪽 구성원까지 확장"),
+    ("edit.marker_lane_move_up", "Move Edit Selection To Marker Lane Above", "편집 선택 영역을 위쪽 마커 레인으로 이동"),
+    ("edit.marker_lane_move_down", "Move Edit Selection To Marker Lane Below", "편집 선택 영역을 아래쪽 마커 레인으로 이동"),
+    ("edit.marker_lane_extend_up", "Extend Edit Selection To Marker Lane Above", "편집 선택 영역을 위쪽 마커 레인까지 확장"),
+    ("edit.marker_lane_extend_down", "Extend Edit Selection To Marker Lane Below", "편집 선택 영역을 아래쪽 마커 레인까지 확장"),
+    ("edit.space_clips", "Space Clips…", "클립 간격 설정…"),
+    ("edit.snap_next", "Next", "다음"),
+    ("edit.snap_previous", "Previous", "이전"),
+    ("edit.copy_to_target_playlist", "Target Playlist", "대상 플레이리스트"),
+    ("edit.copy_to_duplicate_playlist", "Duplicate of Main Playlist", "기본 플레이리스트의 복제본"),
+    ("edit.tce_to_timeline", "TCE Edit to Timeline Selection", "타임라인 선택 영역에 맞게 편집 영역 시간 압축/확장"),
+    ("edit.automation_coalesce_volume_to_clip_gain", "Coalesce Volume Automation to Clip Gain", "볼륨 오토메이션을 클립 게인에 통합"),
+    ("edit.automation_coalesce_clip_gain_to_volume", "Coalesce Clip Gain to Volume Automation", "클립 게인을 볼륨 오토메이션에 통합"),
+    ("edit.automation_trim_to_all", "Trim to All Enabled", "활성화된 모든 항목에 트림"),
+    ("edit.automation_glide_to_all", "Glide to All Enabled", "활성화된 모든 항목에 글라이드"),
+    ("edit.automation_glide_pan", "Glide Pan Only", "팬만 글라이드"),
+    ("event.time_ops_window", "Time Operations Window", "시간 작업 창"),
+    ("event.tempo_ops_window", "Tempo Operations Window", "템포 작업 창"),
+    ("event.midi_ops_window", "MIDI Operations Window", "MIDI 작업 창"),
+    ("event.move_song_start", "Move Song Start...", "곡 시작 위치 이동..."),
+    ("event.tempo_linear", "Linear...", "선형..."),
+    ("event.tempo_parabolic", "Parabolic...", "포물선..."),
+    ("event.tempo_s_curve", "S-Curve...", "S자 곡선..."),
+    ("event.tempo_stretch", "Stretch...", "늘이기..."),
+    ("event.select_split_notes", "Select/Split Notes...", "음표 선택/분할..."),
+    ("event.input_quantize", "Input Quantize...", "입력 퀀타이즈..."),
+    ("event.step_input", "Step Input...", "스텝 입력..."),
+    ("event.restore_performance", "Restore Performance...", "연주 복원..."),
+    ("event.flatten_performance", "Flatten Performance...", "연주 확정..."),
+    ("event.midi_track_offsets", "MIDI Track Offsets", "MIDI 트랙 오프셋"),
+    ("event.midi_rtp", "MIDI Real-Time Properties", "MIDI 실시간 속성"),
+    ("event.extract_chords", "Extract Chords from Selection", "선택 영역에서 코드 추출"),
+    ("event.beat_detective", "Beat Detective", "비트 감지기"),
+    ("event.identify_beat", "Identify Beat...", "비트 식별..."),
+    ("event.retrospective_record", "Retrospective Record", "소급 녹음"),
+    ("clip.edit_lock", "Edit Lock/Unlock", "편집 잠금/해제"),
+    ("clip.time_lock", "Time Lock/Unlock", "시간 잠금/해제"),
+    ("clip.send_to_back", "Send to Back", "맨 뒤로 보내기"),
+    ("clip.bring_to_front", "Bring to Front", "맨 앞으로 가져오기"),
+    ("clip.rating", "Rating", "평점"),
+    ("clip.group", "Group", "그룹화"),
+    ("clip.ungroup", "Ungroup", "그룹 해제"),
+    ("clip.ungroup_all", "Ungroup All", "모든 그룹 해제"),
+    ("clip.loop", "Loop...", "루프..."),
+    ("clip.rename", "Rename...", "이름 변경..."),
+    ("clip.gain_nudge_up", "Nudge Clip Gain Up", "클립 게인 조금 올리기"),
+    ("clip.gain_nudge_down", "Nudge Clip Gain Down", "클립 게인 조금 내리기"),
+    ("clip.gain_render", "Render", "렌더링"),
+    ("clip.gain_bypass", "Bypass", "바이패스"),
+    ("clip.identify_sync_point", "Identify Sync Point", "동기화 지점 지정"),
+    ("clip.remove_sync_point", "Remove Sync Point", "동기화 지점 제거"),
+    ("clip.quantize_to_grid", "Quantize to Grid", "격자에 퀀타이즈"),
+    ("clip.color", "Clip Color", "클립 색상"),
+    ("clip.elastic_properties", "Elastic Properties", "탄력 오디오 속성"),
+    ("clip.remove_warp", "Remove Warp", "워프 제거"),
+    ("clip.place_source", "Place Audio File", "오디오 파일 배치"),
+    ("clip.capture", "Capture...", "캡처..."),
+    ("edit.copy_audio_as_midi", "Audio as MIDI", "오디오를 MIDI로"),
+    ("session.new", "New...", "새로 만들기..."),
+    ("session.open", "Open Session...", "세션 열기..."),
+    ("session.close", "Close Session", "세션 닫기"),
+    ("session.save", "Save Session", "세션 저장"),
+    ("session.save_as", "Save Session As...", "세션을 다른 이름으로 저장..."),
+    ("session.save_copy", "Save Session Copy In...", "세션 사본 저장 위치..."),
+    ("session.save_template", "Save As Template...", "템플릿으로 저장..."),
+    ("session.revert", "Revert Session to Saved...", "세션을 저장된 상태로 되돌리기..."),
+    ("file.import_audio", "Audio...", "오디오..."),
+    ("file.import_midi", "MIDI...", "MIDI..."),
+    ("file.import_video", "Video...", "비디오..."),
+    ("file.bounce_mix", "Bounce Mix...", "믹스 바운스..."),
+    ("file.bounce_stems", "Bounce Stems...", "스템 바운스..."),
+    ("file.export_session_text", "Session Info as Text...", "세션 정보를 텍스트로..."),
+    ("file.export_clips", "Export Clips as Files...", "클립을 파일로 내보내기..."),
+    ("file.export_selected_tracks_as_session", "Selected Tracks as New Session...", "선택한 트랙을 새 세션으로..."),
+    ("file.export_sibelius", "Sibelius...", "Sibelius..."),
+    ("file.send_to_sibelius", "Sibelius", "Sibelius"),
+    ("file.print_score", "Print Score...", "악보 인쇄..."),
+    ("file.score_setup", "Score Setup...", "악보 설정..."),
+    ("file.export_clip_groups", "Export Clip Groups...", "클립 그룹 내보내기..."),
+    ("file.import_clip_groups", "Clip Groups...", "클립 그룹..."),
+    ("file.get_info", "Get Info...", "정보 가져오기..."),
+    ("app.quit", "Quit", "종료"),
+    ("clip.regroup", "Regroup", "다시 그룹화"),
+    ("clip.unloop", "Unloop...", "루프 해제..."),
+    ("clip.effects_set", "Set Clip Effects", "클립 효과 설정"),
+    ("clip.conform_to_tempo", "Conform to Tempo", "템포에 맞추기"),
+    ("clip.remove_pitch_shift", "Remove Pitch Shift", "피치 이동 제거"),
+    ("event.tempo", "Tempo Change", "템포 변경"),
+    ("event.tempo_remove", "Remove Tempo Change", "템포 변경 제거"),
+    ("event.meter", "Change Meter...", "박자 변경..."),
+    ("event.tempo_scale", "Scale...", "비율 조절..."),
+    ("event.tempo_constant", "Constant...", "고정값..."),
+    ("event.insert_time", "Insert Time...", "시간 삽입..."),
+    ("event.cut_time", "Cut Time...", "시간 잘라내기..."),
+    ("event.quantize", "Quantize...", "퀀타이즈..."),
+    ("event.transpose", "Transpose...", "조옮김..."),
+    ("event.change_velocity", "Change Velocity...", "벨로시티 변경..."),
+    ("event.change_duration", "Change Duration...", "길이 변경..."),
+    ("event.remove_duplicate_notes", "Remove Duplicate Notes", "중복 음표 제거"),
+    ("event.add_key_change", "Add Key Change...", "조성 변경 추가..."),
+    ("event.renumber_bars", "Renumber Bars...", "마디 번호 다시 매기기..."),
+    ("event.all_notes_off", "All MIDI Notes Off", "모든 MIDI 음표 끄기"),
+    ("event.midi_note", "Add MIDI Note", "MIDI 음표 추가"),
+    ("markers.add", "New Memory Location", "새 메모리 위치"),
+    ("markers.delete", "Delete Memory Location", "메모리 위치 삭제"),
+    ("markers.edit", "Edit Memory Location", "메모리 위치 편집"),
+    ("markers.recall", "Recall Memory Location", "메모리 위치 불러오기"),
+    ("markers.next", "Go to Next Marker", "다음 마커로 이동"),
+    ("markers.previous", "Go to Previous Marker", "이전 마커로 이동"),
+    ("engine.clap_plugins", "List CLAP Plugins", "CLAP 플러그인 목록"),
+    ("audiosuite.process", "AudioSuite Process", "AudioSuite 처리"),
+    ("mix.insert_state", "Store Plugin State", "플러그인 상태 저장"),
+    ("options.destructive_record", "Destructive Record", "파괴적 녹음"),
+    ("options.trackpunch", "TrackPunch", "트랙 펀치"),
+    ("options.scrolling", "Edit Window Scrolling", "편집 창 스크롤"),
+    ("options.solo_mode", "Solo Mode", "솔로 모드"),
+    ("options.keyboard_focus", "Keyboard Focus", "키보드 포커스"),
+    ("options.pre_roll", "Pre-Roll Amount", "프리롤 길이"),
+    ("options.post_roll", "Post-Roll Amount", "포스트롤 길이"),
+    ("setup.session", "Session", "세션"),
+    ("setup.preferences", "Preferences...", "환경 설정..."),
+    ("setup.reset_edit_state", "Reset Edit Settings", "편집 설정 초기화"),
+    ("midi.notes", "List MIDI Notes", "MIDI 음표 목록"),
+    ("midi.new_clip", "New MIDI Clip", "새 MIDI 클립"),
+    ("midi.note_add", "Add Note", "음표 추가"),
+    ("midi.note_edit", "Edit Note", "음표 편집"),
+    ("midi.note_delete", "Delete Notes", "음표 삭제"),
+    ("midi.notes_move", "Move Notes", "음표 이동"),
+    ("mix.sampler_load", "Load Sampler Sample", "샘플러 샘플 불러오기"),
+    ("mix.sampler_clear", "Clear Sampler Sample", "샘플러 샘플 지우기"),
+    ("session.audio_health", "Session Audio Health", "세션 오디오 상태"),
+    ("mix.instrument", "Set Instrument", "악기 설정"),
+    ("mix.instrument_param", "Set Instrument Parameter", "악기 매개 변수 설정"),
+    ("mix.volume", "Set Volume", "볼륨 설정"),
+    ("mix.pan", "Set Pan", "팬 설정"),
+    ("mix.solo", "Solo", "솔로"),
+    ("mix.solo_safe", "Solo Safe", "솔로 세이프"),
+    ("mix.clear_solos", "Clear All Solos", "모든 솔로 해제"),
+    ("mix.clear_mutes", "Clear All Mutes", "모든 음소거 해제"),
+    ("mix.record_arm", "Record Enable", "녹음 대기"),
+    ("mix.input_monitor", "Input Monitoring", "입력 모니터링"),
+    ("mix.phase_invert", "Phase Invert", "위상 반전"),
+    ("mix.trim", "Input Trim", "입력 트림"),
+    ("mix.automation_mode", "Automation Mode", "오토메이션 모드"),
+    ("mix.insert", "Insert Plugin", "플러그인 삽입"),
+    ("mix.insert_remove", "Remove Insert", "인서트 제거"),
+    ("mix.insert_bypass", "Bypass Insert", "인서트 바이패스"),
+    ("mix.insert_param", "Set Plugin Parameter", "플러그인 매개 변수 설정"),
+    ("mix.insert_params", "Set Plugin Parameters", "플러그인 매개 변수 설정"),
+    ("mix.insert_move", "Move Insert", "인서트 이동"),
+    ("mix.send", "Assign Send", "센드 할당"),
+    ("mix.send_remove", "Remove Send", "센드 제거"),
+    ("mix.send_level", "Send Level", "센드 레벨"),
+    ("mix.new_bus", "New Bus", "새 버스"),
+    ("automation.set_point", "Add Automation Breakpoint", "오토메이션 제어점 추가"),
+    ("automation.write_range", "Write Automation", "오토메이션 기록"),
+    ("automation.clear", "Clear Automation", "오토메이션 지우기"),
+    ("automation.thin", "Thin", "간소화"),
+    ("automation.thin_all", "Thin All", "모두 간소화"),
+    ("automation.write_to_current", "Write to Current", "현재 항목에 기록"),
+    ("automation.write_to_all", "Write to All Enabled", "활성화된 모든 항목에 기록"),
+    ("automation.volume_to_clip_gain", "Convert Volume Automation to Clip Gain", "볼륨 오토메이션을 클립 게인으로 변환"),
+    ("automation.clip_gain_to_volume", "Convert Clip Gain to Volume Automation", "클립 게인을 볼륨 오토메이션으로 변환"),
+    ("automation.copy_to_send", "Copy to Send...", "센드로 복사..."),
+    ("session.inspect", "Inspect Session", "세션 검사"),
+    ("track.inspect", "Inspect Track", "트랙 검사"),
+    ("clip.inspect", "Inspect Clip", "클립 검사"),
+    ("engine.commands", "List Commands", "명령 목록"),
+    ("engine.history", "Undo History", "실행 취소 기록"),
+    ("engine.plugins", "List Plugins", "플러그인 목록"),
+    ("engine.parity", "Parity Report", "기능 동등성 보고서"),
+    ("session.format_time", "Format Time", "시간 서식"),
+    ("mix.surround_pan", "Set Surround Pan", "서라운드 팬 설정"),
+    ("mix.send_surround_pan", "Set Send Surround Pan", "센드 서라운드 팬 설정"),
+    ("setup.main_format", "Main Output Format", "기본 출력 형식"),
+    ("options.prepare_dpe_tracks", "Prepare DPE Tracks", "DPE 트랙 준비"),
+    ("setup.hardware", "Hardware...", "하드웨어..."),
+    ("setup.playback_engine", "Playback Engine...", "재생 엔진..."),
+    ("setup.disk_allocation", "Disk Allocation...", "디스크 할당..."),
+    ("setup.peripherals", "Peripherals...", "주변 장치..."),
+    ("setup.io", "I/O...", "I/O..."),
+    ("setup.current_timecode", "Current Timecode Position...", "현재 타임코드 위치..."),
+    ("setup.current_feet_frames", "Current Feet+Frames Position...", "현재 피트+프레임 위치..."),
+    ("setup.external_timecode_offset", "External Timecode Offset...", "외부 타임코드 오프셋..."),
+    ("setup.video_sync_offset", "Video Sync Offset...", "비디오 동기화 오프셋..."),
+    ("setup.click_countoff", "Click/Countoff...", "클릭/카운트인..."),
+    ("setup.keyboard_shortcuts", "Keyboard Shortcuts...", "키보드 단축키..."),
+    ("setup.midi_studio", "MIDI Studio...", "MIDI 스튜디오..."),
+    ("setup.midi_beat_clock", "MIDI Beat Clock...", "MIDI 비트 클록..."),
+    ("setup.midi_input_filter", "MIDI Input Filter...", "MIDI 입력 필터..."),
+    ("setup.midi_input_devices", "MIDI Input Devices...", "MIDI 입력 장치..."),
+    ("setup.transcription_settings", "Transcription Settings...", "전사 설정..."),
+    ("file.import_session_data", "Session Data...", "세션 데이터..."),
+    ("track.group", "Group...", "그룹..."),
+    ("track.ungroup", "Delete Group", "그룹 삭제"),
+    ("track.group_toggle", "Toggle Group Active", "그룹 활성 상태 전환"),
+    ("track.group_modify", "Modify Group", "그룹 수정"),
+    ("track.duplicate", "Duplicate...", "복제..."),
+    ("track.split_into_mono", "Split into Mono", "모노로 분할"),
+    ("track.make_inactive", "Make Inactive", "비활성화"),
+    ("track.move_to_new_folder", "Move to New Folder...", "새 폴더로 이동..."),
+    ("track.move_to_folder", "Move to Folder", "폴더로 이동"),
+    ("track.remove_from_folder", "Remove from Folder", "폴더에서 제거"),
+    ("track.change_width", "Change Track Width", "트랙 채널 수 변경"),
+    ("track.rename", "Rename Track", "트랙 이름 변경"),
+    ("track.comments", "Track Comments", "트랙 댓글"),
+    ("track.color", "Track Color", "트랙 색상"),
+    ("track.height", "Track Height", "트랙 높이"),
+    ("track.height_up", "Increase Track Height", "트랙 높이 늘리기"),
+    ("track.height_down", "Decrease Track Height", "트랙 높이 줄이기"),
+    ("track.view", "Track View", "트랙 보기"),
+    ("track.hide", "Hide Track", "트랙 숨기기"),
+    ("track.pin", "Pin Track", "트랙 고정"),
+    ("track.move", "Move Track", "트랙 이동"),
+    ("track.freeze", "Freeze", "프리즈"),
+    ("track.commit", "Commit...", "커밋..."),
+    ("track.bounce", "Bounce...", "바운스..."),
+    ("track.bypass_inserts", "All", "모두"),
+    ("track.set_record_to_input_only", "Set Record Tracks to Input Only", "녹음 트랙을 입력 전용으로 설정"),
+    ("track.scroll_to", "Scroll to Track...", "트랙으로 스크롤..."),
+    ("track.clear_clip_indicators", "Clear All Clip Indicators", "모든 클리핑 표시기 지우기"),
+    ("track.clear_trim_automation", "Clear Trim Automation", "트림 오토메이션 지우기"),
+    ("track.create_click", "Create Click Track", "클릭 트랙 만들기"),
+    ("track.playlist_duplicate", "Duplicate Playlist", "플레이리스트 복제"),
+    ("track.playlist_select", "Select Playlist", "플레이리스트 선택"),
+    ("track.playlist_delete_unused", "Delete Unused Playlists", "사용하지 않는 플레이리스트 삭제"),
+    ("track.folder_toggle", "Open/Close Folder", "폴더 열기/닫기"),
+    ("track.vca", "Assign to VCA", "VCA에 할당"),
+    ("track.playlist_promote", "Promote to Main Playlist", "기본 플레이리스트로 승격"),
+    ("track.input", "Track Input", "트랙 입력"),
+    ("track.output", "Track Output", "트랙 출력"),
+    ("track.timebase", "Track Timebase", "트랙 시간 기준"),
+    ("track.elastic", "Elastic Audio", "탄력 오디오"),
+    ("engine.vst3_plugins", "List VST3 Plugins", "VST3 플러그인 목록"),
+    ("view.zoom_in", "Zoom In", "확대"),
+    ("view.zoom_out", "Zoom Out", "축소"),
+    ("view.zoom_by", "Zoom By", "확대/축소 배율"),
+    ("view.zoom_set", "Set Zoom", "확대/축소 설정"),
+    ("view.zoom_preset", "Zoom Preset", "확대/축소 사전 설정"),
+    ("view.zoom_to_selection", "Zoom to Selection", "선택 영역에 맞춰 확대/축소"),
+    ("view.zoom_fit", "Fill Window With Session", "세션을 창에 맞추기"),
+    ("view.scroll", "Scroll Timeline", "타임라인 스크롤"),
+    ("view.waveform_zoom", "Waveform Zoom", "파형 확대/축소"),
+    ("view.ruler", "Rulers", "눈금자"),
+    ("view.main_counter", "Main Counter", "기본 카운터"),
+    ("view.sub_counter", "Sub Counter", "보조 카운터"),
+    ("view.grid", "Grid Value", "격자 값"),
+    ("view.grid_lines", "Show Grid Lines", "격자선 표시"),
+    ("view.nudge", "Nudge Value", "넛지 값"),
+    ("view.zoom_toggle", "Zoom Toggle", "확대/축소 전환"),
+    ("edit.tab", "Tab", "탭"),
+    ("video.online", "Video Track Online", "비디오 트랙 온라인"),
+    ("video.offset", "Set Video Start", "비디오 시작 설정"),
+    ("video.list", "Movies", "동영상"),
+    ("view.edit_minimal", "Minimal", "최소"),
+    ("view.ruler_timecode2", "Timecode 2", "타임코드 2"),
+    ("view.ruler_markers2", "Markers 2", "마커 2"),
+    ("view.ruler_markers3", "Markers 3", "마커 3"),
+    ("view.ruler_markers4", "Markers 4", "마커 4"),
+    ("view.ruler_markers5", "Markers 5", "마커 5"),
+    ("view.ruler_all_markers", "All Marker Rulers", "모든 마커 눈금자"),
+    ("view.expanded_sends_none", "None", "없음"),
+    ("view.flags", "View Flags", "표시 플래그"),
+    ("track.object", "Object / Bed", "개체 / 베드"),
+    ("track.convert_aux_to_folder", "Convert Aux to Routing Folder", "보조 트랙을 라우팅 폴더로 변환"),
+    ("track.extract_midi", "Extract MIDI to New Track", "MIDI를 새 트랙으로 추출"),
+    ("track.save_preset", "Save Track Preset...", "트랙 사전 설정 저장..."),
+    ("track.load_preset", "Load Track Preset", "트랙 사전 설정 불러오기"),
+    ("track.bypass_inserts_ae", "Inserts A-E", "인서트 A-E"),
+    ("track.bypass_inserts_fj", "Inserts F-J", "인서트 F-J"),
+    ("track.bypass_eq", "EQ", "EQ"),
+    ("track.bypass_dynamics", "Dynamics", "다이내믹스"),
+    ("track.bypass_reverb", "Reverb", "리버브"),
+    ("track.bypass_delay", "Delay", "지연"),
+    ("track.bypass_modulation", "Modulation", "모듈레이션"),
+    ("track.mute_sends_ae", "Sends A-E", "센드 A-E"),
+    ("track.mute_sends_fj", "Sends F-J", "센드 F-J"),
+    ("track.write_midi_rtp", "Write MIDI Real-Time Properties", "MIDI 실시간 속성 기록"),
+    ("track.trim_automation_write", "Write Trim Automation", "트림 오토메이션 기록"),
+    ("track.coalesce_trim", "Coalesce Trim Automation", "트림 오토메이션 통합"),
+    ("track.coalesce_vca", "Coalesce VCA Controller Automation", "VCA 컨트롤러 오토메이션 통합"),
+    ("track.designate_target_playlist", "Designate as Target Playlist", "대상 플레이리스트로 지정"),
+    ("track.show_target_playlist", "Show Target Playlist", "대상 플레이리스트 표시"),
+    ("track.toggle_recent_playlist", "Toggle Recent Playlist", "최근 플레이리스트 전환"),
+    ("transport.play", "Play", "재생"),
+    ("transport.stop", "Stop", "중지"),
+    ("transport.toggle", "Play/Stop", "재생/중지"),
+    ("transport.record", "Record", "녹음"),
+    ("transport.pause", "Pause", "일시 정지"),
+    ("transport.half_speed", "Half-Speed Playback", "절반 속도 재생"),
+    ("transport.play_selection", "Play Edit", "편집 영역 재생"),
+    ("transport.rtz", "Return to Zero", "처음으로 돌아가기"),
+    ("transport.go_to_end", "Go to End", "끝으로 이동"),
+    ("transport.rewind", "Rewind", "되감기"),
+    ("transport.fast_forward", "Fast Forward", "빨리 감기"),
+    ("transport.locate", "Locate", "위치 이동"),
+    ("transport.status", "Transport Status", "트랜스포트 상태"),
+];
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -13,6 +13,7 @@ pub mod edit_window;
 pub mod extra_windows;
 pub mod fonts;
 pub mod icons;
+pub mod i18n;
 pub mod menus;
 pub mod midi_editor;
 pub mod mix_window;
@@ -55,6 +56,8 @@ pub enum MainWindow {
 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
 #[serde(default)]
 pub struct UiState {
+    /// Display language, stored with per-user UI settings, outside the document.
+    pub language: i18n::Language,
     /// File › Open Recent, newest first (see `recent`).
     pub recent_sessions: Vec<String>,
     pub docking: docking::Workspace,
@@ -130,6 +133,7 @@ pub struct UiState {
 impl Default for UiState {
     fn default() -> Self {
         UiState {
+            language: i18n::Language::English,
             recent_sessions: Vec::new(),
             docking: docking::Workspace::default(),
             mix_docking: docking::mix_default(),
@@ -416,6 +420,15 @@ impl SoundApp {
 
     /// Run a command; errors go to the status line. Returns the result.
     pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
+        i18n::set_current(self.ui.language);
+        if id == "ui.language" {
+            let code = params.get("language").and_then(Value::as_str).ok_or("missing language")?;
+            let language = i18n::Language::from_code(code).ok_or("unsupported interface language")?;
+            self.ui.language = language;
+            i18n::set_current(language);
+            return Ok(json!({"language": language.code()}));
+        }
+
         if matches!(id, "session.new" | "session.open" | "session.close" | "session.revert") && bounce::refuses_session_switch(self) {
             return Err(self.ui.status.clone());
         }
@@ -1134,6 +1147,7 @@ impl SoundApp {
 
     /// Per-frame logic: control channel, transport, document sync.
     pub fn logic(&mut self, ctx: &egui::Context) {
+        i18n::set_current(self.ui.language);
         if ctx.current_pass_index() > 0 {
             return;
         }
@@ -1230,6 +1244,7 @@ impl SoundApp {
 
     /// Lay out the whole window.
     pub fn ui(&mut self, ui: &mut egui::Ui) {
+        i18n::set_current(self.ui.language);
         let ctx = ui.ctx().clone();
         // The integration creates this Ui before logic resolves the current frame's palette.
         ui.set_style(ctx.global_style());
@@ -1594,3 +1609,24 @@ mod tests {
         assert!((speed(&app) - 1.0).abs() < 1e-3, "a half-speed pass that ends on its own restores full speed");
     }
 }
+
+#[cfg(test)]
+mod korean_integration_tests {
+    use super::*;
+    #[test]
+    fn interface_language_uses_control_command_and_persists_outside_documents() {
+        let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
+        let before = serde_json::to_value(app.engine.session()).unwrap();
+        assert_eq!(app.ui.language, i18n::Language::English);
+        app.run("ui.language", json!({"language": "ko"})).unwrap();
+        assert_eq!(i18n::tr("File"), "파일");
+        let saved = serde_json::to_value(&app.ui).unwrap();
+        let restored: UiState = serde_json::from_value(saved).unwrap();
+        assert_eq!(restored.language, i18n::Language::Korean);
+        assert!(app.run("ui.language", json!({"language": "unsupported"})).is_err());
+        assert_eq!(app.ui.language, i18n::Language::Korean);
+        assert_eq!(before, serde_json::to_value(app.engine.session()).unwrap());
+        app.run("ui.language", json!({"language": "en"})).unwrap();
+        assert_eq!(i18n::tr("File"), "File");
+    }
+}
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -7,6 +7,7 @@ use soundcraft_engine::catalog;
 
 /// UI-layer commands: (id, label, catalog path or "", shortcut).
 pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
+    ("ui.language", "Language", "", None),
     ("ui.dock", "Arrange Panel", "", None),
     ("ui.dock.reset", "Reset Panel Layout", "Window > Reset Panel Layout", None),
     ("window.mix", "Mix", "Window > Mix", Some("Cmd+=")),
@@ -226,10 +227,14 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
         |ui| {
             egui::MenuBar::new().ui(ui, |ui| {
                 ui.menu_button("SoundCraft", |ui| {
-                    if ui.button("About SoundCraft").clicked() {
+                    if let Some(language) = crate::i18n::chooser(ui, app.ui.language) {
+                        let _ = app.run("ui.language", json!({"language": language.code()}));
+                    }
+                    ui.separator();
+                    if ui.button(crate::i18n::tr("About SoundCraft")).clicked() {
                         app.ui.show_about = true;
                     }
-                    if ui.button("Session Info").clicked() {
+                    if ui.button(crate::i18n::tr("Session Info")).clicked() {
                         app.ui.show_session_info = true;
                     }
                     if ui.button("Session Audio Health").clicked() {
@@ -242,7 +247,7 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
                 });
                 let extra = ui_aliases();
                 for root in tree() {
-                    ui.menu_button(&root.label, |ui| {
+                    ui.menu_button(crate::i18n::tr(&root.label), |ui| {
                         for c in &root.children {
                             menu_node(app, ui, c, &extra);
                             if c.path == "File > Open" {
@@ -254,14 +259,14 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
                         }
                         if root.label == "AudioSuite" {
                             ui.separator();
-                            ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
+                            ui.label(egui::RichText::new(crate::i18n::tr("Processes selected clips offline")).small().color(t.text_dim));
                         }
                         if root.label == "Window" {
                             ui.separator();
                             let mut separate = app.ui.mix_separate;
                             if ui
-                                .checkbox(&mut separate, "Mix in Separate Window")
-                                .on_hover_text("Show Edit and Mix at once, e.g. one per monitor")
+                                .checkbox(&mut separate, crate::i18n::tr("Mix in Separate Window"))
+                                .on_hover_text(crate::i18n::tr("Show Edit and Mix at once, e.g. one per monitor"))
                                 .changed()
                             {
                                 let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));
@@ -274,7 +279,7 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
                     "{}{}  —  {}",
                     app.engine.session().name,
                     if app.engine.is_dirty() { " *" } else { "" },
-                    if app.ui.window == MainWindow::Edit || app.ui.mix_separate { "Edit" } else { "Mix" }
+                    if app.ui.window == MainWindow::Edit || app.ui.mix_separate { crate::i18n::tr("Edit") } else { crate::i18n::tr("Mix") }
                 );
                 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                     ui.label(egui::RichText::new(title).color(t.text_dim));
@@ -288,10 +293,10 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
 fn instrument_menu(app: &mut SoundApp, ui: &mut egui::Ui) {
     match instrument_track(app.engine.session()) {
         Some(id) => {
-            ui.menu_button("Instrument", |ui| crate::mix_window::instrument_menu(app, ui, id));
+            ui.menu_button(crate::i18n::tr("Instrument"), |ui| crate::mix_window::instrument_menu(app, ui, id));
         }
         None => {
-            ui.add_enabled(false, egui::Button::new("Instrument")).on_disabled_hover_text("Select an instrument track");
+            ui.add_enabled(false, egui::Button::new("Instrument")).on_disabled_hover_text(crate::i18n::tr("Select an instrument track"));
         }
     }
 }
@@ -303,7 +308,7 @@ fn instrument_track(s: &soundcraft_model::Session) -> Option<soundcraft_model::T
 
 fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str, &str)]) {
     if !n.children.is_empty() {
-        ui.menu_button(&n.label, |ui| {
+        ui.menu_button(crate::i18n::tr(&n.label), |ui| {
             for c in &n.children {
                 menu_node(app, ui, c, extra);
             }
@@ -312,7 +317,7 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
     }
     let id = command_for_path(&n.path, extra);
     let (enabled, checked) = item_state(app, &n.path, id.as_deref());
-    let label = live_label(app, id.as_deref()).unwrap_or_else(|| n.label.clone());
+    let label = live_label(app, id.as_deref()).unwrap_or_else(|| crate::i18n::tr(&n.label).to_string());
     let label = if checked { format!("✔ {label}") } else { label };
     let shortcut = id.as_deref().and_then(shortcut_for).unwrap_or("");
     let btn = egui::Button::new(label).shortcut_text(crate::shortcuts::shortcut_label(shortcut, ui.ctx().os().is_mac()));
@@ -454,8 +459,8 @@ pub fn shortcut_for(id: &str) -> Option<&'static str> {
 pub fn live_label(app: &SoundApp, id: Option<&str>) -> Option<String> {
     let e = &app.engine;
     match id? {
-        "edit.undo" => Some(e.undo_label().map_or_else(|| "Can't Undo".into(), |l| format!("Undo {l}"))),
-        "edit.redo" => Some(e.redo_label().map_or_else(|| "Can't Redo".into(), |l| format!("Redo {l}"))),
+        "edit.undo" => Some(e.undo_label().map_or_else(|| if crate::i18n::is_korean() { crate::i18n::tr("Undo").into() } else { "Can't Undo".into() }, |l| crate::i18n::format_fields("Undo {action}", &[("action", l.to_string())]))),
+        "edit.redo" => Some(e.redo_label().map_or_else(|| if crate::i18n::is_korean() { crate::i18n::tr("Redo").into() } else { "Can't Redo".into() }, |l| crate::i18n::format_fields("Redo {action}", &[("action", l.to_string())]))),
         _ => None,
     }
 }
diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs
--- a/crates/ui-egui/src/midi_editor.rs
+++ b/crates/ui-egui/src/midi_editor.rs
@@ -64,7 +64,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
     ui.painter().rect_filled(full, 0.0, t.panel_bg);
     let header = Rect::from_min_size(full.min, vec2(full.width(), 22.0));
     ui.painter().rect_filled(header, 0.0, t.panel_bg2);
-    ui.painter().text(pos2(header.min.x + 8.0, header.center().y), Align2::LEFT_CENTER, "MIDI EDITOR", bold(11.5), t.header_text);
+    ui.painter().text(pos2(header.min.x + 8.0, header.center().y), Align2::LEFT_CENTER, crate::i18n::tr("MIDI EDITOR"), bold(11.5), t.header_text);
     let Some(cid) = target_clip(app) else {
         let track = {
             let s = app.engine.session();
@@ -92,12 +92,12 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
     ui.painter().text(
         pos2(header.min.x + 110.0, header.center().y),
         Align2::LEFT_CENTER,
-        format!(
+        if crate::i18n::is_korean() { let __i18n_0 = &(clip.name); let __i18n_1 = &(sequence.notes.len()); let __i18n_2 = &(app.midi.selected.len()); crate::i18n::format_fields("{} · {} notes · {} selected · Pencil: add · drag: move/select · arrows: move · Delete: remove", &[("", format!("{}", __i18n_0)), ("", format!("{}", __i18n_1)), ("", format!("{}", __i18n_2))]) } else { format!(
             "{} · {} notes · {} selected · Pencil: add · drag: move/select · arrows: move · Delete: remove",
             clip.name,
             sequence.notes.len(),
             app.midi.selected.len()
-        ),
+        ) },
         regular(11.0),
         t.text_dim,
     );
@@ -119,16 +119,16 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
     let mut tb = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center)));
     tb.spacing_mut().item_spacing.x = 4.0;
     let mut op: Option<(&str, serde_json::Value)> = None;
-    if tb.small_button("Legato").clicked() {
+    if tb.small_button(crate::i18n::tr("Legato")).clicked() {
         op = Some(("event.change_duration", json!({"legato": 0})));
     }
-    if tb.small_button("Vel −10").clicked() {
+    if tb.small_button(crate::i18n::tr("Vel −10")).clicked() {
         op = Some(("event.change_velocity", json!({"add": -10})));
     }
-    if tb.small_button("Vel +10").clicked() {
+    if tb.small_button(crate::i18n::tr("Vel +10")).clicked() {
         op = Some(("event.change_velocity", json!({"add": 10})));
     }
-    if tb.small_button("Quantize").on_hover_text("Quantize to the grid").clicked() {
+    if tb.small_button(crate::i18n::tr("Quantize")).on_hover_text(crate::i18n::tr("Quantize to the grid")).clicked() {
         op = Some(("event.quantize", json!({"grid": grid_ticks, "strength": 100})));
     }
     if let Some((id, mut p)) = op {
@@ -358,7 +358,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
     }
     // Velocity lane.
     ui.painter().rect_filled(vel, 0.0, Color32::from_rgb(28, 28, 30));
-    ui.painter().text(pos2(full.min.x + 6.0, vel.center().y), Align2::LEFT_CENTER, "Velocity", regular(10.0), t.text_dim);
+    ui.painter().text(pos2(full.min.x + 6.0, vel.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Velocity"), regular(10.0), t.text_dim);
     let vresp = ui.interact(vel, ui.id().with(("vel", cid.0)), Sense::click_and_drag());
     for (i, n) in sequence.notes.iter().enumerate() {
         let x = x_of(n.start);
diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs
--- a/crates/ui-egui/src/mix_window.rs
+++ b/crates/ui-egui/src/mix_window.rs
@@ -199,7 +199,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
             _ if track.kind == TrackKind::Instrument => instrument_slot(app, ui, track, sr),
             Some(k) => insert_slot(app, ui, track, k, sr),
             None if track.kind == TrackKind::Midi => {
-                ui.painter().text(sr.center(), Align2::CENTER_CENTER, "MIDI in: all", regular(9.0), t.text_dim);
+                ui.painter().text(sr.center(), Align2::CENTER_CENTER, crate::i18n::tr("MIDI in: all"), regular(9.0), t.text_dim);
             }
             None => {}
         }
@@ -210,8 +210,8 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
         if has_input {
             let on = app.engine.session().edit.flag(&format!("object.{}", id.0));
             let mut c = ui.new_child(egui::UiBuilder::new().max_rect(sec));
-            let label = egui::RichText::new(if on { "OBJECT" } else { "BED" }).font(bold(9.0));
-            if c.add_sized(sec.size(), egui::Button::new(label).selected(on)).on_hover_text("Route as an immersive object or to the bed").clicked() {
+            let label = egui::RichText::new(if on { crate::i18n::tr("OBJECT") } else { crate::i18n::tr("BED") }).font(bold(9.0));
+            if c.add_sized(sec.size(), egui::Button::new(label).selected(on)).on_hover_text(crate::i18n::tr("Route as an immersive object or to the bed")).clicked() {
                 let _ = app.run("track.object", json!({"tracks": [id.0], "object": !on}));
             }
         }
@@ -506,7 +506,7 @@ pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r:
     if resp.clicked() && hosted && !app.ui.plugin_windows.contains(&(id, slot)) {
         app.ui.plugin_windows.push((id, slot));
     }
-    let menu_resp = if hosted { resp.clone().on_hover_text("Click: open instrument · right-click: change") } else { resp.clone() };
+    let menu_resp = if hosted { resp.clone().on_hover_text(crate::i18n::tr("Click: open instrument · right-click: change")) } else { resp.clone() };
     let popup = if hosted { egui::Popup::context_menu(&menu_resp) } else { egui::Popup::menu(&menu_resp) };
     let popup = popup.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside);
     popup.show(|ui| instrument_menu(app, ui, id));
@@ -628,7 +628,7 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc
         if ui.button("Bypass").clicked() {
             run_pick(app, ui, "mix.insert_bypass", json!({"track": id.0, "slot": slot}));
         }
-        if ui.button("no insert").clicked() {
+        if ui.button(crate::i18n::tr("no insert")).clicked() {
             run_pick(app, ui, "mix.insert_remove", json!({"track": id.0, "slot": slot}));
         }
         ui.separator();
@@ -668,7 +668,7 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
     let current = app.engine.session().track(id).and_then(|t| t.instrument.clone());
     if let Some(info) = current.as_ref().and_then(|i| plugin_info(&i.plugin)) {
         ui.label(egui::RichText::new(info.name).strong());
-        if current.as_ref().is_some_and(|i| !soundcraft_mix::is_third_party(&i.plugin)) && ui.button("Edit Parameters…").clicked() {
+        if current.as_ref().is_some_and(|i| !soundcraft_mix::is_third_party(&i.plugin)) && ui.button(crate::i18n::tr("Edit Parameters…")).clicked() {
             if !app.ui.plugin_windows.contains(&(id, slot)) {
                 app.ui.plugin_windows.push((id, slot));
             }
@@ -678,11 +678,11 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
             && p.has_editor(id, slot)
         {
             if p.editor_open(id, slot) {
-                if ui.button("Close Plugin Editor").clicked() {
+                if ui.button(crate::i18n::tr("Close Plugin Editor")).clicked() {
                     p.close_editor(id, slot);
                     ui.close();
                 }
-            } else if ui.button("Open Plugin Editor").clicked() {
+            } else if ui.button(crate::i18n::tr("Open Plugin Editor")).clicked() {
                 if let Err(e) = p.open_editor(id, slot) {
                     app.ui.status = format!("{}: {e}", info.name);
                 }
@@ -692,7 +692,7 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
         ui.separator();
     }
     let mut picked = None;
-    ui.menu_button("Built-in", |ui| {
+    ui.menu_button(crate::i18n::tr("Built-in"), |ui| {
         for p in soundcraft_dsp::plugins().iter().filter(|p| p.is_instrument) {
             if ui.button(p.name).clicked() {
                 picked = Some(p.id.to_string());
@@ -755,15 +755,15 @@ fn plugin_search(ui: &mut Ui, search: &MenuSearch, instruments: bool) -> Option<
         }
     });
     if n == 0 {
-        ui.weak("No matches");
+        ui.weak(crate::i18n::tr("No matches"));
     }
     if clap.is_none() || vst3.is_none() {
         ui.separator();
         ui.horizontal(|ui| {
-            if clap.is_none() && ui.small_button("Scan CLAP").clicked() {
+            if clap.is_none() && ui.small_button(crate::i18n::tr("Scan CLAP")).clicked() {
                 soundcraft_clap_host::scan();
             }
-            if vst3.is_none() && ui.small_button("Scan VST3").clicked() {
+            if vst3.is_none() && ui.small_button(crate::i18n::tr("Scan VST3")).clicked() {
                 soundcraft_vst3_host::scan();
             }
         });
@@ -857,20 +857,20 @@ fn hosted_menus(ui: &mut Ui, instruments: bool) -> Option<String> {
     let mut picked = None;
     ui.menu_button("CLAP", |ui| {
         let list = soundcraft_clap_host::scan().into_iter().filter(|d| d.is_instrument == instruments);
-        if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), "No CLAP plugins found") {
+        if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), crate::i18n::tr("No CLAP plugins found")) {
             picked = Some(p);
         }
     });
     ui.menu_button("VST3", |ui| {
         let list = soundcraft_vst3_host::scan().into_iter().filter(|d| d.is_instrument == instruments);
-        if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), "No VST3 plugins found") {
+        if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), crate::i18n::tr("No VST3 plugins found")) {
             picked = Some(p);
         }
     });
     if cfg!(target_os = "macos") {
         ui.menu_button("Audio Units", |ui| {
             let list = soundcraft_au_host::scan().into_iter().filter(|d| d.is_instrument == instruments);
-            if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), "No Audio Units found")
+            if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), crate::i18n::tr("No Audio Units found"))
             {
                 picked = Some(p);
             }
@@ -893,7 +893,7 @@ fn vendor_menus(ui: &mut Ui, mut list: Vec<MenuPlugin>, none: &str) -> Option<St
     let mut picked = None;
     egui::ScrollArea::vertical().id_salt("vendors").max_height(max_h).show(ui, |ui| {
         for v in vendors {
-            ui.menu_button(if v.is_empty() { "Unknown vendor" } else { v }, |ui| {
+            ui.menu_button(if v.is_empty() { crate::i18n::tr("Unknown vendor") } else { v }, |ui| {
                 egui::ScrollArea::vertical().id_salt("plugins").max_height(max_h).show(ui, |ui| {
                     for p in list.iter().filter(|p| p.vendor == v) {
                         if ui.button(&p.name).clicked() {
@@ -926,7 +926,7 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us
     let level_r = if compact { r } else { level_icon(r).expand2(vec2(3.0, 0.0)).with_min_y(r.min.y).with_max_y(r.max.y) };
     let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y));
     let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click());
-    route_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {}", (b'A' + slot as u8) as char)));
+    route_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, if crate::i18n::is_korean() { let __i18n_0 = &((b'A' + slot as u8) as char); crate::i18n::format_fields("Send {}", &[("", format!("{}", __i18n_0))]) } else { format!("Send {}", (b'A' + slot as u8) as char) }));
     let mark = if compact { Mark::Arrow } else { Mark::Letter((b'a' + slot as u8) as char) };
     match &snd {
         Some(s) => {
@@ -956,10 +956,10 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us
                 let _ =
                     app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0));
             }
-            let _ = level_resp.clone().on_hover_text(format!(
+            let _ = level_resp.clone().on_hover_text(if crate::i18n::is_korean() { let __i18n_0 = &(db_text(s.level_db)); crate::i18n::format_fields("Send {which} to {name}: {} dB\nClick for the send fader · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options", &[("which", format!("{}", &which)), ("name", format!("{}", &name)), ("", format!("{}", __i18n_0))]) } else { format!(
                 "Send {which} to {name}: {} dB\nClick for the send fader · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options",
                 db_text(s.level_db)
-            ));
+            ) });
             level_resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s));
         }
         None => empty_slot(ui.painter(), r, ui.rect_contains_pointer(r), mark),
@@ -968,15 +968,15 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us
         egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
             ui.set_min_width(250.0);
             ui.horizontal(|ui| {
-                ui.strong(format!("Send {}", (b'A' + slot as u8) as char));
+                ui.strong(if crate::i18n::is_korean() { let __i18n_0 = &((b'A' + slot as u8) as char); crate::i18n::format_fields("Send {}", &[("", format!("{}", __i18n_0))]) } else { format!("Send {}", (b'A' + slot as u8) as char) });
                 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                     ui.label(egui::RichText::new(route_name(app, &send.target)).color(Tokens::current().counter_text));
                 });
             });
-            ui.label(egui::RichText::new("DESTINATION").size(8.0).color(Tokens::current().text_dim));
+            ui.label(egui::RichText::new(crate::i18n::tr("DESTINATION")).size(8.0).color(Tokens::current().text_dim));
             send_route_menu(app, ui, id, slot, Some(&send.target));
             ui.separator();
-            if ui.button("Remove send").clicked() {
+            if ui.button(crate::i18n::tr("Remove send")).clicked() {
                 let _ = app.run("mix.send_remove", json!({"track": id.0, "slot": slot}));
                 ui.close();
             }
@@ -984,7 +984,7 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us
     } else {
         egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
             ui.set_min_width(250.0);
-            ui.strong(format!("Send {} · Choose destination", (b'A' + slot as u8) as char));
+            ui.strong(if crate::i18n::is_korean() { let __i18n_0 = &((b'A' + slot as u8) as char); crate::i18n::format_fields("Send {} · Choose destination", &[("", format!("{}", __i18n_0))]) } else { format!("Send {} · Choose destination", (b'A' + slot as u8) as char) });
             send_route_menu(app, ui, id, slot, None);
         });
     }
@@ -1013,7 +1013,7 @@ fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) {
     };
     let t = Tokens::current();
     let mut open = true;
-    egui::Window::new(format!("{name} · Send {}", (b'A' + slot as u8) as char))
+    egui::Window::new(if crate::i18n::is_korean() { let __i18n_0 = &((b'A' + slot as u8) as char); crate::i18n::format_fields("{name} · Send {}", &[("name", format!("{}", &name)), ("", format!("{}", __i18n_0))]) } else { format!("{name} · Send {}", (b'A' + slot as u8) as char) })
         .id(egui::Id::new("send_window"))
         .open(&mut open)
         .auto_sized()
@@ -1069,9 +1069,9 @@ fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) {
 /// Secondary controls live behind the amount readout, separate from the bus selector like Logic.
 fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, send: &soundcraft_model::SendSlot) {
     ui.set_min_width(230.0);
-    ui.strong(format!("Send {} · {}", (b'A' + slot as u8) as char, route_name(app, &send.target)));
+    ui.strong(if crate::i18n::is_korean() { let __i18n_0 = &((b'A' + slot as u8) as char); let __i18n_1 = &(route_name(app, &send.target)); crate::i18n::format_fields("Send {} · {}", &[("", format!("{}", __i18n_0)), ("", format!("{}", __i18n_1))]) } else { format!("Send {} · {}", (b'A' + slot as u8) as char, route_name(app, &send.target)) });
     let mut db = send.level_db.max(-60.0);
-    if ui.add(egui::Slider::new(&mut db, -60.0..=12.0).text("Level").suffix(" dB")).changed() {
+    if ui.add(egui::Slider::new(&mut db, -60.0..=12.0).text(crate::i18n::tr("Level")).suffix(" dB")).changed() {
         let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0));
     }
     send_level_scale(ui, send.level_db);
@@ -1082,7 +1082,7 @@ fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize,
                 let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db}));
             }
         }
-        if ui.small_button("0 dB").clicked() {
+        if ui.small_button(crate::i18n::tr("0 dB")).clicked() {
             let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0}));
         }
         if ui.small_button("−∞").clicked() {
@@ -1090,7 +1090,7 @@ fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize,
         }
     });
     let mut pan = send.pan;
-    if ui.add(egui::Slider::new(&mut pan, -1.0..=1.0).text("Pan")).changed() {
+    if ui.add(egui::Slider::new(&mut pan, -1.0..=1.0).text(crate::i18n::tr("Pan"))).changed() {
         let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "pan": pan}), &format!("send-pan:{}:{slot}", id.0));
     }
     ui.horizontal(|ui| {
@@ -1106,7 +1106,7 @@ fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize,
     if let Route::Bus(bus) = send.target
         && let Some(aux) =
             app.engine.session().tracks.iter().find(|t| t.kind == TrackKind::Aux && t.mixer.input == Route::Bus(bus)).map(|t| (t.id, t.name.clone()))
-        && ui.button("Rename bus…").clicked()
+        && ui.button(crate::i18n::tr("Rename bus…")).clicked()
     {
         app.dialogs.open_rename_track(aux.0, &aux.1);
         ui.close();
@@ -1245,7 +1245,7 @@ fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, cu
         ui.close();
     }
     ui.separator();
-    if ui.button("new bus...").clicked() {
+    if ui.button(crate::i18n::tr("new bus...")).clicked() {
         let mut n = busses.len() + 1;
         while busses.iter().any(|bus| bus.name == format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() {
             n += 1;
@@ -1258,7 +1258,7 @@ fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, cu
 fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
     let key = if input { "input" } else { "output" };
     let cmd = if input { "track.input" } else { "track.output" };
-    if ui.button(if input { "no input" } else { "no output" }).clicked() {
+    if ui.button(if input { crate::i18n::tr("no input") } else { crate::i18n::tr("no output") }).clicked() {
         let _ = app.run(cmd, json!({"track": id.0, key: "none"}));
         ui.close();
     }
@@ -1269,15 +1269,15 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
     }
     if input {
         for i in 1..=8 {
-            if ui.button(format!("In {i}")).clicked() {
-                let _ = app.run(cmd, json!({"track": id.0, key: format!("In {i}")}));
+            if ui.button(if crate::i18n::is_korean() {  crate::i18n::format_fields("In {i}", &[("i", format!("{}", &i))]) } else { format!("In {i}") }).clicked() {
+                let _ = app.run(cmd, json!({"track": id.0, key: if crate::i18n::is_korean() {  crate::i18n::format_fields("In {i}", &[("i", format!("{}", &i))]) } else { format!("In {i}") }}));
                 ui.close();
             }
         }
     }
     ui.separator();
     let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
-    ui.strong("Bus");
+    ui.strong(crate::i18n::tr("Bus"));
     let search = MenuSearch::show(ui, egui::Id::new(("route-bus-search", id.0, input)), "Search buses…");
     let visible: Vec<&String> = busses.iter().filter(|b| search.matches(b)).collect();
     if let Some(i) = search.rows(ui, visible.len(), 420.0, |ui, i, highlighted| menu_row(ui, highlighted, visible[i])) {
diff --git a/crates/ui-egui/src/ops_windows.rs b/crates/ui-egui/src/ops_windows.rs
--- a/crates/ui-egui/src/ops_windows.rs
+++ b/crates/ui-egui/src/ops_windows.rs
@@ -88,16 +88,16 @@ fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) {
         return;
     }
     egui::Window::new("Beat Detective").open(&mut open).default_size(vec2(360.0, 220.0)).show(ctx, |ui| {
-        ui.label("Analyses the edit selection on the selected tracks.");
-        ui.add(egui::Slider::new(&mut app.ops.beat_sensitivity, 0.0..=1.0).text("Sensitivity"));
+        ui.label(crate::i18n::tr("Analyses the edit selection on the selected tracks."));
+        ui.add(egui::Slider::new(&mut app.ops.beat_sensitivity, 0.0..=1.0).text(crate::i18n::tr("Sensitivity")));
         crate::widgets::checkbox(ui, &mut app.ops.beat_separate, "Separate clips at detected beats");
         crate::widgets::checkbox(ui, &mut app.ops.beat_set_tempo, "Set the session tempo from the beats");
         ui.horizontal(|ui| {
-            if ui.button("Analyze").clicked() {
+            if ui.button(crate::i18n::tr("Analyze")).clicked() {
                 let r = app.run("event.beat_detective", json!({"sensitivity": app.ops.beat_sensitivity, "separate": false, "set_tempo": false}));
                 app.ops.beat_result = r.map_or_else(|e| e, |v| v.to_string());
             }
-            if ui.button(egui::RichText::new("Apply").strong()).clicked() {
+            if ui.button(egui::RichText::new(crate::i18n::tr("Apply")).strong()).clicked() {
                 let r = app.run(
                     "event.beat_detective",
                     json!({"sensitivity": app.ops.beat_sensitivity, "separate": app.ops.beat_separate, "set_tempo": app.ops.beat_set_tempo}),
@@ -107,10 +107,10 @@ fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) {
         });
         ui.separator();
         ui.horizontal(|ui| {
-            ui.label("Identify Beat: selection is");
+            ui.label(crate::i18n::tr("Identify Beat: selection is"));
             ui.add(egui::DragValue::new(&mut app.ops.identify_bars).range(1..=64));
-            ui.label("bar(s)");
-            if ui.button("Set Tempo").clicked() {
+            ui.label(crate::i18n::tr("bar(s)"));
+            if ui.button(crate::i18n::tr("Set Tempo")).clicked() {
                 let r = app.run("event.identify_beat", json!({"bars": app.ops.identify_bars}));
                 app.ops.beat_result = r.map_or_else(|e| e, |v| v.to_string());
             }
@@ -149,11 +149,11 @@ fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) {
                 ui.selectable_value(&mut app.ops.tempo_curve, c.to_string(), c);
             }
         });
-        ui.add(egui::DragValue::new(&mut app.ops.tempo_start).range(5.0..=999.0).prefix("start bpm "));
-        ui.add(egui::DragValue::new(&mut app.ops.tempo_end).range(5.0..=999.0).prefix("end bpm "));
+        ui.add(egui::DragValue::new(&mut app.ops.tempo_start).range(5.0..=999.0).prefix(crate::i18n::tr("start bpm ")));
+        ui.add(egui::DragValue::new(&mut app.ops.tempo_end).range(5.0..=999.0).prefix(crate::i18n::tr("end bpm ")));
         ui.label(
             egui::RichText::new(
-                "Applies across the edit selection (constant: at the insertion point; scale: factor = end / start; stretch: factor = start / end).",
+                crate::i18n::tr("Applies across the edit selection (constant: at the insertion point; scale: factor = end / start; stretch: factor = start / end)."),
             )
             .small(),
         );
@@ -190,10 +190,10 @@ fn time_ops(app: &mut SoundApp, ctx: &egui::Context) {
                 });
             }
             "insert" => {
-                ui.add(egui::DragValue::new(&mut app.ops.time_seconds).range(0.0..=3600.0).suffix(" s"));
+                ui.add(egui::DragValue::new(&mut app.ops.time_seconds).range(0.0..=3600.0).suffix(crate::i18n::tr(" s")));
             }
             _ => {
-                ui.label(egui::RichText::new("Uses the edit selection.").small());
+                ui.label(egui::RichText::new(crate::i18n::tr("Uses the edit selection.")).small());
             }
         }
         if ui.button(egui::RichText::new("Apply").strong()).clicked() {
@@ -228,18 +228,18 @@ fn midi_ops(app: &mut SoundApp, ctx: &egui::Context) {
                         ui.selectable_value(&mut app.ops.quant_grid, g.to_string(), g);
                     }
                 });
-                ui.add(egui::Slider::new(&mut app.ops.quant_strength, 0.0..=100.0).text("Strength %"));
-                ui.add(egui::Slider::new(&mut app.ops.quant_swing, 0.0..=100.0).text("Swing %"));
+                ui.add(egui::Slider::new(&mut app.ops.quant_strength, 0.0..=100.0).text(crate::i18n::tr("Strength %")));
+                ui.add(egui::Slider::new(&mut app.ops.quant_swing, 0.0..=100.0).text(crate::i18n::tr("Swing %")));
             }
             "transpose" => {
                 ui.add(egui::Slider::new(&mut app.ops.transpose, -48..=48).text("Semitones"));
             }
             "change velocity" => {
-                ui.add(egui::Slider::new(&mut app.ops.velocity_add, -127..=127).text("Add"));
+                ui.add(egui::Slider::new(&mut app.ops.velocity_add, -127..=127).text(crate::i18n::tr("Add")));
             }
             _ => {}
         }
-        ui.label(egui::RichText::new("Applies to the selected MIDI clips (or those under the selection).").small());
+        ui.label(egui::RichText::new(crate::i18n::tr("Applies to the selected MIDI clips (or those under the selection).")).small());
         if ui.button(egui::RichText::new("Apply").strong()).clicked() {
             let r = match app.ops.midi_op.as_str() {
                 "quantize" => {
@@ -262,18 +262,18 @@ fn rtp(app: &mut SoundApp, ctx: &egui::Context) {
         return;
     }
     egui::Window::new("MIDI Real-Time Properties").open(&mut open).default_size(vec2(340.0, 200.0)).show(ctx, |ui| {
-        ui.label("Non-destructive, applied at playback to the selected MIDI tracks.");
+        ui.label(crate::i18n::tr("Non-destructive, applied at playback to the selected MIDI tracks."));
         let mut changed = false;
-        changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_velocity, -127..=127).text("Velocity +")).changed();
-        changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_transpose, -48..=48).text("Transpose")).changed();
-        changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_duration, 1.0..=400.0).text("Duration %")).changed();
-        changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_delay, -960..=960).text("Delay (ticks)")).changed();
+        changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_velocity, -127..=127).text(crate::i18n::tr("Velocity +"))).changed();
+        changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_transpose, -48..=48).text(crate::i18n::tr("Transpose"))).changed();
+        changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_duration, 1.0..=400.0).text(crate::i18n::tr("Duration %"))).changed();
+        changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_delay, -960..=960).text(crate::i18n::tr("Delay (ticks)"))).changed();
         if changed {
             let p = json!({"velocity": app.ops.rtp_velocity, "transpose": app.ops.rtp_transpose, "duration": app.ops.rtp_duration, "delay": app.ops.rtp_delay});
             let _ = app.engine.execute_merged("event.midi_rtp", &p, "rtp");
         }
         ui.horizontal(|ui| {
-            if ui.button("Write to Notes").clicked() {
+            if ui.button(crate::i18n::tr("Write to Notes")).clicked() {
                 let _ = app.run("track.write_midi_rtp", json!({}));
             }
             if ui.button("Clear").clicked() {
diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs
--- a/crates/ui-egui/src/palette.rs
+++ b/crates/ui-egui/src/palette.rs
@@ -21,11 +21,11 @@ fn entries() -> Vec<Entry> {
     let mut v: Vec<Entry> = soundcraft_engine::command_specs()
         .iter()
         .map(|c| Entry {
-            id: c.id.to_string(), label: c.label.to_string(), path: c.menu.join(" › "), shortcut: c.shortcut.unwrap_or("").to_string()
+            id: c.id.to_string(), label: crate::i18n::command(c.id, c.label).to_string(), path: crate::i18n::path(&c.menu.join(" › ")), shortcut: c.shortcut.unwrap_or("").to_string()
         })
         .collect();
     for (id, label, path, sc) in crate::menus::UI_COMMANDS {
-        v.push(Entry { id: id.to_string(), label: label.to_string(), path: path.replace(" > ", " › "), shortcut: sc.unwrap_or("").to_string() });
+        v.push(Entry { id: id.to_string(), label: crate::i18n::command(id, label).to_string(), path: crate::i18n::path(&path.replace(" > ", " › ")), shortcut: sc.unwrap_or("").to_string() });
     }
     v
 }
@@ -65,7 +65,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
     let t = Tokens::current();
     let mut run: Option<String> = None;
     let mut close = ctx.input(|i| i.key_pressed(Key::Escape));
-    egui::Window::new("Search")
+    egui::Window::new(crate::i18n::tr("Search"))
         .collapsible(false)
         .resizable(false)
         .title_bar(false)
@@ -73,7 +73,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
         .fixed_size(vec2(560.0, 380.0))
         .show(ctx, |ui| {
             let r = ui
-                .add(egui::TextEdit::singleline(&mut app.palette.query).hint_text("Search commands…").desired_width(f32::INFINITY).font(mono(15.0)));
+                .add(egui::TextEdit::singleline(&mut app.palette.query).hint_text(crate::i18n::tr("Search commands…")).desired_width(f32::INFINITY).font(mono(15.0)));
             r.request_focus();
             let q = app.palette.query.trim().to_string();
             let mut hits: Vec<(i32, Entry)> =
diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs
--- a/crates/ui-egui/src/panels.rs
+++ b/crates/ui-egui/src/panels.rs
@@ -93,14 +93,14 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
         app.engine.session().groups.iter().map(|g| (g.id.0, g.letter, g.name.clone(), g.active, g.color)).collect();
     let out = egui::ScrollArea::vertical().id_salt("groups_scroll").auto_shrink([false, false]).scroll_bar_visibility(hidden).show(ui, |ui| {
         let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::hover());
-        ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "<ALL>", regular(11.5).clone(), t.text);
+        ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr("<ALL>"), regular(11.5).clone(), t.text);
         let all = ui.interact(r, ui.id().with("group_all"), Sense::click());
         all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "<ALL>"));
         if all.double_clicked() {
             let _ = app.run("edit.select_all", json!({}));
         }
         all.context_menu(|ui| {
-            if ui.button("New Group…").clicked() {
+            if ui.button(crate::i18n::tr("New Group…")).clicked() {
                 let _ = app.dialogs.open_for_command(&app.engine, "track.group");
                 ui.close();
             }
@@ -128,15 +128,15 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
                 let _ = app.run("edit.select", json!({"tracks": members, "exact": true}));
             }
             resp.context_menu(|ui| {
-                if ui.button("Select Tracks in Group").clicked() {
+                if ui.button(crate::i18n::tr("Select Tracks in Group")).clicked() {
                     let _ = app.run("edit.select", json!({"tracks": members, "exact": true}));
                     ui.close();
                 }
-                if ui.button(if active { "Disable" } else { "Enable" }).clicked() {
+                if ui.button(if active { crate::i18n::tr("Disable") } else { crate::i18n::tr("Enable") }).clicked() {
                     let _ = app.run("track.group_toggle", json!({"group": id}));
                     ui.close();
                 }
-                if ui.button("Modify Group…").clicked() {
+                if ui.button(crate::i18n::tr("Modify Group…")).clicked() {
                     let _ = app.dialogs.open_modify_group(&app.engine, soundcraft_model::GroupId(id));
                     ui.close();
                 }
@@ -203,7 +203,7 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
     };
     let fkey = egui::Id::new("clip_filter");
     let mut filter: String = ui.ctx().memory(|m| m.data.get_temp(fkey)).unwrap_or_default();
-    ui.add(egui::TextEdit::singleline(&mut filter).hint_text("🔍 Find clips").desired_width(f32::INFINITY));
+    ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::i18n::tr("🔍 Find clips")).desired_width(f32::INFINITY));
     ui.ctx().memory_mut(|m| m.data.insert_temp(fkey, filter.clone()));
     let needle = filter.to_lowercase();
     let items: Vec<_> = items.into_iter().filter(|(_, n, _, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect();
@@ -212,20 +212,20 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
             let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), if whole { Sense::click_and_drag() } else { Sense::click() });
             resp.context_menu(|ui| {
                 if whole {
-                    if ui.button("Place on New Track").clicked() {
+                    if ui.button(crate::i18n::tr("Place on New Track")).clicked() {
                         let _ = app.run("clip.place_source", json!({"source": id}));
                         ui.close();
                     }
                     let sel_track = app.engine.session().edit.selected_tracks.first().map(|t| t.0);
                     if let Some(t) = sel_track
-                        && ui.button("Place on Selected Track").clicked()
+                        && ui.button(crate::i18n::tr("Place on Selected Track")).clicked()
                     {
                         let _ = app.run("clip.place_source", json!({"source": id, "track": t}));
                         ui.close();
                     }
                     ui.separator();
                 }
-                if ui.button("Clear…").clicked() {
+                if ui.button(crate::i18n::tr("Clear…")).clicked() {
                     let (clips, sources) = if whole { (vec![], vec![SourceId(id)]) } else { (vec![ClipId(id)], vec![]) };
                     app.dialogs.open_clear(&app.engine, clips, sources);
                     ui.close();
@@ -272,7 +272,7 @@ fn transport_window(app: &mut SoundApp, ctx: &egui::Context) {
         return;
     }
     let t = Tokens::current();
-    egui::Window::new("Transport").open(&mut open).resizable(false).default_pos(pos2(400.0, 500.0)).show(ctx, |ui| {
+    egui::Window::new(crate::i18n::tr("Transport")).open(&mut open).resizable(false).default_pos(pos2(400.0, 500.0)).show(ctx, |ui| {
         let flags = app.engine.session().edit.flags.clone();
         let expanded = flags.contains("view.transport.expanded");
         ui.horizontal(|ui| {
@@ -316,15 +316,15 @@ fn transport_window(app: &mut SoundApp, ctx: &egui::Context) {
         if expanded {
             let e = s.edit.clone();
             egui::Grid::new("tx_exp").num_columns(4).show(ui, |ui| {
-                ui.label("Start");
+                ui.label(crate::i18n::tr("Start"));
                 ui.label(egui::RichText::new(fmt(e.selection.start)).font(mono(11.0)));
-                ui.label("Pre-roll");
-                ui.label(egui::RichText::new(format!("{:.2} s", s.sample_rate.seconds(e.pre_roll))).font(mono(11.0)));
+                ui.label(crate::i18n::tr("Pre-roll"));
+                ui.label(egui::RichText::new(if crate::i18n::is_korean() { let __i18n_0 = &(s.sample_rate.seconds(e.pre_roll)); crate::i18n::format_fields("{:.2} s", &[(":.2", format!("{:.2}", __i18n_0))]) } else { format!("{:.2} s", s.sample_rate.seconds(e.pre_roll)) }).font(mono(11.0)));
                 ui.end_row();
-                ui.label("End");
+                ui.label(crate::i18n::tr("End"));
                 ui.label(egui::RichText::new(fmt(e.selection.end)).font(mono(11.0)));
-                ui.label("Post-roll");
-                ui.label(egui::RichText::new(format!("{:.2} s", s.sample_rate.seconds(e.post_roll))).font(mono(11.0)));
+                ui.label(crate::i18n::tr("Post-roll"));
+                ui.label(egui::RichText::new(if crate::i18n::is_korean() { let __i18n_0 = &(s.sample_rate.seconds(e.post_roll)); crate::i18n::format_fields("{:.2} s", &[(":.2", format!("{:.2}", __i18n_0))]) } else { format!("{:.2} s", s.sample_rate.seconds(e.post_roll)) }).font(mono(11.0)));
                 ui.end_row();
             });
         }
@@ -360,7 +360,7 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) {
     if !open {
         return;
     }
-    egui::Window::new("Big Counter").open(&mut open).default_size(vec2(520.0, 110.0)).show(ctx, |ui| {
+    egui::Window::new(crate::i18n::tr("Big Counter")).open(&mut open).default_size(vec2(520.0, 110.0)).show(ctx, |ui| {
         let s = app.engine.session();
         let txt = s.format_position(app.position(), s.edit.main_counter);
         let r = ui.available_rect_before_wrap();
@@ -373,10 +373,10 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) {
 
 pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) {
     ui.horizontal(|ui| {
-        if ui.button("+ Marker").clicked() {
+        if ui.button(crate::i18n::tr("+ Marker")).clicked() {
             let _ = app.run("markers.add", json!({}));
         }
-        if ui.button("+ Selection").clicked() {
+        if ui.button(crate::i18n::tr("+ Selection")).clicked() {
             let _ = app.run("markers.add", json!({"kind": "selection"}));
         }
     });
@@ -403,7 +403,7 @@ pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) {
             }
             ui.end_row();
             for (n, name, loc, len, kind) in rows {
-                if ui.button(n.to_string()).on_hover_text("Recall").clicked() {
+                if ui.button(n.to_string()).on_hover_text(crate::i18n::tr("Recall")).clicked() {
                     let _ = app.run("markers.recall", json!({"number": n}));
                 }
                 let key = egui::Id::new(("mem_name", n));
@@ -440,7 +440,7 @@ pub(crate) fn undo_history(app: &mut SoundApp, ui: &mut Ui) {
             }
         }
         if let Some(r) = app.engine.redo_label() {
-            ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::current().text_dim));
+            ui.label(egui::RichText::new(if crate::i18n::is_korean() {  crate::i18n::format_fields("(redo) {r}", &[("r", format!("{}", &r))]) } else { format!("(redo) {r}") }).italics().color(Tokens::current().text_dim));
         }
     });
 }
@@ -474,7 +474,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
                         if ui.button(label).clicked() {
                             let _ = app.run("mix.insert_bypass", json!({"track": tid.0, "slot": slot}));
                         }
-                        ui.menu_button("Presets ▾", |ui| preset_menu(app, ui, tid, slot, info, &ins));
+                        ui.menu_button(crate::i18n::tr("Presets ▾"), |ui| preset_menu(app, ui, tid, slot, info, &ins));
                     }
                     if soundcraft_mix::is_third_party(&ins.plugin) {
                         let open = app.player.as_ref().is_some_and(|p| p.editor_open(tid, slot));
@@ -482,7 +482,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
                         let label = if open { "Close Plugin Editor" } else { "Open Plugin Editor" };
                         let resp = ui.add_enabled(available, egui::Button::new(label));
                         let resp =
-                            if available { resp } else { resp.on_disabled_hover_text("This plugin has no editor of its own, or it is not loaded") };
+                            if available { resp } else { resp.on_disabled_hover_text(crate::i18n::tr("This plugin has no editor of its own, or it is not loaded")) };
                         if resp.clicked()
                             && let Some(p) = &app.player
                         {
@@ -496,7 +496,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
                 });
                 if instrument && soundcraft_mix::is_third_party(&ins.plugin) {
                     // A hosted instrument keeps its sounds and settings in its own editor.
-                    ui.label(egui::RichText::new("Load and edit sounds in the plugin's own editor.").color(Tokens::current().text_dim));
+                    ui.label(egui::RichText::new(crate::i18n::tr("Load and edit sounds in the plugin's own editor.")).color(Tokens::current().text_dim));
                     return;
                 }
                 if instrument && ins.plugin == crate::sampler_window::SAMPLER {
@@ -621,9 +621,9 @@ fn about(app: &mut SoundApp, ctx: &egui::Context) {
             2 => crate::credits::models_ui(ui),
             _ => {
                 ui.label(egui::RichText::new("SoundCraft").font(bold(22.0)));
-                ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
-                ui.label("An open-source digital audio workstation from the ArtCraft team.");
-                ui.label("Dual-licensed MIT OR Apache-2.0. Made with Rust and egui.");
+                ui.label(if crate::i18n::is_korean() { let __i18n_0 = &(env!("CARGO_PKG_VERSION")); crate::i18n::format_fields("Version {}", &[("", format!("{}", __i18n_0))]) } else { format!("Version {}", env!("CARGO_PKG_VERSION")) });
+                ui.label(crate::i18n::tr("An open-source digital audio workstation from the ArtCraft team."));
+                ui.label(crate::i18n::tr("Dual-licensed MIT OR Apache-2.0. Made with Rust and egui."));
                 ui.hyperlink_to("getartcraft.com/apps/soundcraft", "https://getartcraft.com/apps/soundcraft");
                 ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft");
             }
@@ -659,13 +659,13 @@ fn preset_menu(
     info: &soundcraft_dsp::PluginInfo,
     ins: &soundcraft_model::Insert,
 ) {
-    if ui.button("<factory default>").clicked() {
+    if ui.button(crate::i18n::tr("<factory default>")).clicked() {
         let defaults: serde_json::Map<String, serde_json::Value> = info.params.iter().map(|p| (p.id.to_string(), json!(p.default))).collect();
         let _ = app.run("mix.insert_params", json!({"track": tid.0, "slot": slot, "params": defaults, "preset": "<factory default>"}));
         ui.close();
     }
     let Some(dir) = preset_dir(app, info.id) else {
-        ui.label("User presets need the desktop app.");
+        ui.label(crate::i18n::tr("User presets need the desktop app."));
         return;
     };
     let mut names: Vec<String> = std::fs::read_dir(&dir)
@@ -696,7 +696,7 @@ fn preset_menu(
     let mut name: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| format!("{} preset", info.name));
     ui.horizontal(|ui| {
         ui.add(egui::TextEdit::singleline(&mut name).desired_width(140.0));
-        if ui.button("Save").clicked() {
+        if ui.button(crate::i18n::tr("Save")).clicked() {
             let clean = soundcraft_engine::io::sanitize_name(&name);
             let body = json!({"format": "soundcraft-plugin-preset", "plugin": info.id, "params": ins.params});
             let ok = std::fs::create_dir_all(&dir).is_ok() && std::fs::write(dir.join(format!("{clean}.json")), body.to_string()).is_ok();
diff --git a/crates/ui-egui/src/recent.rs b/crates/ui-egui/src/recent.rs
--- a/crates/ui-egui/src/recent.rs
+++ b/crates/ui-egui/src/recent.rs
@@ -84,9 +84,9 @@ impl SoundApp {
 
 /// The File › Open Recent submenu.
 pub fn menu(app: &mut SoundApp, ui: &mut egui::Ui) {
-    ui.menu_button("Open Recent", |ui| {
+    ui.menu_button(crate::i18n::tr("Open Recent"), |ui| {
         if app.ui.recent_sessions.is_empty() {
-            ui.add_enabled(false, egui::Button::new("No Recent Sessions"));
+            ui.add_enabled(false, egui::Button::new(crate::i18n::tr("No Recent Sessions")));
             return;
         }
         let now = ui.ctx().input(|i| i.time);
@@ -94,14 +94,14 @@ pub fn menu(app: &mut SoundApp, ui: &mut egui::Ui) {
         let mut open = None;
         for (i, path) in app.ui.recent_sessions.iter().enumerate() {
             let here = exists.get(i).copied().unwrap_or(true);
-            let text = if here { label(path) } else { format!("{}  (missing)", label(path)) };
-            let r = ui.add_enabled(here, egui::Button::new(text)).on_hover_text(path.as_str()).on_disabled_hover_text(format!("{path}\nnot found"));
+            let text = if here { label(path) } else { if crate::i18n::is_korean() { let __i18n_0 = &(label(path)); crate::i18n::format_fields("{}  (missing)", &[("", format!("{}", __i18n_0))]) } else { format!("{}  (missing)", label(path)) } };
+            let r = ui.add_enabled(here, egui::Button::new(text)).on_hover_text(path.as_str()).on_disabled_hover_text(if crate::i18n::is_korean() {  crate::i18n::format_fields("{path}\nnot found", &[("path", format!("{}", &path))]) } else { format!("{path}\nnot found") });
             if r.clicked() {
                 open = Some(path.clone());
             }
         }
         ui.separator();
-        if ui.button("Clear Recent").clicked() {
+        if ui.button(crate::i18n::tr("Clear Recent")).clicked() {
             app.ui.recent_sessions.clear();
             app.recent_changed = true;
             ui.close();
@@ -111,7 +111,7 @@ pub fn menu(app: &mut SoundApp, ui: &mut egui::Ui) {
             // Through the dialog layer, so unsaved changes are asked about first.
             if let Err(e) = crate::dialogs::run_or_ask(app, "session.open", json!({"path": path})) {
                 log::warn!("{path}: {e}");
-                app.ui.status = format!("Could not open {path}: {e}");
+                app.ui.status = if crate::i18n::is_korean() {  crate::i18n::format_fields("Could not open {path}: {e}", &[("path", format!("{}", &path)), ("e", format!("{}", &e))]) } else { format!("Could not open {path}: {e}") };
             }
         }
     });
diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs
--- a/crates/ui-egui/src/recovery.rs
+++ b/crates/ui-egui/src/recovery.rs
@@ -9,22 +9,21 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
     let Some(left) = app.leftovers.first().cloned() else { return };
     let mut open = true;
     let mut recover = None;
-    egui::Window::new(egui::RichText::new("Recover Unsaved Work").font(bold(13.0)))
+    egui::Window::new(egui::RichText::new(crate::i18n::tr("Recover Unsaved Work")).font(bold(13.0)))
         .open(&mut open)
         .collapsible(false)
         .resizable(false)
         .anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0))
         .show(ctx, |ui| {
-            let age = left.saved.elapsed().map_or_else(|_| "recently".into(), |d| ago(d.as_secs()));
-            let what = if left.recording { "unsaved changes and an unfinished recording" } else { "unsaved changes" };
-            ui.label(format!("“{}” has {what} from an earlier run, autosaved {age}.", left.name));
+            let age = left.saved.elapsed().map_or_else(|_| crate::i18n::tr("recently").into(), |d| ago(d.as_secs()));
+            ui.label(recovery_message(&left.name, left.recording, &age));
             ui.separator();
             ui.horizontal(|ui| {
-                if ui.button("Discard").clicked() {
+                if ui.button(crate::i18n::tr("Discard")).clicked() {
                     recover = Some(false);
                 }
-                let recover_button = egui::Button::new(egui::RichText::new("Recover").strong()).fill(Tokens::current().accent);
-                if ui.add_enabled(!app.engine.transport.recording, recover_button).on_disabled_hover_text("Stop recording first").clicked() {
+                let recover_button = egui::Button::new(egui::RichText::new(crate::i18n::tr("Recover")).strong()).fill(Tokens::current().accent);
+                if ui.add_enabled(!app.engine.transport.recording, recover_button).on_disabled_hover_text(crate::i18n::tr("Stop recording first")).clicked() {
                     recover = Some(true);
                 }
             });
@@ -41,10 +40,53 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
 
 fn ago(secs: u64) -> String {
     let (n, unit) = match secs {
-        0..60 => return "less than a minute ago".into(),
+        0..60 => return crate::i18n::tr("less than a minute ago").into(),
         60..3_600 => (secs / 60, "minute"),
         3_600..86_400 => (secs / 3_600, "hour"),
         _ => (secs / 86_400, "day"),
     };
-    format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" })
+    let suffix = if n == 1 { "" } else { "s" };
+    if crate::i18n::is_korean() {
+        crate::i18n::format_fields("{n} {unit}{} ago", &[
+            ("n", n.to_string()),
+            ("unit", crate::i18n::tr(unit).to_owned()),
+            ("", crate::i18n::english_ending(suffix).to_owned()),
+        ])
+    } else {
+        format!("{n} {unit}{} ago", suffix)
+    }
+}
+
+fn recovery_message(name: &str, recording: bool, age: &str) -> String {
+    let what = crate::i18n::tr(if recording { "unsaved changes and an unfinished recording" } else { "unsaved changes" });
+    if crate::i18n::is_korean() {
+        crate::i18n::format_fields("“{}” has {what} from an earlier run, autosaved {age}.", &[
+            ("", name.to_owned()), ("what", what.to_owned()), ("age", age.to_owned()),
+        ])
+    } else {
+        format!("“{name}” has {what} from an earlier run, autosaved {age}.")
+    }
+}
+
+#[cfg(test)]
+mod korean_recovery_tests {
+    use super::{ago, recovery_message};
+    use crate::i18n::{Language, set_current, tr};
+
+    #[test]
+    fn recovery_fragments_form_complete_sentences_in_both_languages() {
+        set_current(Language::Korean);
+        for (secs, expected) in [(0, "1분 미만 전"), (59, "1분 미만 전"), (60, "1분 전"), (120, "2분 전"), (3600, "1시간 전"), (7200, "2시간 전"), (86400, "1일 전"), (172800, "2일 전")] {
+            assert_eq!(ago(secs), expected);
+        }
+        assert_eq!(recovery_message("Session {name}", false, &ago(120)), "“Session {name}”에 이전 실행의 저장되지 않은 변경 내용이(가) 남아 있습니다. 2분 전 자동 저장되었습니다.");
+        assert_eq!(recovery_message("Session {name}", true, tr("recently")), "“Session {name}”에 이전 실행의 저장되지 않은 변경 내용과 완료되지 않은 녹음이(가) 남아 있습니다. 최근 자동 저장되었습니다.");
+        set_current(Language::English);
+        assert_eq!(ago(59), "less than a minute ago");
+        assert_eq!(ago(60), "1 minute ago");
+        assert_eq!(ago(120), "2 minutes ago");
+        assert_eq!(ago(3600), "1 hour ago");
+        assert_eq!(ago(86400), "1 day ago");
+        assert_eq!(recovery_message("Session {name}", true, "recently"), "“Session {name}” has unsaved changes and an unfinished recording from an earlier run, autosaved recently.");
+    }
 }
diff --git a/crates/ui-egui/src/sampler_window.rs b/crates/ui-egui/src/sampler_window.rs
--- a/crates/ui-egui/src/sampler_window.rs
+++ b/crates/ui-egui/src/sampler_window.rs
@@ -161,8 +161,8 @@ pub fn sample_strip(app: &mut SoundApp, ui: &mut Ui, tid: TrackId, ins: &Insert)
     let audio = ins.sample.and_then(|id| s.pool.get(id)).cloned();
     ui.horizontal(|ui| {
         let can_pick = app.services.pick_open.is_some();
-        let load = ui.add_enabled(can_pick, egui::Button::new("Load Sample…"));
-        let load = if can_pick { load } else { load.on_disabled_hover_text("Drop an audio file on this window") };
+        let load = ui.add_enabled(can_pick, egui::Button::new(crate::i18n::tr("Load Sample…")));
+        let load = if can_pick { load } else { load.on_disabled_hover_text(crate::i18n::tr("Drop an audio file on this window")) };
         if load.clicked()
             && let Some(pick) = &app.services.pick_open
             && let Some(path) = pick("mix.sampler_load", AUDIO_EXTS)
@@ -193,7 +193,7 @@ pub fn sample_strip(app: &mut SoundApp, ui: &mut Ui, tid: TrackId, ins: &Insert)
     p.rect_filled(r, 4.0, t.playlist_bg);
     p.rect_stroke(r, 4.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
     let Some(audio) = audio else {
-        p.text(r.center(), Align2::CENTER_CENTER, "Drop an audio file here", regular(12.0), t.text_dim);
+        p.text(r.center(), Align2::CENTER_CENTER, crate::i18n::tr("Drop an audio file here"), regular(12.0), t.text_dim);
         return;
     };
     let frames = audio.frames().max(1);
diff --git a/crates/ui-egui/src/score_editor.rs b/crates/ui-egui/src/score_editor.rs
--- a/crates/ui-egui/src/score_editor.rs
+++ b/crates/ui-egui/src/score_editor.rs
@@ -38,9 +38,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
     if !open {
         return;
     }
-    egui::Window::new("Score Editor").open(&mut open).default_size(vec2(900.0, 340.0)).show(ctx, |ui| {
+    egui::Window::new(crate::i18n::tr("Score Editor")).open(&mut open).default_size(vec2(900.0, 340.0)).show(ctx, |ui| {
         let Some(cid) = crate::midi_editor::target_clip(app) else {
-            ui.label("Select a MIDI clip or MIDI track.");
+            ui.label(crate::i18n::tr("Select a MIDI clip or MIDI track."));
             return;
         };
         let s = app.engine.session();
@@ -141,7 +141,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
                 }
             }
         });
-        ui.label(egui::RichText::new("Notation view of the MIDI clip; edit notes in the MIDI Editor or Event List.").small());
+        ui.label(egui::RichText::new(crate::i18n::tr("Notation view of the MIDI clip; edit notes in the MIDI Editor or Event List.")).small());
     });
     app.ui.show_score = open;
 }
diff --git a/crates/ui-egui/src/spot.rs b/crates/ui-egui/src/spot.rs
--- a/crates/ui-egui/src/spot.rs
+++ b/crates/ui-egui/src/spot.rs
@@ -117,7 +117,7 @@ fn recorded_at(s: &Session, time_reference: u64) -> Samples {
 pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) {
     let show = |at: Samples, f: TimeFormat| s.format_position(at, f);
     egui::Grid::new("spot").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
-        ui.label("Time Scale");
+        ui.label(crate::i18n::tr("Time Scale"));
         egui::ComboBox::from_id_salt("spot_scale").selected_text(d.format.label()).show_ui(ui, |ui| {
             for f in TimeFormat::ALL {
                 ui.selectable_value(&mut d.format, f, f.label());
@@ -150,14 +150,14 @@ pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) {
             }
             ui.end_row();
         }
-        ui.label("Duration");
+        ui.label(crate::i18n::tr("Duration"));
         ui.label(RichText::new(format_length(d.start, d.len, d.format, s.sample_rate, &s.tempo, s.frame_rate)).font(mono(13.0)));
         ui.end_row();
         if let Some(at) = d.stamp {
-            ui.label("Original Time Stamp");
+            ui.label(crate::i18n::tr("Original Time Stamp"));
             ui.horizontal(|ui| {
                 ui.label(RichText::new(show(at, TimeFormat::Timecode)).font(mono(13.0)));
-                if ui.button("Use").on_hover_text("Spot to where it was recorded").clicked() {
+                if ui.button(crate::i18n::tr("Use")).on_hover_text(crate::i18n::tr("Spot to where it was recorded")).clicked() {
                     d.start = at;
                 }
             });
diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs
--- a/crates/ui-egui/src/toolbar.rs
+++ b/crates/ui-egui/src/toolbar.rs
@@ -80,10 +80,10 @@ fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
                 mode_click(app, m, ui.input(|i| i.modifiers));
             }
             resp.on_hover_text(match m {
-                EditMode::Shuffle if locked => format!("Shuffle is locked ({cmd}-click to unlock)"),
-                EditMode::Shuffle => format!("Shuffle mode ({fkey}); {cmd}-click locks it out"),
-                EditMode::Grid => format!("Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes"),
-                _ => format!("{} mode ({fkey}); Shift-click adds Snap to Grid", if m == EditMode::Slip { "Slip" } else { "Spot" }),
+                EditMode::Shuffle if locked => if crate::i18n::is_korean() {  crate::i18n::format_fields("Shuffle is locked ({cmd}-click to unlock)", &[("cmd", format!("{}", &cmd))]) } else { format!("Shuffle is locked ({cmd}-click to unlock)") },
+                EditMode::Shuffle => if crate::i18n::is_korean() {  crate::i18n::format_fields("Shuffle mode ({fkey}); {cmd}-click locks it out", &[("fkey", format!("{}", &fkey)), ("cmd", format!("{}", &cmd))]) } else { format!("Shuffle mode ({fkey}); {cmd}-click locks it out") },
+                EditMode::Grid => if crate::i18n::is_korean() {  crate::i18n::format_fields("Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes", &[("fkey", format!("{}", &fkey))]) } else { format!("Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes") },
+                _ => if crate::i18n::is_korean() { let __i18n_0 = &(if m == EditMode::Slip { crate::i18n::tr("Slip") } else { crate::i18n::tr("Spot") }); crate::i18n::format_fields("{} mode ({fkey}); Shift-click adds Snap to Grid", &[("", format!("{}", __i18n_0)), ("fkey", format!("{}", &fkey))]) } else { format!("{} mode ({fkey}); Shift-click adds Snap to Grid", if m == EditMode::Slip { crate::i18n::tr("Slip") } else { crate::i18n::tr("Spot") }) },
             });
         }
     });
@@ -182,7 +182,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) {
             let bracket = Rect::from_min_max(pos2(bx0 - 2.0, r.min.y + 1.0), pos2(bx1 + 2.0, r.min.y + 5.0));
             let resp = ui.interact(bracket.expand2(vec2(0.0, 2.0)), ui.id().with("smart"), Sense::click());
             ui.painter().rect_filled(bracket, 1.0, if cur == Tool::Smart { t.accent } else { t.tool_bracket_off });
-            if resp.on_hover_text("Smart Tool (F6+F7 / F7+F8)").clicked() {
+            if resp.on_hover_text(crate::i18n::tr("Smart Tool (F6+F7 / F7+F8)")).clicked() {
                 let _ = app.run("edit.tool", json!({"tool": "smart"}));
             }
             ui.horizontal(|ui| {
@@ -251,7 +251,7 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
     // Sub counter row.
     let y = r.max.y - 11.0;
     ui.painter().line_segment([pos2(r.min.x + 6.0, r.max.y - 21.0), pos2(r.max.x - 6.0, r.max.y - 21.0)], Stroke::new(1.0, t.counter_rule));
-    ui.painter().text(pos2(r.min.x + 10.0, y), Align2::LEFT_CENTER, "Sub", regular(11.0), t.counter_label);
+    ui.painter().text(pos2(r.min.x + 10.0, y), Align2::LEFT_CENTER, crate::i18n::tr("Sub"), regular(11.0), t.counter_label);
     ui.painter().text(pos2(r.min.x + 150.0, y), Align2::RIGHT_CENTER, fmt_pos(app, pos, sub_fmt), mono(11.0), t.counter_text);
     let st = if app.engine.is_dirty() { "modified" } else { "saved" };
     let dev = app.player.as_ref().map_or("no audio engine", |p| if p.silent { "silent clock" } else { "audio on" });
@@ -269,8 +269,8 @@ fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
     let gl = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 8.0), vec2(48.0, 16.0));
     let resp = ui.interact(gl, ui.id().with("grid_lines"), Sense::click());
     key(ui.painter(), gl, &resp, if lines { t.mode_on } else { t.counter_off });
-    ui.painter().text(gl.center(), Align2::CENTER_CENTER, "Grid", bold(11.0), if lines { t.mode_on_text } else { t.mode_off_text });
-    if resp.on_hover_text("Show grid lines").clicked() {
+    ui.painter().text(gl.center(), Align2::CENTER_CENTER, crate::i18n::tr("Grid"), bold(11.0), if lines { t.mode_on_text } else { t.mode_off_text });
+    if resp.on_hover_text(crate::i18n::tr("Show grid lines")).clicked() {
         let _ = app.run("view.grid_lines", json!({}));
     }
     let gr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 6.0), pos2(r.max.x - 4.0, r.min.y + 26.0));
@@ -326,19 +326,19 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) {
                 let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click());
                 key(ui.painter(), r, &resp, key_fill(&resp));
                 icons::draw(ui.painter(), r.shrink(5.0), "stop", if playing { t.text } else { t.accent });
-                if resp.on_hover_text("Stop (Space)").clicked() {
+                if resp.on_hover_text(crate::i18n::tr("Stop (Space)")).clicked() {
                     let _ = app.run("transport.stop", json!({}));
                 }
                 let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click());
                 key(ui.painter(), r, &resp, if playing { Color32::from_rgb(40, 90, 50) } else { key_fill(&resp) });
                 icons::draw(ui.painter(), r.shrink(5.0), "play", if playing { Color32::from_rgb(120, 255, 140) } else { t.counter_text });
-                if resp.on_hover_text("Play (Space)").clicked() {
+                if resp.on_hover_text(crate::i18n::tr("Play (Space)")).clicked() {
                     let _ = app.run("transport.play", json!({}));
                 }
                 let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click());
                 key(ui.painter(), r, &resp, if recording { Color32::from_rgb(110, 30, 30) } else { key_fill(&resp) });
                 icons::draw(ui.painter(), r.shrink(5.0), "record", t.rec);
-                let resp = resp.on_hover_text("Record (Cmd+Space)");
+                let resp = resp.on_hover_text(crate::i18n::tr("Record (Cmd+Space)"));
                 if let Some(why) = app.record_hint() {
                     // Below the whole group, so the hint covers none of its buttons.
                     let below = Rect::from_min_max(r.min, pos2(r.max.x, g.max.y));
@@ -378,19 +378,19 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) {
     let co = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 6.0), vec2(64.0, 15.0));
     let resp = ui.interact(co, ui.id().with("countoff"), Sense::click());
     key(ui.painter(), co, &resp, if countoff { t.mode_on } else { t.counter_off });
-    ui.painter().text(co.center(), Align2::CENTER_CENTER, "Count Off", bold(10.0), if countoff { t.mode_on_text } else { t.mode_off_text });
+    ui.painter().text(co.center(), Align2::CENTER_CENTER, crate::i18n::tr("Count Off"), bold(10.0), if countoff { t.mode_on_text } else { t.mode_off_text });
     if resp.clicked() {
         let _ = app.run("options.countoff", json!({}));
     }
-    ui.painter().text(pos2(r.max.x - 8.0, co.center().y), Align2::RIGHT_CENTER, format!("{bars} bars"), mono(11.0), t.counter_text);
-    ui.painter().text(pos2(r.min.x + 70.0, r.min.y + 32.0), Align2::RIGHT_CENTER, "Meter", regular(11.0), t.counter_label);
+    ui.painter().text(pos2(r.max.x - 8.0, co.center().y), Align2::RIGHT_CENTER, if crate::i18n::is_korean() {  crate::i18n::format_fields("{bars} bars", &[("bars", format!("{}", &bars))]) } else { format!("{bars} bars") }, mono(11.0), t.counter_text);
+    ui.painter().text(pos2(r.min.x + 70.0, r.min.y + 32.0), Align2::RIGHT_CENTER, crate::i18n::tr("Meter"), regular(11.0), t.counter_label);
     ui.painter().text(pos2(r.max.x - 8.0, r.min.y + 32.0), Align2::RIGHT_CENTER, meter, mono(11.0), t.counter_text);
-    ui.painter().text(pos2(r.min.x + 70.0, r.min.y + 48.0), Align2::RIGHT_CENTER, "Tempo", regular(11.0), t.counter_label);
+    ui.painter().text(pos2(r.min.x + 70.0, r.min.y + 48.0), Align2::RIGHT_CENTER, crate::i18n::tr("Tempo"), regular(11.0), t.counter_label);
     icons::draw(ui.painter(), Rect::from_center_size(pos2(r.min.x + 82.0, r.min.y + 48.0), vec2(13.0, 13.0)), "note", t.counter_text);
     tempo_value(app, ui, r.max.x - 8.0, r.min.y + 48.0, bpm);
     let clk = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 40.0), vec2(18.0, 18.0));
     icons::draw(ui.painter(), clk, "click", if click { t.counter_text } else { t.text_dim });
-    if ui.interact(clk, ui.id().with("click"), Sense::click()).on_hover_text("Click").clicked() {
+    if ui.interact(clk, ui.id().with("click"), Sense::click()).on_hover_text(crate::i18n::tr("Click")).clicked() {
         let _ = app.run("options.click", json!({}));
     }
 }
@@ -422,7 +422,7 @@ fn tempo_value(app: &mut SoundApp, ui: &mut Ui, right: f32, y: f32, bpm: f64) {
     if resp.drag_stopped() || resp.lost_focus() {
         app.engine.end_merge();
     }
-    resp.on_hover_text("Drag to change the tempo (Shift: fine steps), or click to type it");
+    resp.on_hover_text(crate::i18n::tr("Drag to change the tempo (Shift: fine steps), or click to type it"));
 }
 
 fn output_meter(app: &mut SoundApp, ui: &mut Ui) {
diff --git a/crates/ui-egui/src/video_track.rs b/crates/ui-egui/src/video_track.rs
--- a/crates/ui-egui/src/video_track.rs
+++ b/crates/ui-egui/src/video_track.rs
@@ -445,20 +445,20 @@ pub fn draw_lane(app: &mut SoundApp, painter: &Painter, s: &Session, track: &Tra
         }
         let p = painter.with_clip_rect(vis);
         let Some(v) = s.video(source) else {
-            p.text(vis.center(), egui::Align2::CENTER_CENTER, "movie missing from session", regular(10.0), Color32::from_rgb(220, 120, 120));
+            p.text(vis.center(), egui::Align2::CENTER_CENTER, crate::i18n::tr("movie missing from session"), regular(10.0), Color32::from_rgb(220, 120, 120));
             continue;
         };
         if !on {
-            p.text(vis.center(), egui::Align2::CENTER_CENTER, "Video Track Offline", regular(10.0), Color32::from_gray(170));
+            p.text(vis.center(), egui::Align2::CENTER_CENTER, crate::i18n::tr("Video Track Offline"), regular(10.0), Color32::from_gray(170));
             continue;
         }
         if let Some(why) = &v.offline {
-            p.text(vis.center(), egui::Align2::CENTER_CENTER, format!("media offline: {why}"), regular(10.0), Color32::from_rgb(220, 120, 120));
+            p.text(vis.center(), egui::Align2::CENTER_CENTER, if crate::i18n::is_korean() {  crate::i18n::format_fields("media offline: {why}", &[("why", format!("{}", &why))]) } else { format!("media offline: {why}") }, regular(10.0), Color32::from_rgb(220, 120, 120));
             continue;
         }
         let key = MovieKey::new(source, &v.path);
         if let Some(Err(e)) = app.video.info(key) {
-            p.text(vis.center(), egui::Align2::CENTER_CENTER, format!("media offline: {e}"), regular(10.0), Color32::from_rgb(220, 120, 120));
+            p.text(vis.center(), egui::Align2::CENTER_CENTER, if crate::i18n::is_korean() {  crate::i18n::format_fields("media offline: {e}", &[("e", format!("{}", &e))]) } else { format!("media offline: {e}") }, regular(10.0), Color32::from_rgb(220, 120, 120));
             continue;
         }
         let aspect = app.video.info(key).and_then(|i| i.as_ref().ok()).map_or(v.aspect(), MovieInfo::display_aspect) as f32;
diff --git a/crates/ui-egui/src/video_window.rs b/crates/ui-egui/src/video_window.rs
--- a/crates/ui-egui/src/video_window.rs
+++ b/crates/ui-egui/src/video_window.rs
@@ -35,7 +35,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
         .clamp(0.2, 5.0);
     let mut open = true;
     let mut burn = app.ui.video_burn_in;
-    egui::Window::new("Video").open(&mut open).default_size(vec2(480.0, 480.0 / aspect + 28.0)).resizable(true).show(ctx, |ui| {
+    egui::Window::new(crate::i18n::tr("Video")).open(&mut open).default_size(vec2(480.0, 480.0 / aspect + 28.0)).resizable(true).show(ctx, |ui| {
         ui.horizontal(|ui| {
             crate::widgets::checkbox(ui, &mut burn, "Timecode burn-in");
             let text = match &pic {
@@ -113,7 +113,7 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
     let pos = app.position();
     let mut open = true;
     let mut locate = None;
-    egui::Window::new("Video Universe").open(&mut open).default_size(vec2(640.0, 90.0)).resizable(true).show(ctx, |ui| {
+    egui::Window::new(crate::i18n::tr("Video Universe")).open(&mut open).default_size(vec2(640.0, 90.0)).resizable(true).show(ctx, |ui| {
         let avail = ui.available_rect_before_wrap();
         let (r, resp) = ui.allocate_exact_size(vec2(avail.width().max(120.0), 64.0), Sense::click_and_drag());
         let painter = ui.painter_at(r);
@@ -122,7 +122,7 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
         let x_at = |at: i64| r.min.x + r.width() * (at as f32 / end as f32);
         let on = online(&s);
         let Some(track) = s.tracks.iter().find(|t| t.kind == TrackKind::Video) else {
-            painter.text(r.center(), Align2::CENTER_CENTER, "No Video track — File › Import › Video…", regular(11.0), Color32::from_gray(150));
+            painter.text(r.center(), Align2::CENTER_CENTER, crate::i18n::tr("No Video track — File › Import › Video…"), regular(11.0), Color32::from_gray(150));
             return;
         };
         let ppp = ctx.pixels_per_point();
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -472,7 +472,7 @@ pub fn surround_panner(ui: &mut Ui, size: f32, speakers: &[PannerSpeaker], puck:
         if sresp.double_clicked() {
             edit = Some(if kind == 0 { PannerEdit::Divergence(0.0) } else { PannerEdit::Height(0.0) });
         }
-        let _ = sresp.on_hover_text(if kind == 0 { "Divergence (0 = point source)" } else { "Elevation (height speakers)" });
+        let _ = sresp.on_hover_text(if kind == 0 { crate::i18n::tr("Divergence (0 = point source)") } else { crate::i18n::tr("Elevation (height speakers)") });
     }
     edit
 }
diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs
--- a/crates/ui-egui/src/windows.rs
+++ b/crates/ui-egui/src/windows.rs
@@ -30,22 +30,22 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
 fn audio_health(app: &mut SoundApp, ctx: &egui::Context) {
     let mut open = app.ui.show_audio_health;
     win(ctx, &mut open, "Session Audio Health", vec2(620.0, 360.0), |ui| {
-        let refresh = ui.button("Refresh Report").clicked();
+        let refresh = ui.button(crate::i18n::tr("Refresh Report")).clicked();
         if app.audio_health_report.is_none() || refresh {
             app.audio_health_report = Some(app.engine.execute("session.audio_health", &json!({})).map_err(|error| error.to_string()));
         }
         let Some(result) = &app.audio_health_report else { return };
         match result {
             Ok(report) => {
-                ui.label(format!("{} audio clips checked · {} issues", report["audio_clips"], report["issue_count"]));
+                ui.label(if crate::i18n::is_korean() { let __i18n_0 = &(report["audio_clips"]); let __i18n_1 = &(report["issue_count"]); crate::i18n::format_fields("{} audio clips checked · {} issues", &[("", format!("{}", __i18n_0)), ("", format!("{}", __i18n_1))]) } else { format!("{} audio clips checked · {} issues", report["audio_clips"], report["issue_count"]) });
                 ui.label(match report["active_audio_end_seconds"].as_f64() {
-                    Some(seconds) => format!("Active audio ends at {seconds:.2} seconds"),
-                    None => "Active audio end time is unknown (invalid session sample rate)".into(),
+                    Some(seconds) => if crate::i18n::is_korean() {  crate::i18n::format_fields("Active audio ends at {seconds:.2} seconds", &[("seconds:.2", format!("{:.2}", &seconds))]) } else { format!("Active audio ends at {seconds:.2} seconds") },
+                    None => crate::i18n::tr("Active audio end time is unknown (invalid session sample rate)").into(),
                 });
-                ui.label("Includes alternate takes and muted clips. Refresh after editing.");
+                ui.label(crate::i18n::tr("Includes alternate takes and muted clips. Refresh after editing."));
                 ui.separator();
                 if report["healthy"] == true {
-                    ui.label("All audio clips have loaded media within source bounds.");
+                    ui.label(crate::i18n::tr("All audio clips have loaded media within source bounds."));
                 }
                 egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
                     if let Some(issues) = report["issues"].as_array() {
@@ -57,7 +57,7 @@ fn audio_health(app: &mut SoundApp, ctx: &egui::Context) {
                                     text("track"),
                                     text("playlist"),
                                     text("clip"),
-                                    if issue["active"] == true { "" } else { " (alternate playlist)" }
+                                    if issue["active"] == true { "" } else { crate::i18n::tr(" (alternate playlist)") }
                                 ));
                                 ui.label(text("message"));
                                 if let Some(path) = issue["path"].as_str() {
@@ -68,7 +68,7 @@ fn audio_health(app: &mut SoundApp, ctx: &egui::Context) {
                     }
                 });
                 ui.separator();
-                ui.label("Checks loaded media only, not files on disk, signal levels, plugins or routing.");
+                ui.label(crate::i18n::tr("Checks loaded media only, not files on disk, signal levels, plugins or routing."));
             }
             Err(error) => {
                 ui.label(error.to_string());
@@ -91,7 +91,7 @@ fn win(ctx: &egui::Context, open: &mut bool, title: &str, size: egui::Vec2, body
 fn automation(app: &mut SoundApp, ctx: &egui::Context) {
     let mut open = app.ui.show_automation;
     win(ctx, &mut open, "Automation", vec2(260.0, 260.0), |ui| {
-        ui.label("Write automation for the selected tracks:");
+        ui.label(crate::i18n::tr("Write automation for the selected tracks:"));
         let tracks = app.engine.session().edit.selected_tracks.clone();
         ui.horizontal_wrapped(|ui| {
             for m in [
@@ -110,7 +110,7 @@ fn automation(app: &mut SoundApp, ctx: &egui::Context) {
             }
         });
         ui.separator();
-        ui.label("Enabled parameters");
+        ui.label(crate::i18n::tr("Enabled parameters"));
         for f in ["volume", "pan", "mute", "send level", "send pan", "send mute", "plugin"] {
             let id = format!("automation.enable.{}", f.replace(' ', "_"));
             let mut on = !app.engine.session().edit.flag(&format!("{id}.off"));
@@ -132,7 +132,7 @@ fn automation(app: &mut SoundApp, ctx: &egui::Context) {
 fn color_palette(app: &mut SoundApp, ctx: &egui::Context) {
     let mut open = app.ui.show_color_palette;
     win(ctx, &mut open, "Color Palette", vec2(300.0, 140.0), |ui| {
-        ui.label("Apply to selected tracks (Shift: selected clips)");
+        ui.label(crate::i18n::tr("Apply to selected tracks (Shift: selected clips)"));
         ui.horizontal_wrapped(|ui| {
             for (i, c) in soundcraft_model::TRACK_COLORS.iter().enumerate() {
                 let (r, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click());
@@ -147,7 +147,7 @@ fn color_palette(app: &mut SoundApp, ctx: &egui::Context) {
             }
         });
         ui.separator();
-        ui.add(egui::Slider::new(&mut app.ui.track_tint, 0.0..=1.0).show_value(false).text("Track tint in the Edit window"));
+        ui.add(egui::Slider::new(&mut app.ui.track_tint, 0.0..=1.0).show_value(false).text(crate::i18n::tr("Track tint in the Edit window")));
     });
     app.ui.show_color_palette = open;
 }
@@ -157,11 +157,11 @@ fn disk_usage(app: &mut SoundApp, ctx: &egui::Context) {
     win(ctx, &mut open, "Disk Usage", vec2(360.0, 160.0), |ui| {
         let s = app.engine.session();
         let bytes: u64 = s.sources.iter().map(|x| x.frames * u64::from(x.channels) * 4).sum();
-        ui.label(format!("Session media: {} files, {:.1} MB in memory (32-bit float)", s.sources.len(), bytes as f64 / 1_048_576.0));
+        ui.label(if crate::i18n::is_korean() { let __i18n_0 = &(s.sources.len()); let __i18n_1 = &(bytes as f64 / 1_048_576.0); crate::i18n::format_fields("Session media: {} files, {:.1} MB in memory (32-bit float)", &[("", format!("{}", __i18n_0)), (":.1", format!("{:.1}", __i18n_1))]) } else { format!("Session media: {} files, {:.1} MB in memory (32-bit float)", s.sources.len(), bytes as f64 / 1_048_576.0) });
         let per_min = f64::from(s.sample_rate.hz()) * 60.0 * 3.0 / 1_048_576.0;
-        ui.label(format!("Recording at 24-bit uses {per_min:.1} MB per mono track-minute"));
+        ui.label(if crate::i18n::is_korean() {  crate::i18n::format_fields("Recording at 24-bit uses {per_min:.1} MB per mono track-minute", &[("per_min:.1", format!("{:.1}", &per_min))]) } else { format!("Recording at 24-bit uses {per_min:.1} MB per mono track-minute") });
         if let Some(p) = &app.engine.path {
-            ui.label(format!("Session file: {p}"));
+            ui.label(if crate::i18n::is_korean() {  crate::i18n::format_fields("Session file: {p}", &[("p", format!("{}", &p))]) } else { format!("Session file: {p}") });
         }
     });
     app.ui.show_disk_usage = open;
@@ -170,12 +170,12 @@ fn disk_usage(app: &mut SoundApp, ctx: &egui::Context) {
 fn system_usage(app: &mut SoundApp, ctx: &egui::Context) {
     let mut open = app.ui.show_system_usage;
     win(ctx, &mut open, "System Usage", vec2(320.0, 140.0), |ui| {
-        ui.label(format!("UI frame time: {:.1} ms", app.frame_ms));
-        ui.label(format!("Audio: {}", app.player.as_ref().map_or("no engine".to_string(), |p| format!("{} @ {} Hz", p.device_name, p.device_rate))));
+        ui.label(if crate::i18n::is_korean() { let __i18n_0 = &(app.frame_ms); crate::i18n::format_fields("UI frame time: {:.1} ms", &[(":.1", format!("{:.1}", __i18n_0))]) } else { format!("UI frame time: {:.1} ms", app.frame_ms) });
+        ui.label(if crate::i18n::is_korean() { let __i18n_0 = &(app.player.as_ref().map_or(crate::i18n::tr("no engine").to_string(), |p| if crate::i18n::is_korean() { let __i18n_0 = &(p.device_name); let __i18n_1 = &(p.device_rate); crate::i18n::format_fields("{} @ {} Hz", &[("", format!("{}", __i18n_0)), ("", format!("{}", __i18n_1))]) } else { format!("{} @ {} Hz", p.device_name, p.device_rate) })); crate::i18n::format_fields("Audio: {}", &[("", format!("{}", __i18n_0))]) } else { format!("Audio: {}", app.player.as_ref().map_or(crate::i18n::tr("no engine").to_string(), |p| format!("{} @ {} Hz", p.device_name, p.device_rate))) });
         let plugins: usize = app.engine.session().tracks.iter().map(|t| t.mixer.inserts.iter().flatten().count()).sum();
-        ui.label(format!("Active plugin instances: {plugins}"));
-        ui.label(format!("Tracks: {}", app.engine.session().tracks.len()));
-        ui.label(format!("Undo steps: {}", app.engine.undo_history().len()));
+        ui.label(if crate::i18n::is_korean() {  crate::i18n::format_fields("Active plugin instances: {plugins}", &[("plugins", format!("{}", &plugins))]) } else { format!("Active plugin instances: {plugins}") });
+        ui.label(if crate::i18n::is_korean() { let __i18n_0 = &(app.engine.session().tracks.len()); crate::i18n::format_fields("Tracks: {}", &[("", format!("{}", __i18n_0))]) } else { format!("Tracks: {}", app.engine.session().tracks.len()) });
+        ui.label(if crate::i18n::is_korean() { let __i18n_0 = &(app.engine.undo_history().len()); crate::i18n::format_fields("Undo steps: {}", &[("", format!("{}", __i18n_0))]) } else { format!("Undo steps: {}", app.engine.undo_history().len()) });
     });
     app.ui.show_system_usage = open;
 }
@@ -185,15 +185,15 @@ fn task_manager(app: &mut SoundApp, ctx: &egui::Context) {
     win(ctx, &mut open, "Task Manager", vec2(320.0, 120.0), |ui| {
         match &app.bounce {
             Some(b) => {
-                ui.label(format!("Bouncing {} ({:.0}%)", b.path().display(), b.progress() * 100.0));
+                ui.label(if crate::i18n::is_korean() { let __i18n_0 = &(b.path().display()); let __i18n_1 = &(b.progress() * 100.0); crate::i18n::format_fields("Bouncing {} ({:.0}%)", &[("", format!("{}", __i18n_0)), (":.0", format!("{:.0}", __i18n_1))]) } else { format!("Bouncing {} ({:.0}%)", b.path().display(), b.progress() * 100.0) });
                 ui.add(egui::ProgressBar::new(b.progress()));
             }
             None => {
-                ui.label("No background tasks are running.");
+                ui.label(crate::i18n::tr("No background tasks are running."));
             }
         }
         ui.label(
-            egui::RichText::new("Renders and AudioSuite processes run to completion before returning.").small().color(Tokens::current().text_dim),
+            egui::RichText::new(crate::i18n::tr("Renders and AudioSuite processes run to completion before returning.")).small().color(Tokens::current().text_dim),
         );
     });
     app.ui.show_task_manager = open;
@@ -222,7 +222,7 @@ fn metadata(app: &mut SoundApp, ctx: &egui::Context) {
         });
         ui.separator();
         let mut c = s.comments.clone();
-        ui.label("Session comments");
+        ui.label(crate::i18n::tr("Session comments"));
         if ui.text_edit_multiline(&mut c).changed() {
             app.engine.session_mut().comments = c;
         }
@@ -234,7 +234,7 @@ fn event_list(app: &mut SoundApp, ctx: &egui::Context) {
     let mut open = app.ui.show_event_list;
     win(ctx, &mut open, "MIDI Event List", vec2(420.0, 360.0), |ui| {
         let Some(cid) = crate::midi_editor::target_clip(app) else {
-            ui.label("Select a MIDI clip.");
+            ui.label(crate::i18n::tr("Select a MIDI clip."));
             return;
         };
         let notes = match app.engine.session().find_clip(cid).map(|(_, c)| c.content.clone()) {
@@ -311,7 +311,7 @@ fn midi_keyboard(app: &mut SoundApp, ctx: &egui::Context) {
             let next = s.tempo.tick_to_samples(s.tempo.samples_to_ticks(at, s.sample_rate) + len, s.sample_rate);
             let _ = app.run("transport.locate", json!({"at": next}));
         }
-        ui.label(egui::RichText::new("Click keys to step-enter notes into the selected MIDI clip at the insertion point.").small());
+        ui.label(egui::RichText::new(crate::i18n::tr("Click keys to step-enter notes into the selected MIDI clip at the insertion point.")).small());
     });
     app.ui.show_midi_keyboard = open;
 }
@@ -321,10 +321,10 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
     if !open {
         return;
     }
-    egui::Window::new("Workspace").open(&mut open).default_size(vec2(460.0, 420.0)).show(ctx, |ui| {
+    egui::Window::new(crate::i18n::tr("Workspace")).open(&mut open).default_size(vec2(460.0, 420.0)).show(ctx, |ui| {
         let dir = if app.ui.workspace_dir.is_empty() { std::env::var("HOME").unwrap_or_else(|_| ".".into()) } else { app.ui.workspace_dir.clone() };
         ui.horizontal(|ui| {
-            if ui.button("⬆").on_hover_text("Parent folder").clicked()
+            if ui.button("⬆").on_hover_text(crate::i18n::tr("Parent folder")).clicked()
                 && let Some(p) = std::path::Path::new(&dir).parent()
             {
                 app.ui.workspace_dir = p.to_string_lossy().into_owned();
@@ -368,7 +368,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
                 }
             }
         });
-        ui.label(egui::RichText::new("Double-click to import audio/MIDI at the insertion point or open a session.").small());
+        ui.label(egui::RichText::new(crate::i18n::tr("Double-click to import audio/MIDI at the insertion point or open a session.")).small());
     });
     app.ui.show_workspace = open;
 }
@@ -376,7 +376,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
 fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
     let mut open = app.ui.show_configurations;
     win(ctx, &mut open, "Window Configurations", vec2(320.0, 240.0), |ui| {
-        if ui.button("New Configuration…").clicked() {
+        if ui.button(crate::i18n::tr("New Configuration…")).clicked() {
             let n = app.ui.configurations.len() + 1;
             let mut snap = app.ui.clone();
             snap.configurations.clear();
@@ -438,11 +438,11 @@ fn chosen_device<'a>(devices: &'a [DeviceInfo], name: Option<&str>) -> Option<&'
 
 fn device_combo(ui: &mut egui::Ui, id: &str, value: &mut Option<String>, devices: &[DeviceInfo]) {
     let default = devices.iter().find(|d| d.is_default).map_or("none".to_string(), |d| d.name.clone());
-    let shown = value.clone().unwrap_or_else(|| format!("Default ({default})"));
+    let shown = value.clone().unwrap_or_else(|| if crate::i18n::is_korean() {  crate::i18n::format_fields("Default ({default})", &[("default", format!("{}", &default))]) } else { format!("Default ({default})") });
     egui::ComboBox::from_id_salt(id).width(300.0).selected_text(shown).show_ui(ui, |ui| {
-        ui.selectable_value(value, None, format!("Default ({default})"));
+        ui.selectable_value(value, None, if crate::i18n::is_korean() {  crate::i18n::format_fields("Default ({default})", &[("default", format!("{}", &default))]) } else { format!("Default ({default})") });
         for d in devices {
-            ui.selectable_value(value, Some(d.name.clone()), format!("{} ({} ch)", d.name, d.max_channels));
+            ui.selectable_value(value, Some(d.name.clone()), if crate::i18n::is_korean() { let __i18n_0 = &(d.name); let __i18n_1 = &(d.max_channels); crate::i18n::format_fields("{} ({} ch)", &[("", format!("{}", __i18n_0)), ("", format!("{}", __i18n_1))]) } else { format!("{} ({} ch)", d.name, d.max_channels) });
         }
     });
 }
@@ -450,7 +450,7 @@ fn device_combo(ui: &mut egui::Ui, id: &str, value: &mut Option<String>, devices
 fn number_combo(ui: &mut egui::Ui, id: &str, value: &mut Option<u32>, options: &[u32], unit: &str) {
     let shown = value.map_or("Device default".to_string(), |v| format!("{v} {unit}"));
     egui::ComboBox::from_id_salt(id).width(300.0).selected_text(shown).show_ui(ui, |ui| {
-        ui.selectable_value(value, None, "Device default");
+        ui.selectable_value(value, None, crate::i18n::tr("Device default"));
         for v in options {
             ui.selectable_value(value, Some(*v), format!("{v} {unit}"));
         }
@@ -471,12 +471,12 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
     win(ctx, &mut open, "Playback Engine", vec2(460.0, 360.0), |ui| {
         let t = Tokens::current();
         egui::Grid::new("pe").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
-            ui.label("Audio system");
+            ui.label(crate::i18n::tr("Audio system"));
             let def = systems.iter().find(|s| s.is_default).map_or("none".to_string(), |s| s.name.clone());
-            let shown = draft.system.clone().unwrap_or_else(|| format!("Default ({def})"));
+            let shown = draft.system.clone().unwrap_or_else(|| if crate::i18n::is_korean() {  crate::i18n::format_fields("Default ({def})", &[("def", format!("{}", &def))]) } else { format!("Default ({def})") });
             egui::ComboBox::from_id_salt("pe-system").width(300.0).selected_text(shown).show_ui(ui, |ui| {
                 let before = draft.system.clone();
-                ui.selectable_value(&mut draft.system, None, format!("Default ({def})"));
+                ui.selectable_value(&mut draft.system, None, if crate::i18n::is_korean() {  crate::i18n::format_fields("Default ({def})", &[("def", format!("{}", &def))]) } else { format!("Default ({def})") });
                 for s in systems.iter() {
                     ui.selectable_value(&mut draft.system, Some(s.name.clone()), &s.name);
                 }
@@ -488,31 +488,31 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
             });
             ui.end_row();
             let sys = chosen_system(&systems, &draft);
-            ui.label("Output device");
+            ui.label(crate::i18n::tr("Output device"));
             device_combo(ui, "pe-out", &mut draft.output, sys.map_or(&[][..], |s| &s.outputs));
             ui.end_row();
-            ui.label("Input device");
+            ui.label(crate::i18n::tr("Input device"));
             device_combo(ui, "pe-in", &mut draft.input, sys.map_or(&[][..], |s| &s.inputs));
             ui.end_row();
             let out = sys.and_then(|s| chosen_device(&s.outputs, draft.output.as_deref()));
-            ui.label("Sample rate");
+            ui.label(crate::i18n::tr("Sample rate"));
             number_combo(ui, "pe-rate", &mut draft.sample_rate, out.map_or(&[][..], |d| &d.sample_rates), "Hz");
             ui.end_row();
-            ui.label("Buffer size");
+            ui.label(crate::i18n::tr("Buffer size"));
             let buffers = out.map(DeviceInfo::buffer_sizes).unwrap_or_else(|| soundcraft_playback::devices::STANDARD_BUFFERS.to_vec());
             number_combo(ui, "pe-buffer", &mut draft.buffer_size, &buffers, "samples");
             ui.end_row();
         });
         ui.add_space(4.0);
         ui.horizontal(|ui| {
-            if ui.button("Refresh devices").clicked() {
+            if ui.button(crate::i18n::tr("Refresh devices")).clicked() {
                 refresh = true;
             }
             let changed = draft != app.ui.audio;
-            if ui.add_enabled(changed, egui::Button::new("Apply")).on_hover_text("Reopen the audio devices with these settings").clicked() {
+            if ui.add_enabled(changed, egui::Button::new("Apply")).on_hover_text(crate::i18n::tr("Reopen the audio devices with these settings")).clicked() {
                 apply = true;
             }
-            if changed && ui.button("Revert").clicked() {
+            if changed && ui.button(crate::i18n::tr("Revert")).clicked() {
                 draft = app.ui.audio.clone();
             }
         });
@@ -521,34 +521,34 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
             ui.label("Output");
             ui.label(app.player.as_ref().map_or("none".to_string(), |p| {
                 if p.silent {
-                    "no device (silent clock)".to_string()
+                    crate::i18n::tr("no device (silent clock)").to_string()
                 } else {
-                    let buf = p.device_buffer.map_or("default buffer".to_string(), |b| format!("{b} samples"));
-                    format!("{} · {} Hz · {} ch · {buf}", p.device_name, p.device_rate, p.device_channels)
+                    let buf = p.device_buffer.map_or(crate::i18n::tr("default buffer").to_string(), |b| if crate::i18n::is_korean() {  crate::i18n::format_fields("{b} samples", &[("b", format!("{}", &b))]) } else { format!("{b} samples") });
+                    if crate::i18n::is_korean() { let __i18n_0 = &(p.device_name); let __i18n_1 = &(p.device_rate); let __i18n_2 = &(p.device_channels); crate::i18n::format_fields("{} · {} Hz · {} ch · {buf}", &[("", format!("{}", __i18n_0)), ("", format!("{}", __i18n_1)), ("", format!("{}", __i18n_2)), ("buf", format!("{}", &buf))]) } else { format!("{} · {} Hz · {} ch · {buf}", p.device_name, p.device_rate, p.device_channels) }
                 }
             }));
             ui.end_row();
             ui.label("Input");
-            ui.label(app.recorder.as_ref().map_or("opened on first record or monitor".to_string(), |r| {
-                format!("{} · {} Hz · {} ch", r.device_name, r.sample_rate, r.channels)
+            ui.label(app.recorder.as_ref().map_or(crate::i18n::tr("opened on first record or monitor").to_string(), |r| {
+                if crate::i18n::is_korean() { let __i18n_0 = &(r.device_name); let __i18n_1 = &(r.sample_rate); let __i18n_2 = &(r.channels); crate::i18n::format_fields("{} · {} Hz · {} ch", &[("", format!("{}", __i18n_0)), ("", format!("{}", __i18n_1)), ("", format!("{}", __i18n_2))]) } else { format!("{} · {} Hz · {} ch", r.device_name, r.sample_rate, r.channels) }
             }));
             ui.end_row();
-            ui.label("Session rate");
+            ui.label(crate::i18n::tr("Session rate"));
             let sr = app.engine.session().sample_rate.hz();
             let note = app
                 .player
                 .as_ref()
                 .filter(|p| !p.silent && p.device_rate != sr)
                 .map_or(String::new(), |_| " (resampled to the device rate)".to_string());
-            ui.label(format!("{sr} Hz{note}"));
+            ui.label(if crate::i18n::is_korean() {  crate::i18n::format_fields("{sr} Hz{note}", &[("sr", format!("{}", &sr)), ("note", format!("{}", &note))]) } else { format!("{sr} Hz{note}") });
             ui.end_row();
         });
-        ui.label(egui::RichText::new("The mix runs in 512-sample blocks; the buffer size sets the device's own latency.").small().color(t.text_dim));
+        ui.label(egui::RichText::new(crate::i18n::tr("The mix runs in 512-sample blocks; the buffer size sets the device's own latency.")).small().color(t.text_dim));
         let mut dc = app.engine.session().edit.delay_compensation;
         if crate::widgets::checkbox(ui, &mut dc, "Delay compensation").changed() {
             let _ = app.run("options.delay_compensation", json!({"value": dc}));
         }
-        if ui.button("Reconnect audio device").clicked() {
+        if ui.button(crate::i18n::tr("Reconnect audio device")).clicked() {
             app.reconnect_audio();
         }
     });
@@ -609,7 +609,7 @@ pub fn audio_device_command(app: &mut SoundApp, p: &serde_json::Value) -> Result
 pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) {
     let mut open = app.ui.show_io_setup;
     win(ctx, &mut open, "I/O Setup", vec2(420.0, 300.0), |ui| {
-        ui.label(egui::RichText::new("Busses").strong());
+        ui.label(egui::RichText::new(crate::i18n::tr("Busses")).strong());
         let busses: Vec<(String, String)> = app.engine.session().busses.iter().map(|b| (b.name.clone(), b.format.label().to_string())).collect();
         egui::Grid::new("busses").num_columns(3).striped(true).show(ui, |ui| {
             for (name, fmt) in busses {
@@ -632,11 +632,11 @@ pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) {
             let _ = app.run("mix.new_bus", json!({"name": format!("Bus {n}")}));
         }
         ui.separator();
-        ui.label(egui::RichText::new("Outputs").strong());
+        ui.label(egui::RichText::new(crate::i18n::tr("Outputs")).strong());
         // Main output format: stereo, or a surround format (the mix and master faders follow).
         let main = app.engine.session().main_format();
         ui.horizontal(|ui| {
-            ui.label("Main output");
+            ui.label(crate::i18n::tr("Main output"));
             egui::ComboBox::from_id_salt("main_output_format").selected_text(main.label()).show_ui(ui, |ui| {
                 for f in soundcraft_model::ChannelFormat::ALL.into_iter().filter(|f| (2..=16).contains(&f.channels())) {
                     if ui.selectable_label(f == main, f.label()).clicked() && f != main {
@@ -644,10 +644,10 @@ pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) {
                     }
                 }
             });
-            ui.label(egui::RichText::new(format!("{} ch", main.channels())).small());
+            ui.label(egui::RichText::new(if crate::i18n::is_korean() { let __i18n_0 = &(main.channels()); crate::i18n::format_fields("{} ch", &[("", format!("{}", __i18n_0))]) } else { format!("{} ch", main.channels()) }).small());
         });
         for o in app.engine.session().outputs.clone() {
-            ui.label(format!("{} — {} from channel {}", o.name, o.format.label(), o.first_channel + 1));
+            ui.label(if crate::i18n::is_korean() { let __i18n_0 = &(o.name); let __i18n_1 = &(o.format.label()); let __i18n_2 = &(o.first_channel + 1); crate::i18n::format_fields("{} — {} from channel {}", &[("", format!("{}", __i18n_0)), ("", format!("{}", __i18n_1)), ("", format!("{}", __i18n_2))]) } else { format!("{} — {} from channel {}", o.name, o.format.label(), o.first_channel + 1) });
         }
     });
     app.ui.show_io_setup = open;
@@ -665,8 +665,8 @@ pub fn shortcuts_window(app: &mut SoundApp, ctx: &egui::Context) {
                 for (shortcut, label, menu) in engine.chain(windows) {
                     let Some(shortcut) = shortcut else { continue };
                     ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(shortcut, mac)).font(mono(11.0)));
-                    ui.label(label);
-                    ui.label(egui::RichText::new(menu).small());
+                    ui.label(crate::i18n::tr(label));
+                    ui.label(egui::RichText::new(crate::i18n::path(&menu)).small());
                     ui.end_row();
                 }
             });
@@ -689,7 +689,7 @@ pub fn clip_effects(app: &mut SoundApp, ctx: &egui::Context) {
                 .map(|c| c.id)
         });
         let Some(cid) = clip else {
-            ui.label("Select an audio clip.");
+            ui.label(crate::i18n::tr("Select an audio clip."));
             return;
         };
         let name = app.engine.session().find_clip(cid).map(|(_, c)| c.name.clone()).unwrap_or_default();
@@ -731,7 +731,7 @@ pub fn clip_effects(app: &mut SoundApp, ctx: &egui::Context) {
             });
             let mut g = get(app, "gain", 0.0);
             let before = g;
-            ui.add(egui::Slider::new(&mut g, -36.0..=24.0).text("Clip effects gain (dB)"));
+            ui.add(egui::Slider::new(&mut g, -36.0..=24.0).text(crate::i18n::tr("Clip effects gain (dB)")));
             if (g - before).abs() > f32::EPSILON {
                 let _ =
                     app.engine.execute_merged("clip.effects_set", &json!({"clips": [cid.0], "params": {"gain": g}}), &format!("cfx:{}:gain", cid.0));
