Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Sonnet 5.5 committed 2026-10-24 17:08:51 -07:00
1 parent 894dd5a695
commit df5d847169
157 files changed
+3935 -742

No files matched your search

+107 -14
View File
@@ -2,28 +2,74 @@ import Foundation
/// A one-tap stem mix. [build] returns nil when the track lacks the stems it needs.
struct MixPreset: Identifiable, Equatable {
let label: String
/// 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 { label }
static func == (a: MixPreset, b: MixPreset) -> Bool { a.label == b.label }
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(label: "Full Mix") { _ in [:] }
static let karaoke = MixPreset(label: "Karaoke") { n in n.contains("vocals") ? ["vocals": StemSetting(muted: true)] : nil }
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(label: "A Cappella") { n in n.contains("vocals") ? ["vocals": StemSetting(solo: true)] : nil },
MixPreset(label: "No Drums") { n in n.contains("drums") ? ["drums": StemSetting(muted: true)] : nil },
MixPreset(label: "Rhythm") { n in
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(label: "No Guitar") { n in n.contains("guitar") ? ["guitar": StemSetting(muted: true)] : nil },
MixPreset(label: "No Bass") { n in n.contains("bass") ? ["bass": StemSetting(muted: 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() } }
@@ -33,10 +79,19 @@ enum MixPresets {
return all.first { p in p.build(names).map { normalized($0) == m } ?? false }
}
/// The preset after the current one (a custom mix goes back to Full), skipping ones these stems can't do.
static func next(_ names: [String], _ mix: [String: StemSetting]) -> MixPreset {
let usable = all.filter { $0.build(names) != nil }
guard let cur = current(names, mix), let i = usable.firstIndex(of: cur) else { return full }
/// 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]
}
@@ -48,3 +103,41 @@ enum MixPresets {
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([])
}
}