import Foundation /// Equalizer APO's config text, the format AutoEQ publishes headphone corrections in /// (ParametricEQ.txt): /// /// Preamp: -6.2 dB /// Filter 1: ON LSC Fc 105 Hz Gain 6.0 dB Q 0.70 /// Filter 2: ON PK Fc 2300 Hz Gain -3.1 dB Q 1.41 /// /// Also understood: "Channel: L" / "R" / "all" lines, BW Oct instead of Q, and the older /// type names (LS, HS, LP, HP, PEQ ...). Other commands (GraphicEQ, Delay, Copy) are skipped. enum ApoConfig { struct Result { var filters: [EqFilter]; var preamp: Float?; var skipped: Int } private static let types: [String: FilterType] = [ "PK": .Peak, "PEQ": .Peak, "MODAL": .Peak, "LS": .LowShelf, "LSC": .LowShelf, "LSQ": .LowShelf, "HS": .HighShelf, "HSC": .HighShelf, "HSQ": .HighShelf, "LP": .LowPass, "LPQ": .LowPass, "HP": .HighPass, "HPQ": .HighPass, "BP": .BandPass, "NO": .Notch, "AP": .AllPass, ] /// Nil when the text has no preamp or filter lines at all. static func parse(_ text: String) -> Result? { var filters: [EqFilter] = [] var preamp: Float? var channel = EqChannel.Both var skipped = 0 for raw in text.components(separatedBy: .newlines) { let line = (raw.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? "") .trimmingCharacters(in: .whitespaces) if line.isEmpty { continue } guard let colon = line.firstIndex(of: ":") else { continue } let cmd = line[.. EqFilter? { func at(_ i: Int) -> String? { i < args.count ? args[i] : nil } var i = 0 var on = true switch at(0)?.uppercased() { case "ON": i += 1 case "OFF": on = false; i += 1 default: break } guard let t = at(i)?.uppercased(), let type = types[t] else { return nil } i += 1 var freq: Float? var gain: Float = 0 var q: Float? var slope = 12 while i < args.count { let key = args[i].lowercased() let num = at(i + 1).flatMap(Float.init) if key == "fc", let num { freq = num; i += 2 } else if key == "gain", let num { gain = num; i += 2 } else if key == "q", let num { q = num; i += 2 } else if key == "bw" && at(i + 1)?.lowercased() == "oct" { if let bw = at(i + 2).flatMap(Double.init) { q = Float(pow(2, bw).squareRoot() / (pow(2, bw) - 1)) } i += 3 } else if key.hasSuffix("db"), let s = Float(key.dropLast(2)) { // "LSC 12dB Fc ...": a shelf or pass slope in dB per octave. slope = Int(s); i += 1 } else { i += 1 } } guard let fc = freq else { return nil } let defaultQ: Float = type == .Peak ? EQ_Q : 0.707 return EqFilter(id: EqController.newFilterId(), type: type, freq: fc, gain: type.hasGain ? gain : 0, q: q ?? defaultQ, slope: type.hasSlope ? min(max(slope, 6), 48) : 12, channel: channel, on: on) } /// The profile as an Equalizer APO config: preamp, graphic bands as peaks, then the filters. static func format(_ p: EqProfile) -> String { func n(_ v: Float) -> String { var s = String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v) while s.hasSuffix("0") { s.removeLast() } if s.hasSuffix(".") { s.removeLast() } return s } var out = "# \(p.name), from Stemify\n" out += "Preamp: \(n(p.preamp)) dB\n" var channel = EqChannel.Both var k = 1 for f in EqFilter.graphic(p.gains) + p.filters { if f.channel != channel { channel = f.channel out += "Channel: " + (channel == .Left ? "L" : channel == .Right ? "R" : "all") + "\n" } let head = "\(f.on ? "ON" : "OFF") \(f.type.apo) Fc \(n(f.freq)) Hz" if f.type.hasGain { out += "Filter \(k): \(head) Gain \(n(f.gain)) dB Q \(n(f.q))\n"; k += 1 } else if !f.type.hasSlope || f.slope == 12 { out += "Filter \(k): \(head) Q \(n(f.q))\n"; k += 1 } else if f.slope < 12 { out += "# 6 dB/oct \(f.type.label.lowercased()): APO has no first-order filter, so this is a gentle 12 dB/oct one\n" out += "Filter \(k): \(head) Q 0.5\n"; k += 1 } else { // Steeper slopes are Butterworth: one APO filter per second-order section. let order = f.slope / 6 for j in 1...(order / 2) { let q = Float(1 / (2 * cos(Double(2 * j - 1) * .pi / Double(2 * order)))) out += "Filter \(k): \(head) Q \(n(q))\n"; k += 1 } } } return out } }