eqtune 0.3.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;

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct PresetBackup {
    pub source: String,
    pub dest: String,
}

/// 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),
    SavePreset {
        name: String,
    },
    ClonePreset {
        source: String,
        dest: String,
    },
    DeletePresets {
        names: Vec<String>,
    },
    RenamePreset {
        from: String,
        to: String,
    },
    ExportPreset {
        name: String,
        path: PathBuf,
    },
    ImportPreset {
        path: PathBuf,
        name: Option<String>,
    },
    SetBand {
        freq: f32,
        gain_db: f32,
        q: f32,
    },
    RemoveBand {
        freq: f32,
    },
    SetPreamp(f32),
    SetAutoOffLowPower(bool),
    SetAutoOffIdle(bool),
    SaveSessionAs {
        name: String,
    },
    SaveSessionOverwrite,
    DiscardSession,
    ResetPreset {
        name: String,
    },
    ConfirmResetPreset {
        name: String,
        backups: Vec<PresetBackup>,
    },
    Reset,
    ConfirmReset {
        backups: Vec<PresetBackup>,
    },
}

/// 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>,
    },
    /// Returned by reset commands when modified shipped presets would be replaced.
    ResetWouldOverwrite {
        names: Vec<String>,
    },
    /// Returned by `off` when live tuning edits have not been persisted yet.
    UnsavedSession(Tuning),
    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,
    /// Whether sustained no-media/no-signal idle suspension is enabled.
    pub auto_off_idle: bool,
    /// Whether the engine is currently suspended because no media is active.
    pub idle_suspended: 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::SavePreset { name: "car".into() },
            Request::ClonePreset {
                source: "bright".into(),
                dest: "desk".into(),
            },
            Request::DeletePresets {
                names: vec!["desk".into(), "car".into()],
            },
            Request::RenamePreset {
                from: "car".into(),
                to: "car-v2".into(),
            },
            Request::ExportPreset {
                name: "car-v2".into(),
                path: PathBuf::from("/tmp/car-v2.toml"),
            },
            Request::ImportPreset {
                path: PathBuf::from("/tmp/car-v2.toml"),
                name: Some("shared-car".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),
            Request::SetAutoOffIdle(false),
            Request::SaveSessionAs {
                name: "daily".into(),
            },
            Request::SaveSessionOverwrite,
            Request::DiscardSession,
            Request::ResetPreset {
                name: "bright".into(),
            },
            Request::ConfirmResetPreset {
                name: "bright".into(),
                backups: vec![PresetBackup {
                    source: "bright".into(),
                    dest: "my-bright".into(),
                }],
            },
            Request::ConfirmReset {
                backups: vec![PresetBackup {
                    source: "mellow".into(),
                    dest: "my-mellow".into(),
                }],
            },
        ];
        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,
            auto_off_idle: true,
            idle_suspended: false,
        };
        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::ResetWouldOverwrite {
                names: vec!["bright".into()],
            },
            Response::UnsavedSession(Tuning {
                enabled: false,
                preset: "bright".into(),
                preamp_db: -8.0,
                bands: vec![],
            }),
            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);
        }
    }
}