Add vocalist preset selector

This commit is contained in:
diyaa
2026-09-15 19:07:55 +02:00
parent 297b791a3e
commit b43c5416f2
9 changed files with 392 additions and 13 deletions
@@ -799,6 +799,50 @@ public struct InstrumentPlacement: Codable, Equatable, Sendable {
}
}
public struct VocalistPreset: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var label: String
public var familyCategory: String
public var regionOrigin: String?
public var voiceType: String?
public var genderSelection: String?
public var performanceStyle: String?
public var aliases: [String]
public var searchTerms: [String]
public init(
id: String,
label: String,
familyCategory: String,
regionOrigin: String? = nil,
voiceType: String? = nil,
genderSelection: String? = nil,
performanceStyle: String? = nil,
aliases: [String] = [],
searchTerms: [String] = []
) {
self.id = id
self.label = label
self.familyCategory = familyCategory
self.regionOrigin = regionOrigin
self.voiceType = voiceType
self.genderSelection = genderSelection
self.performanceStyle = performanceStyle
self.aliases = aliases
self.searchTerms = searchTerms
}
public var vocalist: Vocalist {
Vocalist(
id: id,
label: label,
voiceType: voiceType,
genderSelection: genderSelection,
performanceStyle: performanceStyle
)
}
}
public struct Vocalist: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var label: String
@@ -0,0 +1,129 @@
import Foundation
public struct LocalVocalistPresetCatalog: Sendable {
public let items: [VocalistPreset]
public init(items: [VocalistPreset] = LocalVocalistPresetCatalog.defaultItems) {
self.items = items
}
public var familyCategories: [String] {
Array(Set(items.map(\.familyCategory)))
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
public func vocalists(
matching query: String,
familyCategory: String? = nil
) -> [VocalistPreset] {
let searchTokens = Self.searchTokens(for: query)
let selectedFamilyCategory = familyCategory?.trimmingCharacters(in: .whitespacesAndNewlines)
return items
.filter { item in
let matchesSearch = searchTokens.isEmpty || searchTokens.allSatisfy { token in
Self.searchableTerms(for: item).contains { $0.contains(token) }
}
let matchesFamily = selectedFamilyCategory?.isEmpty != false || item.familyCategory == selectedFamilyCategory
return matchesSearch && matchesFamily
}
.sorted { $0.label.localizedCaseInsensitiveCompare($1.label) == .orderedAscending }
}
public static let defaultItems: [VocalistPreset] = leadVoices + rapAndSpoken + harmonyAndGroups + arabicAndTraditional
private static let leadVoices: [VocalistPreset] = [
item("lead-female", "Female Lead Vocal", "Lead Voices", "Global", "melodic lead", "female", "expressive lead", aliases: ["female singer"]),
item("lead-male", "Male Lead Vocal", "Lead Voices", "Global", "melodic lead", "male", "expressive lead", aliases: ["male singer"]),
item("lead-neutral", "Neutral Lead Vocal", "Lead Voices", "Global", "melodic lead", "neutral", "expressive lead"),
item("soprano", "Soprano Lead", "Lead Voices", "Global", "soprano", "female", "clear high lead"),
item("mezzo-soprano", "Mezzo-Soprano Lead", "Lead Voices", "Global", "mezzo-soprano", "female", "warm lead"),
item("alto", "Alto Lead", "Lead Voices", "Global", "alto", "female", "warm low lead"),
item("tenor", "Tenor Lead", "Lead Voices", "Global", "tenor", "male", "clear lead"),
item("baritone", "Baritone Lead", "Lead Voices", "Global", "baritone", "male", "warm low lead"),
item("bass-vocal", "Bass Lead", "Lead Voices", "Global", "bass", "male", "deep lead"),
item("child-vocal", "Child Vocal", "Lead Voices", "Global", "child voice", "child", "clear youthful lead")
]
private static let rapAndSpoken: [VocalistPreset] = [
item("male-rapper", "Male Rap Vocal", "Rap & Spoken", "Global", "rap", "male", "rhythmic rap", aliases: ["male rapper"], terms: ["راب"]),
item("female-rapper", "Female Rap Vocal", "Rap & Spoken", "Global", "rap", "female", "rhythmic rap", aliases: ["female rapper"], terms: ["راب"]),
item("neutral-rapper", "Neutral Rap Vocal", "Rap & Spoken", "Global", "rap", "neutral", "rhythmic rap", terms: ["راب"]),
item("spoken-word", "Spoken Word", "Rap & Spoken", "Global", "spoken word", "neutral", "narrated delivery", aliases: ["narration"]),
item("melodic-rap", "Melodic Rap Vocal", "Rap & Spoken", "Global", "rap/singing", "neutral", "melodic rap")
]
private static let harmonyAndGroups: [VocalistPreset] = [
item("female-harmony", "Female Harmony Vocal", "Harmony & Groups", "Global", "harmony", "female", "soft harmony", aliases: ["female backing vocal"]),
item("male-harmony", "Male Harmony Vocal", "Harmony & Groups", "Global", "harmony", "male", "soft harmony", aliases: ["male backing vocal"]),
item("mixed-harmony", "Mixed Harmony Vocals", "Harmony & Groups", "Global", "harmony", "mixed", "layered harmony"),
item("female-choir", "Female Choir", "Harmony & Groups", "Global", "choir", "female", "layered choir"),
item("male-choir", "Male Choir", "Harmony & Groups", "Global", "choir", "male", "layered choir"),
item("mixed-choir", "Mixed Choir", "Harmony & Groups", "Global", "choir", "mixed", "wide choral arrangement"),
item("children-choir", "Children's Choir", "Harmony & Groups", "Global", "children choir", "mixed", "bright choir"),
item("duet", "Duet Leads", "Harmony & Groups", "Global", "duet", "mixed", "call and response"),
item("vocal-ensemble", "Vocal Ensemble", "Harmony & Groups", "Global", "ensemble", "mixed", "layered ensemble")
]
private static let arabicAndTraditional: [VocalistPreset] = [
item("arabic-male-lead", "Arabic Male Lead", "Arabic & Traditional", "Arab World", "ornamented male lead", "male", "Arabic melodic delivery", terms: ["صوت عربي رجالي"]),
item("arabic-female-lead", "Arabic Female Lead", "Arabic & Traditional", "Arab World", "ornamented female lead", "female", "Arabic melodic delivery", terms: ["صوت عربي نسائي"]),
item("khaleeji-male-lead", "Khaleeji Male Lead", "Arabic & Traditional", "Gulf", "male lead", "male", "Khaleeji delivery", terms: ["خليجي"]),
item("khaleeji-female-lead", "Khaleeji Female Lead", "Arabic & Traditional", "Gulf", "female lead", "female", "Khaleeji delivery", terms: ["خليجي"]),
item("arabic-chorus", "Arabic Chorus", "Arabic & Traditional", "Arab World", "chorus", "mixed", "Arabic group response", terms: ["كورال عربي"]),
item("sufi-ensemble", "Sufi Vocal Ensemble", "Arabic & Traditional", "Middle East & South Asia", "ensemble", "mixed", "devotional call and response"),
item("folk-storyteller", "Folk Storyteller", "Arabic & Traditional", "Global", "spoken/sung folk vocal", "neutral", "narrative folk delivery")
]
private static func item(
_ id: String,
_ label: String,
_ familyCategory: String,
_ regionOrigin: String? = nil,
_ voiceType: String? = nil,
_ genderSelection: String? = nil,
_ performanceStyle: String? = nil,
aliases: [String] = [],
terms: [String] = []
) -> VocalistPreset {
VocalistPreset(
id: id,
label: label,
familyCategory: familyCategory,
regionOrigin: regionOrigin,
voiceType: voiceType,
genderSelection: genderSelection,
performanceStyle: performanceStyle,
aliases: aliases,
searchTerms: terms
)
}
private static func searchableTerms(for item: VocalistPreset) -> [String] {
searchTokens(
for: [
item.label,
item.familyCategory,
item.regionOrigin ?? "",
item.voiceType ?? "",
item.genderSelection ?? "",
item.performanceStyle ?? ""
] + item.aliases + item.searchTerms
)
}
private static func searchTokens(for query: String) -> [String] {
searchTokens(for: [query])
}
private static func searchTokens(for values: [String]) -> [String] {
values
.joined(separator: " ")
.folding(
options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive],
locale: Locale(identifier: "en_US_POSIX")
)
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "/" })
.map(String.init)
}
}