Commit 9de515ea3b07
tarball19 files changed+2800-0
@@ -0,0 +1,496 @@
1
+//! profile types; mirrors the toml schema2
+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 directory29
+ 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 wins42
+ #[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 adjusting153
+ 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` from204
+/// `"auto"` (untagged matches unit variants against null, not a name), so every shipped profile205
+/// 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 ratio210
+ #[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/8249
+ pub oversample: usize,250
+ pub link: LinkMode,251
+ /// omit for pure brickwall252
+ 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 slower304
+ 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 rule333
+ 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-combine356
+ #[serde(default = "default_smoother_ms")]357
+ pub smoother_ms: f32,358
+ /// below this dB change, smoother updates internally but no `Props.channelVolumes` write359
+ #[serde(default = "default_write_db_threshold")]360
+ pub write_db_threshold: f32,361
+ /// hard per-stream rate limit362
+ #[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 rule370
+ /// (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
+ true392
+}393
+const fn default_peak_threshold_db() -> f32 {394
+ -6.0395
+}396
+const fn default_rms_target_db() -> f32 {397
+ -20.0398
+}399
+const fn default_max_cut_db() -> f32 {400
+ 12.0401
+}402
+const fn default_peak_attack_ms() -> f32 {403
+ 5.0404
+}405
+const fn default_peak_release_ms() -> f32 {406
+ 500.0407
+}408
+const fn default_rms_window_ms() -> f32 {409
+ 1500.0410
+}411
+const fn default_smoother_ms() -> f32 {412
+ 30.0413
+}414
+const fn default_write_db_threshold() -> f32 {415
+ 0.5416
+}417
+const fn default_min_write_interval_ms() -> f32 {418
+ 100.0419
+}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 it426
+ #[default]427
+ Ceiling,428
+ /// stop adjusting entirely until the user opts back in429
+ 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_binary463
+ .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 same470
+ // 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
+}@@ -0,0 +1,1368 @@
1
+//! profile store: disk profiles + persisted overlay → effective profile. overlay persists across2
+//! `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 load26
+ 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 default51
+ 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 materialization55
+ pub setting_overrides: BTreeMap<String, toml::Value>,56
+ /// intentionally persisted across restarts57
+ 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 it62
+ #[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 fatal91
+ 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 scanning100
+ pub share_dirs: Vec<PathBuf>,101
+ /// scanned directly (no `profiles/` suffix) and last, so they win on name conflict; backs102
+ /// `--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 prefix108
+ /// (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
+ dirs143
+}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 in154
+ paths: Option<StorePaths>,155
+ /// set when `active_name` isn't on disk; `effective` falls back to builtin meanwhile156
+ active_missing: Option<String>,157
+ /// one-shot warnings for the runtime to drain at startup158
+ 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 conflict202
+ 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.effective228
+ }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
+ paths236
+ .extra_profile_dirs237
+ .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.overlay247
+ .active_name248
+ .as_deref()249
+ .unwrap_or(BUILTIN_DEFAULT_NAME)250
+ }251
+252
+ #[must_use]253
+ pub fn bypass_global(&self) -> bool {254
+ self.overlay.bypass_global255
+ }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.overlay261
+ .setting_overrides262
+ .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 defensively337
+ 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.overlay385
+ .per_app_overrides386
+ .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.enabled402
+ }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 reloads413
+ 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 consistent439
+ 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 it453
+ 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
+// Materialization497
+// ---------------------------------------------------------------------------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 = overlay512
+ .active_name513
+ .as_deref()514
+ .unwrap_or(BUILTIN_DEFAULT_NAME);515
+ profiles516
+ .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 back577
+ 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
+ materialised597
+}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-empty601
+ // fields, and many tools (pw-cat, electron/flatpak wrappers) set only `application.name`, so a602
+ // 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 retain605
+ // 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 matching628
+/// authored rule in place (preserving thresholds), else prepend two synthetic single-field rules629
+/// (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
+// Helpers677
+// ---------------------------------------------------------------------------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 = path713
+ .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.name722
+ ));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
+ true754
+}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) => arr791
+ .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
+// Tests829
+// ---------------------------------------------------------------------------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 this840
+ // 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 under876
+ // `load`'s later-shadows-earlier rule), and each entry gains a877
+ // `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
+ ..paths938
+ };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 with955
+ // 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
+ ..paths971
+ };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 identifier1020
+ // but streams can advertise themselves via either1021
+ // `application.process.binary` or `application.name`1022
+ // (or neither — those go through default_route). The1023
+ // overlay materialises BOTH single-field rules so each1024
+ // 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 = rules1030
+ .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 = rules1036
+ .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 variants1046
+ // of a single-app override must clear on unset; otherwise1047
+ // a re-add would stack rules and the matcher would carry1048
+ // 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<_> = s1054
+ .effective()1055
+ .rules1056
+ .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 in1068
+ // `apply_route_overrides` would silently drop any base-profile1069
+ // rule whose single-field shape coincided with the overlay's1070
+ // emit pattern. The fix is to delete the retain entirely —1071
+ // prepending already makes the overlay win first-match1072
+ // iteration, and removing the retain closes the data-loss1073
+ // surface. This test pins the surviving-rule behaviour so a1074
+ // 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) plus1097
+ // 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<_> = s1114
+ .effective()1115
+ .rules1116
+ .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 and1152
+ // 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 = s1182
+ .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 = s1194
+ .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 = rules1289
+ .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 = rules1294
+ .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 = true1310
+[[per_app.rules]]1311
+match = { process_binary = ["strawberry"] }1312
+enabled = true1313
+max_cut_db = 18.01314
+"#,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 and1321
+ // its custom max_cut is preserved.1322
+ let strawberry: Vec<_> = s1323
+ .effective()1324
+ .per_app1325
+ .rules1326
+ .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 = s21345
+ .effective()1346
+ .per_app1347
+ .rules1348
+ .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
+}@@ -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 = false44
+[route_overrides]45
+[setting_overrides]46
+"limiter.no_such_field" = 147
+"limiter.ceiling_dbtp" = -3.048
+"#,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
+}@@ -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 storm14
+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
+ debouncer56
+ .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 the83
+ /// 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 up137
+ // 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
+}@@ -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
+}@@ -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 loudness5
+[agc]6
+enabled = true7
+target_lufs = -14.08
+attack_ms = 2500.09
+release_ms = 1000.010
+silence_threshold_lufs = -70.011
+max_boost_db = 12.012
+max_cut_db = 12.013
+14
+# light rms glue to hold the target15
+[compressor]16
+enabled = true17
+detector = "rms"18
+threshold_db = -22.019
+ratio = 2.020
+knee_db = 8.021
+attack_ms = 20.022
+release_ms = 250.023
+makeup_db = "auto"24
+rms_window_ms = 100.025
+26
+# -1 dBTP ceiling (ebu r128 / streaming convention)27
+[limiter]28
+ceiling_dbtp = -1.029
+lookahead_ms = 3.030
+release_ms = 100.031
+hold_ms = 5.032
+oversample = 433
+link = "stereo"34
+35
+[limiter.soft]36
+max_psr_db = 16.037
+static_ceiling_dbtp = -6.038
+attack_ms = 6.039
+release_ms = 200.040
+41
+[meters]42
+publish_hz = 20.043
+44
+[[rules]]45
+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }46
+route = "bypass"47
+48
+[default_route]49
+route = "processed"@@ -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 = false6
+7
+[compressor]8
+enabled = false9
+10
+[limiter]11
+# fail-safe in case a stream lands on the processed sink anyway12
+ceiling_dbtp = -0.113
+lookahead_ms = 2.014
+release_ms = 80.015
+hold_ms = 5.016
+oversample = 417
+link = "stereo"18
+19
+[meters]20
+publish_hz = 10.021
+22
+[default_route]23
+route = "bypass"@@ -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 noise5
+[agc]6
+enabled = true7
+target_lufs = -16.08
+attack_ms = 800.09
+release_ms = 300.010
+silence_threshold_lufs = -65.011
+max_boost_db = 20.012
+max_cut_db = 6.013
+14
+# firm peak compression, intelligibility over fidelity15
+[compressor]16
+enabled = true17
+detector = "peak"18
+threshold_db = -22.019
+ratio = 4.020
+knee_db = 4.021
+attack_ms = 5.022
+release_ms = 80.023
+makeup_db = "auto"24
+25
+# hot ceiling — stay above traffic, not be quiet26
+[limiter]27
+ceiling_dbtp = -0.328
+lookahead_ms = 2.029
+release_ms = 60.030
+hold_ms = 5.031
+oversample = 432
+link = "stereo"33
+34
+# tight psr cap so nothing ducks below the noise floor35
+[limiter.soft]36
+max_psr_db = 8.037
+static_ceiling_dbtp = -8.038
+attack_ms = 3.039
+release_ms = 120.040
+41
+[meters]42
+publish_hz = 20.043
+44
+[[rules]]45
+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }46
+route = "bypass"47
+48
+[default_route]49
+route = "processed"@@ -0,0 +1,62 @@
1
+name = "default"2
+description = "Gentle transparent processing for everyday use."3
+4
+[agc]5
+enabled = true6
+target_lufs = -18.07
+attack_ms = 2000.08
+release_ms = 800.09
+silence_threshold_lufs = -70.010
+max_boost_db = 12.011
+max_cut_db = 12.012
+13
+[compressor]14
+enabled = true15
+detector = "peak"16
+threshold_db = -24.017
+ratio = 2.518
+knee_db = 6.019
+attack_ms = 10.020
+release_ms = 100.021
+makeup_db = "auto"22
+23
+[limiter]24
+ceiling_dbtp = -0.125
+lookahead_ms = 2.026
+release_ms = 80.027
+hold_ms = 5.028
+oversample = 429
+link = "stereo"30
+31
+# soft tier: caps crest at program_lufs + max_psr_db. omit for pure brickwall32
+[limiter.soft]33
+max_psr_db = 14.034
+static_ceiling_dbtp = -6.035
+attack_ms = 5.036
+release_ms = 200.037
+38
+[meters]39
+publish_hz = 20.040
+41
+# music players + daws bypass the processor42
+[[rules]]43
+match = { process_binary = ["spotify", "mpv", "vlc", "ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }44
+route = "bypass"45
+46
+# browsers + voice chat processed47
+[[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"@@ -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 leveling6
+[agc]7
+enabled = true8
+target_lufs = -16.09
+attack_ms = 600.010
+release_ms = 200.011
+silence_threshold_lufs = -65.012
+max_boost_db = 20.013
+max_cut_db = 6.014
+15
+[compressor]16
+enabled = true17
+detector = "peak"18
+threshold_db = -22.019
+ratio = 3.020
+knee_db = 4.021
+attack_ms = 3.022
+release_ms = 50.023
+makeup_db = "auto"24
+25
+[limiter]26
+ceiling_dbtp = -0.327
+lookahead_ms = 2.028
+release_ms = 50.029
+hold_ms = 3.030
+oversample = 431
+link = "stereo"32
+33
+[limiter.soft]34
+max_psr_db = 10.035
+static_ceiling_dbtp = -8.036
+attack_ms = 2.037
+release_ms = 100.038
+39
+[meters]40
+publish_hz = 20.041
+42
+# voice chat processed43
+[[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 latency48
+[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 audio53
+[per_app]54
+enabled = true55
+default_enabled = false56
+57
+[[per_app.rules]]58
+match = { process_binary = ["Discord", "discord", "element-desktop", "Slack", "zoom", "mumble", "TeamSpeak"] }59
+enabled = true60
+peak_threshold_db = -6.061
+rms_target_db = -18.062
+max_cut_db = 12.063
+peak_attack_ms = 5.064
+peak_release_ms = 400.065
+66
+# never level game or music — they own their mix67
+[[per_app.rules]]68
+match = { process_binary = ["spotify", "mpv", "vlc"] }69
+enabled = false@@ -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 action5
+[agc]6
+enabled = true7
+target_lufs = -20.08
+attack_ms = 2500.09
+release_ms = 1000.010
+silence_threshold_lufs = -70.011
+max_boost_db = 12.012
+max_cut_db = 8.013
+14
+# soft-knee rms: lifts dialogue floor, leveling not crushing15
+[compressor]16
+enabled = true17
+detector = "rms"18
+threshold_db = -28.019
+ratio = 2.020
+knee_db = 8.021
+attack_ms = 15.022
+release_ms = 250.023
+makeup_db = "auto"24
+rms_window_ms = 50.025
+26
+[limiter]27
+ceiling_dbtp = -1.028
+lookahead_ms = 3.029
+release_ms = 120.030
+hold_ms = 5.031
+oversample = 432
+link = "stereo"33
+34
+# generous psr cap: explosions punch through, just bounded35
+[limiter.soft]36
+max_psr_db = 18.037
+static_ceiling_dbtp = -6.038
+attack_ms = 8.039
+release_ms = 250.040
+41
+[meters]42
+publish_hz = 20.043
+44
+# video players + browser video processed here (default/night bypass them). daws bypass45
+[[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"@@ -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 transients5
+[agc]6
+enabled = true7
+target_lufs = -16.08
+attack_ms = 3000.09
+release_ms = 1500.010
+silence_threshold_lufs = -70.011
+max_boost_db = 12.012
+max_cut_db = 12.013
+14
+# very light soft-knee rms; glue not compression15
+[compressor]16
+enabled = true17
+detector = "rms"18
+threshold_db = -22.019
+ratio = 1.520
+knee_db = 10.021
+attack_ms = 20.022
+release_ms = 300.023
+makeup_db = "auto"24
+rms_window_ms = 50.025
+26
+[limiter]27
+ceiling_dbtp = -0.328
+lookahead_ms = 2.029
+release_ms = 100.030
+hold_ms = 5.031
+oversample = 432
+link = "stereo"33
+34
+# wide psr cap preserves dynamics; soft tier only catches the rare hot track35
+[limiter.soft]36
+max_psr_db = 16.037
+static_ceiling_dbtp = -5.038
+attack_ms = 6.039
+release_ms = 250.040
+41
+[meters]42
+publish_hz = 20.043
+44
+# music players processed here (default/night bypass them) — else nothing to act on45
+[[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 gain59
+[[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"@@ -0,0 +1,46 @@
1
+name = "night"2
+description = "Aggressive narrow-dynamic listening for late hours."3
+4
+[agc]5
+enabled = true6
+target_lufs = -20.07
+attack_ms = 1000.08
+release_ms = 400.09
+silence_threshold_lufs = -70.010
+max_boost_db = 18.011
+max_cut_db = 6.012
+13
+[compressor]14
+enabled = true15
+detector = "peak"16
+threshold_db = -28.017
+ratio = 4.018
+knee_db = 8.019
+attack_ms = 5.020
+release_ms = 60.021
+makeup_db = "auto"22
+23
+[limiter]24
+ceiling_dbtp = -1.025
+lookahead_ms = 3.026
+release_ms = 60.027
+hold_ms = 5.028
+oversample = 429
+link = "stereo"30
+31
+# tight dynamics: 8 dB psr keeps transients down at low volume32
+[limiter.soft]33
+max_psr_db = 8.034
+static_ceiling_dbtp = -10.035
+attack_ms = 3.036
+release_ms = 150.037
+38
+[meters]39
+publish_hz = 20.040
+41
+[[rules]]42
+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }43
+route = "bypass"44
+45
+[default_route]46
+route = "processed"@@ -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 limits5
+[agc]6
+enabled = true7
+target_lufs = -12.08
+attack_ms = 1500.09
+release_ms = 600.010
+silence_threshold_lufs = -70.011
+max_boost_db = 12.012
+max_cut_db = 12.013
+14
+# heavy rms + auto-makeup: loud and even, fidelity be damned15
+[compressor]16
+enabled = true17
+detector = "rms"18
+threshold_db = -24.019
+ratio = 6.020
+knee_db = 6.021
+attack_ms = 10.022
+release_ms = 150.023
+makeup_db = "auto"24
+rms_window_ms = 60.025
+26
+# ceiling just under 0, no soft tier — let the brickwall slam27
+[limiter]28
+ceiling_dbtp = -0.129
+lookahead_ms = 2.030
+release_ms = 60.031
+hold_ms = 3.032
+oversample = 433
+link = "stereo"34
+35
+[meters]36
+publish_hz = 20.037
+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 even43
+[[rules]]44
+match = { process_binary = ["spotify", "mpv", "vlc", "strawberry", "rhythmbox", "audacious", "mpd"] }45
+route = "processed"46
+47
+[default_route]48
+route = "processed"@@ -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 loudness5
+[agc]6
+enabled = true7
+target_lufs = -16.08
+attack_ms = 1000.09
+release_ms = 600.010
+silence_threshold_lufs = -65.011
+max_boost_db = 18.012
+max_cut_db = 6.013
+14
+# rms with slower release than speech — smooth even narration15
+[compressor]16
+enabled = true17
+detector = "rms"18
+threshold_db = -24.019
+ratio = 3.020
+knee_db = 6.021
+attack_ms = 15.022
+release_ms = 200.023
+makeup_db = "auto"24
+rms_window_ms = 120.025
+26
+[limiter]27
+ceiling_dbtp = -0.528
+lookahead_ms = 2.029
+release_ms = 60.030
+hold_ms = 4.031
+oversample = 432
+link = "stereo"33
+34
+# moderate psr cap: tames plosives/laughter without squashing speech35
+[limiter.soft]36
+max_psr_db = 10.037
+static_ceiling_dbtp = -8.038
+attack_ms = 3.039
+release_ms = 150.040
+41
+[meters]42
+publish_hz = 20.043
+44
+[[rules]]45
+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }46
+route = "bypass"47
+48
+# podcast/audiobook playback rides browser or media players49
+[[rules]]50
+match = { process_binary = ["mpv", "vlc", "firefox", "chromium", "google-chrome"] }51
+route = "processed"52
+53
+[default_route]54
+route = "processed"@@ -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 upward5
+[agc]6
+enabled = true7
+target_lufs = -24.08
+attack_ms = 800.09
+release_ms = 350.010
+silence_threshold_lufs = -70.011
+max_boost_db = 20.012
+max_cut_db = 6.013
+14
+# hard fast peak compression so a sudden effect can't carry next door15
+[compressor]16
+enabled = true17
+detector = "peak"18
+threshold_db = -30.019
+ratio = 6.020
+knee_db = 8.021
+attack_ms = 4.022
+release_ms = 50.023
+makeup_db = "auto"24
+25
+# drastically reduced ceiling — the defining knob; even a true over stays low26
+[limiter]27
+ceiling_dbtp = -6.028
+lookahead_ms = 3.029
+release_ms = 60.030
+hold_ms = 5.031
+oversample = 432
+link = "stereo"33
+34
+# extremely tight psr cap (~5 dB crest) — transients flattened35
+[limiter.soft]36
+max_psr_db = 5.037
+static_ceiling_dbtp = -14.038
+attack_ms = 2.039
+release_ms = 120.040
+41
+[meters]42
+publish_hz = 20.043
+44
+[[rules]]45
+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }46
+route = "bypass"47
+48
+[default_route]49
+route = "processed"@@ -0,0 +1,47 @@
1
+name = "speech"2
+description = "VoIP-focused: fast envelopes, controlled dynamic range."3
+4
+[agc]5
+enabled = true6
+target_lufs = -16.07
+attack_ms = 600.08
+release_ms = 200.09
+silence_threshold_lufs = -65.010
+max_boost_db = 20.011
+max_cut_db = 6.012
+13
+[compressor]14
+enabled = true15
+detector = "peak"16
+threshold_db = -22.017
+ratio = 3.018
+knee_db = 4.019
+attack_ms = 3.020
+release_ms = 50.021
+makeup_db = "auto"22
+23
+[limiter]24
+ceiling_dbtp = -0.325
+lookahead_ms = 2.026
+release_ms = 50.027
+hold_ms = 3.028
+oversample = 429
+link = "stereo"30
+31
+# moderate psr cap: control plosives without losing diction32
+[limiter.soft]33
+max_psr_db = 10.034
+static_ceiling_dbtp = -8.035
+attack_ms = 2.036
+release_ms = 100.037
+38
+[meters]39
+publish_hz = 20.040
+41
+# mark common voip apps explicitly42
+[[rules]]43
+match = { process_binary = ["Discord", "discord", "element-desktop", "Slack", "zoom", "WEBRTC VoiceEngine"] }44
+route = "processed"45
+46
+[default_route]47
+route = "processed"@@ -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 = false7
+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 untouched10
+[compressor]11
+enabled = true12
+detector = "peak"13
+threshold_db = -6.014
+ratio = 10.015
+knee_db = 2.016
+attack_ms = 1.017
+release_ms = 120.018
+makeup_db = 0.019
+20
+# brickwall safety net just below 0 dBTP. no soft tier — only stop a true over21
+[limiter]22
+ceiling_dbtp = -0.523
+lookahead_ms = 2.024
+release_ms = 80.025
+hold_ms = 5.026
+oversample = 427
+link = "stereo"28
+29
+[meters]30
+publish_hz = 20.031
+32
+# production/creative tools bypass regardless33
+[[rules]]34
+match = { process_binary = ["ardour", "reaper", "qpwgraph", "carla", "bitwig-studio"] }35
+route = "bypass"36
+37
+[default_route]38
+route = "processed"@@ -0,0 +1,23 @@
1
+name = "transparent"2
+description = "Limiter only. Compressor and AGC bypassed; the safety net."3
+4
+[agc]5
+enabled = false6
+7
+[compressor]8
+enabled = false9
+10
+[limiter]11
+ceiling_dbtp = -0.112
+lookahead_ms = 2.013
+release_ms = 80.014
+hold_ms = 5.015
+oversample = 416
+link = "stereo"17
+# no soft tier — pure brickwall: under -0.1 dBTP passes untouched, transients can hit ceiling18
+19
+[meters]20
+publish_hz = 20.021
+22
+[default_route]23
+route = "processed"