diff --git a/crates/headroom-core/src/pw/registry.rs b/crates/headroom-core/src/pw/registry.rs
index 32631e8..c271c60 100644
--- a/crates/headroom-core/src/pw/registry.rs
+++ b/crates/headroom-core/src/pw/registry.rs
@@ -127,6 +127,18 @@ struct PendingRoute {
route: Route,
}
+/// The daemon-owned routing links for one stream, paired with the
+/// target sink name they were built for. Stored in
+/// `RoutingState::managed_route_links` so the bypass / profile
+/// re-evaluation paths can ask "is the stream already routed to
+/// this sink?" without losing the existing live links to find out
+/// — keeping them alive avoids the audio gap the destroy+rebuild
+/// cycle otherwise introduces.
+struct ManagedRoute {
+ target_sink_name: String,
+ links: Vec,
+}
+
/// Subject id passed to `set_property` for keys that aren't bound to
/// a specific node (system-wide settings like `default.audio.sink`).
const METADATA_SUBJECT_GLOBAL: u32 = 0;
@@ -192,8 +204,14 @@ pub struct RoutingState {
pending_routes: HashMap,
/// Explicit `link-factory` `Link` proxies owned by the daemon,
/// keyed by source stream node id. Kept alive so the links
- /// persist; dropped on stream removal or route change.
- managed_route_links: HashMap>,
+ /// persist; dropped on stream removal or *target-changing*
+ /// route change. The accompanying `target_sink_name` lets
+ /// `enqueue_route` skip the drop when a re-evaluation arrives
+ /// at the same target (the common case for profile.reload
+ /// / route.unset on an unaffected stream) — avoiding the
+ /// 21-42 ms audio gap that an unconditional drop+rebuild
+ /// would cause.
+ managed_route_links: HashMap,
/// Window-based limit for `default.audio.sink` re-assertions
/// so a hostile WP policy can't pull us into a hot loop.
/// `(window_start, attempts_in_window)`. See
@@ -1023,11 +1041,25 @@ impl RoutingState {
app_label: String,
route: Route,
) {
- // Replacing intent: drop any old managed links for this
- // stream so apply_pending_routes can rebuild against the new
- // target. Dropping the proxies destroys the links via
- // `object.linger = "false"`.
- self.managed_route_links.remove(&node_id);
+ // If we already have live links for this stream pointing
+ // at the *same* sink, leave them alone. `apply_pending_routes`
+ // will run its idempotent destroy/create pass on the next
+ // drain tick and find the want_set already satisfied — a
+ // no-op. This is the common case for profile.reload /
+ // route.set / route.unset / profile.use when the change
+ // doesn't move *this* stream, and skipping the drop avoids
+ // the 21-42 ms audio gap an unconditional rebuild would
+ // cost. When the target *did* change (bypass toggle,
+ // real-sink hot-swap, or rule edit that flipped this
+ // stream's decision), drop the proxies so the old links
+ // tear down before the new ones come up.
+ let already_at_target = self
+ .managed_route_links
+ .get(&node_id)
+ .is_some_and(|m| m.target_sink_name == target_sink_name);
+ if !already_at_target {
+ self.managed_route_links.remove(&node_id);
+ }
self.pending_routes.insert(
node_id,
PendingRoute {
@@ -1156,6 +1188,7 @@ impl RoutingState {
let mut created: Vec = self
.managed_route_links
.remove(&node_id)
+ .map(|m| m.links)
.unwrap_or_default();
let mut all_ok = true;
for (out_port, in_port) in &want {
@@ -1179,7 +1212,13 @@ impl RoutingState {
}
}
if !created.is_empty() {
- self.managed_route_links.insert(node_id, created);
+ self.managed_route_links.insert(
+ node_id,
+ ManagedRoute {
+ target_sink_name: intent.target_sink_name.clone(),
+ links: created,
+ },
+ );
}
if all_ok {
tracing::info!(
diff --git a/crates/headroom-dsp/src/compressor.rs b/crates/headroom-dsp/src/compressor.rs
index a38f08d..906fd96 100644
--- a/crates/headroom-dsp/src/compressor.rs
+++ b/crates/headroom-dsp/src/compressor.rs
@@ -117,11 +117,23 @@ impl Compressor {
self.last_gr_db
}
- /// Update parameters. Recomputes alphas. Envelope state is kept,
- /// so live tweaks don't pop.
+ /// Update parameters. Recomputes alphas. Envelope state is kept
+ /// across same-enabled transitions so live tweaks don't pop, but
+ /// reset on a `disabled → enabled` transition so a stale
+ /// envelope from before the disable doesn't bleed out at the
+ /// release time-constant when processing resumes (otherwise
+ /// switching from a `transparent` profile back to a compressing
+ /// one would briefly duck on the first ~100 ms of audio for no
+ /// reason).
pub fn set_config(&mut self, cfg: CompressorConfig) {
let cfg = cfg.sanitized();
+ let was_disabled = !self.cfg.enabled;
self.cfg = cfg;
+ if was_disabled && self.cfg.enabled {
+ self.envelope_db = -200.0;
+ self.rms_state = 0.0;
+ self.last_gr_db = 0.0;
+ }
self.attack_alpha = time_to_alpha(cfg.attack_ms, self.sample_rate);
self.release_alpha = time_to_alpha(cfg.release_ms, self.sample_rate);
self.rms_alpha = time_to_alpha(cfg.rms_window_ms, self.sample_rate);
@@ -295,6 +307,56 @@ mod tests {
assert_eq!(c.gain_reduction_db(), 0.0);
}
+ #[test]
+ fn enable_transition_resets_stale_envelope() {
+ // Run a loud signal through an enabled compressor to wind
+ // the envelope up, then disable + re-enable via set_config.
+ // The first sample after re-enable must NOT see the stale
+ // envelope (which would otherwise duck the signal until
+ // release_ms wound it down). Concretely: with a quiet input
+ // after re-enable, the envelope should be at the floor, so
+ // GR is zero — same as a freshly-constructed compressor.
+ let loud_cfg = CompressorConfig {
+ enabled: true,
+ threshold_db: -20.0,
+ ratio: 4.0,
+ attack_ms: 0.1,
+ release_ms: 1000.0, // slow release so stale state would otherwise stick
+ knee_db: 0.0,
+ makeup_db: Some(0.0),
+ ..CompressorConfig::default()
+ };
+ let mut c = Compressor::new(loud_cfg, 48_000.0);
+ // Drive hot signal to wind envelope up.
+ for _ in 0..2_000 {
+ c.process_frame(0.5, 0.5);
+ }
+ assert!(
+ c.gain_reduction_db() < -5.0,
+ "precondition: envelope should be wound up; gr={}",
+ c.gain_reduction_db()
+ );
+
+ // Disable, then re-enable — should reset.
+ let mut disabled_cfg = loud_cfg;
+ disabled_cfg.enabled = false;
+ c.set_config(disabled_cfg);
+ c.set_config(loud_cfg);
+
+ // Now drive a quiet signal. With reset envelope, GR should
+ // ride near zero; without reset, the stale envelope would
+ // bleed gain reduction out over ~release_ms.
+ let (l, r) = c.process_frame(0.001, 0.001);
+ assert!(
+ c.gain_reduction_db().abs() < 0.01,
+ "envelope didn't reset across enable transition; gr={}",
+ c.gain_reduction_db()
+ );
+ // Output should be quiet (within makeup-applied scale).
+ assert!(l.abs() < 0.01);
+ assert!(r.abs() < 0.01);
+ }
+
#[test]
fn static_curve_at_threshold_with_soft_knee() {
// At exactly threshold, soft knee contributes exactly half the