Files
stemify/ios/Stemify/UI/Screens/EqScreens.swift
T
cdnutterandClaude Opus 5.5 fdf9e970b2 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>
2026-10-20 07:15:47 -07:00

671 lines
33 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}