Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
77 changes: 77 additions & 0 deletions backend/src/blocks/builtin/mixer/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
));
}

// ========================================================================
Expand Down Expand Up @@ -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),
));
}

// ========================================================================
Expand Down
36 changes: 36 additions & 0 deletions backend/src/blocks/builtin/mixer/definition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
53 changes: 40 additions & 13 deletions backend/src/blocks/builtin/mixer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`,
Expand All @@ -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
Expand Down Expand Up @@ -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<usize> {
let stripped = name
.strip_suffix("_pfl")
.or_else(|| name.strip_suffix("_afl"))?;
stripped.strip_prefix("ch")?.parse::<usize>().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::<usize>().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::<usize>().is_ok() {
return true;
}
}
}
}
false
}

// Crate-internal re-imports (accessible via super::* in tests)
Expand Down
66 changes: 45 additions & 21 deletions backend/src/blocks/builtin/mixer/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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]
Expand Down
Loading
Loading