Add iOS app with CarPlay and Siri
SwiftUI port of the Android app under ios/, with CarPlay browse and Now Playing, Siri play-media intents, and a build-ipa.sh script that makes an unsigned IPA for sideloading. README gets an iOS section and the vibecoded disclaimer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45bc0eefe0
commit
fdf9e970b2
45 files changed
+9822
No files matched your search
@@ -0,0 +1,126 @@
|
||||
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[..<colon].trimmingCharacters(in: .whitespaces).lowercased()
|
||||
let args = line[line.index(after: colon)...].split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
if cmd == "preamp" {
|
||||
guard let v = args.first.flatMap(Float.init) else { continue }
|
||||
preamp = (preamp ?? 0) + v
|
||||
} else if cmd == "channel" {
|
||||
let set = Set(args.map { $0.uppercased() })
|
||||
channel = set == ["L"] ? .Left : set == ["R"] ? .Right : .Both
|
||||
} else if cmd.hasPrefix("filter") {
|
||||
if let f = parseFilter(args, channel) { filters.append(f) } else { skipped += 1 }
|
||||
} else {
|
||||
skipped += 1
|
||||
}
|
||||
}
|
||||
if filters.isEmpty && preamp == nil { return nil }
|
||||
return Result(filters: filters, preamp: preamp, skipped: skipped)
|
||||
}
|
||||
|
||||
private static func parseFilter(_ args: [String], _ channel: EqChannel) -> 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user