use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::config::{Config, Preset};
use crate::dsp::{Band, BandKind, EqSettings};
use crate::ipc::{self, PresetBackup, Request, Response, Status, Tuning};
use crate::sys::{self, EqHandle, TapSession};
const BAND_MATCH_HZ: f32 = 0.5;
const CHANNELS: usize = 2;
const POLL: Duration = Duration::from_millis(100);
const IDLE_SUSPEND_AFTER: Duration = Duration::from_secs(10);
const MIN_BAND_FREQ_HZ: f32 = 20.0;
const MAX_BAND_FREQ_HZ: f32 = 20_000.0;
const MIN_BAND_GAIN_DB: f32 = -24.0;
const MAX_BAND_GAIN_DB: f32 = 24.0;
const MIN_Q: f32 = 0.1;
const MAX_Q: f32 = 10.0;
const MIN_PREAMP_DB: f32 = -60.0;
const MAX_PREAMP_DB: f32 = 12.0;
const MAX_PRESET_NAME_LEN: usize = 64;
#[derive(Debug, Serialize, Deserialize)]
struct PresetFile {
name: String,
bands: Vec<Band>,
preamp_db: f32,
}
pub struct Daemon {
config: Config,
saved_config: Config,
config_path: PathBuf,
engine: Option<(TapSession, EqHandle)>,
engine_target: Option<(u32, u32)>,
engine_target_on: bool,
user_intent: bool,
low_power: bool,
idle_suspended: bool,
}
impl Daemon {
pub fn new() -> anyhow::Result<Self> {
let config = Config::load()?;
Ok(Self {
saved_config: config.clone(),
config,
config_path: Config::path(),
engine: None,
engine_target: None,
engine_target_on: false,
user_intent: false,
low_power: sys::low_power_enabled(),
idle_suspended: false,
})
}
pub fn run(mut self) -> anyhow::Result<()> {
let path = ipc::socket_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let _ = std::fs::remove_file(&path); let listener = UnixListener::bind(&path)?;
listener.set_nonblocking(true)?;
eprintln!("eqtune daemon listening on {}", path.display());
loop {
match listener.accept() {
Ok((stream, _)) => {
let _ = stream.set_nonblocking(false); if let Err(e) = self.handle(stream) {
eprintln!("connection error: {e}");
}
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => eprintln!("accept error: {e}"),
}
self.follow_low_power();
self.follow_idle_activity();
self.follow_default_device();
std::thread::sleep(POLL);
}
}
fn handle(&mut self, stream: UnixStream) -> anyhow::Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut line = String::new();
reader.read_line(&mut line)?;
if line.trim().is_empty() {
return Ok(());
}
let resp = match serde_json::from_str::<Request>(line.trim_end()) {
Ok(req) => self.dispatch(req),
Err(e) => Response::Error(format!("bad request: {e}")),
};
let mut out = stream;
let mut s = serde_json::to_string(&resp)?;
s.push('\n');
out.write_all(s.as_bytes())?;
out.flush()?;
Ok(())
}
fn dispatch(&mut self, req: Request) -> Response {
match self.apply(req) {
Ok(resp) => resp,
Err(e) => Response::Error(e.to_string()),
}
}
fn apply(&mut self, req: Request) -> anyhow::Result<Response> {
match req {
Request::Status => Ok(Response::Status(self.status())),
Request::Enable => {
self.user_intent = true;
self.idle_suspended = false;
self.engine_target_on = true;
self.reconcile()?; Ok(Response::Tuning(self.tuning()))
}
Request::Disable => {
self.user_intent = false;
self.idle_suspended = false;
self.engine_target_on = false;
self.reconcile()?; if self.has_unsaved_session() {
Ok(Response::UnsavedSession(self.tuning()))
} else {
Ok(Response::Ok)
}
}
Request::ListPresets => Ok(Response::Presets {
active: self.config.active_preset.clone(),
names: self.config.presets.keys().cloned().collect(),
}),
Request::SetPreset(name) => {
if !self.config.presets.contains_key(&name) {
return Ok(Response::Error(format!("no such preset: {name}")));
}
self.config.active_preset = name;
self.apply_current_settings();
Ok(Response::Tuning(self.tuning()))
}
Request::SavePreset { name } => {
self.save_session_as(&name)?;
Ok(Response::Tuning(self.tuning()))
}
Request::ClonePreset { source, dest } => {
self.clone_preset(&source, &dest)?;
Ok(Response::Tuning(self.tuning()))
}
Request::DeletePresets { names } => {
self.ensure_no_unsaved_session()?;
delete_presets(&mut self.config, &names)?;
self.persist_and_apply()?;
Ok(Response::Presets {
active: self.config.active_preset.clone(),
names: self.config.presets.keys().cloned().collect(),
})
}
Request::RenamePreset { from, to } => {
self.ensure_no_unsaved_session()?;
rename_preset(&mut self.config, &from, &to)?;
self.persist_and_apply()?;
Ok(Response::Tuning(self.tuning()))
}
Request::ExportPreset { name, path } => {
export_preset(&self.config, &name, &path)?;
Ok(Response::Ok)
}
Request::ImportPreset { path, name } => {
self.ensure_no_unsaved_session()?;
import_preset(&mut self.config, &path, name.as_deref())?;
self.persist_and_apply()?;
Ok(Response::Tuning(self.tuning()))
}
Request::SetBand { freq, gain_db, q } => {
validate_band(freq, gain_db, q)?;
let preset = self.active_preset_mut()?;
if let Some(b) = preset
.bands
.iter_mut()
.find(|b| (b.freq - freq).abs() < BAND_MATCH_HZ)
{
b.gain_db = gain_db;
b.q = q;
} else {
preset.bands.push(Band {
kind: BandKind::Peaking,
freq,
gain_db,
q,
});
preset.bands.sort_by(|a, b| a.freq.total_cmp(&b.freq));
}
self.apply_current_settings();
Ok(Response::Tuning(self.tuning()))
}
Request::RemoveBand { freq } => {
validate_freq(freq)?;
self.active_preset_mut()?
.bands
.retain(|b| (b.freq - freq).abs() >= BAND_MATCH_HZ);
self.apply_current_settings();
Ok(Response::Tuning(self.tuning()))
}
Request::SetPreamp(db) => {
validate_preamp(db)?;
self.active_preset_mut()?.preamp_db = db;
self.apply_current_settings();
Ok(Response::Tuning(self.tuning()))
}
Request::SetAutoOffLowPower(on) => {
self.config.auto_off_low_power = on;
self.saved_config.auto_off_low_power = on;
self.saved_config.save_to(&self.config_path)?;
if on && self.low_power {
self.engine_target_on = false; } else if !on {
self.engine_target_on = self.user_intent; }
self.reconcile()?;
Ok(Response::Ok)
}
Request::SetAutoOffIdle(on) => {
self.config.auto_off_idle = on;
self.saved_config.auto_off_idle = on;
self.saved_config.save_to(&self.config_path)?;
if !on && self.idle_suspended {
self.idle_suspended = false;
if self.user_intent && !(self.config.auto_off_low_power && self.low_power) {
self.engine_target_on = true;
}
}
self.reconcile()?;
Ok(Response::Ok)
}
Request::SaveSessionAs { name } => {
self.save_session_as(&name)?;
Ok(Response::Tuning(self.tuning()))
}
Request::SaveSessionOverwrite => {
self.persist_and_apply()?;
Ok(Response::Tuning(self.tuning()))
}
Request::DiscardSession => {
self.discard_session();
Ok(Response::Tuning(self.tuning()))
}
Request::ResetPreset { name } => {
self.ensure_no_unsaved_session()?;
let changed =
modified_shipped_presets(&self.saved_config, std::slice::from_ref(&name))?;
if changed.is_empty() {
self.confirm_reset_preset(&name, &[])?;
Ok(Response::Tuning(self.tuning()))
} else {
Ok(Response::ResetWouldOverwrite { names: changed })
}
}
Request::ConfirmResetPreset { name, backups } => {
self.ensure_no_unsaved_session()?;
self.confirm_reset_preset(&name, &backups)?;
Ok(Response::Tuning(self.tuning()))
}
Request::Reset => {
self.ensure_no_unsaved_session()?;
let names = shipped_preset_names();
let changed = modified_shipped_presets(&self.saved_config, &names)?;
if changed.is_empty() {
self.confirm_reset_all(&[])?;
Ok(Response::Tuning(self.tuning()))
} else {
Ok(Response::ResetWouldOverwrite { names: changed })
}
}
Request::ConfirmReset { backups } => {
self.ensure_no_unsaved_session()?;
self.confirm_reset_all(&backups)?;
Ok(Response::Tuning(self.tuning()))
}
}
}
fn active_preset_mut(&mut self) -> anyhow::Result<&mut Preset> {
let name = self.config.active_preset.clone();
self.config
.presets
.get_mut(&name)
.ok_or_else(|| anyhow::anyhow!("active preset '{name}' is missing"))
}
fn settings_for(&self, fs: f32) -> EqSettings {
let active = self.config.active();
let bands: &[Band] = active.map(|p| p.bands.as_slice()).unwrap_or(&[]);
let preamp = active.map(|p| p.preamp_db).unwrap_or(0.0);
EqSettings::new(bands, fs, preamp, self.config.limiter)
}
fn reconcile(&mut self) -> anyhow::Result<()> {
if self.engine_target_on && self.engine.is_none() {
self.start_engine()?;
} else if !self.engine_target_on && self.engine.is_some() {
self.engine = None; self.engine_target = None;
}
Ok(())
}
fn start_engine(&mut self) -> anyhow::Result<()> {
if self.engine.is_some() {
return Ok(());
}
let (dev, rate) = current_target();
let settings = self.settings_for(rate as f32);
match TapSession::start(CHANNELS, settings) {
Some(pair) => {
self.engine = Some(pair);
self.engine_target = Some((dev, rate));
Ok(())
}
None => Err(anyhow::anyhow!(
"could not start the audio tap — needs macOS 14.2+ and audio-capture permission"
)),
}
}
fn follow_default_device(&mut self) {
if self.engine.is_none() {
return;
}
let current = current_target();
if self.engine_target != Some(current) {
eprintln!("default output changed to {current:?} — rebuilding engine");
self.engine = None;
self.engine_target = None;
if let Err(e) = self.start_engine() {
eprintln!("engine rebuild failed: {e}");
}
}
}
fn follow_low_power(&mut self) {
let now = sys::low_power_enabled();
if now == self.low_power {
return;
}
self.low_power = now;
if !self.config.auto_off_low_power {
return; }
self.engine_target_on = if now {
false
} else {
self.user_intent && !self.idle_suspended
};
eprintln!(
"low power mode {} — eqtune {}",
if now { "on" } else { "off" },
if self.engine_target_on {
"resuming"
} else {
"suspended"
}
);
if let Err(e) = self.reconcile() {
eprintln!("engine reconcile failed: {e}");
}
}
fn follow_idle_activity(&mut self) {
if !self.config.auto_off_idle || !self.user_intent {
return;
}
if let Some((_, handle)) = &self.engine {
let rate = self.engine_target.map(|(_, r)| r).unwrap_or(48_000);
let idle_frames = IDLE_SUSPEND_AFTER.as_secs().saturating_mul(rate as u64);
if handle.silent_frames() >= idle_frames {
self.idle_suspended = true;
self.engine_target_on = false;
eprintln!("no active media detected — eqtune suspended");
if let Err(e) = self.reconcile() {
eprintln!("engine idle-suspend failed: {e}");
}
}
return;
}
if !self.idle_suspended {
return;
}
if self.config.auto_off_low_power && self.low_power {
return;
}
if sys::default_output_device_running() {
self.idle_suspended = false;
self.engine_target_on = true;
eprintln!("default output active — eqtune resuming");
if let Err(e) = self.reconcile() {
eprintln!("engine idle-resume failed: {e}");
}
}
}
fn persist_and_apply(&mut self) -> anyhow::Result<()> {
self.config.save_to(&self.config_path)?;
self.saved_config = self.config.clone();
self.apply_current_settings();
Ok(())
}
fn apply_current_settings(&mut self) {
if self.engine.is_some() {
let fs = self
.engine_target
.map(|(_, r)| r as f32)
.unwrap_or(48_000.0);
let settings = self.settings_for(fs);
if let Some((_, handle)) = &self.engine {
handle.store(settings); }
}
}
fn has_unsaved_session(&self) -> bool {
self.config != self.saved_config
}
fn ensure_no_unsaved_session(&self) -> anyhow::Result<()> {
if self.has_unsaved_session() {
Err(anyhow::anyhow!(
"unsaved tuning changes are active; run `eqtune off` and save or discard them first"
))
} else {
Ok(())
}
}
fn save_session_as(&mut self, name: &str) -> anyhow::Result<()> {
validate_session_save_name(&self.saved_config, name)?;
let preset = self
.config
.active()
.cloned()
.ok_or_else(|| anyhow::anyhow!("no active preset to save"))?;
let mut next = self.saved_config.clone();
next.presets.insert(name.to_string(), preset);
next.active_preset = name.to_string();
self.config = next;
self.persist_and_apply()
}
fn clone_preset(&mut self, source: &str, dest: &str) -> anyhow::Result<()> {
validate_new_preset_name(&self.saved_config, dest)?;
let preset = self
.config
.presets
.get(source)
.cloned()
.ok_or_else(|| anyhow::anyhow!("no such preset: {source}"))?;
let mut next = self.saved_config.clone();
next.presets.insert(dest.to_string(), preset);
next.active_preset = dest.to_string();
self.config = next;
self.persist_and_apply()
}
fn discard_session(&mut self) {
self.config = self.saved_config.clone();
self.apply_current_settings();
}
fn confirm_reset_preset(&mut self, name: &str, backups: &[PresetBackup]) -> anyhow::Result<()> {
let mut next = self.saved_config.clone();
apply_reset_backups(&mut next, backups)?;
reset_preset(&mut next, name)?;
self.config = next;
self.persist_and_apply()
}
fn confirm_reset_all(&mut self, backups: &[PresetBackup]) -> anyhow::Result<()> {
let mut next = self.saved_config.clone();
apply_reset_backups(&mut next, backups)?;
reset_shipped_presets(&mut next);
self.config = next;
self.idle_suspended = false;
self.persist_and_apply()
}
fn status(&self) -> Status {
let active = self.config.active();
let output_device = self
.engine_target
.filter(|_| self.engine.is_some())
.map(|(dev, _)| format!("#{dev}"));
Status {
enabled: self.engine.is_some(),
active_preset: self.config.active_preset.clone(),
preamp_db: active.map(|p| p.preamp_db).unwrap_or(0.0),
band_count: active.map(|p| p.bands.len()).unwrap_or(0),
limiter: self.config.limiter,
output_device,
low_power: self.low_power,
auto_off_low_power: self.config.auto_off_low_power,
auto_off_idle: self.config.auto_off_idle,
idle_suspended: self.idle_suspended,
}
}
fn tuning(&self) -> Tuning {
let active = self.config.active();
Tuning {
enabled: self.engine.is_some(),
preset: self.config.active_preset.clone(),
preamp_db: active.map(|p| p.preamp_db).unwrap_or(0.0),
bands: active.map(|p| p.bands.clone()).unwrap_or_default(),
}
}
}
fn current_target() -> (u32, u32) {
let dev = sys::default_output_device().unwrap_or(0);
let rate = sys::default_output_sample_rate()
.unwrap_or(48_000.0)
.round() as u32;
(dev, rate)
}
fn validate_band(freq: f32, gain_db: f32, q: f32) -> anyhow::Result<()> {
validate_freq(freq)?;
validate_range("gain", gain_db, MIN_BAND_GAIN_DB, MAX_BAND_GAIN_DB, "dB")?;
validate_range("Q", q, MIN_Q, MAX_Q, "")?;
Ok(())
}
fn validate_freq(freq: f32) -> anyhow::Result<()> {
validate_range("frequency", freq, MIN_BAND_FREQ_HZ, MAX_BAND_FREQ_HZ, "Hz")
}
fn validate_preamp(db: f32) -> anyhow::Result<()> {
validate_range("preamp", db, MIN_PREAMP_DB, MAX_PREAMP_DB, "dB")
}
#[cfg(test)]
fn save_active_preset(config: &mut Config, name: &str) -> anyhow::Result<()> {
validate_new_preset_name(config, name)?;
let preset = config
.active()
.cloned()
.ok_or_else(|| anyhow::anyhow!("no active preset to save"))?;
config.presets.insert(name.to_string(), preset);
config.active_preset = name.to_string();
Ok(())
}
#[cfg(test)]
fn clone_preset(config: &mut Config, source: &str, dest: &str) -> anyhow::Result<()> {
validate_new_preset_name(config, dest)?;
let preset = config
.presets
.get(source)
.cloned()
.ok_or_else(|| anyhow::anyhow!("no such preset: {source}"))?;
config.presets.insert(dest.to_string(), preset);
config.active_preset = dest.to_string();
Ok(())
}
fn delete_presets(config: &mut Config, names: &[String]) -> anyhow::Result<()> {
if names.is_empty() {
return Err(anyhow::anyhow!("at least one preset name is required"));
}
let mut seen = std::collections::BTreeSet::new();
for name in names {
if !seen.insert(name.as_str()) {
return Err(anyhow::anyhow!("duplicate preset name: {name}"));
}
if !config.presets.contains_key(name) {
return Err(anyhow::anyhow!("no such preset: {name}"));
}
}
if names.len() >= config.presets.len() {
return Err(anyhow::anyhow!("cannot delete every preset"));
}
for name in names {
config.presets.remove(name);
}
if names.iter().any(|name| name == &config.active_preset) {
config.active_preset = config
.presets
.keys()
.next()
.cloned()
.ok_or_else(|| anyhow::anyhow!("no presets remain"))?;
}
Ok(())
}
fn rename_preset(config: &mut Config, from: &str, to: &str) -> anyhow::Result<()> {
let preset = config
.presets
.remove(from)
.ok_or_else(|| anyhow::anyhow!("no such preset: {from}"))?;
if let Err(e) = validate_new_preset_name(config, to) {
config.presets.insert(from.to_string(), preset);
return Err(e);
}
config.presets.insert(to.to_string(), preset);
if config.active_preset == from {
config.active_preset = to.to_string();
}
Ok(())
}
fn export_preset(config: &Config, name: &str, path: &Path) -> anyhow::Result<()> {
let preset = config
.presets
.get(name)
.ok_or_else(|| anyhow::anyhow!("no such preset: {name}"))?;
let file = PresetFile {
name: name.to_string(),
bands: preset.bands.clone(),
preamp_db: preset.preamp_db,
};
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, toml::to_string_pretty(&file)?)?;
Ok(())
}
fn import_preset(
config: &mut Config,
path: &Path,
name_override: Option<&str>,
) -> anyhow::Result<()> {
let file: PresetFile = toml::from_str(&std::fs::read_to_string(path)?)?;
let name = name_override.unwrap_or(&file.name);
validate_new_preset_name(config, name)?;
validate_preset(&file.preset())?;
config.presets.insert(name.to_string(), file.preset());
config.active_preset = name.to_string();
Ok(())
}
fn reset_preset(config: &mut Config, name: &str) -> anyhow::Result<()> {
let defaults = Config::default();
let preset = defaults
.presets
.get(name)
.cloned()
.ok_or_else(|| anyhow::anyhow!("no shipped preset: {name}"))?;
config.presets.insert(name.to_string(), preset);
if !config.presets.contains_key(&config.active_preset) {
config.active_preset = name.to_string();
}
Ok(())
}
fn reset_shipped_presets(config: &mut Config) {
let defaults = Config::default();
for (name, preset) in defaults.presets {
config.presets.insert(name, preset);
}
config.active_preset = defaults.active_preset;
}
fn apply_reset_backups(config: &mut Config, backups: &[PresetBackup]) -> anyhow::Result<()> {
for backup in backups {
if !is_shipped_preset_name(&backup.source) {
return Err(anyhow::anyhow!(
"can only back up shipped presets during reset: {}",
backup.source
));
}
validate_new_preset_name(config, &backup.dest)?;
let preset = config
.presets
.get(&backup.source)
.cloned()
.ok_or_else(|| anyhow::anyhow!("no such preset: {}", backup.source))?;
config.presets.insert(backup.dest.clone(), preset);
}
Ok(())
}
fn modified_shipped_presets(config: &Config, names: &[String]) -> anyhow::Result<Vec<String>> {
let defaults = Config::default();
let mut changed = Vec::new();
for name in names {
let default = defaults
.presets
.get(name)
.ok_or_else(|| anyhow::anyhow!("no shipped preset: {name}"))?;
if matches!(config.presets.get(name), Some(current) if current != default) {
changed.push(name.clone());
}
}
Ok(changed)
}
fn shipped_preset_names() -> Vec<String> {
Config::default().presets.keys().cloned().collect()
}
impl PresetFile {
fn preset(&self) -> Preset {
Preset {
bands: self.bands.clone(),
preamp_db: self.preamp_db,
}
}
}
fn validate_new_preset_name(config: &Config, name: &str) -> anyhow::Result<()> {
validate_preset_name(name)?;
if config.presets.contains_key(name) {
return Err(anyhow::anyhow!("preset already exists: {name}"));
}
Ok(())
}
fn validate_session_save_name(config: &Config, name: &str) -> anyhow::Result<()> {
validate_preset_name(name)?;
if config.presets.contains_key(name) && !is_shipped_preset_name(name) {
return Err(anyhow::anyhow!("preset already exists: {name}"));
}
Ok(())
}
fn is_shipped_preset_name(name: &str) -> bool {
Config::default().presets.contains_key(name)
}
fn validate_preset(preset: &Preset) -> anyhow::Result<()> {
validate_preamp(preset.preamp_db)?;
for band in &preset.bands {
validate_band(band.freq, band.gain_db, band.q)?;
}
Ok(())
}
fn validate_preset_name(name: &str) -> anyhow::Result<()> {
if name.is_empty() {
return Err(anyhow::anyhow!("preset name must not be empty"));
}
if name.len() > MAX_PRESET_NAME_LEN {
return Err(anyhow::anyhow!(
"preset name must be at most {MAX_PRESET_NAME_LEN} characters"
));
}
if name != name.trim() {
return Err(anyhow::anyhow!(
"preset name must not have leading or trailing whitespace"
));
}
if !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
{
return Err(anyhow::anyhow!(
"preset name may only contain ASCII letters, digits, '-', '_', or '.'"
));
}
Ok(())
}
fn validate_range(name: &str, value: f32, min: f32, max: f32, unit: &str) -> anyhow::Result<()> {
if !value.is_finite() {
return Err(anyhow::anyhow!("{name} must be a finite number"));
}
if !(min..=max).contains(&value) {
return Err(anyhow::anyhow!(
"{name} must be between {} and {}",
format_bound(min, unit),
format_bound(max, unit)
));
}
Ok(())
}
fn format_bound(value: f32, unit: &str) -> String {
if unit.is_empty() {
trim_number(value)
} else {
format!("{} {unit}", trim_number(value))
}
}
fn trim_number(n: f32) -> String {
let s = format!("{n:.3}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn band_validation_accepts_practical_values() {
validate_band(20.0, -24.0, 0.1).unwrap();
validate_band(20_000.0, 24.0, 10.0).unwrap();
validate_band(1000.0, 0.0, 1.41).unwrap();
}
#[test]
fn band_validation_rejects_invalid_values() {
for (freq, gain, q) in [
(0.0, 0.0, 1.0),
(20_001.0, 0.0, 1.0),
(1000.0, -24.1, 1.0),
(1000.0, 24.1, 1.0),
(1000.0, 0.0, 0.0),
(1000.0, 0.0, 10.1),
(f32::NAN, 0.0, 1.0),
(1000.0, f32::INFINITY, 1.0),
(1000.0, 0.0, f32::NEG_INFINITY),
] {
assert!(validate_band(freq, gain, q).is_err());
}
}
#[test]
fn preamp_validation_accepts_safe_range() {
validate_preamp(-60.0).unwrap();
validate_preamp(0.0).unwrap();
validate_preamp(12.0).unwrap();
}
#[test]
fn preamp_validation_rejects_invalid_values() {
for db in [-60.1, 12.1, f32::NAN, f32::INFINITY] {
assert!(validate_preamp(db).is_err());
}
}
#[test]
fn preset_save_clones_active_and_selects_new_preset() {
let mut c = Config::default();
let active = c.active().cloned().unwrap();
save_active_preset(&mut c, "car").unwrap();
assert_eq!(c.active_preset, "car");
assert_eq!(c.presets["car"], active);
}
#[test]
fn preset_clone_copies_source_and_selects_dest() {
let mut c = Config::default();
let source = c.presets["mellow"].clone();
clone_preset(&mut c, "mellow", "night").unwrap();
assert_eq!(c.active_preset, "night");
assert_eq!(c.presets["night"], source);
}
#[test]
fn preset_delete_removes_active_and_selects_another() {
let mut c = Config::default();
c.active_preset = "mellow".into();
delete_presets(&mut c, &["mellow".into()]).unwrap();
assert!(!c.presets.contains_key("mellow"));
assert!(c.presets.contains_key(&c.active_preset));
}
#[test]
fn preset_delete_rejects_last_preset() {
let mut c = Config::default();
c.presets.retain(|name, _| name == "bright");
c.active_preset = "bright".into();
assert!(delete_presets(&mut c, &["bright".into()]).is_err());
assert!(c.presets.contains_key("bright"));
}
#[test]
fn preset_delete_removes_multiple_presets_atomically() {
let mut c = Config::default();
c.presets.insert(
"daily".into(),
Preset {
bands: vec![],
preamp_db: 0.0,
},
);
c.active_preset = "daily".into();
delete_presets(&mut c, &["daily".into(), "mellow".into()]).unwrap();
assert!(!c.presets.contains_key("daily"));
assert!(!c.presets.contains_key("mellow"));
assert!(c.presets.contains_key(&c.active_preset));
}
#[test]
fn preset_delete_rejects_duplicate_or_all_names_without_mutating() {
let mut c = Config::default();
let before = c.clone();
assert!(delete_presets(&mut c, &["bright".into(), "bright".into()]).is_err());
assert_eq!(c, before);
let all = c.presets.keys().cloned().collect::<Vec<_>>();
assert!(delete_presets(&mut c, &all).is_err());
assert_eq!(c, before);
}
#[test]
fn preset_rename_moves_preset_and_updates_active_name() {
let mut c = Config::default();
c.active_preset = "bright".into();
let bright = c.presets["bright"].clone();
rename_preset(&mut c, "bright", "daily").unwrap();
assert!(!c.presets.contains_key("bright"));
assert_eq!(c.active_preset, "daily");
assert_eq!(c.presets["daily"], bright);
}
#[test]
fn preset_names_are_restricted_for_new_presets() {
let c = Config::default();
for name in ["", "two words", " lead", "trail ", "emoji-☃"] {
assert!(validate_new_preset_name(&c, name).is_err());
}
assert!(validate_new_preset_name(&c, "bright").is_err());
assert!(validate_new_preset_name(&c, "daily.v2").is_ok());
}
#[test]
fn preset_export_writes_shareable_toml() {
let c = Config::default();
let path = tmp_path("export.toml");
export_preset(&c, "bright", &path).unwrap();
let file: PresetFile = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(file.name, "bright");
assert_eq!(file.preset(), c.presets["bright"]);
}
#[test]
fn preset_import_reads_file_and_selects_preset() {
let mut c = Config::default();
let path = tmp_path("import.toml");
let file = PresetFile {
name: "shared".into(),
bands: c.presets["mellow"].bands.clone(),
preamp_db: c.presets["mellow"].preamp_db,
};
std::fs::write(&path, toml::to_string_pretty(&file).unwrap()).unwrap();
import_preset(&mut c, &path, None).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(c.active_preset, "shared");
assert_eq!(c.presets["shared"], file.preset());
}
#[test]
fn preset_import_name_override_wins() {
let mut c = Config::default();
let path = tmp_path("import-override.toml");
let file = PresetFile {
name: "shared".into(),
bands: c.presets["mellow"].bands.clone(),
preamp_db: c.presets["mellow"].preamp_db,
};
std::fs::write(&path, toml::to_string_pretty(&file).unwrap()).unwrap();
import_preset(&mut c, &path, Some("renamed-share")).unwrap();
let _ = std::fs::remove_file(&path);
assert!(!c.presets.contains_key("shared"));
assert_eq!(c.active_preset, "renamed-share");
assert_eq!(c.presets["renamed-share"], file.preset());
}
#[test]
fn preset_import_rejects_duplicate_or_invalid_values() {
let mut c = Config::default();
let duplicate = tmp_path("import-duplicate.toml");
let invalid = tmp_path("import-invalid.toml");
let file = PresetFile {
name: "bright".into(),
bands: c.presets["mellow"].bands.clone(),
preamp_db: c.presets["mellow"].preamp_db,
};
std::fs::write(&duplicate, toml::to_string_pretty(&file).unwrap()).unwrap();
let invalid_file = PresetFile {
name: "too-loud".into(),
bands: c.presets["mellow"].bands.clone(),
preamp_db: 99.0,
};
std::fs::write(&invalid, toml::to_string_pretty(&invalid_file).unwrap()).unwrap();
assert!(import_preset(&mut c, &duplicate, None).is_err());
assert!(import_preset(&mut c, &invalid, None).is_err());
let _ = std::fs::remove_file(&duplicate);
let _ = std::fs::remove_file(&invalid);
}
#[test]
fn tuning_edits_are_unsaved_until_resolved() {
let mut d = daemon_with(Config::default());
let saved_bright = d.saved_config.presets["bright"].clone();
d.apply(Request::SetPreamp(-3.0)).unwrap();
assert_ne!(d.config, d.saved_config);
d.apply(Request::SaveSessionAs {
name: "daily".into(),
})
.unwrap();
assert_eq!(d.config, d.saved_config);
assert_eq!(d.config.active_preset, "daily");
assert_eq!(d.config.presets["bright"], saved_bright);
assert_eq!(d.config.presets["daily"].preamp_db, -3.0);
}
#[test]
fn session_save_as_can_overwrite_shipped_preset_name() {
let mut d = daemon_with(Config::default());
let original_bright = d.saved_config.presets["bright"].clone();
d.apply(Request::SetPreset("mellow".into())).unwrap();
d.apply(Request::SetPreamp(-3.0)).unwrap();
d.apply(Request::SaveSessionAs {
name: "bright".into(),
})
.unwrap();
assert_eq!(d.config, d.saved_config);
assert_eq!(d.config.active_preset, "bright");
assert_ne!(d.saved_config.presets["bright"], original_bright);
assert_eq!(d.saved_config.presets["bright"].preamp_db, -3.0);
}
#[test]
fn session_save_as_rejects_existing_custom_preset_name() {
let mut d = daemon_with(Config::default());
d.apply(Request::SavePreset {
name: "daily".into(),
})
.unwrap();
d.apply(Request::SetPreamp(-3.0)).unwrap();
let err = d
.apply(Request::SaveSessionAs {
name: "daily".into(),
})
.unwrap_err();
assert!(err.to_string().contains("preset already exists"));
}
#[test]
fn session_overwrite_commits_active_preset_name() {
let mut d = daemon_with(Config::default());
d.apply(Request::SetPreamp(-3.0)).unwrap();
d.apply(Request::SaveSessionOverwrite).unwrap();
assert_eq!(d.config, d.saved_config);
assert_eq!(d.config.active_preset, "bright");
assert_eq!(d.saved_config.presets["bright"].preamp_db, -3.0);
}
#[test]
fn session_discard_reverts_to_saved_config() {
let mut d = daemon_with(Config::default());
let saved = d.saved_config.clone();
d.apply(Request::SetPreamp(-3.0)).unwrap();
d.apply(Request::DiscardSession).unwrap();
assert_eq!(d.config, saved);
assert_eq!(d.saved_config, saved);
}
#[test]
fn off_returns_unsaved_session_when_tuning_is_dirty() {
let mut d = daemon_with(Config::default());
d.apply(Request::SetPreamp(-3.0)).unwrap();
let resp = d.apply(Request::Disable).unwrap();
assert!(matches!(resp, Response::UnsavedSession(_)));
}
#[test]
fn reset_preset_restores_shipped_preset() {
let mut c = Config::default();
let defaults = Config::default();
for name in ["bright", "mellow", "pro"] {
c.presets.get_mut(name).unwrap().preamp_db = -3.0;
reset_preset(&mut c, name).unwrap();
assert_eq!(c.presets[name], defaults.presets[name]);
c.presets.remove(name);
reset_preset(&mut c, name).unwrap();
assert_eq!(c.presets[name], defaults.presets[name]);
}
}
#[test]
fn reset_all_restores_shipped_presets_and_preserves_custom_presets() {
let mut c = Config::default();
let defaults = Config::default();
c.presets.insert(
"daily".into(),
Preset {
bands: vec![],
preamp_db: -4.0,
},
);
for name in ["bright", "mellow", "pro"] {
c.presets.get_mut(name).unwrap().preamp_db = -3.0;
}
reset_shipped_presets(&mut c);
for name in ["bright", "mellow", "pro"] {
assert_eq!(c.presets[name], defaults.presets[name]);
}
assert!(c.presets.contains_key("daily"));
assert_eq!(c.active_preset, "bright");
}
#[test]
fn reset_requests_restore_shipped_presets_from_saved_config() {
let mut config = Config::default();
config.presets.insert(
"daily".into(),
Preset {
bands: vec![],
preamp_db: -4.0,
},
);
for name in ["bright", "mellow", "pro"] {
config.presets.get_mut(name).unwrap().preamp_db = -3.0;
}
let mut d = daemon_with(config);
let defaults = Config::default();
d.apply(Request::SetPreamp(1.0)).unwrap(); let blocked = d
.apply(Request::ResetPreset {
name: "bright".into(),
})
.unwrap_err();
assert!(blocked.to_string().contains("unsaved tuning changes"));
d.apply(Request::DiscardSession).unwrap();
let resp = d
.apply(Request::ResetPreset {
name: "bright".into(),
})
.unwrap();
assert!(matches!(resp, Response::ResetWouldOverwrite { .. }));
d.apply(Request::ConfirmResetPreset {
name: "bright".into(),
backups: vec![],
})
.unwrap();
assert_eq!(d.saved_config.presets["bright"], defaults.presets["bright"]);
assert_eq!(d.saved_config.presets["mellow"].preamp_db, -3.0);
assert!(d.saved_config.presets.contains_key("daily"));
let resp = d.apply(Request::Reset).unwrap();
assert!(matches!(resp, Response::ResetWouldOverwrite { .. }));
d.apply(Request::ConfirmReset { backups: vec![] }).unwrap();
for name in ["bright", "mellow", "pro"] {
assert_eq!(d.saved_config.presets[name], defaults.presets[name]);
}
assert!(d.saved_config.presets.contains_key("daily"));
assert_eq!(d.saved_config.active_preset, "bright");
}
#[test]
fn confirmed_reset_can_save_modified_builtin_copy_first() {
let mut config = Config::default();
config.presets.get_mut("bright").unwrap().preamp_db = -3.0;
let modified_bright = config.presets["bright"].clone();
let mut d = daemon_with(config);
let resp = d
.apply(Request::ResetPreset {
name: "bright".into(),
})
.unwrap();
assert!(matches!(resp, Response::ResetWouldOverwrite { .. }));
d.apply(Request::ConfirmResetPreset {
name: "bright".into(),
backups: vec![PresetBackup {
source: "bright".into(),
dest: "my-bright".into(),
}],
})
.unwrap();
assert_eq!(
d.saved_config.presets["bright"],
Config::default().presets["bright"]
);
assert_eq!(d.saved_config.presets["my-bright"], modified_bright);
}
#[test]
fn reset_recreates_deleted_shipped_preset_without_overwrite_warning() {
let mut config = Config::default();
config.presets.remove("bright");
let mut d = daemon_with(config);
let resp = d
.apply(Request::ResetPreset {
name: "bright".into(),
})
.unwrap();
assert!(matches!(resp, Response::Tuning(_)));
assert_eq!(
d.saved_config.presets["bright"],
Config::default().presets["bright"]
);
}
fn daemon_with(config: Config) -> Daemon {
Daemon {
saved_config: config.clone(),
config,
config_path: tmp_path("daemon-config.toml"),
engine: None,
engine_target: None,
engine_target_on: false,
user_intent: false,
low_power: false,
idle_suspended: false,
}
}
fn tmp_path(name: &str) -> std::path::PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"eqtune-test-{}-{unique}-{name}",
std::process::id()
))
}
}