atagen

headroom

public
Star0
Watch0

Commit 9de515ea3b07

tarball

atagen <boss@atagen.co>2026-06-04 03:27 UTC

19 files changed+2800-0
profiles: store, hot-reload, and shipped catalog
Added file: crates/headroom-core/src/profile.rs+496 additions
@@ -0,0 +1,496 @@
1+//! profile types; mirrors the toml schema
2+
3+use serde::{Deserialize, Serialize};
4+
5+use headroom_dsp::{CompressorConfig, Detector, LimiterConfig, SoftTierConfig};
6+
7+/// serde-capable mirror of [`Detector`]; keeps `headroom-dsp` dep-free.
8+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
9+#[serde(rename_all = "lowercase")]
10+pub enum DetectorChoice {
11+ #[default]
12+ Peak,
13+ Rms,
14+}
15+
16+impl From<DetectorChoice> for Detector {
17+ fn from(c: DetectorChoice) -> Self {
18+ match c {
19+ DetectorChoice::Peak => Detector::Peak,
20+ DetectorChoice::Rms => Detector::Rms,
21+ }
22+ }
23+}
24+use headroom_ipc::{Route, RouteRule, RouteRuleMatch};
25+
26+#[derive(Debug, Clone, Serialize, Deserialize)]
27+pub struct Profile {
28+ /// unique within the profiles directory
29+ pub name: String,
30+ pub description: String,
31+
32+ #[serde(default)]
33+ pub agc: AgcSection,
34+ #[serde(default)]
35+ pub compressor: CompressorSection,
36+ #[serde(default)]
37+ pub limiter: LimiterSection,
38+ #[serde(default)]
39+ pub meters: MetersSection,
40+
41+ /// evaluated in order; first match wins
42+ #[serde(default)]
43+ pub rules: Vec<RouteRule>,
44+ #[serde(default)]
45+ pub default_route: DefaultRouteSection,
46+
47+ #[serde(default)]
48+ pub per_app: PerAppSection,
49+}
50+
51+impl Profile {
52+ /// mirrors the `default.toml` shipped profile.
53+ #[must_use]
54+ pub fn default_v0() -> Self {
55+ Self {
56+ name: "default".into(),
57+ description: "Gentle transparent processing for everyday use.".into(),
58+ agc: AgcSection::default(),
59+ compressor: CompressorSection::default(),
60+ limiter: LimiterSection::default(),
61+ meters: MetersSection::default(),
62+ rules: vec![
63+ RouteRule {
64+ match_: RouteRuleMatch {
65+ process_binary: vec![
66+ "spotify".into(),
67+ "mpv".into(),
68+ "vlc".into(),
69+ "ardour".into(),
70+ "reaper".into(),
71+ "qpwgraph".into(),
72+ "carla".into(),
73+ "bitwig-studio".into(),
74+ ],
75+ ..Default::default()
76+ },
77+ route: Route::Bypass,
78+ },
79+ RouteRule {
80+ match_: RouteRuleMatch {
81+ process_binary: vec![
82+ "firefox".into(),
83+ "chromium".into(),
84+ "google-chrome".into(),
85+ "Discord".into(),
86+ "discord".into(),
87+ "element-desktop".into(),
88+ "Slack".into(),
89+ "zoom".into(),
90+ "WEBRTC VoiceEngine".into(),
91+ ],
92+ ..Default::default()
93+ },
94+ route: Route::Processed,
95+ },
96+ ],
97+ default_route: DefaultRouteSection {
98+ route: Route::Processed,
99+ },
100+ per_app: PerAppSection::default(),
101+ }
102+ }
103+
104+ #[must_use]
105+ pub fn build_limiter_config(&self) -> LimiterConfig {
106+ let soft = self.limiter.soft.as_ref().map(|s| SoftTierConfig {
107+ max_psr_db: s.max_psr_db,
108+ static_ceiling_dbtp: s.static_ceiling_dbtp,
109+ attack_ms: s.attack_ms,
110+ release_ms: s.release_ms,
111+ });
112+ LimiterConfig {
113+ ceiling_dbtp: self.limiter.ceiling_dbtp,
114+ lookahead_ms: self.limiter.lookahead_ms,
115+ release_ms: self.limiter.release_ms,
116+ hold_ms: self.limiter.hold_ms,
117+ oversample: self.limiter.oversample,
118+ fir_taps: 31,
119+ soft,
120+ }
121+ .sanitized()
122+ }
123+
124+ #[must_use]
125+ pub fn build_compressor_config(&self) -> CompressorConfig {
126+ let makeup_db = match self.compressor.makeup_db {
127+ MakeupGain::Auto => None,
128+ MakeupGain::Db(v) => Some(v),
129+ };
130+ CompressorConfig {
131+ enabled: self.compressor.enabled,
132+ threshold_db: self.compressor.threshold_db,
133+ ratio: self.compressor.ratio,
134+ knee_db: self.compressor.knee_db,
135+ attack_ms: self.compressor.attack_ms,
136+ release_ms: self.compressor.release_ms,
137+ makeup_db,
138+ detector: self.compressor.detector.into(),
139+ rms_window_ms: self.compressor.rms_window_ms,
140+ }
141+ .sanitized()
142+ }
143+}
144+
145+#[derive(Debug, Clone, Serialize, Deserialize)]
146+#[serde(default)]
147+pub struct AgcSection {
148+ pub enabled: bool,
149+ pub target_lufs: f32,
150+ pub attack_ms: f32,
151+ pub release_ms: f32,
152+ /// below this momentary loudness the agc stops adjusting
153+ pub silence_threshold_lufs: f32,
154+ pub max_boost_db: f32,
155+ pub max_cut_db: f32,
156+}
157+
158+impl Default for AgcSection {
159+ fn default() -> Self {
160+ Self {
161+ enabled: true,
162+ target_lufs: -18.0,
163+ attack_ms: 2000.0,
164+ release_ms: 800.0,
165+ silence_threshold_lufs: -70.0,
166+ max_boost_db: 12.0,
167+ max_cut_db: 12.0,
168+ }
169+ }
170+}
171+
172+#[derive(Debug, Clone, Serialize, Deserialize)]
173+#[serde(default)]
174+pub struct CompressorSection {
175+ pub enabled: bool,
176+ pub detector: DetectorChoice,
177+ pub threshold_db: f32,
178+ pub ratio: f32,
179+ pub knee_db: f32,
180+ pub attack_ms: f32,
181+ pub release_ms: f32,
182+ pub makeup_db: MakeupGain,
183+ /// ignored when `detector == Peak`
184+ pub rms_window_ms: f32,
185+}
186+
187+impl Default for CompressorSection {
188+ fn default() -> Self {
189+ Self {
190+ enabled: true,
191+ detector: DetectorChoice::Peak,
192+ threshold_db: -24.0,
193+ ratio: 2.5,
194+ knee_db: 6.0,
195+ attack_ms: 10.0,
196+ release_ms: 100.0,
197+ makeup_db: MakeupGain::Auto,
198+ rms_window_ms: 5.0,
199+ }
200+ }
201+}
202+
203+/// de/serialize hand-rolled: derived `#[serde(untagged)]` can't deserialize unit `Auto` from
204+/// `"auto"` (untagged matches unit variants against null, not a name), so every shipped profile
205+/// with `makeup_db = "auto"` silently failed to parse under toml and got skipped.
206+#[derive(Debug, Clone, Copy, Default)]
207+pub enum MakeupGain {
208+ Db(f32),
209+ /// compute conservative auto-makeup from threshold and ratio
210+ #[default]
211+ Auto,
212+}
213+
214+impl Serialize for MakeupGain {
215+ fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
216+ match self {
217+ MakeupGain::Db(v) => ser.serialize_f32(*v),
218+ MakeupGain::Auto => ser.serialize_str("auto"),
219+ }
220+ }
221+}
222+
223+impl<'de> Deserialize<'de> for MakeupGain {
224+ fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
225+ #[derive(Deserialize)]
226+ #[serde(untagged)]
227+ enum Repr {
228+ Num(f32),
229+ Str(String),
230+ }
231+ match Repr::deserialize(de)? {
232+ Repr::Num(v) => Ok(MakeupGain::Db(v)),
233+ Repr::Str(s) if s.eq_ignore_ascii_case("auto") => Ok(MakeupGain::Auto),
234+ Repr::Str(s) => Err(serde::de::Error::custom(format!(
235+ "invalid makeup_db {s:?}: expected a number or \"auto\""
236+ ))),
237+ }
238+ }
239+}
240+
241+#[derive(Debug, Clone, Serialize, Deserialize)]
242+#[serde(default)]
243+pub struct LimiterSection {
244+ pub ceiling_dbtp: f32,
245+ pub lookahead_ms: f32,
246+ pub release_ms: f32,
247+ pub hold_ms: f32,
248+ /// 1/2/4/8
249+ pub oversample: usize,
250+ pub link: LinkMode,
251+ /// omit for pure brickwall
252+ pub soft: Option<LimiterSoftSection>,
253+}
254+
255+impl Default for LimiterSection {
256+ fn default() -> Self {
257+ Self {
258+ ceiling_dbtp: -0.1,
259+ lookahead_ms: 2.0,
260+ release_ms: 80.0,
261+ hold_ms: 5.0,
262+ oversample: 4,
263+ link: LinkMode::Stereo,
264+ soft: Some(LimiterSoftSection::default()),
265+ }
266+ }
267+}
268+
269+#[derive(Debug, Clone, Serialize, Deserialize)]
270+#[serde(default)]
271+pub struct LimiterSoftSection {
272+ /// max peak-to-loudness ratio (dB)
273+ pub max_psr_db: f32,
274+ /// static fallback ceiling (dBTP)
275+ pub static_ceiling_dbtp: f32,
276+ pub attack_ms: f32,
277+ pub release_ms: f32,
278+}
279+
280+impl Default for LimiterSoftSection {
281+ fn default() -> Self {
282+ Self {
283+ max_psr_db: 14.0,
284+ static_ceiling_dbtp: -6.0,
285+ attack_ms: 5.0,
286+ release_ms: 200.0,
287+ }
288+ }
289+}
290+
291+#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
292+#[serde(rename_all = "kebab-case")]
293+pub enum LinkMode {
294+ /// one envelope shared across channels (no image shift)
295+ #[default]
296+ Stereo,
297+ DualMono,
298+}
299+
300+#[derive(Debug, Clone, Serialize, Deserialize)]
301+#[serde(default)]
302+pub struct MetersSection {
303+ /// max publish rate (Hz); server may publish slower
304+ pub publish_hz: f32,
305+}
306+
307+impl Default for MetersSection {
308+ fn default() -> Self {
309+ Self { publish_hz: 20.0 }
310+ }
311+}
312+
313+#[derive(Debug, Clone, Serialize, Deserialize)]
314+#[serde(default)]
315+pub struct DefaultRouteSection {
316+ pub route: Route,
317+}
318+
319+impl Default for DefaultRouteSection {
320+ fn default() -> Self {
321+ Self {
322+ route: Route::Processed,
323+ }
324+ }
325+}
326+
327+/// per-app level control (layer a)
328+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
329+#[serde(default)]
330+pub struct PerAppSection {
331+ pub enabled: bool,
332+ /// default state for streams matched by no rule
333+ pub default_enabled: bool,
334+ pub rules: Vec<PerAppRule>,
335+}
336+
337+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
338+pub struct PerAppRule {
339+ #[serde(rename = "match", default)]
340+ pub match_: RouteRuleMatch,
341+ #[serde(default = "default_true")]
342+ pub enabled: bool,
343+ #[serde(default = "default_peak_threshold_db")]
344+ pub peak_threshold_db: f32,
345+ #[serde(default = "default_rms_target_db")]
346+ pub rms_target_db: f32,
347+ #[serde(default = "default_max_cut_db")]
348+ pub max_cut_db: f32,
349+ #[serde(default = "default_peak_attack_ms")]
350+ pub peak_attack_ms: f32,
351+ #[serde(default = "default_peak_release_ms")]
352+ pub peak_release_ms: f32,
353+ #[serde(default = "default_rms_window_ms")]
354+ pub rms_window_ms: f32,
355+ /// damps switching between peak-path and rms-path winning post-combine
356+ #[serde(default = "default_smoother_ms")]
357+ pub smoother_ms: f32,
358+ /// below this dB change, smoother updates internally but no `Props.channelVolumes` write
359+ #[serde(default = "default_write_db_threshold")]
360+ pub write_db_threshold: f32,
361+ /// hard per-stream rate limit
362+ #[serde(default = "default_min_write_interval_ms")]
363+ pub min_write_interval_ms: f32,
364+ #[serde(default)]
365+ pub defer_to_user: DeferPolicy,
366+}
367+
368+impl PerAppRule {
369+ /// synthesises a per-app enable/disable override for an app with no authored rule
370+ /// (see `profile_store::apply_per_app_overlay`).
371+ #[must_use]
372+ pub fn defaulted(match_: RouteRuleMatch, enabled: bool) -> Self {
373+ Self {
374+ match_,
375+ enabled,
376+ peak_threshold_db: default_peak_threshold_db(),
377+ rms_target_db: default_rms_target_db(),
378+ max_cut_db: default_max_cut_db(),
379+ peak_attack_ms: default_peak_attack_ms(),
380+ peak_release_ms: default_peak_release_ms(),
381+ rms_window_ms: default_rms_window_ms(),
382+ smoother_ms: default_smoother_ms(),
383+ write_db_threshold: default_write_db_threshold(),
384+ min_write_interval_ms: default_min_write_interval_ms(),
385+ defer_to_user: DeferPolicy::default(),
386+ }
387+ }
388+}
389+
390+const fn default_true() -> bool {
391+ true
392+}
393+const fn default_peak_threshold_db() -> f32 {
394+ -6.0
395+}
396+const fn default_rms_target_db() -> f32 {
397+ -20.0
398+}
399+const fn default_max_cut_db() -> f32 {
400+ 12.0
401+}
402+const fn default_peak_attack_ms() -> f32 {
403+ 5.0
404+}
405+const fn default_peak_release_ms() -> f32 {
406+ 500.0
407+}
408+const fn default_rms_window_ms() -> f32 {
409+ 1500.0
410+}
411+const fn default_smoother_ms() -> f32 {
412+ 30.0
413+}
414+const fn default_write_db_threshold() -> f32 {
415+ 0.5
416+}
417+const fn default_min_write_interval_ms() -> f32 {
418+ 100.0
419+}
420+
421+/// policy when the user changes volume on a managed stream.
422+#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
423+#[serde(rename_all = "lowercase")]
424+pub enum DeferPolicy {
425+ /// user value is a ceiling: keep cutting on spikes, never raise above it
426+ #[default]
427+ Ceiling,
428+ /// stop adjusting entirely until the user opts back in
429+ Strict,
430+}
431+
432+#[cfg(test)]
433+mod tests {
434+ use super::*;
435+
436+ #[test]
437+ fn default_v0_builds_sane_dsp_configs() {
438+ let p = Profile::default_v0();
439+ let lim = p.build_limiter_config();
440+ assert!((lim.ceiling_dbtp - (-0.1)).abs() < 1e-6);
441+ assert_eq!(lim.oversample, 4);
442+ assert!(lim.soft.is_some());
443+
444+ let comp = p.build_compressor_config();
445+ assert!((comp.threshold_db - (-24.0)).abs() < 1e-6);
446+ assert!((comp.ratio - 2.5).abs() < 1e-6);
447+ // Auto-makeup translates to `None`.
448+ assert!(comp.makeup_db.is_none());
449+ }
450+
451+ #[test]
452+ fn default_v0_has_expected_routing_rules() {
453+ let p = Profile::default_v0();
454+ assert_eq!(p.default_route.route, Route::Processed);
455+ // First rule should be the bypass list.
456+ assert_eq!(p.rules[0].route, Route::Bypass);
457+ assert!(p.rules[0].match_.process_binary.iter().any(|s| s == "mpv"));
458+ // Second the processed list.
459+ assert_eq!(p.rules[1].route, Route::Processed);
460+ assert!(p.rules[1]
461+ .match_
462+ .process_binary
463+ .iter()
464+ .any(|s| s == "firefox"));
465+ }
466+
467+ #[test]
468+ fn makeup_gain_serialises_as_string_or_number() {
469+ // Auto serialises to the lowercase string `"auto"` — the same
470+ // token profiles use on disk — and round-trips.
471+ let auto = serde_json::to_string(&MakeupGain::Auto).unwrap();
472+ assert_eq!(auto, "\"auto\"");
473+ let back: MakeupGain = serde_json::from_str(&auto).unwrap();
474+ assert!(matches!(back, MakeupGain::Auto));
475+
476+ let db = serde_json::to_string(&MakeupGain::Db(3.0)).unwrap();
477+ let back: MakeupGain = serde_json::from_str(&db).unwrap();
478+ assert!(matches!(back, MakeupGain::Db(v) if (v - 3.0).abs() < 1e-6));
479+ }
480+
481+ #[test]
482+ fn makeup_gain_parses_auto_from_toml_case_insensitively() {
483+ #[derive(Deserialize)]
484+ struct Holder {
485+ makeup_db: MakeupGain,
486+ }
487+ for tok in ["\"auto\"", "\"Auto\"", "\"AUTO\""] {
488+ let h: Holder = toml::from_str(&format!("makeup_db = {tok}")).unwrap();
489+ assert!(matches!(h.makeup_db, MakeupGain::Auto), "token {tok}");
490+ }
491+ let h: Holder = toml::from_str("makeup_db = -3.5").unwrap();
492+ assert!(matches!(h.makeup_db, MakeupGain::Db(v) if (v + 3.5).abs() < 1e-6));
493+ // A bogus string is a hard error, not a silent skip.
494+ assert!(toml::from_str::<Holder>("makeup_db = \"loud\"").is_err());
495+ }
496+}
Added file: crates/headroom-core/src/profile_store.rs+1368 additions
@@ -0,0 +1,1368 @@
1+//! profile store: disk profiles + persisted overlay → effective profile. overlay persists across
2+//! `profile.use` intentionally (`route.set obs bypass` = "prefer obs bypassed in general")
3+
4+use std::collections::BTreeMap;
5+use std::fs;
6+use std::io;
7+use std::path::{Path, PathBuf};
8+use std::time::{SystemTime, UNIX_EPOCH};
9+
10+use serde::{Deserialize, Serialize};
11+use serde_json::Value;
12+use thiserror::Error;
13+
14+use headroom_ipc::{Route, RouteRule, RouteRuleMatch};
15+
16+use crate::profile::Profile;
17+
18+pub const BUILTIN_DEFAULT_NAME: &str = "default";
19+
20+const OVERLAY_FILE: &str = "overlay.toml";
21+const PROFILES_DIR: &str = "profiles";
22+
23+#[derive(Debug, Clone)]
24+pub struct StoredProfile {
25+ /// matches `profile.name`; file-name mismatch surfaced as a warning on load
26+ pub name: String,
27+ pub source: ProfileSource,
28+ pub profile: Profile,
29+}
30+
31+#[derive(Debug, Clone)]
32+pub enum ProfileSource {
33+ Builtin,
34+ Shipped(PathBuf),
35+ User(PathBuf),
36+}
37+
38+impl ProfileSource {
39+ /// true if a user-authored file can replace this source.
40+ #[must_use]
41+ pub fn is_overridable(&self) -> bool {
42+ !matches!(self, ProfileSource::User(_))
43+ }
44+}
45+
46+/// persisted user choices riding on top of the active profile.
47+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
48+#[serde(default)]
49+pub struct UserOverlay {
50+ /// `None` = built-in default
51+ pub active_name: Option<String>,
52+ /// keyed by `application.process.binary`
53+ pub route_overrides: BTreeMap<String, Route>,
54+ /// `toml::Value` since the overlay is toml on disk; converted to json at materialization
55+ pub setting_overrides: BTreeMap<String, toml::Value>,
56+ /// intentionally persisted across restarts
57+ pub bypass_global: bool,
58+ /// keyed by app label (process_binary or application_name); see [`apply_per_app_overlay`]
59+ #[serde(default)]
60+ pub per_app_overrides: BTreeMap<String, bool>,
61+ /// `None` keeps the profile's `per_app.enabled`; `Some` forces it
62+ #[serde(default, skip_serializing_if = "Option::is_none")]
63+ pub per_app_master: Option<bool>,
64+}
65+
66+#[derive(Debug, Error)]
67+pub enum StoreError {
68+ #[error("profile '{0}' not loaded")]
69+ ProfileNotFound(String),
70+ #[error("setting '{0}' not found in active profile")]
71+ SettingNotFound(String),
72+ #[error("setting '{key}' rejected: {msg}")]
73+ SettingInvalid {
74+ key: String,
75+ msg: String,
76+ },
77+ #[error("no user-set route for '{0}'")]
78+ NoUserRoute(String),
79+ #[error("io: {0}")]
80+ Io(#[from] io::Error),
81+ #[error("overlay parse: {0}")]
82+ OverlayParse(toml::de::Error),
83+ #[error("overlay serialize: {0}")]
84+ OverlaySerialize(toml::ser::Error),
85+}
86+
87+#[derive(Debug, Default, Clone)]
88+pub struct ReloadReport {
89+ pub loaded: Vec<String>,
90+ /// reload is best-effort: a broken file is reported and skipped, not fatal
91+ pub warnings: Vec<String>,
92+}
93+
94+#[derive(Debug, Clone)]
95+pub struct StorePaths {
96+ /// profiles live in `<config_dir>/profiles/`
97+ pub config_dir: PathBuf,
98+ pub state_dir: PathBuf,
99+ /// `profiles/` appended before scanning
100+ pub share_dirs: Vec<PathBuf>,
101+ /// scanned directly (no `profiles/` suffix) and last, so they win on name conflict; backs
102+ /// `--profile-dir`
103+ pub extra_profile_dirs: Vec<PathBuf>,
104+}
105+
106+impl StorePaths {
107+ /// share dirs from `$XDG_DATA_DIRS` so package-installed profiles resolve regardless of prefix
108+ /// (incl. NixOS `/run/current-system/sw/share`).
109+ #[must_use]
110+ pub fn from_env() -> Self {
111+ let home = std::env::var_os("HOME")
112+ .map(PathBuf::from)
113+ .unwrap_or_else(|| PathBuf::from("/"));
114+ let config_root = std::env::var_os("XDG_CONFIG_HOME")
115+ .map(PathBuf::from)
116+ .unwrap_or_else(|| home.join(".config"));
117+ let state_root = std::env::var_os("XDG_STATE_HOME")
118+ .map(PathBuf::from)
119+ .unwrap_or_else(|| home.join(".local").join("state"));
120+ let data_dirs = std::env::var_os("XDG_DATA_DIRS").unwrap_or_default();
121+ Self {
122+ config_dir: config_root.join("headroom"),
123+ state_dir: state_root.join("headroom"),
124+ share_dirs: share_dirs_from_data_dirs(&data_dirs),
125+ extra_profile_dirs: Vec::new(),
126+ }
127+ }
128+}
129+
130+/// each entry gets a `headroom/` subdir; empty falls back to `/usr/local/share:/usr/share`.
131+/// reversed because xdg precedence is first-wins but `load` scans later-shadows-earlier.
132+fn share_dirs_from_data_dirs(data_dirs: &std::ffi::OsStr) -> Vec<PathBuf> {
133+ let value = if data_dirs.is_empty() {
134+ std::ffi::OsString::from("/usr/local/share:/usr/share")
135+ } else {
136+ data_dirs.to_os_string()
137+ };
138+ let mut dirs: Vec<PathBuf> = std::env::split_paths(&value)
139+ .map(|p| p.join("headroom"))
140+ .collect();
141+ dirs.reverse();
142+ dirs
143+}
144+
145+#[derive(Debug)]
146+pub struct ProfileStore {
147+ profiles: BTreeMap<String, StoredProfile>,
148+ overlay: UserOverlay,
149+ /// cached materialization of `profiles[active] + overlay`
150+ effective: Profile,
151+ /// `None` for in-memory stores (tests, `builtin`)
152+ overlay_path: Option<PathBuf>,
153+ /// remembered so `reload` doesn't need them threaded back in
154+ paths: Option<StorePaths>,
155+ /// set when `active_name` isn't on disk; `effective` falls back to builtin meanwhile
156+ active_missing: Option<String>,
157+ /// one-shot warnings for the runtime to drain at startup
158+ pending_warnings: Vec<String>,
159+}
160+
161+impl ProfileStore {
162+ /// in-memory store with only the built-in default.
163+ #[must_use]
164+ pub fn builtin() -> Self {
165+ let builtin = StoredProfile {
166+ name: BUILTIN_DEFAULT_NAME.into(),
167+ source: ProfileSource::Builtin,
168+ profile: Profile::default_v0(),
169+ };
170+ let mut profiles = BTreeMap::new();
171+ profiles.insert(builtin.name.clone(), builtin);
172+ Self {
173+ profiles,
174+ overlay: UserOverlay::default(),
175+ effective: Profile::default_v0(),
176+ overlay_path: None,
177+ paths: None,
178+ active_missing: None,
179+ pending_warnings: Vec::new(),
180+ }
181+ }
182+
183+ /// order: built-in → share dirs → user dir → extra dirs; later shadows earlier by name.
184+ /// per-file parse errors become warnings + skip; only unrecoverable i/o errors out.
185+ pub fn load(paths: &StorePaths) -> Result<Self, StoreError> {
186+ let mut store = Self::builtin();
187+ store.overlay_path = Some(paths.state_dir.join(OVERLAY_FILE));
188+ store.paths = Some(paths.clone());
189+
190+ for share in &paths.share_dirs {
191+ let dir = share.join(PROFILES_DIR);
192+ scan_dir_into(&mut store.profiles, &dir, true, &mut store.pending_warnings);
193+ }
194+ let user_profiles = paths.config_dir.join(PROFILES_DIR);
195+ scan_dir_into(
196+ &mut store.profiles,
197+ &user_profiles,
198+ false,
199+ &mut store.pending_warnings,
200+ );
201+ // scanned directly and last so they win on name conflict
202+ for dir in &paths.extra_profile_dirs {
203+ scan_dir_into(&mut store.profiles, dir, false, &mut store.pending_warnings);
204+ }
205+
206+ if let Some(path) = &store.overlay_path {
207+ match fs::read_to_string(path) {
208+ Ok(text) => match toml::from_str::<UserOverlay>(&text) {
209+ Ok(overlay) => store.overlay = overlay,
210+ Err(e) => store.pending_warnings.push(format!(
211+ "overlay at {} failed to parse, ignoring: {e}",
212+ path.display()
213+ )),
214+ },
215+ Err(e) if e.kind() == io::ErrorKind::NotFound => {}
216+ Err(e) => return Err(StoreError::Io(e)),
217+ }
218+ }
219+
220+ store.refresh_active_missing();
221+ store.rematerialize();
222+ Ok(store)
223+ }
224+
225+ #[must_use]
226+ pub fn effective(&self) -> &Profile {
227+ &self.effective
228+ }
229+
230+ /// watch dir: first `--profile-dir` override if present, else user config `profiles/`.
231+ #[must_use]
232+ pub fn primary_profile_watch_dir(&self) -> Option<PathBuf> {
233+ let paths = self.paths.as_ref()?;
234+ Some(
235+ paths
236+ .extra_profile_dirs
237+ .first()
238+ .cloned()
239+ .unwrap_or_else(|| paths.config_dir.join(PROFILES_DIR)),
240+ )
241+ }
242+
243+ /// returns the *requested* name even when missing on disk (so operators see the mismatch).
244+ #[must_use]
245+ pub fn active_name(&self) -> &str {
246+ self.overlay
247+ .active_name
248+ .as_deref()
249+ .unwrap_or(BUILTIN_DEFAULT_NAME)
250+ }
251+
252+ #[must_use]
253+ pub fn bypass_global(&self) -> bool {
254+ self.overlay.bypass_global
255+ }
256+
257+ /// overlay setting overrides as json; values that fail toml→json conversion are dropped.
258+ #[must_use]
259+ pub fn setting_overrides(&self) -> BTreeMap<String, Value> {
260+ self.overlay
261+ .setting_overrides
262+ .iter()
263+ .filter_map(|(k, v)| toml_to_json(v).ok().map(|jv| (k.clone(), jv)))
264+ .collect()
265+ }
266+
267+ pub fn list(&self) -> impl Iterator<Item = &StoredProfile> {
268+ self.profiles.values()
269+ }
270+
271+ /// `Some(name)` if the overlay-selected profile is unknown to the store.
272+ #[must_use]
273+ pub fn is_active_missing(&self) -> Option<&str> {
274+ self.active_missing.as_deref()
275+ }
276+
277+ /// consuming drain; use [`Self::warnings`] for a non-consuming snapshot.
278+ pub fn take_warnings(&mut self) -> Vec<String> {
279+ std::mem::take(&mut self.pending_warnings)
280+ }
281+
282+ #[must_use]
283+ pub fn warnings(&self) -> Vec<String> {
284+ self.pending_warnings.clone()
285+ }
286+
287+ /// overlay overrides are preserved across the switch — the whole point of the overlay.
288+ pub fn use_profile(&mut self, name: &str) -> Result<(), StoreError> {
289+ if !self.profiles.contains_key(name) {
290+ return Err(StoreError::ProfileNotFound(name.to_owned()));
291+ }
292+ self.overlay.active_name = Some(name.to_owned());
293+ self.active_missing = None;
294+ self.rematerialize();
295+ self.persist_overlay()?;
296+ Ok(())
297+ }
298+
299+ /// prepended to the effective rule list, shadowing profile rules for the same app.
300+ pub fn set_route(&mut self, app: &str, route: Route) -> Result<(), StoreError> {
301+ self.overlay.route_overrides.insert(app.to_owned(), route);
302+ self.rematerialize();
303+ self.persist_overlay()?;
304+ Ok(())
305+ }
306+
307+ pub fn unset_route(&mut self, app: &str) -> Result<(), StoreError> {
308+ if self.overlay.route_overrides.remove(app).is_none() {
309+ return Err(StoreError::NoUserRoute(app.to_owned()));
310+ }
311+ self.rematerialize();
312+ self.persist_overlay()?;
313+ Ok(())
314+ }
315+
316+ /// validated by trial materialization; rejected overrides leave the overlay unchanged.
317+ pub fn set_setting(&mut self, key: &str, value: Value) -> Result<(), StoreError> {
318+ let mut trial = self.overlay.clone();
319+ let toml_value = json_to_toml(&value).map_err(|msg| StoreError::SettingInvalid {
320+ key: key.to_owned(),
321+ msg,
322+ })?;
323+ trial.setting_overrides.insert(key.to_owned(), toml_value);
324+ let probe = materialize(&self.profiles, &trial);
325+ match probe {
326+ Materialized::Ok(_) => {}
327+ Materialized::SettingTypeError { offending_key, err } if offending_key == key => {
328+ return Err(StoreError::SettingInvalid {
329+ key: key.to_owned(),
330+ msg: err,
331+ });
332+ }
333+ Materialized::SettingMissing { offending_key } if offending_key == key => {
334+ return Err(StoreError::SettingNotFound(key.to_owned()));
335+ }
336+ // shouldn't happen (trial = overlay + one key), but surface defensively
337+ Materialized::SettingTypeError { offending_key, err } => {
338+ return Err(StoreError::SettingInvalid {
339+ key: format!("{key} (caused {offending_key} to fail)"),
340+ msg: err,
341+ });
342+ }
343+ Materialized::SettingMissing { offending_key } => {
344+ return Err(StoreError::SettingNotFound(format!(
345+ "{key} (caused {offending_key} to be unreachable)"
346+ )));
347+ }
348+ }
349+ self.overlay = trial;
350+ self.rematerialize();
351+ self.persist_overlay()?;
352+ Ok(())
353+ }
354+
355+ /// idempotent; returns whether an override was present.
356+ pub fn clear_setting(&mut self, key: &str) -> Result<bool, StoreError> {
357+ let existed = self.overlay.setting_overrides.remove(key).is_some();
358+ if existed {
359+ self.rematerialize();
360+ self.persist_overlay()?;
361+ }
362+ Ok(existed)
363+ }
364+
365+ /// leaves route / per-app / bypass overrides intact; returns the count cleared.
366+ pub fn clear_all_settings(&mut self) -> Result<usize, StoreError> {
367+ let n = self.overlay.setting_overrides.len();
368+ if n > 0 {
369+ self.overlay.setting_overrides.clear();
370+ self.rematerialize();
371+ self.persist_overlay()?;
372+ }
373+ Ok(n)
374+ }
375+
376+ pub fn set_bypass(&mut self, enabled: bool) -> Result<(), StoreError> {
377+ self.overlay.bypass_global = enabled;
378+ self.persist_overlay()?;
379+ Ok(())
380+ }
381+
382+ /// registry thread reconciles managed taps on the matching `PwCommand::ReevaluateLayerA`.
383+ pub fn set_per_app_enabled(&mut self, app: &str, enabled: bool) -> Result<(), StoreError> {
384+ self.overlay
385+ .per_app_overrides
386+ .insert(app.to_owned(), enabled);
387+ self.rematerialize();
388+ self.persist_overlay()?;
389+ Ok(())
390+ }
391+
392+ pub fn set_per_app_master(&mut self, enabled: bool) -> Result<(), StoreError> {
393+ self.overlay.per_app_master = Some(enabled);
394+ self.rematerialize();
395+ self.persist_overlay()?;
396+ Ok(())
397+ }
398+
399+ #[must_use]
400+ pub fn per_app_master(&self) -> bool {
401+ self.effective.per_app.enabled
402+ }
403+
404+ /// atomic: on fatal i/o the in-memory state is left untouched; per-file parse errors warn+skip.
405+ pub fn reload(&mut self) -> Result<ReloadReport, StoreError> {
406+ let Some(paths) = self.paths.clone() else {
407+ return Ok(ReloadReport {
408+ loaded: vec![BUILTIN_DEFAULT_NAME.into()],
409+ warnings: vec!["store has no disk paths; nothing to reload".into()],
410+ });
411+ };
412+ // clean slate so `warnings()` reflects current state, not the union of all past reloads
413+ self.pending_warnings.clear();
414+ let mut warnings = Vec::new();
415+ let mut new_profiles: BTreeMap<String, StoredProfile> = BTreeMap::new();
416+ new_profiles.insert(
417+ BUILTIN_DEFAULT_NAME.into(),
418+ StoredProfile {
419+ name: BUILTIN_DEFAULT_NAME.into(),
420+ source: ProfileSource::Builtin,
421+ profile: Profile::default_v0(),
422+ },
423+ );
424+ for share in &paths.share_dirs {
425+ let dir = share.join(PROFILES_DIR);
426+ scan_dir_into(&mut new_profiles, &dir, true, &mut warnings);
427+ }
428+ let user_profiles = paths.config_dir.join(PROFILES_DIR);
429+ scan_dir_into(&mut new_profiles, &user_profiles, false, &mut warnings);
430+ for dir in &paths.extra_profile_dirs {
431+ scan_dir_into(&mut new_profiles, dir, false, &mut warnings);
432+ }
433+
434+ let loaded: Vec<String> = new_profiles.keys().cloned().collect();
435+ self.profiles = new_profiles;
436+ self.refresh_active_missing();
437+ self.rematerialize();
438+ // rematerialize may have dropped invalid overrides — persist to keep disk consistent
439+ if let Err(e) = self.persist_overlay() {
440+ warnings.push(format!("could not persist overlay after reload: {e}"));
441+ }
442+ self.pending_warnings.extend(warnings.iter().cloned());
443+ Ok(ReloadReport { loaded, warnings })
444+ }
445+
446+ /// overrides that no longer typecheck warn but stay in the overlay (user may switch back).
447+ fn rematerialize(&mut self) {
448+ let mut warnings = Vec::new();
449+ let mat = materialize(&self.profiles, &self.overlay);
450+ let profile = match mat {
451+ Materialized::Ok(p) => p,
452+ // override failed against the active profile — keep it stored, materialize skipping it
453+ Materialized::SettingMissing { offending_key }
454+ | Materialized::SettingTypeError {
455+ offending_key,
456+ err: _,
457+ } => {
458+ warnings.push(format!(
459+ "setting override '{offending_key}' doesn't apply to active profile; \
460+ keeping the override for future use"
461+ ));
462+ materialize_skipping(&self.profiles, &self.overlay, &[offending_key])
463+ }
464+ };
465+ self.pending_warnings.extend(warnings);
466+ self.effective = profile;
467+ }
468+
469+ fn refresh_active_missing(&mut self) {
470+ match self.overlay.active_name.as_deref() {
471+ Some(name) if !self.profiles.contains_key(name) => {
472+ self.active_missing = Some(name.to_owned());
473+ self.pending_warnings.push(format!(
474+ "active profile '{name}' is not on disk; using built-in default until \
475+ the profile is restored or `profile.use` selects a known one"
476+ ));
477+ }
478+ _ => self.active_missing = None,
479+ }
480+ }
481+
482+ fn persist_overlay(&self) -> Result<(), StoreError> {
483+ let Some(path) = self.overlay_path.as_ref() else {
484+ return Ok(());
485+ };
486+ if let Some(parent) = path.parent() {
487+ fs::create_dir_all(parent)?;
488+ }
489+ let body = toml::to_string_pretty(&self.overlay).map_err(StoreError::OverlaySerialize)?;
490+ atomic_write(path, body.as_bytes())?;
491+ Ok(())
492+ }
493+}
494+
495+// ---------------------------------------------------------------------------
496+// Materialization
497+// ---------------------------------------------------------------------------
498+
499+enum Materialized {
500+ Ok(Profile),
501+ SettingMissing {
502+ offending_key: String,
503+ },
504+ SettingTypeError {
505+ offending_key: String,
506+ err: String,
507+ },
508+}
509+
510+fn pick_base<'a>(profiles: &'a BTreeMap<String, StoredProfile>, overlay: &UserOverlay) -> &'a Profile {
511+ let name = overlay
512+ .active_name
513+ .as_deref()
514+ .unwrap_or(BUILTIN_DEFAULT_NAME);
515+ profiles
516+ .get(name)
517+ .or_else(|| profiles.get(BUILTIN_DEFAULT_NAME))
518+ .map(|sp| &sp.profile)
519+ .expect("ProfileStore always contains the built-in default")
520+}
521+
522+fn materialize(
523+ profiles: &BTreeMap<String, StoredProfile>,
524+ overlay: &UserOverlay,
525+) -> Materialized {
526+ let base = pick_base(profiles, overlay);
527+ let mut json = match serde_json::to_value(base) {
528+ Ok(v) => v,
529+ Err(e) => {
530+ return Materialized::SettingTypeError {
531+ offending_key: "<profile>".into(),
532+ err: format!("base profile failed to serialise: {e}"),
533+ };
534+ }
535+ };
536+ for (key, value) in &overlay.setting_overrides {
537+ let json_value = match toml_to_json(value) {
538+ Ok(v) => v,
539+ Err(e) => {
540+ return Materialized::SettingTypeError {
541+ offending_key: key.clone(),
542+ err: e,
543+ };
544+ }
545+ };
546+ if !set_dotted(&mut json, key, json_value) {
547+ return Materialized::SettingMissing {
548+ offending_key: key.clone(),
549+ };
550+ }
551+ }
552+ let mut materialised: Profile = match serde_json::from_value(json) {
553+ Ok(p) => p,
554+ Err(e) => {
555+ return Materialized::SettingTypeError {
556+ offending_key: "<profile>".into(),
557+ err: e.to_string(),
558+ };
559+ }
560+ };
561+ apply_route_overrides(&mut materialised, &overlay.route_overrides);
562+ apply_per_app_overlay(
563+ &mut materialised,
564+ overlay.per_app_master,
565+ &overlay.per_app_overrides,
566+ );
567+ Materialized::Ok(materialised)
568+}
569+
570+fn materialize_skipping(
571+ profiles: &BTreeMap<String, StoredProfile>,
572+ overlay: &UserOverlay,
573+ skip_keys: &[String],
574+) -> Profile {
575+ let base = pick_base(profiles, overlay);
576+ // already-validated path: an error here means the base profile is malformed — fall back
577+ let mut json = serde_json::to_value(base).unwrap_or_else(|_| {
578+ serde_json::to_value(Profile::default_v0()).expect("default_v0 always serialises")
579+ });
580+ for (key, value) in &overlay.setting_overrides {
581+ if skip_keys.iter().any(|s| s == key) {
582+ continue;
583+ }
584+ if let Ok(jv) = toml_to_json(value) {
585+ let _ = set_dotted(&mut json, key, jv);
586+ }
587+ }
588+ let mut materialised: Profile =
589+ serde_json::from_value(json).unwrap_or_else(|_| Profile::default_v0());
590+ apply_route_overrides(&mut materialised, &overlay.route_overrides);
591+ apply_per_app_overlay(
592+ &mut materialised,
593+ overlay.per_app_master,
594+ &overlay.per_app_overrides,
595+ );
596+ materialised
597+}
598+
599+fn apply_route_overrides(profile: &mut Profile, overrides: &BTreeMap<String, Route>) {
600+ // two single-field rules per override, not one AND-shape rule: the matcher ANDs non-empty
601+ // fields, and many tools (pw-cat, electron/flatpak wrappers) set only `application.name`, so a
602+ // both-fields rule would miss them. two rules = OR across identity fields; first-match wins.
603+ //
604+ // no retain pre-pass: `materialize` is stateless so overlay rules can't accumulate; a retain
605+ // would only drop base-profile rules that coincidentally match the overlay shape (data loss).
606+ let mut new_rules: Vec<RouteRule> = Vec::with_capacity(overrides.len() * 2);
607+ for (app, route) in overrides {
608+ new_rules.push(RouteRule {
609+ match_: RouteRuleMatch {
610+ process_binary: vec![app.clone()],
611+ ..Default::default()
612+ },
613+ route: *route,
614+ });
615+ new_rules.push(RouteRule {
616+ match_: RouteRuleMatch {
617+ application_name: vec![app.clone()],
618+ ..Default::default()
619+ },
620+ route: *route,
621+ });
622+ }
623+ new_rules.extend(std::mem::take(&mut profile.rules));
624+ profile.rules = new_rules;
625+}
626+
627+/// master first (`per_app_master` wins over profile's `enabled`); then per-app: flip a matching
628+/// authored rule in place (preserving thresholds), else prepend two synthetic single-field rules
629+/// (process_binary + application_name OR-shape) so the override wins first-match iteration.
630+fn apply_per_app_overlay(
631+ profile: &mut Profile,
632+ master: Option<bool>,
633+ overrides: &BTreeMap<String, bool>,
634+) {
635+ if let Some(enabled) = master {
636+ profile.per_app.enabled = enabled;
637+ }
638+ if overrides.is_empty() {
639+ return;
640+ }
641+ let mut prepend: Vec<crate::profile::PerAppRule> = Vec::new();
642+ for (app, enabled) in overrides {
643+ let mut matched_existing = false;
644+ for rule in &mut profile.per_app.rules {
645+ let m = &rule.match_;
646+ if m.process_binary.iter().any(|p| p == app)
647+ || m.application_name.iter().any(|n| n == app)
648+ {
649+ rule.enabled = *enabled;
650+ matched_existing = true;
651+ }
652+ }
653+ if matched_existing {
654+ continue;
655+ }
656+ prepend.push(crate::profile::PerAppRule::defaulted(
657+ RouteRuleMatch {
658+ process_binary: vec![app.clone()],
659+ ..Default::default()
660+ },
661+ *enabled,
662+ ));
663+ prepend.push(crate::profile::PerAppRule::defaulted(
664+ RouteRuleMatch {
665+ application_name: vec![app.clone()],
666+ ..Default::default()
667+ },
668+ *enabled,
669+ ));
670+ }
671+ prepend.extend(std::mem::take(&mut profile.per_app.rules));
672+ profile.per_app.rules = prepend;
673+}
674+
675+// ---------------------------------------------------------------------------
676+// Helpers
677+// ---------------------------------------------------------------------------
678+
679+fn scan_dir_into(
680+ out: &mut BTreeMap<String, StoredProfile>,
681+ dir: &Path,
682+ shipped: bool,
683+ warnings: &mut Vec<String>,
684+) {
685+ let entries = match fs::read_dir(dir) {
686+ Ok(e) => e,
687+ Err(e) if e.kind() == io::ErrorKind::NotFound => return,
688+ Err(e) => {
689+ warnings.push(format!("can't scan {}: {e}", dir.display()));
690+ return;
691+ }
692+ };
693+ for entry in entries.flatten() {
694+ let path = entry.path();
695+ if path.extension().and_then(|s| s.to_str()) != Some("toml") {
696+ continue;
697+ }
698+ let text = match fs::read_to_string(&path) {
699+ Ok(t) => t,
700+ Err(e) => {
701+ warnings.push(format!("read {}: {e}", path.display()));
702+ continue;
703+ }
704+ };
705+ let profile: Profile = match toml::from_str(&text) {
706+ Ok(p) => p,
707+ Err(e) => {
708+ warnings.push(format!("parse {}: {e}", path.display()));
709+ continue;
710+ }
711+ };
712+ let stem = path
713+ .file_stem()
714+ .and_then(|s| s.to_str())
715+ .unwrap_or("")
716+ .to_owned();
717+ if stem != profile.name {
718+ warnings.push(format!(
719+ "{}: file name '{stem}' doesn't match profile.name '{}' — using profile.name",
720+ path.display(),
721+ profile.name
722+ ));
723+ }
724+ let name = profile.name.clone();
725+ let source = if shipped {
726+ ProfileSource::Shipped(path)
727+ } else {
728+ ProfileSource::User(path)
729+ };
730+ out.insert(name.clone(), StoredProfile { name, source, profile });
731+ }
732+}
733+
734+fn set_dotted(value: &mut Value, key: &str, new: Value) -> bool {
735+ let parts: Vec<&str> = key.split('.').collect();
736+ let Some((last, parents)) = parts.split_last() else {
737+ return false;
738+ };
739+ let mut cur = value;
740+ for part in parents {
741+ cur = match cur.get_mut(*part) {
742+ Some(v) => v,
743+ None => return false,
744+ };
745+ }
746+ let Some(map) = cur.as_object_mut() else {
747+ return false;
748+ };
749+ if !map.contains_key(*last) {
750+ return false;
751+ }
752+ map.insert((*last).to_string(), new);
753+ true
754+}
755+
756+fn toml_to_json(v: &toml::Value) -> Result<Value, String> {
757+ match v {
758+ toml::Value::String(s) => Ok(Value::String(s.clone())),
759+ toml::Value::Integer(i) => Ok(Value::from(*i)),
760+ toml::Value::Float(f) => serde_json::Number::from_f64(*f)
761+ .map(Value::Number)
762+ .ok_or_else(|| "non-finite float in setting override".into()),
763+ toml::Value::Boolean(b) => Ok(Value::Bool(*b)),
764+ toml::Value::Datetime(d) => Ok(Value::String(d.to_string())),
765+ toml::Value::Array(arr) => arr.iter().map(toml_to_json).collect::<Result<Vec<_>, _>>().map(Value::Array),
766+ toml::Value::Table(t) => {
767+ let mut map = serde_json::Map::new();
768+ for (k, v) in t {
769+ map.insert(k.clone(), toml_to_json(v)?);
770+ }
771+ Ok(Value::Object(map))
772+ }
773+ }
774+}
775+
776+fn json_to_toml(v: &Value) -> Result<toml::Value, String> {
777+ match v {
778+ Value::Null => Err("null is not representable in TOML overlay".into()),
779+ Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
780+ Value::Number(n) => {
781+ if let Some(i) = n.as_i64() {
782+ Ok(toml::Value::Integer(i))
783+ } else if let Some(f) = n.as_f64() {
784+ Ok(toml::Value::Float(f))
785+ } else {
786+ Err("number out of range for TOML".into())
787+ }
788+ }
789+ Value::String(s) => Ok(toml::Value::String(s.clone())),
790+ Value::Array(arr) => arr
791+ .iter()
792+ .map(json_to_toml)
793+ .collect::<Result<Vec<_>, _>>()
794+ .map(toml::Value::Array),
795+ Value::Object(obj) => {
796+ let mut t = toml::value::Table::new();
797+ for (k, v) in obj {
798+ t.insert(k.clone(), json_to_toml(v)?);
799+ }
800+ Ok(toml::Value::Table(t))
801+ }
802+ }
803+}
804+
805+/// stage to a tmp file then `rename` over the target (atomic within a filesystem on linux).
806+fn atomic_write(path: &Path, body: &[u8]) -> io::Result<()> {
807+ let pid = std::process::id();
808+ let stamp = SystemTime::now()
809+ .duration_since(UNIX_EPOCH)
810+ .map(|d| d.as_nanos())
811+ .unwrap_or(0);
812+ let tmp_name = format!(
813+ "{}.tmp-{pid}-{stamp}",
814+ path.file_name().and_then(|s| s.to_str()).unwrap_or("overlay")
815+ );
816+ let tmp_path = path.with_file_name(tmp_name);
817+ fs::write(&tmp_path, body)?;
818+ match fs::rename(&tmp_path, path) {
819+ Ok(()) => Ok(()),
820+ Err(e) => {
821+ let _ = fs::remove_file(&tmp_path);
822+ Err(e)
823+ }
824+ }
825+}
826+
827+// ---------------------------------------------------------------------------
828+// Tests
829+// ---------------------------------------------------------------------------
830+
831+#[cfg(test)]
832+mod overlay_tests;
833+
834+#[cfg(test)]
835+mod tests {
836+ use super::*;
837+
838+ fn tmp_paths() -> (StorePaths, tempdir::TempDir) {
839+ // We don't actually depend on the `tempdir` crate in this
840+ // crate — use std::env::temp_dir + a unique subdir.
841+ let base = std::env::temp_dir().join(format!(
842+ "headroom-test-{}-{}",
843+ std::process::id(),
844+ SystemTime::now()
845+ .duration_since(UNIX_EPOCH)
846+ .unwrap()
847+ .as_nanos()
848+ ));
849+ fs::create_dir_all(base.join("config/profiles")).unwrap();
850+ fs::create_dir_all(base.join("state")).unwrap();
851+ let paths = StorePaths {
852+ config_dir: base.join("config"),
853+ state_dir: base.join("state"),
854+ share_dirs: vec![],
855+ extra_profile_dirs: vec![],
856+ };
857+ // We hand back a guard that removes the dir on drop.
858+ // The `tempdir` alias is faked via the wrapper below.
859+ (paths, tempdir::TempDir(base))
860+ }
861+
862+ // Tiny inline tempdir guard to avoid a new dependency.
863+ mod tempdir {
864+ use std::path::PathBuf;
865+ pub struct TempDir(pub PathBuf);
866+ impl Drop for TempDir {
867+ fn drop(&mut self) {
868+ let _ = std::fs::remove_dir_all(&self.0);
869+ }
870+ }
871+ }
872+
873+ #[test]
874+ fn share_dirs_from_xdg_data_dirs_appends_headroom_and_reverses() {
875+ // First data dir must end up scanned last (so it wins under
876+ // `load`'s later-shadows-earlier rule), and each entry gains a
877+ // `headroom` segment.
878+ let dirs = share_dirs_from_data_dirs(std::ffi::OsStr::new(
879+ "/run/current-system/sw/share:/usr/share",
880+ ));
881+ assert_eq!(
882+ dirs,
883+ vec![
884+ PathBuf::from("/usr/share/headroom"),
885+ PathBuf::from("/run/current-system/sw/share/headroom"),
886+ ]
887+ );
888+ }
889+
890+ #[test]
891+ fn share_dirs_empty_falls_back_to_fhs_default() {
892+ let dirs = share_dirs_from_data_dirs(std::ffi::OsStr::new(""));
893+ // Reversed `/usr/local/share:/usr/share` default.
894+ assert_eq!(
895+ dirs,
896+ vec![
897+ PathBuf::from("/usr/share/headroom"),
898+ PathBuf::from("/usr/local/share/headroom"),
899+ ]
900+ );
901+ }
902+
903+ #[test]
904+ fn builtin_store_has_default_profile() {
905+ let s = ProfileStore::builtin();
906+ assert_eq!(s.active_name(), "default");
907+ assert_eq!(s.effective().name, "default");
908+ assert!(!s.bypass_global());
909+ assert!(s.list().any(|p| p.name == "default"));
910+ }
911+
912+ #[test]
913+ fn load_with_empty_dirs_yields_builtin_only() {
914+ let (paths, _g) = tmp_paths();
915+ let s = ProfileStore::load(&paths).unwrap();
916+ assert_eq!(s.list().count(), 1);
917+ assert_eq!(s.effective().name, "default");
918+ }
919+
920+ #[test]
921+ fn user_profile_overrides_shipped_by_name() {
922+ let (paths, _g) = tmp_paths();
923+ let shipped = paths.config_dir.parent().unwrap().join("share/profiles");
924+ fs::create_dir_all(&shipped).unwrap();
925+ fs::write(
926+ shipped.join("night.toml"),
927+ "name = \"night\"\ndescription = \"shipped night\"\n",
928+ )
929+ .unwrap();
930+ fs::write(
931+ paths.config_dir.join("profiles/night.toml"),
932+ "name = \"night\"\ndescription = \"user night\"\n",
933+ )
934+ .unwrap();
935+ let paths2 = StorePaths {
936+ share_dirs: vec![shipped.parent().unwrap().to_path_buf()],
937+ ..paths
938+ };
939+ let s = ProfileStore::load(&paths2).unwrap();
940+ let night = s.list().find(|p| p.name == "night").unwrap();
941+ assert!(matches!(night.source, ProfileSource::User(_)));
942+ assert_eq!(night.profile.description, "user night");
943+ }
944+
945+ #[test]
946+ fn extra_profile_dir_scanned_directly_and_wins() {
947+ let (paths, _g) = tmp_paths();
948+ // A user profile that a same-named --profile-dir entry overrides.
949+ fs::write(
950+ paths.config_dir.join("profiles/movie.toml"),
951+ "name = \"movie\"\ndescription = \"user movie\"\n",
952+ )
953+ .unwrap();
954+ // The override dir holds movie + an extra profile, scanned with
955+ // no `profiles/` suffix.
956+ let extra = paths.config_dir.parent().unwrap().join("repo-profiles");
957+ fs::create_dir_all(&extra).unwrap();
958+ fs::write(
959+ extra.join("movie.toml"),
960+ "name = \"movie\"\ndescription = \"repo movie\"\n",
961+ )
962+ .unwrap();
963+ fs::write(
964+ extra.join("party.toml"),
965+ "name = \"party\"\ndescription = \"repo party\"\n",
966+ )
967+ .unwrap();
968+ let paths2 = StorePaths {
969+ extra_profile_dirs: vec![extra.clone()],
970+ ..paths
971+ };
972+ let s = ProfileStore::load(&paths2).unwrap();
973+ // Both override profiles are present...
974+ assert!(s.list().any(|p| p.name == "party"));
975+ // ...and the override wins over the user profile of the same name.
976+ let movie = s.list().find(|p| p.name == "movie").unwrap();
977+ assert_eq!(movie.profile.description, "repo movie");
978+ // The watch dir is the override dir, not the config dir.
979+ assert_eq!(s.primary_profile_watch_dir(), Some(extra));
980+ }
981+
982+ #[test]
983+ fn parse_error_is_warning_not_fatal() {
984+ let (paths, _g) = tmp_paths();
985+ fs::write(
986+ paths.config_dir.join("profiles/broken.toml"),
987+ "this is not = valid = toml",
988+ )
989+ .unwrap();
990+ let mut s = ProfileStore::load(&paths).unwrap();
991+ let warnings = s.take_warnings();
992+ assert!(warnings.iter().any(|w| w.contains("broken.toml")));
993+ // Built-in still present and active.
994+ assert_eq!(s.effective().name, "default");
995+ }
996+
997+ #[test]
998+ fn use_profile_unknown_errors() {
999+ let mut s = ProfileStore::builtin();
1000+ assert!(matches!(
1001+ s.use_profile("does-not-exist"),
1002+ Err(StoreError::ProfileNotFound(_))
1003+ ));
1004+ }
1005+
1006+ #[test]
1007+ fn set_route_appears_in_effective_rules() {
1008+ let (paths, _g) = tmp_paths();
1009+ let mut s = ProfileStore::load(&paths).unwrap();
1010+ s.set_route("obs", Route::Bypass).unwrap();
1011+ // First rule should now be the override.
1012+ let rule = &s.effective().rules[0];
1013+ assert_eq!(rule.match_.process_binary, vec!["obs".to_string()]);
1014+ assert_eq!(rule.route, Route::Bypass);
1015+ }
1016+
1017+ #[test]
1018+ fn set_route_emits_both_process_binary_and_application_name_rules() {
1019+ // The route.set CLI verb accepts a single app identifier
1020+ // but streams can advertise themselves via either
1021+ // `application.process.binary` or `application.name`
1022+ // (or neither — those go through default_route). The
1023+ // overlay materialises BOTH single-field rules so each
1024+ // possible identity field is covered.
1025+ let (paths, _g) = tmp_paths();
1026+ let mut s = ProfileStore::load(&paths).unwrap();
1027+ s.set_route("pw-cat", Route::Bypass).unwrap();
1028+ let rules = &s.effective().rules;
1029+ let proc_rule = rules
1030+ .iter()
1031+ .find(|r| r.match_.process_binary == vec!["pw-cat".to_string()])
1032+ .expect("process_binary rule");
1033+ assert_eq!(proc_rule.route, Route::Bypass);
1034+ assert!(proc_rule.match_.application_name.is_empty());
1035+ let name_rule = rules
1036+ .iter()
1037+ .find(|r| r.match_.application_name == vec!["pw-cat".to_string()])
1038+ .expect("application_name rule");
1039+ assert_eq!(name_rule.route, Route::Bypass);
1040+ assert!(name_rule.match_.process_binary.is_empty());
1041+ }
1042+
1043+ #[test]
1044+ fn set_route_then_unset_leaves_no_residual_rules() {
1045+ // Both the process_binary and application_name variants
1046+ // of a single-app override must clear on unset; otherwise
1047+ // a re-add would stack rules and the matcher would carry
1048+ // dead entries indefinitely.
1049+ let (paths, _g) = tmp_paths();
1050+ let mut s = ProfileStore::load(&paths).unwrap();
1051+ s.set_route("pw-cat", Route::Bypass).unwrap();
1052+ s.unset_route("pw-cat").unwrap();
1053+ let residual: Vec<_> = s
1054+ .effective()
1055+ .rules
1056+ .iter()
1057+ .filter(|r| {
1058+ r.match_.process_binary == vec!["pw-cat".to_string()]
1059+ || r.match_.application_name == vec!["pw-cat".to_string()]
1060+ })
1061+ .collect();
1062+ assert!(residual.is_empty(), "leftover override rules: {residual:#?}");
1063+ }
1064+
1065+ #[test]
1066+ fn user_rule_with_overlay_shape_survives_set_route_for_same_app() {
1067+ // Regression for Codex audit Q5: an earlier retain pre-pass in
1068+ // `apply_route_overrides` would silently drop any base-profile
1069+ // rule whose single-field shape coincided with the overlay's
1070+ // emit pattern. The fix is to delete the retain entirely —
1071+ // prepending already makes the overlay win first-match
1072+ // iteration, and removing the retain closes the data-loss
1073+ // surface. This test pins the surviving-rule behaviour so a
1074+ // future refactor can't quietly reintroduce the prune.
1075+ let (paths, _g) = tmp_paths();
1076+ fs::write(
1077+ paths.config_dir.join("profiles/custom.toml"),
1078+ r#"
1079+name = "custom"
1080+description = "user custom"
1081+default_route = { route = "processed" }
1082+[[rules]]
1083+match = { process_binary = ["obs"] }
1084+route = "processed"
1085+"#,
1086+ )
1087+ .unwrap();
1088+ let mut s = ProfileStore::load(&paths).unwrap();
1089+ s.use_profile("custom").unwrap();
1090+ // Sanity: user rule is loaded once.
1091+ assert_eq!(s.effective().rules.len(), 1);
1092+
1093+ s.set_route("obs", Route::Bypass).unwrap();
1094+
1095+ let rules = &s.effective().rules;
1096+ // Two overlay rules (process_binary + application_name) plus
1097+ // the preserved user rule.
1098+ assert_eq!(rules.len(), 3, "rules: {rules:#?}");
1099+ assert_eq!(rules[0].route, Route::Bypass);
1100+ assert_eq!(rules[0].match_.process_binary, vec!["obs".to_string()]);
1101+ assert_eq!(rules[1].route, Route::Bypass);
1102+ assert_eq!(rules[1].match_.application_name, vec!["obs".to_string()]);
1103+ assert_eq!(rules[2].route, Route::Processed);
1104+ assert_eq!(rules[2].match_.process_binary, vec!["obs".to_string()]);
1105+ }
1106+
1107+ #[test]
1108+ fn set_route_updates_existing_override() {
1109+ let (paths, _g) = tmp_paths();
1110+ let mut s = ProfileStore::load(&paths).unwrap();
1111+ s.set_route("obs", Route::Bypass).unwrap();
1112+ s.set_route("obs", Route::Processed).unwrap();
1113+ let obs_rules: Vec<_> = s
1114+ .effective()
1115+ .rules
1116+ .iter()
1117+ .filter(|r| r.match_.process_binary == vec!["obs".to_string()])
1118+ .collect();
1119+ assert_eq!(obs_rules.len(), 1);
1120+ assert_eq!(obs_rules[0].route, Route::Processed);
1121+ }
1122+
1123+ #[test]
1124+ fn unset_route_missing_errors() {
1125+ let (paths, _g) = tmp_paths();
1126+ let mut s = ProfileStore::load(&paths).unwrap();
1127+ assert!(matches!(
1128+ s.unset_route("never-set"),
1129+ Err(StoreError::NoUserRoute(_))
1130+ ));
1131+ }
1132+
1133+ #[test]
1134+ fn set_setting_changes_effective_profile() {
1135+ let (paths, _g) = tmp_paths();
1136+ let mut s = ProfileStore::load(&paths).unwrap();
1137+ s.set_setting("limiter.ceiling_dbtp", serde_json::json!(-1.0))
1138+ .unwrap();
1139+ assert!((s.effective().limiter.ceiling_dbtp - -1.0).abs() < 1e-6);
1140+ }
1141+
1142+ #[test]
1143+ fn clear_setting_reverts_to_profile() {
1144+ let (paths, _g) = tmp_paths();
1145+ let mut s = ProfileStore::load(&paths).unwrap();
1146+ let profile_default = s.effective().limiter.ceiling_dbtp;
1147+ s.set_setting("limiter.ceiling_dbtp", serde_json::json!(-3.0))
1148+ .unwrap();
1149+ assert!((s.effective().limiter.ceiling_dbtp - -3.0).abs() < 1e-6);
1150+
1151+ // Clearing a present override reverts the effective value and
1152+ // reports it existed; clearing again is a no-op.
1153+ assert!(s.clear_setting("limiter.ceiling_dbtp").unwrap());
1154+ assert!((s.effective().limiter.ceiling_dbtp - profile_default).abs() < 1e-6);
1155+ assert!(!s.clear_setting("limiter.ceiling_dbtp").unwrap());
1156+ }
1157+
1158+ #[test]
1159+ fn clear_all_settings_reverts_everything_and_counts() {
1160+ let (paths, _g) = tmp_paths();
1161+ let mut s = ProfileStore::load(&paths).unwrap();
1162+ let ceiling0 = s.effective().limiter.ceiling_dbtp;
1163+ let target0 = s.effective().agc.target_lufs;
1164+ s.set_setting("limiter.ceiling_dbtp", serde_json::json!(-3.0))
1165+ .unwrap();
1166+ s.set_setting("agc.target_lufs", serde_json::json!(-9.0))
1167+ .unwrap();
1168+
1169+ assert_eq!(s.clear_all_settings().unwrap(), 2);
1170+ assert!((s.effective().limiter.ceiling_dbtp - ceiling0).abs() < 1e-6);
1171+ assert!((s.effective().agc.target_lufs - target0).abs() < 1e-6);
1172+ assert!(s.setting_overrides().is_empty());
1173+ // Idempotent: nothing left to clear.
1174+ assert_eq!(s.clear_all_settings().unwrap(), 0);
1175+ }
1176+
1177+ #[test]
1178+ fn set_setting_rejects_wrong_type() {
1179+ let (paths, _g) = tmp_paths();
1180+ let mut s = ProfileStore::load(&paths).unwrap();
1181+ let err = s
1182+ .set_setting("limiter.ceiling_dbtp", serde_json::json!("nope"))
1183+ .unwrap_err();
1184+ assert!(matches!(err, StoreError::SettingInvalid { .. }));
1185+ // Effective unchanged.
1186+ assert!((s.effective().limiter.ceiling_dbtp - -0.1).abs() < 1e-6);
1187+ }
1188+
1189+ #[test]
1190+ fn set_setting_rejects_unknown_key() {
1191+ let (paths, _g) = tmp_paths();
1192+ let mut s = ProfileStore::load(&paths).unwrap();
1193+ let err = s
1194+ .set_setting("limiter.no_such_field", serde_json::json!(1))
1195+ .unwrap_err();
1196+ assert!(matches!(err, StoreError::SettingNotFound(_)));
1197+ }
1198+
1199+ #[test]
1200+ fn overlay_survives_profile_use() {
1201+ let (paths, _g) = tmp_paths();
1202+ // Ship a second profile.
1203+ fs::write(
1204+ paths.config_dir.join("profiles/night.toml"),
1205+ "name = \"night\"\ndescription = \"loud night\"\n",
1206+ )
1207+ .unwrap();
1208+ let mut s = ProfileStore::load(&paths).unwrap();
1209+ s.set_route("obs", Route::Bypass).unwrap();
1210+ s.set_setting("limiter.ceiling_dbtp", serde_json::json!(-2.0))
1211+ .unwrap();
1212+ s.use_profile("night").unwrap();
1213+ assert_eq!(s.effective().name, "night");
1214+ assert_eq!(s.effective().rules[0].match_.process_binary, vec!["obs".to_string()]);
1215+ assert!((s.effective().limiter.ceiling_dbtp - -2.0).abs() < 1e-6);
1216+ }
1217+
1218+ #[test]
1219+ fn overlay_is_persisted_and_reloaded() {
1220+ let (paths, _g) = tmp_paths();
1221+ {
1222+ let mut s = ProfileStore::load(&paths).unwrap();
1223+ s.set_route("obs", Route::Bypass).unwrap();
1224+ s.set_bypass(true).unwrap();
1225+ s.set_setting("limiter.ceiling_dbtp", serde_json::json!(-3.0))
1226+ .unwrap();
1227+ }
1228+ let mut s2 = ProfileStore::load(&paths).unwrap();
1229+ // Drop the load warnings — they should be empty here.
1230+ assert!(s2.take_warnings().is_empty());
1231+ assert!(s2.bypass_global());
1232+ assert!((s2.effective().limiter.ceiling_dbtp - -3.0).abs() < 1e-6);
1233+ assert_eq!(s2.effective().rules[0].match_.process_binary, vec!["obs".to_string()]);
1234+ }
1235+
1236+ #[test]
1237+ fn missing_active_profile_falls_back_to_builtin() {
1238+ let (paths, _g) = tmp_paths();
1239+ let overlay_text =
1240+ "active_name = \"night\"\nbypass_global = false\n[route_overrides]\n[setting_overrides]\n";
1241+ fs::write(paths.state_dir.join(OVERLAY_FILE), overlay_text).unwrap();
1242+
1243+ let mut s = ProfileStore::load(&paths).unwrap();
1244+ assert_eq!(s.is_active_missing(), Some("night"));
1245+ // effective() falls back to default_v0 via pick_base.
1246+ assert_eq!(s.effective().name, "default");
1247+ let warnings = s.take_warnings();
1248+ assert!(warnings.iter().any(|w| w.contains("night")));
1249+ }
1250+
1251+ #[test]
1252+ fn reload_picks_up_new_profile() {
1253+ let (paths, _g) = tmp_paths();
1254+ let mut s = ProfileStore::load(&paths).unwrap();
1255+ assert_eq!(s.list().count(), 1);
1256+
1257+ fs::write(
1258+ paths.config_dir.join("profiles/extra.toml"),
1259+ "name = \"extra\"\ndescription = \"hot reloaded\"\n",
1260+ )
1261+ .unwrap();
1262+ let report = s.reload().unwrap();
1263+ assert!(report.loaded.iter().any(|n| n == "extra"));
1264+ assert!(report.warnings.is_empty());
1265+ assert!(s.list().any(|p| p.name == "extra"));
1266+ }
1267+
1268+ #[test]
1269+ fn per_app_master_override_forces_enabled() {
1270+ let (paths, _g) = tmp_paths();
1271+ let mut s = ProfileStore::load(&paths).unwrap();
1272+ // default profile ships per_app.enabled = false.
1273+ assert!(!s.effective().per_app.enabled);
1274+ s.set_per_app_master(true).unwrap();
1275+ assert!(s.effective().per_app.enabled);
1276+ assert!(s.per_app_master());
1277+ s.set_per_app_master(false).unwrap();
1278+ assert!(!s.effective().per_app.enabled);
1279+ }
1280+
1281+ #[test]
1282+ fn per_app_override_prepends_synthetic_rule_when_no_match() {
1283+ let (paths, _g) = tmp_paths();
1284+ let mut s = ProfileStore::load(&paths).unwrap();
1285+ s.set_per_app_enabled("strawberry", true).unwrap();
1286+ let rules = &s.effective().per_app.rules;
1287+ // Two synthetic single-field rules (process_binary + application_name).
1288+ let proc_rule = rules
1289+ .iter()
1290+ .find(|r| r.match_.process_binary == vec!["strawberry".to_string()])
1291+ .expect("process_binary rule");
1292+ assert!(proc_rule.enabled);
1293+ let name_rule = rules
1294+ .iter()
1295+ .find(|r| r.match_.application_name == vec!["strawberry".to_string()])
1296+ .expect("application_name rule");
1297+ assert!(name_rule.enabled);
1298+ }
1299+
1300+ #[test]
1301+ fn per_app_override_flips_existing_rule_preserving_thresholds() {
1302+ let (paths, _g) = tmp_paths();
1303+ fs::write(
1304+ paths.config_dir.join("profiles/la.toml"),
1305+ r#"
1306+name = "la"
1307+description = "layer a custom"
1308+[per_app]
1309+enabled = true
1310+[[per_app.rules]]
1311+match = { process_binary = ["strawberry"] }
1312+enabled = true
1313+max_cut_db = 18.0
1314+"#,
1315+ )
1316+ .unwrap();
1317+ let mut s = ProfileStore::load(&paths).unwrap();
1318+ s.use_profile("la").unwrap();
1319+ s.set_per_app_enabled("strawberry", false).unwrap();
1320+ // No synthetic prepend; the existing rule's enabled flips and
1321+ // its custom max_cut is preserved.
1322+ let strawberry: Vec<_> = s
1323+ .effective()
1324+ .per_app
1325+ .rules
1326+ .iter()
1327+ .filter(|r| r.match_.process_binary == vec!["strawberry".to_string()])
1328+ .collect();
1329+ assert_eq!(strawberry.len(), 1, "should not have prepended a duplicate");
1330+ assert!(!strawberry[0].enabled);
1331+ assert!((strawberry[0].max_cut_db - 18.0).abs() < 1e-6);
1332+ }
1333+
1334+ #[test]
1335+ fn per_app_overlay_persists_across_reload() {
1336+ let (paths, _g) = tmp_paths();
1337+ {
1338+ let mut s = ProfileStore::load(&paths).unwrap();
1339+ s.set_per_app_master(true).unwrap();
1340+ s.set_per_app_enabled("strawberry", false).unwrap();
1341+ }
1342+ let s2 = ProfileStore::load(&paths).unwrap();
1343+ assert!(s2.effective().per_app.enabled);
1344+ let disabled = s2
1345+ .effective()
1346+ .per_app
1347+ .rules
1348+ .iter()
1349+ .find(|r| r.match_.process_binary == vec!["strawberry".to_string()])
1350+ .expect("override rule");
1351+ assert!(!disabled.enabled);
1352+ }
1353+
1354+ #[test]
1355+ fn reload_with_broken_file_keeps_daemon_running() {
1356+ let (paths, _g) = tmp_paths();
1357+ let mut s = ProfileStore::load(&paths).unwrap();
1358+ fs::write(
1359+ paths.config_dir.join("profiles/broken.toml"),
1360+ "this == not valid",
1361+ )
1362+ .unwrap();
1363+ let report = s.reload().unwrap();
1364+ assert!(report.warnings.iter().any(|w| w.contains("broken.toml")));
1365+ // Default still active.
1366+ assert_eq!(s.effective().name, "default");
1367+ }
1368+}
Added file: crates/headroom-core/src/profile_store/overlay_tests.rs+74 additions
@@ -0,0 +1,74 @@
1+use std::fs;
2+use std::path::PathBuf;
3+use std::time::{SystemTime, UNIX_EPOCH};
4+
5+use super::*;
6+
7+struct TempDir(PathBuf);
8+
9+impl Drop for TempDir {
10+ fn drop(&mut self) {
11+ let _ = fs::remove_dir_all(&self.0);
12+ }
13+}
14+
15+fn tmp_paths() -> (StorePaths, TempDir) {
16+ let base = std::env::temp_dir().join(format!(
17+ "headroom-profile-overlay-{}-{}",
18+ std::process::id(),
19+ SystemTime::now()
20+ .duration_since(UNIX_EPOCH)
21+ .unwrap()
22+ .as_nanos()
23+ ));
24+ fs::create_dir_all(base.join("config/profiles")).unwrap();
25+ fs::create_dir_all(base.join("state")).unwrap();
26+ (
27+ StorePaths {
28+ config_dir: base.join("config"),
29+ state_dir: base.join("state"),
30+ share_dirs: vec![],
31+ extra_profile_dirs: vec![],
32+ },
33+ TempDir(base),
34+ )
35+}
36+
37+#[test]
38+fn invalid_setting_override_is_preserved_but_skipped() {
39+ let (paths, _g) = tmp_paths();
40+ fs::write(
41+ paths.state_dir.join(OVERLAY_FILE),
42+ r#"
43+bypass_global = false
44+[route_overrides]
45+[setting_overrides]
46+"limiter.no_such_field" = 1
47+"limiter.ceiling_dbtp" = -3.0
48+"#,
49+ )
50+ .unwrap();
51+
52+ let mut s = ProfileStore::load(&paths).unwrap();
53+ assert!((s.effective().limiter.ceiling_dbtp - -3.0).abs() < 1e-6);
54+ assert!(s.setting_overrides().contains_key("limiter.no_such_field"));
55+ let warnings = s.take_warnings();
56+ assert!(
57+ warnings.iter().any(|w| w.contains("limiter.no_such_field")),
58+ "expected warning for invalid override, got {warnings:?}"
59+ );
60+}
61+
62+#[test]
63+fn setting_overrides_reports_json_values() {
64+ let (paths, _g) = tmp_paths();
65+ let mut s = ProfileStore::load(&paths).unwrap();
66+ s.set_setting("limiter.ceiling_dbtp", serde_json::json!(-2.5))
67+ .unwrap();
68+ s.set_setting("agc.enabled", serde_json::json!(false))
69+ .unwrap();
70+
71+ let overrides = s.setting_overrides();
72+ assert_eq!(overrides["limiter.ceiling_dbtp"], serde_json::json!(-2.5));
73+ assert_eq!(overrides["agc.enabled"], serde_json::json!(false));
74+}
Added file: crates/headroom-core/src/profile_watcher.rs+155 additions
@@ -0,0 +1,155 @@
1+//! debounced profile-dir watcher; fires the same reload path as ipc `profile.reload`
2+
3+use std::path::PathBuf;
4+use std::time::Duration;
5+
6+use notify::{RecommendedWatcher, RecursiveMode};
7+use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer};
8+
9+use crate::error::DaemonError;
10+use crate::ipc::execute_reload;
11+use crate::state::SharedState;
12+
13+/// quiet period before firing; past the typical editor save→rename storm
14+const DEBOUNCE: Duration = Duration::from_millis(500);
15+
16+/// drop to stop the background thread.
17+pub struct ProfileWatcher {
18+ _debouncer: Debouncer<RecommendedWatcher>,
19+}
20+
21+impl ProfileWatcher {
22+ /// `Ok(None)` if `profiles_dir` doesn't exist yet (user authored no custom profiles).
23+ pub fn start(profiles_dir: PathBuf, state: SharedState) -> Result<Option<Self>, DaemonError> {
24+ if !profiles_dir.exists() {
25+ tracing::debug!(
26+ path = %profiles_dir.display(),
27+ "profile dir not present; file-watch reload disabled"
28+ );
29+ return Ok(None);
30+ }
31+
32+ let state_for_cb = state;
33+ let mut debouncer = new_debouncer(
34+ DEBOUNCE,
35+ move |result: DebounceEventResult| match result {
36+ Ok(events) if !events.is_empty() => {
37+ tracing::info!(events = events.len(), "profile dir changed; auto-reloading");
38+ match execute_reload(&state_for_cb) {
39+ Ok(report) => {
40+ for w in &report.warnings {
41+ tracing::warn!(warning = %w, "auto-reload warning");
42+ }
43+ }
44+ Err(e) => tracing::error!(error = %e, "auto-reload failed"),
45+ }
46+ }
47+ Ok(_) => {}
48+ Err(e) => {
49+ tracing::warn!(error = %e, "profile watcher backend error");
50+ }
51+ },
52+ )
53+ .map_err(|e| DaemonError::other(format!("debouncer init: {e}")))?;
54+
55+ debouncer
56+ .watcher()
57+ .watch(&profiles_dir, RecursiveMode::NonRecursive)
58+ .map_err(|e| {
59+ DaemonError::other(format!("watch {}: {e}", profiles_dir.display()))
60+ })?;
61+
62+ tracing::info!(
63+ path = %profiles_dir.display(),
64+ debounce_ms = DEBOUNCE.as_millis() as u64,
65+ "profile dir watcher armed"
66+ );
67+ Ok(Some(Self {
68+ _debouncer: debouncer,
69+ }))
70+ }
71+}
72+
73+#[cfg(test)]
74+mod tests {
75+ use super::*;
76+ use crate::profile_store::{ProfileStore, StorePaths};
77+ use crate::state::{self, DaemonState};
78+ use std::fs;
79+ use std::time::{Instant, SystemTime, UNIX_EPOCH};
80+
81+ /// Build an isolated config/state tree and load a `ProfileStore`
82+ /// against it. Returns the paths and a guard that cleans up the
83+ /// dir on drop.
84+ fn tmp_paths() -> (StorePaths, TmpGuard) {
85+ let base = std::env::temp_dir().join(format!(
86+ "headroom-watcher-{}-{}",
87+ std::process::id(),
88+ SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
89+ ));
90+ fs::create_dir_all(base.join("config/profiles")).unwrap();
91+ fs::create_dir_all(base.join("state")).unwrap();
92+ let paths = StorePaths {
93+ config_dir: base.join("config"),
94+ state_dir: base.join("state"),
95+ share_dirs: vec![],
96+ extra_profile_dirs: vec![],
97+ };
98+ (paths, TmpGuard(base))
99+ }
100+
101+ struct TmpGuard(std::path::PathBuf);
102+ impl Drop for TmpGuard {
103+ fn drop(&mut self) {
104+ let _ = fs::remove_dir_all(&self.0);
105+ }
106+ }
107+
108+ #[test]
109+ fn missing_profile_dir_is_not_an_error() {
110+ let dir = std::env::temp_dir().join(format!(
111+ "headroom-no-dir-{}-{}",
112+ std::process::id(),
113+ SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
114+ ));
115+ // dir does not exist.
116+ let store = ProfileStore::builtin();
117+ let state = state::shared(DaemonState::new(store));
118+ let watcher = ProfileWatcher::start(dir, state).expect("graceful no-op");
119+ assert!(watcher.is_none());
120+ }
121+
122+ #[test]
123+ fn dropping_a_new_profile_triggers_reload() {
124+ let (paths, _g) = tmp_paths();
125+ let store = ProfileStore::load(&paths).unwrap();
126+ let state = state::shared(DaemonState::new(store));
127+ let profiles_dir = paths.config_dir.join("profiles");
128+
129+ let _watcher = ProfileWatcher::start(profiles_dir.clone(), state.clone())
130+ .expect("watcher start")
131+ .expect("dir present");
132+
133+ // Initially: only builtin "default" is known.
134+ assert_eq!(state.lock().profiles.list().count(), 1);
135+
136+ // Drop a new profile in. The debouncer waits 500 ms; allow up
137+ // to 5 s before declaring failure (CI fs latency).
138+ fs::write(
139+ profiles_dir.join("hot.toml"),
140+ "name = \"hot\"\ndescription = \"hot-reloaded\"\n",
141+ )
142+ .unwrap();
143+
144+ let deadline = Instant::now() + Duration::from_secs(5);
145+ let mut saw_new = false;
146+ while Instant::now() < deadline {
147+ std::thread::sleep(Duration::from_millis(100));
148+ if state.lock().profiles.list().any(|p| p.name == "hot") {
149+ saw_new = true;
150+ break;
151+ }
152+ }
153+ assert!(saw_new, "watcher should have reloaded after file appeared");
154+ }
155+}
Added file: crates/headroom-core/tests/shipped_profiles.rs+24 additions
@@ -0,0 +1,24 @@
1+use headroom_core::profile::Profile;
2+use std::fs;
3+use std::path::Path;
4+
5+#[test]
6+fn all_shipped_profiles_parse() {
7+ let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../profiles");
8+ let mut names = vec![];
9+ for entry in fs::read_dir(&dir).unwrap() {
10+ let path = entry.unwrap().path();
11+ if path.extension().and_then(|e| e.to_str()) != Some("toml") {
12+ continue;
13+ }
14+ let text = fs::read_to_string(&path).unwrap();
15+ let p: Profile = toml::from_str(&text)
16+ .unwrap_or_else(|e| panic!("failed to parse {}: {e}", path.display()));
17+ let stem = path.file_stem().unwrap().to_str().unwrap();
18+ assert_eq!(p.name, stem, "name/filename mismatch in {}", path.display());
19+ names.push(p.name);
20+ }
21+ names.sort();
22+ eprintln!("parsed profiles: {names:?}");
23+ assert!(names.len() >= 14, "expected >=14 profiles, got {}", names.len());
24+}
Added file: profiles/broadcast-14.toml+49 additions
@@ -0,0 +1,49 @@
1+name = "broadcast-14"
2+description = "Normalizes everything to -14 LUFS (streaming-platform loudness) so all sources match."
3+
4+# -14 lufs = streaming-platform normalized target; everything sits at one loudness
5+[agc]
6+enabled = true
7+target_lufs = -14.0
8+attack_ms = 2500.0
9+release_ms = 1000.0
10+silence_threshold_lufs = -70.0
11+max_boost_db = 12.0
12+max_cut_db = 12.0
13+
14+# light rms glue to hold the target
15+[compressor]
16+enabled = true
17+detector = "rms"
18+threshold_db = -22.0
19+ratio = 2.0
20+knee_db = 8.0
21+attack_ms = 20.0
22+release_ms = 250.0
23+makeup_db = "auto"
24+rms_window_ms = 100.0
25+
26+# -1 dBTP ceiling (ebu r128 / streaming convention)
27+[limiter]
28+ceiling_dbtp = -1.0
29+lookahead_ms = 3.0
30+release_ms = 100.0
31+hold_ms = 5.0
32+oversample = 4
33+link = "stereo"
34+
35+[limiter.soft]
36+max_psr_db = 16.0
37+static_ceiling_dbtp = -6.0
38+attack_ms = 6.0
39+release_ms = 200.0
40+
41+[meters]
42+publish_hz = 20.0
43+
44+[[rules]]
45+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }
46+route = "bypass"
47+
48+[default_route]
49+route = "processed"
Added file: profiles/bypass-all.toml+23 additions
@@ -0,0 +1,23 @@
1+name = "bypass-all"
2+description = "Kill switch. Everything routes to the bypass sink; limiter stands by."
3+
4+[agc]
5+enabled = false
6+
7+[compressor]
8+enabled = false
9+
10+[limiter]
11+# fail-safe in case a stream lands on the processed sink anyway
12+ceiling_dbtp = -0.1
13+lookahead_ms = 2.0
14+release_ms = 80.0
15+hold_ms = 5.0
16+oversample = 4
17+link = "stereo"
18+
19+[meters]
20+publish_hz = 10.0
21+
22+[default_route]
23+route = "bypass"
Added file: profiles/commute.toml+49 additions
@@ -0,0 +1,49 @@
1+name = "commute"
2+description = "Listening in noise (earbuds/transit): heavy normalization and boost, kept loud."
3+
4+# inverse of night: big boost, small cut — drag quiet passages up over noise
5+[agc]
6+enabled = true
7+target_lufs = -16.0
8+attack_ms = 800.0
9+release_ms = 300.0
10+silence_threshold_lufs = -65.0
11+max_boost_db = 20.0
12+max_cut_db = 6.0
13+
14+# firm peak compression, intelligibility over fidelity
15+[compressor]
16+enabled = true
17+detector = "peak"
18+threshold_db = -22.0
19+ratio = 4.0
20+knee_db = 4.0
21+attack_ms = 5.0
22+release_ms = 80.0
23+makeup_db = "auto"
24+
25+# hot ceiling — stay above traffic, not be quiet
26+[limiter]
27+ceiling_dbtp = -0.3
28+lookahead_ms = 2.0
29+release_ms = 60.0
30+hold_ms = 5.0
31+oversample = 4
32+link = "stereo"
33+
34+# tight psr cap so nothing ducks below the noise floor
35+[limiter.soft]
36+max_psr_db = 8.0
37+static_ceiling_dbtp = -8.0
38+attack_ms = 3.0
39+release_ms = 120.0
40+
41+[meters]
42+publish_hz = 20.0
43+
44+[[rules]]
45+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }
46+route = "bypass"
47+
48+[default_route]
49+route = "processed"
Added file: profiles/default.toml+62 additions
@@ -0,0 +1,62 @@
1+name = "default"
2+description = "Gentle transparent processing for everyday use."
3+
4+[agc]
5+enabled = true
6+target_lufs = -18.0
7+attack_ms = 2000.0
8+release_ms = 800.0
9+silence_threshold_lufs = -70.0
10+max_boost_db = 12.0
11+max_cut_db = 12.0
12+
13+[compressor]
14+enabled = true
15+detector = "peak"
16+threshold_db = -24.0
17+ratio = 2.5
18+knee_db = 6.0
19+attack_ms = 10.0
20+release_ms = 100.0
21+makeup_db = "auto"
22+
23+[limiter]
24+ceiling_dbtp = -0.1
25+lookahead_ms = 2.0
26+release_ms = 80.0
27+hold_ms = 5.0
28+oversample = 4
29+link = "stereo"
30+
31+# soft tier: caps crest at program_lufs + max_psr_db. omit for pure brickwall
32+[limiter.soft]
33+max_psr_db = 14.0
34+static_ceiling_dbtp = -6.0
35+attack_ms = 5.0
36+release_ms = 200.0
37+
38+[meters]
39+publish_hz = 20.0
40+
41+# music players + daws bypass the processor
42+[[rules]]
43+match = { process_binary = ["spotify", "mpv", "vlc", "ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }
44+route = "bypass"
45+
46+# browsers + voice chat processed
47+[[rules]]
48+match = { process_binary = [
49+ "firefox",
50+ "chromium",
51+ "google-chrome",
52+ "Discord",
53+ "discord",
54+ "element-desktop",
55+ "Slack",
56+ "zoom",
57+ "WEBRTC VoiceEngine",
58+] }
59+route = "processed"
60+
61+[default_route]
62+route = "processed"
Added file: profiles/gaming.toml+69 additions
@@ -0,0 +1,69 @@
1+name = "gaming"
2+description = "Latency-first: games bypass the bus, voice chat is processed, notifications tamed per-app."
3+
4+# dsp here governs the comms path (voice chat) — the only thing on the bus.
5+# speech-like leveling
6+[agc]
7+enabled = true
8+target_lufs = -16.0
9+attack_ms = 600.0
10+release_ms = 200.0
11+silence_threshold_lufs = -65.0
12+max_boost_db = 20.0
13+max_cut_db = 6.0
14+
15+[compressor]
16+enabled = true
17+detector = "peak"
18+threshold_db = -22.0
19+ratio = 3.0
20+knee_db = 4.0
21+attack_ms = 3.0
22+release_ms = 50.0
23+makeup_db = "auto"
24+
25+[limiter]
26+ceiling_dbtp = -0.3
27+lookahead_ms = 2.0
28+release_ms = 50.0
29+hold_ms = 3.0
30+oversample = 4
31+link = "stereo"
32+
33+[limiter.soft]
34+max_psr_db = 10.0
35+static_ceiling_dbtp = -8.0
36+attack_ms = 2.0
37+release_ms = 100.0
38+
39+[meters]
40+publish_hz = 20.0
41+
42+# voice chat processed
43+[[rules]]
44+match = { process_binary = ["Discord", "discord", "element-desktop", "Slack", "zoom", "WEBRTC VoiceEngine", "mumble", "TeamSpeak"] }
45+route = "processed"
46+
47+# everything else (incl. the game) bypasses → zero added latency
48+[default_route]
49+route = "bypass"
50+
51+# layer a tames notification blasts + evens chat via channelVolumes,
52+# zero signal-path latency — rides on top of the bypass-routed audio
53+[per_app]
54+enabled = true
55+default_enabled = false
56+
57+[[per_app.rules]]
58+match = { process_binary = ["Discord", "discord", "element-desktop", "Slack", "zoom", "mumble", "TeamSpeak"] }
59+enabled = true
60+peak_threshold_db = -6.0
61+rms_target_db = -18.0
62+max_cut_db = 12.0
63+peak_attack_ms = 5.0
64+peak_release_ms = 400.0
65+
66+# never level game or music — they own their mix
67+[[per_app.rules]]
68+match = { process_binary = ["spotify", "mpv", "vlc"] }
69+enabled = false
Added file: profiles/movie.toml+58 additions
@@ -0,0 +1,58 @@
1+name = "movie"
2+description = "Wide-dynamic-range film: lifts quiet dialogue, keeps action punchy but bounded."
3+
4+# slow agc toward film reference; asymmetric — boost dialogue more than cut action
5+[agc]
6+enabled = true
7+target_lufs = -20.0
8+attack_ms = 2500.0
9+release_ms = 1000.0
10+silence_threshold_lufs = -70.0
11+max_boost_db = 12.0
12+max_cut_db = 8.0
13+
14+# soft-knee rms: lifts dialogue floor, leveling not crushing
15+[compressor]
16+enabled = true
17+detector = "rms"
18+threshold_db = -28.0
19+ratio = 2.0
20+knee_db = 8.0
21+attack_ms = 15.0
22+release_ms = 250.0
23+makeup_db = "auto"
24+rms_window_ms = 50.0
25+
26+[limiter]
27+ceiling_dbtp = -1.0
28+lookahead_ms = 3.0
29+release_ms = 120.0
30+hold_ms = 5.0
31+oversample = 4
32+link = "stereo"
33+
34+# generous psr cap: explosions punch through, just bounded
35+[limiter.soft]
36+max_psr_db = 18.0
37+static_ceiling_dbtp = -6.0
38+attack_ms = 8.0
39+release_ms = 250.0
40+
41+[meters]
42+publish_hz = 20.0
43+
44+# video players + browser video processed here (default/night bypass them). daws bypass
45+[[rules]]
46+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }
47+route = "bypass"
48+
49+[[rules]]
50+match = { process_binary = ["mpv", "vlc", "firefox", "chromium", "google-chrome"] }
51+route = "processed"
52+
53+[[rules]]
54+match = { media_role = ["Movie", "Video"] }
55+route = "processed"
56+
57+[default_route]
58+route = "processed"
Added file: profiles/music.toml+68 additions
@@ -0,0 +1,68 @@
1+name = "music"
2+description = "Inter-track loudness leveling for music players. Even volume, dynamics mostly preserved."
3+
4+# slow agc to a music target; long envelopes, leans on program loudness not transients
5+[agc]
6+enabled = true
7+target_lufs = -16.0
8+attack_ms = 3000.0
9+release_ms = 1500.0
10+silence_threshold_lufs = -70.0
11+max_boost_db = 12.0
12+max_cut_db = 12.0
13+
14+# very light soft-knee rms; glue not compression
15+[compressor]
16+enabled = true
17+detector = "rms"
18+threshold_db = -22.0
19+ratio = 1.5
20+knee_db = 10.0
21+attack_ms = 20.0
22+release_ms = 300.0
23+makeup_db = "auto"
24+rms_window_ms = 50.0
25+
26+[limiter]
27+ceiling_dbtp = -0.3
28+lookahead_ms = 2.0
29+release_ms = 100.0
30+hold_ms = 5.0
31+oversample = 4
32+link = "stereo"
33+
34+# wide psr cap preserves dynamics; soft tier only catches the rare hot track
35+[limiter.soft]
36+max_psr_db = 16.0
37+static_ceiling_dbtp = -5.0
38+attack_ms = 6.0
39+release_ms = 250.0
40+
41+[meters]
42+publish_hz = 20.0
43+
44+# music players processed here (default/night bypass them) — else nothing to act on
45+[[rules]]
46+match = { process_binary = [
47+ "spotify",
48+ "mpv",
49+ "vlc",
50+ "strawberry",
51+ "rhythmbox",
52+ "clementine",
53+ "audacious",
54+ "mpd",
55+] }
56+route = "processed"
57+
58+# daws bypass — trusted to set their own gain
59+[[rules]]
60+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }
61+route = "bypass"
62+
63+[[rules]]
64+match = { media_role = ["Music"] }
65+route = "processed"
66+
67+[default_route]
68+route = "processed"
Added file: profiles/night.toml+46 additions
@@ -0,0 +1,46 @@
1+name = "night"
2+description = "Aggressive narrow-dynamic listening for late hours."
3+
4+[agc]
5+enabled = true
6+target_lufs = -20.0
7+attack_ms = 1000.0
8+release_ms = 400.0
9+silence_threshold_lufs = -70.0
10+max_boost_db = 18.0
11+max_cut_db = 6.0
12+
13+[compressor]
14+enabled = true
15+detector = "peak"
16+threshold_db = -28.0
17+ratio = 4.0
18+knee_db = 8.0
19+attack_ms = 5.0
20+release_ms = 60.0
21+makeup_db = "auto"
22+
23+[limiter]
24+ceiling_dbtp = -1.0
25+lookahead_ms = 3.0
26+release_ms = 60.0
27+hold_ms = 5.0
28+oversample = 4
29+link = "stereo"
30+
31+# tight dynamics: 8 dB psr keeps transients down at low volume
32+[limiter.soft]
33+max_psr_db = 8.0
34+static_ceiling_dbtp = -10.0
35+attack_ms = 3.0
36+release_ms = 150.0
37+
38+[meters]
39+publish_hz = 20.0
40+
41+[[rules]]
42+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }
43+route = "bypass"
44+
45+[default_route]
46+route = "processed"
Added file: profiles/party.toml+48 additions
@@ -0,0 +1,48 @@
1+name = "party"
2+description = "Loud room playback (the anti-night): maximum perceived loudness, dynamics sacrificed."
3+
4+# push everything to a hot room-filling target; symmetric limits
5+[agc]
6+enabled = true
7+target_lufs = -12.0
8+attack_ms = 1500.0
9+release_ms = 600.0
10+silence_threshold_lufs = -70.0
11+max_boost_db = 12.0
12+max_cut_db = 12.0
13+
14+# heavy rms + auto-makeup: loud and even, fidelity be damned
15+[compressor]
16+enabled = true
17+detector = "rms"
18+threshold_db = -24.0
19+ratio = 6.0
20+knee_db = 6.0
21+attack_ms = 10.0
22+release_ms = 150.0
23+makeup_db = "auto"
24+rms_window_ms = 60.0
25+
26+# ceiling just under 0, no soft tier — let the brickwall slam
27+[limiter]
28+ceiling_dbtp = -0.1
29+lookahead_ms = 2.0
30+release_ms = 60.0
31+hold_ms = 3.0
32+oversample = 4
33+link = "stereo"
34+
35+[meters]
36+publish_hz = 20.0
37+
38+[[rules]]
39+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }
40+route = "bypass"
41+
42+# music players processed so the whole room mix is loud and even
43+[[rules]]
44+match = { process_binary = ["spotify", "mpv", "vlc", "strawberry", "rhythmbox", "audacious", "mpd"] }
45+route = "processed"
46+
47+[default_route]
48+route = "processed"
Added file: profiles/podcast.toml+54 additions
@@ -0,0 +1,54 @@
1+name = "podcast"
2+description = "Spoken-word playback (podcasts/audiobooks): even narration loudness, smooth and unfatiguing."
3+
4+# one-way narration (cf. speech = fast duplex voip): moderate agc, steady loudness
5+[agc]
6+enabled = true
7+target_lufs = -16.0
8+attack_ms = 1000.0
9+release_ms = 600.0
10+silence_threshold_lufs = -65.0
11+max_boost_db = 18.0
12+max_cut_db = 6.0
13+
14+# rms with slower release than speech — smooth even narration
15+[compressor]
16+enabled = true
17+detector = "rms"
18+threshold_db = -24.0
19+ratio = 3.0
20+knee_db = 6.0
21+attack_ms = 15.0
22+release_ms = 200.0
23+makeup_db = "auto"
24+rms_window_ms = 120.0
25+
26+[limiter]
27+ceiling_dbtp = -0.5
28+lookahead_ms = 2.0
29+release_ms = 60.0
30+hold_ms = 4.0
31+oversample = 4
32+link = "stereo"
33+
34+# moderate psr cap: tames plosives/laughter without squashing speech
35+[limiter.soft]
36+max_psr_db = 10.0
37+static_ceiling_dbtp = -8.0
38+attack_ms = 3.0
39+release_ms = 150.0
40+
41+[meters]
42+publish_hz = 20.0
43+
44+[[rules]]
45+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }
46+route = "bypass"
47+
48+# podcast/audiobook playback rides browser or media players
49+[[rules]]
50+match = { process_binary = ["mpv", "vlc", "firefox", "chromium", "google-chrome"] }
51+route = "processed"
52+
53+[default_route]
54+route = "processed"
Added file: profiles/quiet-hours.toml+49 additions
@@ -0,0 +1,49 @@
1+name = "quiet-hours"
2+description = "More aggressive than night: very low ceiling and narrow dynamics for sleeping households."
3+
4+# pulls everything to a low target with a fast loop so nothing escapes upward
5+[agc]
6+enabled = true
7+target_lufs = -24.0
8+attack_ms = 800.0
9+release_ms = 350.0
10+silence_threshold_lufs = -70.0
11+max_boost_db = 20.0
12+max_cut_db = 6.0
13+
14+# hard fast peak compression so a sudden effect can't carry next door
15+[compressor]
16+enabled = true
17+detector = "peak"
18+threshold_db = -30.0
19+ratio = 6.0
20+knee_db = 8.0
21+attack_ms = 4.0
22+release_ms = 50.0
23+makeup_db = "auto"
24+
25+# drastically reduced ceiling — the defining knob; even a true over stays low
26+[limiter]
27+ceiling_dbtp = -6.0
28+lookahead_ms = 3.0
29+release_ms = 60.0
30+hold_ms = 5.0
31+oversample = 4
32+link = "stereo"
33+
34+# extremely tight psr cap (~5 dB crest) — transients flattened
35+[limiter.soft]
36+max_psr_db = 5.0
37+static_ceiling_dbtp = -14.0
38+attack_ms = 2.0
39+release_ms = 120.0
40+
41+[meters]
42+publish_hz = 20.0
43+
44+[[rules]]
45+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }
46+route = "bypass"
47+
48+[default_route]
49+route = "processed"
Added file: profiles/speech.toml+47 additions
@@ -0,0 +1,47 @@
1+name = "speech"
2+description = "VoIP-focused: fast envelopes, controlled dynamic range."
3+
4+[agc]
5+enabled = true
6+target_lufs = -16.0
7+attack_ms = 600.0
8+release_ms = 200.0
9+silence_threshold_lufs = -65.0
10+max_boost_db = 20.0
11+max_cut_db = 6.0
12+
13+[compressor]
14+enabled = true
15+detector = "peak"
16+threshold_db = -22.0
17+ratio = 3.0
18+knee_db = 4.0
19+attack_ms = 3.0
20+release_ms = 50.0
21+makeup_db = "auto"
22+
23+[limiter]
24+ceiling_dbtp = -0.3
25+lookahead_ms = 2.0
26+release_ms = 50.0
27+hold_ms = 3.0
28+oversample = 4
29+link = "stereo"
30+
31+# moderate psr cap: control plosives without losing diction
32+[limiter.soft]
33+max_psr_db = 10.0
34+static_ceiling_dbtp = -8.0
35+attack_ms = 2.0
36+release_ms = 100.0
37+
38+[meters]
39+publish_hz = 20.0
40+
41+# mark common voip apps explicitly
42+[[rules]]
43+match = { process_binary = ["Discord", "discord", "element-desktop", "Slack", "zoom", "WEBRTC VoiceEngine"] }
44+route = "processed"
45+
46+[default_route]
47+route = "processed"
Added file: profiles/spike-protection.toml+38 additions
@@ -0,0 +1,38 @@
1+name = "spike-protection"
2+description = "Minimal processing until a high threshold. Untouched audio, hard guard against sudden blasts."
3+
4+# no leveling; only catches sudden jarring level jumps (autoplay ads, stingers)
5+[agc]
6+enabled = false
7+
8+# fast high-threshold peak catch: <-6 dBFS passes 1:1, spikes clamped hard (10:1).
9+# makeup_db=0 — never raise the floor, normal material untouched
10+[compressor]
11+enabled = true
12+detector = "peak"
13+threshold_db = -6.0
14+ratio = 10.0
15+knee_db = 2.0
16+attack_ms = 1.0
17+release_ms = 120.0
18+makeup_db = 0.0
19+
20+# brickwall safety net just below 0 dBTP. no soft tier — only stop a true over
21+[limiter]
22+ceiling_dbtp = -0.5
23+lookahead_ms = 2.0
24+release_ms = 80.0
25+hold_ms = 5.0
26+oversample = 4
27+link = "stereo"
28+
29+[meters]
30+publish_hz = 20.0
31+
32+# production/creative tools bypass regardless
33+[[rules]]
34+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }
35+route = "bypass"
36+
37+[default_route]
38+route = "processed"
Added file: profiles/transparent.toml+23 additions
@@ -0,0 +1,23 @@
1+name = "transparent"
2+description = "Limiter only. Compressor and AGC bypassed; the safety net."
3+
4+[agc]
5+enabled = false
6+
7+[compressor]
8+enabled = false
9+
10+[limiter]
11+ceiling_dbtp = -0.1
12+lookahead_ms = 2.0
13+release_ms = 80.0
14+hold_ms = 5.0
15+oversample = 4
16+link = "stereo"
17+# no soft tier — pure brickwall: under -0.1 dBTP passes untouched, transients can hit ceiling
18+
19+[meters]
20+publish_hz = 20.0
21+
22+[default_route]
23+route = "processed"