use std::fs;
use std::path::Path;
use crate::state::{EqBand, FilterType, MAX_ABS_GAIN_DB, MAX_FREQ_HZ, MAX_Q, MIN_FREQ_HZ, MIN_Q};
#[derive(Debug, Clone, PartialEq)]
pub struct PeqWarning {
pub line: usize,
pub message: String,
}
impl std::fmt::Display for PeqWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "line {}: {}", self.line, self.message)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PeqPreset {
pub preamp: f32,
pub bands: Vec<EqBand>,
pub warnings: Vec<PeqWarning>,
}
#[derive(Debug)]
pub enum PeqError {
Io(std::io::Error),
NoFilters,
InvalidPreamp { line: usize, raw: String },
}
impl std::fmt::Display for PeqError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PeqError::Io(e) => write!(f, "Failed to read PEQ file: {e}"),
PeqError::NoFilters => write!(f, "No filters found in PEQ file"),
PeqError::InvalidPreamp { line, raw } => {
write!(f, "Invalid preamp value at line {line}: {raw}")
}
}
}
}
impl std::error::Error for PeqError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
PeqError::Io(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for PeqError {
fn from(e: std::io::Error) -> Self {
PeqError::Io(e)
}
}
pub fn parse_peq(path: &Path) -> Result<PeqPreset, PeqError> {
parse_peq_str(&fs::read_to_string(path)?)
}
fn parse_filter_line(trimmed: &str) -> Result<EqBand, String> {
let rest = trimmed
.split(": ON ")
.nth(1)
.ok_or("missing ': ON ' separator")?;
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() < 9 {
return Err(format!("expected 9 fields, found {}", parts.len()));
}
let filter_type = match parts[0] {
"PK" => FilterType::Peak,
"LSC" => FilterType::LowShelf,
"HSC" => FilterType::HighShelf,
other => return Err(format!("unsupported filter type '{other}'")),
};
if parts[1] != "Fc"
|| parts[3] != "Hz"
|| parts[4] != "Gain"
|| parts[6] != "dB"
|| parts[7] != "Q"
{
return Err(format!(
"unexpected layout (want '<T> Fc <f> Hz Gain <g> dB Q <q>', got '{rest}')"
));
}
let frequency: f32 = parts[2]
.parse()
.map_err(|_| format!("invalid frequency '{}'", parts[2]))?;
let gain: f32 = parts[5]
.parse()
.map_err(|_| format!("invalid gain '{}'", parts[5]))?;
let q: f32 = parts[8]
.parse()
.map_err(|_| format!("invalid Q '{}'", parts[8]))?;
if !frequency.is_finite() || !(MIN_FREQ_HZ..=MAX_FREQ_HZ).contains(&frequency) {
return Err(format!("frequency {frequency} Hz out of range"));
}
if !gain.is_finite() || gain.abs() > MAX_ABS_GAIN_DB {
return Err(format!("gain {gain} dB out of range"));
}
if !q.is_finite() || !(MIN_Q..=MAX_Q).contains(&q) {
return Err(format!("Q {q} out of range"));
}
Ok(EqBand {
frequency,
gain,
q,
filter_type,
})
}
pub fn parse_peq_str(input: &str) -> Result<PeqPreset, PeqError> {
let mut preamp: Option<f32> = None;
let mut bands = Vec::new();
let mut warnings = Vec::new();
for (lineno, line) in input.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some(val) = trimmed.strip_prefix("Preamp:") {
let val = val.trim();
let raw = val.strip_suffix("dB").map_or(val, |s| s.trim()).to_string();
let gain: f32 = raw.parse().map_err(|_| PeqError::InvalidPreamp {
line: lineno + 1,
raw,
})?;
preamp = Some(gain);
continue;
}
if trimmed.contains(": ON ") {
match parse_filter_line(trimmed) {
Ok(band) => bands.push(band),
Err(reason) => warnings.push(PeqWarning {
line: lineno + 1,
message: reason,
}),
}
} else {
warnings.push(PeqWarning {
line: lineno + 1,
message: format!("unrecognized line (skipped): '{trimmed}'"),
});
}
}
if bands.is_empty() {
return Err(PeqError::NoFilters);
}
Ok(PeqPreset {
preamp: preamp.unwrap_or(0.0),
bands,
warnings,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_standard_peq() {
let input = "\
Preamp: -6.0 dB
Filter 1: ON PK Fc 32 Hz Gain 2.5 dB Q 0.71
Filter 2: ON LSC Fc 105 Hz Gain 5.5 dB Q 0.71
Filter 3: ON HSC Fc 10000 Hz Gain -2.0 dB Q 0.70
";
let preset = parse_peq_str(input).unwrap();
assert!((preset.preamp - (-6.0)).abs() < 0.01);
assert_eq!(preset.bands.len(), 3);
assert_eq!(preset.bands[0].filter_type, FilterType::Peak);
assert!((preset.bands[0].frequency - 32.0).abs() < 0.1);
assert!((preset.bands[0].gain - 2.5).abs() < 0.01);
assert!((preset.bands[0].q - 0.71).abs() < 0.01);
assert_eq!(preset.bands[1].filter_type, FilterType::LowShelf);
assert!((preset.bands[1].frequency - 105.0).abs() < 0.1);
assert_eq!(preset.bands[2].filter_type, FilterType::HighShelf);
assert!((preset.bands[2].gain - (-2.0)).abs() < 0.01);
}
#[test]
fn parse_with_comments_and_blanks() {
let input = "\
# My HD600 preset
Preamp: -4.5 dB
Filter 1: ON PK Fc 1000 Hz Gain 6.0 dB Q 1.2
# Some comment
Filter 2: ON PK Fc 3000 Hz Gain -3.0 dB Q 2.0
";
let preset = parse_peq_str(input).unwrap();
assert!((preset.preamp - (-4.5)).abs() < 0.01);
assert_eq!(preset.bands.len(), 2);
}
#[test]
fn no_filters_returns_error() {
let err = parse_peq_str("Preamp: -6.0 dB\n").unwrap_err();
assert!(matches!(err, PeqError::NoFilters));
}
#[test]
fn skips_unknown_filter_types() {
let input = "\
Preamp: -3.0 dB
Filter 1: ON PK Fc 500 Hz Gain 1.0 dB Q 0.5
Filter 2: ON XYZ Fc 800 Hz Gain 2.0 dB Q 1.0
Filter 3: ON PK Fc 2000 Hz Gain -1.0 dB Q 0.8
";
let preset = parse_peq_str(input).unwrap();
assert_eq!(preset.bands.len(), 2);
assert_eq!(preset.warnings.len(), 1);
assert_eq!(preset.warnings[0].line, 3); assert!(
preset.warnings[0]
.message
.contains("unsupported filter type")
);
}
#[test]
fn malformed_fields_warn_with_line_numbers() {
let input = "\
Preamp: -6.0 dB
Filter 1: ON PK Fc 100 Hz Gain 2.0 dB Q 1.0
Filter 2: ON PK Fc 1O0 Hz Gain 2.0 dB Q 1.0
Filter 3: ON PK Fc 300 Hz Gain 99.0 dB Q 1.0
Filter 4: ON XYZ Fc 400 Hz Gain 1.0 dB Q 1.0
";
let preset = parse_peq_str(input).unwrap();
assert_eq!(preset.bands.len(), 1); assert_eq!(preset.warnings.len(), 3);
assert_eq!(preset.warnings[0].line, 3);
assert!(preset.warnings[0].message.contains("invalid frequency"));
assert!(preset.warnings[1].message.contains("out of range")); assert!(
preset.warnings[2]
.message
.contains("unsupported filter type")
);
}
#[test]
fn nan_and_missing_units_are_rejected() {
let input = "Filter 1: ON PK Fc NaN Hz Gain 1.0 dB Q 1.0\n";
let err = parse_peq_str(input).unwrap_err(); assert!(matches!(err, PeqError::NoFilters));
let shifted = "Filter 1: ON PK 100 Hz Gain 2.0 dB Q 1.0\n";
assert!(matches!(
parse_peq_str(shifted).unwrap_err(),
PeqError::NoFilters
));
}
}