Files
stemify/ios/Stemify/Playback/MixPresets.swift
T

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([])
}
}