From 7377aee96e0c08093884c73afd884b3e81320b45 Mon Sep 17 00:00:00 2001 From: Gabriel Baez Date: Sat, 7 Nov 2026 19:33:57 -0600 Subject: [PATCH] Expose synchronized channel and mix master controls --- .github/workflows/ci.yml | 17 ++ CHANGELOG.md | 7 + README.md | 4 + crates/pipedeck-engine/examples/smoke.rs | 194 +++++++++++++++++ crates/pipedeck-engine/src/pw/mod.rs | 45 +++- crates/pipedeck-engine/src/types.rs | 41 ++++ crates/pipedeck/src/cell.rs | 7 +- crates/pipedeck/src/channel_dialog.rs | 31 +-- crates/pipedeck/src/meter_fader.rs | 5 +- crates/pipedeck/src/mix_dialog.rs | 12 +- crates/pipedeck/src/widgets.rs | 112 +++++++++- crates/pipedeck/src/window.rs | 258 +++++++++++++++++++++-- 12 files changed, 667 insertions(+), 66 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 779544a..e35c9a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,3 +69,20 @@ jobs: msrv=$(sed -n 's/^rust-version = "\(.*\)"/\1/p' Cargo.toml) rustup toolchain install "$msrv" --profile minimal cargo "+$msrv" check --workspace --all-targets --locked + + - name: GTK state synchronization + run: | + export XDG_RUNTIME_DIR=$(mktemp -d) + export XDG_CONFIG_HOME=$(mktemp -d) + export GDK_BACKEND=broadway BROADWAY_DISPLAY=:5 + export PIPEWIRE_REMOTE=pipedeck-ui-no-server + gtk4-broadwayd :5 > /tmp/pipedeck-broadway.log 2>&1 & + broadway_pid=$! + trap 'kill "$broadway_pid" 2>/dev/null || true' EXIT + for attempt in $(seq 50); do + [ -S "$XDG_RUNTIME_DIR/broadway6.socket" ] && break + sleep 0.1 + done + dbus-run-session cargo test -p pipedeck --bin pipedeck \ + window::tests::external_levels_update_visible_controls \ + -- --ignored --test-threads=1 diff --git a/CHANGELOG.md b/CHANGELOG.md index c7879c5..fe4a676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Synchronized channel and mix master sliders with red muted speakers. + ### Fixed - Pipedeck crashed when a device it played to or read from went away, a @@ -24,6 +28,9 @@ follow [Semantic Versioning](https://semver.org/). started from a terminal: the connection it told the portal its name on was closed before the shortcuts were asked for on another. +- Physical input master gain and mute now apply to connected mixes, + preserving cell balances and the capture device's hardware controls. + ## [0.2.1] - 2026-10-11 ### Changed diff --git a/README.md b/README.md index 9d9cd45..983867a 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,10 @@ be up to five mixes: Personal, Chat, Stream, Record and Aux. **Cells** connect a channel to a mix. A cell exists only once you press `+` on it, and it carries that pair's fader and mute. +The channel and mix cards have master volume sliders and mute buttons, +including changes made from a Stream Deck. These are separate from the levels +in individual cells. Input masters adjust what Pipedeck sends to its mixes +without changing the capture device's hardware volume. **The ear** on a mix card says whether you hear that mix in your headphones. The button in the header bar chooses which device your diff --git a/crates/pipedeck-engine/examples/smoke.rs b/crates/pipedeck-engine/examples/smoke.rs index 43ae04a..a0a15ae 100644 --- a/crates/pipedeck-engine/examples/smoke.rs +++ b/crates/pipedeck-engine/examples/smoke.rs @@ -445,6 +445,18 @@ fn main() -> ExitCode { let _ = tx.send(ev); }); engine.serve_control(); + if std::env::args().any(|arg| arg == "--input-master-regressions") { + input_master_regressions(&engine, &rx, &dir, &mut failures); + engine.shutdown(); + wait_for(&rx, "Stopped", |event| matches!(event, Event::Stopped)); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&control_dir); + return if failures == 0 { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }; + } // A fresh config starts with one mix and no source. let state = wait_state(&rx, "initial state", |_| true); @@ -2498,6 +2510,8 @@ fn main() -> ExitCode { &mut failures, ); + input_master_regressions(&engine, &rx, &dir, &mut failures); + // A mixer imported from a file takes this one's place, cells and all; // exported, it comes out as the file had it. let file = dir.join("imported.toml"); @@ -2605,3 +2619,183 @@ fn main() -> ExitCode { ExitCode::FAILURE } } + +/// Input master controls must change recorded audio, not the capture device. +fn input_master_regressions( + engine: &pipedeck_engine::EngineHandle, + rx: &mpsc::Receiver, + dir: &std::path::Path, + failures: &mut u32, +) { + std::fs::create_dir_all(dir).unwrap(); + let input = format!("smoke-review-input-{}", std::process::id()); + let tone = dir.join("input-tone.wav"); + tone_wav(&tone, 120); + // A native producer presented as a capture source, with no hardware or + // default-output connection. Its consumers drive the processing clock. + let mut tone_player = Process::new("pw-play") + .arg("--target=0") + .arg(format!( + "--properties={{ media.class=Audio/Source node.name={input} }}" + )) + .arg(&tone) + .spawn() + .unwrap(); + let fixture = dir.join("review-regressions.toml"); + std::fs::write( + &fixture, + format!( + r#" +latency = "512/48000" +[[mix]] +id = 101 +name = "Input test A" +[[mix]] +id = 102 +name = "Input test B" +[[source]] +id = 103 +name = "Synthetic input" +device = "{input}" +gain = 0.5 +muted = true +[[link]] +source = 103 +mix = 101 +gain = 0.8 +[[link]] +source = 103 +mix = 102 +gain = 0.6 +muted = true +"# + ), + ) + .unwrap(); + let reload = || { + drain(rx); + engine.send(Command::ImportConfig(fixture.clone())).unwrap(); + wait_state(rx, "review fixture imported", |s| { + s.sources.iter().any(|source| source.id == SourceId(103)) + }); + settle(); + }; + reload(); + let mic = SourceId(103); + let mix = MixId(101); + let cell = "pipedeck-smoke.link.103.101"; + let other = "pipedeck-smoke.link.103.102"; + let volume_is = |name: &str, gain: f32, muted: bool| { + node_volume(&pw_dump(), name).is_some_and(|(volumes, mute)| { + !volumes.is_empty() + && volumes + .iter() + .all(|value| (*value - gain.powi(3)).abs() < 1e-5) + && mute == muted + }) + }; + check( + volume_is(cell, 0.4, true) && volume_is(other, 0.3, true), + "input cells start with the saved master gain and mute", + failures, + ); + engine + .send(Command::SetSourceMute { + id: mic, + muted: false, + }) + .unwrap(); + settle(); + check( + volume_is(cell, 0.4, false) && volume_is(other, 0.3, true), + "unmuting the input master preserves the other cell's own mute", + failures, + ); + engine + .send(Command::SetLinkGain { + source: mic, + mix, + gain: 0.4, + }) + .unwrap(); + engine + .send(Command::SetSourceGain { id: mic, gain: 0.8 }) + .unwrap(); + settle(); + check( + volume_is(cell, 0.32, false) && volume_is(other, 0.48, true), + "input master and independent cell faders compose without compounding", + failures, + ); + + // Record actual mix samples, rather than relying on control state. + let mix_serial = node_serial(&pw_dump(), "pipedeck-smoke.mix.101").unwrap(); + let open = record_peak(mix_serial, 1, &dir.join("input-open.wav")); + check( + open > 0.001, + &format!("a recorder hears the trimmed input: {open:.5}"), + failures, + ); + engine + .send(Command::SetSourceMute { + id: mic, + muted: true, + }) + .unwrap(); + settle(); + let muted = record_peak(mix_serial, 1, &dir.join("input-muted.wav")); + check( + muted < open * 0.1, + &format!("input master mute silences recorded audio: {muted:.5} vs {open:.5}"), + failures, + ); + let hardware = pw_dump() + .into_iter() + .find(|node| props(node)["node.name"] == input) + .unwrap(); + let param = &hardware["info"]["params"]["Props"][0]; + check( + param["mute"] == false + && param["channelVolumes"] + .as_array() + .is_some_and(|values| values.iter().all(|value| value.as_f64() == Some(1.0))), + "input master controls leave the physical device controls unchanged", + failures, + ); + + let saved = dir.join("review-master-saved.toml"); + engine.send(Command::ExportConfig(saved.clone())).unwrap(); + wait_for( + rx, + "review state exported", + |event| matches!(event, Event::Notice(text) if text.contains("exported")), + ); + let state: pipedeck_engine::Config = + toml::from_str(&std::fs::read_to_string(&saved).unwrap()).unwrap(); + check( + state + .links + .iter() + .any(|link| link.source == mic && link.mix == mix && link.gain == 0.4 && !link.muted) + && state.links.iter().any(|link| { + link.source == mic && link.mix == MixId(102) && link.gain == 0.6 && link.muted + }), + "master changes preserve the saved independent cell balances", + failures, + ); + drain(rx); + engine.send(Command::ImportConfig(saved)).unwrap(); + wait_state(rx, "saved input master restored", |s| { + s.sources + .iter() + .any(|source| source.id == mic && source.gain == 0.8 && source.muted) + }); + settle(); + check( + volume_is(cell, 0.32, true) && volume_is(other, 0.48, true), + "restoring the graph preserves the input master and cell mutes", + failures, + ); + let _ = tone_player.kill(); + let _ = tone_player.wait(); +} diff --git a/crates/pipedeck-engine/src/pw/mod.rs b/crates/pipedeck-engine/src/pw/mod.rs index b51256f..603478a 100644 --- a/crates/pipedeck-engine/src/pw/mod.rs +++ b/crates/pipedeck-engine/src/pw/mod.rs @@ -185,12 +185,19 @@ struct Stage { /// Kept alive as long as the stage exists. See [`LoadedModule`]. _module: LoadedModule, wanted: ChainState, + /// Input rows have no sink to carry their master, so each cell applies + /// it here, after effects, without changing the cell's saved balance. + master: ChainState, } impl Stage { fn apply(&self) { if let Some(node) = &self.node { - apply_props(&node.proxy, &self.node_name, &self.wanted); + apply_props( + &node.proxy, + &self.node_name, + &self.wanted.with_master(self.master), + ); } } } @@ -632,6 +639,7 @@ impl Graph { node: None, _module: module, wanted: output.state(), + master: ChainState::default(), }), Err(e) => { log::error!("{e}"); @@ -886,7 +894,21 @@ impl Graph { sources: self .source_meters .iter() - .map(|(id, meter)| (*id, meter.take())) + .map(|(id, meter)| { + let peak = meter.take(); + let peak = self + .config + .source(*id) + .filter(|source| source.is_input()) + .map_or(peak, |source| { + if source.muted { + 0.0 + } else { + peak * source.state().linear_volume() + } + }); + (*id, peak) + }) .collect(), mixes: self .mix_meters @@ -1768,8 +1790,8 @@ impl Graph { Ok(()) } - /// Trim of a row, applied to its sink. An input row has no sink, so it - /// keeps the value for the interface and changes nothing in the graph. + /// A virtual row's sink carries its master. For physical input rows, + /// apply the master to each cell without touching the hardware volume. pub fn update_source( &mut self, id: SourceId, @@ -1782,11 +1804,20 @@ impl Graph { let mut state = cfg.state(); f(&mut state); cfg.set_state(state); + let is_input = cfg.is_input(); if let Some(sink) = self.sources.get(&id).and_then(|s| s.sink.as_ref()) { apply_props(sink, &id.sink_node_name(), &state); self.written .insert(Owner::Source(id), std::time::Instant::now()); } + if is_input { + for ((source, _), link) in &mut self.links { + if *source == id { + link.master = state; + link.apply(); + } + } + } self.dirty = true; self.emit(Event::SourceChanged { id, state }); Ok(()) @@ -1817,6 +1848,11 @@ impl Graph { self.stage_index .insert(node_name.clone(), StageRef::Cell(link.source, link.mix)); + let master = if source.is_input() { + source.state() + } else { + ChainState::default() + }; let spec = LoopbackSpec::for_link(&source, &mix, &self.config.latency); let module = match LoadedModule::load(&self.context, LOOPBACK_MODULE, &spec.to_args()) { Ok(module) => module, @@ -1834,6 +1870,7 @@ impl Graph { node: None, _module: module, wanted: link.state(), + master, }, ); log::info!("{} feeds {}", source.name, mix.name); diff --git a/crates/pipedeck-engine/src/types.rs b/crates/pipedeck-engine/src/types.rs index 465dd77..f4caf51 100644 --- a/crates/pipedeck-engine/src/types.rs +++ b/crates/pipedeck-engine/src/types.rs @@ -428,6 +428,15 @@ impl Default for ChainState { } impl ChainState { + /// Apply a master without losing the independent fader or mute state. + /// Cubic fader positions compose by multiplication, just as amplitudes do. + pub fn with_master(self, master: Self) -> Self { + Self { + gain: self.gain.clamp(0.0, 1.0) * master.gain.clamp(0.0, 1.0), + muted: self.muted || master.muted, + } + } + /// Linear amplitude to write into `channelVolumes`. pub fn linear_volume(&self) -> f32 { self.gain.clamp(0.0, 1.0).powi(3) @@ -774,4 +783,36 @@ mod tests { assert!(!virt.is_input()); assert!(!toml::to_string(&virt).unwrap().contains("device")); } + #[test] + fn masters_compose_amplitudes_and_preserve_cell_mutes() { + let cell = ChainState { + gain: 0.8, + muted: false, + }; + let master = ChainState { + gain: 0.5, + muted: false, + }; + let combined = cell.with_master(master); + assert!( + (combined.linear_volume() - cell.linear_volume() * master.linear_volume()).abs() < 1e-6 + ); + assert!( + cell.with_master(ChainState { + muted: true, + ..master + }) + .muted + ); + assert!( + ChainState { + muted: true, + ..cell + } + .with_master(master) + .muted + ); + assert_eq!(cell.gain, 0.8); + assert!(!cell.muted); + } } diff --git a/crates/pipedeck/src/cell.rs b/crates/pipedeck/src/cell.rs index 92e0585..fc17270 100644 --- a/crates/pipedeck/src/cell.rs +++ b/crates/pipedeck/src/cell.rs @@ -33,12 +33,7 @@ impl Cell { let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6); root.append(&controls); - let mute = gtk::ToggleButton::new(); - mute.set_icon_name("audio-volume-muted-symbolic"); - mute.set_tooltip_text(Some("Mute")); - mute.add_css_class("flat"); - mute.add_css_class("circular"); - mute.set_active(state.muted); + let mute = crate::widgets::mute_button(state.muted, "Mute this channel in this mix"); controls.append(&mute); let fader = crate::widgets::MeterFader::new(state.gain); diff --git a/crates/pipedeck/src/channel_dialog.rs b/crates/pipedeck/src/channel_dialog.rs index 98702ca..ac53de8 100644 --- a/crates/pipedeck/src/channel_dialog.rs +++ b/crates/pipedeck/src/channel_dialog.rs @@ -29,15 +29,12 @@ pub struct ChannelDialog { look: gtk::Box, volume: gtk::Scale, mute: gtk::ToggleButton, - /// Hidden for an input row, which has no sink to trim. + /// Channel master, applied to the sink or to the input row's cells. trim: gtk::Box, /// What an input row captures, named the way the system names it. device: gtk::Label, /// The trim's fader, with the channel's level drawn in its track. fader: widgets::MeterFader, - /// The level alone, for a channel bound to a microphone, which has no - /// trim to draw it in. - meter: widgets::Meter, apps: gtk::Box, /// The effects tab, which a mix window has too. effects: Rc, @@ -111,7 +108,6 @@ impl ChannelDialog { volume, mute, fader, - meter: widgets::Meter::new(), apps: gtk::Box::new(gtk::Orientation::Vertical, 8), effects: EffectPanel::new(engine, source.id), add_app, @@ -164,24 +160,18 @@ impl ChannelDialog { self.look.append(&self.badge); left.append(&self.look); - let caption = gtk::Label::new(Some(if source.is_input() { - "Captured device" - } else { - "Channel volume" - })); - caption.add_css_class("dim-label"); - caption.set_margin_top(12); - left.append(&caption); - - // An input row owns no node to carry a trim, so the left pane says - // what it listens to instead of offering a fader. if source.is_input() { + let captured = gtk::Label::new(Some("Captured device")); + captured.add_css_class("dim-label"); + captured.set_margin_top(12); + left.append(&captured); left.append(&self.device); - self.meter.root.set_margin_top(6); - left.append(&self.meter.root); - } else { - left.append(&self.trim); } + let caption = gtk::Label::new(Some("Channel volume")); + caption.add_css_class("dim-label"); + caption.set_margin_top(12); + left.append(&caption); + left.append(&self.trim); let filler = gtk::Box::new(gtk::Orientation::Vertical, 0); filler.set_vexpand(true); @@ -368,7 +358,6 @@ impl ChannelDialog { /// Move the meter of the object this window is about. pub fn set_level(&self, peak: f32) { self.fader.set_level(peak); - self.meter.set_level(peak); } pub fn id(&self) -> SourceId { diff --git a/crates/pipedeck/src/meter_fader.rs b/crates/pipedeck/src/meter_fader.rs index e121a6c..b7b3eae 100644 --- a/crates/pipedeck/src/meter_fader.rs +++ b/crates/pipedeck/src/meter_fader.rs @@ -104,13 +104,14 @@ impl MeterFader { } } -/// A level alone, drawn as a fader's track is, for what has no fader: a -/// channel bound to a microphone has nothing to trim. +/// A level alone, drawn as a fader's track is, used by the standalone renderer. +#[allow(dead_code)] pub struct Meter { pub root: gtk::DrawingArea, level: std::rc::Rc>, } +#[allow(dead_code)] impl Meter { pub fn new() -> Self { let level = std::rc::Rc::new(std::cell::Cell::new(0.0)); diff --git a/crates/pipedeck/src/mix_dialog.rs b/crates/pipedeck/src/mix_dialog.rs index 21f79b6..07e9e86 100644 --- a/crates/pipedeck/src/mix_dialog.rs +++ b/crates/pipedeck/src/mix_dialog.rs @@ -68,11 +68,7 @@ impl MixDialog { let fader = widgets::MeterFader::new(mix.gain); let volume = fader.scale.clone(); - let mute = gtk::ToggleButton::new(); - mute.set_icon_name("audio-volume-muted-symbolic"); - mute.set_tooltip_text(Some("Mute the whole mix")); - mute.add_css_class("flat"); - mute.add_css_class("circular"); + let mute = widgets::mute_button(mix.muted, "Mute the whole mix"); let outputs = gtk::Box::new(gtk::Orientation::Vertical, 8); let add_output = gtk::MenuButton::new(); @@ -468,11 +464,7 @@ impl MixDialog { inner.append(&top); let level = gtk::Box::new(gtk::Orientation::Horizontal, 6); - let mute = gtk::ToggleButton::new(); - mute.set_icon_name("audio-volume-muted-symbolic"); - mute.add_css_class("flat"); - mute.add_css_class("circular"); - mute.set_active(state.muted); + let mute = widgets::mute_button(state.muted, "Mute this output"); mute.connect_toggled({ let this = Rc::downgrade(self); move |button| { diff --git a/crates/pipedeck/src/widgets.rs b/crates/pipedeck/src/widgets.rs index 696f45b..52fddf2 100644 --- a/crates/pipedeck/src/widgets.rs +++ b/crates/pipedeck/src/widgets.rs @@ -7,18 +7,122 @@ use libadwaita as adw; /// Fader travel in UI units; a gain is `value / FADER_MAX`. pub const FADER_MAX: f64 = 100.0; +pub const LEVEL_CSS: &str = " + button.pd-mute:checked, + .pd-master.pd-muted { color: @error_color; } + button.pd-mute:checked { background-color: alpha(@error_color, 0.14); } + button.pd-mute:checked:hover { background-color: alpha(@error_color, 0.22); } +"; + +fn speaker_icon(muted: bool) -> &'static str { + if muted { + "audio-volume-muted-symbolic" + } else { + "audio-volume-high-symbolic" + } +} + pub fn mute_button(muted: bool, tooltip: &str) -> gtk::ToggleButton { let button = gtk::ToggleButton::new(); - button.set_icon_name("audio-volume-muted-symbolic"); - button.set_tooltip_text(Some(tooltip)); button.add_css_class("flat"); button.add_css_class("circular"); + button.add_css_class("pd-mute"); + let mute_tip = tooltip.to_owned(); + let unmute_tip = tooltip + .strip_prefix("Mute") + .map(|rest| format!("Unmute{rest}")) + .unwrap_or_else(|| "Unmute".to_owned()); + button.set_icon_name(speaker_icon(muted)); + button.set_tooltip_text(Some(if muted { &unmute_tip } else { &mute_tip })); button.set_active(muted); + button.connect_toggled(move |button| { + button.set_icon_name(speaker_icon(button.is_active())); + button.set_tooltip_text(Some(if button.is_active() { + &unmute_tip + } else { + &mute_tip + })); + }); button } -/// A meter bar, thin enough to sit under a fader without crowding it. -pub use crate::meter_fader::{Meter, MeterFader}; +/// The master controls stay outside the card's settings button, so dragging +/// a slider neither opens settings nor starts a card reorder. +pub struct MasterControl { + pub root: gtk::Box, + scale: gtk::Scale, + mute: gtk::ToggleButton, + syncing: std::rc::Rc>, +} + +impl MasterControl { + pub fn new( + state: pipedeck_engine::ChainState, + name: &str, + on_gain: impl Fn(f32) + 'static, + on_mute: impl Fn(bool) + 'static, + ) -> Self { + let root = gtk::Box::new(gtk::Orientation::Horizontal, 4); + root.add_css_class("pd-master"); + root.set_margin_start(6); + root.set_margin_end(8); + root.set_margin_bottom(4); + let mute = mute_button(state.muted, &format!("Mute {name}")); + let scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, FADER_MAX, 1.0); + scale.set_hexpand(true); + scale.set_draw_value(true); + scale.set_value_pos(gtk::PositionType::Right); + scale.set_digits(0); + scale.set_format_value_func(|_, value| format!("{value:.0}%")); + scale.set_tooltip_text(Some(&format!("{name} master volume"))); + root.append(&mute); + root.append(&scale); + let syncing = std::rc::Rc::new(std::cell::Cell::new(false)); + scale.connect_value_changed({ + let syncing = syncing.clone(); + move |scale| { + if !syncing.get() { + on_gain((scale.value() / FADER_MAX) as f32); + } + } + }); + mute.connect_toggled({ + let syncing = syncing.clone(); + move |button| { + if !syncing.get() { + on_mute(button.is_active()); + } + } + }); + let control = Self { + root, + scale, + mute, + syncing, + }; + control.set_state(state); + control + } + + /// Engine notifications must not echo back as new volume commands. + pub fn set_state(&self, state: pipedeck_engine::ChainState) { + self.syncing.set(true); + self.scale.set_value(f64::from(state.gain) * FADER_MAX); + self.mute.set_active(state.muted); + self.syncing.set(false); + let level = format!("Master {:.0}%", state.gain * 100.0); + if state.muted { + self.root.add_css_class("pd-muted"); + self.root + .set_tooltip_text(Some(&format!("{level} (muted)"))); + } else { + self.root.remove_css_class("pd-muted"); + self.root.set_tooltip_text(Some(&level)); + } + } +} + +pub use crate::meter_fader::MeterFader; /// The mute and the fader side by side. pub fn level_row(mute: >k::ToggleButton, fader: &impl IsA) -> gtk::Box { diff --git a/crates/pipedeck/src/window.rs b/crates/pipedeck/src/window.rs index 01c7b48..a53ebc0 100644 --- a/crates/pipedeck/src/window.rs +++ b/crates/pipedeck/src/window.rs @@ -29,10 +29,10 @@ use crate::widgets; const MIX_COLUMN_WIDTH: i32 = 240; const SOURCE_COLUMN_WIDTH: i32 = 180; -const ROW_HEIGHT: i32 = 56; +const ROW_HEIGHT: i32 = 80; /// A sub-track is a person of a call: a lighter row under its channel. const VOICE_ROW_HEIGHT: i32 = 44; -const MIX_HEADER_HEIGHT: i32 = 72; +const MIX_HEADER_HEIGHT: i32 = 80; const BADGE_ICON_SIZE: i32 = 16; const ADD_MIX_WIDTH: i32 = 56; @@ -63,6 +63,8 @@ pub struct Window { /// Last peak of every channel, so a cell can draw what it passes on. channel_levels: RefCell>, cells: RefCell>, + source_masters: RefCell>, + mix_masters: RefCell>, /// The object windows, while they are open, so engine changes reach them. mix_dialog: RefCell>>, channel_dialog: RefCell>>, @@ -154,6 +156,8 @@ impl Window { preferences: RefCell::new(None), channel_levels: RefCell::new(HashMap::new()), cells: RefCell::new(HashMap::new()), + source_masters: RefCell::new(HashMap::new()), + mix_masters: RefCell::new(HashMap::new()), mix_dialog: RefCell::new(None), channel_dialog: RefCell::new(None), expanded: RefCell::new(HashSet::new()), @@ -369,6 +373,9 @@ impl Window { /// A level can move outside the mixer: a media key, a volume applet, /// anything holding the same sink. fn mix_changed(&self, id: MixId, state: ChainState) { + if let Some(summary) = self.mix_masters.borrow().get(&id) { + summary.set_state(state); + } if let Some(mix) = self .state .borrow_mut() @@ -386,6 +393,9 @@ impl Window { } fn source_changed(&self, id: SourceId, state: ChainState) { + if let Some(summary) = self.source_masters.borrow().get(&id) { + summary.set_state(state); + } if let Some(source) = self .state .borrow_mut() @@ -454,6 +464,8 @@ impl Window { self.grid.remove(&child); } self.cells.borrow_mut().clear(); + self.source_masters.borrow_mut().clear(); + self.mix_masters.borrow_mut().clear(); self.voice_faders.borrow_mut().clear(); let state = self.state.borrow(); @@ -706,7 +718,7 @@ impl Window { labels.append(&title); // The outputs it plays to now, not those switched off in its list. let playing = mix.outputs.iter().filter(|output| output.enabled).count(); - let subtitle = gtk::Label::new(Some(&output_label(playing))); + let subtitle = gtk::Label::new(Some(&format!("Master ยท {}", output_label(playing)))); subtitle.add_css_class("caption"); subtitle.add_css_class("dim-label"); subtitle.set_xalign(0.0); @@ -714,7 +726,23 @@ impl Window { labels.append(&subtitle); content.append(&labels); - let card = clickable_card(&content, MIX_COLUMN_WIDTH, MIX_HEADER_HEIGHT); + let master = widgets::MasterControl::new( + mix.state(), + &mix.name, + { + let engine = self.engine.clone(); + let id = mix.id; + move |gain| engine.send(Command::SetMixGain { id, gain }) + }, + { + let engine = self.engine.clone(); + let id = mix.id; + move |muted| engine.send(Command::SetMixMute { id, muted }) + }, + ); + let (container, card) = + master_card(&content, &master.root, MIX_COLUMN_WIDTH, MIX_HEADER_HEIGHT); + self.mix_masters.borrow_mut().insert(mix.id, master); card.set_tooltip_text(Some("Rename this mix, choose its outputs, or remove it")); card.connect_clicked({ let this = self.clone(); @@ -735,7 +763,8 @@ impl Window { ear.add_css_class("circular"); ear.set_active(listening); ear.set_halign(gtk::Align::End); - ear.set_valign(gtk::Align::Center); + ear.set_valign(gtk::Align::Start); + ear.set_margin_top(10); ear.set_margin_end(10); ear.set_tooltip_text(Some(if listening { "Heard in your headphones; click to stop" @@ -754,7 +783,7 @@ impl Window { }); let overlay = gtk::Overlay::new(); - overlay.set_child(Some(&card)); + overlay.set_child(Some(&container)); overlay.add_overlay(&ear); overlay.set_margin_bottom(6); // Dropped on the overlay rather than the card, or a drop landing on @@ -786,16 +815,41 @@ impl Window { title.set_hexpand(true); title.set_xalign(0.0); title.set_ellipsize(gtk::pango::EllipsizeMode::End); - content.append(&title); + let labels = gtk::Box::new(gtk::Orientation::Vertical, 2); + labels.set_hexpand(true); + labels.set_valign(gtk::Align::Center); + labels.append(&title); + let subtitle = gtk::Label::new(Some("Master")); + subtitle.add_css_class("caption"); + subtitle.add_css_class("dim-label"); + subtitle.set_xalign(0.0); + labels.append(&subtitle); + content.append(&labels); - let card = clickable_card(&content, SOURCE_COLUMN_WIDTH, ROW_HEIGHT); + let master = widgets::MasterControl::new( + source.state(), + &source.name, + { + let engine = self.engine.clone(); + let id = source.id; + move |gain| engine.send(Command::SetSourceGain { id, gain }) + }, + { + let engine = self.engine.clone(); + let id = source.id; + move |muted| engine.send(Command::SetSourceMute { id, muted }) + }, + ); + let (container, card) = + master_card(&content, &master.root, SOURCE_COLUMN_WIDTH, ROW_HEIGHT); + self.source_masters.borrow_mut().insert(source.id, master); card.set_tooltip_text(Some("Rename this channel, set its levels, or remove it")); card.connect_clicked({ let this = self.clone(); let id = source.id; move |_| this.open_channel_dialog(id) }); - reorderable(&card, &card, "source", source.id.0, index, { + reorderable(&card, &container, "source", source.id.0, index, { let engine = self.engine.clone(); move |dragged, to| { engine.send(Command::MoveSource { @@ -808,7 +862,7 @@ impl Window { // A channel carrying a call unfolds into the people in it. let people = source.voices.iter().filter(|voice| voice.present).count(); if people == 0 { - return card.upcast(); + return container.upcast(); } let open = self.expanded.borrow().contains(&source.id); let unfold = gtk::Button::new(); @@ -825,7 +879,8 @@ impl Window { unfold.add_css_class("flat"); unfold.add_css_class("caption"); unfold.set_halign(gtk::Align::End); - unfold.set_valign(gtk::Align::Center); + unfold.set_valign(gtk::Align::Start); + unfold.set_margin_top(10); unfold.set_margin_end(6); unfold.set_tooltip_text(Some(if open { "Fold the people of the call away" @@ -846,7 +901,7 @@ impl Window { } }); let overlay = gtk::Overlay::new(); - overlay.set_child(Some(&card)); + overlay.set_child(Some(&container)); overlay.add_overlay(&unfold); overlay.upcast() } @@ -1086,25 +1141,36 @@ fn badge_slot(badge: >k::Image) -> gtk::Overlay { slot } -fn clickable_card(content: >k::Box, width: i32, height: i32) -> gtk::Button { +/// The title opens settings and provides the drag handle. Master controls +/// are siblings, keeping their pointer and keyboard interaction independent. +fn master_card( + content: >k::Box, + master: >k::Box, + width: i32, + height: i32, +) -> (gtk::Box, gtk::Button) { content.set_margin_top(8); - content.set_margin_bottom(8); + content.set_margin_bottom(2); content.set_margin_start(10); content.set_margin_end(10); let card = gtk::Button::new(); - card.add_css_class("card"); card.add_css_class("flat"); card.add_css_class("pd-card"); card.set_child(Some(content)); - card.set_width_request(width); - card.set_height_request(height); - card + let container = gtk::Box::new(gtk::Orientation::Vertical, 0); + container.add_css_class("card"); + container.set_width_request(width); + container.set_height_request(height); + container.append(&card); + container.append(master); + (container, card) } /// Install the stylesheet. Call once, after GTK is initialised. pub fn load_css() { const CSS: &str = " + .pd-card { padding: 0; } .pd-card .pd-hover-tools { opacity: 0; transition: opacity 120ms ease-out; } .pd-card .pd-badge-face { transition: opacity 120ms ease-out; } .pd-card:hover .pd-badge-face, @@ -1134,8 +1200,9 @@ pub fn load_css() { "; let provider = gtk::CssProvider::new(); provider.load_from_string(&format!( - "{CSS}{}{}", + "{CSS}{}{}{}", crate::meter_fader::CSS, + widgets::LEVEL_CSS, presets::css() )); if let Some(display) = gtk::gdk::Display::default() { @@ -1163,6 +1230,159 @@ fn output_label(count: usize) -> String { #[cfg(test)] mod tests { + #[test] + #[ignore = "requires a GTK display; run on the Broadway backend"] + fn external_levels_update_visible_controls() { + use super::*; + use pipedeck_engine::LinkConfig; + + adw::init().expect("GTK display available"); + load_css(); + let app = adw::Application::builder() + .application_id("dev._2c2t.PipedeckUiTest") + .flags(gtk::gio::ApplicationFlags::NON_UNIQUE) + .build(); + app.register(None::<>k::gio::Cancellable>).unwrap(); + let path = std::env::temp_dir().join(format!("pipedeck-ui-{}.toml", std::process::id())); + let engine = EngineLink::new(pipedeck_engine::spawn(path, |_| {})); + let window = Window::new(&app, engine.clone()); + let (source, mix) = (SourceId(1), MixId(1)); + window.handle_event(Event::State(StateSnapshot { + latency: "512/48000".into(), + stereotool_license: None, + listen_device: None, + mixes: vec![MixConfig::new(mix, "Personal")], + sources: vec![SourceConfig::virtual_sink(source, "Music")], + links: vec![LinkConfig::new(source, mix)], + sounds: vec![], + soundboard: None, + })); + let cell = window.cells.borrow()[&(source, mix)].root.clone(); + fn find>(root: >k::Widget) -> Option { + if let Ok(widget) = root.clone().downcast::() { + return Some(widget); + } + let mut child = root.first_child(); + while let Some(widget) = child { + if let Some(found) = find::(&widget) { + return Some(found); + } + child = widget.next_sibling(); + } + None + } + let slider = find::(cell.upcast_ref()).unwrap(); + let mute = find::(cell.upcast_ref()).unwrap(); + window.handle_event(Event::SourceChanged { + id: source, + state: ChainState { + gain: 0.41, + muted: true, + }, + }); + let summaries = window.source_masters.borrow(); + let summary = &summaries[&source]; + assert_eq!( + summary.root.tooltip_text().as_deref(), + Some("Master 41% (muted)") + ); + assert!(summary.root.has_css_class("pd-muted")); + let source_slider = find::(summary.root.upcast_ref()).unwrap(); + let source_mute = find::(summary.root.upcast_ref()).unwrap(); + assert!((source_slider.value() - 41.0).abs() < 0.001); + assert!(source_mute.is_active()); + // Sliders live beside the settings button, never inside it. + let mut parent = summary.root.parent(); + while let Some(widget) = parent { + assert!(!widget.is::()); + parent = widget.parent(); + } + drop(summaries); + // Master trim must not overwrite independent per-mix balances. + assert_eq!(slider.value(), 100.0); + window.handle_event(Event::MixChanged { + id: mix, + state: ChainState { + gain: 0.63, + muted: false, + }, + }); + assert_eq!( + window.mix_masters.borrow()[&mix] + .root + .tooltip_text() + .as_deref(), + Some("Master 63%") + ); + let mix_slider = + find::(window.mix_masters.borrow()[&mix].root.upcast_ref()).unwrap(); + assert!((mix_slider.value() - 63.0).abs() < 0.001); + assert!((source_slider.value() - 41.0).abs() < 0.001); + window.handle_event(Event::LinkChanged { + source, + mix, + state: ChainState { + gain: 0.37, + muted: true, + }, + }); + assert!((slider.value() - 37.0).abs() < 0.001); + assert!(mute.is_active()); + assert_eq!( + mute.icon_name().as_deref(), + Some("audio-volume-muted-symbolic") + ); + assert!(mute.has_css_class("pd-mute")); + window.handle_event(Event::LinkChanged { + source, + mix, + state: ChainState { + gain: 0.37, + muted: false, + }, + }); + assert!(!mute.is_active()); + assert_eq!( + mute.icon_name().as_deref(), + Some("audio-volume-high-symbolic") + ); + assert_eq!(window.cells.borrow()[&(source, mix)].root, cell); + // Local interaction sends gain/mute once; engine feedback sends none. + let gains = Rc::new(RefCell::new(Vec::new())); + let mutes = Rc::new(RefCell::new(Vec::new())); + let master = widgets::MasterControl::new( + ChainState { + gain: 0.5, + muted: false, + }, + "Test", + { + let gains = gains.clone(); + move |gain| gains.borrow_mut().push(gain) + }, + { + let mutes = mutes.clone(); + move |muted| mutes.borrow_mut().push(muted) + }, + ); + master.set_state(ChainState { + gain: 0.25, + muted: true, + }); + assert!(gains.borrow().is_empty()); + assert!(mutes.borrow().is_empty()); + find::(master.root.upcast_ref()) + .unwrap() + .set_value(72.0); + find::(master.root.upcast_ref()) + .unwrap() + .set_active(false); + assert_eq!(&*gains.borrow(), &[0.72]); + assert_eq!(&*mutes.borrow(), &[false]); + engine.shutdown(); + window.window.close(); + } + #[test] fn a_column_is_never_dropped_among_rows() { let mix = super::drag_payload("mix", 3);