144 lines
6.8 KiB
Swift
144 lines
6.8 KiB
Swift
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<String> {
|
|
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([])
|
|
}
|
|
}
|