import Foundation /// A one-tap stem mix. [build] returns nil when the track lacks the stems it needs. struct MixPreset: Identifiable, Equatable { /// The stored name, which ids are built from. Show [label] instead. let name: String /// One the user saved, as opposed to a built-in. var custom = false let build: ([String]) -> [String: StemSetting]? var id: String { (custom ? "c:" : "b:") + name } /// The name as shown, with A Cappella spelled the way Appearance says. var label: String { !custom && name == "A Cappella" && MixPresets.acapella ? "Acapella" : name } static func == (a: MixPreset, b: MixPreset) -> Bool { a.id == b.id } } enum MixPresets { static let full = MixPreset(name: "Full Mix") { _ in [:] } static let karaoke = MixPreset(name: "Karaoke") { n in n.contains("vocals") ? ["vocals": StemSetting(muted: true)] : nil } static let all: [MixPreset] = [ full, karaoke, MixPreset(name: "A Cappella") { n in n.contains("vocals") ? ["vocals": StemSetting(solo: true)] : nil }, MixPreset(name: "No Drums") { n in n.contains("drums") ? ["drums": StemSetting(muted: true)] : nil }, MixPreset(name: "Rhythm") { n in n.contains("drums") && n.contains("bass") ? ["drums": StemSetting(solo: true), "bass": StemSetting(solo: true)] : nil }, MixPreset(name: "No Guitar") { n in n.contains("guitar") ? ["guitar": StemSetting(muted: true)] : nil }, MixPreset(name: "No Bass") { n in n.contains("bass") ? ["bass": StemSetting(muted: true)] : nil }, ] static func custom(_ c: CustomPreset) -> MixPreset { MixPreset(name: c.name, custom: true) { _ in c.mix } } /// Every preset, the user's and the built-ins, in the order set in the Stem Editor (new ones go to the end). static func ordered(_ s: Settings) -> [MixPreset] { let pool = CustomPreset.load(s).map(custom) + all let order = s.presetOrder.split(separator: "\n").map(String.init) let rank = Dictionary(order.enumerated().map { ($1, $0) }, uniquingKeysWith: { a, _ in a }) return pool.enumerated().sorted { a, b in (rank[a.element.id] ?? order.count + a.offset, a.offset) < (rank[b.element.id] ?? order.count + b.offset, b.offset) }.map(\.element) } /// Set by Appearance. Read straight from UserDefaults so labels work off the main thread. static var acapella: Bool { UserDefaults.standard.bool(forKey: "acapella") } /// Built-ins deleted in the Stem Editor. Custom ids from older builds, which could hide those too, don't count. static func hidden(_ s: Settings) -> Set { Set(s.hiddenPresets.split(separator: "\n").map(String.init).filter { $0.hasPrefix("b:") }) } /// The chips in the mixer bar, and the rotation for every Mix button outside the app. static func bar(_ s: Settings) -> [MixPreset] { let h = hidden(s) return ordered(s).filter { !h.contains($0.id) } } static func restoreDefaults(_ s: Settings) { s.hiddenPresets = "" } static func setOrder(_ s: Settings, _ list: [MixPreset]) { s.presetOrder = list.map(\.id).joined(separator: "\n") } static func setHidden(_ s: Settings, _ id: String, _ on: Bool) { var h = hidden(s) if on { h.insert(id) } else { h.remove(id) } s.hiddenPresets = h.sorted().joined(separator: "\n") } /// The preset every song starts with, whatever Keep Stem Mix says. Nil when unlocked. static func locked(_ s: Settings) -> MixPreset? { s.lockedPreset.isEmpty ? nil : ordered(s).first { $0.id == s.lockedPreset } } /// Drops stems left at their defaults, so mixes compare by what they actually change. static func normalized(_ m: [String: StemSetting]) -> [String: StemSetting] { m.filter { $0.value != StemSetting() } } /// The preset [mix] matches for these stems, or nil for a hand-made mix. static func current(_ names: [String], _ mix: [String: StemSetting]) -> MixPreset? { let m = normalized(mix) return all.first { p in p.build(names).map { normalized($0) == m } ?? false } } /// Like [current], but also matches the user's own presets, so a saved mix is named wherever it shows. static func current(_ s: Settings, _ names: [String], _ mix: [String: StemSetting]) -> MixPreset? { let m = normalized(mix) return ordered(s).first { p in p.build(names).map { normalized($0) == m } ?? false } } /// The mix after the current one in the Stem Editor's list, skipping ones these stems can't do. /// A hand-made mix, or one deleted from the editor, goes to the start of the list. static func next(_ s: Settings, _ names: [String], _ mix: [String: StemSetting]) -> MixPreset { let usable = bar(s).filter { $0.build(names) != nil } guard !usable.isEmpty else { return full } let m = normalized(mix) guard let i = usable.firstIndex(where: { p in p.build(names).map { normalized($0) == m } ?? false }) else { return usable[0] } return usable[(i + 1) % usable.count] } /// True when the vocals stem can't be heard (muted, or another stem soloed). static func vocalsSilent(_ names: [String], _ mix: [String: StemSetting]) -> Bool { if !names.contains("vocals") { return false } let v = mix["vocals"] ?? StemSetting() let anySolo = names.contains { mix[$0]?.solo == true } return v.muted || anySolo && !v.solo || v.level <= 0 } } /// A named, app-wide mix the user saved from the mixer. Stored as JSON in [Settings.presets]. struct CustomPreset: Codable, Identifiable, Equatable { var name: String var mix: [String: StemSetting] var id: String { name } @MainActor static func load() -> [CustomPreset] { load(AppGraph.shared.settings) } static func load(_ s: Settings) -> [CustomPreset] { guard let d = s.presets.data(using: .utf8) else { return [] } return (try? JSONDecoder().decode([CustomPreset].self, from: d)) ?? [] } @MainActor static func save(_ list: [CustomPreset]) { guard let d = try? JSONEncoder().encode(list), let s = String(data: d, encoding: .utf8) else { return } AppGraph.shared.settings.presets = s } /// Adds or replaces the preset with this name. @MainActor static func add(_ name: String, _ mix: [String: StemSetting]) { var l = load().filter { $0.name != name } l.append(CustomPreset(name: name, mix: MixPresets.normalized(mix))) save(l) } @MainActor static func remove(_ name: String) { let s = AppGraph.shared.settings if s.lockedPreset == "c:" + name { s.lockedPreset = "" } save(load().filter { $0.name != name }) } @MainActor static func removeAll() { let s = AppGraph.shared.settings if s.lockedPreset.hasPrefix("c:") { s.lockedPreset = "" } save([]) } }