diff --git a/backend/src/blocks/builtin/mixer/builder.rs b/backend/src/blocks/builtin/mixer/builder.rs index 6515fa66..12bb67b5 100644 --- a/backend/src/blocks/builtin/mixer/builder.rs +++ b/backend/src/blocks/builtin/mixer/builder.rs @@ -462,6 +462,45 @@ impl BlockBuilder for MixerBuilder { ElementPadRef::pad(&aux_level_id, "src"), ElementPadRef::pad(&aux_out_tee_id, "sink"), )); + + // AFL tap on the aux master. Taps from `aux_out_tee` (post-master, + // post-mute, post-meter) so the operator hears exactly what is + // leaving the aux send. Like channel PFL/AFL, the gate volume is + // either 0 or 1 — `bool_to_volume` transforms the public Bool + // property to the underlying volume value. + let aux_afl_enabled = get_bool_prop(properties, &format!("aux{}_afl", aux + 1), false); + let aux_afl_volume_id = format!("{}:aux{}_afl_volume", instance_id, aux); + let aux_afl_volume = gst::ElementFactory::make("volume") + .name(&aux_afl_volume_id) + .property("volume", if aux_afl_enabled { 1.0 } else { 0.0 }) + .build() + .map_err(|e| { + BlockBuildError::ElementCreation(format!("aux{}_afl_volume: {}", aux, e)) + })?; + elements.push((aux_afl_volume_id.clone(), aux_afl_volume)); + + let aux_afl_queue_id = format!("{}:aux{}_afl_queue", instance_id, aux); + let aux_afl_queue = gst::ElementFactory::make("queue") + .name(&aux_afl_queue_id) + .build() + .map_err(|e| { + BlockBuildError::ElementCreation(format!("aux{}_afl_queue: {}", aux, e)) + })?; + elements.push((aux_afl_queue_id.clone(), aux_afl_queue)); + + // aux_out_tee → aux_afl_volume → aux_afl_queue → solo_mixer + internal_links.push(( + ElementPadRef::element(&aux_out_tee_id), + ElementPadRef::pad(&aux_afl_volume_id, "sink"), + )); + internal_links.push(( + ElementPadRef::pad(&aux_afl_volume_id, "src"), + ElementPadRef::pad(&aux_afl_queue_id, "sink"), + )); + internal_links.push(( + ElementPadRef::pad(&aux_afl_queue_id, "src"), + ElementPadRef::element(&solo_mixer_id), + )); } // ======================================================================== @@ -547,6 +586,44 @@ impl BlockBuilder for MixerBuilder { ElementPadRef::pad(&sg_to_main_queue_id, "src"), ElementPadRef::element(&mixer_id), // Request pad from main audiomixer )); + + // AFL tap on the group master. Taps from `group_out_tee` + // (post-master, post-mute, post-meter) so the operator hears the + // summed group exactly as it leaves the bus. Gate volume is 0/1 + // via `bool_to_volume`. + let sg_afl_enabled = get_bool_prop(properties, &format!("group{}_afl", sg + 1), false); + let sg_afl_volume_id = format!("{}:group{}_afl_volume", instance_id, sg); + let sg_afl_volume = gst::ElementFactory::make("volume") + .name(&sg_afl_volume_id) + .property("volume", if sg_afl_enabled { 1.0 } else { 0.0 }) + .build() + .map_err(|e| { + BlockBuildError::ElementCreation(format!("group{}_afl_volume: {}", sg, e)) + })?; + elements.push((sg_afl_volume_id.clone(), sg_afl_volume)); + + let sg_afl_queue_id = format!("{}:group{}_afl_queue", instance_id, sg); + let sg_afl_queue = gst::ElementFactory::make("queue") + .name(&sg_afl_queue_id) + .build() + .map_err(|e| { + BlockBuildError::ElementCreation(format!("group{}_afl_queue: {}", sg, e)) + })?; + elements.push((sg_afl_queue_id.clone(), sg_afl_queue)); + + // group_out_tee → group_afl_volume → group_afl_queue → solo_mixer + internal_links.push(( + ElementPadRef::element(&sg_out_tee_id), + ElementPadRef::pad(&sg_afl_volume_id, "sink"), + )); + internal_links.push(( + ElementPadRef::pad(&sg_afl_volume_id, "src"), + ElementPadRef::pad(&sg_afl_queue_id, "sink"), + )); + internal_links.push(( + ElementPadRef::pad(&sg_afl_queue_id, "src"), + ElementPadRef::element(&solo_mixer_id), + )); } // ======================================================================== diff --git a/backend/src/blocks/builtin/mixer/definition.rs b/backend/src/blocks/builtin/mixer/definition.rs index 4ae9b2ee..edb781be 100644 --- a/backend/src/blocks/builtin/mixer/definition.rs +++ b/backend/src/blocks/builtin/mixer/definition.rs @@ -389,6 +389,24 @@ pub(super) fn mixer_definition() -> BlockDefinition { live: true, persist: None, }); + + // AFL on the aux master — taps post-master, post-mute and sums into + // the solo bus alongside per-channel PFL/AFL. Transient: never + // persisted across restarts. + exposed_properties.push(ExposedProperty { + name: format!("aux{}_afl", aux), + label: format!("Aux {} AFL", aux), + description: format!("Enable AFL (After-Fader Listen) on aux bus {}", aux), + property_type: PropertyType::Bool, + default_value: Some(PropertyValue::Bool(false)), + mapping: PropertyMapping { + element_id: format!("aux{}_afl_volume", aux - 1), + property_name: "volume".to_string(), + transform: Some("bool_to_volume".to_string()), + }, + live: true, + persist: Some(false), + }); } // Add group properties @@ -421,6 +439,24 @@ pub(super) fn mixer_definition() -> BlockDefinition { live: true, persist: None, }); + + // AFL on the group master — taps post-master, post-mute and sums + // into the solo bus alongside per-channel PFL/AFL. Transient: never + // persisted across restarts. + exposed_properties.push(ExposedProperty { + name: format!("group{}_afl", sg), + label: format!("Group {} AFL", sg), + description: format!("Enable AFL (After-Fader Listen) on group {}", sg), + property_type: PropertyType::Bool, + default_value: Some(PropertyValue::Bool(false)), + mapping: PropertyMapping { + element_id: format!("group{}_afl_volume", sg - 1), + property_name: "volume".to_string(), + transform: Some("bool_to_volume".to_string()), + }, + live: true, + persist: Some(false), + }); } // Add per-channel properties (we'll generate for max channels, UI will show based on num_channels) diff --git a/backend/src/blocks/builtin/mixer/mod.rs b/backend/src/blocks/builtin/mixer/mod.rs index 2d9e62c8..d0a8b451 100644 --- a/backend/src/blocks/builtin/mixer/mod.rs +++ b/backend/src/blocks/builtin/mixer/mod.rs @@ -6,8 +6,13 @@ //! - Aux sends (0-32 configurable aux buses, switchable pre/post fader) //! - Groups (0-32 configurable, with output pads) //! - Independent per-channel PFL (pre-fader) and AFL (post-fader) sends +//! - Per-bus AFL on every aux and group (post-master, post-mute tap into the +//! solo bus). PFL on aux/group is intentionally not exposed today — bus +//! faders are essentially always up in operation, so listening pre-master +//! adds little. The property namespace (`*_afl`) leaves room for `*_pfl` +//! later without a breaking change. //! - Monitor bus that follows Main when no PFL/AFL is engaged and switches -//! to the solo mix as soon as any channel toggles PFL or AFL +//! to the solo mix as soon as any channel/aux/group toggles PFL or AFL //! - Main stereo bus with compressor, EQ, limiter, and master fader //! - Per-channel and bus metering. Convention: channel (input) meters are //! tapped pre-fader; bus (output) meters are tapped post-master. @@ -23,6 +28,10 @@ //! post_fader_tee → afl_volume_N → afl_queue_N ─┘ //! //! (pre_fader_tee | post_fader_tee) → aux_send_N_M → aux_queue_N_M → aux_M_mixer +//! +//! aux_M_out_tee → aux_afl_volume_M → aux_afl_queue_M ─┐ +//! ├→ solo_mixer +//! group_K_out_tee → group_afl_volume_K → group_afl_queue_K ─┘ //! ``` //! `level_N` sits pre-fader so the channel meter shows the signal hitting the //! fader regardless of fader position or mute. Bus meters (`main_level`, @@ -34,10 +43,11 @@ //! Monitor bus: the solo_mixer and a tap from main_out_tee both feed a //! monitor_mixer through two volume-gate elements (solo_to_mon and //! main_to_mon). The state layer flips those gates as a side effect of any -//! `chN_pfl`/`chN_afl` write — no PFL/AFL active → main_to_mon=1, -//! solo_to_mon=0; any solo active → reversed. Clients only write the bools; -//! the gates are not part of the public API. monitor_mixer feeds -//! monitor_master_vol → monitor_level → monitor_out_tee → monitor_out pad. +//! `chN_pfl`/`chN_afl`/`auxN_afl`/`groupK_afl` write — no PFL/AFL active → +//! main_to_mon=1, solo_to_mon=0; any solo active → reversed. Clients only +//! write the bools; the gates are not part of the public API. monitor_mixer +//! feeds monitor_master_vol → monitor_level → monitor_out_tee → monitor_out +//! pad. //! //! All output buses terminate in a tee with allow-not-linked=true, so unconnected //! output pads don't cause NOT_LINKED flow errors. Audiomixer elements use @@ -77,14 +87,31 @@ pub const MIXER_BLOCK_ID: &str = "builtin.mixer"; pub const SOLO_TO_MON_ELEMENT: &str = "solo_to_mon"; pub const MAIN_TO_MON_ELEMENT: &str = "main_to_mon"; -/// Return the 1-indexed channel number if `name` matches a `chN_pfl` / `chN_afl` -/// exposed property, otherwise `None`. Used to detect solo-affecting writes in -/// the block-properties batch path. -pub fn parse_solo_property_name(name: &str) -> Option { - let stripped = name - .strip_suffix("_pfl") - .or_else(|| name.strip_suffix("_afl"))?; - stripped.strip_prefix("ch")?.parse::().ok() +/// Return `true` if `name` is one of the public solo-affecting Bool +/// properties: `chN_pfl`, `chN_afl`, `auxN_afl`, or `groupN_afl`. Used by the +/// state layer to detect when a block-properties batch needs to refresh the +/// monitor-source gates. +/// +/// We don't return the index because the state layer keys its solo-intent +/// cache by the full property name — that lets channel/aux/group solos +/// coexist without colliding. +pub fn is_solo_property_name(name: &str) -> bool { + if let Some(stripped) = name.strip_suffix("_pfl") { + return stripped + .strip_prefix("ch") + .and_then(|s| s.parse::().ok()) + .is_some(); + } + if let Some(stripped) = name.strip_suffix("_afl") { + for prefix in ["ch", "aux", "group"] { + if let Some(rest) = stripped.strip_prefix(prefix) { + if rest.parse::().is_ok() { + return true; + } + } + } + } + false } // Crate-internal re-imports (accessible via super::* in tests) diff --git a/backend/src/blocks/builtin/mixer/tests.rs b/backend/src/blocks/builtin/mixer/tests.rs index d8c14377..561bc45f 100644 --- a/backend/src/blocks/builtin/mixer/tests.rs +++ b/backend/src/blocks/builtin/mixer/tests.rs @@ -333,34 +333,58 @@ fn test_mixer_pfl_afl_are_transient() { // Solo state must not persist across pipeline restarts — see the // persist:false guard in state.rs::strip_transient_properties. let def = mixer_definition(); + + let mut names: Vec = Vec::new(); for ch in 1..=4usize { for kind in ["pfl", "afl"] { - let name = format!("ch{}_{}", ch, kind); - let prop = def - .exposed_properties - .iter() - .find(|p| p.name == name) - .unwrap_or_else(|| panic!("missing {}", name)); - assert!(prop.live, "{} should be live", name); - assert_eq!( - prop.persist, - Some(false), - "{} must be marked persist: Some(false)", - name - ); + names.push(format!("ch{}_{}", ch, kind)); } } + // Aux/group AFL are also pure solo state and follow the same rule. + for aux in 1..=4usize { + names.push(format!("aux{}_afl", aux)); + } + for sg in 1..=4usize { + names.push(format!("group{}_afl", sg)); + } + + for name in names { + let prop = def + .exposed_properties + .iter() + .find(|p| p.name == name) + .unwrap_or_else(|| panic!("missing {}", name)); + assert!(prop.live, "{} should be live", name); + assert_eq!( + prop.persist, + Some(false), + "{} must be marked persist: Some(false)", + name + ); + } } #[test] -fn test_parse_solo_property_name_matches_pfl_and_afl() { - use super::parse_solo_property_name; - assert_eq!(parse_solo_property_name("ch1_pfl"), Some(1)); - assert_eq!(parse_solo_property_name("ch12_afl"), Some(12)); - assert_eq!(parse_solo_property_name("ch1_mute"), None); - assert_eq!(parse_solo_property_name("main_fader"), None); - assert_eq!(parse_solo_property_name("ch_pfl"), None); - assert_eq!(parse_solo_property_name("chA_pfl"), None); +fn test_is_solo_property_name_matches_pfl_and_afl() { + use super::is_solo_property_name; + // Channel PFL/AFL + assert!(is_solo_property_name("ch1_pfl")); + assert!(is_solo_property_name("ch12_afl")); + // Aux/group AFL + assert!(is_solo_property_name("aux1_afl")); + assert!(is_solo_property_name("aux32_afl")); + assert!(is_solo_property_name("group1_afl")); + assert!(is_solo_property_name("group16_afl")); + // Non-solo names must be rejected + assert!(!is_solo_property_name("ch1_mute")); + assert!(!is_solo_property_name("main_fader")); + assert!(!is_solo_property_name("ch_pfl")); + assert!(!is_solo_property_name("chA_pfl")); + assert!(!is_solo_property_name("aux1_mute")); + assert!(!is_solo_property_name("group1_mute")); + // PFL only exists for channels today + assert!(!is_solo_property_name("aux1_pfl")); + assert!(!is_solo_property_name("group1_pfl")); } #[test] diff --git a/backend/src/state.rs b/backend/src/state.rs index db8715c0..17b651af 100644 --- a/backend/src/state.rs +++ b/backend/src/state.rs @@ -1554,10 +1554,11 @@ impl AppState { /// written to the resolved underlying element via [`Self::update_element_property`] /// — so all the existing anti-click / ramp behaviour is inherited. /// - /// Block-specific derived state: for the audio mixer, any chN_pfl or chN_afl - /// write in the batch triggers a post-step that recomputes "any solo active" - /// and writes the two monitor-source gates (`solo_to_mon` / `main_to_mon`) with - /// the same `ramp_ms`. Those gates are pure derived state — clients must never + /// Block-specific derived state: for the audio mixer, any chN_pfl / + /// chN_afl / auxN_afl / groupN_afl write in the batch triggers a + /// post-step that recomputes "any solo active" and writes the two + /// monitor-source gates (`solo_to_mon` / `main_to_mon`) with the same + /// `ramp_ms`. Those gates are pure derived state — clients must never /// touch them directly. /// /// Returns `(current_values, rejected)`: @@ -1600,9 +1601,10 @@ impl AppState { let mut rejected: HashMap = HashMap::new(); let mut to_persist: Vec<(String, PropertyValue)> = Vec::new(); - // True iff this batch successfully applied at least one chN_pfl / - // chN_afl write. We update the per-block solo-intent cache below as - // those writes succeed, then run the monitor-gate refresh once. + // True iff this batch successfully applied at least one solo write + // (channel chN_pfl / chN_afl, aux auxN_afl, or group groupN_afl). + // We update the per-block solo-intent cache below as those writes + // succeed, then run the monitor-gate refresh once. let mut mixer_solo_changed = false; for (name, value) in properties { @@ -1655,7 +1657,7 @@ impl AppState { } if definition.id == crate::blocks::builtin::mixer::MIXER_BLOCK_ID - && crate::blocks::builtin::mixer::parse_solo_property_name(&name).is_some() + && crate::blocks::builtin::mixer::is_solo_property_name(&name) { if let PropertyValue::Bool(b) = &value { self.set_mixer_solo_intent(flow_id, block_instance_id, &name, *b) @@ -1671,10 +1673,10 @@ impl AppState { // Mixer: PFL/AFL bools are the only public API for solo. The two // monitor-source gates (solo_to_mon / main_to_mon) are pure derived - // state — any chN_pfl or chN_afl currently engaged → solo bus to - // monitor; otherwise main bus to monitor. We apply this exactly once - // per batch so the two gates are atomic relative to the bool writes - // that triggered them. + // state — any chN_pfl / chN_afl / auxN_afl / groupN_afl currently + // engaged → solo bus to monitor; otherwise main bus to monitor. We + // apply this exactly once per batch so the two gates are atomic + // relative to the bool writes that triggered them. if mixer_solo_changed { self.refresh_mixer_monitor_gates(flow_id, block_instance_id, ramp_ms) .await; @@ -1740,10 +1742,11 @@ impl AppState { .await } - /// Record a single chN_pfl / chN_afl write in the per-block solo-intent - /// cache. `true` adds the property name to the set, `false` removes it. - /// The empty-set case is the no-solo state and matches the build-time - /// gate defaults, so we clean up empty inner / outer maps. + /// Record a single solo-affecting write (chN_pfl / chN_afl / auxN_afl / + /// groupN_afl) in the per-block solo-intent cache. `true` adds the + /// property name to the set, `false` removes it. The empty-set case is + /// the no-solo state and matches the build-time gate defaults, so we + /// clean up empty inner / outer maps. async fn set_mixer_solo_intent( &self, flow_id: &FlowId, @@ -1767,9 +1770,10 @@ impl AppState { } } - /// True iff at least one chN_pfl / chN_afl is currently engaged on the - /// given mixer block instance. Reads only the in-memory intent cache — - /// never touches the running pipeline, so it is immune to mid-ramp races. + /// True iff at least one channel / aux / group PFL or AFL is currently + /// engaged on the given mixer block instance. Reads only the in-memory + /// intent cache — never touches the running pipeline, so it is immune + /// to mid-ramp races. async fn mixer_any_solo_active(&self, flow_id: &FlowId, block_instance_id: &str) -> bool { let state = self.inner.mixer_solo_state.read().await; state @@ -1780,14 +1784,15 @@ impl AppState { } /// Mixer-specific derived state: refresh the monitor-source gates after a - /// batch that contained one or more chN_pfl / chN_afl writes. + /// batch that contained one or more chN_pfl / chN_afl / auxN_afl / + /// groupN_afl writes. /// /// "Any solo active" is computed purely from the in-memory solo-intent /// cache (see [`Self::set_mixer_solo_intent`]) — the running element /// values are not consulted, so a long volume ramp on one channel cannot /// race with a release on another. Both gates are written with the - /// caller's `ramp_ms` so they stay in sync with the per-channel PFL/AFL - /// ramp that just kicked off. + /// caller's `ramp_ms` so they stay in sync with the PFL/AFL ramps that + /// just kicked off. /// /// Gate-write failures are logged but never bubble up — a transient /// element-not-found shouldn't fail the user's solo toggle. Note that a diff --git a/frontend/src/mixer/api.rs b/frontend/src/mixer/api.rs index 8441a9bc..febf76e3 100644 --- a/frontend/src/mixer/api.rs +++ b/frontend/src/mixer/api.rs @@ -385,14 +385,21 @@ impl MixerEditor { ); } - /// Release every PFL/AFL on the given channels in a single batched PATCH. + /// Release every PFL/AFL on the given channels, aux masters, and groups + /// in a single batched PATCH. /// - /// The Clear-all button needs to send 2N writes at once, and the per-property - /// throttle in [`Self::update_channel_property`] would otherwise drop all - /// but the first. Routing through the batched endpoint also collapses N - /// monitor-gate refreshes on the backend into one — gates ramp back to Main - /// exactly once. - pub(super) fn update_solo_clear_batch(&mut self, ctx: &Context, channels: &[usize]) { + /// The Clear-all button needs to send many writes at once, and the + /// per-property throttle in [`Self::update_channel_property`] would + /// otherwise drop all but the first. Routing through the batched endpoint + /// also collapses N monitor-gate refreshes on the backend into one — + /// gates ramp back to Main exactly once. + pub(super) fn update_solo_clear_batch( + &mut self, + ctx: &Context, + channels: &[usize], + aux_masters: &[usize], + groups: &[usize], + ) { if !self.live_updates || !self.pipeline_running { return; } @@ -402,6 +409,12 @@ impl MixerEditor { properties.insert(format!("ch{}_pfl", ch1), PropertyValue::Bool(false)); properties.insert(format!("ch{}_afl", ch1), PropertyValue::Bool(false)); } + for &i in aux_masters { + properties.insert(format!("aux{}_afl", i + 1), PropertyValue::Bool(false)); + } + for &i in groups { + properties.insert(format!("group{}_afl", i + 1), PropertyValue::Bool(false)); + } let ramp_ms = Some(self.fade_ms); let api = self.api.clone(); let flow_id = self.flow_id; @@ -514,6 +527,21 @@ impl MixerEditor { ); } + /// Update group AFL via the block-properties endpoint. The backend's + /// `bool_to_volume` transform writes 0/1 to the group's AFL volume gate, + /// and the same write side-effects the monitor-source gates so the bus + /// appears on the Monitor output. + pub(super) fn update_group_afl(&mut self, ctx: &Context, sg_idx: usize) { + if !self.live_updates || !self.pipeline_running { + return; + } + self.spawn_block_prop_update( + ctx, + format!("group{}_afl", sg_idx + 1), + PropertyValue::Bool(self.groups[sg_idx].afl), + ); + } + /// Update aux master fader via the block-properties endpoint. pub(super) fn update_aux_master_fader(&mut self, ctx: &Context, aux_idx: usize) { if !self.live_updates || !self.pipeline_running { @@ -542,4 +570,19 @@ impl MixerEditor { PropertyValue::Bool(self.aux_masters[aux_idx].mute), ); } + + /// Update aux master AFL via the block-properties endpoint. The backend's + /// `bool_to_volume` transform writes 0/1 to the aux's AFL volume gate, + /// and the same write side-effects the monitor-source gates so the bus + /// appears on the Monitor output. + pub(super) fn update_aux_master_afl(&mut self, ctx: &Context, aux_idx: usize) { + if !self.live_updates || !self.pipeline_running { + return; + } + self.spawn_block_prop_update( + ctx, + format!("aux{}_afl", aux_idx + 1), + PropertyValue::Bool(self.aux_masters[aux_idx].afl), + ); + } } diff --git a/frontend/src/mixer/mod.rs b/frontend/src/mixer/mod.rs index f348be78..93453002 100644 --- a/frontend/src/mixer/mod.rs +++ b/frontend/src/mixer/mod.rs @@ -117,6 +117,8 @@ struct GroupStrip { fader: f32, /// Mute state mute: bool, + /// AFL (After-Fader Listen) state — taps the bus output post-master, post-mute + afl: bool, } /// Aux bus master state. @@ -128,6 +130,8 @@ struct AuxMaster { fader: f32, /// Mute state mute: bool, + /// AFL (After-Fader Listen) state — taps the bus output post-master, post-mute + afl: bool, } impl ChannelStrip { @@ -170,6 +174,7 @@ impl GroupStrip { index, fader: DEFAULT_FADER, mute: false, + afl: false, } } } @@ -180,6 +185,7 @@ impl AuxMaster { index, fader: DEFAULT_FADER, mute: false, + afl: false, } } } @@ -290,13 +296,16 @@ pub struct MixerEditor { } impl MixerEditor { - /// True if any channel currently has PFL or AFL engaged. - /// Used only to render the MAIN/SOLO indicator on the monitor strip — the - /// backend owns the actual monitor source switching as a side effect of - /// any chN_pfl / chN_afl write. The frontend never touches the internal - /// monitor gates directly; PFL/AFL bools are the entire solo API. + /// True if any channel, aux master, or group currently has PFL or AFL + /// engaged. Used only to render the MAIN/SOLO indicator on the monitor + /// strip — the backend owns the actual monitor source switching as a + /// side effect of any chN_pfl / chN_afl / auxN_afl / groupN_afl write. + /// The frontend never touches the internal monitor gates directly; + /// PFL/AFL bools are the entire solo API. pub(super) fn any_solo_active(&self) -> bool { self.channels.iter().any(|c| c.pfl || c.afl) + || self.aux_masters.iter().any(|a| a.afl) + || self.groups.iter().any(|g| g.afl) } } diff --git a/frontend/src/mixer/rendering.rs b/frontend/src/mixer/rendering.rs index c65b09bc..a3229346 100644 --- a/frontend/src/mixer/rendering.rs +++ b/frontend/src/mixer/rendering.rs @@ -1000,22 +1000,44 @@ impl MixerEditor { .add_enabled(solo_active, clear_button) .on_hover_text("Clear all active PFL and AFL"); if clear_resp.clicked() { - let mut changed_indices: Vec = Vec::new(); + let mut changed_channels: Vec = Vec::new(); + let mut changed_aux: Vec = Vec::new(); + let mut changed_groups: Vec = Vec::new(); for (i, ch) in self.channels.iter_mut().enumerate() { if ch.pfl || ch.afl { ch.pfl = false; ch.afl = false; - changed_indices.push(i); + changed_channels.push(i); } } - // Send every chN_pfl=false / chN_afl=false in a single - // batched PATCH. Per-property calls go through a 50 ms - // throttle that would silently drop all but the first - // release in a tight loop; one call sidesteps that and - // also lets the backend flip the monitor gates back to - // Main exactly once (single any_solo recomputation). - if !changed_indices.is_empty() { - self.update_solo_clear_batch(ctx, &changed_indices); + for (i, aux) in self.aux_masters.iter_mut().enumerate() { + if aux.afl { + aux.afl = false; + changed_aux.push(i); + } + } + for (i, sg) in self.groups.iter_mut().enumerate() { + if sg.afl { + sg.afl = false; + changed_groups.push(i); + } + } + // Send every solo=false in a single batched PATCH. + // Per-property calls go through a 50 ms throttle that + // would silently drop all but the first release in a + // tight loop; one call sidesteps that and also lets + // the backend flip the monitor gates back to Main + // exactly once (single any_solo recomputation). + if !changed_channels.is_empty() + || !changed_aux.is_empty() + || !changed_groups.is_empty() + { + self.update_solo_clear_batch( + ctx, + &changed_channels, + &changed_aux, + &changed_groups, + ); } } }); @@ -1107,6 +1129,34 @@ impl MixerEditor { self.groups[sg_idx].mute = !self.groups[sg_idx].mute; self.update_group_mute(ctx, sg_idx); } + + // AFL — after-fader listen on the group bus. + // Labelled "AFL" rather than "Solo" so the PFL/AFL + // distinction stays visible if PFL is added later. + let afl = self.groups[sg_idx].afl; + let afl_fill = if afl { + Color32::from_rgb(160, 200, 80) + } else { + Color32::from_rgb(48, 48, 52) + }; + let afl_text = if afl { + Color32::BLACK + } else { + Color32::from_gray(100) + }; + if ui + .add( + egui::Button::new( + egui::RichText::new("AFL").small().color(afl_text), + ) + .fill(afl_fill) + .min_size(Vec2::new(BUS_STRIP_INNER - 4.0, BTN_H)), + ) + .clicked() + { + self.groups[sg_idx].afl = !self.groups[sg_idx].afl; + self.update_group_afl(ctx, sg_idx); + } }); }); if ui.input(|i| i.pointer.any_pressed()) @@ -1207,6 +1257,34 @@ impl MixerEditor { self.aux_masters[aux_idx].mute = !self.aux_masters[aux_idx].mute; self.update_aux_master_mute(ctx, aux_idx); } + + // AFL — after-fader listen on the aux bus. + // Labelled "AFL" rather than "Solo" so the PFL/AFL + // distinction stays visible if PFL is added later. + let afl = self.aux_masters[aux_idx].afl; + let afl_fill = if afl { + Color32::from_rgb(160, 200, 80) + } else { + Color32::from_rgb(48, 48, 52) + }; + let afl_text = if afl { + Color32::BLACK + } else { + Color32::from_gray(100) + }; + if ui + .add( + egui::Button::new( + egui::RichText::new("AFL").small().color(afl_text), + ) + .fill(afl_fill) + .min_size(Vec2::new(BUS_STRIP_INNER - 4.0, BTN_H)), + ) + .clicked() + { + self.aux_masters[aux_idx].afl = !self.aux_masters[aux_idx].afl; + self.update_aux_master_afl(ctx, aux_idx); + } }); }); if ui.input(|i| i.pointer.any_pressed()) diff --git a/frontend/src/mixer/state.rs b/frontend/src/mixer/state.rs index 82fcbc2a..c8a7790f 100644 --- a/frontend/src/mixer/state.rs +++ b/frontend/src/mixer/state.rs @@ -144,6 +144,10 @@ impl MixerEditor { { sg.mute = *b; } + if let Some(PropertyValue::Bool(b)) = properties.get(&format!("group{}_afl", i + 1)) + { + sg.afl = *b; + } sg }) .collect(); @@ -161,6 +165,9 @@ impl MixerEditor { { aux.mute = *b; } + if let Some(PropertyValue::Bool(b)) = properties.get(&format!("aux{}_afl", i + 1)) { + aux.afl = *b; + } aux }) .collect(); @@ -417,6 +424,7 @@ impl MixerEditor { let n = aux.index + 1; set_f!(format!("aux{}_fader", n), aux.fader, DEFAULT_FADER); set_b!(format!("aux{}_mute", n), aux.mute, false); + set_b!(format!("aux{}_afl", n), aux.afl, false); } // Groups @@ -424,6 +432,7 @@ impl MixerEditor { let n = sg.index + 1; set_f!(format!("group{}_fader", n), sg.fader, DEFAULT_FADER); set_b!(format!("group{}_mute", n), sg.mute, false); + set_b!(format!("group{}_afl", n), sg.afl, false); } // Per-channel @@ -548,12 +557,14 @@ impl MixerEditor { for aux in &mut self.aux_masters { aux.fader = DEFAULT_FADER; aux.mute = false; + aux.afl = false; } // Groups for sg in &mut self.groups { sg.fader = DEFAULT_FADER; sg.mute = false; + sg.afl = false; } // Channels