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,670 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// ---- formatting and slider mappings ---------------------------------------------------------
|
||||
|
||||
private let F_LO = 20.0
|
||||
private let F_HI = 20000.0
|
||||
private let RIGHT = Color(hex: 0xFF9500)
|
||||
|
||||
func hz(_ f: Float) -> String {
|
||||
guard f >= 1000 else { return "\(Int(f)) Hz" }
|
||||
return String(format: f >= 10000 ? "%.1f kHz" : "%.2f kHz", f / 1000)
|
||||
.replacingOccurrences(of: ".00 ", with: " ").replacingOccurrences(of: ".0 ", with: " ")
|
||||
}
|
||||
|
||||
private func db(_ v: Float) -> String {
|
||||
v > 0.05 ? String(format: "+%.1f", v) : v < -0.05 ? String(format: "−%.1f", -v) : "0.0"
|
||||
}
|
||||
|
||||
private func signedDb(_ v: Float) -> String {
|
||||
(v > 0.005 ? "+" : v < -0.005 ? "−" : "") + String(format: "%.1f dB", abs(v))
|
||||
}
|
||||
|
||||
private func freqToPos(_ f: Float) -> Float { Float(min(max(log(Double(f) / F_LO) / log(F_HI / F_LO), 0), 1)) }
|
||||
private func posToFreq(_ p: Float) -> Float { Float(F_LO * pow(F_HI / F_LO, Double(p))) }
|
||||
private func qToPos(_ q: Float) -> Float {
|
||||
min(max(log(q / EqController.qMin) / log(EqController.qMax / EqController.qMin), 0), 1)
|
||||
}
|
||||
private func posToQ(_ p: Float) -> Float { EqController.qMin * pow(EqController.qMax / EqController.qMin, p) }
|
||||
|
||||
/// Rounds a dragged frequency to what a person would type: 3 significant figures.
|
||||
private func tidyFreq(_ f: Float) -> Float {
|
||||
let mag = Float(pow(10.0, floor(log10(Double(f))) - 2))
|
||||
return (f / mag).rounded() * mag
|
||||
}
|
||||
|
||||
private func round1(_ v: Float) -> Float { (v * 10).rounded() / 10 }
|
||||
|
||||
private func parseNumber(_ s: String) -> Float? {
|
||||
Float(s.replacingOccurrences(of: ",", with: ".").replacingOccurrences(of: "−", with: "-")
|
||||
.filter { $0.isNumber || $0 == "." || $0 == "-" })
|
||||
}
|
||||
|
||||
private func trimmed(_ v: Float) -> String {
|
||||
var s = String(format: "%.2f", v)
|
||||
while s.hasSuffix("0") { s.removeLast() }
|
||||
if s.hasSuffix(".") { s.removeLast() }
|
||||
return s
|
||||
}
|
||||
|
||||
private func summary(_ f: EqFilter) -> String {
|
||||
var parts: [String] = []
|
||||
if f.type.hasGain { parts.append(signedDb(f.gain)) }
|
||||
if f.type.hasSlope { parts.append("\(f.slope) dB/oct") }
|
||||
if !f.type.hasSlope || f.slope == 12 { parts.append(String(format: "Q %.2f", f.q)) }
|
||||
if f.channel != .Both { parts.append(f.channel.label) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
/// Sensible starting points, audible but mild, for a new filter of each type.
|
||||
private func newFilter(_ type: FilterType) -> EqFilter {
|
||||
var f = EqFilter(id: EqController.newFilterId(), type: type)
|
||||
switch type {
|
||||
case .Peak: f.freq = 1000; f.gain = 3; f.q = 1.41
|
||||
case .LowShelf: f.freq = 105; f.gain = 3; f.q = 0.71
|
||||
case .HighShelf: f.freq = 8000; f.gain = 3; f.q = 0.71
|
||||
case .LowPass: f.freq = 16000; f.q = 0.71
|
||||
case .HighPass: f.freq = 30; f.q = 0.71; f.slope = 24
|
||||
case .BandPass: f.freq = 1000; f.q = 0.71
|
||||
case .Notch: f.freq = 60; f.q = 4
|
||||
case .AllPass: f.freq = 1000; f.q = 0.71
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func effectivePreamp(_ p: EqProfile) -> Float {
|
||||
p.preamp + (AppGraph.shared.eq.state.antiClip ? EqMath.autoPreampDb(p.chain, manualDb: p.preamp) : 0)
|
||||
}
|
||||
|
||||
// ---- response curve -------------------------------------------------------------------------
|
||||
|
||||
/// The EQ's response, log frequency across, dB up. Left and right are drawn separately when
|
||||
/// some filter applies to only one of them (right in orange). [nodes] are drawn as numbered
|
||||
/// handles; [highlight] gets its own curve in the accent colour.
|
||||
struct EqCurve: View {
|
||||
let chain: [EqFilter]
|
||||
let preampDb: Float
|
||||
let enabled: Bool
|
||||
var range: Float = 18
|
||||
var bandMarkers = false
|
||||
var nodes: [EqFilter] = []
|
||||
var highlight: EqFilter?
|
||||
var labels = false
|
||||
|
||||
@Environment(\.palette) private var p
|
||||
private static let freqs = EqMath.logFreqs(200, lo: F_LO, hi: F_HI)
|
||||
|
||||
var body: some View {
|
||||
let left = EqMath.response(chain, Self.freqs, left: true)
|
||||
let right = chain.contains { $0.channel != .Both } ? EqMath.response(chain, Self.freqs, left: false) : nil
|
||||
let own = highlight.map { EqMath.response([$0], Self.freqs) }
|
||||
Canvas { ctx, size in
|
||||
let r = Double(range)
|
||||
func y(_ d: Double) -> CGFloat { size.height / 2 - CGFloat(d / r) * size.height / 2 }
|
||||
func x(_ f: Double) -> CGFloat { CGFloat(log(f / F_LO) / log(F_HI / F_LO)) * size.width }
|
||||
func hline(_ yy: CGFloat, _ c: Color, _ w: CGFloat) {
|
||||
ctx.stroke(Path { $0.move(to: CGPoint(x: 0, y: yy)); $0.addLine(to: CGPoint(x: size.width, y: yy)) }, with: .color(c), lineWidth: w)
|
||||
}
|
||||
var d = 6.0
|
||||
while d < r { hline(y(d), p.separator, 1); hline(y(-d), p.separator, 1); d += 6 }
|
||||
hline(y(0), p.label3, 1.5)
|
||||
let grid = bandMarkers ? EQ_BANDS.map(Double.init) : [50, 100, 200, 500, 1000, 2000, 5000, 10000]
|
||||
for f in grid {
|
||||
ctx.stroke(Path { $0.move(to: CGPoint(x: x(f), y: 0)); $0.addLine(to: CGPoint(x: x(f), y: size.height)) }, with: .color(p.separator), lineWidth: 1)
|
||||
}
|
||||
if labels {
|
||||
for (f, l) in [(100.0, "100"), (1000.0, "1k"), (10000.0, "10k")] {
|
||||
ctx.draw(Text(l).font(.caption2).foregroundStyle(p.label3), at: CGPoint(x: x(f) + 3, y: size.height - 2), anchor: .bottomLeading)
|
||||
}
|
||||
ctx.draw(Text("+\(Int(range))").font(.caption2).foregroundStyle(p.label3), at: CGPoint(x: 3, y: 1), anchor: .topLeading)
|
||||
ctx.draw(Text("−\(Int(range))").font(.caption2).foregroundStyle(p.label3), at: CGPoint(x: 3, y: size.height - 2), anchor: .bottomLeading)
|
||||
}
|
||||
func curve(_ v: [Double]) -> Path {
|
||||
Path { path in
|
||||
for (i, d) in v.enumerated() {
|
||||
let pt = CGPoint(x: CGFloat(i) / CGFloat(v.count - 1) * size.width, y: y(d))
|
||||
if i == 0 { path.move(to: pt) } else { path.addLine(to: pt) }
|
||||
}
|
||||
}
|
||||
}
|
||||
let round = StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)
|
||||
let main = enabled ? p.accent : p.label3
|
||||
if let own {
|
||||
let c = enabled ? main.opacity(0.35) : p.label3
|
||||
var area = curve(own)
|
||||
area.addLine(to: CGPoint(x: size.width, y: y(0)))
|
||||
area.addLine(to: CGPoint(x: 0, y: y(0)))
|
||||
area.closeSubpath()
|
||||
ctx.fill(area, with: .color(c.opacity(0.4)))
|
||||
ctx.stroke(curve(own), with: .color(c), style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
ctx.stroke(curve(left), with: .color(highlight != nil ? p.label : main), style: round)
|
||||
if let right {
|
||||
ctx.stroke(curve(right), with: .color(enabled ? RIGHT : p.label3), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
|
||||
}
|
||||
if preampDb < 0 && highlight == nil {
|
||||
ctx.stroke(curve(left.map { $0 + Double(preampDb) }), with: .color(p.label2),
|
||||
style: StrokeStyle(lineWidth: 1.2, lineCap: .round, dash: [6, 6]))
|
||||
}
|
||||
for (i, f) in nodes.enumerated() {
|
||||
let sel = f.id == highlight?.id
|
||||
let cx = x(min(max(Double(f.freq), F_LO), F_HI))
|
||||
let cy = f.type.hasGain ? y(min(max(Double(f.gain), -r), r)) : y(0)
|
||||
let rad: CGFloat = sel ? 11 : 9
|
||||
let col = !f.on ? p.label3 : f.channel == .Right ? RIGHT : main
|
||||
let dot = Path(ellipseIn: CGRect(x: cx - rad, y: cy - rad, width: rad * 2, height: rad * 2))
|
||||
ctx.fill(dot, with: .color(sel ? .white : col))
|
||||
if sel { ctx.stroke(dot, with: .color(col), lineWidth: 2.5) }
|
||||
ctx.draw(Text("\(i + 1)").font(.caption2.bold()).foregroundStyle(sel ? col : .white), at: CGPoint(x: cx, y: cy))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One graphic EQ band: a vertical track with a thumb, −1...1, snapping to 0 near the middle.
|
||||
struct VerticalBandSlider: View {
|
||||
let value: Float
|
||||
let onChange: (Float) -> Void
|
||||
@Environment(\.palette) private var p
|
||||
@State private var start: Float?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let t: CGFloat = 24, w: CGFloat = 4
|
||||
let top = t / 2, bottom = geo.size.height - t / 2, mid = (top + bottom) / 2
|
||||
let yy = mid - (bottom - top) / 2 * CGFloat(value)
|
||||
Canvas { ctx, size in
|
||||
let x = size.width / 2
|
||||
ctx.fill(Path(roundedRect: CGRect(x: x - w / 2, y: top, width: w, height: bottom - top), cornerRadius: w / 2), with: .color(p.fillStrong))
|
||||
ctx.fill(Path(roundedRect: CGRect(x: x - w / 2, y: min(yy, mid), width: w, height: abs(yy - mid)), cornerRadius: w / 2), with: .color(p.accent))
|
||||
ctx.stroke(Path { $0.move(to: CGPoint(x: x - 8, y: mid)); $0.addLine(to: CGPoint(x: x + 8, y: mid)) }, with: .color(p.label3), lineWidth: 1)
|
||||
let thumb = Path(ellipseIn: CGRect(x: x - t / 2, y: yy - t / 2, width: t, height: t))
|
||||
ctx.fill(thumb, with: .color(.white))
|
||||
ctx.stroke(thumb, with: .color(.black.opacity(0.12)), lineWidth: 0.5)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.15), radius: 2, y: 1)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(DragGesture(minimumDistance: 2)
|
||||
.onChanged { g in
|
||||
let s = start ?? value
|
||||
if start == nil { start = value }
|
||||
var v = min(max(s - 2 * Float(g.translation.height / (geo.size.height - t)), -1), 1)
|
||||
if abs(v) < 0.04 { v = 0 }
|
||||
if v == 0 && value != 0 { UISelectionFeedbackGenerator().selectionChanged() }
|
||||
onChange(v)
|
||||
}
|
||||
.onEnded { _ in start = nil })
|
||||
.onTapGesture(count: 2) { onChange(0) }
|
||||
}
|
||||
.frame(width: 32)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- main EQ screen -------------------------------------------------------------------------
|
||||
|
||||
struct EqScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let eq = g.eq
|
||||
let state = eq.state
|
||||
let device = eq.device
|
||||
let linked = eq.profileFor(device)
|
||||
let (icon, bg) = deviceIcon(device)
|
||||
List {
|
||||
Section {
|
||||
toggle("Equalizer", "slider.vertical.3", accentHex, Binding(get: { state.enabled }, set: { eq.setEnabled($0) }))
|
||||
toggle("Anti-Clipping Preamp", "waveform.path.ecg", 0xFF3B30, Binding(get: { state.antiClip }, set: { eq.setAntiClip($0) }))
|
||||
} footer: {
|
||||
Text("Anti-clipping lowers the volume by the curve's highest boost so loud songs don't distort.").foregroundStyle(p.label2)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button { deviceSheet(device) } label: {
|
||||
ValueCell(title: device.label, value: linked?.name ?? "Default", icon: icon, iconBg: bg).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain).themedRow()
|
||||
Picker("Mode", selection: Binding(get: { state.automatic }, set: { eq.setAutomatic($0) })) {
|
||||
Text("Automatic").tag(true)
|
||||
Text("Manual").tag(false)
|
||||
}
|
||||
.pickerStyle(.segmented).themedRow()
|
||||
} header: { Text("Output") } footer: {
|
||||
Text(state.automatic ? "Switches profile when you change headphones or speakers." : "Keeps the profile you picked on every output.").foregroundStyle(p.label2)
|
||||
}
|
||||
|
||||
if let pr = eq.active {
|
||||
let preamp = effectivePreamp(pr)
|
||||
Section {
|
||||
VStack(spacing: 12) {
|
||||
EqCurve(chain: pr.chain, preampDb: preamp, enabled: state.enabled, range: EQ_MAX_DB + 3, bandMarkers: true)
|
||||
.frame(height: 64)
|
||||
HStack(spacing: 0) {
|
||||
ForEach(EQ_BANDS.indices, id: \.self) { i in
|
||||
let v = pr.gains[i]
|
||||
VStack(spacing: 4) {
|
||||
Text(db(v)).font(.caption2.monospacedDigit()).foregroundStyle(abs(v) > 0.05 ? p.accent : p.label2)
|
||||
.lineLimit(1).fixedSize()
|
||||
VerticalBandSlider(value: v / EQ_MAX_DB) { eq.setGain(pr.id, band: i, db: round1($0 * EQ_MAX_DB)) }
|
||||
.frame(height: 124)
|
||||
Text(EQ_LABELS[i]).font(.caption2).foregroundStyle(p.label2)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("Preamp").font(.subheadline).foregroundStyle(p.label2)
|
||||
Spacer()
|
||||
Text(abs(preamp) < 0.05 ? "off" : "\(db(preamp)) dB").font(.subheadline.monospacedDigit()).foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.themedRow()
|
||||
} header: {
|
||||
Text(pr.name + (state.automatic ? "" : " (manual)"))
|
||||
}
|
||||
|
||||
Section {
|
||||
Button { profilesSheet(pr, device) } label: {
|
||||
ValueCell(title: "Profile", value: pr.name).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain).themedRow()
|
||||
.onLongPressGesture { profileMenu(pr, device) }
|
||||
let on = pr.filters.filter(\.on).count
|
||||
NavigationLink(value: Route.EqFilters(profileId: pr.id)) {
|
||||
ValueCell(title: "Parametric Filters", value: pr.filters.isEmpty ? "None" : "\(on) on")
|
||||
}
|
||||
.themedRow()
|
||||
Button {
|
||||
ui.overlays.actions("Load Preset", EQ_PRESETS.map { name, gains in SheetAction(name) { eq.setGains(pr.id, gains) } })
|
||||
} label: {
|
||||
ValueCell(title: "Presets", value: EQ_PRESETS.prefix(2).map(\.0).joined(separator: ", ") + "…").contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain).themedRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Equalizer")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear { eq.refreshRoute() }
|
||||
}
|
||||
|
||||
private var accentHex: UInt32 { g.settings.accent.light }
|
||||
|
||||
private func toggle(_ title: String, _ icon: String, _ bg: UInt32, _ on: Binding<Bool>) -> some View {
|
||||
Toggle(isOn: on) {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: icon, bg: bg)
|
||||
Text(title).foregroundStyle(p.label)
|
||||
}
|
||||
}
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
}
|
||||
|
||||
private func deviceSheet(_ device: OutputDevice) {
|
||||
let eq = g.eq
|
||||
var acts = [SheetAction("Use Default") {
|
||||
for pr in eq.state.profiles where pr.devices.contains(device) { eq.link(pr.id, device, false) }
|
||||
}]
|
||||
acts += eq.state.profiles.map { pr in SheetAction(pr.name) { eq.link(pr.id, device, true) } }
|
||||
ui.overlays.actions("Profile for \(device.label)", acts)
|
||||
}
|
||||
|
||||
private func profilesSheet(_ current: EqProfile, _ device: OutputDevice) {
|
||||
let eq = g.eq
|
||||
var acts = eq.state.profiles.map { pr in
|
||||
let devs = pr.devices.map(\.label).joined(separator: ", ")
|
||||
return SheetAction((pr.id == current.id ? "✓ " : "") + pr.name + (devs.isEmpty ? "" : " · \(devs)")) { eq.select(pr.id) }
|
||||
}
|
||||
acts.append(SheetAction("New Profile…", icon: "plus") {
|
||||
ui.overlays.prompt("New EQ Profile", "Starts from the current curve.", "Create", placeholder: "e.g. Sony WH-1000XM5") { name in
|
||||
let n = name.trimmingCharacters(in: .whitespaces)
|
||||
guard !n.isEmpty else { return }
|
||||
let made = eq.create(n, from: current)
|
||||
eq.link(made.id, device, true)
|
||||
eq.select(made.id)
|
||||
}
|
||||
})
|
||||
acts.append(SheetAction("Manage “\(current.name)”…", icon: "ellipsis.circle") { profileMenu(current, device) })
|
||||
ui.overlays.actions("Profiles", acts)
|
||||
}
|
||||
|
||||
private func profileMenu(_ pr: EqProfile, _ device: OutputDevice) {
|
||||
let eq = g.eq
|
||||
let linked = pr.devices.contains(device)
|
||||
var acts = [
|
||||
SheetAction(linked ? "Unlink from \(device.label)" : "Use for \(device.label)", icon: linked ? "link.badge.plus" : "link") {
|
||||
eq.link(pr.id, device, !linked)
|
||||
},
|
||||
]
|
||||
if eq.state.defaultId != pr.id {
|
||||
acts.append(SheetAction("Make Default", icon: "star") { eq.setDefault(pr.id) })
|
||||
}
|
||||
acts.append(SheetAction("Rename…", icon: "pencil") {
|
||||
ui.overlays.prompt("Rename Profile", nil, initial: pr.name, "Save") { name in
|
||||
let n = name.trimmingCharacters(in: .whitespaces)
|
||||
if !n.isEmpty { eq.rename(pr.id, n) }
|
||||
}
|
||||
})
|
||||
acts.append(SheetAction("Duplicate", icon: "plus.square.on.square") { eq.select(eq.create("\(pr.name) Copy", from: pr).id) })
|
||||
if eq.state.profiles.count > 1 {
|
||||
acts.append(SheetAction("Delete Profile", icon: "trash", destructive: true) {
|
||||
ui.overlays.confirm("Delete “\(pr.name)”?", "Outputs linked to it go back to the default profile.", "Delete") { eq.delete(pr.id) }
|
||||
})
|
||||
}
|
||||
ui.overlays.actions(pr.name, acts)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- filter list ----------------------------------------------------------------------------
|
||||
|
||||
struct EqFiltersScreen: View {
|
||||
let profileId: String
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var curveWidth: CGFloat = 1
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let eq = g.eq
|
||||
if let pr = eq.profile(profileId) {
|
||||
let preamp = effectivePreamp(pr)
|
||||
List {
|
||||
Section {
|
||||
EqCurve(chain: pr.chain, preampDb: preamp, enabled: eq.state.enabled, nodes: pr.filters, labels: true)
|
||||
.frame(height: 170)
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { curveWidth = $0 }
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { o in tap(o, pr) }
|
||||
.padding(.vertical, 6)
|
||||
.themedRow()
|
||||
} header: { Text(pr.name) } footer: {
|
||||
Text("Tap a numbered handle to edit that filter. Right-channel filters are orange. Paste an AutoEQ ParametricEQ.txt (from the ••• menu) to load a headphone correction.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
FaderSlider(value: (pr.preamp + EqController.preampRange) / (2 * EqController.preampRange), fill: p.accent, detent: 0.5) { v in
|
||||
eq.setPreamp(profileId, round1((v * 2 - 1) * EqController.preampRange))
|
||||
}
|
||||
ValueLabel(text: signedDb(pr.preamp), width: 76) {
|
||||
ui.overlays.prompt("Preamp", "In dB, −24 to +24.", initial: String(format: "%.1f", pr.preamp), "Set") { s in
|
||||
if let v = parseNumber(s) { eq.setPreamp(profileId, v) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.themedRow()
|
||||
} header: { Text("Preamp") } footer: {
|
||||
if eq.state.antiClip {
|
||||
Text("Anti-clipping adds \(signedDb(preamp - pr.preamp)) on top, so the total is \(signedDb(preamp)).").foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(Array(pr.filters.enumerated()), id: \.element.id) { i, f in
|
||||
NavigationLink(value: Route.EqFilterPage(profileId: profileId, filterId: f.id)) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(i + 1). \(f.type.label) · \(hz(f.freq))").foregroundStyle(f.on ? p.label : p.label2)
|
||||
let s = summary(f)
|
||||
if !s.isEmpty { Text(s).font(.subheadline).foregroundStyle(p.label2) }
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: Binding(get: { f.on }, set: { on in var nf = f; nf.on = on; eq.putFilter(profileId, nf) }))
|
||||
.labelsHidden().tint(Color(hex: 0x34C759))
|
||||
}
|
||||
}
|
||||
.themedRow()
|
||||
.contextMenu {
|
||||
Button { var nf = f; nf.id = EqController.newFilterId(); eq.putFilter(profileId, nf) } label: { Label("Duplicate", systemImage: "plus.square.on.square") }
|
||||
Button(role: .destructive) { eq.removeFilter(profileId, f.id) } label: { Label("Delete", systemImage: "trash") }
|
||||
}
|
||||
.swipeActions {
|
||||
Button(role: .destructive) { eq.removeFilter(profileId, f.id) } label: { Label("Delete", systemImage: "trash") }
|
||||
}
|
||||
}
|
||||
Button { add() } label: { Text("Add Filter…").foregroundStyle(p.accent) }.themedRow()
|
||||
} header: { Text("Filters") }
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Filters")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button { apoMenu(pr) } label: { Image(systemName: "ellipsis.circle") }
|
||||
Button { add() } label: { Image(systemName: "plus") }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Color.clear.onAppear { ui.pop() }
|
||||
}
|
||||
}
|
||||
|
||||
private func tap(_ o: CGPoint, _ pr: EqProfile) {
|
||||
let w = curveWidth, h: CGFloat = 170
|
||||
let dist: (EqFilter) -> CGFloat = { f in
|
||||
let dx = CGFloat(freqToPos(f.freq)) * w - o.x
|
||||
let dy = (f.type.hasGain ? h / 2 - CGFloat(f.gain) / 18 * h / 2 : h / 2) - o.y
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
guard let hit = pr.filters.min(by: { dist($0) < dist($1) }),
|
||||
abs(CGFloat(freqToPos(hit.freq)) * w - o.x) < 40 else { return }
|
||||
ui.push(.EqFilterPage(profileId: profileId, filterId: hit.id))
|
||||
}
|
||||
|
||||
private func add() {
|
||||
let eq = g.eq
|
||||
ui.overlays.actions("Add Filter", FilterType.allCases.map { t in
|
||||
SheetAction(t.label) {
|
||||
let f = newFilter(t)
|
||||
eq.putFilter(profileId, f)
|
||||
ui.push(.EqFilterPage(profileId: profileId, filterId: f.id))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func apoMenu(_ pr: EqProfile) {
|
||||
let eq = g.eq
|
||||
var acts = [
|
||||
SheetAction("Paste APO Config", icon: "doc.on.clipboard") { importApo(pr) },
|
||||
SheetAction("Copy as APO Config", icon: "doc.on.doc") {
|
||||
UIPasteboard.general.string = ApoConfig.format(pr)
|
||||
ui.overlays.hud("Copied")
|
||||
},
|
||||
]
|
||||
if !pr.filters.isEmpty {
|
||||
acts.append(SheetAction("Remove All Filters", icon: "trash", destructive: true) {
|
||||
ui.overlays.confirm("Remove All Filters?", "The graphic EQ bands stay as they are.", "Remove All") {
|
||||
eq.setFilters(profileId, [], preamp: nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
ui.overlays.actions("Equalizer APO / AutoEQ", acts)
|
||||
}
|
||||
|
||||
private func importApo(_ pr: EqProfile) {
|
||||
let eq = g.eq
|
||||
guard let parsed = ApoConfig.parse(UIPasteboard.general.string ?? "") else {
|
||||
ui.overlays.hud("No Equalizer APO filters on the clipboard", "info.circle")
|
||||
return
|
||||
}
|
||||
func plural(_ n: Int, _ w: String) -> String { "\(n) \(w)\(n == 1 ? "" : "s")" }
|
||||
var what: [String] = []
|
||||
if !parsed.filters.isEmpty { what.append(plural(parsed.filters.count, "filter")) }
|
||||
if let pre = parsed.preamp { what.append("preamp \(signedDb(pre))") }
|
||||
let w = what.joined(separator: " and ")
|
||||
let skipped = parsed.skipped > 0 ? " \(plural(parsed.skipped, "line")) it doesn't understand (GraphicEQ, Delay…) will be skipped." : ""
|
||||
ui.overlays.confirm("Replace Filters?", "Loads \(w) into “\(pr.name)”, replacing its \(plural(pr.filters.count, "filter")).\(skipped)", "Replace", destructive: false) {
|
||||
eq.setFilters(profileId, parsed.filters, preamp: parsed.preamp)
|
||||
ui.overlays.hud("Imported \(w)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ValueLabel: View {
|
||||
let text: String
|
||||
let width: CGFloat
|
||||
let action: () -> Void
|
||||
@Environment(\.palette) private var p
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(text).font(.subheadline.monospacedDigit()).foregroundStyle(p.accent).frame(width: width, alignment: .trailing)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- one filter -----------------------------------------------------------------------------
|
||||
|
||||
struct EqFilterPage: View {
|
||||
let profileId: String
|
||||
let filterId: String
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let eq = g.eq
|
||||
if let pr = eq.profile(profileId), let i = pr.filters.firstIndex(where: { $0.id == filterId }) {
|
||||
let f = pr.filters[i]
|
||||
List {
|
||||
Section {
|
||||
GeometryReader { geo in
|
||||
EqCurve(chain: pr.chain, preampDb: 0, enabled: eq.state.enabled && f.on, nodes: pr.filters, highlight: f, labels: true)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(DragGesture(minimumDistance: 0).onChanged { v in
|
||||
guard let cur = eq.profile(profileId)?.filters.first(where: { $0.id == filterId }) else { return }
|
||||
let pos = Float(min(max(v.location.x / geo.size.width, 0), 1))
|
||||
var nf = cur
|
||||
nf.freq = tidyFreq(posToFreq(pos))
|
||||
if cur.type.hasGain {
|
||||
nf.gain = round1(min(max(18 * (1 - 2 * Float(v.location.y / geo.size.height)), -18), 18))
|
||||
}
|
||||
eq.putFilter(profileId, nf)
|
||||
})
|
||||
}
|
||||
.frame(height: 190)
|
||||
.padding(.vertical, 6)
|
||||
.themedRow()
|
||||
} footer: {
|
||||
Text(f.type.hasGain ? "Drag the handle: sideways for frequency, up and down for gain." : "Drag the handle sideways to change the frequency.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
ui.overlays.actions("Filter Type", FilterType.allCases.map { t in
|
||||
SheetAction((t == f.type ? "✓ " : "") + t.label) {
|
||||
var nf = f
|
||||
nf.type = t
|
||||
if t == .Notch && f.type != .Notch { nf.q = 4 }
|
||||
put(nf)
|
||||
}
|
||||
})
|
||||
} label: {
|
||||
HStack {
|
||||
ValueCell(title: "Type", value: f.type.label)
|
||||
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain).themedRow()
|
||||
Toggle("On", isOn: Binding(get: { f.on }, set: { var nf = f; nf.on = $0; put(nf) }))
|
||||
.tint(Color(hex: 0x34C759)).foregroundStyle(p.label).themedRow()
|
||||
}
|
||||
|
||||
Section {
|
||||
param("Frequency", hz(f.freq), prompt: { number("Frequency", "In Hz, 10 to 22000.", f.freq) { var nf = f; nf.freq = $0; put(nf) } }) {
|
||||
FaderSlider(value: freqToPos(f.freq), fill: p.accent) { var nf = f; nf.freq = tidyFreq(posToFreq($0)); put(nf) }
|
||||
}
|
||||
if f.type.hasGain {
|
||||
param("Gain", signedDb(f.gain), prompt: { number("Gain", "In dB, −24 to +24.", f.gain) { var nf = f; nf.gain = $0; put(nf) } }) {
|
||||
FaderSlider(value: (f.gain + EqController.filterGain) / (2 * EqController.filterGain), fill: p.accent, detent: 0.5) {
|
||||
var nf = f; nf.gain = round1(($0 * 2 - 1) * EqController.filterGain); put(nf)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !f.type.hasSlope || f.slope == 12 {
|
||||
param("Q", String(format: "%.2f", f.q), prompt: { number("Q", "0.1 (broad) to 20 (narrow). One octave is 1.41.", f.q) { var nf = f; nf.q = $0; put(nf) } }) {
|
||||
FaderSlider(value: qToPos(f.q), fill: p.accent) { var nf = f; nf.q = (posToQ($0) * 100).rounded() / 100; put(nf) }
|
||||
}
|
||||
}
|
||||
if f.type.hasSlope {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Slope (dB per octave)").font(.subheadline).foregroundStyle(p.label2)
|
||||
Picker("Slope", selection: Binding(get: { f.slope }, set: { var nf = f; nf.slope = $0; put(nf) })) {
|
||||
ForEach(EQ_SLOPES, id: \.self) { Text("\($0)").tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.vertical, 4).themedRow()
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Channel").font(.subheadline).foregroundStyle(p.label2)
|
||||
Picker("Channel", selection: Binding(get: { f.channel }, set: { var nf = f; nf.channel = $0; put(nf) })) {
|
||||
ForEach(EqChannel.allCases, id: \.self) { Text($0.label).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.vertical, 4).themedRow()
|
||||
} footer: {
|
||||
Text(f.type.hasSlope && f.slope != 12 ? "Steeper than 12 dB/oct is a Butterworth response, so there's no Q."
|
||||
: f.type == .AllPass ? "An all-pass changes phase around its frequency, not level, so the curve stays flat."
|
||||
: "Tap a value to type it.")
|
||||
.foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("\(i + 1). \(f.type.label)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
ui.overlays.actions("\(f.type.label) · \(hz(f.freq))", [
|
||||
SheetAction("Duplicate", icon: "plus.square.on.square") {
|
||||
var copy = f
|
||||
copy.id = EqController.newFilterId()
|
||||
put(copy)
|
||||
ui.push(.EqFilterPage(profileId: profileId, filterId: copy.id))
|
||||
},
|
||||
SheetAction("Delete Filter", icon: "trash", destructive: true) { eq.removeFilter(profileId, f.id) },
|
||||
])
|
||||
} label: { Image(systemName: "ellipsis.circle") }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Color.clear.onAppear { ui.pop() }
|
||||
}
|
||||
}
|
||||
|
||||
private func put(_ f: EqFilter) { g.eq.putFilter(profileId, f) }
|
||||
|
||||
private func number(_ title: String, _ hint: String, _ initial: Float, _ set: @escaping (Float) -> Void) {
|
||||
ui.overlays.prompt(title, hint, initial: trimmed(initial), "Set") { s in
|
||||
if let v = parseNumber(s) { set(v) }
|
||||
}
|
||||
}
|
||||
|
||||
private func param<S: View>(_ title: String, _ value: String, prompt: @escaping () -> Void, @ViewBuilder slider: () -> S) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
HStack {
|
||||
Text(title).font(.subheadline).foregroundStyle(p.label2)
|
||||
Spacer()
|
||||
ValueLabel(text: value, width: 110, action: prompt)
|
||||
}
|
||||
slider()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user