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