From c603cd40c48d1c0962770dc6235d3c388f0a238f Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Wed, 30 Sep 2026 18:25:56 -0700 Subject: [PATCH 1/6] feat(mixer): add per-channel direct outputs An opt-in `direct_outs` property gives each channel an output pad, `direct_out_N`, carrying what the channel sends to Main: after its fader, mute and to-Main switch, before the Main sum. A consumer that needs each channel as the operator has set it, such as a guest mix-minus built in another flow, can tap it instead of mirroring the operator's changes. The direct branch has a leaky queue, so a consumer that stops pulling drops direct-out audio instead of blocking the channel on Main. The fullscreen mixer editor replaces the block's properties on save, which dropped construction-time settings it has no controls for (dsp_backend, force_live, latency, min_upstream_latency). It now carries those, and direct_outs, through saves. Co-Authored-By: Claude Opus 5.5 --- backend/src/blocks/builtin/mixer/builder.rs | 85 ++++- .../src/blocks/builtin/mixer/definition.rs | 15 + backend/src/blocks/builtin/mixer/mod.rs | 6 + backend/src/blocks/builtin/mixer/tests.rs | 349 +++++++++++++++++- docs/AUDIO_MIXER_OPERATOR_GUIDE.md | 23 ++ frontend/src/mixer/mod.rs | 4 + frontend/src/mixer/state.rs | 69 ++++ frontend/src/properties.rs | 1 + 8 files changed, 543 insertions(+), 9 deletions(-) diff --git a/backend/src/blocks/builtin/mixer/builder.rs b/backend/src/blocks/builtin/mixer/builder.rs index 62d20f98d..352979476 100644 --- a/backend/src/blocks/builtin/mixer/builder.rs +++ b/backend/src/blocks/builtin/mixer/builder.rs @@ -97,6 +97,19 @@ impl BlockBuilder for MixerBuilder { }); } + // Add per-channel direct outputs + if get_bool_prop(properties, "direct_outs", false) { + for ch in 0..num_channels { + outputs.push(ExternalPad { + name: format!("direct_out_{}", ch + 1), + label: Some(format!("D{}", ch + 1)), + media_type: MediaType::Audio, + internal_element_id: format!("direct_out_tee_{}", ch), + internal_pad_name: "src_%u".to_string(), + }); + } + } + Some(ExternalPads { inputs, outputs }) } @@ -112,6 +125,7 @@ impl BlockBuilder for MixerBuilder { let num_aux_buses = parse_num_aux_buses(properties); let num_groups = parse_num_groups(properties); let sample_rate = parse_sample_rate(properties); + let direct_outs = get_bool_prop(properties, "direct_outs", false); let dsp_backend = get_string_prop(properties, "dsp_backend", "rust"); if dsp_backend != "rust" && dsp_backend != "lv2" { warn!( @@ -1213,10 +1227,73 @@ impl BlockBuilder for MixerBuilder { ElementPadRef::element(&routing_tee_id), ElementPadRef::pad(&to_main_vol_id, "sink"), )); - internal_links.push(( - ElementPadRef::pad(&to_main_vol_id, "src"), - ElementPadRef::pad(&to_main_queue_id, "sink"), - )); + if direct_outs { + // Direct out: what this channel contributes to Main, with + // fader, mute and the to-main switch applied, before the sum. + // to_main_vol → direct_tee → to_main_queue + // direct_tee → direct_queue → direct_out_tee → direct_out_N + let direct_tee_id = format!("{}:direct_tee_{}", instance_id, ch); + let direct_tee = gst::ElementFactory::make("tee") + .name(&direct_tee_id) + .property("allow-not-linked", true) + .build() + .map_err(|e| { + BlockBuildError::ElementCreation(format!("direct_tee ch{}: {}", ch_num, e)) + })?; + elements.push((direct_tee_id.clone(), direct_tee)); + + // The tee pushes to both branches on the channel's streaming + // thread, so a direct-out consumer that stops pulling would + // block it and silence the channel on Main. The leaky queue + // drops direct-out audio instead. + let direct_queue_id = format!("{}:direct_queue_{}", instance_id, ch); + let direct_queue = gst::ElementFactory::make("queue") + .name(&direct_queue_id) + .property_from_str("leaky", "downstream") + .build() + .map_err(|e| { + BlockBuildError::ElementCreation(format!( + "direct_queue ch{}: {}", + ch_num, e + )) + })?; + elements.push((direct_queue_id.clone(), direct_queue)); + + let direct_out_tee_id = format!("{}:direct_out_tee_{}", instance_id, ch); + let direct_out_tee = gst::ElementFactory::make("tee") + .name(&direct_out_tee_id) + .property("allow-not-linked", true) + .build() + .map_err(|e| { + BlockBuildError::ElementCreation(format!( + "direct_out_tee ch{}: {}", + ch_num, e + )) + })?; + elements.push((direct_out_tee_id.clone(), direct_out_tee)); + + internal_links.push(( + ElementPadRef::pad(&to_main_vol_id, "src"), + ElementPadRef::pad(&direct_tee_id, "sink"), + )); + internal_links.push(( + ElementPadRef::element(&direct_tee_id), + ElementPadRef::pad(&to_main_queue_id, "sink"), + )); + internal_links.push(( + ElementPadRef::element(&direct_tee_id), + ElementPadRef::pad(&direct_queue_id, "sink"), + )); + internal_links.push(( + ElementPadRef::pad(&direct_queue_id, "src"), + ElementPadRef::pad(&direct_out_tee_id, "sink"), + )); + } else { + internal_links.push(( + ElementPadRef::pad(&to_main_vol_id, "src"), + ElementPadRef::pad(&to_main_queue_id, "sink"), + )); + } internal_links.push(( ElementPadRef::pad(&to_main_queue_id, "src"), ElementPadRef::element(&mixer_id), diff --git a/backend/src/blocks/builtin/mixer/definition.rs b/backend/src/blocks/builtin/mixer/definition.rs index 4782850a8..5c8289089 100644 --- a/backend/src/blocks/builtin/mixer/definition.rs +++ b/backend/src/blocks/builtin/mixer/definition.rs @@ -115,6 +115,21 @@ pub(super) fn mixer_definition() -> BlockDefinition { live: false, persist: None, }, + // Per-channel direct outputs — construction-time only. + ExposedProperty { + name: "direct_outs".to_string(), + label: "Direct Outs".to_string(), + description: "Add a direct_out_N output per channel: the channel's contribution to Main (after fader, mute and the to-Main switch, before the sum). A channel routed only to a group is silent on it.".to_string(), + property_type: PropertyType::Bool, + default_value: Some(PropertyValue::Bool(false)), + mapping: PropertyMapping { + element_id: "_block".to_string(), + property_name: "direct_outs".to_string(), + transform: None, + }, + live: false, + persist: None, + }, // Monitor master level — drives the Monitor bus output. The Monitor // bus follows Main when no channel has PFL or AFL active, and // switches to the solo mix as soon as any PFL/AFL is engaged. diff --git a/backend/src/blocks/builtin/mixer/mod.rs b/backend/src/blocks/builtin/mixer/mod.rs index c4e78f9fb..0a75109ff 100644 --- a/backend/src/blocks/builtin/mixer/mod.rs +++ b/backend/src/blocks/builtin/mixer/mod.rs @@ -33,6 +33,12 @@ //! ├→ solo_mixer //! group_K_out_tee → group_afl_volume_K → group_afl_queue_K ─┘ //! ``` +//! With `direct_outs` on, each channel also gets a direct output tapped after +//! its to-Main switch: +//! ```text +//! routing_tee_N → to_main_vol_N → direct_tee_N → to_main_queue_N → main audiomixer +//! direct_tee_N → direct_queue_N (leaky) → direct_out_tee_N +//! ``` //! `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`, //! `monitor_level`, `auxN_level`, `groupN_level`) sit on the bus output, diff --git a/backend/src/blocks/builtin/mixer/tests.rs b/backend/src/blocks/builtin/mixer/tests.rs index 922af32fa..9492c4a97 100644 --- a/backend/src/blocks/builtin/mixer/tests.rs +++ b/backend/src/blocks/builtin/mixer/tests.rs @@ -519,13 +519,14 @@ fn small_mixer_props(extra: &[(&str, PropertyValue)]) -> HashMap &gst::Element { - let full = format!("{INSTANCE}:{id}"); + let full = format!("{}:{id}", self.instance); self.result .elements .iter() @@ -535,13 +536,13 @@ impl Assembled { } fn has_element(&self, id: &str) -> bool { - let full = format!("{INSTANCE}:{id}"); + let full = format!("{}:{id}", self.instance); self.result.elements.iter().any(|(k, _)| *k == full) } /// Every element reachable downstream of `id` through linked pads. fn downstream(&self, id: &str) -> HashSet { - let prefix = format!("{INSTANCE}:"); + let prefix = format!("{}:", self.instance); let mut seen = HashSet::new(); let mut queue = VecDeque::from([self.element(id).clone()]); while let Some(element) = queue.pop_front() { @@ -576,10 +577,14 @@ fn resolve_pad(element: &gst::Element, name: &str) -> gst::Pad { /// Build through the real builder and link the result the way the pipeline /// manager does: named pads pad-to-pad, element refs by `Element::link`. fn assemble(properties: &HashMap) -> Assembled { + assemble_as(INSTANCE, properties) +} + +fn assemble_as(instance: &'static str, properties: &HashMap) -> Assembled { init_gst(); let ctx = BlockBuildContext::new(Vec::new(), "all".to_string()); let result = MixerBuilder - .build(INSTANCE, properties, &ctx) + .build(instance, properties, &ctx) .expect("mixer build"); let pipeline = gst::Pipeline::new(); @@ -613,7 +618,11 @@ fn assemble(properties: &HashMap) -> Assembled { .unwrap_or_else(|e| panic!("link {from:?} -> {to:?}: {e}")), }; } - Assembled { pipeline, result } + Assembled { + instance, + pipeline, + result, + } } #[test] @@ -1647,3 +1656,333 @@ fn test_a_bridge_input_next_to_a_two_second_input_configures_latency() { this test no longer shows what it guards" ); } + +// ---- Direct outs ---- + +/// Feed `convert_{ch}` from a live tone at the buses' default rate in 10 ms +/// buffers, so the channel's resampler passes it through unchanged. `wave` is +/// an audiotestsrc wave nick: "sine" (0.5 peak) or "silence". +fn feed_wave(m: &Assembled, ch: usize, wave: &str) { + let rate = strom_types::DEFAULT_AUDIO_SAMPLE_RATE as i32; + let src = gst::ElementFactory::make("audiotestsrc") + .property("is-live", true) + .property("samplesperbuffer", rate / 100) + .property("volume", 0.5f64) + .property_from_str("wave", wave) + .build() + .unwrap(); + let caps = gst::ElementFactory::make("capsfilter") + .property( + "caps", + gst::Caps::builder("audio/x-raw") + .field("rate", rate) + .build(), + ) + .build() + .unwrap(); + m.pipeline.add_many([&src, &caps]).unwrap(); + src.link(&caps).unwrap(); + caps.link_pads(None, m.element(&format!("convert_{ch}")), Some("sink")) + .unwrap(); +} + +/// Peak absolute sample per 10 ms of running time, keyed by the time of each +/// sample (buffer PTS plus its offset), so two outputs carrying the same +/// audio in differently sized buffers produce the same buckets. +type Envelope = Arc>>; + +const BUCKET_NS: u64 = 10_000_000; + +/// Link a fakesink to an output tee and record its envelope. +fn capture(m: &Assembled, tee: &str, sync: bool) -> Envelope { + let sink = gst::ElementFactory::make("fakesink") + .property("sync", sync) + .build() + .unwrap(); + m.pipeline.add(&sink).unwrap(); + m.element(tee) + .link_pads(Some("src_%u"), &sink, None) + .unwrap(); + let env: Envelope = Default::default(); + let out = env.clone(); + sink.static_pad("sink") + .unwrap() + .add_probe(gst::PadProbeType::BUFFER, move |pad, info| { + let Some(buffer) = info.buffer() else { + return gst::PadProbeReturn::Ok; + }; + let (Some(pts), Some(caps)) = (buffer.pts(), pad.current_caps()) else { + return gst::PadProbeReturn::Ok; + }; + let s = caps.structure(0).unwrap(); + assert_eq!(s.get::<&str>("format").unwrap(), "F32LE"); + let rate = s.get::("rate").unwrap() as u64; + let channels = s.get::("channels").unwrap() as usize; + let map = buffer.map_readable().unwrap(); + let mut env = out.lock().unwrap(); + for (frame, samples) in map.chunks_exact(4 * channels).enumerate() { + let t = pts.nseconds() + frame as u64 * 1_000_000_000 / rate; + let peak = samples + .as_chunks::<4>() + .0 + .iter() + .map(|b| f32::from_le_bytes(*b).abs()) + .fold(0.0f32, f32::max); + let slot = env.entry(t / BUCKET_NS).or_insert(0.0); + *slot = slot.max(peak); + } + gst::PadProbeReturn::Ok + }); + env +} + +fn snapshot(env: &Envelope) -> std::collections::BTreeMap { + env.lock().unwrap().clone() +} + +fn play_until_flowing(m: &Assembled, envs: &[&Envelope]) { + m.pipeline.set_state(gst::State::Playing).unwrap(); + let bus = m.pipeline.bus().unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while envs.iter().any(|e| e.lock().unwrap().len() < 20) { + assert!( + Instant::now() < deadline, + "audio did not reach every output: {:?} buckets", + envs.iter() + .map(|e| e.lock().unwrap().len()) + .collect::>() + ); + if let Some(msg) = bus.timed_pop_filtered( + gst::ClockTime::from_mseconds(20), + &[gst::MessageType::Error], + ) { + panic!("pipeline error: {msg:?}"); + } + } +} + +fn direct_props(extra: &[(&str, PropertyValue)]) -> HashMap { + let mut p = small_mixer_props(&[("direct_outs", PropertyValue::Bool(true))]); + p.extend(props(extra)); + p +} + +#[test] +fn test_direct_outs_exist_only_when_enabled() { + let names = |p: &HashMap| -> Vec<(String, Option)> { + MixerBuilder + .get_external_pads(p) + .unwrap() + .outputs + .into_iter() + .filter(|o| o.name.starts_with("direct_out")) + .map(|o| (o.name, o.label)) + .collect() + }; + assert!(names(&small_mixer_props(&[])).is_empty()); + assert!(names(&small_mixer_props(&[( + "direct_outs", + PropertyValue::Bool(false) + )])) + .is_empty()); + assert_eq!( + names(&direct_props(&[])), + [ + ("direct_out_1".to_string(), Some("D1".to_string())), + ("direct_out_2".to_string(), Some("D2".to_string())), + ] + ); + + // Off builds nothing extra; on builds the branch and every pad resolves. + let off = assemble_as("mx_direct_off", &small_mixer_props(&[])); + assert!(!off.has_element("direct_tee_0") && !off.has_element("direct_out_tee_0")); + + let on = assemble_as("mx_direct_on", &direct_props(&[])); + for pad in MixerBuilder + .get_external_pads(&direct_props(&[])) + .unwrap() + .outputs + { + let element = on.element(&pad.internal_element_id); + assert!( + element.pad_template(&pad.internal_pad_name).is_some(), + "{}: {} has no pad {}", + pad.name, + pad.internal_element_id, + pad.internal_pad_name + ); + } + // The tap is after the to-Main switch and still feeds Main. + let after_switch = on.downstream("to_main_vol_0"); + assert!(after_switch.contains("direct_out_tee_0")); + assert!(after_switch.contains("main_out_tee")); + assert!(!on.downstream("convert_1").contains("direct_out_tee_0")); +} + +/// A tone on input 1 and silence on input 2: each direct out carries only its +/// own channel, and carries it at the level it reaches Main with. +#[test] +fn test_direct_out_carries_only_its_channel() { + let m = assemble_as("mx_direct_split", &direct_props(&[])); + feed_wave(&m, 0, "sine"); + feed_wave(&m, 1, "silence"); + // Resolve each output the way a flow link does, through the external pad. + let pads = MixerBuilder.get_external_pads(&direct_props(&[])).unwrap(); + let tee_of = |name: &str| { + pads.outputs + .iter() + .find(|p| p.name == name) + .map(|p| p.internal_element_id.clone()) + .unwrap() + }; + let d1 = capture(&m, &tee_of("direct_out_1"), false); + let d2 = capture(&m, &tee_of("direct_out_2"), false); + play_until_flowing(&m, &[&d1, &d2]); + + let d1 = snapshot(&d1); + let d2 = snapshot(&d2); + let loud = d1.values().filter(|&&p| p > 0.4).count(); + assert!(loud * 10 >= d1.len() * 9, "tone missing on D1: {d1:?}"); + assert!(d2.values().all(|&p| p < 1e-6), "D2 is not silent: {d2:?}"); +} + +/// Mute (fader strip) and the to-Main switch, changed on a running mixer +/// through the ramp manager the property API uses, shape the direct out +/// exactly as they shape this channel on Main. +#[tokio::test(flavor = "multi_thread")] +async fn test_direct_out_follows_mute_and_to_main_ramps() { + use crate::gst::volume_ramp::VolumeRampManager; + + let m = assemble_as("mx_direct_ramp", &direct_props(&[])); + feed_wave(&m, 0, "sine"); + feed_wave(&m, 1, "silence"); + let direct = capture(&m, "direct_out_tee_0", false); + let main = capture(&m, "main_out_tee", false); + play_until_flowing(&m, &[&direct, &main]); + + const RAMP_MS: u32 = 300; + let settle = Duration::from_millis(RAMP_MS as u64 + 300); + let ramps = VolumeRampManager::new(); + let fader = m.element("volume_0"); + let to_main = m.element("to_main_vol_0"); + + assert!(ramps.apply_mute(fader, "volume_0", true, RAMP_MS)); + tokio::time::sleep(settle).await; + assert!(fader.property::("mute")); + let muted_at = snapshot(&direct).keys().last().copied().unwrap(); + + assert!(ramps.apply_mute(fader, "volume_0", false, RAMP_MS)); + tokio::time::sleep(settle).await; + let unmuted_at = snapshot(&direct).keys().last().copied().unwrap(); + + assert!(ramps.apply_volume_ramp(to_main, "to_main_vol_0", 0.0, RAMP_MS)); + tokio::time::sleep(settle).await; + + let direct = snapshot(&direct); + let main = snapshot(&main); + // Main runs behind the direct out by its aggregator latency; compare the + // buckets both have. + let shared: Vec = direct + .keys() + .filter(|k| main.contains_key(k)) + .copied() + .collect(); + assert!(shared.len() > 100, "too little overlap: {}", shared.len()); + // A loaded host makes Main's aggregator miss a deadline now and then, + // which drops this channel from a bucket or two. A tap in the wrong place + // differs for the whole length of a mute or a to-Main change. + let mut run = 0; + let mut longest = 0; + for k in &shared { + run = if (direct[k] - main[k]).abs() > 0.05 { + run + 1 + } else { + 0 + }; + longest = longest.max(run); + } + assert!( + longest < 20, + "direct out differs from Main for {} ms in a row", + longest * 10 + ); + + // Each change is a ramp, not a step, and the ramp is in the shared range. + let fading = |from: u64, to: u64| { + shared + .iter() + .filter(|&&k| k > from && k <= to) + .filter(|&&k| direct[&k] > 0.02 && direct[&k] < 0.4) + .count() + }; + assert!( + fading(0, muted_at) >= 5, + "mute did not fade on the direct out" + ); + assert!( + fading(muted_at, unmuted_at) >= 5, + "unmute did not fade on the direct out" + ); + assert!( + fading(unmuted_at, u64::MAX) >= 5, + "to-Main off did not fade on the direct out" + ); + // Silent while muted, and after the to-Main switch went off. + let tail: Vec = direct.values().rev().take(20).copied().collect(); + assert!(tail.iter().all(|&p| p < 1e-4), "still audible: {tail:?}"); + let mut run = 0; + let mut silent = 0; + for (_, &peak) in direct.range(..unmuted_at) { + run = if peak < 1e-4 { run + 1 } else { 0 }; + silent = silent.max(run); + } + assert!(silent >= 10, "never silent while muted"); +} + +/// A direct-out consumer that stops pulling must not cost this channel any +/// audio on Main. +#[test] +fn test_stalled_direct_out_consumer_does_not_starve_main() { + let m = assemble_as("mx_direct_blocked", &direct_props(&[])); + feed_wave(&m, 0, "sine"); + feed_wave(&m, 1, "silence"); + let main = capture(&m, "main_out_tee", false); + let consumer = gst::ElementFactory::make("fakesink") + .property("sync", false) + .build() + .unwrap(); + m.pipeline.add(&consumer).unwrap(); + m.element("direct_out_tee_0") + .link_pads(Some("src_%u"), &consumer, None) + .unwrap(); + play_until_flowing(&m, &[&main]); + // Stop pulling once running: the consumer's streaming thread parks in the + // probe and never returns. + let consumer_pad = consumer.static_pad("sink").unwrap(); + let block = consumer_pad + .add_probe(gst::PadProbeType::BLOCK_DOWNSTREAM, |_, _| { + gst::PadProbeReturn::Ok + }) + .unwrap(); + // Long enough to fill a default queue (1 s) and then some. + std::thread::sleep(Duration::from_millis(2500)); + let main = snapshot(&main); + consumer_pad.remove_probe(block); + + // Skip the start-up buckets. A blocked tee silences the channel on Main + // for good; a loaded host only drops scattered buckets, so judge by the + // longest run without the tone. + let steady: Vec = main.values().skip(30).copied().collect(); + assert!(steady.len() > 150, "Main stopped: {} buckets", steady.len()); + let mut run = 0; + let mut longest = 0; + for &peak in &steady { + run = if peak < 0.4 { run + 1 } else { 0 }; + longest = longest.max(run); + } + assert!( + longest < 30, + "Main lost the tone for {} ms in a row", + longest * 10 + ); +} diff --git a/docs/AUDIO_MIXER_OPERATOR_GUIDE.md b/docs/AUDIO_MIXER_OPERATOR_GUIDE.md index 93ed7e70b..afc2a16cf 100644 --- a/docs/AUDIO_MIXER_OPERATOR_GUIDE.md +++ b/docs/AUDIO_MIXER_OPERATOR_GUIDE.md @@ -54,6 +54,7 @@ flowchart LR | Groups (subgroups) | 0 – 32 (default 0) | Stereo. Each group has its own output **and** also feeds Main. | | Main output | 1 | Stereo. | | Monitor output | 1 | Stereo. Follows Main, switches to Solo bus on PFL/AFL. | +| Direct outputs | one per channel, off by default | Stereo. Turned on by the `direct_outs` setting. See [3.1](#31-direct-outputs). | The mixer runs at one sample rate, set by the **Sample Rate** property (default 48 kHz, construction-time). Every input is resampled to it, so inputs at different rates can be @@ -160,6 +161,28 @@ Every channel can be independently routed to **Main**, to any of the construction-time setting: changing it rebuilds the mixer when the flow restarts, it does not switch live. +### 3.1 Direct outputs + +With **Direct Outs** on (construction time), each channel gets an output +pad `direct_out_N` carrying exactly what that channel sends to Main: +after its fader, mute and **To Main** switch, before the Main sum and +the Main processing. A mute or To Main change fades on the direct out +with the same ramp as on Main. + +``` +[Channel post-fader] ──► ROUTING TEE ──► TO MAIN (on/off) ──┬──► MAIN + └──► DIRECT OUT N +``` + +This is not the direct out of a hardware console, which taps the +channel at a chosen point (input, pre-EQ, pre-fader, post-fader) and +ignores the channel's bus assignments. Here the To Main switch applies: +a channel routed only to a group reaches program through the group but +is **silent on its direct out**. + +If nothing pulls from a direct out, its audio is dropped; Main is not +held up. + ### Quick routing matrix view ``` diff --git a/frontend/src/mixer/mod.rs b/frontend/src/mixer/mod.rs index 93453002f..48b93b302 100644 --- a/frontend/src/mixer/mod.rs +++ b/frontend/src/mixer/mod.rs @@ -293,6 +293,10 @@ pub struct MixerEditor { strip_interacted: bool, /// Whether the pipeline is currently running (set by the app) pipeline_running: bool, + /// Construction-time properties the editor has no controls for, kept as + /// loaded. A save replaces the block's properties wholesale, so anything + /// not written back here is lost. + build_config: HashMap, } impl MixerEditor { diff --git a/frontend/src/mixer/state.rs b/frontend/src/mixer/state.rs index c8a7790fc..19b848fd8 100644 --- a/frontend/src/mixer/state.rs +++ b/frontend/src/mixer/state.rs @@ -1,5 +1,16 @@ use super::*; +/// Block properties set in the property panel, not the mixer editor. The +/// editor carries them through its saves unchanged. +const BUILD_CONFIG_KEYS: [&str; 6] = [ + "dsp_backend", + "force_live", + "latency", + "min_upstream_latency", + "direct_outs", + "sample_rate", +]; + impl MixerEditor { /// Create a new mixer editor. pub fn new(flow_id: FlowId, block_id: String, num_channels: usize, api: ApiClient) -> Self { @@ -41,11 +52,17 @@ impl MixerEditor { editing_label: None, strip_interacted: false, pipeline_running: false, + build_config: HashMap::new(), } } /// Load channel values from block properties. pub fn load_from_properties(&mut self, properties: &HashMap) { + self.build_config = BUILD_CONFIG_KEYS + .iter() + .filter_map(|&k| properties.get(k).map(|v| (k.to_string(), v.clone()))) + .collect(); + // Load main fader and mute if let Some(PropertyValue::Float(f)) = properties.get("main_fader") { self.main_fader = *f as f32; @@ -354,6 +371,7 @@ impl MixerEditor { "num_groups".to_string(), PropertyValue::Int(self.num_groups as i64), ); + props.extend(self.build_config.clone()); // Main bus set_f!("main_fader".to_string(), self.main_fader, DEFAULT_FADER); @@ -616,6 +634,57 @@ impl MixerEditor { "num_groups".to_string(), PropertyValue::Int(self.num_groups as i64), ); + props.extend(self.build_config.clone()); props } } + +#[cfg(test)] +mod tests { + use super::*; + + fn editor_from(properties: &HashMap) -> MixerEditor { + let mut editor = MixerEditor::new( + FlowId::new_v4(), + "b0".to_string(), + 2, + ApiClient::new_with_auth("http://example.com", None), + ); + editor.load_from_properties(properties); + editor + } + + #[test] + fn saves_keep_build_config_the_editor_does_not_edit() { + let loaded = HashMap::from([ + ("num_channels".to_string(), PropertyValue::Int(2)), + ("direct_outs".to_string(), PropertyValue::Bool(true)), + ( + "dsp_backend".to_string(), + PropertyValue::String("lv2".into()), + ), + ("latency".to_string(), PropertyValue::UInt(30)), + ( + "sample_rate".to_string(), + PropertyValue::String("44100".into()), + ), + ]); + let editor = editor_from(&loaded); + // A save replaces the block's properties, so a key missing here + // reverts to its default on the next build: direct_out pads vanish + // and their links fail. + for saved in [ + editor.collect_properties(), + editor.collect_structural_properties(), + ] { + for key in ["direct_outs", "dsp_backend", "latency", "sample_rate"] { + assert_eq!( + format!("{:?}", saved.get(key)), + format!("{:?}", loaded.get(key)), + "{key} not carried" + ); + } + assert!(!saved.contains_key("force_live"), "unset keys stay unset"); + } + } +} diff --git a/frontend/src/properties.rs b/frontend/src/properties.rs index 621adfee1..7f4853b69 100644 --- a/frontend/src/properties.rs +++ b/frontend/src/properties.rs @@ -1561,6 +1561,7 @@ impl PropertyInspector { "num_channels" | "num_aux_buses" | "num_groups" + | "direct_outs" | "dsp_backend" | "force_live" | "latency" From 4aae33dc0db24fa3d04e4b69a3e90cf87da8a091 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Wed, 30 Sep 2026 20:53:54 -0700 Subject: [PATCH 2/6] fix(mixer): size the direct-out queue for clock-synced consumers A consumer that syncs to the clock, such as the Inter Output block, holds each buffer for the flow's latency. With the queue's default limits (1 s, 200 buffers) the leaky direct-out queue overflowed in a flow above about 1 s of latency, or above about 200 ms with 1 ms buffers, and dropped nearly all of the direct-out audio. The queue now holds 3 s with no buffer or byte limit. Co-Authored-By: Claude Opus 5.5 --- backend/src/blocks/builtin/mixer/builder.rs | 10 +++++++ backend/src/blocks/builtin/mixer/tests.rs | 30 +++++++++++++++++++-- docs/AUDIO_MIXER_OPERATOR_GUIDE.md | 3 ++- frontend/src/mixer/state.rs | 2 +- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/backend/src/blocks/builtin/mixer/builder.rs b/backend/src/blocks/builtin/mixer/builder.rs index 352979476..f554abebc 100644 --- a/backend/src/blocks/builtin/mixer/builder.rs +++ b/backend/src/blocks/builtin/mixer/builder.rs @@ -1246,10 +1246,20 @@ impl BlockBuilder for MixerBuilder { // thread, so a direct-out consumer that stops pulling would // block it and silence the channel on Main. The leaky queue // drops direct-out audio instead. + // + // A consumer that syncs to the clock holds each buffer for the + // flow's latency, so the queue must hold that much audio or it + // leaks nearly all of it. The default limits (1 s, 200 + // buffers) are below a 1-2 s flow latency, or below 400 ms + // with 1 ms buffers. A consumer that stalls and resumes + // first gets up to this much old audio. let direct_queue_id = format!("{}:direct_queue_{}", instance_id, ch); let direct_queue = gst::ElementFactory::make("queue") .name(&direct_queue_id) .property_from_str("leaky", "downstream") + .property("max-size-time", 3 * gst::ClockTime::SECOND.nseconds()) + .property("max-size-buffers", 0u32) + .property("max-size-bytes", 0u32) .build() .map_err(|e| { BlockBuildError::ElementCreation(format!( diff --git a/backend/src/blocks/builtin/mixer/tests.rs b/backend/src/blocks/builtin/mixer/tests.rs index 9492c4a97..627d1a7a5 100644 --- a/backend/src/blocks/builtin/mixer/tests.rs +++ b/backend/src/blocks/builtin/mixer/tests.rs @@ -1964,8 +1964,8 @@ fn test_stalled_direct_out_consumer_does_not_starve_main() { gst::PadProbeReturn::Ok }) .unwrap(); - // Long enough to fill a default queue (1 s) and then some. - std::thread::sleep(Duration::from_millis(2500)); + // Long enough to fill the direct queue (3 s) and then some. + std::thread::sleep(Duration::from_millis(4500)); let main = snapshot(&main); consumer_pad.remove_probe(block); @@ -1986,3 +1986,29 @@ fn test_stalled_direct_out_consumer_does_not_starve_main() { longest * 10 ); } + +/// A consumer that syncs to the clock (the Inter Output block's default) +/// holds each buffer for the flow's latency. At 1.5 s the direct out must +/// still deliver every buffer, not leak them while the consumer waits. +#[test] +fn test_direct_out_feeds_a_synced_consumer_at_high_latency() { + let m = assemble_as("mx_direct_synced", &direct_props(&[])); + feed_wave(&m, 0, "sine"); + feed_wave(&m, 1, "silence"); + let direct = capture(&m, "direct_out_tee_0", true); + m.pipeline + .set_latency(Some(gst::ClockTime::from_mseconds(1500))); + play_until_flowing(&m, &[&direct]); + std::thread::sleep(Duration::from_millis(4000)); + + // The envelope is keyed by sample time, so a dropped buffer is a missing + // bucket between the first and the last. + let keys: Vec = snapshot(&direct).keys().copied().skip(20).collect(); + let span = (keys[keys.len() - 1] - keys[0] + 1) as usize; + assert!(span > 200, "direct out barely ran: {span} buckets"); + let missing = span - keys.len(); + assert!( + missing * 100 <= span, + "direct out lost {missing} of {span} 10 ms buckets" + ); +} diff --git a/docs/AUDIO_MIXER_OPERATOR_GUIDE.md b/docs/AUDIO_MIXER_OPERATOR_GUIDE.md index afc2a16cf..1850549bd 100644 --- a/docs/AUDIO_MIXER_OPERATOR_GUIDE.md +++ b/docs/AUDIO_MIXER_OPERATOR_GUIDE.md @@ -181,7 +181,8 @@ a channel routed only to a group reaches program through the group but is **silent on its direct out**. If nothing pulls from a direct out, its audio is dropped; Main is not -held up. +held up. A consumer that stops and resumes first receives up to 3 s of +old audio. ### Quick routing matrix view diff --git a/frontend/src/mixer/state.rs b/frontend/src/mixer/state.rs index 19b848fd8..5edafce16 100644 --- a/frontend/src/mixer/state.rs +++ b/frontend/src/mixer/state.rs @@ -672,7 +672,7 @@ mod tests { let editor = editor_from(&loaded); // A save replaces the block's properties, so a key missing here // reverts to its default on the next build: direct_out pads vanish - // and their links fail. + // and the flow's next save deletes their links. for saved in [ editor.collect_properties(), editor.collect_structural_properties(), From 911600df475525a2819edc8b16eb1ea83e47ada8 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Wed, 30 Sep 2026 21:37:39 -0700 Subject: [PATCH 3/6] docs(mixer): say an empty channel's direct out can hold the flow A direct out carries nothing until its channel has input, while the other mixer outputs carry silence. An output that waits for its first audio before starting (NDI, DeckLink audio, a raw sink with async on) therefore holds the whole flow until that channel has input. Say so in the property description and the operator guide. Co-Authored-By: Claude Opus 5.5 --- backend/src/blocks/builtin/mixer/definition.rs | 2 +- docs/AUDIO_MIXER_OPERATOR_GUIDE.md | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/src/blocks/builtin/mixer/definition.rs b/backend/src/blocks/builtin/mixer/definition.rs index 5c8289089..1ff310c00 100644 --- a/backend/src/blocks/builtin/mixer/definition.rs +++ b/backend/src/blocks/builtin/mixer/definition.rs @@ -119,7 +119,7 @@ pub(super) fn mixer_definition() -> BlockDefinition { ExposedProperty { name: "direct_outs".to_string(), label: "Direct Outs".to_string(), - description: "Add a direct_out_N output per channel: the channel's contribution to Main (after fader, mute and the to-Main switch, before the sum). A channel routed only to a group is silent on it.".to_string(), + description: "Add a direct_out_N output per channel: the channel's contribution to Main (after fader, mute and the to-Main switch, before the sum). A channel routed only to a group is silent on it. A channel with no input sends nothing, so an output that waits for its first audio (NDI, DeckLink audio) holds the flow until it has input.".to_string(), property_type: PropertyType::Bool, default_value: Some(PropertyValue::Bool(false)), mapping: PropertyMapping { diff --git a/docs/AUDIO_MIXER_OPERATOR_GUIDE.md b/docs/AUDIO_MIXER_OPERATOR_GUIDE.md index 1850549bd..da4ef1d62 100644 --- a/docs/AUDIO_MIXER_OPERATOR_GUIDE.md +++ b/docs/AUDIO_MIXER_OPERATOR_GUIDE.md @@ -184,6 +184,14 @@ If nothing pulls from a direct out, its audio is dropped; Main is not held up. A consumer that stops and resumes first receives up to 3 s of old audio. +A direct out carries nothing until its channel has input, unlike the +other outputs, which carry silence. An output that waits for its first +audio before starting therefore holds the **whole flow**, Main included, +until a source reaches that channel. The NDI and DeckLink audio outputs +wait like this, and so does a raw GStreamer sink with `async` left on +(for example `interaudiosink`). The other Strom outputs, including Inter +Output, do not. + ### Quick routing matrix view ``` From 4ccf920374fd43252015a7b6003cb8b36d615654 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 1 Oct 2026 06:13:38 -0700 Subject: [PATCH 4/6] docs(mixer): say an empty direct out holds only its consumer An output that waits for its first audio gets nothing and keeps the flow from reporting PLAYING, but Main and the other outputs keep playing: live sources take every other element to PLAYING. TAMS Output waits too (its splitmuxsink keeps async on). The other outputs carry silence only with force_live on, the default. Co-Authored-By: Claude Opus 5.5 --- backend/src/blocks/builtin/mixer/definition.rs | 2 +- docs/AUDIO_MIXER_OPERATOR_GUIDE.md | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/src/blocks/builtin/mixer/definition.rs b/backend/src/blocks/builtin/mixer/definition.rs index 1ff310c00..35bea009e 100644 --- a/backend/src/blocks/builtin/mixer/definition.rs +++ b/backend/src/blocks/builtin/mixer/definition.rs @@ -119,7 +119,7 @@ pub(super) fn mixer_definition() -> BlockDefinition { ExposedProperty { name: "direct_outs".to_string(), label: "Direct Outs".to_string(), - description: "Add a direct_out_N output per channel: the channel's contribution to Main (after fader, mute and the to-Main switch, before the sum). A channel routed only to a group is silent on it. A channel with no input sends nothing, so an output that waits for its first audio (NDI, DeckLink audio) holds the flow until it has input.".to_string(), + description: "Add a direct_out_N output per channel: the channel's contribution to Main (after fader, mute and the to-Main switch, before the sum). A channel routed only to a group is silent on it. A channel with no input sends nothing, so an output that waits for its first audio (NDI, DeckLink audio, TAMS) gets nothing and keeps the flow from reporting playing until the channel has input; Main keeps playing.".to_string(), property_type: PropertyType::Bool, default_value: Some(PropertyValue::Bool(false)), mapping: PropertyMapping { diff --git a/docs/AUDIO_MIXER_OPERATOR_GUIDE.md b/docs/AUDIO_MIXER_OPERATOR_GUIDE.md index da4ef1d62..1d88e5137 100644 --- a/docs/AUDIO_MIXER_OPERATOR_GUIDE.md +++ b/docs/AUDIO_MIXER_OPERATOR_GUIDE.md @@ -184,13 +184,15 @@ If nothing pulls from a direct out, its audio is dropped; Main is not held up. A consumer that stops and resumes first receives up to 3 s of old audio. -A direct out carries nothing until its channel has input, unlike the -other outputs, which carry silence. An output that waits for its first -audio before starting therefore holds the **whole flow**, Main included, -until a source reaches that channel. The NDI and DeckLink audio outputs -wait like this, and so does a raw GStreamer sink with `async` left on -(for example `interaudiosink`). The other Strom outputs, including Inter -Output, do not. +A direct out carries nothing until its channel has input. (With Force +Live on, the default, the other outputs carry silence instead.) An +output that waits for its first audio before starting gets nothing +until a source reaches that channel, and the flow reports itself as +paused instead of playing until then. Main and the other outputs keep +playing. The NDI, DeckLink audio and TAMS outputs wait like this, and so +does a raw GStreamer sink with `async` left on (for example +`interaudiosink`). The other Strom outputs, including Inter Output, do +not. ### Quick routing matrix view From 74afa0034858a144bd6dbf575248606ccfb1872f Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Fri, 2 Oct 2026 14:43:01 -0700 Subject: [PATCH 5/6] fix(mixer): keep internal_bus_latency through mixer editor saves The fullscreen mixer editor has no control for internal_bus_latency, so a save reset it to its default and Monitor gaps it had fixed came back. Carry it through saves with the other construction-time settings. Co-Authored-By: Claude Opus 5.5 --- frontend/src/mixer/state.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/mixer/state.rs b/frontend/src/mixer/state.rs index 5edafce16..fd1722bc6 100644 --- a/frontend/src/mixer/state.rs +++ b/frontend/src/mixer/state.rs @@ -2,10 +2,11 @@ use super::*; /// Block properties set in the property panel, not the mixer editor. The /// editor carries them through its saves unchanged. -const BUILD_CONFIG_KEYS: [&str; 6] = [ +const BUILD_CONFIG_KEYS: [&str; 7] = [ "dsp_backend", "force_live", "latency", + "internal_bus_latency", "min_upstream_latency", "direct_outs", "sample_rate", @@ -664,6 +665,7 @@ mod tests { PropertyValue::String("lv2".into()), ), ("latency".to_string(), PropertyValue::UInt(30)), + ("internal_bus_latency".to_string(), PropertyValue::UInt(20)), ( "sample_rate".to_string(), PropertyValue::String("44100".into()), @@ -677,7 +679,13 @@ mod tests { editor.collect_properties(), editor.collect_structural_properties(), ] { - for key in ["direct_outs", "dsp_backend", "latency", "sample_rate"] { + for key in [ + "direct_outs", + "dsp_backend", + "latency", + "internal_bus_latency", + "sample_rate", + ] { assert_eq!( format!("{:?}", saved.get(key)), format!("{:?}", loaded.get(key)), From 14c494e613a367dfdadd2bc62a95045d6fc3dcd1 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Tue, 20 Oct 2026 16:26:45 -0700 Subject: [PATCH 6/6] docs(mixer): outputs no longer wait for first audio after #934 NDI, DeckLink audio and TAMS outputs build their sinks with async=false, so an empty direct out only holds a consumer that leaves async on, such as a raw interaudiosink. Co-Authored-By: Claude Sonnet 5.5 --- backend/src/blocks/builtin/mixer/definition.rs | 2 +- docs/AUDIO_MIXER_OPERATOR_GUIDE.md | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/src/blocks/builtin/mixer/definition.rs b/backend/src/blocks/builtin/mixer/definition.rs index 35bea009e..16965a0a4 100644 --- a/backend/src/blocks/builtin/mixer/definition.rs +++ b/backend/src/blocks/builtin/mixer/definition.rs @@ -119,7 +119,7 @@ pub(super) fn mixer_definition() -> BlockDefinition { ExposedProperty { name: "direct_outs".to_string(), label: "Direct Outs".to_string(), - description: "Add a direct_out_N output per channel: the channel's contribution to Main (after fader, mute and the to-Main switch, before the sum). A channel routed only to a group is silent on it. A channel with no input sends nothing, so an output that waits for its first audio (NDI, DeckLink audio, TAMS) gets nothing and keeps the flow from reporting playing until the channel has input; Main keeps playing.".to_string(), + description: "Add a direct_out_N output per channel: the channel's contribution to Main (after fader, mute and the to-Main switch, before the sum). A channel routed only to a group is silent on it. A channel with no input sends nothing, so a consumer that waits for its first audio (a raw sink with async left on, such as interaudiosink) gets nothing and keeps the flow from reporting playing until the channel has input; Main keeps playing. The NDI, DeckLink audio and TAMS outputs do not wait.".to_string(), property_type: PropertyType::Bool, default_value: Some(PropertyValue::Bool(false)), mapping: PropertyMapping { diff --git a/docs/AUDIO_MIXER_OPERATOR_GUIDE.md b/docs/AUDIO_MIXER_OPERATOR_GUIDE.md index 1d88e5137..612aed2de 100644 --- a/docs/AUDIO_MIXER_OPERATOR_GUIDE.md +++ b/docs/AUDIO_MIXER_OPERATOR_GUIDE.md @@ -185,14 +185,13 @@ held up. A consumer that stops and resumes first receives up to 3 s of old audio. A direct out carries nothing until its channel has input. (With Force -Live on, the default, the other outputs carry silence instead.) An -output that waits for its first audio before starting gets nothing +Live on, the default, the other outputs carry silence instead.) A +consumer that waits for its first audio before starting gets nothing until a source reaches that channel, and the flow reports itself as paused instead of playing until then. Main and the other outputs keep -playing. The NDI, DeckLink audio and TAMS outputs wait like this, and so -does a raw GStreamer sink with `async` left on (for example -`interaudiosink`). The other Strom outputs, including Inter Output, do -not. +playing. A raw GStreamer sink with `async` left on (for example +`interaudiosink`) waits like this. The Strom outputs do not, including +NDI, DeckLink audio, TAMS and Inter Output. ### Quick routing matrix view