eqtune 0.2.0

A lightweight, system-wide audio equalizer for macOS, built on Core Audio process taps.
//! Client↔daemon control protocol over a Unix domain socket (newline-delimited JSON).

use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

use crate::dsp::Band;

/// A command sent from the CLI client to the running daemon.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub enum Request {
    Status,
    Enable,
    Disable,
    ListPresets,
    SetPreset(String),
    SetBand { freq: f32, gain_db: f32, q: f32 },
    RemoveBand { freq: f32 },
    SetPreamp(f32),
    SetAutoOffLowPower(bool),
    Reset,
}

/// The daemon's reply to a [`Request`].
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub enum Response {
    Ok,
    Status(Status),
    /// The active tuning after `on` or any EQ edit, so the client can show the resulting
    /// curve (preset, preamp, and bands).
    Tuning(Tuning),
    Presets { active: String, names: Vec<String> },
    Error(String),
}

/// The active EQ tuning, returned so the CLI can print the current equalizer params
/// after `eqtune on` and after each edit.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Tuning {
    /// Whether the audio engine is currently running.
    pub enabled: bool,
    /// Name of the active preset.
    pub preset: String,
    /// The preset's preamp make-up gain (dB).
    pub preamp_db: f32,
    /// The preset's EQ bands, in frequency order.
    pub bands: Vec<Band>,
}

/// A snapshot of daemon state, returned for `eqtune status`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct Status {
    pub enabled: bool,
    pub active_preset: String,
    pub preamp_db: f32,
    pub band_count: usize,
    pub limiter: bool,
    /// The real output device audio is being sent to (None until the engine runs).
    pub output_device: Option<String>,
    /// Whether macOS Low Power Mode is currently active.
    pub low_power: bool,
    /// Whether the auto-off-on-Low-Power-Mode policy is enabled.
    pub auto_off_low_power: bool,
}

/// Location of the control socket.
pub fn socket_path() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_default();
    PathBuf::from(home).join("Library/Application Support/eqtune/eqtune.sock")
}

/// Connect to the daemon, send one request, and read one response.
pub fn send(req: &Request) -> anyhow::Result<Response> {
    let path = socket_path();
    let mut stream = UnixStream::connect(&path).map_err(|e| {
        anyhow::anyhow!(
            "could not reach the eqtune daemon ({e}). Is it running? Try `eqtune install` then `eqtune on`."
        )
    })?;

    let mut line = serde_json::to_string(req)?;
    line.push('\n');
    stream.write_all(line.as_bytes())?;
    stream.flush()?;

    let mut reader = BufReader::new(stream);
    let mut resp = String::new();
    reader.read_line(&mut resp)?;
    Ok(serde_json::from_str(resp.trim_end())?)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn request_round_trips() {
        let reqs = [
            Request::Status,
            Request::Enable,
            Request::Disable,
            Request::Reset,
            Request::ListPresets,
            Request::SetPreset("flat".into()),
            Request::SetBand { freq: 1000.0, gain_db: -10.0, q: 1.0 },
            Request::RemoveBand { freq: 2000.0 },
            Request::SetPreamp(7.0),
            Request::SetAutoOffLowPower(false),
        ];
        for r in reqs {
            let s = serde_json::to_string(&r).unwrap();
            assert_eq!(serde_json::from_str::<Request>(&s).unwrap(), r);
        }
    }

    #[test]
    fn response_round_trips() {
        let st = Status {
            enabled: true,
            active_preset: "default".into(),
            preamp_db: 7.0,
            band_count: 3,
            limiter: true,
            output_device: Some("MacBook Pro Speakers".into()),
            low_power: false,
            auto_off_low_power: true,
        };
        let resps = [
            Response::Ok,
            Response::Status(st),
            Response::Tuning(Tuning {
                enabled: true,
                preset: "bright".into(),
                preamp_db: -8.0,
                bands: vec![
                    crate::dsp::Band {
                        kind: crate::dsp::BandKind::Peaking,
                        freq: 1000.0,
                        gain_db: 4.5,
                        q: 1.41,
                    },
                    crate::dsp::Band {
                        kind: crate::dsp::BandKind::Peaking,
                        freq: 8000.0,
                        gain_db: 9.5,
                        q: 1.41,
                    },
                ],
            }),
            Response::Presets { active: "default".into(), names: vec!["default".into(), "flat".into()] },
            Response::Error("nope".into()),
        ];
        for r in resps {
            let s = serde_json::to_string(&r).unwrap();
            assert_eq!(serde_json::from_str::<Response>(&s).unwrap(), r);
        }
    }
}