Browse / github.com/storytold/soundcraft / patch
l10n: interface language setting with Simplified Chinese (zh-Hans)
Interface language setting + Simplified Chinese (zh-Hans) as the first translated language Hi SoundCraft team 👋 We use SoundCraft daily in a Chinese-speaking team, and since the localization plan (docs/localization-parity.md) already calls for a Language setting (PR #143) and font fallbacks (PR #141), we built that infrastructure and the first translation on top of it. ## What works now: 简体中文 (zh-Hans) end to end - **Every menu node translated: 512/512**, enforced by a test that fails when a menu entry lacks a translation. ~660 catalog entries in total. - **A language setting like the theme.** `ui.language` switches the interface live — also over the control channel, so agent
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- su469843 (imported from pull request #1002, open)
- Base
93800e0764ca62435ee66a4770f355df954b7fcd- Size
- +1423 −206 in 20 files
- Review
- pending
- Shared
- 2026-10-28
- Id
531eeacc28edc886d111b78b
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock Cargo.toml apps/soundcraft-cli/src/main.rs apps/soundcraft/Cargo.toml apps/soundcraft/src/main.rs apps/soundcraft/src/native_menu.rs crates/l10n/Cargo.toml crates/l10n/src/lib.rs crates/l10n/src/zh_hans.rs crates/ui-egui/Cargo.toml crates/ui-egui/src/control.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/fonts.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/toolbar.rs docs/control-protocol.md docs/gaps.md docs/localization-parity.md xtask/src/main.rs
@@ -3617,6 +3617,7 @@ dependencies = [ "rfd", "serde_json", "soundcraft-engine",+ "soundcraft-l10n", "soundcraft-playback", "soundcraft-ui-egui", "winresource",@@ -3712,6 +3713,13 @@ dependencies = [ "zip", ] +[[package]]+name = "soundcraft-l10n"+version = "0.12.1"+dependencies = [+ "serde_json",+]+ [[package]] name = "soundcraft-midi" version = "0.12.1"@@ -3792,6 +3800,7 @@ dependencies = [ "soundcraft-clap-host", "soundcraft-dsp", "soundcraft-engine",+ "soundcraft-l10n", "soundcraft-midi", "soundcraft-mix", "soundcraft-model",@@ -13,6 +13,7 @@ repository = "https://github.com/storytold/soundcraft" [workspace.dependencies] # internal soundcraft-time = { path = "crates/time" }+soundcraft-l10n = { path = "crates/l10n" } soundcraft-audio-io = { path = "crates/audio-io" } soundcraft-midi = { path = "crates/midi" } soundcraft-video = { path = "crates/video" }@@ -341,6 +341,7 @@ fn is_control_method(method: &str) -> bool { | "ui.menu.list" | "ui.menu.invoke" | "ui.set"+ | "ui.language" | "ui.move" | "ui.click" | "ui.drag"@@ -16,6 +16,7 @@ asio = ["soundcraft-playback/asio"] [dependencies] soundcraft-engine.workspace = true+soundcraft-l10n.workspace = true soundcraft-ui-egui.workspace = true soundcraft-playback.workspace = true eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }@@ -35,6 +35,7 @@ fn set_title(ctx: &egui::Context, title: String) { } impl eframe::App for App {+ /// Per-frame host work: native menu dispatch and sync, then SoundCraft's `logic`. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // What the realtime audio thread logged is written here, off that thread. if let Some(logger) = self.1 {@@ -45,6 +46,17 @@ impl eframe::App for App { menu.process(&mut self.0); } self.0.guarded(|app| app.logic(ctx));+ // A language switch relabels the system menu bar in place: same bar, event channel and+ // global handler (a second NativeMenu would re-register the handler, then drop the old+ // one whose Drop unsets it).+ #[cfg(target_os = "macos")]+ if let Some(menu) = &mut self.2+ && menu.labelled_in != soundcraft_l10n::current()+ {+ if let Err(e) = menu.relabel(&self.0) {+ log::warn!("could not rebuild the system menu bar for the new language: {e}");+ }+ } #[cfg(target_os = "macos")] if let Some(menu) = &mut self.2 { menu.sync(&self.0);@@ -79,7 +91,7 @@ impl eframe::App for App { "{}{} — SoundCraft — {}", self.0.engine.session().name, if self.0.engine.is_dirty() { " *" } else { "" },- if self.0.ui.window == soundcraft_ui_egui::MainWindow::Edit { "Edit" } else { "Mix" }+ if self.0.ui.window == soundcraft_ui_egui::MainWindow::Edit { soundcraft_l10n::t("Edit") } else { soundcraft_l10n::t("Mix") } ) } else { format!("{} — SoundCraft", self.0.engine.session().name)@@ -2,12 +2,14 @@ use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; use objc2::MainThreadMarker; use objc2_app_kit::NSApplication;+use soundcraft_l10n::Language; use soundcraft_ui_egui::{SoundApp, menus}; use std::sync::mpsc::{Receiver, channel}; struct Entry { item: CheckMenuItem, command: Option<String>,+ /// The catalog path, or the language id for `ui.language` items. path: String, enabled: bool, checked: bool,@@ -19,68 +21,53 @@ pub struct NativeMenu { events: Receiver<MenuEvent>, /// When `sync` last walked the entries; see `SYNC_EVERY`. last_sync: Option<std::time::Instant>,+ /// The language the menu was built in: a switch relabels the whole bar in place (`main.rs`).+ pub labelled_in: Language, } /// `sync` walks every menu leaf (~500), so it runs a few times a second rather than every frame, /// and right after a menu click. const SYNC_EVERY: std::time::Duration = std::time::Duration::from_millis(250); +/// The translated text for an English source label.+fn label(en: &str) -> String {+ soundcraft_l10n::td(en).into_owned()+}+ impl NativeMenu { /// Called on the UI thread after eframe has created NSApplication. pub fn new(app: &SoundApp, ctx: &egui::Context) -> muda::Result<Self> {- let menu = Menu::new();+ let (menu, entries) = build(app)?;+ menu.init_for_nsapp(); let (tx, events) = channel();- let mut native = Self { menu, entries: Vec::new(), events, last_sync: None };- let app_menu = Submenu::new("SoundCraft", true);- 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()))?;- app_menu.append(&PredefinedMenuItem::separator())?;- app_menu.append(&PredefinedMenuItem::services(None))?;- app_menu.append(&PredefinedMenuItem::separator())?;- // muda's own Hide items take ⌘H and ⌥⌘H, which are Heal Separation and Copy to Send.- app_menu.append(&MenuItem::with_id("hide", "Hide SoundCraft", true, None))?;- app_menu.append(&MenuItem::with_id("hide_others", "Hide Others", true, None))?;- app_menu.append(&PredefinedMenuItem::show_all(None))?;- app_menu.append(&PredefinedMenuItem::separator())?;- native.add_item(app, &app_menu, "Quit SoundCraft", "", Some("app.quit".into()))?;- native.menu.append(&app_menu)?;- let aliases = menus::ui_aliases();- for root in menus::tree() {- let submenu = native.add_branch(app, &root, &aliases)?;- native.menu.append(&submenu)?;- }- native.menu.init_for_nsapp(); let ctx = ctx.clone(); MenuEvent::set_event_handler(Some(move |event| { if tx.send(event).is_ok() { ctx.request_repaint(); } }));- Ok(native)- }-- fn add_branch(&mut self, app: &SoundApp, node: &menus::MenuNode, aliases: &[(&str, &str)]) -> muda::Result<Submenu> {- let submenu = Submenu::new(&node.label, true);- 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))?;- } else {- submenu.append(&self.add_branch(app, child, aliases)?)?;- }- }- Ok(submenu)+ Ok(Self { menu, entries, events, last_sync: None, labelled_in: soundcraft_l10n::current() }) } - 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);- parent.append(&item)?;- self.entries.push(Entry { item, command, path: path.into(), enabled, checked });+ /// Rebuilds the whole bar in the current language. The replacement is built unattached and+ /// installed only once complete: on an error nothing has changed yet, so the running bar,+ /// its entries and `labelled_in` stand and `App::logic` can retry. The event channel and+ /// the global handler are never touched — a handler registered by a second `NativeMenu`+ /// would be unset again by the first one's `Drop`, leaving the bar dead after one switch.+ pub fn relabel(&mut self, app: &SoundApp) -> muda::Result<()> {+ let (menu, entries) = build(app)?;+ // Installed before the assignment drops the old menu: a `Menu` whose bar is still+ // installed clears the app menu in its own `Drop`, and this way AppKit swaps in one step.+ menu.init_for_nsapp();+ self.menu = menu;+ self.entries = entries;+ self.last_sync = None;+ self.labelled_in = soundcraft_l10n::current(); Ok(()) } + /// Dispatches menu clicks: Hide items locally, Language items through `ui.language`, the+ /// rest through the shared menu dispatch. pub fn process(&mut self, app: &mut SoundApp) { while let Ok(event) = self.events.try_recv() { // A click changes state (and Cocoa flips the item's check mark): sync on the next pass.@@ -92,13 +79,16 @@ impl NativeMenu { if event.id == "hide" { ns.hide(None) } else { ns.hideOtherApplications(None) } } else if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id) && let Some(command) = &entry.command- && menus::item_state(app, &entry.path, Some(command)).0 {- if entry.path.is_empty() && command.starts_with("window.") {- // The application menu's About / Session Info open their window (never close it).- let _ = app.run(command, serde_json::json!({"value": true}));- } else {- menus::invoke_menu(app, command, &entry.path);+ if command == "ui.language" {+ let _ = app.run(command, serde_json::json!({"language": entry.path}));+ } else if menus::item_state(app, &entry.path, Some(command)).0 {+ if entry.path.is_empty() && command.starts_with("window.") {+ // The application menu's About / Session Info open their window (never close it).+ let _ = app.run(command, serde_json::json!({"value": true}));+ } else {+ menus::invoke_menu(app, command, &entry.path);+ } } } }@@ -137,3 +127,69 @@ impl Drop for NativeMenu { MenuEvent::set_event_handler(None::<fn(MenuEvent)>); } }++/// The whole bar, built unattached: the application submenu, then one submenu per menu-tree+/// root. Nothing is installed until the caller has a complete menu in hand.+fn build(app: &SoundApp) -> muda::Result<(Menu, Vec<Entry>)> {+ let menu = Menu::new();+ let mut entries = Vec::new();+ let app_menu = Submenu::new("SoundCraft", true);+ add_item(app, &app_menu, &mut entries, "About SoundCraft", "", Some("window.about".into()))?;+ add_item(app, &app_menu, &mut entries, "Session Info", "", Some("window.session_info".into()))?;+ add_item(app, &app_menu, &mut entries, "Session Audio Health", "", Some("window.audio_health".into()))?;+ app_menu.append(&PredefinedMenuItem::separator())?;+ add_language_menu(&mut entries, &app_menu)?;+ app_menu.append(&PredefinedMenuItem::separator())?;+ app_menu.append(&PredefinedMenuItem::services(None))?;+ app_menu.append(&PredefinedMenuItem::separator())?;+ // muda's own Hide items take ⌘H and ⌥⌘H, which are Heal Separation and Copy to Send.+ app_menu.append(&MenuItem::with_id("hide", soundcraft_l10n::t("Hide SoundCraft"), true, None))?;+ app_menu.append(&MenuItem::with_id("hide_others", soundcraft_l10n::t("Hide Others"), true, None))?;+ app_menu.append(&PredefinedMenuItem::show_all(None))?;+ app_menu.append(&PredefinedMenuItem::separator())?;+ add_item(app, &app_menu, &mut entries, "Quit SoundCraft", "", Some("app.quit".into()))?;+ menu.append(&app_menu)?;+ let aliases = menus::ui_aliases();+ for root in menus::tree() {+ let submenu = add_branch(app, &root, &aliases, &mut entries)?;+ menu.append(&submenu)?;+ }+ Ok((menu, entries))+}++/// SoundCraft › Language: one item per supported language, checked for the current one.+/// Items dispatch `ui.language` and carry the language id in `path`.+fn add_language_menu(entries: &mut Vec<Entry>, parent: &Submenu) -> muda::Result<()> {+ let submenu = Submenu::new(soundcraft_l10n::t("Language"), true);+ let current = soundcraft_l10n::current();+ for lang in Language::ALL {+ let name = lang.native_name();+ let item = CheckMenuItem::new(name, true, current == lang, None);+ submenu.append(&item)?;+ entries.push(Entry { item, command: Some("ui.language".into()), path: lang.id().into(), enabled: true, checked: current == lang });+ }+ parent.append(&submenu)+}++/// One catalog subtree, translated at display time; leaves go through `add_item`.+fn add_branch(app: &SoundApp, node: &menus::MenuNode, aliases: &[(&str, &str)], entries: &mut Vec<Entry>) -> muda::Result<Submenu> {+ let submenu = Submenu::new(label(&node.label), true);+ for child in &node.children {+ if child.children.is_empty() {+ add_item(app, &submenu, entries, &child.label, &child.path, menus::command_for_path(&child.path, aliases))?;+ } else {+ submenu.append(&add_branch(app, child, aliases, entries)?)?;+ }+ }+ Ok(submenu)+}++/// One leaf: a checked menu item whose label is the translation of the English source.+fn add_item(app: &SoundApp, parent: &Submenu, entries: &mut Vec<Entry>, en_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(en_label), enabled, checked, None);+ parent.append(&item)?;+ entries.push(Entry { item, command, path: path.into(), enabled, checked });+ Ok(())+}new file mode 100644@@ -0,0 +1,16 @@+[package]+name = "soundcraft-l10n"+description = "SoundCraft interface languages: the language list, the current language and the translation catalogs"+version.workspace = true+edition.workspace = true+license.workspace = true+rust-version.workspace = true+repository.workspace = true++[dependencies]++[dev-dependencies]+serde_json.workspace = true++[lints]+workspace = truenew file mode 100644@@ -0,0 +1,200 @@+//! Interface languages: the language list, the current-language global and the translation+//! catalogs. The English source string is the catalog key, so a string a language has no entry+//! for falls back to itself — features keep working in English until they are translated.+//!+//! Translation happens where strings are *displayed* (menus, toolbar, dialogs); command ids,+//! menu paths and the labels agents read stay English, because they are also match keys.+//!+//! L0: no dependencies, usable from the engine, the UI and the web app.++#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]+#![forbid(unsafe_code)]++use std::borrow::Cow;+use std::sync::atomic::{AtomicU8, Ordering};++mod zh_hans;++/// A supported interface language.+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]+pub enum Language {+ /// English, the source language every catalog key is written in.+ #[default]+ English,+ /// 简体中文 (Simplified Chinese).+ SimplifiedChinese,+}++impl Language {+ /// Every supported language, in menu order.+ pub const ALL: [Language; 2] = [Language::English, Language::SimplifiedChinese];++ /// BCP 47 id, as stored in preferences and taken by `ui.language`.+ pub fn id(self) -> &'static str {+ match self {+ Language::English => "en",+ Language::SimplifiedChinese => "zh-Hans",+ }+ }++ /// The language's name in its own language, for the Language menu.+ pub fn native_name(self) -> &'static str {+ match self {+ Language::English => "English",+ Language::SimplifiedChinese => "简体中文",+ }+ }++ /// Parses an id from preferences or a control request; `zh`, `zh-CN` and `zh-Hans-CN` mean+ /// zh-Hans, the only Chinese the interface ships.+ pub fn parse(id: &str) -> Option<Language> {+ match id.trim().to_ascii_lowercase().as_str() {+ "en" | "en-us" => Some(Language::English),+ "zh" | "zh-cn" | "zh-hans" | "zh-hans-cn" => Some(Language::SimplifiedChinese),+ _ => None,+ }+ }++ /// The translation of the English source string `s`, or `s` itself when this language has no+ /// entry for it. `s` must be a source literal so English can return it directly.+ pub fn translate(self, s: &'static str) -> &'static str {+ match self {+ Language::English => s,+ Language::SimplifiedChinese => zh_hans::get(s).unwrap_or(s),+ }+ }++ /// [`Language::translate`] for strings built at run time (e.g. "Undo {step}"); never+ /// allocates — translations are `&'static str`, and the fallback borrows the input.+ pub fn translate_dyn<'a>(self, s: &'a str) -> Cow<'a, str> {+ match self {+ Language::English => Cow::Borrowed(s),+ Language::SimplifiedChinese => zh_hans::get(s).map(Cow::Borrowed).unwrap_or_else(|| Cow::Borrowed(s)),+ }+ }++ /// Whether this language has a translation for `s` (English always has: it is the source).+ /// Lets tests require catalog coverage.+ pub fn covers(self, s: &str) -> bool {+ match self {+ Language::English => true,+ Language::SimplifiedChinese => zh_hans::get(s).is_some(),+ }+ }+}++/// The interface language as a discriminant, for the atomic.+fn rank(lang: Language) -> u8 {+ match lang {+ Language::English => 0,+ Language::SimplifiedChinese => 1,+ }+}++static CURRENT: AtomicU8 = AtomicU8::new(0);++/// The language the interface renders in. English until the app sets it from its saved+/// preferences; `ui.language` changes it live, including from the control channel.+pub fn current() -> Language {+ match CURRENT.load(Ordering::Relaxed) {+ 1 => Language::SimplifiedChinese,+ _ => Language::English,+ }+}++/// Sets the language the interface renders in (see [`current`]).+pub fn set_current(lang: Language) {+ CURRENT.store(rank(lang), Ordering::Relaxed);+}++/// Translates `s` (an English source literal) into the current language.+pub fn t(s: &'static str) -> &'static str {+ current().translate(s)+}++/// Translates a run-time string (e.g. "Undo {step}") into the current language.+pub fn td(s: &str) -> Cow<'_, str> {+ current().translate_dyn(s)+}++/// Translates a template containing `{name}` placeholders (e.g. `"Undo {step}"`) and fills them+/// in. The values themselves are not translated. Allocates only when the template has args.+pub fn tf(s: &'static str, args: &[(&str, &str)]) -> Cow<'static, str> {+ let mut out = Cow::Borrowed(t(s));+ for (name, value) in args {+ let pattern = format!("{{{name}}}");+ out = Cow::Owned(out.replace(&pattern, value));+ }+ out+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn ids_parse_round_trip() {+ for lang in Language::ALL {+ assert_eq!(Language::parse(lang.id()), Some(lang));+ assert_eq!(Language::parse(&lang.id().to_uppercase()), Some(lang), "ids parse case-insensitively");+ assert!(!lang.native_name().is_empty());+ }+ assert_eq!(Language::parse("zh"), Some(Language::SimplifiedChinese));+ assert_eq!(Language::parse("zh-CN"), Some(Language::SimplifiedChinese));+ assert_eq!(Language::parse("zh-Hant"), None, "we ship no Traditional Chinese");+ assert_eq!(Language::parse("de"), None);+ assert_eq!(Language::parse(""), None);+ }++ #[test]+ fn ids_are_unique() {+ let ids: Vec<_> = Language::ALL.iter().map(|l| l.id()).collect();+ assert_eq!(ids.len(), std::collections::HashSet::<_>::from_iter(ids.iter().copied()).len());+ }++ #[test]+ fn english_is_the_identity() {+ for s in ["File", "Bounce Mix...", ""] {+ assert_eq!(Language::English.translate(s), s);+ assert_eq!(Language::English.translate_dyn(s).as_ref(), s);+ }+ }++ /// Every zh-Hans entry is a real translation: non-empty, no stray whitespace. An entry+ /// identical to its key is allowed and means "stays English on purpose" (product names,+ /// channel formats, Ambisonics orders); the menu-coverage test relies on that marking.+ #[test]+ fn zh_hans_entries_are_well_formed() {+ for (en, zh) in zh_hans::ENTRIES {+ assert!(!zh.is_empty(), "{en}: empty translation");+ assert_eq!(zh.trim(), *zh, "{en}: translation has stray whitespace: {zh:?}");+ assert!(!en.starts_with(' ') && !en.ends_with(' '), "key has stray whitespace: {en:?}");+ }+ }++ /// Keys stay in sync with the catalog match: ENTRIES and `get` must cover the same strings.+ #[test]+ fn zh_hans_lookup_agrees_with_entries() {+ for (en, zh) in zh_hans::ENTRIES {+ assert_eq!(zh_hans::get(en), Some(*zh), "get() disagrees with ENTRIES for {en:?}");+ }+ assert_eq!(zh_hans::get("no such key"), None);+ }++ /// `get` builds a map, so a duplicate key would silently replace its twin.+ #[test]+ fn zh_hans_has_no_duplicate_keys() {+ assert_eq!(zh_hans::ENTRIES.len(), zh_hans::ENTRIES.iter().map(|(en, _)| *en).collect::<std::collections::HashSet<_>>().len());+ }++ /// Templates fill their placeholders. (Sets the global language; other tests pass explicit+ /// `Language` values, so the global doesn't affect them.)+ #[test]+ fn templates_fill_placeholders() {+ set_current(Language::SimplifiedChinese);+ assert_eq!(tf("Undo {step}", &[("step", "剪切")]), "撤销 剪切");+ assert_eq!(tf("No template here", &[]), "No template here");+ // An unknown template falls back to English and still fills in.+ assert_eq!(tf("No {x} entry", &[("x", "zh")]), "No zh entry");+ }+}new file mode 100644@@ -0,0 +1,735 @@+//! 简体中文 string table. Keys are the English source strings the UI displays; `get` looks them+//! up in a map built once from [`ENTRIES`], so entries may appear in any order.+//!+//! Product names (AudioSuite, Melodyne, HEAT, Sibelius…), channel formats (5.1, LCR…) and the+//! edit-mode names (SHUFFLE, SPOT…) are deliberately absent: they stay English in this language,+//! and a missing entry falls back to the key.++/// `(English source, 简体中文)` pairs.+pub static ENTRIES: &[(&str, &str)] = &[+ // ── Top-level menus ──+ ("File", "文件"),+ ("Edit", "编辑"),+ ("View", "查看"),+ ("Track", "轨道"),+ ("Clip", "剪辑"),+ ("Event", "事件"),+ ("Options", "选项"),+ ("Setup", "设置"),+ ("Window", "窗口"),+ // ── File ──+ ("New...", "新建..."),+ ("Open", "打开"),+ ("Session...", "会话..."),+ ("Project...", "项目..."),+ ("Dashboard...", "仪表板..."),+ ("Close Session", "关闭会话"),+ ("Save Session", "保存会话"),+ ("Save Session As...", "会话另存为..."),+ ("Save Session Copy In...", "将会话副本保存到..."),+ ("Save As Template...", "存储为模板..."),+ ("Revert Session to Saved...", "恢复到已保存的会话..."),+ ("Start Collaboration...", "开始协作..."),+ ("Send To...", "发送到..."),+ ("Bounce Mix...", "并轨混音..."),+ ("Import", "导入"),+ ("Session Data...", "会话数据..."),+ ("Audio...", "音频..."),+ ("Video...", "视频..."),+ ("Clip Groups...", "剪辑组..."),+ ("VENUE Channel Names as Track Names", "将 VENUE 通道名用作轨道名"),+ ("Export", "导出"),+ ("Selected Tracks as New AAF/OMF...", "所选轨道导出为 AAF/OMF..."),+ ("Selected Tracks as New Session...", "所选轨道导出为新会话..."),+ ("Selected Tracks as New Media Composer Compatible Session...", "所选轨道导出为 Media Composer 兼容会话..."),+ ("Session Info as Text...", "会话信息导出为文本..."),+ ("Get Info...", "显示信息..."),+ ("Score Setup...", "乐谱设置..."),+ ("Print Score...", "打印乐谱..."),+ ("Sign In", "登录"),+ // ── Edit ──+ ("Can't Undo", "无法撤销"),+ ("Can't Redo", "无法重做"),+ ("Restore Last Selection", "恢复上次选区"),+ ("Cut", "剪切"),+ ("Copy", "复制"),+ ("Paste", "粘贴"),+ ("Clear", "清除"),+ ("Cut Special", "剪切特殊"),+ ("Copy Special", "复制特殊"),+ ("Paste Special", "粘贴特殊"),+ ("Clear Special", "清除特殊"),+ ("Clip Gain", "剪辑增益"),+ ("Clip Effects", "剪辑效果器"),+ ("All Automation", "全部自动化"),+ ("Pan Automation", "声像自动化"),+ ("Plugin Automation", "插件自动化"),+ ("Audio as MIDI", "音频转为 MIDI"),+ ("Merge MIDI", "合并 MIDI"),+ ("Merge Markers", "合并标记"),+ ("Repeat to Fill Selection", "重复以填满选区"),+ ("To Current Automation Type", "到当前自动化类型"),+ ("Select All", "全选"),+ ("Selection", "选区"),+ ("Change Timeline to Match Edit", "时间线匹配编辑选区"),+ ("Change Edit to Match Timeline", "编辑选区匹配时间线"),+ ("Play Edit", "播放编辑选区"),+ ("Play Timeline", "播放时间线"),+ ("Move Edit Left", "编辑选区左移"),+ ("Move Edit Right", "编辑选区右移"),+ ("Halve Edit", "编辑选区减半"),+ ("Double Edit", "编辑选区加倍"),+ ("Duplicate and Extend Edit", "复制并扩展编辑选区"),+ ("Extend Edit Up", "向上扩展编辑选区"),+ ("Extend Edit Down", "向下扩展编辑选区"),+ ("Extend Edit Up to Members", "向上扩展到组成员"),+ ("Extend Edit Down to Members", "向下扩展到组成员"),+ ("Remove Edit from Top", "从顶部移除编辑选区"),+ ("Remove Edit from Bottom", "从底部移除编辑选区"),+ ("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", "将编辑选区扩展到下方标记车道"),+ ("Duplicate", "重复"),+ ("Duplicate...", "重复..."),+ ("Repeat...", "重复..."),+ ("Shift...", "移动..."),+ ("Space Clips…", "排列剪辑间距…"),+ ("Insert Silence", "插入静音"),+ ("Snap to", "吸附到"),+ ("Next", "下一个"),+ ("Previous", "上一个"),+ ("Trim", "修剪"),+ ("To Selection", "到选区"),+ ("Start to Insertion", "起点到插入点"),+ ("End to Insertion", "终点到插入点"),+ ("Start to Fill Selection", "起点填满选区"),+ ("End to Fill Selection", "终点填满选区"),+ ("To Fill Selection", "填满选区"),+ ("To File Start", "到文件开头"),+ ("To File End", "到文件结尾"),+ ("To File Boundaries", "到文件边界"),+ ("Separate", "分离"),+ ("At Selection", "在选区处"),+ ("On Grid", "在网格上"),+ ("At Transients", "在瞬态处"),+ ("Heal Separation", "愈合分离"),+ ("Consolidate", "合并"),+ ("Mute", "静音"),+ ("Copy Selection to...", "将选区复制到..."),+ ("Move Selection to...", "将选区移动到..."),+ ("Target Playlist", "目标播放列表"),+ ("New Playlist", "新建播放列表"),+ ("Duplicate of Main Playlist", "主播放列表副本"),+ ("Strip Silence", "去除静音"),+ ("TCE Edit to Timeline Selection", "时间压缩/扩展编辑选区到时间线选区"),+ ("Automation", "自动化"),+ ("Copy to Send...", "复制到发送..."),+ ("Thin", "精简"),+ ("Thin All", "全部精简"),+ ("Convert Volume Automation to Clip Gain", "音量自动化转为剪辑增益"),+ ("Convert Clip Gain to Volume Automation", "剪辑增益转为音量自动化"),+ ("Coalesce Volume Automation to Clip Gain", "合并音量自动化到剪辑增益"),+ ("Coalesce Clip Gain to Volume Automation", "合并剪辑增益到音量自动化"),+ ("Write to Current", "写入当前"),+ ("Write to All Enabled", "写入所有已启用"),+ ("Trim to Current", "修剪到当前"),+ ("Trim to All Enabled", "修剪到所有已启用"),+ ("Glide to Current", "滑动到当前"),+ ("Glide to All Enabled", "滑动到所有已启用"),+ ("Glide Pan Only", "仅滑动声像"),+ ("Duplicate Dolby Atmos Plugin Automation to Pan Automation", "将杜比全景声插件自动化复制为声像自动化"),+ ("Duplicate Pan Automation to Dolby Atmos Plugin Automation", "将声像自动化复制为杜比全景声插件自动化"),+ ("Coalesce Pan Guide Automation", "合并声像引导自动化"),+ ("Fades", "淡变"),+ ("Create...", "创建..."),+ ("Delete", "删除"),+ ("Fade to Start", "淡入到开头"),+ ("Fade to End", "淡出到结尾"),+ // ── View ──+ ("Mix", "混音"),+ ("Mix Window Views", "混音窗口视图"),+ ("Edit Window Views", "编辑窗口视图"),+ ("Mic Preamps", "话放"),+ ("Instruments", "乐器"),+ ("Inserts A-E", "插入 A-E"),+ ("Inserts F-J", "插入 F-J"),+ ("Sends A-E", "发送 A-E"),+ ("Sends F-J", "发送 F-J"),+ ("EQ Curve", "EQ 曲线"),+ ("I/O", "输入/输出"),+ ("Object", "对象"),+ ("Meters and Faders", "电平表与推子"),+ ("Delay Compensation", "延迟补偿"),+ ("Track Color", "轨道颜色"),+ ("Comments", "备注"),+ ("All", "全部"),+ ("Minimal", "精简"),+ ("Narrow Mix", "窄混音"),+ ("Real-Time Properties", "实时属性"),+ ("Marker Controls", "标记控件"),+ ("Pinned Tracks", "固定轨道"),+ ("Rulers", "标尺"),+ ("Bars|Beats", "小节|拍"),+ ("Min:Secs", "分:秒"),+ ("Timecode", "时间码"),+ ("Timecode 2", "时间码 2"),+ ("Feet+Frames", "英尺+帧"),+ ("Samples", "采样"),+ ("Tempo", "速度"),+ ("Tempo Editor", "速度编辑器"),+ ("Meter", "拍号"),+ ("Key Signature", "调号"),+ ("Key Signature Staff", "调号谱表"),+ ("Chord Symbols", "和弦符号"),+ ("Markers", "标记"),+ ("Markers 2", "标记 2"),+ ("Markers 3", "标记 3"),+ ("Markers 4", "标记 4"),+ ("Markers 5", "标记 5"),+ ("All Marker Rulers", "所有标记标尺"),+ ("Marker Displays", "标记显示"),+ ("Track Lane", "轨道车道"),+ ("Ruler Lines", "标尺线"),+ ("Folder Lines", "文件夹线"),+ ("Ruler Lane Color", "标尺车道颜色"),+ ("Track Lane Color", "轨道车道颜色"),+ ("Other Displays", "其他显示"),+ ("Universe", "轨道总览"),+ ("Track List", "轨道列表"),+ ("Clip List", "剪辑列表"),+ ("MIDI Input Display", "MIDI 输入显示"),+ ("Lower Dock", "底部停靠区"),+ ("Sync Point", "同步点"),+ ("Processing State", "处理状态"),+ ("Name", "名称"),+ ("Channel Name", "通道名称"),+ ("Scene And Take", "场景与 Take"),+ ("Rating", "评分"),+ ("none", "无"),+ ("Overlap Shadows", "重叠阴影"),+ ("Transparency", "透明度"),+ ("Overwrite Indicator", "覆盖指示器"),+ ("Copy/Move Indicators", "复制/移动指示器"),+ ("Clip Gain Line", "剪辑增益线"),+ ("Clip Gain Info", "剪辑增益信息"),+ ("Clip Effects Status", "剪辑效果器状态"),+ ("ARA Note Overlay", "ARA 音符叠加"),+ ("Current Time", "当前时间"),+ ("Original Time Stamp", "原始时间戳"),+ ("User Time Stamp", "用户时间戳"),+ ("No Time", "无时间"),+ ("Display on All Channels", "在所有通道显示"),+ ("Waveforms", "波形"),+ ("Peak", "峰值"),+ ("Power", "功率"),+ ("Rectified", "整流"),+ ("Outlines", "轮廓"),+ ("Overlapped Crossfades", "重叠交叉淡化"),+ ("Trim Playlist", "修剪播放列表"),+ ("Composite Playlist", "合成播放列表"),+ ("Expanded Sends", "展开发送"),+ ("None", "无"),+ ("Send A", "发送 A"),+ ("Send B", "发送 B"),+ ("Send C", "发送 C"),+ ("Send D", "发送 D"),+ ("Send E", "发送 E"),+ ("Send F", "发送 F"),+ ("Send G", "发送 G"),+ ("Send H", "发送 H"),+ ("Send I", "发送 I"),+ ("Send J", "发送 J"),+ ("Track Number", "轨道编号"),+ ("Track Transcription Lane", "轨道转录车道"),+ ("Dropped Video Frame Indicator", "丢帧指示器"),+ ("Transport", "走带"),+ ("Counters", "计数器"),+ ("MIDI Controls", "MIDI 控件"),+ ("Synchronization", "同步"),+ ("Output Meters", "输出电平表"),+ ("Expanded", "展开"),+ ("Main Counter", "主计数器"),+ // ── Track ──+ ("Group...", "编组..."),+ ("Split into Mono", "拆分为单声道"),+ ("Make Inactive", "设为非激活"),+ ("Move to New Folder...", "移动到新建文件夹..."),+ ("Convert Aux to Routing Folder", "将辅助轨转换为路由文件夹"),+ ("Change Track Width", "更改轨道制式"),+ ("Mono", "单声道"),+ ("Stereo", "立体声"),+ ("Quad", "四声道"),+ ("Show / Hide...", "显示/隐藏..."),+ ("Freeze", "冻结"),+ ("Commit...", "提交..."),+ ("Bounce...", "并轨..."),+ ("Extract MIDI to New Track", "提取 MIDI 到新轨道"),+ ("ARA Plugins", "ARA 插件"),+ ("Save Track Preset...", "保存轨道预设..."),+ ("Bypass Inserts", "旁通插入"),+ ("Dynamics", "动态"),+ ("Reverb", "混响"),+ ("Delay", "延迟"),+ ("Modulation", "调制"),+ ("Mute Sends", "静音发送"),+ ("Write MIDI Real-Time Properties", "写入 MIDI 实时属性"),+ ("Set Record Tracks to Input Only", "将录音轨设为仅输入"),+ ("Scroll to Track...", "滚动到轨道..."),+ ("Scroll Into View", "滚动到可见区域"),+ ("Clear All Clip Indicators", "清除所有剪辑指示器"),+ ("Coalesce VCA Controller Automation", "合并 VCA 控制自动化"),+ ("Coalesce Trim Automation", "合并修剪自动化"),+ ("Clear Trim Automation", "清除修剪自动化"),+ ("Designate as Target Playlist", "指定为目标播放列表"),+ ("Show Target Playlist", "显示目标播放列表"),+ ("Toggle Recent Playlist", "切换最近播放列表"),+ ("Create Click Track", "创建节拍器轨道"),+ // ── Clip ──+ ("Edit Lock/Unlock", "编辑锁定/解锁"),+ ("Time Lock/Unlock", "时间锁定/解锁"),+ ("Send to Back", "移到最底层"),+ ("Bring to Front", "移到最顶层"),+ ("Group", "编组"),+ ("Ungroup", "取消编组"),+ ("Ungroup All", "全部取消编组"),+ ("Regroup", "重新编组"),+ ("Loop...", "循环..."),+ ("Unloop...", "取消循环..."),+ ("Capture...", "捕获..."),+ ("Rename...", "重命名..."),+ ("Identify Sync Point", "标识同步点"),+ ("Quantize to Grid", "量化到网格"),+ ("Bypass", "旁通"),+ ("Render", "渲染"),+ ("Elastic Properties", "弹性属性"),+ ("Conform to Tempo", "匹配速度"),+ ("Remove Warp", "移除弯曲"),+ ("Remove Pitch Shift", "移除变调"),+ // ── Event ──+ ("Time Operations", "时间操作"),+ ("Time Operations Window", "时间操作窗口"),+ ("Change Meter...", "更改拍号..."),+ ("Insert Time...", "插入时间..."),+ ("Cut Time...", "剪切时间..."),+ ("Move Song Start...", "移动歌曲起点..."),+ ("Tempo Operations", "速度操作"),+ ("Tempo Operations Window", "速度操作窗口"),+ ("Constant...", "恒定..."),+ ("Linear...", "线性..."),+ ("Parabolic...", "抛物线..."),+ ("S-Curve...", "S 曲线..."),+ ("Scale...", "缩放..."),+ ("Stretch...", "拉伸..."),+ ("MIDI Operations", "MIDI 操作"),+ ("MIDI Operations Window", "MIDI 操作窗口"),+ ("Quantize...", "量化..."),+ ("Change Velocity...", "更改力度..."),+ ("Change Duration...", "更改时值..."),+ ("Transpose...", "移调..."),+ ("Select/Split Notes...", "选择/拆分音符..."),+ ("Input Quantize...", "输入量化..."),+ ("Step Input...", "步进输入..."),+ ("Restore Performance...", "恢复演奏..."),+ ("Flatten Performance...", "展平演奏..."),+ ("MIDI Track Offsets", "MIDI 轨道偏移"),+ ("MIDI Real-Time Properties", "MIDI 实时属性"),+ ("Remove Duplicate Notes", "移除重复音符"),+ ("Add Key Change...", "添加调号变化..."),+ ("Extract Chords from Selection", "从选区提取和弦"),+ ("Beat Detective", "节拍侦测"),+ ("Identify Beat...", "标识节拍..."),+ ("Renumber Bars...", "重新编号小节..."),+ ("All MIDI Notes Off", "所有 MIDI 音符关闭"),+ ("Retrospective Record", "回顾录音"),+ // ── AudioSuite (product names stay English) ──+ ("Channel Strip", "通道条"),+ ("Pitch Shift Legacy", "旧版变调"),+ ("Time Shift", "时间伸缩"),+ ("DC Offset Removal", "直流偏移去除"),+ ("Gain", "增益"),+ ("Invert", "反相"),+ ("Normalize", "标准化"),+ ("Reverse", "倒放"),+ ("Signal Generator", "信号发生器"),+ ("Time Compression Expansion", "时间压缩扩展"),+ ("Harmonic", "谐波"),+ ("Pitch Shift", "变调"),+ ("Other", "其他"),+ // Product names, channel formats and Ambisonics orders stay English on purpose: an entry+ // identical to its key marks that decision, and the coverage tests read it as "reviewed".+ ("AudioSuite", "AudioSuite"),+ ("Sibelius", "Sibelius"),+ ("Sibelius...", "Sibelius..."),+ ("Sketch...", "Sketch..."),+ ("Ableton Link", "Ableton Link"),+ ("SoundFlow", "SoundFlow"),+ ("Splice", "Splice"),+ ("Learn", "Learn"),+ ("Soundbase", "Soundbase"),+ ("HEAT", "HEAT"),+ ("Melodyne", "Melodyne"),+ ("BF-76", "BF-76"),+ ("Maxim", "Maxim"),+ ("Pitch II", "Pitch II"),+ ("Vari-Fi", "Vari-Fi"),+ ("D-Verb", "D-Verb"),+ ("Mod Delay III", "Mod Delay III"),+ ("Sci-Fi", "Sci-Fi"),+ ("Eleven Lite", "Eleven Lite"),+ ("Lo-Fi", "Lo-Fi"),+ ("Recti-Fi", "Recti-Fi"),+ ("SansAmp PSA-1", "SansAmp PSA-1"),+ ("Dyn3 Compressor/Limiter", "Dyn3 Compressor/Limiter"),+ ("Dyn3 De-Esser", "Dyn3 De-Esser"),+ ("Dyn3 Expander/Gate", "Dyn3 Expander/Gate"),+ ("EQ3 1-Band", "EQ3 1-Band"),+ ("EQ3 7-Band", "EQ3 7-Band"),+ ("QuickPunch", "QuickPunch"),+ ("TrackPunch", "TrackPunch"),+ ("DestructivePunch", "DestructivePunch"),+ ("MIDI", "MIDI"),+ ("MIDI...", "MIDI..."),+ ("MIDI Studio...", "MIDI Studio..."),+ ("EQ", "EQ"),+ ("LCR", "LCR"),+ ("LCRS", "LCRS"),+ ("1", "1"),+ ("2", "2"),+ ("3", "3"),+ ("4", "4"),+ ("5", "5"),+ ("5.0", "5.0"),+ ("5.0.2", "5.0.2"),+ ("5.0.4", "5.0.4"),+ ("5.1", "5.1"),+ ("5.1.2", "5.1.2"),+ ("5.1.4", "5.1.4"),+ ("6.0", "6.0"),+ ("6.1", "6.1"),+ ("7.0", "7.0"),+ ("7.0 SDDS", "7.0 SDDS"),+ ("7.0.2", "7.0.2"),+ ("7.0.4", "7.0.4"),+ ("7.0.6", "7.0.6"),+ ("7.1", "7.1"),+ ("7.1 SDDS", "7.1 SDDS"),+ ("7.1.2", "7.1.2"),+ ("7.1.4", "7.1.4"),+ ("7.1.6", "7.1.6"),+ ("9.0.4", "9.0.4"),+ ("9.0.6", "9.0.6"),+ ("9.1.4", "9.1.4"),+ ("9.1.6", "9.1.6"),+ ("1st Order Ambisonics", "1st Order Ambisonics"),+ ("2nd Order Ambisonics", "2nd Order Ambisonics"),+ ("3rd Order Ambisonics", "3rd Order Ambisonics"),+ ("4th Order Ambisonics", "4th Order Ambisonics"),+ ("5th Order Ambisonics", "5th Order Ambisonics"),+ ("6th Order Ambisonics", "6th Order Ambisonics"),+ ("7th Order Ambisonics", "7th Order Ambisonics"),+ // ── Options ──+ ("Destructive Record", "破坏性录音"),+ ("Prepare DPE Tracks", "准备 DPE 轨道"),+ ("Transport Online", "走带在线"),+ ("Video Track Online", "视频轨在线"),+ ("Pre/Post-Roll", "预卷/后卷"),+ ("Loop Playback", "循环播放"),+ ("Loop Record", "循环录音"),+ ("Dynamic Transport", "动态走带"),+ ("Edit Window Scrolling", "编辑窗口滚动"),+ ("No Scrolling", "不滚动"),+ ("After Playback", "播放后"),+ ("Page", "翻页"),+ ("Continuous", "连续"),+ ("Center Playhead", "播放头居中"),+ ("Link Timeline and Edit Selection", "链接时间线与编辑选区"),+ ("Link Track and Edit Selection", "链接轨道与编辑选区"),+ ("Insertion Follows Playback", "插入点跟随播放"),+ ("Tab to Transient", "跳到瞬态"),+ ("Mirrored MIDI Editing", "镜像 MIDI 编辑"),+ ("Automation Follows Edit", "自动化跟随编辑"),+ ("Markers Follow Edit", "标记跟随编辑"),+ ("Marker Target Follows First Selected Track", "标记目标跟随第一个所选轨道"),+ ("Layered Editing", "分层编辑"),+ ("Click", "节拍器"),+ ("MIDI Thru", "MIDI 直通"),+ ("Auto-Spot Clips", "自动定点放置剪辑"),+ ("Pre-Fader Metering", "推子前电平"),+ ("Solo Mode", "独奏模式"),+ ("SIP (Solo In Place)", "SIP(就地独奏)"),+ ("AFL (After Fader Listen)", "AFL(推子后监听)"),+ ("PFL (Pre Fader Listen)", "PFL(推子前监听)"),+ ("X-OR (Cancels Previous Solo)", "X-OR(取消上一个独奏)"),+ ("Latch", "锁存"),+ ("Momentary", "瞬时"),+ ("Edit/Tool Mode Keyboard Lock", "编辑/工具模式键盘锁定"),+ ("Calibration Mode", "校准模式"),+ ("MIDI Live Mode", "MIDI 实时模式"),+ ("Activate HEAT", "激活 HEAT"),+ ("Low Latency Monitoring", "低延迟监听"),+ // ── Setup ──+ ("Hardware...", "硬件..."),+ ("Playback Engine...", "播放引擎..."),+ ("Disk Allocation...", "磁盘分配..."),+ ("Peripherals...", "外围设备..."),+ ("I/O...", "输入/输出..."),+ ("Machine Track Arming Profiles...", "机器轨道布防配置..."),+ ("Video Sync Offset...", "视频同步偏移..."),+ ("Session", "会话"),+ ("Current Feet+Frames Position...", "当前英尺+帧位置..."),+ ("Current Timecode Position...", "当前时间码位置..."),+ ("External Timecode Offset...", "外部时间码偏移..."),+ ("Transcription Settings...", "转录设置..."),+ ("MIDI Beat Clock...", "MIDI 节拍时钟..."),+ ("MIDI Input Filter...", "MIDI 输入过滤器..."),+ ("MIDI Input Devices...", "MIDI 输入设备..."),+ ("Click/Countoff...", "节拍器/预备拍..."),+ ("Keyboard Shortcuts...", "键盘快捷键..."),+ ("Preferences...", "偏好设置..."),+ // ── Window ──+ ("Configurations", "配置"),+ ("Window Configuration List", "窗口配置列表"),+ ("New Configuration...", "新建配置..."),+ ("Update Active Configuration", "更新当前配置"),+ ("Auto-Update Active Configuration", "自动更新当前配置"),+ ("Arrange", "排列"),+ ("Tile", "平铺"),+ ("Tile Horizontal", "水平平铺"),+ ("Tile Vertical", "垂直平铺"),+ ("Cascade", "层叠"),+ ("Hide All Floating Windows", "隐藏所有浮动窗口"),+ ("Close Window", "关闭窗口"),+ ("Sketch to Front", "Sketch 置前"),+ ("MIDI Editor", "MIDI 编辑器"),+ ("MIDI Editors", "MIDI 编辑器"),+ ("Score Editor", "乐谱编辑器"),+ ("MIDI Event List", "MIDI 事件列表"),+ ("MIDI Keyboard", "MIDI 键盘"),+ ("Task Manager", "任务管理器"),+ ("New Workspace", "新建工作区"),+ ("Default", "默认"),+ ("Track Presets", "轨道预设"),+ ("Workspaces", "工作区"),+ ("Close All Workspaces", "关闭所有工作区"),+ ("Big Counter", "大计数器"),+ ("Memory Locations", "记忆位置"),+ ("Machine Track Arming", "机器轨道布防"),+ ("Video Universe", "视频总览"),+ ("Video", "视频"),+ ("Color Palette", "调色板"),+ ("UI Customization", "界面自定义"),+ ("Metadata Inspector", "元数据检查器"),+ ("Undo History", "撤销历史"),+ ("Transcript", "转录"),+ ("Pro Tools Search", "Pro Tools 搜索"),+ ("Renderer", "渲染器"),+ ("Trim and Downmix", "修剪与下混"),+ ("Collaboration", "协作"),+ ("Pro Tools Projects", "Pro Tools 项目"),+ ("Find Talent", "寻找人才"),+ ("Messages", "消息"),+ ("Disk Usage", "磁盘使用"),+ ("System Usage", "系统占用"),+ // Tree nodes the menu builder inserts beyond the catalog.+ ("Remove Sync Point", "移除同步点"),+ ("New MIDI Clip", "新建 MIDI 剪辑"),+ // ── UI command labels (crates/ui-egui/src/menus.rs UI_COMMANDS) ──+ ("Arrange Panel", "排列面板"),+ ("Reset Panel Layout", "重置面板布局"),+ ("Toggle Mix/Edit", "切换混音/编辑"),+ ("Mix Window View", "混音窗口视图"),+ ("New Tracks…", "新建轨道…"),+ ("Bounce Mix…", "并轨混音…"),+ ("Set UI State", "设置界面状态"),+ ("Appearance", "外观"),+ ("Audio Device", "音频设备"),+ ("I/O Setup", "输入/输出设置"),+ ("Search", "搜索"),+ ("New Configuration", "新建配置"),+ ("MIDI Editors to Front", "MIDI 编辑器置前"),+ ("MIDI Editors to Back", "MIDI 编辑器置后"),+ ("Workspaces to Front", "工作区置前"),+ ("Workspace", "工作区"),+ ("Playback Engine", "播放引擎"),+ ("Keyboard Shortcuts", "键盘快捷键"),+ // ── App menu, toolbar, transport ──+ ("About SoundCraft", "关于 SoundCraft"),+ ("Session Info", "会话信息"),+ ("Session Audio Health", "会话音频健康"),+ ("Quit", "退出"),+ ("Quit SoundCraft", "退出 SoundCraft"),+ ("Hide SoundCraft", "隐藏 SoundCraft"),+ ("Hide Others", "隐藏其他"),+ ("Language", "语言"),+ ("Processes selected clips offline", "离线处理所选剪辑"),+ ("Mix in Separate Window", "在独立窗口中混音"),+ ("Show Edit and Mix at once, e.g. one per monitor", "同时显示编辑窗口和混音窗口,例如各占一台显示器"),+ ("Instrument", "乐器"),+ ("Select an instrument track", "请选择一条乐器轨道"),+ ("Undo", "撤销"),+ ("Redo", "重做"),+ ("Zoom out (Cmd+[)", "缩小 (Cmd+[)"),+ ("Waveform zoom in (Cmd+Alt+])", "波形放大 (Cmd+Alt+])"),+ ("Zoom in (Cmd+])", "放大 (Cmd+])"),+ ("Fill window with session (Alt+A)", "窗口填满整个会话 (Alt+A)"),+ ("Zoom preset (click to recall, Cmd-click to store)", "缩放预设(点击调用,Cmd+点击存储)"),+ ("Zoom Toggle (E)", "缩放切换 (E)"),+ ("Smart Tool (F6+F7 / F7+F8)", "智能工具 (F6+F7 / F7+F8)"),+ ("Commands Keyboard Focus (single-key editing)", "命令键盘焦点(单键编辑)"),+ ("Zoomer", "缩放工具"),+ ("Selector", "选择器"),+ ("Grabber", "抓取器"),+ ("Scrubber", "搓擦工具"),+ ("Pencil", "铅笔"),+ ("Smart", "智能"),+ ("Start", "起点"),+ ("End", "终点"),+ ("Length", "长度"),+ ("Sub", "副"),+ ("modified", "已修改"),+ ("saved", "已保存"),+ ("no audio engine", "无音频引擎"),+ ("silent clock", "静音时钟"),+ ("audio on", "音频开启"),+ ("Grid", "网格"),+ ("Show grid lines", "显示网格线"),+ ("Nudge", "微调"),+ ("1 bar", "1 小节"),+ ("1/2 note", "1/2 音符"),+ ("1/4 note", "1/4 音符"),+ ("1/8 note", "1/8 音符"),+ ("1/16 note", "1/16 音符"),+ ("1/32 note", "1/32 音符"),+ ("1/64 note", "1/64 音符"),+ ("1 msec", "1 毫秒"),+ ("10 msec", "10 毫秒"),+ ("100 msec", "100 毫秒"),+ ("1 second", "1 秒"),+ ("1 frame", "1 帧"),+ ("100 samples", "100 采样"),+ ("Loop Playback (Cmd+Shift+L)", "循环播放 (Cmd+Shift+L)"),+ ("Stop (Space)", "停止 (空格)"),+ ("Play (Space)", "播放 (空格)"),+ ("Record (Cmd+Space)", "录音 (Cmd+空格)"),+ ("Return to Zero (Home)", "回到开头 (Home)"),+ ("Rewind", "快退"),+ ("Fast Forward", "快进"),+ ("Go to End (End)", "到结尾 (End)"),+ ("Count Off", "预备拍"),+ ("bars", "小节"),+ ("Drag to change the tempo (Shift: fine steps), or click to type it", "拖动更改速度(Shift:微调),或点击输入"),+ ("Open Recent", "打开最近使用"),+ ("Clear Menu", "清空菜单"),+ // ── Dialogs ──+ ("New Tracks", "新建轨道"),+ ("New Session", "新建会话"),+ ("Bounce Mix", "并轨混音"),+ ("Rename Track", "重命名轨道"),+ ("Rename Clip", "重命名剪辑"),+ ("Rename Marker", "重命名标记"),+ ("Tempo Change (BPM)", "速度变化 (BPM)"),+ ("Spot", "定点"),+ ("Create Group", "创建编组"),+ ("Modify Group", "修改编组"),+ ("Session Setup", "会话设置"),+ ("Revert to Saved", "恢复到已保存"),+ ("Unsaved Changes", "未保存的更改"),+ ("Import Audio", "导入音频"),+ ("Import MIDI", "导入 MIDI"),+ ("Open Session", "打开会话"),+ ("Save Session As", "会话另存为"),+ ("Repeat", "重复"),+ ("Shift (seconds)", "移动(秒)"),+ ("Transpose", "移调"),+ ("Quantize strength", "量化强度"),+ ("Create", "创建"),+ ("new", "条新"),+ ("in", "时间基准为"),+ ("Name:", "名称:"),+ ("Sample Rate", "采样率"),+ ("Start from the demo session", "从演示会话开始"),+ ("Browse…", "浏览…"),+ ("Format", "格式"),+ ("Bit Depth", "位深"),+ ("Normalize to -0.1 dBFS", "标准化到 -0.1 dBFS"),+ ("Bounces the edit selection, or the whole session when nothing is selected.", "并轨所选编辑选区;没有选区时并轨整个会话。"),+ ("Bounce", "并轨"),+ ("OK", "确定"),+ ("Cancel", "取消"),+ ("Remove", "移除"),+ ("Replace", "替换"),+ ("Save", "保存"),+ ("Don't Save", "不保存"),+ ("Revert", "恢复"),+ ("Strip", "去除"),+ ("Threshold dB", "阈值 dB"),+ ("Min strip duration ms", "最短去除时长(毫秒)"),+ ("Clip start pad ms", "剪辑起点预留(毫秒)"),+ ("Clip end pad ms", "剪辑终点预留(毫秒)"),+ ("Timecode Rate", "时间码帧率"),+ ("Title", "标题"),+ ("Composer", "作曲"),+ ("Bars per system", "每行小节数"),+ ("Show track names", "显示轨道名称"),+ ("Ticks", "滴答"),+ ("Audio Track", "音频轨"),+ ("Aux Input", "辅助输入"),+ ("Master Fader", "主输出推子"),+ ("MIDI Track", "MIDI 轨"),+ ("Instrument Track", "乐器轨"),+ ("VCA Master", "VCA 主控"),+ ("Routing Folder Track", "路由文件夹轨"),+ ("Linear", "线性"),+ ("Equal Power", "等功率"),+ ("S-Curve", "S 曲线"),+ ("Exponential", "指数"),+ ("Logarithmic", "对数"),+ ("times", "次"),+ ("semitones", "半音"),+ ("s", "秒"),+ ("Peak dB", "峰值 dB"),+ ("Length ratio", "长度比例"),+ ("Semitones", "半音"),+ ("Speed", "速度"),+ ("No parameters.", "无参数。"),+ ("Fills the selection on the selected tracks with a new clip.", "在所选轨道的选区内生成一个新剪辑。"),+ ("Processes the selected clips and replaces them with rendered audio.", "处理所选剪辑并用渲染后的音频替换。"),+ ("Edit group", "编辑组"),+ ("Mix group", "混音组"),+ ("Tracks", "轨道"),+ ("Your changes will be lost.", "更改将会丢失。"),+ // ── Templates ({name} placeholders, filled by `tf`) ──+ ("Group {n}", "编组 {n}"),+ ("Shuffle is locked ({cmd}-click to unlock)", "Shuffle 已锁定({cmd}+点击解锁)"),+ ("Shuffle mode ({fkey}); {cmd}-click locks it out", "Shuffle 模式({fkey});{cmd}+点击锁定该模式"),+ (+ "Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes",+ "Grid 模式({fkey};再次点击为 Relative);Shift+点击:在其他模式中吸附到网格",+ ),+ ("{mode} mode ({fkey}); Shift-click adds Snap to Grid", "{mode} 模式({fkey});Shift+点击添加吸附到网格"),+ ("{bars} bars", "{bars} 小节"),+ ("Undo {step}", "撤销 {step}"),+ ("Redo {step}", "重做 {step}"),+ ("Configuration {n}", "配置 {n}"),+ ("Save changes to “{name}” before closing?", "关闭前保存对“{name}”的更改?"),+ ("Revert “{name}” to its last saved version?", "将“{name}”恢复到上次保存的版本?"),+ ("Close “{name}” without saving?", "关闭“{name}”而不保存?"),+ ("“{file}” already exists. Do you want to replace it?", "“{file}”已存在。要替换它吗?"),+ ("Panel windows could not be arranged: {error}", "面板窗口无法排列:{error}"),+];++use std::collections::BTreeMap;+use std::sync::OnceLock;++/// The table as a map, built once on first use. Duplicate keys would silently collapse, so the+/// lib tests reject them.+fn table() -> &'static BTreeMap<&'static str, &'static str> {+ static TABLE: OnceLock<BTreeMap<&'static str, &'static str>> = OnceLock::new();+ TABLE.get_or_init(|| ENTRIES.iter().copied().collect())+}++/// The 简体中文 translation of the English source string `s`, if it has one.+pub fn get(s: &str) -> Option<&'static str> {+ table().get(s).copied()+}@@ -9,6 +9,7 @@ repository.workspace = true [dependencies] craft-ui.workspace = true+soundcraft-l10n.workspace = true soundcraft-time.workspace = true soundcraft-audio-io.workspace = true soundcraft-midi.workspace = true@@ -9,7 +9,8 @@ //! - `ui.click {x,y,button?,count?,mods?}`, `ui.move {x,y}`, `ui.drag {x,y,to_x,to_y,steps?}` //! - `ui.scroll {x,y,dx?,dy?,unit?,steps?,mods?}` (trackpad swipe or wheel), `ui.zoom {x,y,factor,steps?}` (pinch) //! - `ui.key {key, cmd?, shift?, alt?, ctrl?}`, `ui.text {text}`-//! - `ui.screenshot {path?}` (PNG; base64 when no path), `ui.set {...UiState fields}`+//! - `ui.screenshot {path?}` (PNG; base64 when no path), `ui.set {...UiState fields}`,+//! `ui.language {language?}` //! - `app.quit` use crate::SoundApp;@@ -154,6 +155,10 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti Ok(v) => ok(v), Err(e) => err(e), },+ "ui.language" => match app.run("ui.language", p.clone()) {+ Ok(v) => ok(v),+ Err(e) => err(e),+ }, "ui.move" => { app.synthetic.push(egui::Event::PointerMoved(pos)); ok(json!({}))@@ -5,6 +5,7 @@ use crate::theme::{Tokens, bold}; use egui::{Align2, vec2}; use serde_json::{Value, json}; use soundcraft_engine::Engine;+use soundcraft_l10n::{t as tr, td, tf}; use soundcraft_model::{ClipId, GroupId, Session, SourceId, TrackId}; use soundcraft_time::Samples; @@ -145,7 +146,8 @@ impl Dialogs { "track.group" => { let s = e.session(); let members = s.tracks.iter().map(|t| (t.id.0, t.name.clone(), s.edit.selected_tracks.contains(&t.id))).collect();- Dialog::Group { id: None, name: format!("Group {}", s.groups.len() + 1), edit: true, mix: true, members }+ let n = s.groups.len() + 1;+ Dialog::Group { id: None, name: tf("Group {n}", &[("n", &n.to_string())]).into_owned(), edit: true, mix: true, members } } "setup.session" => Dialog::Session { frame_rate: e.session().frame_rate.label().into(), bit_depth: "24".into() }, "file.score_setup" => {@@ -303,7 +305,8 @@ fn default_dir() -> String { } } -/// Show the open dialog, if any.+/// Show the open dialog, if any. Titles and buttons are translated at display time; the+/// `Dialog` payload keeps the English source strings so a language switch can't corrupt values. pub fn show(app: &mut SoundApp, ctx: &egui::Context) { audiosuite_window(app, ctx); crate::bounce::show(app, ctx);@@ -331,8 +334,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::ScoreSetup { .. } => "Score Setup", Dialog::SaveChanges { then } if then.0 == "session.revert" => "Revert to Saved", Dialog::SaveChanges { .. } => "Unsaved Changes",- }- .to_string();+ };+ let title = td(title).into_owned(); let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter)); let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape)); egui::Window::new(egui::RichText::new(&title).font(bold(13.0)))@@ -344,52 +347,52 @@ 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(tr("Create")); ui.add(egui::DragValue::new(count).range(1..=128));- ui.label("new");+ ui.label(tr("new")); egui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| { for f in soundcraft_model::ChannelFormat::ALL {- ui.selectable_value(format, f.label().to_string(), f.label());+ ui.selectable_value(format, f.label().to_string(), tr(f.label())); } }); 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(tr("Audio Track"), |k| tr(k.label()))) .show_ui(ui, |ui| { for k in soundcraft_model::TrackKind::ALL {- if ui.selectable_label(kind == k.id(), k.label()).clicked() {+ if ui.selectable_label(kind == k.id(), tr(k.label())).clicked() { *kind = k.id().to_string(); *name = k.default_name().to_string(); } } });- ui.label("in");- egui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui(+ ui.label(tr("in"));+ egui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { tr("Ticks") } else { tr("Samples") }).show_ui( ui, |ui| {- ui.selectable_value(timebase, "samples".to_string(), "Samples");- ui.selectable_value(timebase, "ticks".to_string(), "Ticks");+ ui.selectable_value(timebase, "samples".to_string(), tr("Samples"));+ ui.selectable_value(timebase, "ticks".to_string(), tr("Ticks")); }, );- ui.label("Name:");+ ui.label(tr("Name:")); ui.add(egui::TextEdit::singleline(name).desired_width(140.0)); });- if buttons(ui, "Create", enter) {+ if buttons(ui, tr("Create"), enter) { action = Some(("track.new".into(), json!({"count": count, "format": format, "kind": kind, "timebase": timebase, "name": name}))); } } Dialog::NewSession { name, sample_rate, demo } => { ui.horizontal(|ui| {- ui.label("Name");+ ui.label(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(tr("Sample Rate")).selected_text(format!("{sample_rate} Hz")).show_ui(ui, |ui| { for r in soundcraft_time::SampleRate::COMMON { ui.selectable_value(sample_rate, r, format!("{r} Hz")); } });- crate::widgets::checkbox(ui, demo, "Start from the demo session");- if buttons(ui, "Create", enter) {+ crate::widgets::checkbox(ui, demo, tr("Start from the demo session"));+ if buttons(ui, tr("Create"), enter) { action = Some(( "session.new".into(), json!({"name": name, "sample_rate": sample_rate, "template": if *demo { "demo" } else { "blank" }}),@@ -398,10 +401,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } Dialog::Bounce { path, format, bit_depth, normalize } => { ui.horizontal(|ui| {- ui.label("File");+ ui.label(tr("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(tr("Browse…")).clicked() && let Some(p) = pick("file.bounce_mix", path) { *path = p;@@ -414,24 +417,24 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } }); ui.horizontal(|ui| {- egui::ComboBox::from_label("Format").selected_text(format.to_uppercase()).show_ui(ui, |ui| {+ egui::ComboBox::from_label(tr("Format")).selected_text(format.to_uppercase()).show_ui(ui, |ui| { for f in ["wav", "aiff", "flac"] { 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(tr("Bit Depth")).selected_text(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")); } }); });- crate::widgets::checkbox(ui, normalize, "Normalize to -0.1 dBFS");+ crate::widgets::checkbox(ui, normalize, tr("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(tr("Bounces the edit selection, or the whole session when nothing is selected.")) .small() .color(Tokens::current().text_dim), );- if buttons(ui, "Bounce", enter) {+ if buttons(ui, tr("Bounce"), enter) { let p = std::path::Path::new(path.as_str()).with_extension(if format == "aiff" { "aif" } else { format.as_str() }); crate::bounce::start( app,@@ -442,26 +445,26 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } Dialog::RenameTrack { id, name } => { name_field(ui, name);- if buttons(ui, "OK", enter) {+ if buttons(ui, tr("OK"), enter) { action = Some(("track.rename".into(), json!({"track": id.0, "name": name}))); } } Dialog::RenameClip { id, name } => { name_field(ui, name);- if buttons(ui, "OK", enter) {+ if buttons(ui, tr("OK"), enter) { action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name}))); } } Dialog::RenameMarker { id, name } => { name_field(ui, name);- if buttons(ui, "OK", enter) {+ if buttons(ui, tr("OK"), enter) { action = Some(("markers.edit".into(), json!({"id": id.0, "rename": name}))); } } Dialog::ClearClips { clips, sources, message } => { ui.set_max_width(340.0); ui.label(message.as_str());- if buttons(ui, "Remove", enter) {+ if buttons(ui, tr("Remove"), enter) { action = Some(("clip.clear".into(), json!({"clips": clips, "sources": sources, "with_clips": true}))); } }@@ -471,21 +474,21 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { && cmd != "session.save_as" && cmd != "session.save_copy" && key != "dir"- && ui.button("Browse…").clicked()+ && ui.button(tr("Browse…")).clicked() && let Some(p) = pick(cmd, &[]) { *path = p; } if let Some(pick) = &app.services.pick_folder && key == "dir"- && ui.button("Browse…").clicked()+ && ui.button(tr("Browse…")).clicked() && let Some(p) = pick(path) { *path = p; } if let Some(pick) = &app.services.pick_save && (cmd == "session.save_as" || cmd == "session.save_copy")- && ui.button("Browse…").clicked()+ && ui.button(tr("Browse…")).clicked() && let Some(p) = pick(cmd, path) { *path = p;@@ -493,9 +496,12 @@ 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(tf(+ "“{file}” already exists. Do you want to replace it?",+ &[("file", &file.file_name().unwrap_or_default().to_string_lossy())],+ )); }- if buttons(ui, if replaces { "Replace" } else { "OK" }, enter && !replaces) {+ if buttons(ui, if replaces { tr("Replace") } else { tr("OK") }, enter && !replaces) { let mut v = json!({}); v[key.as_str()] = json!(path); if replaces {@@ -507,9 +513,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Number { cmd, key, value, suffix, .. } => { ui.horizontal(|ui| { ui.add(egui::DragValue::new(value).speed(0.1));- ui.label(suffix.as_str());+ ui.label(td(suffix)); });- if buttons(ui, "OK", enter) {+ if buttons(ui, tr("OK"), enter) { let v = if key == "by_seconds" { json!({"by": {"seconds": value}}) } else { json!({key.as_str(): value}) }; action = Some((cmd.clone(), v)); }@@ -520,31 +526,31 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ui.add(egui::DragValue::new(bpm).speed(0.1).range(5.0..=1000.0)); ui.label("bpm"); });- if buttons(ui, "OK", enter) {+ if buttons(ui, tr("OK"), enter) { action = Some(tempo_change_action(*at, *bpm)); } } Dialog::Spot(d) => { crate::spot::fields(ui, app.engine.session(), d);- if buttons(ui, "OK", enter) {+ if buttons(ui, tr("OK"), enter) { keep = false; action = d.command(app.engine.session()); } } Dialog::Fades { shape } => { for s in soundcraft_model::FadeShape::ALL {- ui.radio_value(shape, s.label().to_lowercase(), s.label());+ ui.radio_value(shape, s.label().to_lowercase(), tr(s.label())); }- if buttons(ui, "OK", enter) {+ if buttons(ui, tr("OK"), enter) { action = Some(("edit.fades_create".into(), json!({"shape": shape}))); } } 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"));- if buttons(ui, "Strip", enter) {+ ui.add(egui::Slider::new(threshold, -96.0..=0.0).text(tr("Threshold dB")));+ ui.add(egui::Slider::new(min_ms, 0.0..=2000.0).text(tr("Min strip duration ms")));+ ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text(tr("Clip start pad ms")));+ ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text(tr("Clip end pad ms")));+ if buttons(ui, tr("Strip"), enter) { action = Some(( "edit.strip_silence".into(), json!({"threshold_db": threshold, "min_length_ms": min_ms, "pad_before_ms": pre_ms, "pad_after_ms": post_ms}),@@ -553,14 +559,14 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } Dialog::Group { id, name, edit, mix, members } => { ui.horizontal(|ui| {- ui.label("Name");+ ui.label(tr("Name")); ui.text_edit_singleline(name); }); ui.horizontal(|ui| {- crate::widgets::checkbox(ui, edit, "Edit group");- crate::widgets::checkbox(ui, mix, "Mix group");+ crate::widgets::checkbox(ui, edit, tr("Edit group"));+ crate::widgets::checkbox(ui, mix, tr("Mix group")); });- ui.label("Tracks");+ ui.label(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());@@ -568,7 +574,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { }); let ids: Vec<u64> = members.iter().filter(|m| m.2).map(|m| m.0).collect(); // Both commands reject an empty group, so don't offer an OK that can only fail.- if buttons_enabled(ui, "OK", enter, !ids.is_empty()) {+ if buttons_enabled(ui, tr("OK"), enter, !ids.is_empty()) { let mut params = json!({"name": name, "edit": edit, "mix": mix, "tracks": ids}); let cmd = match id { Some(g) => {@@ -581,34 +587,34 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } Dialog::Session { frame_rate, bit_depth } => {- egui::ComboBox::from_label("Timecode Rate").selected_text(frame_rate.as_str()).show_ui(ui, |ui| {+ egui::ComboBox::from_label(tr("Timecode Rate")).selected_text(frame_rate.as_str()).show_ui(ui, |ui| { for r in soundcraft_time::FrameRate::ALL { ui.selectable_value(frame_rate, r.label().to_string(), r.label()); } });- egui::ComboBox::from_label("Bit Depth").selected_text(bit_depth.as_str()).show_ui(ui, |ui| {+ egui::ComboBox::from_label(tr("Bit Depth")).selected_text(bit_depth.as_str()).show_ui(ui, |ui| { for b in ["16", "24", "32f"] { ui.selectable_value(bit_depth, b.to_string(), b); } });- if buttons(ui, "OK", enter) {+ if buttons(ui, tr("OK"), enter) { action = Some(("setup.session".into(), json!({"frame_rate": frame_rate, "bit_depth": bit_depth}))); } } 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(tr("Title")); ui.text_edit_singleline(title); ui.end_row();- ui.label("Composer");+ ui.label(tr("Composer")); ui.text_edit_singleline(composer); ui.end_row();- ui.label("Bars per system");+ ui.label(tr("Bars per system")); ui.add(egui::DragValue::new(bars_per_system).range(1..=16)); ui.end_row(); });- crate::widgets::checkbox(ui, show_track_names, "Show track names");- if buttons(ui, "OK", enter) {+ crate::widgets::checkbox(ui, show_track_names, tr("Show track names"));+ if buttons(ui, tr("OK"), enter) { action = Some(( "file.score_setup".into(), json!({"title": title, "composer": composer, "bars_per_system": bars_per_system, "show_track_names": show_track_names}),@@ -616,26 +622,26 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { } } Dialog::SaveChanges { then } => {- let name = &app.engine.session().name;+ let name = app.engine.session().name.clone(); 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")+ (tf("Revert “{name}” to its last saved version?", &[("name", &name)]).into_owned(), tr("Revert")) } else {- (format!("Close “{name}” without saving?"), "Don't Save")+ (tf("Close “{name}” without saving?", &[("name", &name)]).into_owned(), tr("Don't Save")) };- ui.label(format!("{question} Your changes will be lost."));+ ui.label(format!("{question} {}", tr("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(tf("Save changes to “{name}” before closing?", &[("name", &name)])); ui.separator(); ui.horizontal(|ui| {- if ui.button("Don't Save").clicked() {+ if ui.button(tr("Don't Save")).clicked() { action = Some(then.clone()); }- if buttons(ui, "Save", enter) {+ if buttons(ui, tr("Save"), enter) { save_first = Some(then.clone()); } });@@ -705,7 +711,7 @@ fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> boo let mut pressed = enter && ready; ui.separator(); ui.horizontal(|ui| {- if ui.button("Cancel").clicked() {+ if ui.button(tr("Cancel")).clicked() { ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::new("dlg_cancel"), true)); } let b = egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent);@@ -732,6 +738,7 @@ fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) { ("event.tempo".into(), json!({"bpm": bpm, "at": at})) } +/// The AudioSuite process window: one slider per plugin parameter, a hint line, and Render. fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { let Some(process) = app.ui.audiosuite.clone() else { return }; let mut open = true;@@ -748,33 +755,33 @@ 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(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(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(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(tr("Speed"))); } _ => {- ui.label("No parameters.");+ ui.label(tr("No parameters.")); } } } ui.ctx().memory_mut(|m| m.data.insert_temp(key, params.clone())); let hint = if soundcraft_dsp::is_generator(&process) {- "Fills the selection on the selected tracks with a new clip."+ tr("Fills the selection on the selected tracks with a new clip.") } else {- "Processes the selected clips and replaces them with rendered audio."+ tr("Processes the selected clips and replaces them with rendered audio.") }; ui.label(egui::RichText::new(hint).small());- if ui.button(egui::RichText::new("Render").strong()).clicked() {+ if ui.button(egui::RichText::new(tr("Render")).strong()).clicked() { let p: serde_json::Map<String, Value> = params.iter().map(|(k, v)| (k.clone(), json!(v))).collect(); let _ = app.run("audiosuite.process", json!({"process": process, "params": p})); }@@ -42,6 +42,24 @@ const HANGUL: &[(&str, u32)] = &[ ("/usr/share/fonts/truetype/nanum/NanumGothic.ttf", 0), ("/usr/share/fonts/naver-nanum/NanumGothic.ttf", 0), ];+/// Simplified Chinese fallback, appended *before* the Hangul one so Han glyphs take the+/// Simplified style (a Korean face renders them too, but with Korean glyph variants). Face 2 of+/// the Noto Sans CJK collection is its Simplified Chinese face.+const CJK_SC: &[(&str, u32)] = &[+ ("/System/Library/Fonts/PingFang.ttc", 0),+ ("/System/Library/Fonts/Hiragino Sans GB.ttc", 0),+ ("C:\\Windows\\Fonts\\msyh.ttc", 0),+ ("C:\\Windows\\Fonts\\msyh.ttf", 0),+ ("C:\\Windows\\Fonts\\simsun.ttc", 0),+ ("C:\\Windows\\Fonts\\simhei.ttf", 0),+ ("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 2),+ ("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", 2),+ ("/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", 2),+ ("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", 0),+ ("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", 0),+ ("/usr/share/fonts/wenquanyi/wqy-microhei/wqy-microhei.ttc", 0),+ ("/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", 0),+]; /// Font files larger than this are skipped: egui keeps the whole file in memory. #[cfg(not(target_arch = "wasm32"))] const MAX_FONT_BYTES: u64 = 64 << 20;@@ -108,6 +126,8 @@ fn load(_: &[(&str, u32)], _: &str, _: Option<&str>) -> Option<FontData> { None } +/// egui's bundled fonts plus the system's: the desktop UI font for regular and bold, then the+/// Simplified Chinese and Hangul fallbacks appended to every family. pub fn definitions() -> FontDefinitions { let mut fonts = FontDefinitions::default(); let base: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();@@ -125,6 +145,15 @@ pub fn definitions() -> FontDefinitions { bold_family.insert(0, "system-regular".into()); } fonts.families.insert(FontFamily::Name("bold".into()), bold_family);+ // Simplified Chinese before Hangul, so Han glyphs prefer the Simplified face (see CJK_SC).+ // The lang check is exact: a Traditional-only face would render the simplified interface+ // with Traditional glyph variants; such systems fall through to the candidate list below.+ if let Some(c) = load(CJK_SC, "sans-serif:lang=zh-cn", Some("zh-cn")) {+ fonts.font_data.insert("system-cjk".into(), Arc::new(c));+ for family in fonts.families.values_mut() {+ family.push("system-cjk".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() {@@ -151,6 +180,15 @@ mod tests { assert!(HANGUL.iter().filter(|(p, _)| p.ends_with("NotoSansCJK-Regular.ttc")).all(|(_, i)| *i == 1)); } + #[test]+ fn cjk_candidates_cover_every_desktop_platform() {+ for prefix in ["/System/Library/Fonts/", "C:\\Windows\\Fonts\\", "/usr/share/fonts/"] {+ assert!(CJK_SC.iter().any(|(p, _)| p.starts_with(prefix)), "no Simplified Chinese candidate under {prefix}");+ }+ // The Korean face is 1 and the Japanese face 0; the Simplified Chinese face is 2.+ assert!(CJK_SC.iter().filter(|(p, _)| p.ends_with("NotoSansCJK-Regular.ttc")).all(|(_, i)| *i == 2));+ }+ /// Korean names draw in every family, not as boxes; skipped when the system has no Korean font. #[test] fn every_family_draws_hangul_with_a_system_korean_font() {@@ -169,4 +207,24 @@ mod tests { assert!(ctx.fonts_mut(|f| f.has_glyphs(&font, "가한국어세션")), "{family:?} has no Hangul"); } }++ /// Chinese names and the translated interface draw in every family, not as boxes; skipped+ /// when the system has no Simplified Chinese font.+ #[test]+ fn every_family_draws_chinese_with_a_system_cjk_font() {+ let defs = definitions();+ if !defs.font_data.contains_key("system-cjk") {+ eprintln!("skipping: no Simplified Chinese system font");+ return;+ }+ let families: Vec<FontFamily> = defs.families.keys().cloned().collect();+ let ctx = egui::Context::default();+ ctx.set_fonts(defs);+ let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});+ output.textures_delta.clear();+ for family in families {+ let font = egui::FontId::new(13.0, family.clone());+ assert!(ctx.fonts_mut(|f| f.has_glyphs(&font, "并轨混音轨道剪")), "{family:?} has no Chinese");+ }+ } }@@ -121,6 +121,8 @@ pub struct UiState { pub configurations: Vec<(String, Value)>, /// Appearance (`ui.theme`): dark by default, light or follow the system on request. pub theme: theme::ThemeMode,+ /// Interface language (`ui.language`): a BCP 47 id ("en", "zh-Hans"), English until changed.+ pub language: String, /// The main window's size (logical points) and maximized state, and the interface scale, as /// the desktop app last saw them; it restores them at launch. pub window_size: [f32; 2],@@ -129,6 +131,8 @@ pub struct UiState { } impl Default for UiState {+ /// The interface out of the box: Edit window with the track and clip lists, English, dark+ /// theme, 1× scale. fn default() -> Self { UiState { recent_sessions: Vec::new(),@@ -183,6 +187,7 @@ impl Default for UiState { workspace_dir: String::new(), configurations: Vec::new(), theme: theme::ThemeMode::Dark,+ language: "en".into(), window_size: [1600.0, 1000.0], window_maximized: false, ui_scale: 1.0,@@ -1228,6 +1233,10 @@ impl SoundApp { let dt = self.last_frame.replace(now).map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25); self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1; control::drain(self, ctx);+ // The catalog reads the global, so follow the state even when it was patched (`ui.set`,+ // a restored ui.json) rather than set through `ui.language` — after the drain above, so+ // a request that just landed applies to this frame's draw and menu rebuild check.+ soundcraft_l10n::set_current(soundcraft_l10n::Language::parse(&self.ui.language).unwrap_or_default()); self.note_recent_session(); self.apply_editor_edits(); bounce::step(self, ctx);@@ -4,6 +4,7 @@ use crate::{MainWindow, SoundApp}; use serde_json::{Value, json}; use soundcraft_engine::catalog;+use soundcraft_l10n::{Language, t as tr, td, tf}; /// UI-layer commands: (id, label, catalog path or "", shortcut). pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[@@ -220,6 +221,8 @@ pub fn tree() -> Vec<MenuNode> { roots } +/// The in-window menu bar: the SoundCraft app menu (About, Language, Quit) followed by the+/// incumbent's catalog tree, translated at display time. pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::current(); egui::Panel::top("menu_bar").exact_size(24.0).frame(egui::Frame::NONE.fill(t.menu_bar).inner_margin(egui::Margin::symmetric(8, 2))).show(@@ -227,23 +230,34 @@ 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 ui.button(tr("About SoundCraft")).clicked() { app.ui.show_about = true; }- if ui.button("Session Info").clicked() {+ if ui.button(tr("Session Info")).clicked() { app.ui.show_session_info = true; }- if ui.button("Session Audio Health").clicked() {+ if ui.button(tr("Session Audio Health")).clicked() { let _ = app.run("window.audio_health", json!({"value": true})); } ui.separator();- if ui.button("Quit").clicked() {+ ui.menu_button(tr("Language"), |ui| {+ let current = soundcraft_l10n::current();+ for lang in Language::ALL {+ let label = if current == lang { format!("✔ {}", lang.native_name()) } else { lang.native_name().to_string() };+ if ui.button(label).clicked() {+ let _ = run_ui_command(app, "ui.language", &json!({"language": lang.id()}));+ ui.close();+ }+ }+ });+ ui.separator();+ if ui.button(tr("Quit")).clicked() { let _ = crate::dialogs::run_or_ask(app, "app.quit", json!({})); } }); let extra = ui_aliases(); for root in tree() {- ui.menu_button(&root.label, |ui| {+ ui.menu_button(td(&root.label), |ui| { for c in &root.children { menu_node(app, ui, c, &extra); if c.path == "File > Open" {@@ -255,14 +269,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(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, tr("Mix in Separate Window"))+ .on_hover_text(tr("Show Edit and Mix at once, e.g. one per monitor")) .changed() { let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));@@ -275,7 +289,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 { tr("Edit") } else { tr("Mix") } ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(egui::RichText::new(title).color(t.text_dim));@@ -289,10 +303,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(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(tr("Instrument"))).on_disabled_hover_text(tr("Select an instrument track")); } } }@@ -302,9 +316,11 @@ fn instrument_track(s: &soundcraft_model::Session) -> Option<soundcraft_model::T s.tracks.iter().find(|t| t.kind == soundcraft_model::TrackKind::Instrument && s.edit.selected_tracks.contains(&t.id)).map(|t| t.id) } +/// One menu entry: submenus recurse, leaves resolve their command through the English catalog+/// path and show the translated label. 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(td(&n.label), |ui| { for c in &n.children { menu_node(app, ui, c, extra); }@@ -313,7 +329,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(|| td(&n.label).into_owned()); 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()));@@ -334,7 +350,9 @@ pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option<String> { pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) { let enabled = match id { Some(i)- if i.starts_with("window.") || i.starts_with("view.mix_section") || matches!(i, "audiosuite.process" | "ui.dock" | "ui.dock.reset") =>+ if i.starts_with("window.")+ || i.starts_with("view.mix_section")+ || matches!(i, "audiosuite.process" | "ui.dock" | "ui.dock.reset" | "ui.language") => { true }@@ -344,6 +362,8 @@ pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) (enabled, checked_state(app, path, id)) } +/// Whether the item shows a check mark. Match keys are English paths and command ids; the+/// native Language items carry the language id in `path`. fn checked_state(app: &SoundApp, path: &str, id: Option<&str>) -> bool { let e = &app.engine.session().edit; match id {@@ -371,6 +391,9 @@ fn checked_state(app: &SoundApp, path: &str, id: Option<&str>) -> bool { Some("window.ui_customization") => app.extra.show_ui_customization, Some("window.config_auto_update") => app.extra.auto_update_config, Some("view.mix_section") => path.rsplit(" > ").next().map(mix_section_id).is_some_and(|s| app.ui.mix_views.iter().any(|v| v == s)),+ // Native Language items carry the language id in `path`; both sides parse, so a stored+ // alias (`zh-CN`) still marks the zh-Hans item.+ Some("ui.language") => Language::parse(path) == Language::parse(&app.ui.language), _ => false, } }@@ -455,8 +478,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(|| tr("Can't Undo").to_string(), |l| tf("Undo {step}", &[("step", l)]).into_owned())),+ "edit.redo" => Some(e.redo_label().map_or_else(|| tr("Can't Redo").to_string(), |l| tf("Redo {step}", &[("step", l)]).into_owned())), _ => None, } }@@ -603,13 +626,13 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result< } } else { let n = app.ui.configurations.len() + 1;- app.ui.configurations.push((format!("Configuration {n}"), v));+ app.ui.configurations.push((tf("Configuration {n}", &[("n", &n.to_string())]).into_owned(), v)); } json!({"configurations": app.ui.configurations.len()}) } "window.arrange_tile" | "window.arrange_tile_h" | "window.arrange_tile_v" | "window.arrange_cascade" => { if let Err(error) = crate::docking::arrange(app) {- app.ui.status = format!("Panel windows could not be arranged: {error}");+ app.ui.status = tf("Panel windows could not be arranged: {error}", &[("error", &error)]).into_owned(); } app.arrange_request = true; json!({})@@ -678,6 +701,20 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result< app.ui.theme = mode; json!({"mode": mode.id()}) }+ "ui.language" => {+ if let Some(id) = p.get("language").and_then(Value::as_str) {+ let Some(lang) = soundcraft_l10n::Language::parse(id) else {+ let ids = Language::ALL.iter().map(|l| l.id()).collect::<Vec<_>>().join(" or ");+ return Some(Err(format!("unknown language `{id}` ({ids})")));+ };+ app.ui.language = lang.id().into();+ soundcraft_l10n::set_current(lang);+ }+ json!({+ "language": app.ui.language,+ "languages": Language::ALL.iter().map(|l| json!({"id": l.id(), "name": l.native_name()})).collect::<Vec<_>>(),+ })+ } "ui.set" => match serde_json::to_value(&app.ui) { Ok(mut cur) => { if let (Some(obj), Some(patch)) = (cur.as_object_mut(), p.as_object()) {@@ -715,6 +752,25 @@ mod tests { use super::*; use crate::{Services, SoundApp}; + /// Every node of the menu tree, and every UI command label, has a zh-Hans translation.+ /// A new menu entry without one fails here — add it to `soundcraft-l10n`'s zh_hans table.+ #[test]+ fn menu_tree_and_ui_commands_are_translated_for_zh_hans() {+ let zh = soundcraft_l10n::Language::SimplifiedChinese;+ let roots = tree();+ let mut stack: Vec<&MenuNode> = roots.iter().collect();+ let mut seen: Vec<&str> = Vec::new();+ while let Some(n) = stack.pop() {+ assert!(zh.covers(&n.label), "menu node {:?} has no zh-Hans translation", n.label);+ seen.push(&n.label);+ stack.extend(n.children.iter());+ }+ assert!(seen.len() > 400, "the menu tree shrunk? only {} nodes", seen.len());+ for (_, label, _, _) in UI_COMMANDS {+ assert!(zh.covers(label), "UI command label {label:?} has no zh-Hans translation");+ }+ }+ #[test] fn mix_view_aliases_are_allocated_once() { let first = ui_aliases();@@ -6,6 +6,7 @@ use crate::{SoundApp, icons}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_engine::edit::{SHUFFLE_LOCK, SNAP_TO_GRID};+use soundcraft_l10n::{t as tr, tf}; use soundcraft_model::{EditMode, Tool}; use soundcraft_time::{TimeFormat, format_length}; @@ -51,10 +52,13 @@ struct ModeCellState { fn edit_mode_tooltip(m: EditMode, locked: bool, cmd: &str, fkey: &str) -> String { 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 => tf("Shuffle is locked ({cmd}-click to unlock)", &[("cmd", cmd)]).into_owned(),+ EditMode::Shuffle => tf("Shuffle mode ({fkey}); {cmd}-click locks it out", &[("fkey", fkey), ("cmd", cmd)]).into_owned(),+ EditMode::Grid => tf("Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes", &[("fkey", fkey)]).into_owned(),+ _ => {+ tf("{mode} mode ({fkey}); Shift-click adds Snap to Grid", &[("mode", if m == EditMode::Slip { "Slip" } else { "Spot" }), ("fkey", fkey)])+ .into_owned()+ } } } @@ -73,6 +77,8 @@ fn draw_mode_cell(p: &egui::Painter, cell: Rect, resp: &egui::Response, t: &Toke } } +/// The Shuffle/Spot/Slip/Grid mode keypad. Labels stay English in every language, like the+/// incumbent's; the hover text carries the translation and the mode's function key. fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let ed = &app.engine.session().edit;@@ -123,22 +129,23 @@ fn mode_click(app: &mut SoundApp, m: EditMode, mods: egui::Modifiers) { }; } +/// Zoom out, waveform zoom, zoom in, fit-to-window, and the five zoom presets below them. fn zoom_group(app: &mut SoundApp, ui: &mut Ui) { group(ui, 128.0, 52.0, |ui, _| { ui.vertical(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 3.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0;- if icon_button(ui, vec2(28.0, 22.0), "minus", false, "Zoom out (Cmd+[)").clicked() {+ if icon_button(ui, vec2(28.0, 22.0), "minus", false, tr("Zoom out (Cmd+[)")).clicked() { let _ = app.run("view.zoom_out", json!({})); }- if icon_button(ui, vec2(30.0, 22.0), "wave", false, "Waveform zoom in (Cmd+Alt+])").clicked() {+ if icon_button(ui, vec2(30.0, 22.0), "wave", false, tr("Waveform zoom in (Cmd+Alt+])")).clicked() { let _ = app.run("view.waveform_zoom", json!({"factor": 2.0})); }- if icon_button(ui, vec2(28.0, 22.0), "plus", false, "Zoom in (Cmd+])").clicked() {+ if icon_button(ui, vec2(28.0, 22.0), "plus", false, tr("Zoom in (Cmd+])")).clicked() { let _ = app.run("view.zoom_in", json!({})); }- if icon_button(ui, vec2(28.0, 22.0), "zoom", false, "Fill window with session (Alt+A)").clicked() {+ if icon_button(ui, vec2(28.0, 22.0), "zoom", false, tr("Fill window with session (Alt+A)")).clicked() { let w = ui.ctx().content_rect().width() - 420.0; let _ = app.run("view.zoom_fit", json!({"width_px": w})); }@@ -152,7 +159,7 @@ fn zoom_group(app: &mut SoundApp, ui: &mut Ui) { &i.to_string(), false, Color32::GRAY,- "Zoom preset (click to recall, Cmd-click to store)",+ tr("Zoom preset (click to recall, Cmd-click to store)"), ); if r.clicked() { let store = ui.input(|x| x.modifiers.command);@@ -164,6 +171,8 @@ fn zoom_group(app: &mut SoundApp, ui: &mut Ui) { }); } +/// The tool row: zoom toggle, the six tools, the Smart-tool bracket, and the toggle strip below+/// them (Tab to Transient, the two links, mirrored MIDI, automation follows edit, keyboard focus). fn tools(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let cur = app.engine.session().edit.tool;@@ -172,7 +181,8 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { ui.spacing_mut().item_spacing = vec2(2.0, 3.0); let row = ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0;- if icon_button(ui, vec2(36.0, 26.0), "zoom_toggle", app.engine.session().edit.zoom_toggle.is_some(), "Zoom Toggle (E)").clicked() {+ if icon_button(ui, vec2(36.0, 26.0), "zoom_toggle", app.engine.session().edit.zoom_toggle.is_some(), tr("Zoom Toggle (E)")).clicked()+ { crate::edit_window::zoom_toggle(app); } for (tool, icon) in [@@ -184,7 +194,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { (Tool::Pencil, "pencil"), ] { let smart_member = cur == Tool::Smart && matches!(tool, Tool::Trim | Tool::Selector | Tool::Grabber);- let tip = format!("{}{}", tool.label(), tool.fkey().map(|k| format!(" ({k})")).unwrap_or_default());+ let tip = format!("{}{}", tr(tool.label()), tool.fkey().map(|k| format!(" ({k})")).unwrap_or_default()); if icon_button(ui, vec2(36.0, 26.0), icon, cur == tool || smart_member, &tip).clicked() { let _ = app.run("edit.tool", json!({"tool": tool.id()})); }@@ -197,18 +207,18 @@ 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(tr("Smart Tool (F6+F7 / F7+F8)")).clicked() { let _ = app.run("edit.tool", json!({"tool": "smart"})); } ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let e = app.engine.session().edit.clone(); let toggles: [(&str, &str, bool, &str); 5] = [- ("options.tab_to_transient", "tab_transient", e.tab_to_transient, "Tab to Transient"),- ("options.link_timeline_edit", "link", e.link_timeline_edit, "Link Timeline and Edit Selection"),- ("options.link_track_edit", "link", e.link_track_edit, "Link Track and Edit Selection"),- ("options.mirrored_midi", "note", e.mirrored_midi, "Mirrored MIDI Editing"),- ("options.automation_follows_edit", "wave", e.automation_follows_edit, "Automation Follows Edit"),+ ("options.tab_to_transient", "tab_transient", e.tab_to_transient, tr("Tab to Transient")),+ ("options.link_timeline_edit", "link", e.link_timeline_edit, tr("Link Timeline and Edit Selection")),+ ("options.link_track_edit", "link", e.link_track_edit, tr("Link Track and Edit Selection")),+ ("options.mirrored_midi", "note", e.mirrored_midi, tr("Mirrored MIDI Editing")),+ ("options.automation_follows_edit", "wave", e.automation_follows_edit, tr("Automation Follows Edit")), ]; for (id, icon, on, tip) in toggles { if icon_button(ui, vec2(36.0, 18.0), icon, on, tip).clicked() {@@ -216,7 +226,8 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { } } let focus = e.keyboard_focus == "commands";- if crate::widgets::text_toggle(ui, vec2(36.0, 18.0), "a-z", focus, t.accent, "Commands Keyboard Focus (single-key editing)").clicked()+ if crate::widgets::text_toggle(ui, vec2(36.0, 18.0), "a-z", focus, t.accent, tr("Commands Keyboard Focus (single-key editing)"))+ .clicked() { let _ = app.run("options.keyboard_focus", json!({})); }@@ -230,6 +241,8 @@ fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String { s.format_position(at, f) } +/// The main counter with its format menu, the Start/End/Length readout, and the sub counter and+/// dirty/audio-engine line. fn counters(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let (main_fmt, sub_fmt, sel) = {@@ -246,7 +259,7 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) { icons::draw(ui.painter(), Rect::from_center_size(pos2(main_r.max.x - 9.0, main_r.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim); egui::Popup::menu(&resp).show(|ui| { for f in TimeFormat::ALL {- if ui.selectable_label(main_fmt == f, f.label()).clicked() {+ if ui.selectable_label(main_fmt == f, tr(f.label())).clicked() { let _ = app.run("view.main_counter", json!({"format": f.id()})); } }@@ -254,9 +267,9 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) { // Start / End / Length. let s = app.engine.session(); let rows = [- ("Start", fmt_pos(app, sel.start, main_fmt)),- ("End", fmt_pos(app, sel.end, main_fmt)),- ("Length", format_length(sel.start, sel.len(), main_fmt, s.sample_rate, &s.tempo, s.frame_rate)),+ (tr("Start"), fmt_pos(app, sel.start, main_fmt)),+ (tr("End"), fmt_pos(app, sel.end, main_fmt)),+ (tr("Length"), format_length(sel.start, sel.len(), main_fmt, s.sample_rate, &s.tempo, s.frame_rate)), ]; for (i, (label, val)) in rows.iter().enumerate() { let y = r.min.y + 10.0 + i as f32 * 13.0;@@ -266,13 +279,14 @@ 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, 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" });+ let st = if app.engine.is_dirty() { tr("modified") } else { tr("saved") };+ let dev = app.player.as_ref().map_or(tr("no audio engine"), |p| if p.silent { tr("silent clock") } else { tr("audio on") }); ui.painter().text(pos2(r.max.x - 8.0, y), Align2::RIGHT_CENTER, format!("{st} · {dev}"), regular(10.0), t.text_dim); } +/// The grid-lines toggle, the grid value and the nudge value, each opening a picker menu. fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let (grid, nudge, lines) = {@@ -284,15 +298,15 @@ 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, tr("Grid"), bold(11.0), if lines { t.mode_on_text } else { t.mode_off_text });+ if resp.on_hover_text(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)); ui.painter().text(pos2(gr.max.x - 14.0, gr.center().y), Align2::RIGHT_CENTER, grid.label(), mono(11.0), t.counter_text); let gresp = ui.interact(gr, ui.id().with("grid_value"), Sense::click()); value_menu(app, &gresp, "view.grid");- ui.painter().text(pos2(r.min.x + 10.0, r.min.y + 44.0), Align2::LEFT_CENTER, "Nudge", regular(11.0), t.counter_label);+ ui.painter().text(pos2(r.min.x + 10.0, r.min.y + 44.0), Align2::LEFT_CENTER, tr("Nudge"), regular(11.0), t.counter_label); let nr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 34.0), pos2(r.max.x - 4.0, r.min.y + 54.0)); ui.painter().text(pos2(nr.max.x - 14.0, nr.center().y), Align2::RIGHT_CENTER, nudge.label(), mono(11.0), t.counter_text); let nresp = ui.interact(nr, ui.id().with("nudge_value"), Sense::click());@@ -302,10 +316,13 @@ fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) { } } +/// The grid/nudge picker: musical values send their English source string to the engine (it is+/// the value id), the rest are plain quantities. fn value_menu(app: &mut SoundApp, resp: &egui::Response, cmd: &str) { egui::Popup::menu(resp).show(|ui| {+ // The value sent stays the English source; only the label is translated. for v in ["1 bar", "1/2", "1/4", "1/8", "1/16", "1/32", "1/64", "1/8t", "1/16t", "1/4."] {- if ui.button(v).clicked() {+ if ui.button(tr(v)).clicked() { let _ = app.run(cmd, json!({"value": v})); } }@@ -318,13 +335,14 @@ fn value_menu(app: &mut SoundApp, resp: &egui::Response, cmd: &str) { ("1 frame", json!({"frames": 1})), ("100 samples", json!({"samples": 100})), ] {- if ui.button(label).clicked() {+ if ui.button(tr(label)).clicked() { let _ = app.run(cmd, json!({"value": v})); } } }); } +/// Loop, stop, play, record on top; return-to-zero, rewind, fast forward and go-to-end below. fn transport(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let playing = app.is_playing();@@ -335,25 +353,25 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) { ui.spacing_mut().item_spacing = vec2(3.0, 4.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 3.0;- if icon_button(ui, vec2(38.0, 28.0), "loop", looped, "Loop Playback (Cmd+Shift+L)").clicked() {+ if icon_button(ui, vec2(38.0, 28.0), "loop", looped, tr("Loop Playback (Cmd+Shift+L)")).clicked() { let _ = app.run("options.loop_playback", json!({})); } 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(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(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(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));@@ -366,10 +384,10 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 3.0; for (icon, cmd, tip) in [- ("rtz", "transport.rtz", "Return to Zero (Home)"),- ("rewind", "transport.rewind", "Rewind"),- ("ffwd", "transport.fast_forward", "Fast Forward"),- ("end", "transport.go_to_end", "Go to End (End)"),+ ("rtz", "transport.rtz", tr("Return to Zero (Home)")),+ ("rewind", "transport.rewind", tr("Rewind")),+ ("ffwd", "transport.fast_forward", tr("Fast Forward")),+ ("end", "transport.go_to_end", tr("Go to End (End)")), ] { if icon_button(ui, vec2(38.0, 22.0), icon, false, tip).clicked() { let _ = app.run(cmd, json!({}));@@ -380,6 +398,8 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) { }); } +/// The count-off toggle and bar count, the meter and tempo at the playhead (draggable), and the+/// click toggle. fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let (bpm, meter, countoff, click, bars) = {@@ -393,19 +413,25 @@ 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, 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,+ tf("{bars} bars", &[("bars", &bars.to_string())]),+ mono(11.0),+ t.counter_text,+ );+ ui.painter().text(pos2(r.min.x + 70.0, r.min.y + 32.0), Align2::RIGHT_CENTER, 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, 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(tr("Click")).clicked() { let _ = app.run("options.click", json!({})); } }@@ -437,7 +463,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(tr("Drag to change the tempo (Shift: fine steps), or click to type it")); } fn output_meter(app: &mut SoundApp, ui: &mut Ui) {@@ -45,6 +45,7 @@ Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no tr | `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, zoom (`samples_per_px`, `scroll`), transport, undo label. | | `ui.inspect` | `{}` | UI state (`window`, panels, dialogs), window size, playing/position, audio device, and `edit_layout` (timeline rect and every track row's rect, for clicking). | | `ui.set` | any `UiState` fields | e.g. `{"window": "Mix", "narrow_mix": true}`. |+| `ui.language` | `{language?}` | The interface language: `{"language": "zh-Hans"}` switches live (menus, toolbar, dialogs); without params, returns the current language and the supported list. The choice is saved with the other preferences. | | `ui.menu.list` | `{}` | Every catalog menu path and the command that implements it (or null). | | `ui.menu.invoke` | `{path}` or `{id}` | Like clicking the menu item: may open its dialog (e.g. `"Track > New..."`). | | `ui.click` | `{x, y, button?, count?, cmd?, shift?, alt?, ctrl?}` | Synthetic click (window points). |@@ -93,6 +94,11 @@ System to Dark, or on Linux to the desktop's portal setting when one is availabl track) brings the track to the top of the Edit window and the left of the Mix window, the same as Scroll Into View in the track name menu. A window not on screen catches up when it is next shown. +The interface language defaults to English. `ui.language {"language":"zh-Hans"}` switches the+interface to 简体中文 at once; unsupported CJK text on the web build falls back to boxes until a+bundled font ships. `ui.set` accepts `{"language": …}` too, with the same effect on the next+frame.+ ## Panel docking Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Commands target the active Edit or Mix view; floating panels stay inside the current viewport. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.@@ -1,6 +1,6 @@ # Where SoundCraft falls short of Pro Tools -> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (G-04: mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** major (G-09: language setting, zh-Hans catalog, CJK font fallback) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**@@ -23,7 +23,7 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t | G-06 | (Record-to-disk part: alpha blocker for the tracking row) Sources decoded whole into RAM; recording into RAM | Performance | [hardware-parity.md](hardware-parity.md) | 15–25 | | G-07 | Nothing is compared against Pro Tools' output (no fidelity harness) | Features | this file | 20–30 | | G-08 | Built-in plugin catalogue thin and unverified | Features | [plugin-parity.md](plugin-parity.md) | 80–120 |-| G-09 | English only, no string catalog, no CJK/RTL rendering | Localization | [localization-parity.md](localization-parity.md) | 70–110 |+| G-09 | Localization: only English and zh-Hans (menus/toolbar/dialogs); other languages, generated sentences, panels, web CJK fonts, RTL/shaping missing | Localization | [localization-parity.md](localization-parity.md) | 45–85 | | G-10 | No control surfaces (HUI/MCU, EUCON) | Hardware | [hardware-parity.md](hardware-parity.md) | 40–70 | | G-11 | No external sync (MTC, LTC, MIDI clock, video reference) | Hardware | [hardware-parity.md](hardware-parity.md) | 15–25 | | G-12 | Immersive delivery: no Dolby Atmos/ADM BWF, MPEG-H, binaural | Features | [file-format-parity.md](file-format-parity.md) | 50–80 |@@ -138,9 +138,12 @@ parametric request #139. ≈ 80–120 h. ### G-09. Localization -See [`localization-parity.md`](localization-parity.md): no catalog, every string literal in code;-egui has no complex-script shaping or RTL; no CJK font fallback (PR #141 open). ≈ 70–110 h plus-native-speaker review.+The catalog and the language setting are in (`soundcraft-l10n`, `ui.language`, zh-Hans covering+the full menu tree, toolbar/transport and common dialogs; desktop CJK renders through a+system-font fallback) — see [`localization-parity.md`](localization-parity.md). Remaining:+per-language translations plus native-speaker review, generated sentences (clear-clip+confirmation, some status messages), the panels' long tail, web CJK fonts (PR #141 open), IME+testing; egui has no complex-script shaping or RTL. ≈ 45–85 h. ### G-10. Control surfaces @@ -1,25 +1,36 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language status) · **Target:** Avid Pro Tools Ultimate 2026.4.1--SoundCraft is English only. There is no string catalog: every menu label, dialog and tooltip is a-literal in Rust (`crates/engine/src/cmd/*`, `crates/ui-egui/src/*`). Pro Tools ships its UI in-**8 languages** (bundle `.lproj` folders: English, German, Spanish, French, Japanese, Korean,-Simplified Chinese, Traditional Chinese).--**Localization: ~12 %** (measured: 1 of the target's 8 UI languages, and English is the only one-complete). Remaining ≈ 70–110 h: a catalog and extraction pass (≈ 15–25 h), a font fallback for-CJK/Devanagari/Arabic (PR #141 open), RTL layout and complex-script shaping (egui has neither:-≈ 25–40 h, or ship Arabic/Hindi menus-only), then ≈ 3–5 h of agent translation per language, plus-native-speaker review (human).+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** major (language setting, zh-Hans catalog, CJK font fallback) · **Target:** Avid Pro Tools Ultimate 2026.4.1++SoundCraft's strings live in code (`crates/engine/src/cmd/*`, `crates/ui-egui/src/*`). A catalog+(`soundcraft-l10n`, L0) now translates them at display time: keys are the English source strings,+a language without an entry falls back to English, and command ids, menu paths and agent-facing+docs stay English because they are match keys. `ui.language` switches the interface live and is+saved with the preferences; the SoundCraft menu has a Language submenu (and the macOS system menu+bar rebuilds in the new language). Pro Tools ships its UI in **8 languages** (bundle `.lproj`+folders: English, German, Spanish, French, Japanese, Korean, Simplified Chinese, Traditional+Chinese).++**Localization: ~19 %** (measured: 2 of the target's 8 UI languages; English complete, and+zh-Hans covers the full menu tree, toolbar/transport and the common dialogs — roughly 600+catalog entries — while generated sentences (clear-clip confirmation, some status messages) and+the secondary panels still show English). Remaining ≈ 45–85 h: per-language translations+(≈ 3–5 h each) plus native-speaker review, bundled CJK fonts for the web build (PR #141 open),+RTL layout and complex-script shaping (egui has neither: ≈ 25–40 h, or ship Arabic/Hindi+menus-only), and the secondary panels' long tail. Open work: PR #143 (French and Spanish, a Language setting), PR #141 (bundled multilingual font fallbacks), issue #67 (Ukrainian). +CJK text renders on the desktop through a system-font fallback loaded at runtime (never+redistributed: Microsoft YaHei, PingFang/Hiragino Sans GB, Noto Sans CJK SC, same pattern as the+existing Hangul fallback). The web build has no system fonts and needs the bundled-font work+from PR #141.+ | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Est. to full (h) | In Pro Tools |-|---|---|---|---|---|---|---|---:|---|+|---|---|---|---|---|---|---:|---|---| | English | en | all (source) | ✅ | Latin ✅ | — | **full** | 0 | ✅ |-| Simplified Chinese | zh-Hans | 0 (0 %) | ❌ | ❌ no CJK font fallback, no IME testing | no | none | 6–10 | ✅ |+| Simplified Chinese | zh-Hans | menus, toolbar, transport, common dialogs (~600); panels and generated sentences fall back | 🟡 common dialogs and tooltips; generated sentences pending | ✅ desktop (system CJK font fallback); ❌ web (no bundled font) | no | **catalog** | 1–2 | ✅ | | Spanish | es | 0 (0 %) — PR #143 pending | ❌ | Latin ✅ | no | none | 3–5 | ✅ | | Hindi | hi | 0 (0 %) | ❌ | ❌ no Devanagari font or shaping | no | none | 8–15 | ❌ | | Arabic | ar | 0 (0 %) | ❌ | ❌ no RTL layout, no shaping | no | none | 15–25 | ❌ |@@ -28,7 +39,7 @@ fallbacks), issue #67 (Ukrainian). | Indonesian | id | 0 (0 %) | ❌ | Latin ✅ | no | none | 3–5 | ❌ | | Japanese | ja | 0 (0 %) | ❌ | ❌ no CJK font fallback, no IME testing | no | none | 6–10 | ✅ | | German | de | 0 (0 %) | ❌ | Latin ✅ | no | none | 3–5 | ✅ |-| Korean | ko | 0 (0 %) | ❌ | ❌ no Hangul font fallback | no | none | 6–10 | ✅ |+| Korean | ko | 0 (0 %) | ❌ | ✅ Hangul fallback (system font) | no | none | 6–10 | ✅ | | Vietnamese | vi | 0 (0 %) | ❌ | 🟡 Latin with stacked diacritics, untested | no | none | 3–5 | ❌ | Other languages shipped: **0**. (Pro Tools also ships Traditional Chinese, zh-Hant.)@@ -39,4 +50,5 @@ Per-language hours exclude the shared catalog and script work above. | Date | Change | Summary | |---|---|---|+| 2026-10-24 | major | Language setting (`ui.language`, Language menu, persisted), `soundcraft-l10n` catalog keyed by the English source, zh-Hans covering the full menu tree + toolbar/transport + common dialogs, Simplified-Chinese system-font fallback | | 2026-10-10 | major | Created: English only, no catalog; the twelve key languages at 0 %; Pro Tools' 8 languages listed from its bundle |@@ -23,6 +23,7 @@ fn cargo(args: &[&str]) -> Result<(), String> { /// Layer table: crate name (minus `soundcraft-`) → layer. Apps and xtask are exempt. const LAYERS: &[(&str, u8)] = &[ ("time", 0),+ ("l10n", 0), ("audio-io", 0), ("midi", 0), ("video", 0),@@ -42,6 +43,8 @@ const STANDALONE: &[&str] = &["time", "audio-io", "midi", "video", "dsp"]; const UI_CRATES: &[&str] = &["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "egui-wgpu", "egui_glow"]; const EXEMPT: &[&str] = &["soundcraft", "soundcraft-cli", "soundcraft-web", "xtask"]; +/// Checks every workspace crate against the layer table: registered, and depending only+/// downward (no UI crates below L6, standalone crates on nothing internal). fn layers() -> Result<(), String> { let out = Command::new("cargo").args(["metadata", "--format-version", "1", "--no-deps"]).current_dir(root()).output().map_err(|e| e.to_string())?;