collective

Browse / github.com/storytold/soundcraft / patch

Let a soloed VCA solo its members and implicit solo follow aux chains

Let a soloed VCA solo its members and implicit solo follow aux chains ## Problem and resulting behavior Two solo cases silence the session instead of letting the soloed audio through. 1. **Soloing a VCA master mutes everything.** The VCA's own `solo` flag counted as "something is soloed", but nothing passed it on to its members. Every track, members included, was solo-muted. A VCA's mute already mutes its members; its solo now solos them, as on the incumbent's VCA masters. A VCA with no members mutes nothing. 2. **Implicit solo stops after one bus.** `receives_solo` only looked at tracks writing directly onto an aux's input bus. With `track → bus 1 → aux 1 → b

Status
open
Score
0.00
Adopters
0 other operators
Author
lucasegen (imported from pull request #800, open)
Base
60087e0f46032118bf309ed9ea161a9b4a872713
Size
+247 −21 in 7 files
Review
pending
Shared
2026-10-28
Id
86472d03e8065f60f770e3d2

How it went

No other operator has reported using it yet.

Diff

raw · crates/mix/src/lib.rs crates/mix/src/routing.rs crates/mix/src/strip.rs crates/mix/src/tests.rs crates/ui-egui/src/edit_window.rs crates/ui-egui/src/edit_window/header.rs crates/ui-egui/src/edit_window/tracks.rs

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@@ -81,7 +81,8 @@ pub use offline::{ }; pub use pan::itu_stereo_fold; use pan::{PanSpec, apply_matrix, apply_matrix_span, build_matrix};-use routing::{processing_order, structure_fingerprint};+pub use routing::solo_muted_tracks;+use routing::{processing_order, solo_reach, structure_fingerprint}; use strip::{BusBuf, LiveInput, SlotCtx, Strip, apply_params, contain_failure, note_insert_latency, process_strip, strip_channels, strip_format}; #[cfg(test)] use strip::{DelayLine, MAX_LATENCY};@@ -144,6 +145,8 @@ pub struct MixEngine {     retired: Vec<Box<dyn Plugin>>,     /// Ownership workspace reused for parallel strip processing.     work: Vec<(usize, Strip)>,+    /// Per track (by index): the solo lets it through. Refilled each block, see [`solo_reach`].+    heard: Vec<bool>, }  impl MixEngine {@@ -174,6 +177,7 @@ impl MixEngine {             adopt: HashMap::new(),             retired: Vec::new(),             work: Vec::new(),+            heard: Vec::new(),         }     } @@ -369,7 +373,8 @@ impl MixEngine {         for c in &mut self.main {             c.iter_mut().take(frames).for_each(|x| *x = 0.0);         }-        let any_solo = s.tracks.iter().any(|t| t.mixer.solo && !t.inactive);+        let any_solo = solo_reach(s, &self.order, &mut self.heard);+        let heard = &self.heard;         let order = std::mem::take(&mut self.order);         let live = |t: &Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca);         let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);@@ -391,7 +396,8 @@ impl MixEngine {         let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording };         run_strips(&mut work, |(i, st)| {             if let Some(t) = s.tracks.get(*i) {-                process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo);+                let solo_muted = any_solo && !heard.get(*i).copied().unwrap_or(false);+                process_strip(s, t, st, &busses, &live_in, pos, frames, solo_muted);             }         });         self.busses = busses;@@ -464,13 +470,14 @@ impl MixEngine {         self.work = work;         let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);         // Phase 2: auxes in dependency order (they read busses).-        for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| live(t)).filter(|t| is_aux(t)) {+        for (i, t) in order.iter().filter_map(|&i| s.tracks.get(i).map(|t| (i, t))).filter(|(_, t)| live(t) && is_aux(t)) {             let Some(mut st) = self.strips.remove(&t.id) else { continue };             if st.scratch.len() < self.max_block {                 st.scratch.resize(self.max_block, 0.0);             }             let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording };-            process_strip(s, t, &mut st, &self.busses, &live_in, pos, frames, any_solo);+            let solo_muted = any_solo && !self.heard.get(i).copied().unwrap_or(false);+            process_strip(s, t, &mut st, &self.busses, &live_in, pos, frames, solo_muted);             let ch = st.buf.len();             st.align.set(if pdc { lmax_a.saturating_sub(st.latency) } else { 0 }, ch);             st.align.process(&mut st.buf, frames);diff --git a/crates/mix/src/routing.rs b/crates/mix/src/routing.rs--- a/crates/mix/src/routing.rs+++ b/crates/mix/src/routing.rs@@ -141,12 +141,70 @@ fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec<usize> {         .collect() } -/// Implicit solo: an aux is not muted when a soloed track feeds its input bus.-pub(crate) fn receives_solo(s: &Session, t: &Track) -> bool {-    let Route::Bus(b) = &t.mixer.input else { return false };-    s.tracks-        .iter()-        .any(|o| o.mixer.solo && (o.mixer.output == Route::Bus(*b) || o.mixer.sends.iter().flatten().any(|sn| sn.target == Route::Bus(*b))))+/// A track's own solo, or the solo of the VCA master it belongs to.+fn soloed(s: &Session, t: &Track) -> bool {+    t.mixer.solo || t.mixer.vca.is_some_and(|vid| s.tracks.iter().any(|v| v.id.0 == vid && v.kind == TrackKind::Vca && !v.inactive && v.mixer.solo))+}++/// True when `t` writes onto bus `b`, by its output or a send that is not muted (a muted send carries nothing).+fn feeds(t: &Track, b: BusId) -> bool {+    t.mixer.output == Route::Bus(b) || t.mixer.sends.iter().flatten().any(|sn| sn.target == Route::Bus(b) && !sn.mute)+}++/// Marks in `heard` (by track index) the tracks the solo lets through: the soloed ones and,+/// implicitly, every active aux or folder track whose input bus they feed, hop after hop down a+/// chain of auxes. Only those strips read their input bus; an audio track with a bus input plays+/// its own clips, so the solo mutes it (unless it is solo-safe) and goes no further through it.+/// A VCA's solo solos its members, so a VCA with none mutes nothing. Returns false when+/// nothing is soloed. `order` lists the track indices producers first (see+/// [`processing_order`]), so a chain is usually marked in one pass. Reuses `heard`'s+/// allocation: the callback stays allocation-free.+pub(crate) fn solo_reach(s: &Session, order: &[usize], heard: &mut Vec<bool>) -> bool {+    heard.clear();+    if !s.tracks.iter().any(|t| t.mixer.solo && !t.inactive) {+        return false;+    }+    heard.extend(s.tracks.iter().map(|t| !t.inactive && t.kind != TrackKind::Vca && soloed(s, t)));+    if !heard.contains(&true) {+        return false;+    }+    // ponytail: each pass is O(tracks²); in processing order a chain takes one pass, a stale+    // order or a feedback loop a few more, and a pass that marks nothing ends the walk.+    // Upgrade path: a bus -> receivers map built in `sync`.+    for _ in 0..s.tracks.len() {+        let mut marked = false;+        for &i in order {+            let Some(t) = s.tracks.get(i) else { continue };+            if !matches!(t.kind, TrackKind::Aux | TrackKind::Folder) {+                continue;+            }+            let Route::Bus(b) = t.mixer.input else { continue };+            if t.inactive || heard.get(i).copied().unwrap_or(true) {+                continue;+            }+            if s.tracks.iter().zip(heard.iter()).any(|(o, &h)| h && feeds(o, b))+                && let Some(x) = heard.get_mut(i)+            {+                *x = true;+                marked = true;+            }+        }+        if !marked {+            break;+        }+    }+    true+}++/// Per track (by index): the solo mutes it, as the mix engine renders it. For displays such as+/// the implied-mute shade of a track's Mute key; compute it once per frame, not per track. A VCA+/// passes no audio, so the solo never mutes it: its fader still drives the members that sound.+pub fn solo_muted_tracks(s: &Session) -> Vec<bool> {+    let mut heard = Vec::new();+    if !s.tracks.iter().any(|t| t.mixer.solo) || !solo_reach(s, &processing_order(s), &mut heard) {+        return vec![false; s.tracks.len()];+    }+    s.tracks.iter().zip(heard).map(|(t, h)| !h && !t.mixer.solo && !t.mixer.solo_safe && t.kind != TrackKind::Vca).collect() }  #[cfg(test)]diff --git a/crates/mix/src/strip.rs b/crates/mix/src/strip.rs--- a/crates/mix/src/strip.rs+++ b/crates/mix/src/strip.rs@@ -3,7 +3,6 @@ use super::clips::{ClipFx, clip_fx_params, render_clip}; use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance, is_third_party}; use super::midi::{chase_controllers, schedule_notes}; use super::pan::{PanSpec, apply_matrix, build_matrix, identity_matrix};-use super::routing::receives_solo; use super::*;  /// A bus: its format and one buffer per channel.@@ -207,7 +206,7 @@ pub(crate) fn process_strip(     live: &LiveInput<'_>,     pos: Samples,     frames: usize,-    any_solo: bool,+    solo_muted: bool, ) {     for c in strip.buf.iter_mut() {         c.iter_mut().take(frames).for_each(|x| *x = 0.0);@@ -345,7 +344,7 @@ pub(crate) fn process_strip(     }     strip.gr = gr;     strip.latency = latency.min(MAX_LATENCY);-    let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t);+    let soloed_out = solo_muted && !t.mixer.solo_safe;     strip.muted = t.mixer.mute || soloed_out;     if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) {         strip.pre.resize_with(strip.buf.len(), Vec::new);diff --git a/crates/mix/src/tests.rs b/crates/mix/src/tests.rs--- a/crates/mix/src/tests.rs+++ b/crates/mix/src/tests.rs@@ -4,7 +4,7 @@  use super::*; use soundcraft_audio_io::AudioBuffer;-use soundcraft_model::{Insert, SourceAudio, SourceId};+use soundcraft_model::{Insert, SendSlot, SourceAudio, SourceId}; use std::sync::Arc;  fn synth_session(level_db: f32) -> Session {@@ -116,6 +116,166 @@ fn fader_mute_and_solo() {     assert!(out[0][2000].abs() < 1e-6); } +#[test]+fn soloing_a_vca_solos_its_members() {+    let (mut s, member) = session_with_dc(0.5, 4800);+    // Quieter than the member, so the render tells which of the two sounds.+    let other = add_dc_track(&mut s, 0.5, 4800);+    s.track_mut(other).unwrap().mixer.volume_db = -6.0;+    let vca = s.add_track(TrackKind::Vca, ChannelFormat::Mono, Some("VCA"));+    s.track_mut(member).unwrap().mixer.vca = Some(vca.0);+    s.track_mut(other).unwrap().mixer.mute = true;+    let only_member = render_range(&s, Range::new(0, 4800), 512);+    assert!(only_member[0][2000].abs() > 0.1, "the member should sound");+    s.track_mut(other).unwrap().mixer.mute = false;+    s.track_mut(vca).unwrap().mixer.solo = true;+    assert_eq!(render_range(&s, Range::new(0, 4800), 512), only_member);+    assert_eq!(solo_muted_tracks(&s), [false, true, false], "member, other, VCA");+    // A soloed member leaves its VCA unshaded.+    s.track_mut(vca).unwrap().mixer.solo = false;+    s.track_mut(member).unwrap().mixer.solo = true;+    assert_eq!(solo_muted_tracks(&s), [false, true, false], "member soloed: member, other, VCA");+}++#[test]+fn soloing_an_empty_vca_mutes_nothing() {+    let (mut s, _) = session_with_dc(0.5, 4800);+    let vca = s.add_track(TrackKind::Vca, ChannelFormat::Mono, Some("VCA"));+    let all = render_range(&s, Range::new(0, 4800), 512);+    s.track_mut(vca).unwrap().mixer.solo = true;+    assert_eq!(render_range(&s, Range::new(0, 4800), 512), all);+    assert_eq!(solo_muted_tracks(&s), [false, false], "track, VCA");+}++/// `src` -> bus 1 -> aux 1 -> bus 2 -> aux 2 -> main mix, by output (`send` false) or by a+/// 0 dB post-fader send (`send` true), plus `other`, 6 dB quieter, straight to the main mix.+/// Track indices: src 0, other 1, aux 1 2, aux 2 3.+fn aux_chain(send: bool) -> (Session, TrackId, TrackId, [TrackId; 2]) {+    let (mut s, src) = session_with_dc(0.5, 4800);+    let other = add_dc_track(&mut s, 0.5, 4800);+    s.track_mut(other).unwrap().mixer.volume_db = -6.0;+    let (b1, b2) = (s.add_bus("B1", ChannelFormat::Stereo), s.add_bus("B2", ChannelFormat::Stereo));+    let x1 = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("X1"));+    let x2 = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("X2"));+    let t = s.track_mut(src).unwrap();+    if send {+        let mut sn = SendSlot::new(Route::Bus(b1));+        sn.level_db = 0.0;+        t.mixer.sends = vec![Some(sn)];+        t.mixer.output = Route::None;+    } else {+        t.mixer.output = Route::Bus(b1);+    }+    s.track_mut(x1).unwrap().mixer.input = Route::Bus(b1);+    s.track_mut(x1).unwrap().mixer.output = Route::Bus(b2);+    s.track_mut(x2).unwrap().mixer.input = Route::Bus(b2);+    s.track_mut(x2).unwrap().mixer.output = Route::Main;+    (s, src, other, [x1, x2])+}++#[test]+fn implicit_solo_follows_a_chain_of_auxes() {+    for send in [false, true] {+        let (mut s, src, other, _) = aux_chain(send);+        s.track_mut(other).unwrap().mixer.mute = true;+        let only_src = render_range(&s, Range::new(0, 4800), 512);+        assert!(only_src[0][2000].abs() > 0.1, "send {send}: the source should reach the main mix");+        s.track_mut(other).unwrap().mixer.mute = false;+        s.track_mut(src).unwrap().mixer.solo = true;+        assert_eq!(render_range(&s, Range::new(0, 4800), 512), only_src, "send {send}");+    }+}++/// The tracks the solo lets through, walking in `order`.+fn reach(s: &Session, order: &[usize]) -> Vec<bool> {+    let mut heard = Vec::new();+    assert!(solo_reach(s, order, &mut heard), "something is soloed");+    heard+}++#[test]+fn a_muted_send_does_not_carry_the_solo() {+    let (mut s, src, _, _) = aux_chain(true);+    s.track_mut(src).unwrap().mixer.solo = true;+    // src 0, other 1, aux 1 2, aux 2 3.+    assert_eq!(reach(&s, &processing_order(&s)), vec![true, false, true, true], "the send carries the solo down the chain");+    if let Some(Some(sn)) = s.track_mut(src).unwrap().mixer.sends.first_mut() {+        sn.mute = true;+    }+    assert_eq!(reach(&s, &processing_order(&s)), vec![true, false, false, false], "a muted send carries nothing");+    assert_eq!(solo_muted_tracks(&s), vec![false, true, true, true], "so the solo mutes both auxes");+}++#[test]+fn implicit_solo_walk_skips_inactive_auxes_and_ends_on_loops() {+    let (mut s, src, _, [x1, x2]) = aux_chain(false);+    s.track_mut(src).unwrap().mixer.solo = true;+    let order = processing_order(&s);+    assert_eq!(reach(&s, &order), [true, false, true, true]);+    // Against the processing order the walk takes more passes but gets there.+    let backwards: Vec<usize> = order.iter().rev().copied().collect();+    assert_eq!(reach(&s, &backwards), [true, false, true, true]);+    // An inactive aux carries no audio, so it lets no solo through to the auxes after it.+    s.track_mut(x1).unwrap().inactive = true;+    assert_eq!(reach(&s, &order), [true, false, false, false]);+    // Aux 2 feeding aux 1's input closes a loop: the walk still ends, and the chain sounds.+    s.track_mut(x1).unwrap().inactive = false;+    let b1 = s.track(x1).unwrap().mixer.input.clone();+    let mut sn = SendSlot::new(b1);+    sn.level_db = -20.0;+    s.track_mut(x2).unwrap().mixer.sends = vec![Some(sn)];+    assert_eq!(reach(&s, &processing_order(&s)), [true, false, true, true]);+    let out = render_range(&s, Range::new(0, 4800), 512);+    assert!(out.iter().flatten().all(|x| x.is_finite()));+    assert!(out[0][2000].abs() > 0.1, "{}", out[0][2000]);+}++#[test]+fn an_audio_track_with_a_bus_input_is_solo_muted() {+    // An audio track plays its own clips whatever its input route says: the solo mutes it,+    // as it mutes any track that is not soloed, and solo safe lets it through.+    let (mut s, src, _, [x1, _]) = aux_chain(false);+    let b1 = s.track(x1).unwrap().mixer.input.clone();+    let print = add_dc_track(&mut s, 0.5, 4800);+    s.track_mut(print).unwrap().mixer.input = b1;+    s.track_mut(src).unwrap().mixer.solo = true;+    // Baseline: the soloed source alone, through the aux chain.+    s.track_mut(print).unwrap().mixer.mute = true;+    let only_src = render_range(&s, Range::new(0, 4800), 512);+    assert!(only_src[0][2000].abs() > 0.1, "the soloed source should sound");+    s.track_mut(print).unwrap().mixer.mute = false;+    assert_eq!(render_range(&s, Range::new(0, 4800), 512), only_src);+    assert_eq!(solo_muted_tracks(&s), [false, true, false, false, true], "src, other, X1, X2, print");+    s.track_mut(print).unwrap().mixer.solo_safe = true;+    assert_eq!(solo_muted_tracks(&s), [false, true, false, false, false], "print is solo safe");+    let out = render_range(&s, Range::new(0, 4800), 512);+    assert!(out[0][2000] > only_src[0][2000] + 0.1, "{} vs {}", out[0][2000], only_src[0][2000]);+}++#[test]+fn an_audio_track_with_a_bus_input_does_not_relay_the_solo() {+    let (mut s, src, _, [x1, _]) = aux_chain(false);+    let b1 = s.track(x1).unwrap().mixer.input.clone();+    let b3 = s.add_bus("B3", ChannelFormat::Stereo);+    // Audio track 4 reads bus 1 and writes bus 3; aux 5 reads bus 3; folder 6 reads bus 1.+    let print = add_dc_track(&mut s, 0.5, 4800);+    let x3 = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("X3"));+    let folder = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, Some("F"));+    let m = &mut s.track_mut(print).unwrap().mixer;+    m.input = b1.clone();+    m.output = Route::Bus(b3);+    s.track_mut(x3).unwrap().mixer.input = Route::Bus(b3);+    s.track_mut(folder).unwrap().mixer.input = b1;+    s.track_mut(src).unwrap().mixer.solo = true;+    // Source, other, X1, X2, print, X3, folder: the aux chain and the folder hear the solo,+    // the audio track does not, and neither does the aux after it.+    let expected = [true, false, true, true, false, false, true];+    let order = processing_order(&s);+    assert_eq!(reach(&s, &order), expected);+    let backwards: Vec<usize> = order.iter().rev().copied().collect();+    assert_eq!(reach(&s, &backwards), expected);+}+ #[test] fn hard_pans() {     let (mut s, t) = session_with_dc(0.5, 4800);diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs--- a/crates/ui-egui/src/edit_window.rs+++ b/crates/ui-egui/src/edit_window.rs@@ -167,12 +167,13 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {     }     app.edit_layout.content_h = content_h;     let clip_rect = Rect::from_min_max(pos2(full.min.x, tl.min.y), pos2(full.max.x, tl.max.y));+    let solo_muted = soundcraft_mix::solo_muted_tracks(app.engine.session());     for (id, r) in app.edit_layout.rows.clone() {         let row = rect(r);         if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y {             let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row));             child.set_clip_rect(clip_rect);-            track_row(app, &mut child, TrackId(id), row, tl, &cols);+            track_row(app, &mut child, TrackId(id), row, tl, &cols, &solo_muted);         }     }     // Below the tracks a click clears the selection and a double-click opens New Tracks.diff --git a/crates/ui-egui/src/edit_window/header.rs b/crates/ui-egui/src/edit_window/header.rs--- a/crates/ui-egui/src/edit_window/header.rs+++ b/crates/ui-egui/src/edit_window/header.rs@@ -3,7 +3,8 @@ use super::*;  /// Draw one track header: resize edge, selection and context menu, folder/options control, automation-lane toggle, /// name and the remaining track controls.-pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool, head_bg: Color32) {+/// `solo_muted`: the solo mutes this track (the implied-mute shade of its Mute key).+pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool, head_bg: Color32, solo_muted: bool) {     let t = Tokens::current();     let id = track.id;     // Drag the bottom edge to resize (snaps to the height presets).@@ -152,12 +153,11 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head:     let m = &track.mixer;     if h >= 37.0 {         let (records, mixes) = (track.kind.has_playlist(), track.kind != TrackKind::Master);-        let implied = !m.solo && app.engine.session().tracks.iter().any(|t| t.mixer.solo);         let keys = [             (HeaderKey::Rec, m.record_arm, false, "mix.record_arm", records),             (HeaderKey::Input, m.input_monitor, false, "mix.input_monitor", records),             (HeaderKey::Solo, m.solo, false, "mix.solo", mixes),-            (HeaderKey::Mute, m.mute, implied, "mix.mute", mixes),+            (HeaderKey::Mute, m.mute, solo_muted, "mix.mute", mixes),         ];         for (i, (key, lit, implied, cmd, shown)) in keys.into_iter().enumerate() {             if shown && header_toggle(ui, at(23.0 + i as f32 * 26.0, 20.0, 16.0, 16.0), id, &track.name, key, lit, implied).clicked() {diff --git a/crates/ui-egui/src/edit_window/tracks.rs b/crates/ui-egui/src/edit_window/tracks.rs--- a/crates/ui-egui/src/edit_window/tracks.rs+++ b/crates/ui-egui/src/edit_window/tracks.rs@@ -55,7 +55,7 @@ pub(crate) fn click_track(app: &mut SoundApp, id: TrackId, mods: egui::Modifiers  // ---- track rows ------------------------------------------------------------------------------ -pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, cols: &[Column]) {+pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, cols: &[Column], solo_muted: &[bool]) {     let t = Tokens::current();     let s = app.engine.session_arc();     let Some(track) = s.track(id) else { return };@@ -73,7 +73,8 @@ pub(super) fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect,     ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(STRIP_W, head.height())), 0.0, rgb(track.color));     ui.painter().line_segment([pos2(head.min.x, row.max.y + 0.5), pos2(tl.max.x, row.max.y + 0.5)], Stroke::new(1.0, t.border));     ui.painter().hline(head.min.x + STRIP_W..=head.max.x, head.min.y + 0.5, Stroke::new(1.0, t.edge_light));-    track_header(app, ui, track, head, selected, head_bg);+    let implied = s.track_index(id).and_then(|i| solo_muted.get(i).copied()).unwrap_or(false);+    track_header(app, ui, track, head, selected, head_bg, implied);     // Lane.     ui.painter().vline(tl.min.x - 0.5, row.y_range(), Stroke::new(1.0, t.border));     let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect()));