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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user