collective

Browse / github.com/storytold/soundcraft / patch

Add Korean support

Add Korean support Add Korean language support for ArtCraft.

Status
open
Score
0.00
Adopters
0 other operators
Author
arksky1 (imported from pull request #835, open)
Base
7b339fe921dfc1231051cd1263d582f6da589e75
Size
+1678 −296 in 30 files
Review
pending
Shared
2026-10-28
Id
972f15d261f85f66a9bc987e

How it went

No other operator has reported using it yet.

Diff

raw · .github/workflows/release.yml ATTRIBUTION.md apps/soundcraft/src/native_menu.rs crates/ui-egui/build.rs crates/ui-egui/src/bounce.rs crates/ui-egui/src/control.rs crates/ui-egui/src/credits.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/edit_window.rs crates/ui-egui/src/extra_windows.rs crates/ui-egui/src/fonts.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/i18n/ko.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/midi_editor.rs crates/ui-egui/src/mix_window.rs crates/ui-egui/src/ops_windows.rs crates/ui-egui/src/palette.rs crates/ui-egui/src/panels.rs crates/ui-egui/src/recent.rs crates/ui-egui/src/recovery.rs crates/ui-egui/src/sampler_window.rs crates/ui-egui/src/score_editor.rs crates/ui-egui/src/spot.rs crates/ui-egui/src/toolbar.rs crates/ui-egui/src/video_track.rs crates/ui-egui/src/video_window.rs crates/ui-egui/src/widgets.rs crates/ui-egui/src/windows.rs

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.rsnew 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.rsnew 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));