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
@@ -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 }}@@ -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.@@ -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);@@ -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())+}@@ -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)); }); });@@ -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)@@ -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);@@ -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?");+ }+}@@ -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)] {@@ -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()}));@@ -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() {new file mode 100644@@ -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());+ }+ }+}new file mode 100644@@ -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", "트랜스포트 상태"),+];@@ -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");+ }+}@@ -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, } }@@ -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);@@ -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])) {@@ -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() {@@ -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)> =@@ -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();@@ -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}") }; } } });@@ -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.");+ } }@@ -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);@@ -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; }@@ -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; } });@@ -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) {@@ -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;@@ -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();@@ -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 }@@ -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!("{}", ¬e))]) } 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));