diff --git a/backend/src/blocks/builtin/mixer/builder.rs b/backend/src/blocks/builtin/mixer/builder.rs index 62d20f98d..f554abebc 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,83 @@ 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. + // + // 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!( + "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..16965a0a4 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. 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 { + 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..627d1a7a5 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,359 @@ 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 the direct queue (3 s) and then some. + std::thread::sleep(Duration::from_millis(4500)); + 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 + ); +} + +/// 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 93ed7e70b..612aed2de 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,38 @@ 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. 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.) 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. 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 ``` 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..fd1722bc6 100644 --- a/frontend/src/mixer/state.rs +++ b/frontend/src/mixer/state.rs @@ -1,5 +1,17 @@ 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; 7] = [ + "dsp_backend", + "force_live", + "latency", + "internal_bus_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 +53,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 +372,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 +635,64 @@ 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)), + ("internal_bus_latency".to_string(), PropertyValue::UInt(20)), + ( + "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 the flow's next save deletes their links. + for saved in [ + editor.collect_properties(), + editor.collect_structural_properties(), + ] { + for key in [ + "direct_outs", + "dsp_backend", + "latency", + "internal_bus_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"