mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-09 11:50:40 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ff012ab88 | ||
|
|
041230dc33 | ||
|
|
06a295be46 | ||
|
|
63d0a83542 | ||
|
|
81c11215cb | ||
|
|
bfff03de37 | ||
|
|
a0bdd38b16 | ||
|
|
e713d75a19 |
@@ -15,14 +15,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
release:
|
||||
needs: credits
|
||||
# The shared workflow creates the draft release, which needs write access to contents.
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -33,7 +26,6 @@ jobs:
|
||||
app-name: soundcraft
|
||||
app-title: SoundCraft
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
Generated
+37
-24
@@ -1064,17 +1064,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1097,7 +1097,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -1105,7 +1105,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1126,7 +1126,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -4330,7 +4330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -4354,7 +4354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4364,7 +4364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4376,7 +4376,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
@@ -4389,7 +4389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -4402,7 +4402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -4420,7 +4420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4431,7 +4431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
@@ -4454,7 +4454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4464,7 +4464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -4480,7 +4480,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4493,7 +4493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -4508,7 +4508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4518,7 +4518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -4548,7 +4548,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -4560,7 +4560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -4576,7 +4576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6224,7 +6224,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6456,6 +6456,7 @@ dependencies = [
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6470,6 +6471,18 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
|
||||
+5
-5
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -50,15 +50,15 @@ zip = { version = "9", default-features = false }
|
||||
cpal = "0.18"
|
||||
|
||||
# Which file paths stored in a session may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
|
||||
egui = { version = "0.37" }
|
||||
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
|
||||
@@ -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 Analysis and Identify Beat;
|
||||
pitch-preserving Elastic Audio warping.
|
||||
S-curve tempo ramps; key signatures and detected chords; Beat Detective and Identify Beat;
|
||||
pitch-preserving Elastic 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
|
||||
|
||||
@@ -108,11 +108,9 @@ impl eframe::App for App {
|
||||
logger.report_audio_thread();
|
||||
}
|
||||
// The system Quit (⌘Q) ends the process from inside the event loop, so `run_native` never
|
||||
// returns: release the player's plugin instances and shut plugin hosting down here, and
|
||||
// start the helper of an update that waits for the app to close.
|
||||
// returns: release the player's plugin instances and shut plugin hosting down here.
|
||||
self.0.player = None;
|
||||
soundcraft_engine::shutdown_plugin_hosts();
|
||||
self.0.start_pending_update();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Harmonic effects: Saturator (tanh), Bit Crusher (bit/sample-rate reduction), Rectifier and
|
||||
//! Harmonic effects: Saturator (tanh), Lo-Fi (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: "Bit Crusher",
|
||||
short_name: "Crush",
|
||||
name: "Lo-Fi",
|
||||
short_name: "LoFi",
|
||||
category: Category::Harmonic,
|
||||
params: &LOFI_PARAMS,
|
||||
is_instrument: false,
|
||||
|
||||
@@ -65,50 +65,13 @@ 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())
|
||||
.chain(LEGACY_LABEL_ALIASES.iter())
|
||||
.find(|(p, _)| *p == entry)
|
||||
{
|
||||
if let Some((_, id)) = ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()).chain(extra.iter()).find(|(p, _)| *p == entry) {
|
||||
return Some((*id).to_string());
|
||||
}
|
||||
let parts: Vec<&str> = entry.split(" > ").collect();
|
||||
@@ -153,7 +116,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn aliases_point_at_real_commands() {
|
||||
for (path, id) in ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()).chain(LEGACY_LABEL_ALIASES.iter()) {
|
||||
for (path, id) in ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()) {
|
||||
assert!(crate::find_command(id).is_some(), "{path} → unknown {id}");
|
||||
assert!(catalog().contains(path), "alias for unknown catalog entry {path}");
|
||||
}
|
||||
@@ -168,40 +131,4 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
shuffle_lock
|
||||
),
|
||||
cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10, F6+F7, F7+F8, Cmd+6-7"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| {
|
||||
cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| {
|
||||
let t = str_param(p, "tool").and_then(soundcraft_model::Tool::from_id).ok_or_else(|| bad("edit.tool", "unknown tool"))?;
|
||||
e.session_mut().edit.tool = t;
|
||||
Ok(json!({"tool": t}))
|
||||
|
||||
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(
|
||||
"event.beat_detective",
|
||||
"Beat Analysis",
|
||||
"Beat Detective",
|
||||
["Event"],
|
||||
None,
|
||||
"{tracks?, start?, end?, sensitivity?: 0.5, separate?: false, set_tempo?: false}",
|
||||
|
||||
@@ -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", "MusicXML...", ["File", "Export"], None, "{path, tracks?} — MIDI tracks as MusicXML (.musicxml)", always, |e, p| {
|
||||
cmd!(noundo "file.export_sibelius", "Sibelius...", ["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", "MusicXML", ["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", "Sibelius", ["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| {
|
||||
|
||||
@@ -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", "Punch Recording", ["Options"], Some("Cmd+Shift+P"), quickpunch),
|
||||
toggle!("options.quickpunch", "QuickPunch", ["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),
|
||||
|
||||
@@ -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", "Tempo Sync", ["View", "Transport"], "view.transport.ableton_link"),
|
||||
flag!("view.transport_ableton_link", "Ableton Link", ["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| {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-78
@@ -178,12 +178,8 @@ pub fn stored_path(stored: &str, session: &Path) -> std::result::Result<PathBuf,
|
||||
craft_docpath::resolve_absolute(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))
|
||||
}
|
||||
|
||||
/// Create `dir` and its parents, inside the automation write root when there is one. The browser
|
||||
/// has no folders: there each file is delivered by its name ([`write_file`]) and this does nothing.
|
||||
/// Create `dir` and its parents, inside the automation write root when there is one.
|
||||
pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Ok(());
|
||||
}
|
||||
let dir = dir.as_ref();
|
||||
// The empty folder of a bare file name ("t.scraft") is the working directory, which the root
|
||||
// check would refuse as an empty path.
|
||||
@@ -567,48 +563,29 @@ impl From<StripNames> for soundcraft_model::AutoName {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write each clip as a WAV file named after it into `dir` (in the browser, delivered by name
|
||||
/// through [`write_file`]). Returns how many were written.
|
||||
pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> Result<usize> {
|
||||
let s = e.session();
|
||||
make_dir(dir)?;
|
||||
let mut names = ClipNames::default();
|
||||
let mut n = 0;
|
||||
for id in ids {
|
||||
let Some((t, c)) = s.find_clip(*id) else { continue };
|
||||
let audio = soundcraft_mix::render_clips(s, t, c.range());
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
|
||||
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let path = clip_file(dir, &sanitize_name(&c.name), &mut names, Path::exists)?;
|
||||
write_file(&path.to_string_lossy(), &bytes)?;
|
||||
}
|
||||
Ok(names.taken.len())
|
||||
}
|
||||
|
||||
/// The files an export has named so far, and for each name the suffix to try next, so a run of
|
||||
/// same-named clips doesn't count up from 2 again for every clip.
|
||||
#[derive(Default)]
|
||||
struct ClipNames {
|
||||
taken: std::collections::HashSet<PathBuf>,
|
||||
next: std::collections::HashMap<String, u32>,
|
||||
}
|
||||
|
||||
/// The first of `<stem>.wav`, `<stem>-2.wav`, … in `dir` that is neither `on_disk` nor already
|
||||
/// named by this export, recorded in `names`: the browser has no disk to look at, so same-named
|
||||
/// clips are kept apart by `names`.
|
||||
fn clip_file(dir: &str, stem: &str, names: &mut ClipNames, on_disk: impl Fn(&Path) -> bool) -> Result<PathBuf> {
|
||||
let file = |k: u32| Path::new(dir).join(if k < 2 { format!("{stem}.wav") } else { format!("{stem}-{k}.wav") });
|
||||
let mut k = names.next.get(stem).copied().unwrap_or(1);
|
||||
let mut path = file(k);
|
||||
while on_disk(&path) || names.taken.contains(&path) {
|
||||
k = k.saturating_add(1);
|
||||
if k > 10_000 {
|
||||
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
|
||||
let stem = sanitize_name(&c.name);
|
||||
let mut path = Path::new(dir).join(format!("{stem}.wav"));
|
||||
let mut k = 2u32;
|
||||
while path.exists() {
|
||||
path = Path::new(dir).join(format!("{stem}-{k}.wav"));
|
||||
k = k.saturating_add(1);
|
||||
if k > 10_000 {
|
||||
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
|
||||
}
|
||||
}
|
||||
path = file(k);
|
||||
write_path(&path, bytes)?;
|
||||
n += 1;
|
||||
}
|
||||
names.next.insert(stem.to_owned(), k.saturating_add(1));
|
||||
names.taken.insert(path.clone());
|
||||
Ok(path)
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Write `session` to `path`; returns the session with updated media paths, the number of audio
|
||||
@@ -1019,44 +996,3 @@ mod stem_tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod clip_tests {
|
||||
/// The browser has no disk to find earlier files on: same-named clips get their own names
|
||||
/// from the export itself, as they do from the folder natively (#1409).
|
||||
#[test]
|
||||
fn same_named_clips_get_their_own_names_with_nothing_on_disk() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-clip-names-{}", std::process::id()));
|
||||
let mut names = super::ClipNames::default();
|
||||
for want in ["Take.wav", "Take-2.wav", "Take-3.wav"] {
|
||||
let path = super::clip_file(&dir.to_string_lossy(), "Take", &mut names, std::path::Path::exists).unwrap();
|
||||
assert_eq!(path, dir.join(want));
|
||||
}
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
|
||||
/// A file already in the folder is still skipped, and a run of same-named clips picks up where
|
||||
/// the last one left off instead of looking at every earlier name again for each clip.
|
||||
#[test]
|
||||
fn same_named_clips_skip_files_on_disk_and_do_not_count_up_from_the_start_each_time() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-clip-disk-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("Take-2.wav"), b"").unwrap();
|
||||
let looked = std::cell::Cell::new(0usize);
|
||||
let on_disk = |p: &std::path::Path| {
|
||||
looked.set(looked.get() + 1);
|
||||
p.exists()
|
||||
};
|
||||
let mut names = super::ClipNames::default();
|
||||
for want in ["Take.wav", "Take-3.wav", "Take-4.wav"] {
|
||||
let path = super::clip_file(&dir.to_string_lossy(), "Take", &mut names, on_disk).unwrap();
|
||||
assert_eq!(path, dir.join(want));
|
||||
}
|
||||
let before = looked.get();
|
||||
for _ in 0..1_000 {
|
||||
super::clip_file(&dir.to_string_lossy(), "Take", &mut names, on_disk).unwrap();
|
||||
}
|
||||
assert_eq!(looked.get() - before, 1_000);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ 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;
|
||||
|
||||
+10
-26
@@ -33,10 +33,10 @@ fn as_audio_thread(audio: bool, f: impl FnOnce()) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Send) {
|
||||
use rayon::prelude::*;
|
||||
// The audio thread is not a rayon worker, so it would park on a latch waiting for the shared
|
||||
// pool (also busy with offline work): it renders its strips itself.
|
||||
if work.len() >= 4 && !on_audio_thread() {
|
||||
work.par_iter_mut().for_each(|w| as_audio_thread(false, || f(w)));
|
||||
if work.len() >= 4 {
|
||||
// The workers render on behalf of this thread: on the audio thread, they are it too.
|
||||
let audio = on_audio_thread();
|
||||
work.par_iter_mut().for_each(|w| as_audio_thread(audio, || f(w)));
|
||||
} else {
|
||||
work.iter_mut().for_each(f);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ pub(crate) fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Se
|
||||
work.iter_mut().for_each(f);
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
#[cfg(test)]
|
||||
mod audio_thread_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -67,7 +67,7 @@ mod audio_thread_tests {
|
||||
|
||||
/// What `on_audio_thread` says inside each strip job, for strips run from a thread that is
|
||||
/// (or is not) the audio thread.
|
||||
fn seen_by_strips(audio: bool) -> Result<Vec<bool>, &'static str> {
|
||||
fn seen_by_strips(audio: bool) -> Vec<bool> {
|
||||
std::thread::spawn(move || {
|
||||
if audio {
|
||||
mark_audio_thread();
|
||||
@@ -78,31 +78,15 @@ mod audio_thread_tests {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
*w = Some(on_audio_thread());
|
||||
});
|
||||
work.into_iter().collect::<Option<Vec<_>>>().ok_or("a strip did not run")
|
||||
work.into_iter().map(|w| w.unwrap()).collect()
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| "the strip render thread panicked")?
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_rendered_for_the_audio_thread_count_as_the_audio_thread_and_only_then() {
|
||||
assert_eq!(seen_by_strips(true), Ok(vec![true; 64]), "strips rendered for the audio thread are on it");
|
||||
assert_eq!(seen_by_strips(false), Ok(vec![false; 64]), "the workers don't stay marked afterwards");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_audio_thread_never_hands_strips_to_the_rayon_pool() {
|
||||
let on_pool = std::thread::spawn(|| {
|
||||
mark_audio_thread();
|
||||
let mut work = vec![false; 8];
|
||||
run_strips(&mut work, |w| {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
*w = rayon::current_thread_index().is_some();
|
||||
});
|
||||
work.into_iter().filter(|&p| p).count()
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
assert_eq!(on_pool, 0, "the audio thread must not block on the rayon pool");
|
||||
assert!(seen_by_strips(true).iter().all(|&rt| rt), "a worker rendering for the audio thread is on it");
|
||||
assert!(seen_by_strips(false).iter().all(|&rt| !rt), "the workers don't stay marked afterwards");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Event-menu operation windows: Beat Analysis, Tempo Operations, Time Operations, MIDI
|
||||
//! Event-menu operation windows: Beat Detective, 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 Analysis window: sensitivity, separate and set-tempo options, Analyze and Apply results, and Identify Beat.
|
||||
/// Beat Detective 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 Analysis").open(&mut open).default_size(vec2(360.0, 220.0)).show(ctx, |ui| {
|
||||
egui::Window::new("Beat Detective").open(&mut open).default_size(vec2(360.0, 220.0)).show(ctx, |ui| {
|
||||
ui.label("Analyses the edit selection on the selected tracks.");
|
||||
ui.add(egui::Slider::new(&mut app.ops.beat_sensitivity, 0.0..=1.0).text("Sensitivity"));
|
||||
crate::widgets::checkbox(ui, &mut app.ops.beat_separate, "Separate clips at detected beats");
|
||||
|
||||
@@ -472,11 +472,6 @@ pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWin
|
||||
// and bottom edges take frames (`drop_edges` keeps its default).
|
||||
style.new_dock_zones = true;
|
||||
style.side_edge_zones = false;
|
||||
// The MIDI body's rect stands for a piano roll on screen (the arrow keys go to it), so only a
|
||||
// frame that draws the panel records one: open behind another tab, the old rect is gone.
|
||||
if edit {
|
||||
ui.ctx().data_mut(|data| data.remove::<egui::Rect>(egui::Id::unique("soundcraft-midi-body")));
|
||||
}
|
||||
let output = DockArea::new(area(edit)).show_with_limits(
|
||||
ui,
|
||||
&workspace.layout,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use super::layout::STRIP_W;
|
||||
use super::tracks::{click_track, folder_depth, height_targets, in_edit_window, track_context_menu, view_label, view_menu};
|
||||
use super::{
|
||||
Align2, AutoParam, Color32, Pos2, Rect, Sense, Shape, SoundApp, Stroke, Tokens, Track, TrackHeight, TrackId, TrackKind, Ui, bold, groove, json,
|
||||
@@ -15,7 +14,6 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head:
|
||||
click_track(app, id, ui.input(|i| i.modifiers), in_edit_window);
|
||||
}
|
||||
bg.context_menu(|ui| track_context_menu(app, ui, id));
|
||||
color_strip(app, ui, id, head);
|
||||
resize_edge(app, ui, track, head);
|
||||
strip_buttons(app, ui, track, head);
|
||||
name_plate(app, ui, track, head, selected);
|
||||
@@ -28,28 +26,6 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head:
|
||||
header_meter(ui, Rect::from_min_max(pos2(head.max.x - 8.0, head.min.y + 1.0), pos2(head.max.x, head.max.y - 1.5)), &md, track.channels() >= 2);
|
||||
}
|
||||
|
||||
/// The colour strip: a click selects the track as the header does, a double click opens the Color
|
||||
/// Palette for the selected tracks. A plain click on a track that is already selected keeps the
|
||||
/// selection, so double-clicking one of several selected tracks colours them all. Registered after
|
||||
/// the header background, so it sits on top of it, and before the strip's own buttons and the
|
||||
/// resize edge, which stay on top of it.
|
||||
fn color_strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, head: Rect) {
|
||||
let strip = Rect::from_min_size(head.min, vec2(STRIP_W, head.height()));
|
||||
let resp = ui.interact(strip, ui.scope_id().with(("color_strip", id.0)), Sense::click());
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let selected = app.engine.session().edit.selected_tracks.contains(&id);
|
||||
if resp.double_clicked() {
|
||||
// A Cmd click toggles, so a Cmd double click's first click deselected the track: add it back.
|
||||
if !selected {
|
||||
click_track(app, id, mods, in_edit_window);
|
||||
}
|
||||
app.ui.show_color_palette = true;
|
||||
} else if resp.clicked() && !(selected && mods.is_none()) {
|
||||
click_track(app, id, mods, in_edit_window);
|
||||
}
|
||||
resp.context_menu(|ui| track_context_menu(app, ui, id));
|
||||
}
|
||||
|
||||
/// Drag the bottom edge of the header to resize the track (snaps to the height presets).
|
||||
fn resize_edge(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
|
||||
let id = track.id;
|
||||
@@ -389,18 +365,12 @@ 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,
|
||||
soundcraft_engine::feature_names::display_label(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, 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, soundcraft_engine::feature_names::display_label(alg)).clicked() {
|
||||
if ui.selectable_label(current, alg).clicked() {
|
||||
let _ = app.run("track.elastic", json!({"tracks": [id.0], "algorithm": alg}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,53 +534,6 @@ fn shift_and_cmd_click_on_track_names_extend_the_selection() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1254: double-clicking a track's colour strip selects the track and opens the Color Palette;
|
||||
/// a single click only selects it.
|
||||
#[test]
|
||||
fn double_clicking_the_color_strip_opens_the_color_palette() {
|
||||
let mut h = harness(3);
|
||||
let ids = row_ids(&h);
|
||||
let strip = |h: &Harness<SoundApp>, i: usize| pos2(row(h, i).min.x + layout::STRIP_W / 2.0, row(h, i).center().y);
|
||||
h.state_mut().engine.execute("edit.select", &json!({"tracks": [ids[0].0]})).unwrap();
|
||||
h.run_steps(2);
|
||||
let at = strip(&h, 1);
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[1]]);
|
||||
assert!(!h.state().ui.show_color_palette, "a single click on the strip doesn't open the palette");
|
||||
h.run_steps(30);
|
||||
let at = strip(&h, 2);
|
||||
click(&mut h, at);
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[2]]);
|
||||
assert!(h.state().ui.show_color_palette, "a double click on the strip opens the palette");
|
||||
}
|
||||
|
||||
/// #1254: a double click on the strip of one of several selected tracks keeps them all selected,
|
||||
/// so the palette colours every one; with Cmd held the double-clicked track ends selected too.
|
||||
#[test]
|
||||
fn double_clicking_a_selected_tracks_strip_keeps_the_selection() {
|
||||
let mut h = harness(3);
|
||||
let ids = row_ids(&h);
|
||||
let strip = |h: &Harness<SoundApp>, i: usize| pos2(row(h, i).min.x + layout::STRIP_W / 2.0, row(h, i).center().y);
|
||||
h.state_mut().engine.execute("edit.select", &json!({"tracks": [ids[0].0, ids[2].0]})).unwrap();
|
||||
h.run_steps(2);
|
||||
let at = strip(&h, 2);
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[0], ids[2]], "a plain click on a selected strip keeps the selection");
|
||||
assert!(!h.state().ui.show_color_palette);
|
||||
click(&mut h, at);
|
||||
assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[0], ids[2]], "both tracks stay selected");
|
||||
assert!(h.state().ui.show_color_palette, "a double click on the strip opens the palette");
|
||||
|
||||
h.state_mut().ui.show_color_palette = false;
|
||||
h.run_steps(30);
|
||||
let at = strip(&h, 0);
|
||||
click_with(&mut h, at, egui::Modifiers::COMMAND);
|
||||
click_with(&mut h, at, egui::Modifiers::COMMAND);
|
||||
assert!(h.state().engine.session().edit.selected_tracks.contains(&ids[0]), "a Cmd double click leaves its track selected");
|
||||
assert!(h.state().ui.show_color_palette, "a Cmd double click on the strip opens the palette");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quick_drag_from_a_clip_edge_trims_it() {
|
||||
let mut h = smart_demo();
|
||||
|
||||
+10
-231
@@ -1,8 +1,5 @@
|
||||
//! 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};
|
||||
@@ -63,7 +60,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 Analysis", "", None),
|
||||
("window.beat_detective", "Beat Detective", "", None),
|
||||
("window.tempo_ops", "Tempo Operations", "", None),
|
||||
("window.time_ops", "Time Operations", "", None),
|
||||
("window.midi_ops", "MIDI Operations", "", None),
|
||||
@@ -193,10 +190,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.path == path) {
|
||||
let idx = match level.iter().position(|n| n.label == *p) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
level.push(MenuNode { label: display_menu_label(&path, p), path: path.clone(), children: Vec::new() });
|
||||
level.push(MenuNode { label: p.to_string(), path: path.clone(), children: Vec::new() });
|
||||
level.len() - 1
|
||||
}
|
||||
};
|
||||
@@ -205,57 +202,21 @@ 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.path == "Clip") {
|
||||
let at = clip.children.iter().position(|n| n.path == "Clip > Identify Sync Point").map_or(clip.children.len(), |i| i + 1);
|
||||
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);
|
||||
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.path == "Clip") {
|
||||
if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") {
|
||||
clip.children.rotate_right(1);
|
||||
}
|
||||
for (path, _) in MENU_WINDOWS {
|
||||
@@ -308,11 +269,11 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
|
||||
instrument_menu(app, ui);
|
||||
}
|
||||
}
|
||||
if root.path == "AudioSuite" {
|
||||
if root.label == "AudioSuite" {
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
|
||||
}
|
||||
if root.path == "Window" {
|
||||
if root.label == "Window" {
|
||||
ui.separator();
|
||||
let mut separate = app.ui.mix_separate;
|
||||
if ui
|
||||
@@ -373,9 +334,7 @@ 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()));
|
||||
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()
|
||||
if ui.add_enabled(enabled, btn).clicked()
|
||||
&& let Some(id) = id
|
||||
{
|
||||
invoke_menu(app, &id, &n.path);
|
||||
@@ -390,7 +349,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 || catalog::is_placeholder(path) {
|
||||
let enabled = if app.ui.show_preferences {
|
||||
false
|
||||
} else {
|
||||
match id {
|
||||
@@ -943,184 +902,4 @@ 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-157
@@ -54,13 +54,8 @@ fn search_chord(m: Modifiers, mac: bool) -> bool {
|
||||
mac && m.command && m.ctrl
|
||||
}
|
||||
|
||||
/// Commands a host auto-repeat runs again, so holding a zoom key keeps zooming (#1377). Every other
|
||||
/// shortcut ignores auto-repeats (#1063): holding Cmd+Z undoes once. Decided by the command a chord
|
||||
/// resolves to, not by its keys.
|
||||
const REPEATING: [&str; 3] = ["view.zoom_in", "view.zoom_out", "view.waveform_zoom"];
|
||||
|
||||
/// Dispatch global keyboard shortcuts once no text field or dialog wants input, ignoring non-scripted auto-repeats
|
||||
/// (except the [`REPEATING`] zoom commands) while still running a host press alongside a scripted one.
|
||||
/// while still running a host press alongside a scripted one.
|
||||
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
// Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom.
|
||||
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||||
@@ -69,29 +64,29 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
}
|
||||
// A scripted press is identified by its exact position in this pass's events, not by matching
|
||||
// key/modifier values. A fresh host press with the same chord must still run its own action in
|
||||
// addition to the scripted one, while a host auto-repeat runs only a zoom command: egui rewrites
|
||||
// an event's `repeat` flag from the physical key state, which says nothing about whether that
|
||||
// particular event was injected.
|
||||
// addition to the scripted one, while a host auto-repeat stays ignored: egui rewrites an
|
||||
// event's `repeat` flag from the physical key state, which says nothing about whether that
|
||||
// particular event was injected. Every non-scripted repeat is ignored as before.
|
||||
let scripted = app.scripted_key_press_indices();
|
||||
let events: Vec<(Key, Modifiers, bool)> = ctx.input(|i| {
|
||||
let events: Vec<(Key, Modifiers)> = ctx.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, e)| match e {
|
||||
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => Some((*key, *modifiers, *repeat && !scripted.contains(&index))),
|
||||
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => {
|
||||
if scripted.contains(&index) {
|
||||
Some((*key, *modifiers))
|
||||
} else {
|
||||
(!*repeat).then_some((*key, *modifiers))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
// Cmd and Ctrl are separate keys only on macOS (asked at runtime so the web build in a Mac browser agrees).
|
||||
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
|
||||
for (key, mods, repeat) in events {
|
||||
if repeat {
|
||||
if let Some((id, params)) = repeating_command(key, mods, mac) {
|
||||
let _ = app.run(id, params);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (key, mods) in events {
|
||||
if fixed(app, key, mods, mac) {
|
||||
continue;
|
||||
}
|
||||
@@ -110,14 +105,6 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
|
||||
}
|
||||
|
||||
/// The command a host auto-repeat of this chord runs, if it is one of [`REPEATING`]. A repeat
|
||||
/// resolves its chord the way a press falls through to these zooms ([`zoom_key`], then the
|
||||
/// registry) without the earlier [`fixed`] / [`fixed_more`] arms, so an arm added there for one of
|
||||
/// these chords must keep the two paths in agreement.
|
||||
fn repeating_command(key: Key, mods: Modifiers, mac: bool) -> Option<(&'static str, serde_json::Value)> {
|
||||
zoom_key(key, mods, mac).or_else(|| command_for(key, mods, mac).map(|id| (id, json!({})))).filter(|(id, _)| REPEATING.contains(id))
|
||||
}
|
||||
|
||||
fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> {
|
||||
soundcraft_engine::command_specs()
|
||||
.iter()
|
||||
@@ -179,13 +166,14 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
|
||||
true
|
||||
}
|
||||
Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 if plain => {
|
||||
let _ = app.run("edit.tool", json!({"tool": tool_key(app, key)}));
|
||||
true
|
||||
}
|
||||
// Cmd+6 / Cmd+7 (Ctrl off macOS) are Pencil and the Smart Tool. Cmd+1–5 keep their window and Session
|
||||
// keys: egui reports the numeric keypad's digits as the main keyboard's, so the two can't be told apart.
|
||||
Key::Num6 | Key::Num7 if mods_match(Modifiers::COMMAND, m, mac) => {
|
||||
let tool = if key == Key::Num6 { "pencil" } else { "smart" };
|
||||
let tool = match key {
|
||||
Key::F5 => "zoom",
|
||||
Key::F6 => "trim",
|
||||
Key::F7 => "selector",
|
||||
Key::F8 => "grabber",
|
||||
Key::F9 => "scrubber",
|
||||
_ => "pencil",
|
||||
};
|
||||
let _ = app.run("edit.tool", json!({"tool": tool}));
|
||||
true
|
||||
}
|
||||
@@ -211,7 +199,12 @@ fn fixed_more(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
|
||||
let _ = app.run("edit.tab", json!({"back": m.alt || (!mac && m.ctrl), "extend": m.shift}));
|
||||
true
|
||||
}
|
||||
// The registry's key families "Cmd+Alt+0-5" and "Cmd+Ctrl+1-5", which `parse` can't read ("Cmd+Alt+[ / ]" is in `zoom_key`).
|
||||
// The registry's key families "Cmd+Alt+[ / ]", "Cmd+Alt+0-5" and "Cmd+Ctrl+1-5", which `parse` can't read.
|
||||
Key::OpenBracket | Key::CloseBracket if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => {
|
||||
let factor = if key == Key::CloseBracket { 2.0 } else { 0.5 };
|
||||
let _ = app.run("view.waveform_zoom", json!({"factor": factor}));
|
||||
true
|
||||
}
|
||||
Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => {
|
||||
let _ = app.run("clip.rating", json!({"rating": digit(key)}));
|
||||
true
|
||||
@@ -239,11 +232,6 @@ fn fixed_more(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
|
||||
let _ = app.run(id, json!({}));
|
||||
true
|
||||
}
|
||||
// With an edit selection (a selected clip makes one), Left/Right centre the Edit window on its start/end.
|
||||
// Over the piano roll the arrows move its notes instead.
|
||||
Key::ArrowLeft | Key::ArrowRight if plain && app.ui.window == MainWindow::Edit && !piano_roll_has_pointer(app) => {
|
||||
centre_on_selection(app, key == Key::ArrowRight)
|
||||
}
|
||||
// Commands Keyboard Focus: single-key editing (Edit window only).
|
||||
k if !m.command
|
||||
&& !m.alt
|
||||
@@ -254,26 +242,13 @@ fn fixed_more(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
|
||||
{
|
||||
true
|
||||
}
|
||||
_ => match zoom_key(key, m, mac) {
|
||||
Some((id, params)) => {
|
||||
let _ = app.run(id, params);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The zoom keys [`fixed`] handles, shared with [`repeating_command`]: T / R zoom the timeline in / out
|
||||
/// (Commands Focus style) and the registry's "Cmd+Alt+[ / ]", which `parse` can't read, steps the waveform zoom.
|
||||
fn zoom_key(key: Key, m: Modifiers, mac: bool) -> Option<(&'static str, serde_json::Value)> {
|
||||
let plain = !m.command && !m.alt && !m.ctrl && !m.shift;
|
||||
match key {
|
||||
Key::R | Key::T if plain => Some((if key == Key::T { "view.zoom_in" } else { "view.zoom_out" }, json!({}))),
|
||||
Key::OpenBracket | Key::CloseBracket if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => {
|
||||
Some(("view.waveform_zoom", json!({"factor": if key == Key::CloseBracket { 2.0 } else { 0.5 }})))
|
||||
Key::R | Key::T if plain => {
|
||||
// Zoom out / in on the timeline (Commands Focus style).
|
||||
let id = if key == Key::T { "view.zoom_in" } else { "view.zoom_out" };
|
||||
let _ = app.run(id, json!({}));
|
||||
true
|
||||
}
|
||||
_ => None,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,22 +288,6 @@ fn edit_mode_key(app: &mut SoundApp, pressed: Key) {
|
||||
let _ = app.run("edit.mode", json!({"mode": mode, "snap_to_grid": snap}));
|
||||
}
|
||||
|
||||
/// F5–F10 choose the Edit tools; F7 with F6 or F8 held, in either order, chooses the Smart Tool.
|
||||
fn tool_key(app: &SoundApp, pressed: Key) -> &'static str {
|
||||
let ctx = app.docking_context.as_ref();
|
||||
let held = |k: Key| ctx.is_some_and(|c| c.input(|i| i.key_down(k)));
|
||||
match pressed {
|
||||
Key::F6 | Key::F8 if held(Key::F7) => "smart",
|
||||
Key::F7 if held(Key::F6) || held(Key::F8) => "smart",
|
||||
Key::F5 => "zoom",
|
||||
Key::F6 => "trim",
|
||||
Key::F7 => "selector",
|
||||
Key::F8 => "grabber",
|
||||
Key::F9 => "scrubber",
|
||||
_ => "pencil",
|
||||
}
|
||||
}
|
||||
|
||||
/// What choosing Grid picks: in Grid the other Grid mode, from another mode the one used last.
|
||||
pub(crate) fn grid_mode(app: &SoundApp) -> &'static str {
|
||||
use soundcraft_model::EditMode;
|
||||
@@ -434,30 +393,6 @@ fn tab_clip(app: &mut SoundApp, back: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the Edit window so the edit selection's start (or `end`) is in its middle. Returns false, leaving
|
||||
/// the key unhandled, when there is only an insertion point.
|
||||
fn centre_on_selection(app: &mut SoundApp, end: bool) -> bool {
|
||||
let s = app.engine.session();
|
||||
let sel = s.edit.selection;
|
||||
if sel.start >= sel.end {
|
||||
return false;
|
||||
}
|
||||
let [x0, _, x1, _] = app.edit_layout.timeline;
|
||||
let half = f64::from((x1 - x0).max(0.0)) / 2.0 * s.edit.zoom.samples_per_px.max(0.01);
|
||||
let at = if end { sel.end } else { sel.start };
|
||||
let _ = app.run("view.scroll", json!({"to": at.saturating_sub(soundcraft_time::to_samples(half)).max(0)}));
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the pointer is over the MIDI editor, whose piano roll moves the selected notes with the arrow keys.
|
||||
fn piano_roll_has_pointer(app: &SoundApp) -> bool {
|
||||
app.ui.show_midi_editor
|
||||
&& app.docking_context.as_ref().is_some_and(|c| {
|
||||
let body = c.data(|d| d.get_temp::<egui::Rect>(egui::Id::unique("soundcraft-midi-body")));
|
||||
c.pointer_hover_pos().zip(body).is_some_and(|(p, r)| r.contains(p))
|
||||
})
|
||||
}
|
||||
|
||||
fn digit(key: Key) -> u8 {
|
||||
key.name().parse().unwrap_or(0)
|
||||
}
|
||||
@@ -545,63 +480,4 @@ mod tests {
|
||||
assert_eq!(command_for(Key::K, Modifiers { shift: true, ..PC_CTRL }, false), Some("file.export_clips"));
|
||||
assert!(crate::dialogs::takes_path("file.export_clips"));
|
||||
}
|
||||
|
||||
fn app_with_selection() -> crate::SoundApp {
|
||||
let mut app = crate::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default());
|
||||
app.ui.window = crate::MainWindow::Edit;
|
||||
app.edit_layout.timeline = [0.0, 0.0, 100.0, 100.0];
|
||||
app.run("view.zoom_set", serde_json::json!({"samples_per_px": 10.0})).unwrap();
|
||||
app.run("edit.select", serde_json::json!({"start": 2000, "end": 4000})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_arrows_centre_selection_but_modified_arrows_do_not() {
|
||||
for mac in [false, true] {
|
||||
for (key, expected) in [(Key::ArrowLeft, 1500), (Key::ArrowRight, 3500)] {
|
||||
let mut app = app_with_selection();
|
||||
assert!(super::fixed_more(&mut app, key, Modifiers::NONE, mac));
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll, expected);
|
||||
for modifiers in [Modifiers::SHIFT, Modifiers::ALT, Modifiers::CTRL, Modifiers::COMMAND, PC_CTRL, MAC_CMD] {
|
||||
app.run("view.scroll", serde_json::json!({"to": 123})).unwrap();
|
||||
assert!(!super::fixed_more(&mut app, key, modifiers, mac));
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll, 123, "{key:?} {modifiers:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_arrows_do_not_scroll_the_mix_window_or_an_insertion_point() {
|
||||
for key in [Key::ArrowLeft, Key::ArrowRight] {
|
||||
let mut app = app_with_selection();
|
||||
app.ui.window = crate::MainWindow::Mix;
|
||||
assert!(!super::fixed_more(&mut app, key, Modifiers::NONE, false));
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll, 0);
|
||||
app.ui.window = crate::MainWindow::Edit;
|
||||
app.run("edit.select", serde_json::json!({"start": 2000, "end": 2000})).unwrap();
|
||||
assert!(!super::fixed_more(&mut app, key, Modifiers::NONE, false));
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_arrows_leave_the_piano_roll_in_control_under_its_pointer() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_selection();
|
||||
app.ui.show_midi_editor = true;
|
||||
app.docking_context = Some(ctx.clone());
|
||||
let body = egui::Rect::from_min_max(egui::pos2(10.0, 10.0), egui::pos2(100.0, 100.0));
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("soundcraft-midi-body"), body));
|
||||
for (pointer, handled) in [(body.center(), false), (egui::pos2(200.0, 200.0), true)] {
|
||||
let mut output = ctx.run_ui(egui::RawInput { events: vec![egui::Event::PointerMoved(pointer)], ..Default::default() }, |_| {
|
||||
for key in [Key::ArrowLeft, Key::ArrowRight] {
|
||||
app.run("view.scroll", serde_json::json!({"to": 0})).unwrap();
|
||||
assert_eq!(super::fixed_more(&mut app, key, Modifiers::NONE, false), handled);
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll > 0, handled);
|
||||
}
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,78 +840,6 @@ fn a_held_key_does_not_turn_a_scripted_press_into_a_repeat() {
|
||||
assert!(ctx.input(|i| i.key_down(egui::Key::Z)), "the host still holds Z after the repeat");
|
||||
}
|
||||
|
||||
/// Runs one input pass of `app` carrying host key `events` (no scripted input).
|
||||
fn key_pass(ctx: &egui::Context, app: &mut SoundApp, events: Vec<egui::Event>) {
|
||||
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0));
|
||||
let mut input = egui::RawInput { screen_rect: Some(rect), events, ..Default::default() };
|
||||
app.raw_input_hook(ctx, &mut input);
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// The demo app after a few idle passes, ready for keyboard input.
|
||||
fn app_for_keys() -> (egui::Context, SoundApp) {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
for _ in 0..4 {
|
||||
key_pass(&ctx, &mut app, Vec::new());
|
||||
}
|
||||
(ctx, app)
|
||||
}
|
||||
|
||||
/// A host press of `key` and then two host auto-repeats of it, each in its own pass, as holding the key gives.
|
||||
fn hold_key(ctx: &egui::Context, app: &mut SoundApp, key: egui::Key, modifiers: egui::Modifiers) {
|
||||
for repeat in [false, true, true] {
|
||||
key_pass(ctx, app, vec![egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat, modifiers }]);
|
||||
}
|
||||
key_pass(ctx, app, vec![egui::Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers }]);
|
||||
}
|
||||
|
||||
/// #1377: holding a zoom key keeps zooming, one step per auto-repeat.
|
||||
#[test]
|
||||
fn holding_t_or_r_keeps_zooming_the_timeline() {
|
||||
let (ctx, mut app) = app_for_keys();
|
||||
let start = app.engine.session().edit.zoom.samples_per_px;
|
||||
hold_key(&ctx, &mut app, egui::Key::T, egui::Modifiers::NONE);
|
||||
assert_eq!(app.engine.session().edit.zoom.samples_per_px, start / 8.0, "T and two repeats zoom in three steps");
|
||||
hold_key(&ctx, &mut app, egui::Key::R, egui::Modifiers::NONE);
|
||||
assert_eq!(app.engine.session().edit.zoom.samples_per_px, start, "R and two repeats zoom back out three steps");
|
||||
}
|
||||
|
||||
/// #1377: the registry's Cmd+[ / Cmd+] and the Cmd+Alt+[ / ] waveform zoom repeat too.
|
||||
#[test]
|
||||
fn holding_a_zoom_chord_keeps_zooming() {
|
||||
let (ctx, mut app) = app_for_keys();
|
||||
let cmd = egui::Modifiers { command: true, ..egui::Modifiers::NONE };
|
||||
let start = app.engine.session().edit.zoom.samples_per_px;
|
||||
hold_key(&ctx, &mut app, egui::Key::OpenBracket, cmd);
|
||||
assert_eq!(app.engine.session().edit.zoom.samples_per_px, start * 8.0, "Cmd+[ and two repeats zoom out three steps");
|
||||
hold_key(&ctx, &mut app, egui::Key::CloseBracket, cmd);
|
||||
assert_eq!(app.engine.session().edit.zoom.samples_per_px, start, "Cmd+] and two repeats zoom in three steps");
|
||||
let waveform = app.engine.session().edit.zoom.waveform_zoom;
|
||||
hold_key(&ctx, &mut app, egui::Key::CloseBracket, egui::Modifiers { alt: true, ..cmd });
|
||||
assert_eq!(app.engine.session().edit.zoom.waveform_zoom, waveform * 8.0, "Cmd+Alt+] and two repeats raise the waveform three steps");
|
||||
}
|
||||
|
||||
/// Only the zoom commands repeat: a held registry chord (Cmd+Z) still undoes once and a held
|
||||
/// `fixed` key (Enter) still adds one marker.
|
||||
#[test]
|
||||
fn holding_cmd_z_or_enter_still_runs_once() {
|
||||
let (ctx, mut app) = app_for_keys();
|
||||
for _ in 0..3 {
|
||||
app.run("track.new", json!({"count": 1, "format": "stereo"})).unwrap();
|
||||
}
|
||||
let depth = app.engine.undo_history().len();
|
||||
hold_key(&ctx, &mut app, egui::Key::Z, egui::Modifiers { command: true, ..egui::Modifiers::NONE });
|
||||
assert_eq!(app.engine.undo_history().len(), depth - 1, "auto-repeats of Cmd+Z are ignored");
|
||||
let markers = app.engine.session().markers.len();
|
||||
hold_key(&ctx, &mut app, egui::Key::Enter, egui::Modifiers::NONE);
|
||||
assert_eq!(app.engine.session().markers.len(), markers + 1, "auto-repeats of Enter are ignored");
|
||||
}
|
||||
|
||||
/// A fresh physical Cmd+Z and an identical scripted Cmd+Z in one frame are distinct actions.
|
||||
/// Matching them by value would swallow the scripted press; provenance keeps both.
|
||||
#[test]
|
||||
|
||||
@@ -237,7 +237,7 @@ fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String {
|
||||
s.format_position(at, f)
|
||||
}
|
||||
|
||||
/// Main and sub position counters with their format menus, selection readouts and dirty/device status.
|
||||
/// Main and sub position counters, a main-counter format menu, selection readouts and dirty/device status.
|
||||
fn counters(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let t = Tokens::current();
|
||||
let (main_fmt, sub_fmt, sel) = {
|
||||
@@ -252,7 +252,13 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
|
||||
ui.painter().text(pos2(main_r.max.x - 18.0, main_r.center().y), Align2::RIGHT_CENTER, txt, mono(26.0), t.counter_text);
|
||||
let resp = ui.interact(main_r, ui.scope_id().with("main_counter"), Sense::click());
|
||||
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);
|
||||
format_menu(app, &resp, main_fmt, "view.main_counter");
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
for f in TimeFormat::ALL {
|
||||
if ui.selectable_label(main_fmt == f, f.label()).clicked() {
|
||||
let _ = app.run("view.main_counter", json!({"format": f.id()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
// Start / End / Length.
|
||||
let s = app.engine.session();
|
||||
let rows = [
|
||||
@@ -270,27 +276,11 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
|
||||
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 + 150.0, y), Align2::RIGHT_CENTER, fmt_pos(app, pos, sub_fmt), mono(11.0), t.counter_text);
|
||||
let sub_r = Rect::from_min_max(pos2(r.min.x + 6.0, r.max.y - 20.0), pos2(r.min.x + 164.0, r.max.y - 2.0));
|
||||
let sub_resp = ui.interact(sub_r, ui.scope_id().with("sub_counter"), Sense::click());
|
||||
icons::draw(ui.painter(), Rect::from_center_size(pos2(r.min.x + 158.0, y), vec2(8.0, 8.0)), "triangle_down", t.text_dim);
|
||||
format_menu(app, &sub_resp, sub_fmt, "view.sub_counter");
|
||||
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" });
|
||||
ui.painter().text(pos2(r.max.x - 8.0, y), Align2::RIGHT_CENTER, format!("{st} · {dev}"), regular(10.0), t.text_dim);
|
||||
}
|
||||
|
||||
/// A counter's time-format menu: picking a format runs `command` (`view.main_counter` or
|
||||
/// `view.sub_counter`) with it.
|
||||
fn format_menu(app: &mut SoundApp, resp: &egui::Response, current: TimeFormat, command: &str) {
|
||||
egui::Popup::menu(resp).show(|ui| {
|
||||
for f in TimeFormat::ALL {
|
||||
if ui.selectable_label(current == f, f.label()).clicked() {
|
||||
let _ = app.run(command, json!({"format": f.id()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Draws the compact Grid and Nudge readouts and their scale menus.
|
||||
fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
|
||||
let t = Tokens::current();
|
||||
|
||||
@@ -43,19 +43,6 @@ impl SoundApp {
|
||||
pub fn set_startup_update_check(&mut self, on: bool) {
|
||||
self.updates_rt.startup_check = on;
|
||||
}
|
||||
|
||||
/// Starts the helper of an update that waits for the app to close. Call it when the exit is certain.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn start_pending_update(&mut self) {
|
||||
self.updates_rt.updater.start_pending_update();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the result dialog shows: while the updater reports something (a requested check, or what the last update
|
||||
/// came to, which the check at start reads), and not while the startup prompt is open.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn shows_result(status: &Status, prompt_open: bool) -> bool {
|
||||
!matches!(status, Status::Idle) && !prompt_open
|
||||
}
|
||||
|
||||
pub(crate) fn check(app: &mut SoundApp) {
|
||||
@@ -75,6 +62,7 @@ pub(crate) fn tick(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let rt = &mut app.updates_rt;
|
||||
if std::mem::take(&mut rt.check_requested) {
|
||||
rt.updater.check_now(ctx);
|
||||
rt.manual_window_open = true;
|
||||
} else if rt.startup_check {
|
||||
rt.updater.check_at_start(ctx);
|
||||
}
|
||||
@@ -94,19 +82,20 @@ pub(crate) fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
Action::None => {}
|
||||
}
|
||||
// Help ▸ Check for Updates, and the first start after an update, show their outcome in the shared result dialog
|
||||
// until the user closes it. A found update goes to the startup prompt, which takes over from the dialog.
|
||||
// Help ▸ Check for Updates shows its outcome in the shared result dialog until the user closes it.
|
||||
let rt = &mut app.updates_rt;
|
||||
rt.manual_window_open = shows_result(rt.updater.status(), rt.updater.prompt_open());
|
||||
if rt.manual_window_open {
|
||||
match rt.result_dialog.show(ctx, rt.updater.status()) {
|
||||
CheckResultResponse::Close => rt.manual_window_open = false,
|
||||
CheckResultResponse::CheckAgain => rt.updater.check_now(ctx),
|
||||
CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(),
|
||||
CheckResultResponse::None => {}
|
||||
}
|
||||
// A found update goes to the startup prompt, which takes over from the dialog.
|
||||
if matches!(rt.updater.status(), Status::Idle) {
|
||||
rt.manual_window_open = false;
|
||||
}
|
||||
}
|
||||
// Closing the dialog, or the prompt taking over, dismisses the outcome it showed.
|
||||
// Closing the dialog dismisses the outcome it showed.
|
||||
if !rt.manual_window_open {
|
||||
rt.updater.dismiss_status();
|
||||
}
|
||||
@@ -126,64 +115,3 @@ pub(crate) fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
app.updates_rt.manual_window_open = open;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use craft_update::Paths;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// One pass of the update check and its windows, with `events` as the frame's input, dropping texture deltas the
|
||||
/// tests do not inspect.
|
||||
fn frame(app: &mut SoundApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||||
let raw = egui::RawInput { events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
tick(app, ui.ctx());
|
||||
show(app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_result_dialog_shows_every_reported_status_until_the_prompt_opens() {
|
||||
for status in [
|
||||
Status::Checking,
|
||||
Status::UpToDate,
|
||||
Status::Failed("offline".into()),
|
||||
Status::Installing,
|
||||
Status::Ready,
|
||||
Status::Managed,
|
||||
Status::Updated("0.2.0".into()),
|
||||
Status::InstallFailed("disk full".into()),
|
||||
] {
|
||||
assert!(shows_result(&status, false), "{status:?}");
|
||||
assert!(!shows_result(&status, true), "{status:?} with the prompt open");
|
||||
}
|
||||
assert!(!shows_result(&Status::Idle, false));
|
||||
}
|
||||
|
||||
/// The first start after an update says so in the result dialog, which stays until the user closes it.
|
||||
#[test]
|
||||
fn the_first_start_after_an_update_reports_it_until_closed() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-update-result-{}", std::process::id()));
|
||||
let paths = Paths::under(&dir);
|
||||
std::fs::create_dir_all(&paths.data).unwrap();
|
||||
std::fs::write(paths.data.join("self-update-done.txt"), "v0.2.0").unwrap();
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.updates_rt.updater =
|
||||
Updater::new(&crate::UPDATE_APP, "0.2.0").with_source(Arc::new(|| Ok(None))).with_pins_path(dir.join("pins.json")).with_paths(paths);
|
||||
app.set_startup_update_check(true);
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..3 {
|
||||
frame(&mut app, &ctx, Vec::new());
|
||||
}
|
||||
assert_eq!(app.updates_rt.updater.status(), &Status::Updated("0.2.0".into()));
|
||||
assert!(app.updates_rt.manual_window_open);
|
||||
let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
frame(&mut app, &ctx, vec![escape]);
|
||||
assert_eq!(app.updates_rt.updater.status(), &Status::Idle);
|
||||
assert!(!app.updates_rt.manual_window_open);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ const KNOWN_UNREACHED: &[&str] = &[
|
||||
"transport.pause",
|
||||
"transport.status",
|
||||
"video.offset",
|
||||
"view.sub_counter",
|
||||
];
|
||||
|
||||
fn ui_source() -> String {
|
||||
|
||||
@@ -281,24 +281,6 @@ fn shuffle_drags_leave_locked_clips_in_the_selection_out_of_it() {
|
||||
assert_eq!(starts(&app, &ids), vec![0, SEC]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f6_f7_and_f7_f8_in_either_order_choose_the_smart_tool() {
|
||||
let (mut app, ctx, _, _) = app("slip", "grabber", &[]);
|
||||
let tool = |app: &SoundApp| app.engine.session().edit.tool.id();
|
||||
for (first, second) in [(Key::F6, Key::F7), (Key::F7, Key::F6), (Key::F7, Key::F8), (Key::F8, Key::F7)] {
|
||||
frame(&ctx, &mut app, key(Key::F5));
|
||||
frame(&ctx, &mut app, vec![key_event(first, true, Modifiers::NONE)]);
|
||||
frame(&ctx, &mut app, key(second));
|
||||
frame(&ctx, &mut app, vec![key_event(first, false, Modifiers::NONE)]);
|
||||
assert_eq!(tool(&app), "smart", "{first:?}+{second:?}");
|
||||
}
|
||||
let alone = [(Key::F5, "zoom"), (Key::F6, "trim"), (Key::F7, "selector"), (Key::F8, "grabber"), (Key::F9, "scrubber"), (Key::F10, "pencil")];
|
||||
for (k, want) in alone {
|
||||
frame(&ctx, &mut app, key(k));
|
||||
assert_eq!(tool(&app), want, "{k:?} alone");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f4_pressed_before_f1_leaves_the_kind_of_grid_alone() {
|
||||
let (mut app, ctx, _, _) = app("grid", "grabber", &[]);
|
||||
|
||||
@@ -51,7 +51,6 @@ mod side_lists_text;
|
||||
mod strip_silence_dialog;
|
||||
mod strip_silence_rectangles;
|
||||
mod strip_silence_rename;
|
||||
mod sub_counter;
|
||||
mod toolbar_scroll;
|
||||
mod toolbar_tempo;
|
||||
mod track_height;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Pro Tools keys for commands that had none: Grid and Nudge values, Commands Focus nudging and selection
|
||||
//! keys, playlists, F12, Ctrl+Tab off macOS, the window keys and the Cmd+6/7 tool keys.
|
||||
//! keys, playlists, F12, Ctrl+Tab off macOS and the window keys.
|
||||
|
||||
use egui::os::OperatingSystem;
|
||||
use egui::{Event, Key, Modifiers, Pos2, RawInput, Rect, vec2};
|
||||
@@ -299,19 +299,3 @@ fn shift_tab_extends_the_selection_and_tab_to_transient_stops_on_hits() {
|
||||
assert_eq!(start(&app), clip.start, "{os:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_6_and_7_choose_the_pencil_and_the_smart_tool() {
|
||||
for (os, cmd) in OSES {
|
||||
let (ctx, mut app) = rig(os);
|
||||
app.run("edit.tool", json!({"tool": "grabber"})).unwrap();
|
||||
press(&ctx, &mut app, Key::Num6, cmd);
|
||||
assert_eq!(app.engine.session().edit.tool.id(), "pencil", "{os:?}");
|
||||
if os == OperatingSystem::Mac {
|
||||
press(&ctx, &mut app, Key::Num7, Modifiers::CTRL);
|
||||
assert_eq!(app.engine.session().edit.tool.id(), "pencil", "the Mac's Control+7 is not Cmd+7");
|
||||
}
|
||||
press(&ctx, &mut app, Key::Num7, cmd);
|
||||
assert_eq!(app.engine.session().edit.tool.id(), "smart", "{os:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,109 +113,3 @@ fn cmd_equals_toggles_mix_and_edit_without_zooming() {
|
||||
assert_eq!(ctx.zoom_factor(), 1.25, "{os:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// One full app frame on a 1600×1000 screen.
|
||||
fn app_frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<egui::Event>) {
|
||||
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)));
|
||||
ctx.run_ui(egui::RawInput { screen_rect, events, ..Default::default() }, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
}
|
||||
|
||||
fn arrow(key: Key) -> Vec<egui::Event> {
|
||||
[true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }).to_vec()
|
||||
}
|
||||
|
||||
/// Where `at` is drawn in the Edit window's timeline, and the timeline's middle (#1376).
|
||||
fn x_and_middle(app: &SoundApp, at: i64) -> (f32, f32) {
|
||||
let tl = soundcraft_ui_egui::edit_window::rect(app.edit_layout.timeline);
|
||||
(soundcraft_ui_egui::edit_window::x_of(app.engine.session(), tl, at), tl.center().x)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_arrows_centre_the_edit_window_on_the_selected_clip() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let (clip_id, clip) = app.engine.session().tracks.iter().flat_map(|t| t.clips()).find(|c| c.start > 100_000).map(|c| (c.id, c.range())).unwrap();
|
||||
app.run("view.zoom_set", json!({"samples_per_px": 10.0})).unwrap();
|
||||
app.run("view.scroll", json!({"to": 0})).unwrap();
|
||||
for _ in 0..3 {
|
||||
app_frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
app.run("edit.select", json!({"clips": [clip_id.0]})).unwrap();
|
||||
assert!(x_and_middle(&app, clip.start).0 > app.edit_layout.timeline[2], "the clip starts off-screen");
|
||||
|
||||
app_frame(&ctx, &mut app, arrow(Key::ArrowLeft));
|
||||
let (x, middle) = x_and_middle(&app, clip.start);
|
||||
assert!((x - middle).abs() < 1.0, "Left: the clip start at {x}, the middle at {middle}");
|
||||
app_frame(&ctx, &mut app, arrow(Key::ArrowRight));
|
||||
let (x, middle) = x_and_middle(&app, clip.end);
|
||||
assert!((x - middle).abs() < 1.0, "Right: the clip end at {x}, the middle at {middle}");
|
||||
|
||||
// An insertion point alone leaves the view where it is.
|
||||
app.run("edit.select", json!({"start": clip.start})).unwrap();
|
||||
let scroll = app.engine.session().edit.zoom.scroll;
|
||||
app_frame(&ctx, &mut app, arrow(Key::ArrowLeft));
|
||||
app_frame(&ctx, &mut app, arrow(Key::ArrowRight));
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll, scroll);
|
||||
}
|
||||
|
||||
/// Over the piano roll the arrows move the selected note and leave the Edit window's view alone.
|
||||
#[test]
|
||||
fn the_piano_roll_keeps_the_plain_arrows() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.ui.show_midi_editor = true;
|
||||
app.run("edit.select", json!({"tracks": ["Keys"], "start": 0, "end": 480_000})).unwrap();
|
||||
app.run("view.zoom_set", json!({"samples_per_px": 10.0})).unwrap();
|
||||
app.run("view.scroll", json!({"to": 1_000_000})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..6 {
|
||||
app_frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
let body = ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::unique("soundcraft-midi-body"))).unwrap();
|
||||
app_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(body.center())]);
|
||||
app.midi.selected = vec![0];
|
||||
let note_start = |app: &SoundApp| {
|
||||
let clip = soundcraft_ui_egui::midi_editor::target_clip(app).unwrap();
|
||||
match &app.engine.session().find_clip(clip).unwrap().1.content {
|
||||
soundcraft_model::ClipContent::Midi { sequence } => sequence.notes[app.midi.selected[0]].start,
|
||||
_ => panic!("not a MIDI clip"),
|
||||
}
|
||||
};
|
||||
let (before, scroll) = (note_start(&app), app.engine.session().edit.zoom.scroll);
|
||||
app_frame(&ctx, &mut app, arrow(Key::ArrowRight));
|
||||
assert!(note_start(&app) > before, "the note moved right");
|
||||
assert_eq!(app.engine.session().edit.zoom.scroll, scroll, "the Edit window kept its view");
|
||||
}
|
||||
|
||||
/// The MIDI editor open behind another tab has no piano roll on screen: the panel in front of it takes no
|
||||
/// arrows, so they centre the Edit window there as anywhere else.
|
||||
#[test]
|
||||
fn a_midi_editor_behind_another_tab_leaves_the_arrows_to_the_edit_window() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
app.ui.show_midi_editor = true;
|
||||
let (clip_id, clip) = app.engine.session().tracks.iter().flat_map(|t| t.clips()).find(|c| c.start > 100_000).map(|c| (c.id, c.range())).unwrap();
|
||||
app.run("view.zoom_set", json!({"samples_per_px": 10.0})).unwrap();
|
||||
app.run("view.scroll", json!({"to": 0})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..6 {
|
||||
app_frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
let body = ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::unique("soundcraft-midi-body"))).unwrap();
|
||||
app.run("ui.dock", json!({"operation": "open", "panel": "history"})).unwrap();
|
||||
app.run("ui.dock", json!({"operation": "move", "panel": "history", "target": "midi", "zone": "tab"})).unwrap();
|
||||
for _ in 0..3 {
|
||||
app_frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
assert!(app.ui.show_midi_editor, "the MIDI editor is still open");
|
||||
assert_ne!(app.ui.docking.layout.panel_state(&soundcraft_ui_egui::docking::Panel::Midi), craft_ui::docking::PanelState::Front);
|
||||
app_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(body.center())]);
|
||||
app.run("edit.select", json!({"clips": [clip_id.0]})).unwrap();
|
||||
|
||||
app_frame(&ctx, &mut app, arrow(Key::ArrowLeft));
|
||||
let (x, middle) = x_and_middle(&app, clip.start);
|
||||
assert!((x - middle).abs() < 1.0, "Left: the clip start at {x}, the middle at {middle}");
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
//! Clicking the toolbar's Sub counter offers the time formats, as the Main counter does (#1374).
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, epaint::ClippedShape, vec2};
|
||||
use soundcraft_time::TimeFormat;
|
||||
use soundcraft_ui_egui::{Services, SoundApp};
|
||||
|
||||
struct Rig {
|
||||
ctx: egui::Context,
|
||||
app: SoundApp,
|
||||
shapes: Vec<ClippedShape>,
|
||||
}
|
||||
|
||||
impl Rig {
|
||||
fn new() -> Rig {
|
||||
let mut r = Rig {
|
||||
ctx: egui::Context::default(),
|
||||
app: SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()),
|
||||
shapes: Vec::new(),
|
||||
};
|
||||
r.frames(4);
|
||||
r
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) {
|
||||
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
|
||||
let mut out = self.ctx.run_ui(raw, |ui| {
|
||||
self.app.logic(ui.ctx());
|
||||
self.app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
self.shapes = out.shapes;
|
||||
}
|
||||
|
||||
fn frames(&mut self, n: usize) {
|
||||
for _ in 0..n {
|
||||
self.frame(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Centre of the last text drawn as `text` (a popup's items are painted over the window).
|
||||
fn text(&self, text: &str) -> Option<Pos2> {
|
||||
self.shapes.iter().rev().find_map(|c| match &c.shape {
|
||||
Shape::Text(t) if t.galley.text() == text => Some(t.galley.rect.translate(t.pos.to_vec2()).center()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn count(&self, text: &str) -> usize {
|
||||
self.shapes.iter().filter(|c| matches!(&c.shape, Shape::Text(t) if t.galley.text() == text)).count()
|
||||
}
|
||||
|
||||
fn click(&mut self, at: Pos2) {
|
||||
for pressed in [true, false] {
|
||||
self.frame(vec![
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
}
|
||||
self.frames(2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sub_counter_menu_changes_its_format() {
|
||||
let mut r = Rig::new();
|
||||
let main = r.app.engine.session().edit.main_counter;
|
||||
assert_eq!(r.app.engine.session().edit.sub_counter, Some(TimeFormat::Samples));
|
||||
let sub = r.text("Sub").expect("the Sub counter is in the toolbar");
|
||||
let shown = r.count(TimeFormat::MinSecs.label());
|
||||
r.click(sub);
|
||||
assert_eq!(r.count(TimeFormat::MinSecs.label()), shown + 1, "clicking the Sub counter opens its format menu");
|
||||
let item = r.text(TimeFormat::MinSecs.label()).expect("clicking the Sub counter lists the formats");
|
||||
r.click(item);
|
||||
assert_eq!(r.app.engine.session().edit.sub_counter, Some(TimeFormat::MinSecs));
|
||||
assert_eq!(r.app.engine.session().edit.main_counter, main, "the Main counter keeps its format");
|
||||
}
|
||||
@@ -8,8 +8,7 @@ the AI models named in `Co-Authored-By` trailers.
|
||||
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
|
||||
disk or the network at run time; the web build has them too):
|
||||
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
|
||||
username. Never hand-edit. Release builds regenerate it for the release commit
|
||||
(`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated.
|
||||
username. Never hand-edit.
|
||||
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
|
||||
themselves. **Each contributor adds only their own entry**, in their own PR.
|
||||
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user