collective

Browse / github.com/storytold/soundcraft / patch

Mix: make Pre-Fader Metering meter before the fader

Mix: make Pre-Fader Metering meter before the fader Options › Pre-Fader Metering was stored with the session, but every track meter still read post-fader. With it on, track meters now read the pre-fader tap (after the inserts, before fader and VCA) that pre-fader sends already use. The strip keeps that copy whenever the option is on, and it goes through the same delay compensation as the post-fader signal, so the meter stays in step with what is heard. Mute doesn't silence the pre-fader tap (mute automation does, as for pre-fader sends). Master faders keep metering what they send out. Known limits, documented in `docs/control-protocol.md` and

Status
open
Score
0.00
Adopters
0 other operators
Author
forwardaudio (imported from pull request #1046, open)
Base
aa74bffac1aebfeedee0a3b60729ad8ceefc2195
Size
+100 −5 in 3 files
Review
pending
Shared
2026-10-28
Id
e62755a336e54103551547e8

How it went

No other operator has reported using it yet.

Diff

raw · crates/mix/src/lib.rs docs/control-protocol.md docs/ui-parity.md

diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs--- a/crates/mix/src/lib.rs+++ b/crates/mix/src/lib.rs@@ -360,6 +360,8 @@ pub struct MixEngine {     main_fmt: ChannelFormat,     pub meters: HashMap<TrackId, StripMeter>,     pub main_meter: StripMeter,+    /// This block's Options › Pre-Fader Metering.+    pre_fader_metering: bool,     pub pan_law: PanLaw,     /// Last rendered position end (to detect seeks).     last_end: Samples,@@ -451,6 +453,7 @@ impl MixEngine {             main: vec![vec![0.0; max_block]; MAIN_CHANNELS],             main_fmt: ChannelFormat::Stereo,             meters: HashMap::new(),+            pre_fader_metering: false,             main_meter: StripMeter::default(),             pan_law: PanLaw::Minus3,             last_end: 0,@@ -640,6 +643,7 @@ impl MixEngine {     /// Render `frames` (≤ max_block) samples starting at `pos` into `out` (≥ 2 channels).     pub fn render(&mut self, s: &Session, pos: Samples, frames: usize, out: &mut [Vec<f32>]) {         let frames = frames.min(self.max_block);+        self.pre_fader_metering = s.edit.pre_fader_metering;         if pos != self.last_end {             // Seek: silence held notes and ringing plugins.             for st in self.strips.values_mut() {@@ -731,7 +735,7 @@ impl MixEngine {             let ch = st.buf.len();             st.align.set(lmax_t.saturating_sub(st.latency), ch);             st.align.process(&mut st.buf, frames);-            if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) {+            if needs_pre_tap(s, t) {                 st.align_pre.set(lmax_t.saturating_sub(st.latency), ch);                 st.align_pre.process(&mut st.pre, frames);             }@@ -859,7 +863,10 @@ impl MixEngine {     /// Send, meter and pan a processed strip into the busses / main mix.     fn route_strip(&mut self, t: &Track, strip: &mut Strip, pos: Samples, frames: usize) {         let m = self.meters.entry(t.id).or_default();-        m.measure(&strip.buf, frames, strip.gr);+        // Options › Pre-Fader Metering: the pre-fader tap (after the inserts, before fader and+        // VCA, delay-compensated like the post-fader signal).+        let pre = self.pre_fader_metering && strip.pre.len() == strip.buf.len();+        m.measure(if pre { &strip.pre } else { &strip.buf }, frames, strip.gr);         m.plugin_failures = strip.failures;         let src_fmt = strip_format(t, strip.buf.len());         let law = self.pan_law;@@ -1072,7 +1079,7 @@ fn process_strip(     strip.latency = latency.min(MAX_LATENCY);     let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t);     strip.muted = t.mixer.mute || soloed_out;-    if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) {+    if needs_pre_tap(s, t) {         strip.pre.resize_with(strip.buf.len(), Vec::new);         for (d, src) in strip.pre.iter_mut().zip(strip.buf.iter()) {             d.clear();@@ -1131,6 +1138,12 @@ fn process_strip(     } } +/// A strip keeps a pre-fader copy of its signal for pre-fader sends, and for its meter under+/// Options › Pre-Fader Metering.+fn needs_pre_tap(s: &Session, t: &Track) -> bool {+    s.edit.pre_fader_metering || t.mixer.sends.iter().flatten().any(|x| x.pre_fader)+}+ /// Silences a block the plugin filled with NaN or infinity and takes the plugin out until the /// next [`MixEngine::reset`]. True then. fn contain_failure(slot: &mut PluginSlot, buf: &mut [Vec<f32>], frames: usize) -> bool {@@ -3034,6 +3047,79 @@ mod tests {         vec![PreparedInstance { track, slot, id: "clap:test.nan".into(), ch, plugin: Box::new(Nan(soundcraft_dsp::create("gain").unwrap())) }]     } +    /// Passes audio through but reports `.1` samples of latency, so delay compensation delays the+    /// other strips.+    struct Late(Box<dyn Plugin>, usize);++    impl Plugin for Late {+        fn info(&self) -> &'static soundcraft_dsp::PluginInfo {+            self.0.info()+        }+        fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {+            self.0.prepare(sr, mb, ch);+        }+        fn reset(&mut self) {+            self.0.reset();+        }+        fn set_param(&mut self, id: &str, v: f32) -> bool {+            self.0.set_param(id, v)+        }+        fn param(&self, id: &str) -> Option<f32> {+            self.0.param(id)+        }+        fn process(&mut self, _io: &mut [Vec<f32>], _frames: usize) {}+        fn latency(&self) -> usize {+            self.1+        }+    }++    #[test]+    fn pre_fader_metering_reads_before_the_fader_in_step_with_delay_compensation() {+        let (mut s, a) = session_with_dc(0.5, 48_000);+        let b = add_dc_track(&mut s, 0.5, 48_000);+        s.track_mut(b).unwrap().mixer.volume_db = -20.0;+        s.track_mut(a).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.late"));+        let run = |s: &Session| {+            let mut eng = MixEngine::new(48_000.0, 512);+            eng.set_external_instances(true);+            eng.adopt(vec![PreparedInstance {+                track: a,+                slot: 0,+                id: "clap:test.late".into(),+                ch: 1,+                plugin: Box::new(Late(soundcraft_dsp::create("gain").unwrap(), 1024)),+            }]);+            (0..3)+                .map(|i| {+                    render_block(&mut eng, s, i * 512);+                    eng.meters[&b].peak[0]+                })+                .collect::<Vec<f32>>()+        };+        // B waits 1024 samples for A's latency, so the first two blocks meter silence.+        let post = run(&s);+        assert!(post[0] == 0.0 && post[1] == 0.0 && (post[2] - 0.05).abs() < 1e-3, "post-fader: {post:?}");+        s.edit.pre_fader_metering = true;+        let pre = run(&s);+        assert!(pre[0] == 0.0 && pre[1] == 0.0, "the pre-fader meter is delay-compensated too: {pre:?}");+        assert!((pre[2] - 0.5).abs() < 1e-3, "and reads before the fader: {pre:?}");+        // The Mute button leaves the pre-fader tap alone.+        s.track_mut(b).unwrap().mixer.mute = true;+        assert!((run(&s)[2] - 0.5).abs() < 1e-3);+    }++    #[test]+    fn master_faders_meter_post_fader_even_with_pre_fader_metering() {+        let (mut s, _) = session_with_dc(0.5, 48_000);+        let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);+        s.track_mut(m).unwrap().mixer.volume_db = -20.0;+        s.edit.pre_fader_metering = true;+        let mut eng = MixEngine::new(48_000.0, 512);+        render_block(&mut eng, &s, 0);+        let expect = 0.5 * std::f32::consts::FRAC_1_SQRT_2 * 0.1;+        assert!((eng.meters[&m].peak[0] - expect).abs() < 1e-3, "{:?}", eng.meters[&m]);+    }+     #[test]     fn an_insert_that_puts_out_nan_is_taken_out_until_the_next_reset() {         let (mut s, a) = session_with_dc(0.5, 48_000);diff --git a/docs/control-protocol.md b/docs/control-protocol.md--- a/docs/control-protocol.md+++ b/docs/control-protocol.md@@ -89,6 +89,14 @@ a portal answer it uses Dark. Manual Light and Dark stay fixed when the OS appea An offscreen render without `--system-theme` supplies no OS appearance and therefore resolves System to Dark, or on Linux to the desktop's portal setting when one is available. +`options.pre_fader_metering` meters tracks after their inserts and before fader and VCA, in+step with delay compensation (master faders always meter what they send out). The Mute button+leaves pre-fader meters reading; mute automation silences them, as it does pre-fader sends.+Aux inputs' pre-fader tap is not delay-compensated (as for their pre-fader sends), so with plugin+latency an aux's pre-fader meter can run slightly ahead. Turning the option on sizes every+strip's pre-fader buffer and delay line in the audio callback that receives the change (a+document edit, like adding tracks), not in steady state.+ `view.scroll_into_view {"track":"Bass"}` (a track id or name; without it, the first selected track) brings the track to the top of the Edit window and the left of the Mix window, the same as Scroll Into View in the track name menu. A window not on screen catches up when it is next shown.diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (Scroll Into View in the track name menu) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (Pre-Fader Metering meters before the fader) · **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@@ -60,7 +60,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. |---|---|---| | Dark theme close to Pro Tools' | ✅ | Light theme (#32) and live system appearance (#85) beyond Pro Tools | | 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 |+| Meter types and ballistics (peak, RMS, VU, K-scales, gain reduction) | 🟡 | Peak/RMS; Options › Pre-Fader Metering meters tracks before their faders. Meter type choice and GR meters partial | | Clip indicators, track colours, waveform views (peak/power/rectified/outlines) | ✅ | | | Fader resolution, fine adjust, numeric entry | 🟡 | | | macOS system menu bar | ✅ | #10 |@@ -69,6 +69,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | Options › Pre-Fader Metering now meters tracks before their faders (the option was stored but did nothing). Readiness estimate unchanged | | 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 | | 2026-10-13 | minor | A plugin opens when it's inserted (#486) | | 2026-10-13 | minor | Drag across checkboxes (#399) |