Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
194 changes: 194 additions & 0 deletions crates/pipedeck-engine/examples/smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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<Event>,
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();
}
45 changes: 41 additions & 4 deletions crates/pipedeck-engine/src/pw/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
}
}
}
Expand Down Expand Up @@ -632,6 +639,7 @@ impl Graph {
node: None,
_module: module,
wanted: output.state(),
master: ChainState::default(),
}),
Err(e) => {
log::error!("{e}");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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(())
Expand Down Expand Up @@ -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,
Expand All @@ -1834,6 +1870,7 @@ impl Graph {
node: None,
_module: module,
wanted: link.state(),
master,
},
);
log::info!("{} feeds {}", source.name, mix.name);
Expand Down
Loading