import AVFoundation import Foundation import Observation enum DeviceKind: String { case Speaker, Wired, Bluetooth, Usb, Hdmi, Car, Other } /// An output route, identified by AVAudioSession port type + port name. struct OutputDevice: Codable, Hashable { var type: String var name: String var id: String { "\(type)|\(name)" } var kind: DeviceKind { Self.kindOf(type) } var label: String { switch kind { case .Speaker: "Phone Speaker" case .Wired: name.trimmingCharacters(in: .whitespaces).isEmpty || name == "Headphones" ? "Wired Headphones" : name default: name.trimmingCharacters(in: .whitespaces).isEmpty ? kind.rawValue : name } } static let speaker = OutputDevice(type: AVAudioSession.Port.builtInSpeaker.rawValue, name: "") static func of(_ port: AVAudioSessionPortDescription) -> OutputDevice { // One speaker profile is enough; the receiver (earpiece) counts as the speaker too. if kindOf(port.portType.rawValue) == .Speaker { return speaker } return OutputDevice(type: port.portType.rawValue, name: port.portName) } static func kindOf(_ type: String) -> DeviceKind { switch AVAudioSession.Port(rawValue: type) { case .builtInSpeaker, .builtInReceiver: .Speaker case .headphones, .lineOut: .Wired case .bluetoothA2DP, .bluetoothLE, .bluetoothHFP: .Bluetooth case .usbAudio: .Usb case .HDMI, .airPlay: .Hdmi case .carAudio: .Car default: .Other } } } struct EqProfile: Codable, Hashable, Identifiable { var id: String var name: String var gains: [Float] = Array(repeating: 0, count: EQ_BANDS.count) var devices: [OutputDevice] = [] /// Parametric filters, after the graphic bands. var filters: [EqFilter] = [] /// Manual preamp in dB, ahead of the anti-clip one (Equalizer APO's "Preamp:"). var preamp: Float = 0 init(id: String, name: String, gains: [Float] = Array(repeating: 0, count: EQ_BANDS.count), devices: [OutputDevice] = [], filters: [EqFilter] = [], preamp: Float = 0) { self.id = id; self.name = name; self.gains = gains; self.devices = devices; self.filters = filters; self.preamp = preamp } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: AnyKey.self) id = c.str("id") ?? UUID().uuidString name = c.str("name") ?? "Profile" gains = (try? c.decode([Float].self, forKey: AnyKey("gains"))) ?? Array(repeating: 0, count: EQ_BANDS.count) if gains.count != EQ_BANDS.count { gains = (gains + Array(repeating: 0, count: EQ_BANDS.count)).prefix(EQ_BANDS.count).map { $0 } } devices = c.list("devices") filters = c.list("filters") preamp = Float(c.double("preamp") ?? 0) } /// Everything the engine runs: graphic bands as peaks, then the filters that are on. var chain: [EqFilter] { EqFilter.graphic(gains) + filters.filter(\.active) } } struct EqState: Codable, Equatable { var enabled = true var automatic = true /// Picked by hand; wins over automatic switching until cleared. var manualId: String? var defaultId = "flat" var antiClip = true var profiles = [EqProfile(id: "flat", name: "Flat")] var seenDevices: [OutputDevice] = [] init() {} init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: AnyKey.self) enabled = c.bool("enabled") ?? true automatic = c.bool("automatic") ?? true manualId = c.str("manualId") defaultId = c.str("defaultId") ?? "flat" antiClip = c.bool("antiClip") ?? true let p: [EqProfile] = c.list("profiles") if !p.isEmpty { profiles = p } seenDevices = c.list("seenDevices") } } /// iOS Music's EQ list, approximated on ten octave bands. let EQ_PRESETS: [(String, [Float])] = [ ("Flat", [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), ("Acoustic", [4.5, 4.5, 3.5, 1, 1.5, 1.5, 3, 3.5, 3, 2]), ("Bass Booster", [5.5, 4.5, 3.5, 2.5, 1.5, 0, 0, 0, 0, 0]), ("Bass Reducer", [-5.5, -4.5, -3.5, -2.5, -1.5, 0, 0, 0, 0, 0]), ("Classical", [4.5, 3.5, 3, 2.5, -1.5, -1.5, 0, 2, 3, 3.5]), ("Dance", [3.5, 6.5, 5, 0, 2, 3.5, 5, 4.5, 3.5, 0]), ("Deep", [5, 3.5, 1.5, 1, 3, 2.5, 1.5, -2, -3.5, -4.5]), ("Electronic", [4, 3.5, 1, 0, -2, 2, 1, 1.5, 4, 5]), ("Hip-Hop", [5, 4, 1.5, 3, -1, -1, 1.5, -0.5, 2, 3]), ("Jazz", [4, 3, 1.5, 2, -1.5, -1.5, 0, 1.5, 3, 3.5]), ("Late Night", [4.5, 3, 2, -0.5, 1.5, 1.5, 2, 3, 3.5, 4]), ("Loudness", [6, 4, 0, 0, -2, 0, -1, -5, 5, 1]), ("Piano", [3, 2, 0, 2.5, 3, 1.5, 3.5, 4.5, 3, 3.5]), ("Pop", [-1.5, -1, 0, 2, 4, 4, 2, 0, -1, -1.5]), ("R&B", [2.5, 7, 5.5, 1.5, -2.5, -1.5, 2, 2.5, 3, 3.5]), ("Rock", [5, 4, 3, 1.5, -0.5, -1, 0.5, 2.5, 3.5, 4.5]), ("Small Speakers", [5.5, 4.5, 3.5, 2.5, 1.5, 0, -1.5, -2.5, -3.5, -4]), ("Spoken Word", [-3.5, -0.5, 0, 0.5, 3.5, 4.5, 4.5, 4, 2, 0]), ("Treble Booster", [0, 0, 0, 0, 0, 1.5, 2.5, 3.5, 4.5, 5.5]), ("Treble Reducer", [0, 0, 0, 0, 0, -1.5, -2.5, -3.5, -4.5, -5.5]), ("Vocal Booster", [-1.5, -3, -3, 1.5, 3.5, 3.5, 3, 1.5, 0, -1.5]), ] /// EQ profiles and the current output route. In automatic mode the active profile is the /// one linked to the route (else the default); a hand-picked profile overrides that until /// the user goes back to automatic. Main thread only. @Observable final class EqController: @unchecked Sendable { static let freqMin: Float = 10 static let freqMax: Float = 22000 static let filterGain: Float = 24 static let qMin: Float = 0.1 static let qMax: Float = 20 static let preampRange: Float = 24 static func newFilterId() -> String { UUID().uuidString.lowercased() } @ObservationIgnored private let file: URL @ObservationIgnored private var saveWork: DispatchWorkItem? @ObservationIgnored var onParams: ((EqParams?) -> Void)? private(set) var state: EqState private(set) var device = OutputDevice.speaker private(set) var active: EqProfile? init(dir: URL) { file = dir.appendingPathComponent("eq.json") state = Self.load(file) active = nil active = resolve(state, device) #if !SWIFT_PACKAGE NotificationCenter.default.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] _ in self?.refreshRoute() } refreshRoute() #endif } /// Where media is going right now. func refreshRoute() { #if !SWIFT_PACKAGE if let port = AVAudioSession.sharedInstance().currentRoute.outputs.first { setDevice(.of(port)) } #endif } func setDevice(_ d: OutputDevice) { if device == d && state.seenDevices.contains(d) { return } device = d if !state.seenDevices.contains(d) { var s = state s.seenDevices = Array(([d] + s.seenDevices).prefix(24)) update(s) } else { publish() } } // ---- edits ------------------------------------------------------------------------ func setEnabled(_ on: Bool) { var s = state; s.enabled = on; update(s) } func setAntiClip(_ on: Bool) { var s = state; s.antiClip = on; update(s) } /// Automatic switching on clears the manual pick. func setAutomatic(_ on: Bool) { var s = state s.automatic = on s.manualId = on ? nil : (s.manualId ?? active?.id) update(s) } /// Picking a profile by hand overrides automatic switching. func select(_ id: String) { var s = state; s.manualId = id; s.automatic = false; update(s) } func setGain(_ id: String, band: Int, db: Float) { edit(id) { $0.gains[band] = Self.clamp(db, EQ_MAX_DB) } } func setGains(_ id: String, _ gains: [Float]) { edit(id) { $0.gains = gains.map { Self.clamp($0, EQ_MAX_DB) } } } func rename(_ id: String, _ name: String) { edit(id) { p in if !name.trimmingCharacters(in: .whitespaces).isEmpty { p.name = name } } } func setPreamp(_ id: String, _ db: Float) { edit(id) { $0.preamp = Self.clamp(db, Self.preampRange) } } /// Adds or replaces (by id) a parametric filter. func putFilter(_ id: String, _ f: EqFilter) { var clean = f clean.freq = min(max(f.freq, Self.freqMin), Self.freqMax) clean.gain = Self.clamp(f.gain, Self.filterGain) clean.q = min(max(f.q, Self.qMin), Self.qMax) edit(id) { p in if let i = p.filters.firstIndex(where: { $0.id == f.id }) { p.filters[i] = clean } else { p.filters.append(clean) } } } func removeFilter(_ id: String, _ filterId: String) { edit(id) { $0.filters.removeAll { $0.id == filterId } } } /// Replaces the filters (and the preamp, when given): an Equalizer APO / AutoEQ import. func setFilters(_ id: String, _ filters: [EqFilter], preamp: Float?) { edit(id) { p in p.filters = filters if let preamp { p.preamp = Self.clamp(preamp, Self.preampRange) } } } @discardableResult func create(_ name: String, from: EqProfile? = nil) -> EqProfile { let trimmed = name.trimmingCharacters(in: .whitespaces) let p = EqProfile(id: UUID().uuidString.lowercased(), name: trimmed.isEmpty ? "Profile" : name, gains: from?.gains ?? Array(repeating: 0, count: EQ_BANDS.count), filters: from?.filters.map { var f = $0; f.id = Self.newFilterId(); return f } ?? [], preamp: from?.preamp ?? 0) var s = state s.profiles.append(p) update(s) return p } func delete(_ id: String) { var s = state guard s.profiles.count > 1 else { return } s.profiles.removeAll { $0.id == id } if s.defaultId == id { s.defaultId = s.profiles[0].id } if s.manualId == id { s.manualId = nil } update(s) } func setDefault(_ id: String) { var s = state; s.defaultId = id; update(s) } /// Links [device] to [id], unlinking it from any other profile (one profile per route). func link(_ id: String, _ device: OutputDevice, _ on: Bool) { var s = state s.profiles = s.profiles.map { p in var p = p p.devices.removeAll { $0 == device } if p.id == id && on { p.devices.append(device) } return p } update(s) } func profileFor(_ device: OutputDevice) -> EqProfile? { state.profiles.first { $0.devices.contains(device) } } func profile(_ id: String) -> EqProfile? { state.profiles.first { $0.id == id } } private func edit(_ id: String, _ f: (inout EqProfile) -> Void) { var s = state if let i = s.profiles.firstIndex(where: { $0.id == id }) { f(&s.profiles[i]) } update(s) } private func update(_ s: EqState) { state = s publish() // Slider drags update many times a second; write once they settle. saveWork?.cancel() let snapshot = s let w = DispatchWorkItem { [file] in if let d = try? JSONEncoder().encode(snapshot) { try? d.write(to: file, options: .atomic) } } saveWork = w DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.4, execute: w) } private func publish() { let p = resolve(state, device) if active != p { active = p } onParams?(Self.paramsFor(state, p)) } func currentParams() -> EqParams? { Self.paramsFor(state, active) } private func resolve(_ s: EqState, _ d: OutputDevice) -> EqProfile? { if !s.automatic, let m = s.manualId, let p = s.profiles.first(where: { $0.id == m }) { return p } return s.profiles.first { $0.devices.contains(d) } ?? s.profiles.first { $0.id == s.defaultId } ?? s.profiles.first } static func paramsFor(_ s: EqState, _ p: EqProfile?) -> EqParams? { guard s.enabled, let p else { return nil } let chain = p.chain if chain.isEmpty && p.preamp == 0 { return nil } return EqParams(filters: chain, preampDb: p.preamp + (s.antiClip ? EqMath.autoPreampDb(chain, manualDb: p.preamp) : 0)) } private static func clamp(_ v: Float, _ r: Float) -> Float { min(max(v, -r), r) } private static func load(_ file: URL) -> EqState { if let d = try? Data(contentsOf: file), let s = try? JSONDecoder().decode(EqState.self, from: d) { return s } var s = EqState() s.profiles = [ EqProfile(id: "flat", name: "Flat"), EqProfile(id: "speaker", name: "Small Speaker Tune", gains: EQ_PRESETS.first { $0.0 == "Small Speakers" }!.1, devices: [.speaker]), ] return s } }