Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 94cbb5b68a MIDI: take Standard MIDI Files and the sequence model from craft-midi
craft-midi in craft-libs was moved from this crate (storytold/craft-libs#884):
smf.rs, sequence.rs, the error type and their tests. soundcraft-midi now
re-exports them, so every caller keeps its imports, and keeps what is
SoundCraft's own: the editing operations, the display names and XorShift.

The SMF and Sequence tests are deleted here; they run in craft-midi, which
also adds one for the track limit. The serde names do not change (the same
derives behind craft-midi's serde feature), so sessions read as before.
2026-11-12 00:26:51 +00:00
Dylan M. Taylor e584f2cd6c Review fixes after the craft-audio switch; pin craft-audio at craft-libs main
Follow-up to #1506, for its review comments:

- convert: a decode that concealed damaged packets is reported as a warning,
  and --in-place refuses to replace the source with it.
- convert: the decoded speaker layout goes to the encoder, so 5.1 with side
  surrounds stays side surrounds in WAV and FLAC.
- FileFormat::of names an MP4 or QuickTime file by its codec: AAC or HE-AAC
  is Aac, ALAC is Alac, anything else (Opus, AC-3, PCM) is Other.
- Bounce keeps a picked extension that already names the format (Mix.aifc,
  Mix.AIFF), so the file written is the one the save dialog confirmed.
- rust-version is 1.95: craft-audio, craft-media and craft-ui declare it, so
  1.90 was not a version the workspace builds with.

craft-audio and craft-media move to craft-libs main 7db4645d.
2026-11-12 00:26:51 +00:00
47 changed files with 119 additions and 2306 deletions
Generated
+17 -17
View File
@@ -4353,7 +4353,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"chrono",
"clap",
@@ -4377,7 +4377,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"log",
"serde",
@@ -4387,7 +4387,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"clap",
"craft-audio",
@@ -4400,7 +4400,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"clap-sys",
"libloading 0.9.0",
@@ -4413,7 +4413,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"clap",
"clap_complete",
@@ -4431,7 +4431,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"log",
"proptest",
@@ -4442,7 +4442,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"craft-audio",
"craft-docpath",
@@ -4466,7 +4466,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"craft-midi",
"proptest",
@@ -4474,7 +4474,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"craft-audio",
"log",
@@ -4490,7 +4490,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"craft-audio",
"proptest",
@@ -4503,7 +4503,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"cpal",
"craft-audio",
@@ -4518,7 +4518,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"proptest",
"serde",
@@ -4528,7 +4528,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"craft-audio",
"craft-ui",
@@ -4558,7 +4558,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"craft-media",
"image",
@@ -4570,7 +4570,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"libloading 0.9.0",
"log",
@@ -4586,7 +4586,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"craft-audio",
"eframe",
@@ -6234,7 +6234,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.20.1"
version = "0.20.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.20.1"
version = "0.20.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+1 -7
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-27 · **Change:** trivial (Cancel Zoom Toggle, side panels in dock columns, Auto Pan stereo balance, Alt-click returns a plugin parameter to its default, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Cancel Zoom Toggle, side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
@@ -125,9 +125,6 @@ mutated and truncated movies. Field reports from the first public week are track
- **2026-10-25:** Alt+Shift+E (`view.zoom_toggle_cancel`) and an Alt-click on the Zoom Toggle button cancel Zoom Toggle and keep the current view: the zoom, the scroll and the track heights stay as they are, and the button is no longer lit. The next Zoom Toggle zooms in from that view and goes back to it. No readiness percentage change.
- **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change.
- **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.
- **2026-10-24:** An Alt-click (Option-click on a Mac) on a parameter in the plugin window, the Clip Effects window or an AudioSuite plugin's window returns it to its default value, on a slider or a slider's value box, and in the plugin window on a choice list too, as an Alt-click returns a fader to 0 dB. In the plugin window and the Clip Effects window the reset is one undo step. While Alt is held, a drag on a slider leaves the value where it is. No readiness percentage change.
- **2026-10-24:** The parameter sliders of the plugin window, the Clip Effects window and AudioSuite plugins move a tenth as far for the same drag while Ctrl (Command on a Mac) is held, as the Strip Silence sliders do. Logarithmic sliders move a tenth as far along the slider, and whole-number sliders stay on whole numbers. No readiness percentage change.
- **2026-10-24:** The Strip Silence window's four sliders move a tenth as far for the same drag while Ctrl (Command on a Mac) is held, on the slider and on its value box. Pressing or releasing the key during a drag leaves the value where it is. The Threshold and Clip Start Pad boxes show one decimal place. No readiness percentage change.
- **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
- **2026-10-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change.
- **2026-10-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change.
@@ -183,9 +180,6 @@ mutated and truncated movies. Field reports from the first public week are track
| 2026-10-25 | trivial | Progress log: Cancel Zoom Toggle; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Alt-click returns a plugin parameter to its default; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: fine adjustment on plugin parameter sliders; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence fine adjustment; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged |
+2 -6
View File
@@ -80,16 +80,12 @@ fn not_found() -> std::io::Error {
std::io::Error::from_raw_os_error(2)
}
/// Exit 1 with the unreachable message for `port`. The reason is either the OS's refusal, whose text
/// differs by platform, or the client's connect timeout, which on Windows can expire before a
/// closed loopback port is refused.
/// Exit 1 with the refused-connection message for `port`; the OS's text for the refusal differs by platform.
#[track_caller]
fn assert_refused(got: &Invoke, port: u16) {
assert_eq!((got.code, got.stdout.as_str()), (1, ""));
let prefix = format!("error: cannot reach SoundCraft at 127.0.0.1:{port}: ");
let reason = got.stderr.strip_prefix(&prefix).unwrap_or_else(|| panic!("{}", got.stderr));
let refused = reason.contains(" (os error ") && reason.ends_with(")\n");
assert!(refused || reason == "connection timed out\n", "{}", got.stderr);
assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr);
}
// ---- logging ----
+2 -6
View File
@@ -53,16 +53,12 @@ fn not_found() -> std::io::Error {
std::io::Error::from_raw_os_error(2)
}
/// Exit 1 with the unreachable message for `port`. The reason is either the OS's refusal, whose text
/// differs by platform, or the client's connect timeout, which on Windows can expire before a
/// closed loopback port is refused.
/// Exit 1 with the refused-connection message for `port`; the OS's text for the refusal differs by platform.
#[track_caller]
fn assert_refused(got: &Invoke, port: u16) {
assert_eq!((got.code, got.stdout.as_str()), (1, ""));
let prefix = format!("error: cannot reach SoundCraft at 127.0.0.1:{port}: ");
let reason = got.stderr.strip_prefix(&prefix).unwrap_or_else(|| panic!("{}", got.stderr));
let refused = reason.contains(" (os error ") && reason.ends_with(")\n");
assert!(refused || reason == "connection timed out\n", "{}", got.stderr);
assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr);
}
#[test]
+7 -43
View File
@@ -1,7 +1,5 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`soundcraft-cli mcp --connect PORT`) wraps.
//! A line that is not a JSON object with a string `method` gets one error reply, and the
//! connection is closed.
//!
//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without
//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]).
@@ -105,8 +103,7 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
}
/// Answer newline-delimited JSON requests for one connection: bound each line, forward it to the UI thread with a 90
/// s reply deadline, and stop at EOF, invalid UTF-8, a closed request queue or a line that isn't a request (see
/// [`parse_request`]). That line gets one error reply first.
/// s reply deadline, and stop at EOF, invalid UTF-8 or a closed request queue. Bad JSON still gets an error reply.
fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
@@ -126,8 +123,11 @@ fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context)
if line.trim().is_empty() {
continue;
}
let reply = match parse_request(line) {
Ok((id, method, params)) => {
let reply = match serde_json::from_str::<Value>(line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
match tx.try_send(req) {
Ok(()) => {
@@ -143,12 +143,7 @@ fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context)
Err(TrySendError::Disconnected(_)) => break,
}
}
// Control clients send one request object per line. A peer that sends anything else isn't
// one: answer once and close, so nothing it sends after that line runs.
Err(e) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("{e}; closing the connection")}));
break;
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
if writeln!(out, "{reply}").is_err() {
break;
@@ -156,15 +151,6 @@ fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context)
}
}
/// A request line's `(id, method, params)`. The line must be a JSON object with a string `method`;
/// otherwise the error says what it is instead.
fn parse_request(line: &str) -> Result<(Value, String, Value), String> {
let msg = serde_json::from_str::<Value>(line).map_err(|e| format!("bad JSON: {e}"))?;
let Some(msg) = msg.as_object() else { return Err("not a request: expected a JSON object".into()) };
let Some(method) = msg.get("method").and_then(Value::as_str) else { return Err("not a request: no string `method`".into()) };
Ok((msg.get("id").cloned().unwrap_or(Value::Null), method.to_string(), msg.get("params").cloned().unwrap_or(json!({}))))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -240,26 +226,4 @@ mod tests {
assert!(line.contains("too many control connections"), "{line}");
drop(held);
}
#[test]
fn a_line_that_is_not_a_request_ends_the_connection() {
let addr = serve_echo();
for junk in ["not json", "[1, 2]", "\"ping\"", r#"{"id": 5}"#, r#"{"id": 5, "method": 7}"#] {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
// One write, so both lines arrive together and the request after the junk is waiting.
c.write_all(format!("{junk}\n{{\"id\":6,\"method\":\"ping\"}}\n").as_bytes()).unwrap();
let mut r = BufReader::new(c);
let mut line = String::new();
r.read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["ok"].as_bool(), Some(false), "{junk}: {line}");
line.clear();
let closed = match r.read_line(&mut line) {
Ok(n) => n == 0,
Err(e) => e.kind() == std::io::ErrorKind::ConnectionReset,
};
assert!(closed, "{junk}: the connection stays open and the next request is answered: {line}");
}
}
}
-3
View File
@@ -156,9 +156,6 @@ fn services() -> Services {
};
d.pick_file()
})),
pick_open_many: Some(Box::new(|_purpose: &str| {
rfd::FileDialog::new().add_filter("Audio", &FileFormat::readable_extensions()).pick_files().unwrap_or_default()
})),
pick_save: Some(Box::new(|purpose: &str, path: &std::path::Path| {
let mut d = match purpose {
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", craft_audio::ENCODE_EXTENSIONS),
-7
View File
@@ -240,7 +240,6 @@ fn grid_ticks(p: &Value) -> i64 {
"1/1" | "1" | "bar" => TICKS_PER_QUARTER * 4,
"1/2" => TICKS_PER_QUARTER * 2,
"1/4" => TICKS_PER_QUARTER,
"1/4t" => TICKS_PER_QUARTER * 2 / 3,
"1/8" => TICKS_PER_QUARTER / 2,
"1/8t" => TICKS_PER_QUARTER / 3,
"1/16t" => TICKS_PER_QUARTER / 6,
@@ -433,12 +432,6 @@ mod tests {
use crate::demo::demo_engine;
use serde_json::json;
#[test]
fn grid_ticks_accepts_the_quarter_triplet() {
use super::{TICKS_PER_QUARTER, grid_ticks};
assert_eq!(grid_ticks(&json!({"grid": "1/4t"})), TICKS_PER_QUARTER * 2 / 3);
}
#[test]
fn tempo_constant_with_an_empty_range_sets_the_tempo_at_its_start() {
use soundcraft_time::Range;
+4 -16
View File
@@ -114,7 +114,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Assign Send",
[],
None,
"{track?, slot?: 0..9|a..j, bus: name, level_db?: -144..12, default -144 (silent; kept when re-targeting), pre_fader?: false}",
"{track?, slot?: 0..9|a..j, bus: name, level_db?: 0 (kept when re-targeting), pre_fader?: false}",
has_selection,
send
),
@@ -244,15 +244,6 @@ fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
return Err(bad("mix.volume", "`db` or `delta_db` required"));
}
let s = e.session_mut();
// The fader stops at -144 and +12 dB. The reply says when a value was held there: +15 dB used
// to play at +12 with nothing to show it.
let clamped = std::cell::Cell::new(false);
let fader = |db: f32| {
if !(-144.0..=12.0).contains(&db) {
clamped.set(true);
}
db.clamp(-144.0, 12.0)
};
// Tracks already moved as a group sibling of an earlier target must not move again.
let mut done: Vec<TrackId> = Vec::new();
for t in &tracks {
@@ -266,14 +257,11 @@ fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
(None, Some(d)) => cur + d as f32,
_ => cur,
};
let new = if new.is_finite() { fader(new) } else { cur };
let new = if new.is_finite() { new.clamp(-144.0, 12.0) } else { cur };
let d = new - cur;
set_grouped(s, *t, "volume", |tr| tr.mixer.volume_db = if tr.id == *t { new } else { fader(tr.mixer.volume_db + d) });
set_grouped(s, *t, "volume", |tr| tr.mixer.volume_db = if tr.id == *t { new } else { (tr.mixer.volume_db + d).clamp(-144.0, 12.0) });
}
// Every requested track at its final volume, also one that moved as a group sibling of an
// earlier target and was skipped above.
let volumes: Vec<Value> = tracks.iter().filter_map(|t| s.track(*t)).map(|tr| json!({"track": tr.id.0, "db": tr.mixer.volume_db})).collect();
Ok(json!({"tracks": tracks.len(), "volumes": volumes, "clamped": clamped.get()}))
Ok(json!({"tracks": tracks.len()}))
}
fn pan(e: &mut Engine, p: &Value) -> Result<Value> {
-34
View File
@@ -263,18 +263,6 @@ fn retargeting_a_send_keeps_its_settings() {
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
}
#[test]
fn a_new_send_starts_at_the_documented_level() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Vox", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Rev"})).unwrap();
e.execute("mix.send", &json!({"track": "Vox", "bus": "Rev"})).unwrap();
let level = e.session().tracks[0].mixer.sends[0].as_ref().unwrap().level_db;
assert_eq!(level, -144.0);
let doc = crate::cmd::command_specs().iter().find(|c| c.id == "mix.send").unwrap().params;
assert!(doc.contains("level_db?: -144..12, default -144 (silent;"), "{doc}");
}
fn grouped_demo() -> Engine {
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &json!({"tracks": [3], "db": 0.0})).unwrap();
@@ -306,28 +294,6 @@ fn volume_absolute_on_a_whole_group_keeps_offsets() {
assert_eq!(volumes(&e), [-10.0, -12.0]);
}
/// The fader stops at +12 dB; the reply says what each track got, and when a value was held at a stop.
#[test]
fn volume_held_at_the_fader_stop_is_reported() {
let mut e = grouped_demo();
let r = e.execute("mix.volume", &json!({"tracks": [3], "db": -6.0})).unwrap();
assert_eq!((r["volumes"].clone(), r["clamped"].clone()), (json!([{"track": 3, "db": -6.0}]), json!(false)));
let r = e.execute("mix.volume", &json!({"tracks": [3], "db": 15.0})).unwrap();
assert_eq!((r["volumes"].clone(), r["clamped"].clone()), (json!([{"track": 3, "db": 12.0}]), json!(true)));
assert_eq!(volumes(&e), [12.0, 12.0]);
// A group member held at the stop counts too.
let mut e = grouped_demo();
e.session_mut().track_mut(TrackId(6)).unwrap().mixer.volume_db = 6.0;
let r = e.execute("mix.volume", &json!({"tracks": [3], "db": 10.0})).unwrap();
assert_eq!(r["clamped"], json!(true));
assert_eq!(volumes(&e), [10.0, 12.0]);
// Both grouped targets are listed, each at the value it ended with.
let mut e = grouped_demo();
e.session_mut().track_mut(TrackId(6)).unwrap().mixer.volume_db = -2.0;
let r = e.execute("mix.volume", &json!({"tracks": [3, 6], "db": -10.0})).unwrap();
assert_eq!(r["volumes"], json!([{"track": 3, "db": -10.0}, {"track": 6, "db": -12.0}]));
}
#[test]
fn clear_with_unknown_param_keeps_lanes() {
let (mut e, t) = tone_session(&[(0, -6.0), (100, -3.0)]);
+3 -9
View File
@@ -206,8 +206,8 @@ pub fn load_sampler_bytes(e: &mut Engine, track: TrackId, name: &str, bytes: &[u
/// Resolve each movie referenced by the session file `session` ([`stored_path`]): keep the
/// resolved path when its file exists, else look for a file of the same name next to the session
/// (moved projects) and relink. A movie whose stored path is refused, or resolves to a file that
/// is not there, and has no such copy is marked offline. Returns the movies still missing.
/// (moved projects) and relink. A movie whose stored path is refused and has no such copy is
/// marked offline. Returns the movies still missing.
pub fn relink_videos(s: &mut Session, session: &Path) -> Vec<String> {
let session_dir = session.parent().unwrap_or(Path::new(""));
let mut missing = Vec::new();
@@ -228,13 +228,7 @@ pub fn relink_videos(s: &mut Session, session: &Path) -> Vec<String> {
.find(|c| !name.as_os_str().is_empty() && file_found(c));
match (near, p) {
(Some(c), _) => v.path = c.to_string_lossy().into_owned(),
// The movie is gone: mark it offline, so the Video track never opens the stored
// string (which the working directory would resolve). The string itself is kept as
// stored, so saving does not swap a path relative to the session for this folder's.
(None, Ok(p)) => {
missing.push(format!("{} (video)", p.display()));
v.offline = Some("file not found".into());
}
(None, Ok(p)) => missing.push(format!("{} (video)", p.display())),
(None, Err(why)) => {
missing.push(format!("{} (video): {why}", v.path));
v.offline = Some(why);
+13 -29
View File
@@ -8,10 +8,6 @@ pub(super) const MAX_MIDI_TEMPOS: usize = 10_000;
pub(super) const MAX_MIDI_TRACKS: usize = 512;
/// Import a Standard MIDI File as MIDI tracks (one per SMF track).
///
/// The file is checked in full before the session changes, so a refused file (too many tempo or
/// meter changes or tracks, a track longer than a day) leaves the session as it was: callers that
/// keep their own undo step, like the web shell, never see a half-applied import.
pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: bool) -> Result<Vec<TrackId>> {
let smf = soundcraft_midi::read_smf(bytes).map_err(|err| EngineError::Io(err.to_string()))?;
if tempo_map {
@@ -27,43 +23,24 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b
if tracks_to_import > MAX_MIDI_TRACKS {
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_TRACKS} tracks")));
}
// Phase 1, read only: the tempo map the clips will be placed with, built apart from the
// session. `session_mut` would already count as an edit (revision, unsaved mark).
let s = e.session();
let mut tempo = s.tempo.clone();
let s = e.session_mut();
if tempo_map {
for (tick, bpm) in &smf.tempos {
if tempo.tempo_at_tick(*tick) == *bpm {
if s.tempo.tempo_at_tick(*tick) == *bpm {
continue;
}
let _ = tempo.set_tempo(*tick, *bpm);
let _ = s.tempo.set_tempo(*tick, *bpm);
}
for (tick, n, d) in &smf.meters {
let _ = tempo.set_meter(*tick, u32::from(*n), u32::from(*d));
let _ = s.tempo.set_meter(*tick, u32::from(*n), u32::from(*d));
}
}
let base_tick = tempo.samples_to_ticks(at, s.sample_rate);
let mut clips = Vec::new();
let base_tick = s.tempo.samples_to_ticks(at, s.sample_rate);
let mut out = Vec::new();
for (i, tr) in smf.tracks.iter().enumerate() {
if tr.sequence.notes.is_empty() && tr.sequence.ctrls.is_empty() {
continue;
}
// A controller on the clip's last tick would not play, so the clip runs one tick past the last one.
let past_ctrls = tr.sequence.ctrls.iter().map(|c| c.tick.saturating_add(1)).max().unwrap_or(0);
let end_tick = base_tick.saturating_add(tr.sequence.end_tick().max(past_ctrls).max(1));
let len = tempo.tick_to_samples(end_tick, s.sample_rate).saturating_sub(at);
if len > s.sample_rate.samples(86_400.0) {
return Err(EngineError::Io(format!("MIDI track {}: longer than 24 hours", i + 1)));
}
clips.push((i, tr, len));
}
// Phase 2: nothing can fail from here on.
let s = e.session_mut();
if tempo_map {
s.tempo = tempo;
}
let mut out = Vec::new();
for (i, tr, len) in clips {
let mut name = if tr.name.trim().is_empty() { format!("MIDI {}", i + 1) } else { tr.name.clone() };
soundcraft_model::truncate_chars(&mut name, soundcraft_model::MAX_NAME_CHARS);
let id = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some(&name));
@@ -71,6 +48,13 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b
if let Some(t) = s.track_mut(id) {
t.instrument = Some(soundcraft_model::Insert::new(if drums { "drum_synth" } else { "subtractive_synth" }));
}
// A controller on the clip's last tick would not play, so the clip runs one tick past the last one.
let past_ctrls = tr.sequence.ctrls.iter().map(|c| c.tick.saturating_add(1)).max().unwrap_or(0);
let end_tick = base_tick.saturating_add(tr.sequence.end_tick().max(past_ctrls).max(1));
let len = s.tempo.tick_to_samples(end_tick, s.sample_rate).saturating_sub(at);
if len > s.sample_rate.samples(86_400.0) {
return Err(EngineError::Io(format!("MIDI track {}: longer than 24 hours", i + 1)));
}
let cid = s.new_clip_id();
crate::edit::place_clip(s, id, Clip::midi(cid, name, at, len.max(1), tr.sequence.clone()));
out.push(id);
-27
View File
@@ -186,33 +186,6 @@ mod name_tests {
assert!(err.to_string().contains("longer than 24 hours"), "{err}");
}
#[test]
fn a_refused_midi_file_leaves_the_session_untouched() {
// Track 1 fits; track 2 is longer than a day. Before the import is refused, nothing of
// track 1, nor the file's tempo and meter changes, may have reached the session.
let note = |length| soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length };
let short = soundcraft_midi::Sequence { notes: vec![note(960)], ..Default::default() };
let held = soundcraft_midi::Sequence { notes: vec![note((1 << 28) - 1)], ..Default::default() };
let smf = soundcraft_midi::Smf {
tracks: vec![
soundcraft_midi::SmfTrack { name: "fits".into(), sequence: short },
soundcraft_midi::SmfTrack { name: "too long".into(), sequence: held },
],
tempos: vec![(0, 91.0)],
meters: vec![(0, 7, 8)],
..Default::default()
};
let mut e = Engine::default();
let before = e.session().clone();
let (revision, dirty) = (e.revision, e.is_dirty());
let err = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap_err();
assert!(err.to_string().contains("MIDI track 2: longer than 24 hours"), "{err}");
assert_eq!(e.session().tracks.len(), before.tracks.len());
assert_eq!(e.session().tempo, before.tempo);
assert_eq!(*e.session(), before);
assert_eq!((e.revision, e.is_dirty()), (revision, dirty), "a refused file is not an edit");
}
#[test]
fn midi_with_more_meter_changes_than_scores_have_is_refused() {
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
-93
View File
@@ -520,99 +520,6 @@ mod open_tests {
assert!(r.session().pool.get(r.session().sources[0].id).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
/// Every outcome of [`relink_videos`] for a movie: its file is there; it is gone but a file of
/// the same name is next to the session or in its `Video Files` (relinked); it is gone with
/// no copy (offline, path kept); its stored path is refused, with or without such a copy.
#[test]
fn each_movie_is_kept_relinked_or_offline() {
let dir = std::env::temp_dir().join(format!("soundcraft-relink-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Movies")).unwrap();
std::fs::create_dir_all(dir.join("Video Files")).unwrap();
for f in ["Movies/here.mov", "near.mov", "Video Files/filed.mov", "remote_copy.mov"] {
std::fs::write(dir.join(f), b"movie").unwrap();
}
let session = dir.join("Song.scraft");
let mut s = Session::default();
let stored = [
"Movies/here.mov",
"Old/near.mov",
"Old/filed.mov",
"Old/gone.mov",
"file://192.0.2.1/s/remote.mov",
"file://192.0.2.1/s/remote_copy.mov",
];
for path in stored {
let id = SourceId(s.alloc());
s.videos.push(soundcraft_model::VideoSource {
id,
name: path.into(),
path: path.into(),
width: 0,
height: 0,
frame_rate: 0.0,
duration: 0.0,
codec: String::new(),
offline: None,
});
}
let missing = relink_videos(&mut s, &session);
let got: Vec<(String, Option<String>)> = s.videos.iter().map(|v| (v.path.clone(), v.offline.clone())).collect();
let at = |f: &str| dir.join(f).to_string_lossy().into_owned();
assert_eq!(got[0], (stored_path("Movies/here.mov", &session).unwrap().to_string_lossy().into_owned(), None));
assert_eq!(got[1], (at("near.mov"), None));
assert_eq!(got[2], (at("Video Files/filed.mov"), None));
assert_eq!(got[3], ("Old/gone.mov".into(), Some("file not found".into())));
assert_eq!(got[4].0, stored[4]);
assert!(got[4].1.as_deref().is_some_and(|why| why.starts_with("not opened:")), "{:?}", got[4]);
assert_eq!(got[5], (at("remote_copy.mov"), None));
assert_eq!(missing.len(), 2, "{missing:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_movie_is_offline_and_keeps_its_stored_path() {
// The stored path is relative to the session and names no file. The movie is offline, so
// the Video track never opens the stored string (the working directory would resolve
// it), and the string is kept: saved into another folder, the session finds the movie
// once it is back there under the same relative path.
let dir = std::env::temp_dir().join(format!("soundcraft-missing-movie-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (first, moved) = (dir.join("a"), dir.join("b"));
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(moved.join("Movies")).unwrap();
let mut s = Session::default();
let id = SourceId(s.alloc());
s.videos.push(soundcraft_model::VideoSource {
id,
name: "gone".into(),
path: "Movies/gone.mov".into(),
width: 0,
height: 0,
frame_rate: 0.0,
duration: 0.0,
codec: String::new(),
offline: None,
});
let session = first.join("Song.scraft");
std::fs::write(&session, s.to_json().unwrap()).unwrap();
let mut e = Engine::default();
let missing = open_session(&mut e, &session).unwrap();
assert_eq!(missing, vec![format!("{} (video)", stored_path("Movies/gone.mov", &session).unwrap().display())]);
let v = &e.session().videos[0];
assert_eq!((v.path.as_str(), v.offline.as_deref()), ("Movies/gone.mov", Some("file not found")));
// Saved next to the movie's new home, the session opens it.
let session = moved.join("Song.scraft");
save_session(&mut e, session.to_str().unwrap()).unwrap();
std::fs::write(moved.join("Movies").join("gone.mov"), b"movie").unwrap();
let mut r = Engine::default();
assert!(open_session(&mut r, &session).unwrap().is_empty());
let v = &r.session().videos[0];
assert_eq!(v.path, stored_path("Movies/gone.mov", &session).unwrap().to_string_lossy());
assert!(v.offline.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(all(test, unix))]
+14 -55
View File
@@ -82,8 +82,6 @@ pub struct Dialogs {
strip_values: StripValues,
/// The Strip Silence rectangles the Edit window draws while Strip Silence is open.
strip_rectangles: StripRectangles,
/// True while a dialog was drawn on the previous frame.
shown: bool,
}
impl Dialogs {
@@ -124,15 +122,15 @@ impl Dialogs {
}
pub fn open_rename_track(&mut self, id: TrackId, name: &str) {
self.set(Dialog::RenameTrack { id, name: name.to_string() });
self.open = Some(Dialog::RenameTrack { id, name: name.to_string() });
}
pub fn open_rename_clip(&mut self, id: ClipId, name: &str) {
self.set(Dialog::RenameClip { id, name: name.to_string() });
self.open = Some(Dialog::RenameClip { id, name: name.to_string() });
}
pub fn open_rename_marker(&mut self, id: soundcraft_model::MarkerId, name: &str) {
self.set(Dialog::RenameMarker { id, name: name.to_string() });
self.open = Some(Dialog::RenameMarker { id, name: name.to_string() });
}
/// Groups list › Modify Group…: the creation dialog, pre-filled from an existing group. False
@@ -141,31 +139,24 @@ impl Dialogs {
let s = e.session();
let Some(g) = s.group(id) else { return false };
let members = s.tracks.iter().map(|t| (t.id.0, t.name.clone(), g.members.contains(&t.id))).collect();
self.set(Dialog::Group { id: Some(id), name: g.name.clone(), edit: g.edit, mix: g.mix, members });
self.open = Some(Dialog::Group { id: Some(id), name: g.name.clone(), edit: g.edit, mix: g.mix, members });
true
}
/// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear.
pub fn open_clear(&mut self, e: &Engine, clips: Vec<ClipId>, sources: Vec<SourceId>) -> bool {
let Some(message) = clear_message(e.session(), &clips, &sources) else { return false };
self.set(Dialog::ClearClips { clips, sources, message });
self.open = Some(Dialog::ClearClips { clips, sources, message });
true
}
/// Opens `d` in place of any open dialog. Its first frame clears the keyboard focus and ignores Enter, as for a
/// dialog opened when none was open.
pub(crate) fn set(&mut self, d: Dialog) {
self.open = Some(d);
self.shown = false;
}
/// Clip List › Auto Rename: the clips' naming scheme, filled in from the first clip's. False
/// when none of the clips is in the session.
pub fn open_auto_rename(&mut self, e: &Engine, clips: Vec<ClipId>) -> bool {
let s = e.session();
let Some((_, first)) = clips.iter().find_map(|id| s.find_clip(*id)) else { return false };
let names = first.auto_name.and_then(|k| s.auto_names.get(&k)).map_or_else(StripNames::default, StripNames::from);
self.set(Dialog::AutoRename { clips, names });
self.open = Some(Dialog::AutoRename { clips, names });
true
}
@@ -174,7 +165,7 @@ impl Dialogs {
self.after_save = None;
let home = default_dir();
let name = e.session().name.clone();
let d = match id {
self.open = Some(match id {
"clip.clear" => {
let ed = &e.session().edit;
return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone());
@@ -255,8 +246,7 @@ impl Dialogs {
Some(d) => d,
None => return false,
},
};
self.set(d);
});
true
}
}
@@ -331,25 +321,7 @@ fn bounce_file_row(app: &mut SoundApp, ui: &mut egui::Ui, path: &mut String, for
fn path_prompt_form(app: &mut SoundApp, ui: &mut egui::Ui, cmd: &str, path: &mut String, key: &str, enter: bool) -> Option<(String, Value)> {
crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "Path");
let mut picked = None;
// Import Audio picks several files at once; every other open dialog takes one.
let multi = cmd == "file.import_audio" && app.services.pick_open_many.is_some();
if multi
&& let Some(pick) = &app.services.pick_open_many
&& ui.button("Browse…").clicked()
{
let mut files = pick(cmd);
if files.len() > 1 {
let texts: Result<Vec<&str>, _> = files.iter().map(|f| unicode(f)).collect();
match texts {
Ok(texts) => return Some((cmd.to_string(), json!({"paths": texts}))),
Err(e) => app.ui.status = e.to_string(),
}
} else {
picked = files.pop();
}
}
if let Some(pick) = &app.services.pick_open
&& !multi
&& cmd != "session.save_as"
&& cmd != "session.save_copy"
&& key != "dir"
@@ -404,10 +376,10 @@ fn strip_silence_form(
let mut rename_from = None;
let [threshold, min_ms, pre_ms, post_ms] = values;
let before = [*threshold, *min_ms, *pre_ms, *post_ms];
crate::widgets::fine_slider(ui, threshold, -96.0..=0.0, "Threshold dB");
crate::widgets::fine_slider(ui, min_ms, 0.0..=4000.0, "Min strip duration ms");
crate::widgets::fine_slider(ui, pre_ms, 0.0..=500.0, "Clip start pad ms");
crate::widgets::fine_slider(ui, post_ms, 0.0..=2000.0, "Clip end pad ms");
ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB"));
ui.add(egui::Slider::new(min_ms, 0.0..=4000.0).text("Min strip duration ms"));
ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms"));
ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms"));
let values = [*threshold, *min_ms, *pre_ms, *post_ms];
// The Edit window is drawn before this window, so it draws the rectangles for new values in the next frame.
if values != before {
@@ -453,16 +425,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
if !matches!(app.dialogs.open, Some(Dialog::StripSilence { .. } | Dialog::StripSilenceRename { .. })) {
app.dialogs.strip_rectangles = StripRectangles::default();
}
let Some(mut d) = app.dialogs.open.take() else {
app.dialogs.shown = false;
return;
};
// On the first frame a dialog is drawn, clear the keyboard focus a widget outside it may still hold, and ignore Enter:
// an Enter in that frame is the one that opened the dialog from a menu item or the command palette.
let first_frame = !std::mem::replace(&mut app.dialogs.shown, true);
if first_frame {
ctx.memory_mut(|m| m.stop_text_input());
}
let Some(mut d) = app.dialogs.open.take() else { return };
let mut keep = true;
let mut action: Option<(String, Value)> = None;
let mut bounce_params: Option<Value> = None;
@@ -495,11 +458,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::SaveChanges { .. } => "Unsaved Changes",
}
.to_string();
// egui treats Enter on a focused button, checkbox, radio button or combo box as a click, so Enter presses the default
// button only when no widget has the keyboard focus or a text field has it. A held Enter's repeats do not press it.
let fresh_enter =
|i: &egui::InputState| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Enter, pressed: true, repeat: false, .. }));
let enter = !first_frame && ctx.input(fresh_enter) && (ctx.memory(|m| m.focused().is_none()) || ctx.text_edit_focused());
let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter));
let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape));
egui::Window::new(egui::RichText::new(&title).font(bold(13.0)))
.collapsible(false)
+4 -15
View File
@@ -18,32 +18,21 @@ pub(super) fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
if let Some(info) = soundcraft_dsp::plugin_info(&process) {
for p in info.params {
let v = params.entry(p.id.to_string()).or_insert(p.default);
let mut wide = f64::from(*v);
crate::widgets::FineSlider::new(&mut wide, f64::from(p.min)..=f64::from(p.max))
.text(p.name)
.default_value(f64::from(p.default))
.show(ui);
*v = wide as f32;
ui.add(egui::Slider::new(v, p.min..=p.max).text(p.name));
}
} else {
match process.as_str() {
"normalize" => {
let v = params.entry("target_db".into()).or_insert(-0.1);
let mut wide = f64::from(*v);
crate::widgets::FineSlider::new(&mut wide, -24.0..=0.0).text("Peak dB").default_value(-0.1).show(ui);
*v = wide as f32;
ui.add(egui::Slider::new(v, -24.0..=0.0).text("Peak dB"));
}
"time_stretch" => {
let v = params.entry("ratio".into()).or_insert(1.0);
let mut wide = f64::from(*v);
crate::widgets::FineSlider::new(&mut wide, 0.25..=4.0).text("Length ratio").default_value(1.0).show(ui);
*v = wide as f32;
ui.add(egui::Slider::new(v, 0.25..=4.0).text("Length ratio"));
}
"pitch_shift" => {
let v = params.entry("semitones".into()).or_insert(0.0);
let mut wide = f64::from(*v);
crate::widgets::FineSlider::new(&mut wide, -24.0..=24.0).text("Semitones").default_value(0.0).show(ui);
*v = wide as f32;
ui.add(egui::Slider::new(v, -24.0..=24.0).text("Semitones"));
}
"speed_ramp" => {
for (id, label, default, choices) in SPEED_RAMP_CHOICES {
+1 -1
View File
@@ -14,7 +14,7 @@ pub fn ask_to_save(app: &mut SoundApp, id: &str, params: &Value) -> bool {
if !app.engine.is_dirty() {
return false;
}
app.dialogs.set(Dialog::SaveChanges { then: (id.into(), params.clone()) });
app.dialogs.open = Some(Dialog::SaveChanges { then: (id.into(), params.clone()) });
true
}
-2
View File
@@ -43,8 +43,6 @@ use layout::COLUMN_TITLES_H;
use layout::lane_rect;
pub use layout::{COLUMN_W, EditLayout, HEADER_W, LANE_H, RULER_H, edit_columns, header_width, rect, sample_at, track_height, x_of};
use overlay::overlay;
#[cfg(test)]
use overlay::playhead_colour;
pub use rulers::ruler_height;
use rulers::{column_titles, draw_rulers, views_button};
pub use scroll::zoom_toggle;
+1 -1
View File
@@ -315,7 +315,7 @@ fn trim_clip(app: &mut SoundApp, clip: ClipId, to: Samples, start: bool) {
pub(super) fn spot_or_run(app: &mut SoundApp, spot: Option<Spot>) {
let Some(spot) = spot else { return };
if app.engine.session().edit.edit_mode == EditMode::Spot {
app.dialogs.set(Dialog::Spot(spot));
app.dialogs.open = Some(Dialog::Spot(spot));
} else if let Some((id, params)) = spot.command(app.engine.session()) {
let _ = app.run(&id, params);
}
+1 -1
View File
@@ -550,7 +550,7 @@ fn io_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
ui.painter().rect_filled(r, 0.0, t.slot_well);
let ui = column_ui(ui, "io", r);
let row = |y: f32, h: f32| (r.height() >= y + h + 1.0).then_some(Rect::from_min_size(r.min + vec2(7.0, y), vec2(r.width() - 14.0, h)));
if let Some(input) = row(4.0, 15.0).filter(|_| crate::mix_window::has_input_selector(track.kind)) {
if let Some(input) = row(4.0, 15.0) {
let input_name = crate::mix_window::input_name(app, track);
let resp = header_selector(&ui, input, ui.scope_id().with(("in", id.0)), &input_name, t.text, true, &format!("Input for {}", track.name));
crate::mix_window::route_popup(app, &resp.on_hover_text("Input"), id, true);
+2 -8
View File
@@ -356,20 +356,14 @@ fn lane_click(app: &mut SoundApp, ui: &mut Ui, c: &LaneCtx<'_>, resp: &egui::Res
ids.push(c.id.0);
let _ = app.run("edit.select", json!({"clips": ids}));
if s.edit.edit_mode == EditMode::Spot {
match Spot::clips(s, vec![c.id], None, 0) {
Some(spot) => app.dialogs.set(Dialog::Spot(spot)),
None => app.dialogs.open = None,
}
app.dialogs.open = Spot::clips(s, vec![c.id], None, 0).map(Dialog::Spot);
}
}
}
Tool::Trim if s.edit.edit_mode == EditMode::Spot && hit.is_some() => {
if let Some(c) = &hit {
let start = at - c.start < c.end() - at;
match Spot::trim(s, c.id, start, if start { c.start } else { c.end() }) {
Some(spot) => app.dialogs.set(Dialog::Spot(spot)),
None => app.dialogs.open = None,
}
app.dialogs.open = Spot::trim(s, c.id, start, if start { c.start } else { c.end() }).map(Dialog::Spot);
}
}
Tool::Zoom => {
+1 -7
View File
@@ -4,12 +4,6 @@ use super::layout::x_of;
use super::scroll::{FollowFrame, follow_update};
use super::{Color32, Gesture, Rect, Samples, Shape, SoundApp, Stroke, StrokeKind, Tokens, TrackId, Ui, json, ops, pos2, rgb};
/// The playback cursor is red only while recording; plain playback draws it in its own neutral
/// grey, apart from the insertion colour of the edit cursor that stands beside it.
pub(super) fn playhead_colour(t: &Tokens, recording: bool) -> Color32 {
if recording { t.playhead } else { t.playback }
}
/// Selection overlay, insertion point and playhead across rulers and tracks.
/// While playing, auto-scroll keeps the playhead in view for the follow
/// modes (`page`/`continuous`/`center`) — but any outside view move (a manual
@@ -67,7 +61,7 @@ pub(super) fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
}
if app.is_playing() {
let x = x_of(s, tl, app.position());
painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.5, playhead_colour(&t, app.engine.transport.recording)));
painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.5, t.playhead));
// Session reads first (owned copies), then the layout mutation: the
// two borrow disjoint fields.
let pos = app.position();
+1 -1
View File
@@ -110,7 +110,7 @@ pub(super) fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect,
{
let at = snap(&s, sample_at(&s, tl, p.x).max(0));
let bpm = s.tempo.tempo_at_tick(s.tempo.samples_to_ticks(at, sr));
app.dialogs.set(crate::dialogs::Dialog::TempoChange { at, bpm });
app.dialogs.open = Some(crate::dialogs::Dialog::TempoChange { at, bpm });
}
let mut last_label = f32::MIN;
for ev in s.tempo.tempos() {
-62
View File
@@ -1574,68 +1574,6 @@ fn alt_click_on_a_breakpoint_deletes_it() {
}
}
#[test]
fn playhead_is_red_only_while_recording() {
for t in [Tokens::DARK, Tokens::LIGHT] {
assert_eq!(playhead_colour(&t, true), t.playhead);
assert_eq!(playhead_colour(&t, false), t.playback);
assert_ne!(t.playback, t.playhead, "plain playback must not read as the record line");
}
}
/// WCAG contrast ratio between two colours (1 = identical, 21 = black on white).
fn contrast(a: Color32, b: Color32) -> f32 {
let lum = |c: Color32| {
let lin = |v: u8| {
let v = f32::from(v) / 255.0;
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
};
0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
};
let (hi, lo) = (lum(a).max(lum(b)), lum(a).min(lum(b)));
(hi + 0.05) / (lo + 0.05)
}
/// The edit cursor stands still beside the moving playback line while playing: in both themes
/// they must be two clearly different greys, and the line must still read on the lanes.
#[test]
fn the_playback_line_is_set_apart_from_the_insertion_point_in_both_themes() {
for (name, t) in [("dark", Tokens::DARK), ("light", Tokens::LIGHT)] {
let apart = contrast(t.playback, t.insertion);
assert!(apart >= 2.5, "{name}: playback {:?} and insertion {:?} are only {apart:.2}:1 apart", t.playback, t.insertion);
let reads = contrast(t.playback, t.playlist_bg);
assert!(reads >= 3.0, "{name}: the playback line is only {reads:.2}:1 on the lanes");
}
}
/// The colour `overlay` gives the full-height playback line (width 1.5) in this transport state.
fn playback_line_colour(recording: bool) -> Option<Color32> {
use egui::{Rect, pos2};
let _palette = crate::theme::lock_test_palette();
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default());
app.engine.session_mut().edit.zoom.samples_per_px = 1_000.0;
app.engine.transport.playing = true;
app.engine.transport.recording = recording;
app.engine.transport.position = 80_000;
let tl = Rect::from_min_max(pos2(100.0, 100.0), pos2(900.0, 500.0));
let area = Rect::from_min_max(pos2(100.0, 50.0), tl.max);
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| overlay(&mut app, ui, tl, area));
output.textures_delta.clear();
output.shapes.iter().find_map(|s| match &s.shape {
Shape::LineSegment { points, stroke } if points[0].x == 180.0 && stroke.width == 1.5 => Some(stroke.color),
_ => None,
})
}
#[test]
fn overlay_draws_the_playback_line_neutral_and_the_record_line_red() {
let _palette = crate::theme::lock_test_palette();
let t = Tokens::current();
assert_eq!(playback_line_colour(false), Some(t.playback));
assert_eq!(playback_line_colour(true), Some(t.playhead));
}
#[test]
fn alt_click_on_a_marker_deletes_it() {
let mut engine = soundcraft_engine::demo::demo_engine();
+1 -6
View File
@@ -1,4 +1,3 @@
use super::overlay::playhead_colour;
use super::{Color32, Rect, Samples, Sense, Session, SoundApp, Stroke, StrokeKind, Tokens, Track, Ui, json, pos2, rgb, vec2};
/// The Universe's clip blocks for one document revision and area: each track's clips merged into
@@ -83,11 +82,7 @@ pub(super) fn universe(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Tokens::current().universe_view), StrokeKind::Outside);
if app.is_playing() {
let x = r.min.x + app.position() as f32 / total * r.width();
let line = [pos2(x, r.min.y), pos2(x, r.max.y)];
// The Universe paints clips in their raw track colours, some as light as the neutral playback line: a dark
// edge on each side keeps it readable over all of them.
ui.painter().line_segment(line, Stroke::new(3.0, Color32::from_black_alpha(160)));
ui.painter().line_segment(line, Stroke::new(1.0, playhead_colour(&Tokens::current(), app.engine.transport.recording)));
ui.painter().line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Tokens::current().playhead));
}
let resp = ui.interact(r, ui.scope_id().with("universe"), Sense::click_and_drag());
if let Some(p) = resp.interact_pointer_pos()
+1 -37
View File
@@ -46,30 +46,8 @@ impl Imports {
}
/// Imports the audio file `params["path"]` names (the `file.import_audio` params) in the
/// background; several `paths` queue one import each. Anything else (no path, the browser) runs the command now.
/// background. Anything else (several `paths`, no path, the browser) runs the command now.
pub fn start(app: &mut SoundApp, params: Value) {
// Several picked files: one queue entry each, so they decode one at a time and the app stays responsive.
if !cfg!(target_arch = "wasm32")
&& params.get("path").is_none()
&& let Some(paths) = params.get("paths").and_then(Value::as_array)
{
let singles: Vec<Value> = paths
.iter()
.filter_map(Value::as_str)
.map(|p| {
let mut one = params.clone();
if let Some(o) = one.as_object_mut() {
o.remove("paths");
o.insert("path".into(), Value::from(p));
}
one
})
.collect();
if !singles.is_empty() {
app.imports.queue.extend(singles);
return;
}
}
if cfg!(target_arch = "wasm32") || params.get("path").and_then(Value::as_str).is_none() {
run_now(app, params);
return;
@@ -235,20 +213,6 @@ mod tests {
assert_eq!(app.engine.session().tracks.len(), 1, "the good file still imports");
}
/// Issue #1089: several files picked in one Browse go to the queue, one entry each.
#[test]
fn several_paths_import_one_after_another() {
let ctx = egui::Context::default();
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let (a, b, c) = (tone_wav("many-a.wav"), tone_wav("many-b.wav"), tone_wav("many-c.wav"));
start(&mut app, json!({"paths": [a, b, c]}));
assert_eq!(app.imports.queue.len(), 3);
step(&mut app, &ctx);
assert_eq!(app.imports.label().map(|l| l.1), Some(2), "the other two wait for the first");
settle(&mut app, &ctx);
assert_eq!(app.engine.session().tracks.len(), 3);
}
#[test]
fn a_failed_import_stays_on_the_status_line_while_the_next_one_starts() {
let ctx = egui::Context::default();
+14 -31
View File
@@ -371,36 +371,27 @@ fn comments_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f
y
}
/// Whether a track shows an input selector. A master fader has no input of its own:
/// it only shows the output of the path it controls.
pub(crate) fn has_input_selector(kind: TrackKind) -> bool {
kind != TrackKind::Master
}
/// Input (except on master faders), output and automation mode selectors.
/// Input, output and automation mode selectors.
fn io_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 {
let StripCtx { track, id, narrow, x0, inner_w, .. } = *cx;
let t = Tokens::current();
if cx.shows("io") {
let has_input = has_input_selector(track.kind);
let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 2.0 * 18.0 + 30.0));
inset(ui.painter(), sec, t.strip_section);
section_label(ui, sec, "I / O", narrow);
let in_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0), vec2(inner_w - 4.0, 16.0));
let out_r = if has_input { Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0)) } else { in_r };
if has_input {
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).max_rect(in_r));
let input = input_name(app, track);
let resp = selector_box_named(
&mut c,
in_r.width(),
in_r.height(),
&input,
t.text,
SelectorLabels { name: &format!("Input for {}", track.name), value: &input, tooltip: "Input" },
);
route_popup(app, &resp, id, true);
}
let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0));
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).max_rect(in_r));
let input = input_name(app, track);
let resp = selector_box_named(
&mut c,
in_r.width(),
in_r.height(),
&input,
t.text,
SelectorLabels { name: &format!("Input for {}", track.name), value: &input, tooltip: "Input" },
);
route_popup(app, &resp, id, true);
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-output", id.0)).max_rect(out_r));
let output = route_name(app, &track.mixer.output);
let resp = selector_box_named(
@@ -413,7 +404,7 @@ fn io_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) ->
);
route_popup(app, &resp, id, false);
// Automation mode.
let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0 + 2.0 * 18.0), vec2(inner_w - 4.0, 16.0));
let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 51.0), vec2(inner_w - 4.0, 16.0));
ui.painter().text(pos2(am_r.center().x, am_r.min.y - 1.0), Align2::CENTER_BOTTOM, "", regular(9.0), t.text_dim);
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(am_r));
let mode = track.mixer.automation_mode;
@@ -676,14 +667,6 @@ fn name_and_readout(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>) {
mod tests {
use super::{Readout, dragged_send_db, editable_readout, parse_db, parse_pan};
use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2};
use soundcraft_model::TrackKind;
#[test]
fn master_strip_has_no_input_selector() {
assert!(!super::has_input_selector(TrackKind::Master));
assert!(super::has_input_selector(TrackKind::Audio));
assert!(super::has_input_selector(TrackKind::Aux));
}
#[test]
fn typed_db_accepts_console_notation_and_clamps() {
+1 -2
View File
@@ -335,6 +335,7 @@ enum SendFooter {
/// that scrolls, "new bus..." (and "Remove send" for an existing send) fixed under it.
/// `current` is the send's destination and its name, if the slot has a send.
fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: TrackId, slot: usize, current: Option<(Route, String)>) {
let busses = app.engine.session().busses.clone();
let which = (b'A' + slot as u8) as char;
let has_send = current.is_some();
let header = |ui: &mut Ui| {
@@ -354,7 +355,6 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac
}
}
let search = MenuSearch::show(ui, egui::Id::unique(("send-bus-search", id.0, slot)), "Search buses…");
let busses = &app.engine.session().busses;
let visible: Vec<_> = busses.iter().filter(|bus| search.matches(&bus.name)).collect();
(search, visible)
};
@@ -402,7 +402,6 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac
}
match footer {
Some(SendFooter::NewBus) => {
let busses = &app.engine.session().busses;
let mut n = busses.len() + 1;
while busses.iter().any(|bus| bus.name.eq_ignore_ascii_case(&format!("Bus {n}")))
|| app.engine.session().track_by_name(&format!("Bus {n}")).is_some()
+2 -12
View File
@@ -78,26 +78,16 @@ pub(super) fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
}
});
x = idx as f32;
if r.response.clicked() && ui.input(|i| i.modifiers.alt) {
x = p.default;
egui::Popup::close_all(ui.ctx());
}
r.response
} else {
let mut wide = f64::from(x);
let mut sl = crate::widgets::FineSlider::new(&mut wide, f64::from(p.min)..=f64::from(p.max))
.id_salt(p.id)
.show_value(false)
.default_value(f64::from(p.default));
let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false);
if instrument && crate::sampler_window::whole_number(&ins.plugin, p.id) {
sl = sl.integer();
}
if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 {
sl = sl.logarithmic(true);
}
let r = sl.show(ui);
x = wide as f32;
r
ui.add(sl)
};
// The grid cell's painted label is not an accesskit name; tie the
// combo box or slider to it so screen readers and label queries find it.
+2 -10
View File
@@ -791,13 +791,7 @@ pub fn clip_effects(app: &mut SoundApp, ctx: &egui::Context) {
let key = format!("{prefix}{}", p.id);
let mut v = get(app, &key, p.default);
let before = v;
let mut wide = f64::from(v);
crate::widgets::FineSlider::new(&mut wide, f64::from(p.min)..=f64::from(p.max))
.text(p.name)
.logarithmic(p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0)
.default_value(f64::from(p.default))
.show(col);
v = wide as f32;
col.add(egui::Slider::new(&mut v, p.min..=p.max).text(p.name));
if (v - before).abs() > f32::EPSILON {
let mut m = serde_json::Map::new();
m.insert(key.clone(), json!(v));
@@ -812,9 +806,7 @@ pub fn clip_effects(app: &mut SoundApp, ctx: &egui::Context) {
});
let mut g = get(app, "gain", 0.0);
let before = g;
let mut wide = f64::from(g);
crate::widgets::FineSlider::new(&mut wide, -36.0..=24.0).text("Clip effects gain (dB)").default_value(0.0).show(ui);
g = wide as f32;
ui.add(egui::Slider::new(&mut g, -36.0..=24.0).text("Clip effects gain (dB)"));
if (g - before).abs() > f32::EPSILON {
let _ =
app.engine.execute_merged("clip.effects_set", &json!({"clips": [cid.0], "params": {"gain": g}}), &format!("cfx:{}:gain", cid.0));
-2
View File
@@ -5,8 +5,6 @@ use std::path::{Path, PathBuf};
#[derive(Default)]
pub struct Services {
pub pick_open: Option<Box<dyn Fn(&str, &[&str]) -> Option<PathBuf>>>,
/// Like `pick_open`, but the user may select several files (Import Audio). Empty when cancelled.
pub pick_open_many: Option<Box<dyn Fn(&str) -> Vec<PathBuf>>>,
/// The purpose and the file to start from.
pub pick_save: Option<Box<dyn Fn(&str, &Path) -> Option<PathBuf>>>,
/// The folder to start in.
-5
View File
@@ -143,9 +143,6 @@ pub struct Tokens {
pub selection: Color32,
pub playhead: Color32,
pub insertion: Color32,
/// The playback line while the transport runs without recording: a mid grey, set well apart
/// from `insertion` (the edit cursor stands beside it while playing) in both themes.
pub playback: Color32,
pub solo: Color32,
pub mute: Color32,
pub rec: Color32,
@@ -256,7 +253,6 @@ impl Tokens {
selection: Color32::from_rgba_premultiplied(40, 70, 110, 110),
playhead: Color32::from_rgb(232, 60, 50),
insertion: Color32::from_rgb(230, 230, 230),
playback: Color32::from_rgb(140, 140, 142),
solo: Color32::from_rgb(222, 196, 52),
mute: Color32::from_rgb(232, 148, 40),
rec: Color32::from_rgb(214, 52, 46),
@@ -342,7 +338,6 @@ impl Tokens {
selection: Color32::from_rgba_premultiplied(100, 145, 205, 90),
playhead: Color32::from_rgb(210, 48, 42),
insertion: Color32::from_rgb(35, 36, 40),
playback: Color32::from_rgb(110, 113, 119),
solo: Color32::from_rgb(178, 145, 20),
mute: Color32::from_rgb(194, 106, 22),
rec: Color32::from_rgb(190, 42, 38),
-169
View File
@@ -424,175 +424,6 @@ pub fn db_text(db: f32) -> String {
if db <= -143.9 { "-inf".into() } else { format!("{db:.1}") }
}
/// Where a [`fine_slider`] drag is anchored: the pointer's x and the value there, whether the key
/// was held at the anchor, whether the drag moves the value from the anchor and not to the
/// pointer, and the pass that last drew the slider during the drag.
#[derive(Clone, Copy)]
struct FineDrag {
x: f32,
value: f64,
fine: bool,
relative: bool,
pass: u64,
}
/// A horizontal slider that moves a tenth as far for the same drag while Ctrl (Command on a Mac)
/// is held, as a [`fader`] does, on the rail and on the value box. Pressing or releasing the key
/// during a drag on the rail or the value box leaves the value where it is, and the drag goes on
/// from there. The value box shows the decimal places that a tenth of a point's step needs, and a
/// drag on the rail that the key has touched rounds the value to them.
pub fn fine_slider(ui: &mut Ui, value: &mut f64, range: std::ops::RangeInclusive<f64>, text: &str) -> Response {
FineSlider::new(value, range).text(text).show(ui)
}
/// A [`fine_slider`] with the options of `egui::Slider` that plug-in parameters use.
pub struct FineSlider<'a> {
value: &'a mut f64,
range: std::ops::RangeInclusive<f64>,
text: &'a str,
id_salt: Option<&'a str>,
show_value: bool,
logarithmic: bool,
integer: bool,
default: Option<f64>,
}
impl<'a> FineSlider<'a> {
pub fn new(value: &'a mut f64, range: std::ops::RangeInclusive<f64>) -> Self {
Self { value, range, text: "", id_salt: None, show_value: true, logarithmic: false, integer: false, default: None }
}
/// The value that an Alt-click (Option-click on a Mac) on the slider or its value box sets,
/// as a [`fader`] returns to 0 dB. While Alt is held, a drag leaves the value where it is, and
/// an Alt-click on the value box does not open it for typing.
pub fn default_value(mut self, default: f64) -> Self {
self.default = Some(default);
self
}
/// The label after the slider. It also tells sliders in the same `Ui` apart unless
/// [`Self::id_salt`] is set.
pub fn text(mut self, text: &'a str) -> Self {
self.text = text;
self
}
/// Tells sliders in the same `Ui` apart when they have no label.
pub fn id_salt(mut self, salt: &'a str) -> Self {
self.id_salt = Some(salt);
self
}
/// Whether the value box is shown. Without it, a drag that the key has touched rounds the
/// value only on a whole-number slider.
pub fn show_value(mut self, show: bool) -> Self {
self.show_value = show;
self
}
/// A logarithmic rail, as `egui::Slider::logarithmic`, for a range above zero. A range that
/// reaches zero or below stays linear.
pub fn logarithmic(mut self, logarithmic: bool) -> Self {
self.logarithmic = logarithmic;
self
}
/// Whole numbers only, as `egui::Slider::integer`.
pub fn integer(mut self) -> Self {
self.integer = true;
self
}
pub fn show(self, ui: &mut Ui) -> Response {
let Self { value, range, text, id_salt, show_value, logarithmic, integer, default } = self;
let id = ui.scope_id().with(("fine_slider", id_salt.unwrap_or(text)));
let before = *value;
let (fine, x) = ui.input(|i| (i.modifiers.command || i.modifiers.ctrl, i.pointer.interact_pos().map(|p| p.x)));
let reset = default.is_some() && ui.input(|i| i.modifiers.alt);
// A drag lasts through the pass that releases the button, where egui still sets the value
// from the pointer. An anchor that the previous pass did not store was left by an earlier
// drag, and is ignored.
let pass = ui.ctx().cumulative_pass_nr();
let mut drag = ui.data(|d| d.get_temp::<FineDrag>(id)).filter(|d| d.pass + 1 == pass);
let (lo, hi) = (*range.start(), *range.end());
let log = logarithmic && lo > 0.0 && hi > lo;
// The handle's travel along the rail, as `egui::Slider` lays it out.
let thickness = ui.text_style_height(&egui::TextStyle::Body).max(ui.spacing().interact_size.y);
let travel = f64::from((ui.spacing().slider_width - 2.0 * thickness / 2.5).max(1.0));
// Where a value sits along the rail (0 to 1), and back, as `egui::Slider` maps them.
let span = if log { (hi / lo).ln() } else { hi - lo };
let along = |v: f64| if span > 0.0 { if log { (v / lo).ln() / span } else { (v - lo) / span } } else { 0.0 };
let at = |n: f64| {
let n = n.clamp(0.0, 1.0);
if log { lo * (n * span).exp() } else { lo + n * span }
};
// How far the value moves for one point of the rail at `v`.
let per_point = |v: f64| if log { v * span / travel } else { span / travel };
let scale = if fine { 0.1 } else { 1.0 };
let decimals = if integer { 0 } else { (-(per_point(lo) * 0.1).log10()).ceil().clamp(0.0, 6.0) as usize };
if let (Some(d), Some(x)) = (&mut drag, x) {
if d.fine != fine || reset {
*d = FineDrag { x, value: *value, fine, relative: true, pass };
} else if d.relative {
let moved = at(along(d.value) + f64::from(x - d.x) / travel * scale);
let moved = if show_value || integer { egui::emath::round_to_decimals(moved, decimals) } else { moved };
*value = moved.clamp(lo, hi);
}
}
let hold = reset || drag.is_some_and(|d| d.relative);
let speed = per_point(*value) * scale;
let mut slider = egui::Slider::from_get_set(range, |v| {
if let Some(v) = v
&& !hold
{
*value = v;
}
*value
})
.show_value(show_value)
.logarithmic(log)
.min_decimals(decimals)
.text(text);
if integer {
slider = slider.integer();
}
if fine {
slider = slider.drag_value_speed(speed);
}
let mut resp = ui.add(slider);
// A drag from the anchor set the value before the slider read it.
if *value != before {
resp.mark_changed();
}
// The rail senses drags only, so a click on it ends as a drag that the pointer did not
// move far enough to count as one.
let clicked = resp.clicked() || (resp.drag_stopped() && ui.input(|i| i.pointer.could_any_button_be_click()));
if let Some(d) = default
&& reset
&& clicked
{
let d = if integer { d.round() } else { d };
*value = d.clamp(lo, hi);
resp.surrender_focus();
resp.mark_changed();
}
let rail = Rect::from_min_size(resp.rect.min, vec2(ui.spacing().slider_width, resp.rect.height()));
let on_rail = resp.dragged() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| rail.contains(p));
let next = match (on_rail, drag, x) {
(true, Some(d), _) => Some(FineDrag { pass, ..d }),
(true, None, Some(x)) => Some(FineDrag { x, value: *value, fine, relative: fine, pass }),
_ => None,
};
ui.data_mut(|m| match next {
Some(d) => {
m.insert_temp(id, d);
}
None => m.remove::<FineDrag>(id),
});
resp
}
}
/// A speaker dot on the surround panner: label, square position (-1..1, y up = front), height layer.
pub struct PannerSpeaker {
pub label: &'static str,
-21
View File
@@ -39,27 +39,6 @@ fn bounce_from_dialog(h: &mut Harness<'_, SoundApp>, file: &Path) {
assert!(h.query_by_label("Bounce Mix").is_none());
}
#[test]
fn enter_on_a_focused_cancel_closes_the_dialog_and_starts_no_bounce() {
let dir = scratch("bounce-enter-cancel");
let file = dir.join("Mix.wav");
let mut h = harness();
let app = h.state_mut();
assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix"));
if let Some(Dialog::Bounce { path, .. }) = &mut app.dialogs.open {
*path = file.to_string_lossy().into_owned();
}
h.run_steps(2);
h.get_by_label("Cancel").focus();
h.run_steps(2);
h.event(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
h.run_steps(3);
assert!(h.state().dialogs.open.is_none());
assert!(h.state().bounce.is_none(), "Cancel started a bounce");
assert!(!file.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_bounce_renders_across_frames_with_progress_until_its_file_is_written() {
let dir = scratch("bounce-frames");
-34
View File
@@ -146,40 +146,6 @@ fn spot_drags_ask_where_before_moving() {
assert!((starts(&app, &ids)[0] - 2 * SEC).abs() < 4000);
}
#[test]
fn a_spot_dialog_that_replaces_an_open_dialog_ignores_the_enter_of_its_first_frame() {
let (mut app, ctx, t, ids) = app("spot", "grabber", &[(SEC, SEC)]);
assert!(app.dialogs.open_for_command(&app.engine, "setup.session"));
frame(&ctx, &mut app, vec![]);
frame(&ctx, &mut app, vec![]);
let (from, to) = (point(&app, t, SEC * 3 / 2), point(&app, t, SEC * 5 / 2));
frame(&ctx, &mut app, vec![Event::PointerMoved(from)]);
frame(&ctx, &mut app, vec![button(from, true)]);
let started = from + vec2(7.0, 0.0);
for k in 0..=5 {
frame(&ctx, &mut app, vec![Event::PointerMoved(started + (to - started) * (k as f32 / 5.0))]);
}
frame(&ctx, &mut app, vec![button(to, false), key_event(Key::Enter, true, Modifiers::NONE)]);
assert_eq!(app.dialogs.open_name(), Some("spot"), "the Enter of the Spot dialog's first frame leaves it open");
assert_eq!(starts(&app, &ids), vec![SEC]);
}
#[test]
fn a_spot_dialog_that_a_click_opens_over_an_open_dialog_ignores_the_enter_of_its_first_frame() {
for (tool, at) in [("grabber", SEC * 3 / 2), ("trim", SEC + SEC / 8)] {
let (mut app, ctx, t, ids) = app("spot", tool, &[(SEC, SEC)]);
assert!(app.dialogs.open_for_command(&app.engine, "setup.session"));
frame(&ctx, &mut app, vec![]);
frame(&ctx, &mut app, vec![]);
let at = point(&app, t, at);
frame(&ctx, &mut app, vec![Event::PointerMoved(at)]);
frame(&ctx, &mut app, vec![button(at, true)]);
frame(&ctx, &mut app, vec![button(at, false), key_event(Key::Enter, true, Modifiers::NONE)]);
assert_eq!(app.dialogs.open_name(), Some("spot"), "{tool}: the Enter of the Spot dialog's first frame leaves it open");
assert_eq!(starts(&app, &ids), vec![SEC], "{tool}");
}
}
#[test]
fn slip_trims_stop_at_the_next_clip() {
let (mut app, ctx, t, ids) = app("slip", "trim", &[(0, SEC), (2 * SEC, SEC)]);
@@ -1,298 +0,0 @@
//! Enter on a focused dialog button, checkbox, radio button or combo box acts on that widget and does not press the
//! dialog's default button.
use std::path::PathBuf;
use egui::accesskit::Role;
use egui::epaint::ClippedShape;
use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_audio_io::FileFormat;
use soundcraft_model::GroupId;
use soundcraft_ui_egui::dialogs::{Dialog, ask_to_save};
use soundcraft_ui_egui::{Services, SoundApp, menus};
fn harness(app: SoundApp) -> Harness<'static, SoundApp> {
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(3);
h
}
/// Gives the button the keyboard focus, then presses Enter.
fn enter_on(h: &mut Harness<'_, SoundApp>, button: &str) {
enter_on_widget(h, Role::Button, button);
}
/// Gives the widget the keyboard focus, then presses Enter.
fn enter_on_widget(h: &mut Harness<'_, SoundApp>, role: Role, label: &str) {
h.get_by_role_and_label(role, label).focus();
h.run_steps(2);
h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
#[test]
fn enter_on_a_focused_cancel_closes_the_dialog_without_its_action() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let groups = app.engine.session().groups.len();
menus::invoke_menu(&mut app, "track.group", "Track > Group...");
let mut h = harness(app);
assert_eq!(h.state().dialogs.open_name(), Some("group"));
enter_on(&mut h, "Cancel");
assert_eq!(h.state().dialogs.open_name(), None);
assert_eq!(h.state().engine.session().groups.len(), groups);
}
#[test]
fn enter_on_a_focused_dont_save_closes_without_saving() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.run("mix.mute", json!({"track": "Kick"})).unwrap();
menus::invoke_menu(&mut app, "session.close", "File > Close Session");
let mut h = harness(app);
assert_eq!(h.state().dialogs.open_name(), Some("save_changes"));
enter_on(&mut h, "Don't Save");
assert_eq!(h.state().dialogs.open_name(), None, "no Save As prompt");
assert!(h.state().engine.session().tracks.is_empty());
}
#[test]
fn enter_on_a_focused_browse_browses_without_pressing_ok() {
let picked = "picked/Song.scraft";
let services = Services { pick_open: Some(Box::new(move |_, _| Some(PathBuf::from(picked)))), ..Services::default() };
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, services);
menus::invoke_menu(&mut app, "session.open", "File > Open Session...");
let mut h = harness(app);
enter_on(&mut h, "Browse…");
assert!(matches!(&h.state().dialogs.open, Some(Dialog::PathPrompt { path, .. }) if path == picked));
assert!(h.state().engine.session().track_by_name("Kick").is_some(), "the demo session is still open");
}
#[test]
fn enter_on_a_focused_browse_in_bounce_mix_does_not_bounce() {
let picked = "picked/Mix.flac";
let services = Services { pick_save: Some(Box::new(move |_, _| Some(PathBuf::from(picked)))), ..Services::default() };
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, services);
menus::invoke_menu(&mut app, "file.bounce_mix", "File > Bounce Mix...");
let mut h = harness(app);
enter_on(&mut h, "Browse…");
assert!(h.state().bounce.is_none(), "Enter on Browse started a bounce");
assert!(matches!(&h.state().dialogs.open, Some(Dialog::Bounce { path, format, .. }) if path == picked && *format == FileFormat::Flac));
}
#[test]
fn enter_on_a_focused_default_button_runs_its_action_once() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let tracks = app.engine.session().tracks.len();
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
let mut h = harness(app);
enter_on(&mut h, "Create");
assert_eq!(h.state().dialogs.open_name(), None);
assert_eq!(h.state().engine.session().tracks.len(), tracks + 1);
}
#[test]
fn enter_on_a_focused_checkbox_toggles_it_and_keeps_the_dialog_open() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
menus::invoke_menu(&mut app, "file.score_setup", "File > Score Setup...");
let mut h = harness(app);
let Some(Dialog::ScoreSetup { show_track_names: before, .. }) = h.state().dialogs.open.clone() else { panic!("Score Setup did not open") };
enter_on_widget(&mut h, Role::CheckBox, "Show track names");
assert!(matches!(&h.state().dialogs.open, Some(Dialog::ScoreSetup { show_track_names, .. }) if *show_track_names != before));
}
#[test]
fn enter_on_a_focused_radio_button_picks_it_and_keeps_the_dialog_open() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create...");
let mut h = harness(app);
assert_eq!(h.state().dialogs.open_name(), Some("fades"));
enter_on_widget(&mut h, Role::RadioButton, "Linear");
assert!(matches!(&h.state().dialogs.open, Some(Dialog::Fades { shape }) if shape == "linear"));
}
#[test]
fn enter_with_nothing_focused_presses_the_default_button() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create...");
let mut h = harness(app);
assert_eq!(h.state().dialogs.open_name(), Some("fades"));
h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), None);
}
#[test]
fn a_dialog_opened_while_a_window_button_has_the_focus_takes_enter() {
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let mut h = harness(app);
h.get_all_by_role(Role::Button).next().expect("the window has a button").focus();
h.run_steps(2);
assert!(h.ctx.memory(|m| m.focused().is_some()));
menus::invoke_menu(h.state_mut(), "edit.fades_create", "Edit > Fades > Create...");
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), Some("fades"));
h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), None, "Enter pressed Create");
}
#[test]
fn the_enter_that_opens_a_dialog_does_not_press_its_default_button() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
run_frame(&ctx, &mut app, vec![]);
menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create...");
run_frame(&ctx, &mut app, vec![enter_key(false), enter_up()]);
assert_eq!(app.dialogs.open_name(), Some("fades"), "the opening Enter leaves the dialog open");
run_frame(&ctx, &mut app, vec![enter_key(false)]);
assert_eq!(app.dialogs.open_name(), None, "the next Enter presses Create");
}
/// Runs one frame of `app` with `events` and returns what it painted.
fn run_frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<Event>) -> Vec<ClippedShape> {
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
out.shapes
}
fn enter_key(repeat: bool) -> Event {
Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }
}
fn enter_up() -> Event {
Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }
}
#[test]
fn the_enter_that_replaces_an_open_dialog_does_not_press_the_new_default_button() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
menus::invoke_menu(&mut app, "setup.session", "Setup > Session");
run_frame(&ctx, &mut app, vec![]);
run_frame(&ctx, &mut app, vec![]);
assert_eq!(app.dialogs.open_name(), Some("session"));
menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create...");
run_frame(&ctx, &mut app, vec![enter_key(false), enter_up()]);
assert_eq!(app.dialogs.open_name(), Some("fades"), "the opening Enter leaves the new dialog open");
run_frame(&ctx, &mut app, vec![enter_key(false)]);
assert_eq!(app.dialogs.open_name(), None, "the next Enter presses Create");
}
#[test]
fn a_rename_dialog_that_replaces_an_open_dialog_ignores_the_enter_of_its_first_frame() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
menus::invoke_menu(&mut app, "setup.session", "Setup > Session");
run_frame(&ctx, &mut app, vec![]);
run_frame(&ctx, &mut app, vec![]);
assert_eq!(app.dialogs.open_name(), Some("session"));
let id = app.engine.session().tracks[0].id;
app.dialogs.open_rename_track(id, "Renamed");
run_frame(&ctx, &mut app, vec![enter_key(false), enter_up()]);
assert_eq!(app.dialogs.open_name(), Some("rename_track"), "the first frame's Enter leaves the new dialog open");
}
/// The first Kick clip of `app`.
fn kick_clip(app: &SoundApp) -> soundcraft_model::ClipId {
app.engine.session().track_by_name("Kick").unwrap().clips()[0].id
}
#[test]
fn clear_auto_rename_modify_group_and_save_changes_replacing_a_dialog_ignore_the_enter_of_their_first_frame() {
type Open = fn(&mut SoundApp);
let cases: [(&str, Open); 4] = [
("clear_clips", |app| {
let clip = kick_clip(app);
assert!(app.dialogs.open_clear(&app.engine, vec![clip], vec![]));
}),
("auto_rename", |app| {
let clip = kick_clip(app);
assert!(app.dialogs.open_auto_rename(&app.engine, vec![clip]));
}),
("modify_group", |app| {
let track = app.engine.session().tracks[0].id.0;
let group = app.run("track.group", json!({"tracks": [track], "name": "Drums"})).unwrap()["group"].as_u64().unwrap();
assert!(app.dialogs.open_modify_group(&app.engine, GroupId(group)));
}),
("save_changes", |app| {
app.run("mix.mute", json!({"track": "Kick"})).unwrap();
assert!(ask_to_save(app, "session.close", &json!({})));
}),
];
for (name, open) in cases {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
menus::invoke_menu(&mut app, "setup.session", "Setup > Session");
run_frame(&ctx, &mut app, vec![]);
run_frame(&ctx, &mut app, vec![]);
open(&mut app);
run_frame(&ctx, &mut app, vec![enter_key(false), enter_up()]);
assert_eq!(app.dialogs.open_name(), Some(name), "the first frame's Enter leaves the new dialog open");
}
}
#[test]
fn a_tempo_change_dialog_that_replaces_an_open_dialog_ignores_the_enter_of_its_first_frame() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
menus::invoke_menu(&mut app, "setup.session", "Setup > Session");
run_frame(&ctx, &mut app, vec![]);
let shapes = run_frame(&ctx, &mut app, vec![]);
assert_eq!(app.dialogs.open_name(), Some("session"));
let [left, ..] = app.edit_layout.timeline;
let y = shapes
.iter()
.find_map(|c| match &c.shape {
Shape::Text(t) if t.galley.text() == "Tempo" && t.pos.x < left => Some(t.galley.rect.translate(t.pos.to_vec2()).center().y),
_ => None,
})
.expect("the Tempo ruler's label");
let at = pos2(left + 50.0, y);
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
for pressed in [true, false, true] {
run_frame(&ctx, &mut app, vec![Event::PointerMoved(at), button(pressed)]);
}
run_frame(&ctx, &mut app, vec![button(false), enter_key(false), enter_up()]);
assert_eq!(app.dialogs.open_name(), Some("tempo_change"), "the first frame's Enter leaves the new dialog open");
}
#[test]
fn a_held_enter_does_not_press_the_default_button_until_it_is_pressed_again() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
run_frame(&ctx, &mut app, vec![]);
menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create...");
run_frame(&ctx, &mut app, vec![enter_key(false)]);
run_frame(&ctx, &mut app, vec![enter_key(true)]);
run_frame(&ctx, &mut app, vec![enter_key(true)]);
assert_eq!(app.dialogs.open_name(), Some("fades"), "the held Enter's repeats leave the dialog open");
run_frame(&ctx, &mut app, vec![enter_up()]);
run_frame(&ctx, &mut app, vec![enter_key(false)]);
assert_eq!(app.dialogs.open_name(), None, "a new press of Enter presses Create");
}
#[test]
fn enter_on_a_focused_combo_box_opens_it_and_keeps_the_dialog_open() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
menus::invoke_menu(&mut app, "setup.session", "Setup > Session");
let mut h = harness(app);
assert_eq!(h.state().dialogs.open_name(), Some("session"));
assert!(h.query_by_label("47.952").is_none());
enter_on_widget(&mut h, Role::ComboBox, "Timecode Rate");
assert_eq!(h.state().dialogs.open_name(), Some("session"));
assert!(h.query_by_label("47.952").is_some(), "the Timecode Rate list is open");
}
-41
View File
@@ -1,41 +0,0 @@
//! Import Audio's Browse button lets the user select several files at once (#1089).
use egui::vec2;
use egui_kittest::{Harness, kittest::Queryable};
use soundcraft_ui_egui::{Services, SoundApp, dialogs::Dialog};
use std::path::PathBuf;
fn browse(picked: Vec<PathBuf>) -> Harness<'static, SoundApp> {
let pick_open_many: Box<dyn Fn(&str) -> Vec<PathBuf>> = Box::new(move |purpose| {
assert_eq!(purpose, "file.import_audio");
picked.clone()
});
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services { pick_open_many: Some(pick_open_many), ..Default::default() });
assert!(app.dialogs.open_for_command(&app.engine, "file.import_audio"));
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(3);
h.get_by_label("Browse…").click();
h.run_steps(1);
h
}
#[test]
fn browse_with_several_files_imports_them_and_closes_the_dialog() {
let h = browse(vec!["/nope/a.wav".into(), "/nope/b.wav".into()]);
assert!(h.state().dialogs.open.is_none());
assert!(h.state().imports.busy() || !h.state().ui.status.is_empty(), "the files were queued");
}
#[test]
fn browse_with_one_file_fills_the_path_field() {
let h = browse(vec!["/nope/a.wav".into()]);
match &h.state().dialogs.open {
Some(Dialog::PathPrompt { path, .. }) => assert_eq!(path, "/nope/a.wav"),
other => panic!("{other:?}"),
}
}
-5
View File
@@ -18,11 +18,9 @@ mod docking_workflows;
mod edit_menu;
mod edit_modes;
mod edit_window_headers;
mod focused_dialog_buttons;
mod group_dialog;
mod hide_floating;
mod idle;
mod import_browse;
mod key_families;
mod long_menu_scroll;
mod marker_rename;
@@ -37,8 +35,6 @@ mod mix_separate;
mod new_midi_clip;
mod new_tracks;
mod open_on_insert;
mod plugin_default_reset;
mod plugin_fine_sliders;
mod plugin_window;
mod pro_tools_keys;
mod record_hint;
@@ -56,7 +52,6 @@ mod side_lists_text;
mod strip_silence_dialog;
mod strip_silence_rectangles;
mod strip_silence_rename;
mod strip_silence_sliders;
mod sub_counter;
mod toolbar_scroll;
mod toolbar_tempo;
@@ -1,347 +0,0 @@
use egui::accesskit::Role;
use egui::{Event, Id, Modifiers, PointerButton, Pos2, Rect, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_dsp::ParamInfo;
use soundcraft_model::TrackId;
use soundcraft_ui_egui::{Services, SoundApp};
use std::collections::BTreeMap;
fn harness(app: SoundApp) -> Harness<'static, SoundApp> {
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
fn hold(h: &mut Harness<'_, SoundApp>, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.run_steps(1);
}
fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers });
h.run_steps(1);
}
fn move_to(h: &mut Harness<'_, SoundApp>, pos: Pos2) {
h.event(Event::PointerMoved(pos));
h.run_steps(1);
}
/// Presses at `at` with Alt held, moves `dx` points right in six steps, and releases; `dx` of 0
/// is an Alt-click.
fn alt_press(h: &mut Harness<'_, SoundApp>, at: Pos2, dx: f32) {
hold(h, Modifiers::ALT);
move_to(h, at);
press(h, at, true, Modifiers::ALT);
for i in 1..=6 {
if dx != 0.0 {
move_to(h, at + vec2(dx * i as f32 / 6.0, 0.0));
}
}
press(h, at + vec2(dx, 0.0), false, Modifiers::ALT);
hold(h, Modifiers::NONE);
h.run_steps(2);
}
/// A point near the left end of `rail`, where a plain click would set a value far from the
/// parameter's default.
fn near_left(rail: Rect) -> Pos2 {
rail.left_center() + vec2(3.0, 0.0)
}
/// The rails of the unlabeled sliders inside the window with id `window`, top to bottom.
fn window_sliders(h: &Harness<'_, SoundApp>, window: Id) -> Vec<Rect> {
let area = h.ctx.memory(|m| m.area_rect(window)).unwrap();
let mut rails: Vec<Rect> = h.query_all_by_role(Role::Slider).map(|n| n.rect()).filter(|r| area.contains_rect(*r)).collect();
rails.sort_by(|a, b| a.top().total_cmp(&b.top()));
rails
}
fn param(plugin: &str, id: &str) -> &'static ParamInfo {
soundcraft_dsp::plugin_info(plugin).unwrap().params.iter().find(|p| p.id == id).unwrap()
}
fn insert_param(h: &Harness<'_, SoundApp>, track: TrackId, slot: usize, p: &ParamInfo) -> f32 {
let tr = h.state().engine.session().track(track).unwrap();
let ins = if slot == soundcraft_mix::INSTRUMENT_SLOT { tr.instrument.as_ref() } else { tr.mixer.inserts[slot].as_ref() };
ins.unwrap().params.get(p.id).copied().unwrap_or(p.default)
}
/// The center of the value box on the same row as `rail`, to the rail's right.
fn value_box(h: &Harness<'_, SoundApp>, rail: Rect) -> Pos2 {
h.query_all_by_role(Role::SpinButton)
.map(|n| n.rect())
.find(|r| r.left() > rail.right() && (r.center().y - rail.center().y).abs() < 4.0)
.unwrap()
.center()
}
/// A plain click at `at`, which sets a value from the pointer.
fn plain_click(h: &mut Harness<'_, SoundApp>, at: Pos2) {
move_to(h, at);
press(h, at, true, Modifiers::NONE);
press(h, at, false, Modifiers::NONE);
}
fn undo_steps(h: &Harness<'_, SoundApp>) -> usize {
h.state().engine.undo_history().len()
}
/// Kick's EQ 7-Band window, with Input at 6 dB and HPF Slope on its last choice.
fn kick_eq() -> (Harness<'static, SoundApp>, TrackId) {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
let slope = param("eq_7band", "hpf_slope");
app.run("mix.insert_param", json!({"track": kick.0, "slot": 0, "param": "input_gain", "value": 6.0})).unwrap();
app.run("mix.insert_param", json!({"track": kick.0, "slot": 0, "param": "hpf_slope", "value": slope.max})).unwrap();
app.engine.end_merge();
app.ui.plugin_windows = vec![(kick, 0)];
(harness(app), kick)
}
#[test]
fn alt_click_returns_a_plugin_window_slider_to_its_default_in_one_undo_step() {
let (mut h, kick) = kick_eq();
let input = param("eq_7band", "input_gain");
assert_ne!(input.default, 6.0);
let rail = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)))[0];
let steps = undo_steps(&h);
alt_press(&mut h, near_left(rail), 0.0);
assert_eq!(insert_param(&h, kick, 0, input), input.default, "Alt-click did not return Input to its default");
assert_eq!(undo_steps(&h), steps + 1, "Alt-click is not one undo step");
h.state_mut().engine.execute("edit.undo", &json!({})).unwrap();
assert_eq!(insert_param(&h, kick, 0, input), 6.0, "Undo did not bring Input back");
}
#[test]
fn alt_drag_leaves_a_plugin_window_slider_where_it_is() {
let (mut h, kick) = kick_eq();
let input = param("eq_7band", "input_gain");
let rail = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)))[0];
let steps = undo_steps(&h);
alt_press(&mut h, near_left(rail), 40.0);
assert_eq!(insert_param(&h, kick, 0, input), 6.0, "an Alt-drag moved Input");
assert_eq!(undo_steps(&h), steps, "an Alt-drag added an undo step");
}
#[test]
fn alt_click_returns_a_plugin_window_choice_to_its_default_without_opening_the_list() {
let (mut h, kick) = kick_eq();
let slope = param("eq_7band", "hpf_slope");
assert_ne!(slope.default, slope.max);
let row = h.get_by_label("HPF Slope").rect();
let combo = h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).find(|r| (r.center().y - row.center().y).abs() < 4.0).unwrap();
let steps = undo_steps(&h);
alt_press(&mut h, combo.center(), 0.0);
assert_eq!(insert_param(&h, kick, 0, slope), slope.default, "Alt-click did not return HPF Slope to its default");
assert!(!egui::Popup::is_any_open(&h.ctx), "Alt-click opened the list");
assert_eq!(undo_steps(&h), steps + 1, "Alt-click is not one undo step");
h.state_mut().engine.execute("edit.undo", &json!({})).unwrap();
assert_eq!(insert_param(&h, kick, 0, slope), slope.max, "Undo did not bring HPF Slope back");
}
#[test]
fn alt_pressed_during_a_drag_holds_the_value_and_the_drag_goes_on_from_there() {
let (mut h, kick) = kick_eq();
let input = param("eq_7band", "input_gain");
let from = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)))[0].center();
move_to(&mut h, from);
press(&mut h, from, true, Modifiers::NONE);
let pressed = insert_param(&h, kick, 0, input);
move_to(&mut h, from + vec2(10.0, 0.0));
let before = insert_param(&h, kick, 0, input);
assert!(before > pressed, "a plain drag did not move Input");
hold(&mut h, Modifiers::ALT);
assert_eq!(insert_param(&h, kick, 0, input), before, "pressing Alt moved Input");
for i in 1..=6 {
move_to(&mut h, from + vec2(10.0 + 5.0 * i as f32, 0.0));
}
assert_eq!(insert_param(&h, kick, 0, input), before, "a drag with Alt held moved Input");
hold(&mut h, Modifiers::NONE);
assert_eq!(insert_param(&h, kick, 0, input), before, "releasing Alt moved Input to the pointer");
move_to(&mut h, from + vec2(50.0, 0.0));
let moved = insert_param(&h, kick, 0, input) - before;
press(&mut h, from + vec2(50.0, 0.0), false, Modifiers::NONE);
let ratio = moved / (before - pressed);
assert!((0.8..=1.2).contains(&ratio), "10 points after Alt moved Input {moved}, 10 points before it {}", before - pressed);
}
/// Kick's compressor window, with Attack away from its default.
fn kick_compressor_attack() -> (Harness<'static, SoundApp>, TrackId, f32) {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
let attack = param("compressor", "attack");
let away = (attack.default * 2.0).min(attack.max);
assert_ne!(away, attack.default);
assert!(attack.taper == soundcraft_dsp::Taper::Log, "Attack is no longer a log-taper parameter");
app.run("mix.insert_param", json!({"track": kick.0, "slot": 1, "param": "attack", "value": away})).unwrap();
app.engine.end_merge();
app.ui.plugin_windows = vec![(kick, 1)];
(harness(app), kick, away)
}
fn compressor_attack_rail(h: &Harness<'_, SoundApp>, kick: TrackId) -> Rect {
let params = soundcraft_dsp::plugin_info("compressor").unwrap().params;
let index = params.iter().filter(|p| p.choices.is_empty()).position(|p| p.id == "attack").unwrap();
window_sliders(h, Id::unique(("plugin", kick.0, 1usize)))[index]
}
#[test]
fn alt_click_returns_a_log_taper_slider_to_its_default() {
let (mut h, kick, _) = kick_compressor_attack();
let attack = param("compressor", "attack");
let rail = compressor_attack_rail(&h, kick);
alt_press(&mut h, near_left(rail), 0.0);
assert_eq!(insert_param(&h, kick, 1, attack), attack.default, "Alt-click did not return Attack to its default");
}
#[test]
fn alt_drag_leaves_a_log_taper_slider_where_it_is() {
let (mut h, kick, away) = kick_compressor_attack();
let attack = param("compressor", "attack");
let rail = compressor_attack_rail(&h, kick);
let steps = undo_steps(&h);
alt_press(&mut h, near_left(rail), 40.0);
assert_eq!(insert_param(&h, kick, 1, attack), away, "an Alt-drag moved Attack");
assert_eq!(undo_steps(&h), steps, "an Alt-drag added an undo step");
}
#[test]
fn alt_click_returns_a_whole_number_slider_to_its_default() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
app.run("mix.instrument", json!({"track": keys.0, "plugin": "sampler"})).unwrap();
app.run("mix.instrument_param", json!({"track": keys.0, "param": "root_note", "value": 72.0})).unwrap();
app.engine.end_merge();
app.ui.plugin_windows = vec![(keys, soundcraft_mix::INSTRUMENT_SLOT)];
let mut h = harness(app);
let root = param("sampler", "root_note");
assert_ne!(root.default, 72.0);
let rail = window_sliders(&h, Id::unique(("plugin", keys.0, soundcraft_mix::INSTRUMENT_SLOT)))[0];
let steps = undo_steps(&h);
alt_press(&mut h, near_left(rail), 0.0);
let v = insert_param(&h, keys, soundcraft_mix::INSTRUMENT_SLOT, root);
assert_eq!(v, root.default, "Alt-click did not return Root Note to its default");
assert_eq!(v.fract(), 0.0, "Root Note is not a whole number after an Alt-click");
assert_eq!(undo_steps(&h), steps + 1, "Alt-click is not one undo step");
}
#[test]
fn alt_click_returns_clip_effects_sliders_and_value_boxes_to_their_defaults() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let s = app.engine.session();
let clip = s.track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id;
app.run("edit.select", json!({"clips": [clip.0]})).unwrap();
app.run("clip.effects_set", json!({"clips": [clip.0], "params": {"eq.input_gain": 6.0, "comp.threshold": -6.0}})).unwrap();
app.engine.end_merge();
app.ui.show_clip_effects = true;
let mut h = harness(app);
for (label, key, p, on_box) in
[("Input", "eq.input_gain", param("eq_7band", "input_gain"), false), ("Threshold", "comp.threshold", param("compressor", "threshold"), true)]
{
let key = format!("clip_fx.{}.{key}", clip.0);
let read = |h: &Harness<'_, SoundApp>| h.state().engine.session().edit.values.get(&key).copied();
assert_ne!(read(&h), Some(f64::from(p.default)));
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
let at = if on_box { value_box(&h, rail) } else { near_left(rail) };
// A plain click on the value box gives it the keyboard focus for typing.
let focus = h.ctx.memory(|m| m.focused());
let steps = undo_steps(&h);
alt_press(&mut h, at, 0.0);
assert_eq!(read(&h), Some(f64::from(p.default)), "Alt-click did not return {label} to its default");
assert_eq!(undo_steps(&h), steps + 1, "Alt-click on {label} is not one undo step");
assert_eq!(h.ctx.memory(|m| m.focused()), focus, "Alt-click opened {label}'s value box for typing");
}
}
#[test]
fn fallback_audiosuite_sliders_reset_with_alt_click() {
for (process, label, id, default) in [
("normalize", "Peak dB", "target_db", -0.1f32),
("time_stretch", "Length ratio", "ratio", 1.0),
("pitch_shift", "Semitones", "semitones", 0.0),
] {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.audiosuite = Some(process.into());
let mut h = harness(app);
let read = |h: &Harness<'_, SoundApp>| -> BTreeMap<String, f32> {
h.ctx.data(|d| d.get_temp(Id::unique(("as_params", process.to_string())))).unwrap_or_default()
};
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
move_to(&mut h, near_left(rail));
press(&mut h, near_left(rail), true, Modifiers::NONE);
press(&mut h, near_left(rail), false, Modifiers::NONE);
assert_ne!(read(&h)[id], default, "{process}: plain click did not move the value");
alt_press(&mut h, near_left(rail), 0.0);
assert_eq!(read(&h)[id], default, "{process}: Alt-click did not restore the default");
plain_click(&mut h, near_left(rail));
assert_ne!(read(&h)[id], default, "{process}: plain click did not move the value");
let focus = h.ctx.memory(|m| m.focused());
let at = value_box(&h, rail);
alt_press(&mut h, at, 0.0);
assert_eq!(read(&h)[id], default, "{process}: Alt-click on the value box did not restore the default");
assert_eq!(h.ctx.memory(|m| m.focused()), focus, "{process}: Alt-click opened the value box for typing");
}
}
#[test]
fn alt_click_returns_audiosuite_parameter_sliders_to_their_defaults() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.audiosuite = Some("compressor".into());
let mut h = harness(app);
let read = |h: &Harness<'_, SoundApp>| -> BTreeMap<String, f32> {
h.ctx.data(|d| d.get_temp(Id::unique(("as_params", "compressor".to_string())))).unwrap_or_default()
};
let threshold = param("compressor", "threshold");
let rail = h.get_by_role_and_label(Role::Slider, "Threshold").rect();
move_to(&mut h, near_left(rail));
press(&mut h, near_left(rail), true, Modifiers::NONE);
press(&mut h, near_left(rail), false, Modifiers::NONE);
assert_ne!(read(&h)["threshold"], threshold.default, "a plain click did not move Threshold");
alt_press(&mut h, near_left(rail), 0.0);
assert_eq!(read(&h)["threshold"], threshold.default, "Alt-click did not return Threshold to its default");
plain_click(&mut h, near_left(rail));
assert_ne!(read(&h)["threshold"], threshold.default, "a plain click did not move Threshold");
let focus = h.ctx.memory(|m| m.focused());
let at = value_box(&h, rail);
alt_press(&mut h, at, 0.0);
assert_eq!(read(&h)["threshold"], threshold.default, "Alt-click on the value box did not return Threshold to its default");
assert_eq!(h.ctx.memory(|m| m.focused()), focus, "Alt-click opened Threshold's value box for typing");
}
#[test]
fn clip_effects_gain_resets_with_alt_click_and_undo_restores_the_value() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let clip = app.engine.session().track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id;
app.run("edit.select", json!({"clips": [clip.0]})).unwrap();
app.run("clip.effects_set", json!({"clips": [clip.0], "params": {"gain": 6.0}})).unwrap();
app.engine.end_merge();
app.ui.show_clip_effects = true;
let mut h = harness(app);
let key = format!("clip_fx.{}.gain", clip.0);
let input = h.get_by_role_and_label(Role::Slider, "Input").rect();
move_to(&mut h, input.center());
h.event(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -2000.0),
modifiers: Modifiers::NONE,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
});
h.run_steps(30);
let rail = h.get_by_role_and_label(Role::Slider, "Clip effects gain (dB)").rect();
let steps = undo_steps(&h);
alt_press(&mut h, near_left(rail), 0.0);
assert_eq!(h.state().engine.session().edit.values.get(&key), Some(&0.0));
assert_eq!(undo_steps(&h), steps + 1);
h.state_mut().engine.execute("edit.undo", &json!({})).unwrap();
assert_eq!(h.state().engine.session().edit.values.get(&key), Some(&6.0));
}
@@ -1,359 +0,0 @@
use egui::accesskit::Role;
use egui::{Event, Id, Modifiers, PointerButton, Pos2, Rect, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_dsp::ParamInfo;
use soundcraft_model::TrackId;
use soundcraft_ui_egui::{Services, SoundApp};
use std::collections::BTreeMap;
fn harness(app: SoundApp) -> Harness<'static, SoundApp> {
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
fn hold(h: &mut Harness<'_, SoundApp>, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.run_steps(1);
}
fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers });
h.run_steps(1);
}
fn move_to(h: &mut Harness<'_, SoundApp>, pos: Pos2) {
h.event(Event::PointerMoved(pos));
h.run_steps(1);
}
/// Presses the middle of `rail` and drags `dx` points right in six steps with `modifiers` held,
/// calling `read` after each step; returns how far `read` moved from where the press put it.
fn drag(h: &mut Harness<'_, SoundApp>, rail: Rect, dx: f32, modifiers: Modifiers, read: &dyn Fn(&Harness<'_, SoundApp>) -> f64) -> f64 {
hold(h, modifiers);
let from = rail.center();
move_to(h, from);
press(h, from, true, modifiers);
let start = read(h);
for i in 1..=6 {
move_to(h, from + vec2(dx * i as f32 / 6.0, 0.0));
read(h);
}
let moved = read(h) - start;
press(h, from + vec2(dx, 0.0), false, modifiers);
assert_eq!(read(h) - start, moved, "releasing the button moved the value");
hold(h, Modifiers::NONE);
moved
}
/// A plain 30-point drag and a Ctrl-drag of the same length on `rail`; returns how far each
/// moved `read`.
fn plain_and_fine(h: &mut Harness<'_, SoundApp>, rail: Rect, read: &dyn Fn(&Harness<'_, SoundApp>) -> f64) -> (f64, f64) {
let plain = drag(h, rail, 30.0, Modifiers::NONE, read);
let fine = drag(h, rail, 30.0, Modifiers::COMMAND, read);
(plain, fine)
}
fn assert_tenth(what: &str, (plain, fine): (f64, f64), within: std::ops::RangeInclusive<f64>) {
assert!(plain > 0.0, "{what}: a plain drag did not move the value");
assert!(within.contains(&(fine / plain)), "{what}: Ctrl-drag moved {fine}, a plain drag {plain}");
}
/// The value box on the same row as `rail`, to the rail's right.
fn value_box(h: &Harness<'_, SoundApp>, rail: Rect) -> Rect {
h.query_all_by_role(Role::SpinButton).map(|n| n.rect()).find(|r| r.left() > rail.right() && (r.center().y - rail.center().y).abs() < 4.0).unwrap()
}
/// The rails of the unlabeled sliders inside the window with id `window`, top to bottom.
fn window_sliders(h: &Harness<'_, SoundApp>, window: Id) -> Vec<Rect> {
let area = h.ctx.memory(|m| m.area_rect(window)).unwrap();
let mut rails: Vec<Rect> = h.query_all_by_role(Role::Slider).map(|n| n.rect()).filter(|r| area.contains_rect(*r)).collect();
rails.sort_by(|a, b| a.top().total_cmp(&b.top()));
rails
}
/// The plug-in's parameters that its window shows as sliders, in order.
fn slider_params(plugin: &str) -> Vec<&'static ParamInfo> {
soundcraft_dsp::plugin_info(plugin).unwrap().params.iter().filter(|p| p.choices.is_empty()).collect()
}
fn insert_param(h: &Harness<'_, SoundApp>, track: TrackId, slot: usize, p: &ParamInfo) -> f64 {
let tr = h.state().engine.session().track(track).unwrap();
let ins = if slot == soundcraft_mix::INSTRUMENT_SLOT { tr.instrument.as_ref() } else { tr.mixer.inserts[slot].as_ref() };
f64::from(ins.unwrap().params.get(p.id).copied().unwrap_or(p.default))
}
#[test]
fn plugin_window_sliders_move_a_tenth_as_far_with_ctrl_held() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
app.ui.plugin_windows = vec![(kick, 0)];
let mut h = harness(app);
let rails = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)));
let params = slider_params("eq_7band");
assert_eq!((params[0].id, params[2].id), ("input_gain", "hpf_freq"));
for i in [0, 2] {
let p = params[i];
// Along the rail, so that a log-taper parameter compares as a linear one does.
let read = move |h: &Harness<'_, SoundApp>| f64::from(p.to_normalized(insert_param(h, kick, 0, p) as f32));
let others = |h: &Harness<'_, SoundApp>| params.iter().filter(|o| o.id != p.id).map(|o| insert_param(h, kick, 0, o)).collect::<Vec<_>>();
let before = others(&h);
assert_tenth(p.name, plain_and_fine(&mut h, rails[i], &read), 0.08..=0.12);
assert_eq!(others(&h), before, "dragging {} moved another parameter", p.name);
}
}
#[test]
fn the_control_key_alone_also_gives_a_fine_drag_on_a_plugin_slider() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
app.ui.plugin_windows = vec![(kick, 0)];
let mut h = harness(app);
let rails = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)));
let p = slider_params("eq_7band")[0];
let read = move |h: &Harness<'_, SoundApp>| insert_param(h, kick, 0, p);
let plain = drag(&mut h, rails[0], 30.0, Modifiers::NONE, &read);
let fine = drag(&mut h, rails[0], 30.0, Modifiers::CTRL, &read);
assert_tenth(p.name, (plain, fine), 0.08..=0.12);
}
/// Presses `rail`'s middle, drags 10 points, presses Ctrl, drags 30 more, releases Ctrl and then
/// the button. Each change of the key and the release leave `read` where it was, and the 30
/// points move it less than `plain` moved for the same distance.
fn toggle_ctrl_during_a_drag(h: &mut Harness<'_, SoundApp>, what: &str, rail: Rect, plain: f64, read: &dyn Fn(&Harness<'_, SoundApp>) -> f64) {
let from = rail.center();
move_to(h, from);
press(h, from, true, Modifiers::NONE);
move_to(h, from + vec2(10.0, 0.0));
let before = read(h);
hold(h, Modifiers::COMMAND);
assert_eq!(read(h), before, "{what}: pressing Ctrl moved the value");
for i in 1..=6 {
move_to(h, from + vec2(10.0 + 5.0 * i as f32, 0.0));
read(h);
}
let held = read(h);
assert!(held > before && held - before < plain / 2.0, "{what}: 30 points with Ctrl moved {}, a plain drag {plain}", held - before);
hold(h, Modifiers::NONE);
assert_eq!(read(h), held, "{what}: releasing Ctrl moved the value");
press(h, from + vec2(40.0, 0.0), false, Modifiers::NONE);
assert_eq!(read(h), held, "{what}: releasing the button moved the value");
}
#[test]
fn pressing_and_releasing_ctrl_during_a_drag_keeps_the_value_of_a_logarithmic_slider() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
app.ui.plugin_windows = vec![(kick, 0)];
let mut h = harness(app);
let rails = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)));
let p = slider_params("eq_7band")[2];
assert_eq!(p.id, "hpf_freq");
let read = move |h: &Harness<'_, SoundApp>| f64::from(p.to_normalized(insert_param(h, kick, 0, p) as f32));
let plain = drag(&mut h, rails[2], 30.0, Modifiers::NONE, &read);
toggle_ctrl_during_a_drag(&mut h, p.name, rails[2], plain, &read);
}
#[test]
fn pressing_and_releasing_ctrl_during_a_drag_keeps_a_whole_number_slider_whole() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
app.run("mix.instrument", json!({"track": keys.0, "plugin": "sampler"})).unwrap();
app.ui.plugin_windows = vec![(keys, soundcraft_mix::INSTRUMENT_SLOT)];
let mut h = harness(app);
let rails = window_sliders(&h, Id::unique(("plugin", keys.0, soundcraft_mix::INSTRUMENT_SLOT)));
let root = slider_params("sampler")[0];
assert_eq!(root.id, "root_note");
let read = move |h: &Harness<'_, SoundApp>| {
let v = insert_param(h, keys, soundcraft_mix::INSTRUMENT_SLOT, root);
assert_eq!(v.fract(), 0.0, "Root Note is {v} during a drag");
v
};
let plain = drag(&mut h, rails[0], 30.0, Modifiers::NONE, &read);
toggle_ctrl_during_a_drag(&mut h, root.name, rails[0], plain, &read);
}
#[test]
fn a_small_ctrl_drag_at_the_low_end_of_a_log_slider_moves_the_value() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
app.ui.plugin_windows = vec![(kick, 1)];
let mut h = harness(app);
let rails = window_sliders(&h, Id::unique(("plugin", kick.0, 1usize)));
let attack = slider_params("compressor")[2];
assert_eq!(attack.id, "attack");
let from = rails[2].left_center() + vec2(1.0, 0.0);
hold(&mut h, Modifiers::COMMAND);
move_to(&mut h, from);
press(&mut h, from, true, Modifiers::COMMAND);
let start = insert_param(&h, kick, 1, attack);
assert_eq!(start, f64::from(attack.min), "the press did not put Attack at its lowest value");
move_to(&mut h, from + vec2(1.0, 0.0));
let moved = insert_param(&h, kick, 1, attack);
press(&mut h, from + vec2(1.0, 0.0), false, Modifiers::COMMAND);
assert!(moved > start, "a one-point Ctrl-drag left Attack at {moved}");
assert_eq!(insert_param(&h, kick, 1, attack), moved, "releasing the button moved the value");
}
#[test]
fn a_small_ctrl_drag_at_the_low_end_of_a_log_slider_with_a_value_box_moves_the_value() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let clip = app.engine.session().track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id;
app.run("edit.select", json!({"clips": [clip.0]})).unwrap();
app.ui.show_clip_effects = true;
let mut h = harness(app);
let attack = slider_params("compressor")[2];
assert_eq!(attack.id, "attack");
let key = format!("clip_fx.{}.comp.attack", clip.0);
let from = h.get_by_role_and_label(Role::Slider, attack.name).rect().left_center() + vec2(1.0, 0.0);
hold(&mut h, Modifiers::COMMAND);
move_to(&mut h, from);
press(&mut h, from, true, Modifiers::COMMAND);
let read = |h: &Harness<'_, SoundApp>| h.state().engine.session().edit.values.get(&key).copied().unwrap();
let start = read(&h);
assert!((start - f64::from(attack.min)).abs() < 1e-6, "the press put Attack at {start}");
move_to(&mut h, from + vec2(1.0, 0.0));
let moved = read(&h);
press(&mut h, from + vec2(1.0, 0.0), false, Modifiers::COMMAND);
assert!(moved > start, "a one-point Ctrl-drag left Attack at {moved}");
}
#[test]
fn whole_number_instrument_sliders_move_a_tenth_as_far_and_stay_whole() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
app.run("mix.instrument", json!({"track": keys.0, "plugin": "sampler"})).unwrap();
app.ui.plugin_windows = vec![(keys, soundcraft_mix::INSTRUMENT_SLOT)];
let mut h = harness(app);
let rails = window_sliders(&h, Id::unique(("plugin", keys.0, soundcraft_mix::INSTRUMENT_SLOT)));
let root = slider_params("sampler")[0];
assert_eq!(root.id, "root_note");
// Each read during the drags checks that the instrument was sent a whole number.
let read = move |h: &Harness<'_, SoundApp>| {
let v = insert_param(h, keys, soundcraft_mix::INSTRUMENT_SLOT, root);
assert_eq!(v.fract(), 0.0, "Root Note is {v} during a drag");
v
};
assert_tenth(root.name, plain_and_fine(&mut h, rails[0], &read), 0.06..=0.14);
}
#[test]
fn clip_effects_sliders_move_a_tenth_as_far_with_ctrl_held() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let s = app.engine.session();
let clip = s.track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id;
app.run("edit.select", json!({"clips": [clip.0]})).unwrap();
app.ui.show_clip_effects = true;
let mut h = harness(app);
for (label, key, default) in [("Input", "eq.input_gain", 0.0), ("Threshold", "comp.threshold", -20.0), ("Clip effects gain (dB)", "gain", 0.0)] {
if key == "gain" {
let input = h.get_by_role_and_label(Role::Slider, "Input").rect();
move_to(&mut h, input.center());
h.event(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -2000.0),
modifiers: Modifiers::NONE,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
});
h.run_steps(30);
}
let key = format!("clip_fx.{}.{key}", clip.0);
let read = move |h: &Harness<'_, SoundApp>| h.state().engine.session().edit.values.get(&key).copied().unwrap_or(default);
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
assert_tenth(label, plain_and_fine(&mut h, rail, &read), 0.08..=0.12);
}
}
#[test]
fn clip_effects_value_boxes_move_a_tenth_as_far_with_ctrl_held() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let s = app.engine.session();
let clip = s.track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id;
app.run("edit.select", json!({"clips": [clip.0]})).unwrap();
app.ui.show_clip_effects = true;
let mut h = harness(app);
for (label, key, default) in [("Input", "eq.input_gain", 0.0), ("Threshold", "comp.threshold", -20.0)] {
let key = format!("clip_fx.{}.{key}", clip.0);
let read = move |h: &Harness<'_, SoundApp>| h.state().engine.session().edit.values.get(&key).copied().unwrap_or(default);
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
let boxed = value_box(&h, rail);
assert_tenth(label, plain_and_fine(&mut h, boxed, &read), 0.07..=0.13);
}
}
#[test]
fn clip_effects_log_taper_sliders_use_a_logarithmic_rail() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let s = app.engine.session();
let clip = s.track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id;
app.run("edit.select", json!({"clips": [clip.0]})).unwrap();
app.ui.show_clip_effects = true;
let mut h = harness(app);
let ratio = slider_params("compressor")[1];
assert_eq!(ratio.id, "ratio");
let rail = h.get_by_role_and_label(Role::Slider, ratio.name).rect();
move_to(&mut h, rail.center());
press(&mut h, rail.center(), true, Modifiers::NONE);
press(&mut h, rail.center(), false, Modifiers::NONE);
let key = format!("clip_fx.{}.comp.ratio", clip.0);
let v = h.state().engine.session().edit.values.get(&key).copied().unwrap();
// The middle of a 1 to 100 log rail is 10; a linear rail puts it near 50.
assert!((7.0..=14.0).contains(&v), "a click in the middle of the Ratio rail set {v}");
}
#[test]
fn fallback_audiosuite_sliders_move_a_tenth_as_far_with_ctrl_held() {
for (process, label, id) in
[("normalize", "Peak dB", "target_db"), ("time_stretch", "Length ratio", "ratio"), ("pitch_shift", "Semitones", "semitones")]
{
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.audiosuite = Some(process.into());
let mut h = harness(app);
let read = |h: &Harness<'_, SoundApp>| {
let params: BTreeMap<String, f32> = h.ctx.data(|d| d.get_temp(Id::unique(("as_params", process.to_string())))).unwrap_or_default();
f64::from(params[id])
};
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
assert_tenth(label, plain_and_fine(&mut h, rail, &read), 0.08..=0.12);
}
}
#[test]
fn audiosuite_parameter_sliders_move_a_tenth_as_far_with_ctrl_held() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.audiosuite = Some("compressor".into());
let mut h = harness(app);
for (label, id) in [("Threshold", "threshold"), ("Knee", "knee")] {
let read = move |h: &Harness<'_, SoundApp>| {
let params: BTreeMap<String, f32> = h.ctx.data(|d| d.get_temp(Id::unique(("as_params", "compressor".to_string())))).unwrap_or_default();
f64::from(params[id])
};
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
assert_tenth(label, plain_and_fine(&mut h, rail, &read), 0.08..=0.12);
}
}
#[test]
fn audiosuite_value_boxes_move_a_tenth_as_far_with_ctrl_held() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.audiosuite = Some("compressor".into());
let mut h = harness(app);
for (label, id) in [("Threshold", "threshold"), ("Knee", "knee")] {
let read = move |h: &Harness<'_, SoundApp>| {
let params: BTreeMap<String, f32> = h.ctx.data(|d| d.get_temp(Id::unique(("as_params", "compressor".to_string())))).unwrap_or_default();
f64::from(params[id])
};
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
let boxed = value_box(&h, rail);
assert_tenth(label, plain_and_fine(&mut h, boxed, &read), 0.07..=0.13);
}
}
-16
View File
@@ -96,22 +96,6 @@ fn save_in_the_question_saves_before_closing() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn enter_on_a_focused_cancel_neither_saves_nor_closes() {
let mut h = harness();
let tracks = h.state().engine.session().tracks.len();
mute_kick(&mut h);
close_session(&mut h);
assert_eq!(h.state().dialogs.open_name(), Some("save_changes"));
h.get_by_label("Cancel").focus();
steps(&mut h);
h.event(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
steps(&mut h);
assert!(h.state().dialogs.open.is_none(), "no Save As dialog follows");
assert_eq!(h.state().engine.session().tracks.len(), tracks);
assert!(h.state().engine.is_dirty());
}
/// A session the browser read: an empty one, so opening it is easy to see.
fn session_bytes() -> Vec<u8> {
soundcraft_engine::Engine::default().session().to_json().unwrap().into_bytes()
@@ -80,45 +80,6 @@ fn enter_on_a_focused_separate_separates_and_does_not_strip() {
assert_eq!(covered(&separated), covered(&before), "Enter on Separate keeps all the audio");
}
#[test]
fn enter_on_a_focused_cancel_closes_the_dialog_and_strips_nothing() {
let (mut h, track, before) = strip_silence_dialog();
h.get_by_role_and_label(Role::Button, "Cancel").focus();
h.run_steps(2);
h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), None);
assert_eq!(clips(&h, track), before, "Cancel leaves the clips as they were");
}
#[test]
fn enter_right_after_switching_between_strip_silence_and_rename_presses_the_new_default_button() {
let (mut h, _, _) = strip_silence_dialog();
let enter = |h: &mut Harness<'static, SoundApp>| {
for pressed in [true, false] {
h.event(Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
}
};
h.get_by_role_and_label(Role::Button, "Rename…").focus();
h.run_steps(2);
enter(&mut h);
h.run_steps(1);
assert_eq!(h.state().dialogs.open_name(), Some("strip_silence_rename"));
enter(&mut h);
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), Some("strip_silence"), "the next Enter presses OK in Rename Selected Clips");
h.get_by_role_and_label(Role::Button, "Rename…").click();
h.run_steps(2);
h.get_by_role_and_label(Role::Button, "OK").focus();
h.run_steps(2);
enter(&mut h);
h.run_steps(1);
assert_eq!(h.state().dialogs.open_name(), Some("strip_silence"));
enter(&mut h);
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), None, "the next Enter presses Strip in Strip Silence");
}
#[test]
fn strip_silence_strip_removes_the_silence() {
let (mut h, track, before) = strip_silence_dialog();
@@ -1,223 +0,0 @@
use egui::accesskit::Role;
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_ui_egui::dialogs::Dialog;
use soundcraft_ui_egui::{Services, SoundApp, menus, widgets};
const SLIDERS: [&str; 4] = ["Threshold dB", "Min strip duration ms", "Clip start pad ms", "Clip end pad ms"];
/// Selects the whole Kick track and opens Edit > Strip Silence from the menu.
fn strip_silence_dialog() -> Harness<'static, SoundApp> {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let track = app.engine.session().track_by_name("Kick").unwrap().id;
let end = app.engine.session().content_end();
app.run("edit.select", json!({"tracks": [track.0], "start": 0, "end": end})).unwrap();
menus::invoke_menu(&mut app, "edit.strip_silence", "Edit > Strip Silence");
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(3);
h
}
/// The value of the slider labeled `label`, in the order the window shows them.
fn value(h: &Harness<'_, SoundApp>, label: &str) -> f64 {
let Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) = h.state().dialogs.open else { panic!("Strip Silence is not open") };
let i = SLIDERS.iter().position(|s| *s == label).unwrap();
[threshold, min_ms, pre_ms, post_ms][i]
}
fn hold<S>(h: &mut Harness<'_, S>, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.run_steps(1);
}
fn press<S>(h: &mut Harness<'_, S>, pos: Pos2, pressed: bool, modifiers: Modifiers) {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers });
h.run_steps(1);
}
fn move_to<S>(h: &mut Harness<'_, S>, pos: Pos2) {
h.event(Event::PointerMoved(pos));
h.run_steps(1);
}
/// Presses the middle of a slider's rail (`role` `Slider`) or value box (`SpinButton`) and drags
/// `dx` points right in six steps, with `modifiers` held; returns how far the value moved from
/// where the press put it.
fn drag_on(h: &mut Harness<'_, SoundApp>, role: Role, label: &str, dx: f32, modifiers: Modifiers) -> f64 {
hold(h, modifiers);
let from = h.get_by_role_and_label(role, label).rect().center();
move_to(h, from);
press(h, from, true, modifiers);
let start = value(h, label);
for i in 1..=6 {
move_to(h, from + vec2(dx * i as f32 / 6.0, 0.0));
}
let moved = value(h, label) - start;
press(h, from + vec2(dx, 0.0), false, modifiers);
assert_eq!(value(h, label) - start, moved, "{label}: releasing the button moved the value");
hold(h, Modifiers::NONE);
moved
}
fn drag(h: &mut Harness<'_, SoundApp>, label: &str, dx: f32, modifiers: Modifiers) -> f64 {
drag_on(h, Role::Slider, label, dx, modifiers)
}
#[test]
fn strip_silence_sliders_move_a_tenth_as_far_with_ctrl_held() {
let mut h = strip_silence_dialog();
for label in SLIDERS {
let plain = drag(&mut h, label, 30.0, Modifiers::NONE);
let fine = drag(&mut h, label, 30.0, Modifiers::COMMAND);
assert!(plain > 0.0, "{label}: a plain drag did not move the value");
let ratio = fine / plain;
assert!((0.08..=0.12).contains(&ratio), "{label}: Ctrl-drag moved {fine}, a plain drag {plain}");
}
}
#[test]
fn ctrl_dragging_the_value_box_moves_a_tenth_as_far_too() {
let mut h = strip_silence_dialog();
for label in ["Threshold dB", "Clip start pad ms"] {
let plain = drag_on(&mut h, Role::SpinButton, label, 20.0, Modifiers::NONE);
let fine = drag_on(&mut h, Role::SpinButton, label, 20.0, Modifiers::COMMAND);
assert!(plain > 0.0, "{label}: a plain drag on the value box did not move the value");
let ratio = fine / plain;
assert!((0.07..=0.13).contains(&ratio), "{label}: Ctrl-drag on the value box moved {fine}, a plain drag {plain}");
}
}
#[test]
fn ctrl_without_command_moves_a_tenth_as_far() {
let mut h = strip_silence_dialog();
for (role, dx, within) in [(Role::Slider, 30.0, 0.08..=0.12), (Role::SpinButton, 20.0, 0.07..=0.13)] {
let label = "Threshold dB";
let plain = drag_on(&mut h, role, label, dx, Modifiers::NONE);
let fine = drag_on(&mut h, role, label, dx, Modifiers::CTRL);
assert!(plain > 0.0, "{role:?}: a plain drag did not move the value");
assert!(within.contains(&(fine / plain)), "{role:?}: Ctrl-drag moved {fine}, a plain drag {plain}");
}
}
#[test]
fn alt_click_and_alt_drag_do_what_a_plain_click_and_drag_do() {
let mut h = strip_silence_dialog();
let label = "Threshold dB";
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
let near = rail.left_center() + vec2(3.0, 0.0);
let click = |h: &mut Harness<'static, SoundApp>, at: Pos2, modifiers: Modifiers| {
hold(h, modifiers);
move_to(h, at);
press(h, at, true, modifiers);
press(h, at, false, modifiers);
hold(h, Modifiers::NONE);
};
click(&mut h, near, Modifiers::NONE);
let clicked = value(&h, label);
click(&mut h, rail.center(), Modifiers::NONE);
assert_ne!(value(&h, label), clicked, "a click in the middle of the rail did not move the value");
click(&mut h, near, Modifiers::ALT);
assert_eq!(value(&h, label), clicked, "an Alt-click did not do what a plain click does");
let plain = drag(&mut h, label, 30.0, Modifiers::NONE);
let alt = drag(&mut h, label, 30.0, Modifiers::ALT);
assert!(plain > 0.0, "a plain drag did not move the value");
assert_eq!(alt, plain, "an Alt-drag did not do what a plain drag does");
}
#[test]
fn the_value_box_shows_the_tenths_a_ctrl_drag_reaches() {
let mut h = strip_silence_dialog();
drag(&mut h, "Threshold dB", 7.0, Modifiers::COMMAND);
let v = value(&h, "Threshold dB");
let shown = h.get_by_role_and_label(Role::SpinButton, "Threshold dB").value().unwrap_or_default();
assert_eq!(shown, format!("{v:.1}"));
}
#[test]
fn pressing_and_releasing_ctrl_during_a_drag_keeps_the_value() {
let mut h = strip_silence_dialog();
let label = "Threshold dB";
let plain = drag(&mut h, label, 30.0, Modifiers::NONE);
let from = h.get_by_role_and_label(Role::Slider, label).rect().center();
move_to(&mut h, from);
press(&mut h, from, true, Modifiers::NONE);
move_to(&mut h, from + vec2(10.0, 0.0));
let before = value(&h, label);
hold(&mut h, Modifiers::COMMAND);
assert_eq!(value(&h, label), before, "pressing Ctrl moved the value");
for i in 1..=6 {
move_to(&mut h, from + vec2(10.0 + 5.0 * i as f32, 0.0));
}
let fine = value(&h, label) - before;
assert!((0.08..=0.12).contains(&(fine / plain)), "Ctrl-drag moved {fine}, a plain drag {plain}");
let held = value(&h, label);
hold(&mut h, Modifiers::NONE);
assert_eq!(value(&h, label), held, "releasing Ctrl moved the value back to the pointer");
press(&mut h, from + vec2(40.0, 0.0), false, Modifiers::NONE);
assert_eq!(value(&h, label), held, "releasing the button moved the value back to the pointer");
}
#[test]
fn pressing_ctrl_during_a_drag_on_the_value_box_keeps_the_value() {
let mut h = strip_silence_dialog();
let label = "Threshold dB";
let plain = drag_on(&mut h, Role::SpinButton, label, 30.0, Modifiers::NONE);
let held = drag_on(&mut h, Role::SpinButton, label, 30.0, Modifiers::COMMAND);
assert!(held < plain / 2.0, "a Ctrl-drag on the value box moved {held}, a plain drag {plain}");
let from = h.get_by_role_and_label(Role::SpinButton, label).rect().center();
move_to(&mut h, from);
press(&mut h, from, true, Modifiers::NONE);
move_to(&mut h, from + vec2(10.0, 0.0));
let before = value(&h, label);
hold(&mut h, Modifiers::COMMAND);
assert_eq!(value(&h, label), before, "pressing Ctrl moved the value");
for i in 1..=6 {
move_to(&mut h, from + vec2(10.0 + 5.0 * i as f32, 0.0));
}
let fine = value(&h, label) - before;
// The rest of the drag moves as far as a drag with Ctrl held from the start.
assert!((0.8..=1.2).contains(&(fine / held)), "after Ctrl, the value box moved {fine}; a Ctrl-drag moves {held}");
let last = value(&h, label);
press(&mut h, from + vec2(40.0, 0.0), false, Modifiers::COMMAND);
assert_eq!(value(&h, label), last, "releasing the button moved the value");
hold(&mut h, Modifiers::NONE);
}
#[test]
fn the_control_key_alone_also_gives_a_fine_drag() {
let mut h = strip_silence_dialog();
let label = "Threshold dB";
let plain = drag(&mut h, label, 30.0, Modifiers::NONE);
let fine = drag(&mut h, label, 30.0, Modifiers::CTRL);
assert!((0.08..=0.12).contains(&(fine / plain)), "Control-drag moved {fine}, a plain drag {plain}");
}
#[test]
fn a_ctrl_drag_reports_every_change_in_the_response() {
let mut h = Harness::builder().with_size(vec2(400.0, 100.0)).build_ui_state(
|ui, (value, changed): &mut (f64, bool)| {
*changed = widgets::fine_slider(ui, value, 0.0..=96.0, "Level").changed();
},
(48.0, false),
);
h.run_steps(2);
let from = h.get_by_role_and_label(Role::Slider, "Level").rect().center();
hold(&mut h, Modifiers::COMMAND);
move_to(&mut h, from);
press(&mut h, from, true, Modifiers::COMMAND);
for i in 1..=6 {
let before = h.state().0;
move_to(&mut h, from + vec2(5.0 * i as f32, 0.0));
assert_ne!(h.state().0, before, "step {i} did not move the value");
assert!(h.state().1, "step {i} moved the value without reporting a change");
}
press(&mut h, from + vec2(30.0, 0.0), false, Modifiers::COMMAND);
}
+19 -188
View File
@@ -36,36 +36,20 @@ impl Os {
/// The standard VST3 search locations for this OS (existing or not), followed by every entry of
/// the `VST3_PATH` environment variable.
pub fn default_search_paths() -> Vec<PathBuf> {
default_search_paths_with(Os::current(), |name| std::env::var_os(name), std::env::current_exe().ok())
}
/// [`default_search_paths`] with the environment lookup and executable path injected, so the
/// Windows common-files fallback, `VST3_PATH` and the application-local folders are testable.
fn default_search_paths_with(os: Os, var: impl Fn(&str) -> Option<std::ffi::OsString>, executable: Option<PathBuf>) -> Vec<PathBuf> {
let mut v = search_paths(
os,
var("HOME").map(PathBuf::from),
// A 32-bit process on 64-bit Windows sees the x86 directory in COMMONPROGRAMFILES.
var("COMMONPROGRAMW6432").or_else(|| var("COMMONPROGRAMFILES")).map(PathBuf::from),
var("COMMONPROGRAMFILES(X86)").map(PathBuf::from),
var("LOCALAPPDATA").map(PathBuf::from),
executable,
let mut v = os_search_paths(
Os::current(),
std::env::var_os("HOME").map(PathBuf::from),
std::env::var_os("COMMONPROGRAMFILES").map(PathBuf::from),
std::env::var_os("LOCALAPPDATA").map(PathBuf::from),
);
if let Some(cp) = var("VST3_PATH") {
if let Some(cp) = std::env::var_os("VST3_PATH") {
v.extend(std::env::split_paths(&cp).filter(|p| !p.as_os_str().is_empty()));
}
v
}
/// Builds the search list without reading process state, so every OS layout is testable anywhere.
fn search_paths(
os: Os,
home: Option<PathBuf>,
common_program_files: Option<PathBuf>,
common_program_files_x86: Option<PathBuf>,
local_app_data: Option<PathBuf>,
executable: Option<PathBuf>,
) -> Vec<PathBuf> {
/// The OS-specific standard locations, given the relevant environment values.
pub fn os_search_paths(os: Os, home: Option<PathBuf>, common_program_files: Option<PathBuf>, local_app_data: Option<PathBuf>) -> Vec<PathBuf> {
let mut v = Vec::new();
match os {
Os::MacOs => {
@@ -73,44 +57,21 @@ fn search_paths(
v.push(h.join("Library/Audio/Plug-Ins/VST3"));
}
v.push(PathBuf::from("/Library/Audio/Plug-Ins/VST3"));
v.push(PathBuf::from("/Network/Library/Audio/Plug-Ins/VST3"));
// Standalone binaries have no app bundle; don't scan a directory above their executable.
let contents = executable.as_deref().and_then(Path::parent).filter(|dir| dir.ends_with("Contents/MacOS")).and_then(Path::parent);
if let Some(contents) = contents {
v.push(contents.join("VST3"));
}
}
Os::Windows => {
if let Some(c) = common_program_files {
v.push(c.join("VST3"));
}
if let Some(l) = local_app_data {
v.push(l.join("Programs").join("Common").join("VST3"));
}
for c in [common_program_files, common_program_files_x86].into_iter().flatten() {
let path = c.join("VST3");
// Both environment variables can name the same 32-bit common-files directory.
if !v.contains(&path) {
v.push(path);
}
}
if let Some(exe) = executable
&& let Some(app_dir) = exe.parent()
{
v.push(app_dir.join("VST3"));
}
}
Os::Unix => {
// Keep the user/global/application order from Steinberg's Linux plug-in locations.
if let Some(h) = home {
v.push(h.join(".vst3"));
}
v.push(PathBuf::from("/usr/lib64/vst3"));
v.push(PathBuf::from("/usr/lib/vst3"));
v.push(PathBuf::from("/usr/local/lib64/vst3"));
v.push(PathBuf::from("/usr/local/lib/vst3"));
if let Some(exe) = executable
&& let Some(app_dir) = exe.parent()
{
v.push(app_dir.join("vst3"));
}
}
}
v
@@ -276,11 +237,6 @@ mod tests {
d
}
/// Returns a closure that looks up environment variables from the given pairs, for testing.
fn env_from(pairs: Vec<(&'static str, std::ffi::OsString)>) -> impl Fn(&str) -> Option<std::ffi::OsString> {
move |name| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| value.clone())
}
#[test]
fn finds_files_and_bundles_recursively_but_not_inside_bundles() {
let d = tmp("find");
@@ -336,142 +292,17 @@ mod tests {
}
#[test]
fn default_paths_are_not_empty() {
fn os_paths_cover_the_standard_locations() {
let mac = os_search_paths(Os::MacOs, Some("/Users/u".into()), None, None);
assert_eq!(mac, vec![PathBuf::from("/Users/u/Library/Audio/Plug-Ins/VST3"), PathBuf::from("/Library/Audio/Plug-Ins/VST3")]);
let win = os_search_paths(Os::Windows, None, Some("C:/CPF".into()), Some("C:/LAD".into()));
assert_eq!(win[0], PathBuf::from("C:/CPF").join("VST3"));
assert_eq!(win.len(), 2);
let unix = os_search_paths(Os::Unix, Some("/home/u".into()), None, None);
assert_eq!(unix, vec![PathBuf::from("/home/u/.vst3"), PathBuf::from("/usr/lib/vst3"), PathBuf::from("/usr/local/lib/vst3")]);
assert!(!default_search_paths().is_empty());
}
#[test]
fn macos_paths_cover_documented_locations_in_order() {
let exe = PathBuf::from("/Applications/SoundCraft.app/Contents/MacOS/soundcraft");
let global_and_app = vec![
PathBuf::from("/Library/Audio/Plug-Ins/VST3"),
PathBuf::from("/Network/Library/Audio/Plug-Ins/VST3"),
PathBuf::from("/Applications/SoundCraft.app/Contents/VST3"),
];
let mut with_home = vec![PathBuf::from("/Users/u/Library/Audio/Plug-Ins/VST3")];
with_home.extend(global_and_app.iter().cloned());
assert_eq!(search_paths(Os::MacOs, Some("/Users/u".into()), None, None, None, Some(exe.clone())), with_home);
assert_eq!(search_paths(Os::MacOs, None, None, None, None, Some(exe)), global_and_app);
}
#[test]
fn macos_paths_without_a_bundle_keep_only_standard_directories() {
let globals = vec![PathBuf::from("/Library/Audio/Plug-Ins/VST3"), PathBuf::from("/Network/Library/Audio/Plug-Ins/VST3")];
assert_eq!(search_paths(Os::MacOs, None, None, None, None, None), globals);
for exe in ["/usr/local/bin/soundcraft", "/tmp/MacOS/soundcraft", "/tmp/Contents/bin/soundcraft"] {
assert_eq!(search_paths(Os::MacOs, None, None, None, None, Some(exe.into())), globals);
}
let mut with_home = vec![PathBuf::from("/Users/u/Library/Audio/Plug-Ins/VST3")];
with_home.extend(globals);
assert_eq!(search_paths(Os::MacOs, Some("/Users/u".into()), None, None, None, None), with_home);
}
#[test]
fn windows_paths_cover_documented_locations_in_order() {
let paths = search_paths(
Os::Windows,
None,
Some("D:/Program Files/Common Files".into()),
Some("D:/Program Files (x86)/Common Files".into()),
Some("C:/Users/u/AppData/Local".into()),
Some("D:/SoundCraft/soundcraft.exe".into()),
);
assert_eq!(
paths,
vec![
PathBuf::from("C:/Users/u/AppData/Local/Programs/Common/VST3"),
PathBuf::from("D:/Program Files/Common Files/VST3"),
PathBuf::from("D:/Program Files (x86)/Common Files/VST3"),
PathBuf::from("D:/SoundCraft/VST3"),
]
);
}
#[test]
fn windows_paths_handle_missing_values_and_shared_common_directory() {
assert!(search_paths(Os::Windows, None, None, None, None, None).is_empty());
assert_eq!(
search_paths(Os::Windows, None, None, None, None, Some("C:/SoundCraft/soundcraft.exe".into())),
vec![PathBuf::from("C:/SoundCraft/VST3")]
);
let common = PathBuf::from("C:/Program Files (x86)/Common Files");
assert_eq!(search_paths(Os::Windows, None, Some(common.clone()), Some(common.clone()), None, None), vec![common.join("VST3")]);
assert_eq!(search_paths(Os::Windows, None, None, Some(common.clone()), None, None), vec![common.join("VST3")]);
}
#[test]
fn default_paths_prefer_the_64_bit_windows_common_directory() {
let paths = default_search_paths_with(
Os::Windows,
env_from(vec![
("COMMONPROGRAMW6432", "C:/Program Files/Common Files".into()),
("COMMONPROGRAMFILES", "C:/ignored/Common Files".into()),
("COMMONPROGRAMFILES(X86)", "C:/Program Files (x86)/Common Files".into()),
("LOCALAPPDATA", "C:/Users/u/AppData/Local".into()),
]),
Some("C:/SoundCraft/soundcraft.exe".into()),
);
assert_eq!(
paths,
vec![
PathBuf::from("C:/Users/u/AppData/Local/Programs/Common/VST3"),
PathBuf::from("C:/Program Files/Common Files/VST3"),
PathBuf::from("C:/Program Files (x86)/Common Files/VST3"),
PathBuf::from("C:/SoundCraft/VST3"),
]
);
}
#[test]
fn default_paths_fall_back_to_common_program_files() {
let paths =
default_search_paths_with(Os::Windows, env_from(vec![("COMMONPROGRAMFILES", "C:/Program Files (x86)/Common Files".into())]), None);
assert_eq!(paths, vec![PathBuf::from("C:/Program Files (x86)/Common Files/VST3")]);
}
#[test]
fn default_paths_append_vst3_path_entries() {
let vst3_path = std::env::join_paths(["/opt/one", "/opt/two"]).unwrap();
let paths = default_search_paths_with(
Os::Unix,
env_from(vec![("HOME", "/home/u".into()), ("VST3_PATH", vst3_path)]),
Some("/opt/SoundCraft/soundcraft".into()),
);
assert_eq!(
paths,
vec![
PathBuf::from("/home/u/.vst3"),
PathBuf::from("/usr/lib64/vst3"),
PathBuf::from("/usr/lib/vst3"),
PathBuf::from("/usr/local/lib64/vst3"),
PathBuf::from("/usr/local/lib/vst3"),
PathBuf::from("/opt/SoundCraft/vst3"),
PathBuf::from("/opt/one"),
PathBuf::from("/opt/two"),
]
);
}
#[test]
fn unix_paths_cover_documented_locations_in_order() {
let exe = PathBuf::from("/opt/SoundCraft/soundcraft");
let globals = vec![
PathBuf::from("/usr/lib64/vst3"),
PathBuf::from("/usr/lib/vst3"),
PathBuf::from("/usr/local/lib64/vst3"),
PathBuf::from("/usr/local/lib/vst3"),
];
let mut global_and_app = globals.clone();
global_and_app.push(PathBuf::from("/opt/SoundCraft/vst3"));
let mut with_home = vec![PathBuf::from("/home/u/.vst3")];
with_home.extend(global_and_app.iter().cloned());
assert_eq!(search_paths(Os::Unix, Some("/home/u".into()), None, None, None, Some(exe.clone())), with_home);
assert_eq!(search_paths(Os::Unix, None, None, None, None, Some(exe)), global_and_app);
let mut home_and_globals = vec![PathBuf::from("/home/u/.vst3")];
home_and_globals.extend(globals);
assert_eq!(search_paths(Os::Unix, Some("/home/u".into()), None, None, None, None), home_and_globals);
}
#[test]
fn sub_categories_map_to_categories() {
let c = |s: &str| category_from_sub_categories(&split_sub_categories(s));
+1 -1
View File
@@ -33,7 +33,7 @@ Newline-delimited JSON over TCP on `127.0.0.1`. One request per line, one reply
{"id": 1, "ok": true, "result": {"tracks": [12, 13]}}
```
Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no track named `Nope`"}`. A line that is not a JSON object with a string `method` gets one error reply, and the connection is closed.
Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no track named `Nope`"}`.
## Methods
+3 -7
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-27 · **Change:** minor (Cancel Zoom Toggle; Tracks and Groups and Clips in dock columns; Alt-click returns a plugin parameter to its default) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Cancel Zoom Toggle) · **Target:** Avid Pro Tools Ultimate 2026.4.1
How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts,
windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command
@@ -51,7 +51,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| I/O Setup | 🟡 | Busses and paths; no hardware channel matrix (G-01) |
| Preferences | 🟡 | Many settings stored, fewer honoured |
| Window configurations, Tile/Cascade | 🟡 | Configurations ✅; Tile/Cascade in PR #151 |
| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6); Alt/Option-click returns a parameter to its default |
| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6) |
| A plugin opens when it's inserted | ✅ | #486; a hosted plugin's own editor, else its plugin window |
| Command palette (Search) | ✅ | Over every command |
@@ -63,19 +63,15 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| Interface scaling | 🟡 | Slider in PR #147 |
| Meter types and ballistics (peak, RMS, VU, K-scales, gain reduction) | 🟡 | Peak/RMS; meter type choice and GR meters partial |
| Clip indicators, track colours, waveform views (peak/power/rectified/outlines) | ✅ | |
| Fader resolution, fine adjust, numeric entry | 🟡 | Ctrl/Cmd-drag moves the faders, the Strip Silence sliders and their value boxes, plugin parameter sliders, the Clip Effects sliders and the AudioSuite sliders a tenth as far |
| Fader resolution, fine adjust, numeric entry | 🟡 | |
| macOS system menu bar | ✅ | #10 |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-27 | minor | Clip Effects gain supports Alt/Option-click reset to 0 dB and Ctrl/Cmd fine adjustment. Readiness estimate unchanged |
| 2026-10-25 | minor | Zoom Toggle row: Alt+Shift+E and Alt-click cancel Zoom Toggle and keep the view. Readiness estimate unchanged |
| 2026-10-25 | minor | Edit and Mix windows: Tracks and Groups and Clips in full-height dock columns. Readiness estimate unchanged |
| 2026-10-24 | minor | Plugin parameters: Alt/Option-click returns one to its default. Readiness estimate unchanged |
| 2026-10-24 | minor | Plugin parameter sliders: Ctrl/Cmd-drag moves them a tenth as far. Readiness estimate unchanged |
| 2026-10-24 | minor | Strip Silence sliders: Ctrl/Cmd-drag moves them a tenth as far. Readiness estimate unchanged |
| 2026-10-20 | minor | Preferences uses one fixed non-scrolling size with compact stacked choices, removes the duplicate meter-type list, and command-backs visual-effects reduction and undo depth alongside the UI theme |
| 2026-10-19 | minor | Preferences opens from Setup with nine searchable pages, compact grouped controls, staged apply/cancel, platform tab shortcuts and disabled placeholders for unavailable settings |
| 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged |
-2
View File
@@ -112,8 +112,6 @@ $Portable = Join-Path $TargetDir "windows-package\soundcraft-$Version-windows-$A
Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Portable | Out-Null
Copy-Item (Join-Path $Stage '*.exe') $Portable
# The ZIP is portable by default; keep the marker out of the MSI staging directory.
Set-Content -LiteralPath (Join-Path $Portable 'portable.txt') -Encoding Ascii -Value 'SoundCraft portable mode: settings and logs are stored in SoundCraftData beside the executable.'
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md') {
$p = Join-Path $Root $f
if (Test-Path $p) { Copy-Item $p $Portable }