Naming: descriptive names for Pro Tools product and trademark labels (#1457)

Display labels only; command ids, catalog paths, session tokens and processor ids are unchanged.

- Avid-listed names and bundled plug-in products get our own names (Beat Analysis, Punch Recording, Room Reverb, Compressor/Limiter, Saturator, Bit Crusher, Rendered (High Quality), ...).
- Features we don't have stay in the menu as disabled placeholders with "Not available yet." (Tempo Sync, No ARA plug-ins, Analog Saturation).
- AudioSuite, Elastic Audio, TrackPunch, Pitch Shift Legacy and Time Compression Expansion keep their names until RFC-0029 settles.
- parity_floor and catalog dispatch unchanged; docs/feature-naming.md has the crosswalk.
This commit is contained in:
FredApps authored and GitHub committed 2026-11-09 04:31:12 +01:00
1 parent 0ea934290d
commit 7c6315d999
13 files changed
+456 -27

No files matched your search

+2 -2
View File
@@ -134,8 +134,8 @@ where things are.
playlist per take, input monitoring through the full channel strip, and autosave with recovery.
- **Time and tempo:** Bars|Beats, Min:Secs, Timecode (every rate, drop-frame included),
Feet+Frames and Samples; tempo and meter maps with a tempo editor; linear, parabolic and
S-curve tempo ramps; key signatures and detected chords; Beat Detective and Identify Beat;
pitch-preserving Elastic warping.
S-curve tempo ramps; key signatures and detected chords; Beat Analysis and Identify Beat;
pitch-preserving Elastic Audio warping.
- **Audio files:** WAV/BWF/RF64, AIFF/AIFC and FLAC in and out; MP3, Ogg Vorbis, AAC, ALAC, CAF
and the audio of MP4/MOV movies in. Any sample rate and bit depth.
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with optional dither and
+3 -3
View File
@@ -1,4 +1,4 @@
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction), Rectifier and
//! Harmonic effects: Saturator (tanh), Bit Crusher (bit/sample-rate reduction), Rectifier and
//! Subharmonic (octave-down bass synthesis).
use crate::biquad::{Biquad, FilterType};
@@ -134,8 +134,8 @@ static LOFI_PARAMS: [ParamInfo; 4] = [
pub static LOFI_INFO: PluginInfo = PluginInfo {
id: "lofi",
name: "Lo-Fi",
short_name: "LoFi",
name: "Bit Crusher",
short_name: "Crush",
category: Category::Harmonic,
params: &LOFI_PARAMS,
is_instrument: false,
+75 -2
View File
@@ -65,13 +65,50 @@ pub const ALIASES: &[(&str, &str)] = &[
("View > Rulers > Chord Symbols", "view.ruler"),
];
// Name migration fallbacks run after explicit UI mappings so opening a dialog
// keeps its original command and enablement rather than becoming a direct edit.
const LEGACY_LABEL_ALIASES: &[(&str, &str)] = &[
// Historical catalog paths remain valid when their display names change.
("Clip > Elastic Properties", "clip.elastic_properties"),
("Options > QuickPunch", "options.quickpunch"),
("Options > TrackPunch", "options.trackpunch"),
("View > Transport > Ableton Link", "view.transport_ableton_link"),
("Event > Beat Detective", "event.beat_detective"),
("File > Export > Sibelius...", "file.export_sibelius"),
("File > Send To... > Sibelius", "file.send_to_sibelius"),
];
/// Catalog leaves naming features we do not have. They keep the incumbent's place — and with it
/// the parity floor — under a descriptive label, but they are never interactive: the UI draws them
/// disabled with a "Not available yet." hover until the behaviour exists, as Preferences does for
/// settings without a backend. Naming the place does not claim the protocol, plug-in format or
/// processor behind it.
pub const PLACEHOLDERS: &[&str] = &[
"View > Transport > Ableton Link",
"Track > ARA Plugins > Melodyne",
"Clip > ARA Plugins > Melodyne",
"View > Mix Window Views > HEAT",
"Options > Activate HEAT",
];
/// Whether `path` is a catalog leaf kept as a placeholder for a feature we do not have.
pub fn is_placeholder(path: &str) -> bool {
PLACEHOLDERS.contains(&path)
}
pub fn catalog() -> Vec<&'static str> {
MENUS.lines().map(str::trim).filter(|l| !l.is_empty()).collect()
}
/// Command id implementing a catalog entry, if any.
pub fn implemented_by(entry: &str, extra: &[(&str, &str)]) -> Option<String> {
if let Some((_, id)) = ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()).chain(extra.iter()).find(|(p, _)| *p == entry) {
if let Some((_, id)) = ALIASES
.iter()
.chain(crate::catalog_more::ALIASES_MORE.iter())
.chain(extra.iter())
.chain(LEGACY_LABEL_ALIASES.iter())
.find(|(p, _)| *p == entry)
{
return Some((*id).to_string());
}
let parts: Vec<&str> = entry.split(" > ").collect();
@@ -116,7 +153,7 @@ mod tests {
#[test]
fn aliases_point_at_real_commands() {
for (path, id) in ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()) {
for (path, id) in ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()).chain(LEGACY_LABEL_ALIASES.iter()) {
assert!(crate::find_command(id).is_some(), "{path} → unknown {id}");
assert!(catalog().contains(path), "alias for unknown catalog entry {path}");
}
@@ -131,4 +168,40 @@ mod tests {
assert!(pct >= 25.0, "engine parity regressed: {pct}%");
assert!(done >= 396, "engine parity regressed: {done} menu items");
}
/// A placeholder must keep its catalog place (so the floor holds and the menu still lists it)
/// and must not read as the incumbent's own name: the label says what it is, not who makes it.
#[test]
fn placeholders_keep_their_place_with_honest_labels() {
let entries = catalog();
for path in PLACEHOLDERS {
assert!(entries.contains(path), "placeholder for an unknown catalog entry: {path}");
assert!(is_placeholder(path), "{path}");
let leaf = path.rsplit_once(" > ").map_or(*path, |(_, leaf)| leaf);
let label = crate::feature_names::display_label(leaf);
assert_ne!(label, leaf, "{path} still shows the incumbent's name");
for retired in ["ableton", "melodyne", "heat"] {
assert!(!label.to_lowercase().contains(retired), "{path}: {label}");
}
}
assert!(!is_placeholder("Options > Loop Record"), "an implemented item is not a placeholder");
assert!(!is_placeholder("View > Transport > MIDI Controls"));
}
#[test]
fn renamed_labels_keep_legacy_catalog_dispatch() {
let ui = [("Event > Beat Detective", "window.beat_detective")];
assert_eq!(implemented_by("Event > Beat Detective", &ui).as_deref(), Some("window.beat_detective"));
for (path, id, label) in [
("Options > QuickPunch", "options.quickpunch", "Punch Recording"),
("Options > TrackPunch", "options.trackpunch", "TrackPunch"),
("View > Transport > Ableton Link", "view.transport_ableton_link", "Tempo Sync"),
("Event > Beat Detective", "event.beat_detective", "Beat Analysis"),
("File > Export > Sibelius...", "file.export_sibelius", "MusicXML..."),
("File > Send To... > Sibelius", "file.send_to_sibelius", "MusicXML"),
] {
assert_eq!(implemented_by(path, &[]).as_deref(), Some(id));
assert_eq!(crate::find_command(id).unwrap().label, label);
}
}
}
+1 -1
View File
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(
"event.beat_detective",
"Beat Detective",
"Beat Analysis",
["Event"],
None,
"{tracks?, start?, end?, sensitivity?: 0.5, separate?: false, set_tempo?: false}",
+2 -2
View File
@@ -144,10 +144,10 @@ pub fn specs() -> Vec<CommandSpec> {
crate::io::save_session(&mut tmp, &path)?;
Ok(json!({"path": path}))
}),
cmd!(noundo "file.export_sibelius", "Sibelius...", ["File", "Export"], None, "{path, tracks?} — MIDI tracks as MusicXML (.musicxml)", always, |e, p| {
cmd!(noundo "file.export_sibelius", "MusicXML...", ["File", "Export"], None, "{path, tracks?} — MIDI tracks as MusicXML (.musicxml)", always, |e, p| {
write_score(e, p, "file.export_sibelius", false)
}),
cmd!(noundo "file.send_to_sibelius", "Sibelius", ["File", "Send To..."], None, "{path?, tracks?} — writes MusicXML for a notation program (to the temp folder when no `path`)", always, |e, p| {
cmd!(noundo "file.send_to_sibelius", "MusicXML", ["File", "Send To..."], None, "{path?, tracks?} — writes MusicXML for a notation program (to the temp folder when no `path`)", always, |e, p| {
write_score(e, p, "file.send_to_sibelius", false)
}),
cmd!(noundo "file.print_score", "Print Score...", ["File"], Some("Cmd+P"), "{path?, tracks?} — engraves the MIDI tracks as a printable SVG page", always, |e, p| {
+1 -1
View File
@@ -19,7 +19,7 @@ macro_rules! toggle {
pub fn specs() -> Vec<CommandSpec> {
vec![
toggle!("options.loop_record", "Loop Record", ["Options"], Some("Alt+L"), loop_record),
toggle!("options.quickpunch", "QuickPunch", ["Options"], Some("Cmd+Shift+P"), quickpunch),
toggle!("options.quickpunch", "Punch Recording", ["Options"], Some("Cmd+Shift+P"), quickpunch),
toggle!("options.pre_post_roll", "Pre/Post-Roll", ["Options"], Some("Cmd+K"), pre_post_roll),
toggle!("options.loop_playback", "Loop Playback", ["Options"], Some("Cmd+Shift+L"), loop_playback),
toggle!("options.link_timeline_edit", "Link Timeline and Edit Selection", ["Options"], Some("Shift+/"), link_timeline_edit),
+1 -1
View File
@@ -184,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
flag!("view.transport_counters", "Counters", ["View", "Transport"], "view.transport.counters"),
flag!("view.transport_midi_controls", "MIDI Controls", ["View", "Transport"], "view.transport.midi_controls"),
flag!("view.transport_synchronization", "Synchronization", ["View", "Transport"], "view.transport.synchronization"),
flag!("view.transport_ableton_link", "Ableton Link", ["View", "Transport"], "view.transport.ableton_link"),
flag!("view.transport_ableton_link", "Tempo Sync", ["View", "Transport"], "view.transport.ableton_link"),
flag!("view.transport_output_meters", "Output Meters", ["View", "Transport"], "view.transport.output_meters"),
flag!("view.transport_expanded", "Expanded", ["View", "Transport"], "view.transport.expanded"),
cmd!(query "view.flags", "View Flags", [], None, "{prefix?} — lists the view/option flags that are on", always, |e, p| {
+56
View File
@@ -0,0 +1,56 @@
//! Display-only terminology. Catalog paths, command ids, processor ids and saved values are stable.
//! Never use the returned label for command dispatch or persistence. Labels for features we do not
//! have describe a placeholder, which the menu draws disabled ("Not available yet.") until the
//! behaviour exists: `catalog::PLACEHOLDERS` lists them.
//!
//! Names still being settled (RFC-0029) are not mapped here: the incumbent's label stays until the
//! RFC decides, so AudioSuite, Elastic Audio, Elastic Properties and TrackPunch pass through.
pub fn display_label(label: &str) -> &str {
match label {
"Beat Detective" => "Beat Analysis",
"x-form" => "Rendered (High Quality)",
"QuickPunch" => "Punch Recording",
"Ableton Link" => "Tempo Sync",
"Pro Tools Search" => "Search",
"Pro Tools Projects" => "Shared Projects",
"Melodyne" => "No ARA plug-ins",
"HEAT" => "Analog Saturation",
"Activate HEAT" => "Activate Analog Saturation",
"Sibelius" => "MusicXML",
"Sibelius..." => "MusicXML...",
_ => label,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_labels_are_descriptive_without_changing_unknown_text() {
for (old, new) in [
("Beat Detective", "Beat Analysis"),
("x-form", "Rendered (High Quality)"),
("QuickPunch", "Punch Recording"),
("Ableton Link", "Tempo Sync"),
("Pro Tools Search", "Search"),
("Pro Tools Projects", "Shared Projects"),
("Melodyne", "No ARA plug-ins"),
("HEAT", "Analog Saturation"),
("Activate HEAT", "Activate Analog Saturation"),
("Sibelius...", "MusicXML..."),
] {
assert_eq!(display_label(old), new);
}
assert_eq!(display_label("My session"), "My session");
}
/// Names deferred to RFC-0029 keep the incumbent's label until the RFC settles them.
#[test]
fn deferred_names_pass_through() {
for label in ["AudioSuite", "AudioSuite Process", "Elastic Audio", "Elastic Properties", "Enable Elastic Audio on New Tracks", "TrackPunch"] {
assert_eq!(display_label(label), label);
}
}
}
+1
View File
@@ -13,6 +13,7 @@ pub mod cmd;
pub mod demo;
mod dirty;
pub mod edit;
pub mod feature_names;
pub mod file_access;
pub mod inspect;
pub mod io;
+3 -3
View File
@@ -1,4 +1,4 @@
//! Event-menu operation windows: Beat Detective, Tempo Operations, Time Operations, MIDI
//! Event-menu operation windows: Beat Analysis, Tempo Operations, Time Operations, MIDI
//! Operations and MIDI Real-Time Properties. Each is a small form over engine commands.
use crate::SoundApp;
@@ -88,13 +88,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
rtp(app, ctx);
}
/// Beat Detective window: sensitivity, separate and set-tempo options, Analyze and Apply results, and Identify Beat.
/// Beat Analysis window: sensitivity, separate and set-tempo options, Analyze and Apply results, and Identify Beat.
fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_beat_detective;
if !open {
return;
}
egui::Window::new("Beat Detective").open(&mut open).default_size(vec2(360.0, 220.0)).show(ctx, |ui| {
egui::Window::new("Beat Analysis").open(&mut open).default_size(vec2(360.0, 220.0)).show(ctx, |ui| {
ui.label("Analyses the edit selection on the selected tracks.");
ui.add(egui::Slider::new(&mut app.ops.beat_sensitivity, 0.0..=1.0).text("Sensitivity"));
crate::widgets::checkbox(ui, &mut app.ops.beat_separate, "Separate clips at detected beats");
+8 -2
View File
@@ -389,12 +389,18 @@ fn field_row(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, head_bg
ui.painter().line_segment([c + vec2(0.0, -3.5), c + vec2(0.0, 1.0)], Stroke::new(1.0, t.text));
ui.painter().add(Shape::convex_polygon(vec![c + vec2(0.0, 0.5), c + vec2(3.0, 3.5), c + vec2(-3.0, 3.5)], t.text, Stroke::NONE));
if let Some(alg) = &track.elastic {
ui.painter().with_clip_rect(field).text(pos2(field.min.x + 14.0, field.center().y), Align2::LEFT_CENTER, alg, bold(10.0), t.text);
ui.painter().with_clip_rect(field).text(
pos2(field.min.x + 14.0, field.center().y),
Align2::LEFT_CENTER,
soundcraft_engine::feature_names::display_label(alg),
bold(10.0),
t.text,
);
}
egui::Popup::menu(&resp.on_hover_text("Elastic Audio")).show(|ui| {
for alg in ["none", "polyphonic", "rhythmic", "monophonic", "varispeed", "x-form"] {
let current = track.elastic.as_deref().unwrap_or("none") == alg;
if ui.selectable_label(current, alg).clicked() {
if ui.selectable_label(current, soundcraft_engine::feature_names::display_label(alg)).clicked() {
let _ = app.run("track.elastic", json!({"tracks": [id.0], "algorithm": alg}));
}
}
+231 -10
View File
@@ -1,5 +1,8 @@
//! The menu bar, built from the incumbent's menu catalog so every item has its place. Items
//! that map to a command are live; the rest are shown disabled (and listed in the parity report).
//! A few features we do not have yet keep their catalog place as placeholders
//! ([`catalog::PLACEHOLDERS`]): they are never interactive and say so on hover, as the Preferences
//! placeholders do.
use crate::{MainWindow, SoundApp};
use serde_json::{Value, json};
@@ -60,7 +63,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
("window.clip_effects_dock", "Clip Effects", "View > Other Displays > Lower Dock > Clip Effects", Some("Alt+6")),
("window.search", "Search", "Window > Pro Tools Search", Some("Cmd+Ctrl+S")),
("window.score", "Score Editor", "Window > Score Editor", Some("Cmd+Ctrl+=")),
("window.beat_detective", "Beat Detective", "", None),
("window.beat_detective", "Beat Analysis", "", None),
("window.tempo_ops", "Tempo Operations", "", None),
("window.time_ops", "Time Operations", "", None),
("window.midi_ops", "MIDI Operations", "", None),
@@ -190,10 +193,10 @@ fn insert_path(roots: &mut Vec<MenuNode>, line: &str) {
path.push_str(" > ");
}
path.push_str(p);
let idx = match level.iter().position(|n| n.label == *p) {
let idx = match level.iter().position(|n| n.path == path) {
Some(i) => i,
None => {
level.push(MenuNode { label: p.to_string(), path: path.clone(), children: Vec::new() });
level.push(MenuNode { label: display_menu_label(&path, p), path: path.clone(), children: Vec::new() });
level.len() - 1
}
};
@@ -202,21 +205,57 @@ fn insert_path(roots: &mut Vec<MenuNode>, line: &str) {
}
}
/// Offline leaves whose name waits on RFC-0029. They keep the catalog's own text and are listed
/// beside the processor that implements them, so no menu item disappears.
const DEFERRED_LABELS: &[&str] = &["AudioSuite > Pitch Shift > Pitch Shift Legacy", "AudioSuite > Other > Time Compression Expansion"];
/// Display names come from our processor registry; paths remain legacy automation keys.
fn display_menu_label(path: &str, fallback: &str) -> String {
if DEFERRED_LABELS.contains(&path) {
return fallback.to_string();
}
if let Some((_, process)) = AUDIOSUITE.iter().find(|(key, _)| *key == path) {
if let Some(info) = soundcraft_dsp::plugin_info(process) {
return info.name.to_string();
}
let label = match *process {
"pitch_shift" => "Pitch Shift",
"time_stretch" => "Time Stretch",
"speed_ramp" => "Speed Ramp",
_ => fallback,
};
return label.to_string();
}
soundcraft_engine::feature_names::display_label(fallback).to_string()
}
pub fn tree() -> Vec<MenuNode> {
let mut roots: Vec<MenuNode> = Vec::new();
let mut processors = std::collections::HashSet::new();
for line in catalog::catalog() {
// Several legacy entries map to one processor. List it once per category,
// while keeping every historical path accepted by command_for_path. A deferred leaf keeps
// its own catalog name, so it is not folded into the processor it shares.
if !DEFERRED_LABELS.contains(&line)
&& let Some((_, process)) = AUDIOSUITE.iter().find(|(path, _)| *path == line)
{
let category = line.rsplit_once(" > ").map_or(line, |(category, _)| category);
if !processors.insert((category, *process)) {
continue;
}
}
insert_path(&mut roots, line);
}
insert_path(&mut roots, "Window > Reset Panel Layout");
// Not in the observed catalog either; Pro Tools lists it right under Identify Sync Point.
if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") {
let at = clip.children.iter().position(|n| n.label == "Identify Sync Point").map_or(clip.children.len(), |i| i + 1);
if let Some(clip) = roots.iter_mut().find(|n| n.path == "Clip") {
let at = clip.children.iter().position(|n| n.path == "Clip > Identify Sync Point").map_or(clip.children.len(), |i| i + 1);
clip.children.insert(at, MenuNode { label: "Remove Sync Point".into(), path: "Clip > Remove Sync Point".into(), children: Vec::new() });
}
// Not in the incumbent catalog, but the only UI path to an empty MIDI clip (#282).
// It creates a clip rather than acting on the selected ones, so it leads the Clip menu.
insert_path(&mut roots, "Clip > New MIDI Clip");
if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") {
if let Some(clip) = roots.iter_mut().find(|n| n.path == "Clip") {
clip.children.rotate_right(1);
}
for (path, _) in MENU_WINDOWS {
@@ -269,11 +308,11 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
instrument_menu(app, ui);
}
}
if root.label == "AudioSuite" {
if root.path == "AudioSuite" {
ui.separator();
ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
}
if root.label == "Window" {
if root.path == "Window" {
ui.separator();
let mut separate = app.ui.mix_separate;
if ui
@@ -334,7 +373,9 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
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()));
if ui.add_enabled(enabled, btn).clicked()
let response = ui.add_enabled(enabled, btn);
let response = if catalog::is_placeholder(&n.path) { response.on_disabled_hover_text("Not available yet.") } else { response };
if response.clicked()
&& let Some(id) = id
{
invoke_menu(app, &id, &n.path);
@@ -349,7 +390,7 @@ pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option<String> {
/// Enabled and checked state shared by all menu presentations.
pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) {
let enabled = if app.ui.show_preferences {
let enabled = if app.ui.show_preferences || catalog::is_placeholder(path) {
false
} else {
match id {
@@ -902,4 +943,184 @@ mod tests {
let info = soundcraft_dsp::plugin_info("ring_mod").unwrap();
assert!(info.audiosuite && info.category == soundcraft_dsp::Category::Modulation);
}
#[test]
fn offline_menu_uses_own_names_and_keeps_legacy_dispatch() {
for (path, process) in AUDIOSUITE {
assert_eq!(command_for_path(path, &ui_aliases()).as_deref(), Some("audiosuite.process"));
assert_eq!(params_for(path, "audiosuite.process")["process"], *process);
if DEFERRED_LABELS.contains(path) {
continue;
}
if let Some(info) = soundcraft_dsp::plugin_info(process) {
assert_eq!(display_menu_label(path, "ignored"), info.name);
}
}
let tree = tree();
let offline = tree.iter().find(|node| node.path == "AudioSuite").unwrap();
assert_eq!(offline.label, "AudioSuite");
let dynamics = offline.children.iter().find(|node| node.path == "AudioSuite > Dynamics").unwrap();
assert_eq!(dynamics.children.iter().filter(|node| node.label == "Compressor/Limiter").count(), 1);
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let command = command_for_path("Event > Beat Detective", &ui_aliases()).unwrap();
invoke_menu(&mut app, &command, "Event > Beat Detective");
assert!(app.ui.show_beat_detective);
for (path, expected) in [
("AudioSuite > Pitch Shift > Vari-Fi", "Speed Ramp"),
("AudioSuite > Harmonic > Lo-Fi", "Bit Crusher"),
("Track > ARA Plugins > Melodyne", "No ARA plug-ins"),
("Options > Activate HEAT", "Activate Analog Saturation"),
] {
assert_eq!(display_menu_label(path, path.rsplit(" > ").next().unwrap()), expected);
}
}
/// Leaves deferred to RFC-0029 keep the catalog's own name in the menu, next to the processor
/// that implements them: the rename waits, and no catalog item is dropped in the meantime.
#[test]
fn deferred_leaves_keep_their_catalog_names() {
fn labels<'a>(nodes: &'a [MenuNode], path: &str) -> Vec<&'a str> {
for node in nodes {
if node.path == path {
return node.children.iter().map(|child| child.label.as_str()).collect();
}
let found = labels(&node.children, path);
if !found.is_empty() {
return found;
}
}
Vec::new()
}
let tree = tree();
let pitch_shift = labels(&tree, "AudioSuite > Pitch Shift");
assert!(pitch_shift.contains(&"Pitch Shift"), "{pitch_shift:?}");
assert!(pitch_shift.contains(&"Pitch Shift Legacy"), "{pitch_shift:?}");
assert!(pitch_shift.contains(&"Time Stretch"), "the Time Shift item keeps our processor name: {pitch_shift:?}");
let other = labels(&tree, "AudioSuite > Other");
assert!(other.contains(&"Time Compression Expansion"), "{other:?}");
for path in DEFERRED_LABELS {
assert!(AUDIOSUITE.iter().any(|(key, _)| key == path), "{path}");
assert_eq!(display_menu_label(path, "kept"), "kept", "{path}");
}
}
fn retired_feature_label(label: &str) -> Option<&'static str> {
let lower = label.to_lowercase();
[
"beat detective",
"x-form",
"quickpunch",
"ableton link",
"pro tools search",
"pro tools projects",
"melodyne",
"heat",
"eq3",
"bf-76",
"dyn3",
"maxim",
"pitch ii",
"vari-fi",
"d-verb",
"mod delay iii",
"sci-fi",
"eleven lite",
"lo-fi",
"recti-fi",
"sansamp psa-1",
]
.into_iter()
// Whole labels or words: Maximizer is our processor, not the retired Maxim name.
.find(|retired| {
lower.match_indices(retired).any(|(start, token)| {
let before = lower[..start].chars().next_back();
let after = lower[start + token.len()..].chars().next();
before.is_none_or(|c| !c.is_alphanumeric()) && after.is_none_or(|c| !c.is_alphanumeric())
})
})
}
#[test]
fn retired_labels_stay_out_of_rendered_menus_and_public_registries() {
fn check(nodes: &[MenuNode]) {
for node in nodes {
assert_eq!(retired_feature_label(&node.label), None, "{}: {}", node.path, node.label);
check(&node.children);
}
}
check(&tree());
for (_, label, _, _) in UI_COMMANDS {
assert_eq!(retired_feature_label(label), None, "UI command: {label}");
}
for command in soundcraft_engine::cmd::command_specs() {
assert_eq!(retired_feature_label(command.label), None, "{}: {}", command.id, command.label);
}
for plugin in soundcraft_dsp::plugins() {
assert_eq!(retired_feature_label(plugin.name), None, "{}: {}", plugin.id, plugin.name);
assert_eq!(retired_feature_label(plugin.short_name), None, "{}: {}", plugin.id, plugin.short_name);
}
// The guard only checks built-in display values. Paths, processor ids and user text stay unchanged.
assert!(AUDIOSUITE.iter().any(|(path, _)| *path == "AudioSuite > Harmonic > Lo-Fi"));
assert!(soundcraft_dsp::plugin_info("lofi").is_some());
assert_eq!(soundcraft_engine::feature_names::display_label("A future descriptive feature"), "A future descriptive feature");
}
/// A placeholder keeps the incumbent's catalog place with an honest label, and is never
/// interactive: `item_state` refuses it however enabled its command would be.
#[test]
fn feature_placeholders_stay_disabled_with_honest_labels() {
fn leaf<'a>(nodes: &'a [MenuNode], path: &str) -> Option<&'a MenuNode> {
for node in nodes {
if node.path == path && node.children.is_empty() {
return Some(node);
}
if let Some(found) = leaf(&node.children, path) {
return Some(found);
}
}
None
}
let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let tree = tree();
for (path, label) in [
("View > Transport > Ableton Link", "Tempo Sync"),
("Track > ARA Plugins > Melodyne", "No ARA plug-ins"),
("Clip > ARA Plugins > Melodyne", "No ARA plug-ins"),
("View > Mix Window Views > HEAT", "Analog Saturation"),
("Options > Activate HEAT", "Activate Analog Saturation"),
] {
assert!(catalog::is_placeholder(path), "{path} is not listed as a placeholder");
let node = leaf(&tree, path).unwrap_or_else(|| panic!("placeholder missing from the menu: {path}"));
assert_eq!(node.label, label, "{path}");
let id = command_for_path(path, &ui_aliases());
assert!(!item_state(&app, path, id.as_deref()).0, "{path} must stay disabled");
}
// The flag command behind Tempo Sync is still registered (ids stay), only its menu item is a
// placeholder: nothing claims a synchronization protocol we do not implement.
assert!(soundcraft_engine::find_command("view.transport_ableton_link").is_some());
}
#[test]
fn display_guard_rejects_retired_labels_without_rejecting_own_processors() {
for label in ["Beat Detective", "QuickPunch", "Activate HEAT", "BF-76", "Dyn3 De-Esser", "Lo-Fi"] {
assert!(retired_feature_label(label).is_some(), "{label}");
}
// Deferred to RFC-0029: these keep the incumbent's label, so the guard must allow them.
for label in [
"Offline Processing",
"AudioSuite",
"Beat Analysis",
"Elastic Audio",
"Elastic Properties",
"TrackPunch",
"Pitch Shift Legacy",
"Time Compression Expansion",
"Rendered (High Quality)",
"Maximizer",
"Bit Crusher",
"Room Reverb",
] {
assert_eq!(retired_feature_label(label), None, "{label}");
}
}
}
+72
View File
@@ -0,0 +1,72 @@
# Feature naming migration
Status: draft, 2026-11-08, revised 2026-11-09. This is an engineering naming policy and rollout plan, not trademark clearance.
## Scheme
Use short, descriptive names for our own features. Built-in processing menus use the names in our processor registry, rather than names of another vendor's processors. Keep comparison catalogs and compatibility keys separate from display text. Labels must not determine dispatch, storage or processor selection.
| Previous visible name | Display name | Compatibility retained |
| --- | --- | --- |
| Beat Detective | Beat Analysis | `event.beat_detective`, `window.beat_detective` |
| x-form | Rendered (High Quality) | serialized `x-form` algorithm value |
| QuickPunch | Punch Recording | option id and stored field |
| Ableton Link | Tempo Sync | existing display flag only, no claim of Link interoperability; disabled placeholder |
| Pro Tools Search / Pro Tools Projects | Search / Shared Projects | historical catalog paths |
| Melodyne | No ARA plug-ins | disabled ARA placeholder, no added implementation |
| HEAT / Activate HEAT | Analog Saturation / Activate Analog Saturation | disabled catalog entries |
| Sibelius export/send | MusicXML | command ids; actual export format unchanged |
The algorithm labels describe the existing behavior. This change does not add a distinct third-party algorithm, synchronization protocol, ARA integration or hosted service.
## Deferred to RFC-0029
Some names are still being decided, so they keep the incumbent's label and nothing here renames them: AudioSuite (the menu, its process window title, `AudioSuite Process`, the task-manager note and the README wording), Elastic Audio, Elastic Properties, "Enable Elastic Audio on New Tracks" and the Preferences section that holds it, TrackPunch, Pitch Shift Legacy and Time Compression Expansion. RFC-0029 settles whether first-party feature names change at all; renaming ahead of it produced labels that had to be withdrawn, so the mapping waits for the RFC instead.
Their ids, catalog paths, menu rows and saved values were never renamed, so re-applying a name later stays a display-map change. Two of them, Pitch Shift Legacy and Time Compression Expansion, sit in menus whose other entries show our processor name (Pitch Shift, Time Stretch). They are listed beside that processor under their own catalog label rather than folded into it, because they wait on the RFC and because a menu item is never dropped.
## Disabled placeholders
Four features we do not have keep their five catalog leaves: Tempo Sync (the incumbent's Ableton Link item), No ARA plug-ins (Track and Clip › ARA Plugins › Melodyne) and Analog Saturation (View › Mix Window Views › HEAT and Options › Activate HEAT). They keep the incumbent's place in the menu, and therefore the parity floor, but they are never interactive: the menu draws them disabled with a "Not available yet." hover, `item_state` refuses them whatever their command would allow, and no label claims the protocol, plug-in format or processor behind them. "No ARA plug-ins" says what the item is: a place where plug-ins of that kind would be listed once a host exists. Preferences already has the same rule for settings without a backend.
Adding a placeholder means adding its catalog path to `PLACEHOLDERS` in `crates/engine/src/catalog.rs`; an engine test checks that each one is still a catalog entry with a descriptive label, and a UI test opens each one and checks that it is still in the menu and stays disabled. A placeholder becomes a feature by wiring a command and removing its path from that list.
## Offline processor names
| Previous catalog item | Our processor name / id |
| --- | --- |
| EQ3 1-Band / EQ3 7-Band | EQ 1-Band / `eq_1band`; EQ 7-Band / `eq_7band` |
| BF-76 / Dyn3 Compressor/Limiter | Compressor/Limiter / `compressor` |
| Dyn3 De-Esser | De-Esser / `de_esser` |
| Dyn3 Expander/Gate | Expander/Gate / `expander_gate` |
| Maxim | Maximizer / `maximizer` |
| Pitch II | Pitch Shift / `pitch_shift` |
| Time Shift | Time Stretch / `time_stretch` |
| Vari-Fi | Speed Ramp / `speed_ramp` |
| D-Verb | Room Reverb / `room_reverb` |
| Mod Delay III | Mod Delay / `mod_delay` |
| Sci-Fi | Ring Modulator / `ring_mod` |
| Eleven Lite / SansAmp PSA-1 | Saturator / `saturator` |
| Lo-Fi | Bit Crusher / `lofi` |
| Recti-Fi | Rectifier / `rectifier` |
Time Shift here is the incumbent's offline pitch-and-time process, which our `time_stretch` implements. It is not our own Time Shift plug-in (`time_shift`), a millisecond or sample delay that stays under that name; the two share a name and nothing else.
Other offline items already have descriptive names. Multiple legacy entries mapping to the same processor appear once per menu category. Every historical catalog path remains invokable. The catalog remains an observed interoperability/parity record; it is not used verbatim for rendered labels. Changing `lofi`'s display name does not change its id, parameters or audio implementation.
## Product-name decision
Deferred by the owner. The SoundCraft product name remains unchanged; this work only changes feature labels.
The SoundCraft app name has a separate clearance concern involving the established audio brand Soundcraft. A candidate for review is **ArtCraft Audio**, a descriptive extension of the existing suite identity. It is only a proposal; no availability or legal clearance is claimed. Avoid adopting another audio product name simply because it ends in “Craft.”
A product rename needs an owner decision and a separate migration PR covering app/window titles, website and release copy, package/binary names, bundle/application ids, installers, desktop files, update feeds, settings locations, file associations, shortcuts, URLs and external integrations. Preserve old command/package aliases and discover existing user data rather than resetting it. Verify the existing ArtCraft mark license applies to the new context before changing any brand assets. This feature-label draft makes none of those product-identity changes.
## Validation and release gates
- Regression tests cover old catalog-to-command mappings, own processor names, deduplication, the deferred leaves that keep their catalog label, and continued opening of the Beat Analysis dialog through its legacy menu path. Guards scan the rendered menu tree, UI/engine command labels and processor display names for retired labels; compatibility paths, ids and the deferred names are excluded, and unknown display text still falls back unchanged.
- Menu tree identity uses the stable path, not the rendered name.
- This main branch currently ships an English-only interface. Carry the mapping into pending localization PRs as they land; do not reintroduce vendor marks in translated labels.
- Run `cargo xtask ci` on the final head. Exercise offline processing from the AudioSuite menus, old automation paths, opening saved sessions containing `lofi` and `x-form`, Undo/Redo, and preference search.
- Recapture the UI with the supported `ui_shot` example. `docs/images/edit.png` has been visually inspected and still shows the old processing-menu title. Inspect and refresh all affected README images and attribution records from the running app; unchanged screenshots are not claimed to be refreshed by this draft.
- Review current help/marketing copy and localization work after integration. Historical parity documents, API examples and factual interoperability references may retain old names with their context. Preserve license/attribution records.