Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
afc93c1
Strip Silence: the sliders move a tenth as far while Ctrl or Command …
dotCooCoo Oct 25, 2026
a9ef108
Plugins: parameter sliders move a tenth as far while Ctrl or Command …
dotCooCoo Oct 25, 2026
82e3ce0
Merge main into feat/strip-silence-fine-sliders
dotCooCoo Oct 25, 2026
9774947
Merge main into feat/strip-silence-fine-sliders
dotCooCoo Oct 25, 2026
eaf0634
Merge commit '9774947' into feat/plugin-parameter-fine-sliders
dotCooCoo Oct 25, 2026
df1d064
Merge main into feat/plugin-parameter-fine-sliders
dotCooCoo Oct 25, 2026
8cec3da
Merge main into feat/plugin-parameter-fine-sliders
dotCooCoo Oct 25, 2026
92a5ce9
Dialogs: Enter on a focused Cancel or Don't Save no longer runs OK
dotCooCoo Oct 25, 2026
cb8014f
Merge main into feat/plugin-parameter-fine-sliders
dotCooCoo Oct 26, 2026
f554274
Merge main into feat/plugin-parameter-fine-sliders
dotCooCoo Oct 28, 2026
245523a
Bounce Mix: Cancel starts no bounce; fine adjust on more AudioSuite s…
dotCooCoo Oct 28, 2026
3005888
Tests: give the Enter-on-Cancel bounce test its own scratch folder
dotCooCoo Oct 28, 2026
7810959
Merge main into feat/plugin-parameter-fine-sliders
dotCooCoo Oct 28, 2026
052d857
Dialogs: leave Enter on a focused Cancel or Don't Save to its own pul…
dotCooCoo Nov 1, 2026
30ee5b5
Merge commit 'refs/check/gf-dialog-enter-main' into feat/plugin-param…
dotCooCoo Nov 1, 2026
cbadb1b
Merge main into feat/plugin-parameter-fine-sliders
dotCooCoo Nov 6, 2026
70bdc7f
Merge main into feat/plugin-parameter-fine-sliders
dotCooCoo Nov 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-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
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Cancel Zoom Toggle, side panels in dock columns, Auto Pan stereo balance, fine adjustment on plugin parameter sliders, Strip Silence fine adjustment, 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
Expand Down Expand Up @@ -125,6 +125,8 @@ 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:** 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.
Expand Down Expand Up @@ -180,6 +182,8 @@ 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: 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 |
Expand Down
8 changes: 4 additions & 4 deletions crates/ui-egui/src/dialogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,10 +367,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];
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"));
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");
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 {
Expand Down
16 changes: 12 additions & 4 deletions crates/ui-egui/src/dialogs/audiosuite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,29 @@ 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);
ui.add(egui::Slider::new(v, p.min..=p.max).text(p.name));
let mut wide = f64::from(*v);
crate::widgets::fine_slider(ui, &mut wide, f64::from(p.min)..=f64::from(p.max), p.name);
*v = wide as f32;
}
} else {
match process.as_str() {
"normalize" => {
let v = params.entry("target_db".into()).or_insert(-0.1);
ui.add(egui::Slider::new(v, -24.0..=0.0).text("Peak dB"));
let mut wide = f64::from(*v);
crate::widgets::fine_slider(ui, &mut wide, -24.0..=0.0, "Peak dB");
*v = wide as f32;
}
"time_stretch" => {
let v = params.entry("ratio".into()).or_insert(1.0);
ui.add(egui::Slider::new(v, 0.25..=4.0).text("Length ratio"));
let mut wide = f64::from(*v);
crate::widgets::fine_slider(ui, &mut wide, 0.25..=4.0, "Length ratio");
*v = wide as f32;
}
"pitch_shift" => {
let v = params.entry("semitones".into()).or_insert(0.0);
ui.add(egui::Slider::new(v, -24.0..=24.0).text("Semitones"));
let mut wide = f64::from(*v);
crate::widgets::fine_slider(ui, &mut wide, -24.0..=24.0, "Semitones");
*v = wide as f32;
}
"speed_ramp" => {
for (id, label, default, choices) in SPEED_RAMP_CHOICES {
Expand Down
8 changes: 6 additions & 2 deletions crates/ui-egui/src/panels/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,18 @@ pub(super) fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
x = idx as f32;
r.response
} else {
let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false);
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);
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);
}
ui.add(sl)
let r = sl.show(ui);
x = wide as f32;
r
};
// 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.
Expand Down
4 changes: 3 additions & 1 deletion crates/ui-egui/src/panels/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,9 @@ 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;
col.add(egui::Slider::new(&mut v, p.min..=p.max).text(p.name));
let mut wide = f64::from(v);
crate::widgets::fine_slider(col, &mut wide, f64::from(p.min)..=f64::from(p.max), p.name);
v = wide as f32;
if (v - before).abs() > f32::EPSILON {
let mut m = serde_json::Map::new();
m.insert(key.clone(), json!(v));
Expand Down
141 changes: 141 additions & 0 deletions crates/ui-egui/src/widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,147 @@ 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, 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 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,
}

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 }
}

/// 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 } = self;
let id = ui.scope_id().with(("fine_slider", id_salt.unwrap_or(text)));
let (fine, x) = ui.input(|i| (i.modifiers.command || i.modifiers.ctrl, i.pointer.interact_pos().map(|p| p.x)));
// 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, 3.0) as usize };
if let (Some(d), Some(x)) = (&mut drag, x) {
if d.fine != fine {
*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 = 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 resp = ui.add(slider);
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,
Expand Down
2 changes: 2 additions & 0 deletions crates/ui-egui/tests/it/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ mod mix_separate;
mod new_midi_clip;
mod new_tracks;
mod open_on_insert;
mod plugin_fine_sliders;
mod plugin_window;
mod pro_tools_keys;
mod record_hint;
Expand All @@ -51,6 +52,7 @@ 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;
Expand Down
Loading
Loading