Add worldwide genre style selector
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import MusicAssistantCore
|
||||
import SwiftUI
|
||||
|
||||
struct GenreStyleBrowserView: View {
|
||||
@Binding private var project: SongProject
|
||||
private let saveProject: (SongProject) async -> Bool
|
||||
private let catalog: LocalGenreStyleCatalog
|
||||
|
||||
@State private var searchText = ""
|
||||
@State private var selectedFamilyCategory: String?
|
||||
@State private var selectedRegionOrigin: String?
|
||||
@State private var hasUnsavedSelectionChanges = false
|
||||
@State private var isSaving = false
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
init(
|
||||
project: Binding<SongProject>,
|
||||
saveProject: @escaping (SongProject) async -> Bool = { _ in true },
|
||||
catalog: LocalGenreStyleCatalog = LocalGenreStyleCatalog()
|
||||
) {
|
||||
_project = project
|
||||
self.saveProject = saveProject
|
||||
self.catalog = catalog
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Picker("Category", selection: $selectedFamilyCategory) {
|
||||
Text("All categories").tag(String?.none)
|
||||
|
||||
ForEach(catalog.familyCategories, id: \.self) { familyCategory in
|
||||
Text(familyCategory).tag(Optional(familyCategory))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Spacer()
|
||||
|
||||
Picker("Region", selection: $selectedRegionOrigin) {
|
||||
Text("All regions").tag(String?.none)
|
||||
|
||||
ForEach(catalog.regionsOfOrigin, id: \.self) { regionOrigin in
|
||||
Text(regionOrigin).tag(Optional(regionOrigin))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Divider()
|
||||
|
||||
List(
|
||||
catalog.styles(
|
||||
matching: searchText,
|
||||
familyCategory: selectedFamilyCategory,
|
||||
regionOrigin: selectedRegionOrigin
|
||||
),
|
||||
id: \.id
|
||||
) { style in
|
||||
Toggle(isOn: selectionBinding(for: style)) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(style.name)
|
||||
.font(.body.weight(.medium))
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text(style.familyCategory)
|
||||
|
||||
if let regionOrigin = style.regionOrigin {
|
||||
Text(regionOrigin)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.toggleStyle(.checkbox)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Genre & Style Catalog")
|
||||
.searchable(text: $searchText, prompt: "Search genres and styles")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") {
|
||||
Task {
|
||||
await saveSelectionChangesIfNeeded()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.disabled(isSaving)
|
||||
}
|
||||
|
||||
if isSaving {
|
||||
ToolbarItem(placement: .status) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 520, minHeight: 560)
|
||||
.onDisappear {
|
||||
guard hasUnsavedSelectionChanges, !isSaving else { return }
|
||||
|
||||
let projectToSave = project
|
||||
hasUnsavedSelectionChanges = false
|
||||
|
||||
Task {
|
||||
_ = await saveProject(projectToSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectionBinding(for item: GenreStyleCatalogItem) -> Binding<Bool> {
|
||||
Binding {
|
||||
project.isGenreStyleSelected(item.genreStyle)
|
||||
} set: { isSelected in
|
||||
if isSelected {
|
||||
project.selectGenreStyle(item.genreStyle)
|
||||
} else {
|
||||
project.deselectGenreStyle(id: item.id)
|
||||
}
|
||||
hasUnsavedSelectionChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
private func saveSelectionChangesIfNeeded() async {
|
||||
guard hasUnsavedSelectionChanges else { return }
|
||||
|
||||
isSaving = true
|
||||
let didSave = await saveProject(project)
|
||||
hasUnsavedSelectionChanges = !didSave
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
GenreStyleBrowserPreview()
|
||||
}
|
||||
|
||||
private struct GenreStyleBrowserPreview: View {
|
||||
@State private var project = SongProject(
|
||||
title: "Genre Catalog Preview",
|
||||
idea: "Browse music styles."
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
GenreStyleBrowserView(project: $project)
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,11 @@ struct ProjectInspectorView: View {
|
||||
@Binding var project: SongProject
|
||||
let saveProject: (SongProject) async -> Bool
|
||||
|
||||
@State private var newGenreName = ""
|
||||
@State private var newInstrumentName = ""
|
||||
@State private var newVocalistLabel = ""
|
||||
@State private var newLanguageName = ""
|
||||
@State private var isInstrumentBrowserPresented = false
|
||||
@State private var isGenreStyleBrowserPresented = false
|
||||
@State private var isSaving = false
|
||||
@State private var saveStatus: SaveStatus?
|
||||
|
||||
@@ -50,17 +50,18 @@ struct ProjectInspectorView: View {
|
||||
}
|
||||
|
||||
Section("Genres") {
|
||||
Button {
|
||||
isGenreStyleBrowserPresented = true
|
||||
} label: {
|
||||
Label("Browse genres and styles", systemImage: "music.note.list")
|
||||
}
|
||||
|
||||
EditableTokenList(
|
||||
values: project.genres.map(\.name),
|
||||
removeAction: { index in
|
||||
project.deselectGenreStyle(id: project.genres[index].id)
|
||||
}
|
||||
)
|
||||
AddTextRow(
|
||||
placeholder: "Add genre",
|
||||
text: $newGenreName,
|
||||
addAction: addGenre
|
||||
)
|
||||
}
|
||||
|
||||
Section("Instruments") {
|
||||
@@ -149,6 +150,9 @@ struct ProjectInspectorView: View {
|
||||
.sheet(isPresented: $isInstrumentBrowserPresented) {
|
||||
InstrumentBrowserView(project: $project, saveProject: saveProject)
|
||||
}
|
||||
.sheet(isPresented: $isGenreStyleBrowserPresented) {
|
||||
GenreStyleBrowserView(project: $project, saveProject: saveProject)
|
||||
}
|
||||
}
|
||||
|
||||
private var durationSecondsBinding: Binding<String> {
|
||||
@@ -172,13 +176,6 @@ struct ProjectInspectorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func addGenre() {
|
||||
let name = trimmed(newGenreName)
|
||||
guard !name.isEmpty else { return }
|
||||
project.selectGenreStyle(GenreStyle(id: stableID(from: name), name: name))
|
||||
newGenreName = ""
|
||||
}
|
||||
|
||||
private func addInstrument() {
|
||||
let name = trimmed(newInstrumentName)
|
||||
guard !name.isEmpty else { return }
|
||||
|
||||
@@ -594,6 +594,35 @@ public struct GenreStyle: Codable, Equatable, Identifiable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct GenreStyleCatalogItem: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var name: String
|
||||
public var familyCategory: String
|
||||
public var regionOrigin: String?
|
||||
public var aliases: [String]
|
||||
public var searchTerms: [String]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
familyCategory: String,
|
||||
regionOrigin: String? = nil,
|
||||
aliases: [String] = [],
|
||||
searchTerms: [String] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.familyCategory = familyCategory
|
||||
self.regionOrigin = regionOrigin
|
||||
self.aliases = aliases
|
||||
self.searchTerms = searchTerms
|
||||
}
|
||||
|
||||
public var genreStyle: GenreStyle {
|
||||
GenreStyle(id: id, name: name, aliases: aliases)
|
||||
}
|
||||
}
|
||||
|
||||
public struct MoodTag: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var name: String
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import Foundation
|
||||
|
||||
public struct LocalGenreStyleCatalog: Sendable {
|
||||
public let items: [GenreStyleCatalogItem]
|
||||
|
||||
public init(items: [GenreStyleCatalogItem] = LocalGenreStyleCatalog.worldwideItems) {
|
||||
self.items = items
|
||||
}
|
||||
|
||||
public var familyCategories: [String] {
|
||||
Array(Set(items.map(\.familyCategory)))
|
||||
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
public var regionsOfOrigin: [String] {
|
||||
Array(Set(items.compactMap(\.regionOrigin)))
|
||||
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
public func styles(
|
||||
matching query: String,
|
||||
familyCategory: String? = nil,
|
||||
regionOrigin: String? = nil
|
||||
) -> [GenreStyleCatalogItem] {
|
||||
let searchTokens = Self.searchTokens(for: query)
|
||||
let selectedFamilyCategory = familyCategory?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let selectedRegionOrigin = regionOrigin?.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
|
||||
let matchesRegion = selectedRegionOrigin?.isEmpty != false || item.regionOrigin == selectedRegionOrigin
|
||||
return matchesSearch && matchesFamily && matchesRegion
|
||||
}
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
public static let worldwideItems: [GenreStyleCatalogItem] =
|
||||
popAndContemporary + rockAndAlternative + electronicAndDance + urbanAndSoul + jazzAndBlues +
|
||||
countryFolkAndRoots + latinAndCaribbean + middleEasternAndNorthAfrican + southAsian + eastAsian +
|
||||
southeastAsianAndOceania + african + reggaeAndCaribbean + classicalAndCinematic
|
||||
|
||||
private static let popAndContemporary: [GenreStyleCatalogItem] = [
|
||||
item("pop", "Pop", "Pop & Contemporary", "Global"),
|
||||
item("adult-contemporary", "Adult Contemporary", "Pop & Contemporary", "Western"),
|
||||
item("art-pop", "Art Pop", "Pop & Contemporary", "Western"),
|
||||
item("baroque-pop", "Baroque Pop", "Pop & Contemporary", "Western"),
|
||||
item("chamber-pop", "Chamber Pop", "Pop & Contemporary", "Western"),
|
||||
item("dance-pop", "Dance Pop", "Pop & Contemporary", "Western"),
|
||||
item("dream-pop", "Dream Pop", "Pop & Contemporary", "Western"),
|
||||
item("electropop", "Electropop", "Pop & Contemporary", "Western"),
|
||||
item("hyperpop", "Hyperpop", "Pop & Contemporary", "Western"),
|
||||
item("indie-pop", "Indie Pop", "Pop & Contemporary", "Western"),
|
||||
item("power-pop", "Power Pop", "Pop & Contemporary", "Western"),
|
||||
item("synth-pop", "Synth Pop", "Pop & Contemporary", "Western"),
|
||||
item("teen-pop", "Teen Pop", "Pop & Contemporary", "Western"),
|
||||
item("soft-pop", "Soft Pop", "Pop & Contemporary", "Western"),
|
||||
item("singer-songwriter", "Singer-Songwriter", "Pop & Contemporary", "Western")
|
||||
]
|
||||
|
||||
private static let rockAndAlternative: [GenreStyleCatalogItem] = [
|
||||
item("rock", "Rock", "Rock & Alternative", "Western"),
|
||||
item("alternative-rock", "Alternative Rock", "Rock & Alternative", "Western", aliases: ["alt rock"]),
|
||||
item("classic-rock", "Classic Rock", "Rock & Alternative", "Western"),
|
||||
item("hard-rock", "Hard Rock", "Rock & Alternative", "Western"),
|
||||
item("indie-rock", "Indie Rock", "Rock & Alternative", "Western"),
|
||||
item("progressive-rock", "Progressive Rock", "Rock & Alternative", "Western", aliases: ["prog rock"]),
|
||||
item("psychedelic-rock", "Psychedelic Rock", "Rock & Alternative", "Western", aliases: ["psych rock"]),
|
||||
item("garage-rock", "Garage Rock", "Rock & Alternative", "Western"),
|
||||
item("surf-rock", "Surf Rock", "Rock & Alternative", "Western"),
|
||||
item("post-rock", "Post-Rock", "Rock & Alternative", "Western"),
|
||||
item("math-rock", "Math Rock", "Rock & Alternative", "Western"),
|
||||
item("grunge", "Grunge", "Rock & Alternative", "Western"),
|
||||
item("shoegaze", "Shoegaze", "Rock & Alternative", "Western"),
|
||||
item("emo", "Emo", "Rock & Alternative", "Western"),
|
||||
item("punk", "Punk", "Rock & Alternative", "Western"),
|
||||
item("pop-punk", "Pop Punk", "Rock & Alternative", "Western"),
|
||||
item("post-punk", "Post-Punk", "Rock & Alternative", "Western"),
|
||||
item("metal", "Metal", "Rock & Alternative", "Western"),
|
||||
item("heavy-metal", "Heavy Metal", "Rock & Alternative", "Western"),
|
||||
item("power-metal", "Power Metal", "Rock & Alternative", "Western"),
|
||||
item("progressive-metal", "Progressive Metal", "Rock & Alternative", "Western"),
|
||||
item("thrash-metal", "Thrash Metal", "Rock & Alternative", "Western"),
|
||||
item("death-metal", "Death Metal", "Rock & Alternative", "Western"),
|
||||
item("black-metal", "Black Metal", "Rock & Alternative", "Western"),
|
||||
item("doom-metal", "Doom Metal", "Rock & Alternative", "Western"),
|
||||
item("folk-rock", "Folk Rock", "Rock & Alternative", "Western"),
|
||||
item("blues-rock", "Blues Rock", "Rock & Alternative", "Western")
|
||||
]
|
||||
|
||||
private static let electronicAndDance: [GenreStyleCatalogItem] = [
|
||||
item("electronic", "Electronic", "Electronic & Dance", "Global"),
|
||||
item("edm", "EDM", "Electronic & Dance", "Global", aliases: ["electronic dance music"]),
|
||||
item("ambient", "Ambient", "Electronic & Dance", "Western"),
|
||||
item("downtempo", "Downtempo", "Electronic & Dance", "Western"),
|
||||
item("chillout", "Chillout", "Electronic & Dance", "Western"),
|
||||
item("lofi", "Lo-Fi", "Electronic & Dance", "Western", aliases: ["lofi"]),
|
||||
item("synthwave", "Synthwave", "Electronic & Dance", "Western"),
|
||||
item("vaporwave", "Vaporwave", "Electronic & Dance", "Western"),
|
||||
item("electro", "Electro", "Electronic & Dance", "Western"),
|
||||
item("industrial", "Industrial", "Electronic & Dance", "Western"),
|
||||
item("house", "House", "Electronic & Dance", "Western"),
|
||||
item("deep-house", "Deep House", "Electronic & Dance", "Western"),
|
||||
item("tech-house", "Tech House", "Electronic & Dance", "Western"),
|
||||
item("progressive-house", "Progressive House", "Electronic & Dance", "Western"),
|
||||
item("future-house", "Future House", "Electronic & Dance", "Western"),
|
||||
item("techno", "Techno", "Electronic & Dance", "Western"),
|
||||
item("minimal-techno", "Minimal Techno", "Electronic & Dance", "Western"),
|
||||
item("trance", "Trance", "Electronic & Dance", "Western"),
|
||||
item("progressive-trance", "Progressive Trance", "Electronic & Dance", "Western"),
|
||||
item("psytrance", "Psytrance", "Electronic & Dance", "Western"),
|
||||
item("drum-and-bass", "Drum and Bass", "Electronic & Dance", "UK", aliases: ["dnb"]),
|
||||
item("jungle", "Jungle", "Electronic & Dance", "UK"),
|
||||
item("dubstep", "Dubstep", "Electronic & Dance", "UK"),
|
||||
item("uk-garage", "UK Garage", "Electronic & Dance", "UK"),
|
||||
item("breakbeat", "Breakbeat", "Electronic & Dance", "Western"),
|
||||
item("hardstyle", "Hardstyle", "Electronic & Dance", "Europe"),
|
||||
item("moombahton", "Moombahton", "Electronic & Dance", "Latin America")
|
||||
]
|
||||
|
||||
private static let urbanAndSoul: [GenreStyleCatalogItem] = [
|
||||
item("hip-hop", "Hip Hop", "Hip Hop, R&B & Soul", "Global", aliases: ["hiphop"]),
|
||||
item("rap", "Rap", "Hip Hop, R&B & Soul", "Global"),
|
||||
item("boom-bap", "Boom Bap", "Hip Hop, R&B & Soul", "US"),
|
||||
item("trap", "Trap", "Hip Hop, R&B & Soul", "US"),
|
||||
item("drill", "Drill", "Hip Hop, R&B & Soul", "US/UK"),
|
||||
item("conscious-hip-hop", "Conscious Hip Hop", "Hip Hop, R&B & Soul", "US"),
|
||||
item("cloud-rap", "Cloud Rap", "Hip Hop, R&B & Soul", "US"),
|
||||
item("lofi-hip-hop", "Lo-Fi Hip Hop", "Hip Hop, R&B & Soul", "Global"),
|
||||
item("r-and-b", "R&B", "Hip Hop, R&B & Soul", "US", aliases: ["rhythm and blues"]),
|
||||
item("contemporary-r-and-b", "Contemporary R&B", "Hip Hop, R&B & Soul", "US"),
|
||||
item("soul", "Soul", "Hip Hop, R&B & Soul", "US"),
|
||||
item("neo-soul", "Neo Soul", "Hip Hop, R&B & Soul", "US"),
|
||||
item("funk", "Funk", "Hip Hop, R&B & Soul", "US"),
|
||||
item("disco", "Disco", "Hip Hop, R&B & Soul", "US"),
|
||||
item("nu-disco", "Nu Disco", "Hip Hop, R&B & Soul", "Western")
|
||||
]
|
||||
|
||||
private static let jazzAndBlues: [GenreStyleCatalogItem] = [
|
||||
item("jazz", "Jazz", "Jazz & Blues", "US"),
|
||||
item("swing", "Swing", "Jazz & Blues", "US"),
|
||||
item("bebop", "Bebop", "Jazz & Blues", "US"),
|
||||
item("cool-jazz", "Cool Jazz", "Jazz & Blues", "US"),
|
||||
item("hard-bop", "Hard Bop", "Jazz & Blues", "US"),
|
||||
item("modal-jazz", "Modal Jazz", "Jazz & Blues", "US"),
|
||||
item("jazz-fusion", "Jazz Fusion", "Jazz & Blues", "US"),
|
||||
item("smooth-jazz", "Smooth Jazz", "Jazz & Blues", "US"),
|
||||
item("vocal-jazz", "Vocal Jazz", "Jazz & Blues", "US"),
|
||||
item("big-band", "Big Band", "Jazz & Blues", "US"),
|
||||
item("blues", "Blues", "Jazz & Blues", "US"),
|
||||
item("delta-blues", "Delta Blues", "Jazz & Blues", "US"),
|
||||
item("chicago-blues", "Chicago Blues", "Jazz & Blues", "US"),
|
||||
item("gospel", "Gospel", "Jazz & Blues", "US")
|
||||
]
|
||||
|
||||
private static let countryFolkAndRoots: [GenreStyleCatalogItem] = [
|
||||
item("country", "Country", "Country, Folk & Roots", "US"),
|
||||
item("modern-country", "Modern Country", "Country, Folk & Roots", "US"),
|
||||
item("americana", "Americana", "Country, Folk & Roots", "US"),
|
||||
item("bluegrass", "Bluegrass", "Country, Folk & Roots", "US"),
|
||||
item("folk", "Folk", "Country, Folk & Roots", "Global"),
|
||||
item("indie-folk", "Indie Folk", "Country, Folk & Roots", "Western"),
|
||||
item("celtic", "Celtic", "Country, Folk & Roots", "Ireland/Scotland"),
|
||||
item("roots", "Roots", "Country, Folk & Roots", "Global"),
|
||||
item("traditional", "Traditional", "Country, Folk & Roots", "Global"),
|
||||
item("world", "World", "Country, Folk & Roots", "Global", aliases: ["world music"]),
|
||||
item("skiffle", "Skiffle", "Country, Folk & Roots", "UK")
|
||||
]
|
||||
|
||||
private static let latinAndCaribbean: [GenreStyleCatalogItem] = [
|
||||
item("latin-pop", "Latin Pop", "Latin & Caribbean", "Latin America"),
|
||||
item("reggaeton", "Reggaeton", "Latin & Caribbean", "Caribbean"),
|
||||
item("salsa", "Salsa", "Latin & Caribbean", "Caribbean"),
|
||||
item("bachata", "Bachata", "Latin & Caribbean", "Caribbean"),
|
||||
item("merengue", "Merengue", "Latin & Caribbean", "Caribbean"),
|
||||
item("cumbia", "Cumbia", "Latin & Caribbean", "Latin America"),
|
||||
item("bossa-nova", "Bossa Nova", "Latin & Caribbean", "Brazil"),
|
||||
item("samba", "Samba", "Latin & Caribbean", "Brazil"),
|
||||
item("tango", "Tango", "Latin & Caribbean", "Argentina/Uruguay"),
|
||||
item("bolero", "Bolero", "Latin & Caribbean", "Latin America"),
|
||||
item("mariachi", "Mariachi", "Latin & Caribbean", "Mexico"),
|
||||
item("norteno", "Norteno", "Latin & Caribbean", "Mexico"),
|
||||
item("banda", "Banda", "Latin & Caribbean", "Mexico"),
|
||||
item("corrido", "Corrido", "Latin & Caribbean", "Mexico"),
|
||||
item("baile-funk", "Baile Funk", "Latin & Caribbean", "Brazil"),
|
||||
item("dembow", "Dembow", "Latin & Caribbean", "Caribbean")
|
||||
]
|
||||
|
||||
private static let middleEasternAndNorthAfrican: [GenreStyleCatalogItem] = [
|
||||
item("arabic-pop", "Arabic Pop", "Middle East & North Africa", "Arab World", aliases: ["arab pop"], terms: ["بوب عربي"]),
|
||||
item("arabic-traditional", "Arabic Traditional", "Middle East & North Africa", "Arab World", terms: ["موسيقى عربية", "طرب"]),
|
||||
item("khaleeji", "Khaleeji", "Middle East & North Africa", "Gulf", aliases: ["gulf pop"], terms: ["خليجي"]),
|
||||
item("levantine-pop", "Levantine Pop", "Middle East & North Africa", "Levant", terms: ["شامي"]),
|
||||
item("egyptian-pop", "Egyptian Pop", "Middle East & North Africa", "Egypt", terms: ["مصري"]),
|
||||
item("iraqi-pop", "Iraqi Pop", "Middle East & North Africa", "Iraq", terms: ["عراقي"]),
|
||||
item("maghrebi-pop", "Maghrebi Pop", "Middle East & North Africa", "North Africa", terms: ["مغاربي"]),
|
||||
item("rai", "Rai", "Middle East & North Africa", "Algeria/Morocco"),
|
||||
item("chaabi", "Chaabi", "Middle East & North Africa", "North Africa"),
|
||||
item("gnawa", "Gnawa", "Middle East & North Africa", "Morocco"),
|
||||
item("andalusian", "Andalusian", "Middle East & North Africa", "North Africa"),
|
||||
item("turkish-pop", "Turkish Pop", "Middle East & North Africa", "Turkey"),
|
||||
item("turkish-folk", "Turkish Folk", "Middle East & North Africa", "Turkey"),
|
||||
item("persian-pop", "Persian Pop", "Middle East & North Africa", "Iran"),
|
||||
item("persian-traditional", "Persian Traditional", "Middle East & North Africa", "Iran"),
|
||||
item("kurdish", "Kurdish", "Middle East & North Africa", "Kurdistan"),
|
||||
item("armenian", "Armenian", "Middle East & North Africa", "Armenia")
|
||||
]
|
||||
|
||||
private static let southAsian: [GenreStyleCatalogItem] = [
|
||||
item("bollywood", "Bollywood", "South Asian", "India", aliases: ["filmi"]),
|
||||
item("hindustani-classical", "Hindustani Classical", "South Asian", "India"),
|
||||
item("carnatic", "Carnatic", "South Asian", "South India"),
|
||||
item("bhangra", "Bhangra", "South Asian", "Punjab"),
|
||||
item("punjabi-pop", "Punjabi Pop", "South Asian", "Punjab"),
|
||||
item("ghazal", "Ghazal", "South Asian", "South Asia"),
|
||||
item("qawwali", "Qawwali", "South Asian", "South Asia"),
|
||||
item("bhajan", "Bhajan", "South Asian", "India"),
|
||||
item("sufi", "Sufi", "South Asian", "South Asia"),
|
||||
item("pakistani-pop", "Pakistani Pop", "South Asian", "Pakistan"),
|
||||
item("bangladeshi-folk", "Bangladeshi Folk", "South Asian", "Bangladesh")
|
||||
]
|
||||
|
||||
private static let eastAsian: [GenreStyleCatalogItem] = [
|
||||
item("k-pop", "K-Pop", "East Asian", "Korea", aliases: ["kpop"]),
|
||||
item("korean-traditional", "Korean Traditional", "East Asian", "Korea"),
|
||||
item("j-pop", "J-Pop", "East Asian", "Japan", aliases: ["jpop"]),
|
||||
item("japanese-city-pop", "Japanese City Pop", "East Asian", "Japan", aliases: ["city pop"]),
|
||||
item("enka", "Enka", "East Asian", "Japan"),
|
||||
item("anime-music", "Anime Music", "East Asian", "Japan"),
|
||||
item("c-pop", "C-Pop", "East Asian", "China", aliases: ["cpop"]),
|
||||
item("mandopop", "Mandopop", "East Asian", "China/Taiwan"),
|
||||
item("cantopop", "Cantopop", "East Asian", "Hong Kong"),
|
||||
item("chinese-traditional", "Chinese Traditional", "East Asian", "China"),
|
||||
item("taiwanese-pop", "Taiwanese Pop", "East Asian", "Taiwan")
|
||||
]
|
||||
|
||||
private static let southeastAsianAndOceania: [GenreStyleCatalogItem] = [
|
||||
item("indonesian-pop", "Indonesian Pop", "Southeast Asia & Oceania", "Indonesia"),
|
||||
item("dangdut", "Dangdut", "Southeast Asia & Oceania", "Indonesia"),
|
||||
item("gamelan", "Gamelan", "Southeast Asia & Oceania", "Indonesia"),
|
||||
item("malay-pop", "Malay Pop", "Southeast Asia & Oceania", "Malaysia"),
|
||||
item("thai-pop", "Thai Pop", "Southeast Asia & Oceania", "Thailand"),
|
||||
item("luk-thung", "Luk Thung", "Southeast Asia & Oceania", "Thailand"),
|
||||
item("vietnamese-pop", "Vietnamese Pop", "Southeast Asia & Oceania", "Vietnam"),
|
||||
item("filipino-pop", "Filipino Pop", "Southeast Asia & Oceania", "Philippines"),
|
||||
item("pinoy-rock", "Pinoy Rock", "Southeast Asia & Oceania", "Philippines"),
|
||||
item("maori", "Maori", "Southeast Asia & Oceania", "New Zealand"),
|
||||
item("pacific", "Pacific", "Southeast Asia & Oceania", "Oceania"),
|
||||
item("australian-indigenous", "Australian Indigenous", "Southeast Asia & Oceania", "Australia")
|
||||
]
|
||||
|
||||
private static let african: [GenreStyleCatalogItem] = [
|
||||
item("afrobeats", "Afrobeats", "African", "West Africa"),
|
||||
item("afropop", "Afropop", "African", "Africa"),
|
||||
item("afro-house", "Afro House", "African", "Africa"),
|
||||
item("amapiano", "Amapiano", "African", "South Africa"),
|
||||
item("highlife", "Highlife", "African", "West Africa"),
|
||||
item("juju", "Juju", "African", "Nigeria"),
|
||||
item("fuji", "Fuji", "African", "Nigeria"),
|
||||
item("benga", "Benga", "African", "East Africa"),
|
||||
item("soukous", "Soukous", "African", "Central Africa"),
|
||||
item("kwaito", "Kwaito", "African", "South Africa"),
|
||||
item("mbalax", "Mbalax", "African", "Senegal"),
|
||||
item("taarab", "Taarab", "African", "East Africa"),
|
||||
item("ethiopian", "Ethiopian", "African", "Ethiopia"),
|
||||
item("sudanese", "Sudanese", "African", "Sudan"),
|
||||
item("desert-blues", "Desert Blues", "African", "Sahara")
|
||||
]
|
||||
|
||||
private static let reggaeAndCaribbean: [GenreStyleCatalogItem] = [
|
||||
item("reggae", "Reggae", "Reggae & Caribbean", "Jamaica"),
|
||||
item("roots-reggae", "Roots Reggae", "Reggae & Caribbean", "Jamaica"),
|
||||
item("dub", "Dub", "Reggae & Caribbean", "Jamaica"),
|
||||
item("dancehall", "Dancehall", "Reggae & Caribbean", "Jamaica"),
|
||||
item("ska", "Ska", "Reggae & Caribbean", "Jamaica"),
|
||||
item("rocksteady", "Rocksteady", "Reggae & Caribbean", "Jamaica"),
|
||||
item("lovers-rock", "Lovers Rock", "Reggae & Caribbean", "Jamaica"),
|
||||
item("calypso", "Calypso", "Reggae & Caribbean", "Caribbean"),
|
||||
item("soca", "Soca", "Reggae & Caribbean", "Caribbean")
|
||||
]
|
||||
|
||||
private static let classicalAndCinematic: [GenreStyleCatalogItem] = [
|
||||
item("classical", "Classical", "Classical & Cinematic", "Western"),
|
||||
item("baroque", "Baroque", "Classical & Cinematic", "Europe"),
|
||||
item("classical-period", "Classical Period", "Classical & Cinematic", "Europe"),
|
||||
item("romantic", "Romantic", "Classical & Cinematic", "Europe"),
|
||||
item("contemporary-classical", "Contemporary Classical", "Classical & Cinematic", "Global"),
|
||||
item("orchestral", "Orchestral", "Classical & Cinematic", "Western"),
|
||||
item("chamber-music", "Chamber Music", "Classical & Cinematic", "Western"),
|
||||
item("opera", "Opera", "Classical & Cinematic", "Europe"),
|
||||
item("choral", "Choral", "Classical & Cinematic", "Global"),
|
||||
item("cinematic", "Cinematic", "Classical & Cinematic", "Global"),
|
||||
item("soundtrack", "Soundtrack", "Classical & Cinematic", "Global"),
|
||||
item("musical-theater", "Musical Theater", "Classical & Cinematic", "Western"),
|
||||
item("new-age", "New Age", "Classical & Cinematic", "Western"),
|
||||
item("minimalism", "Minimalism", "Classical & Cinematic", "Western")
|
||||
]
|
||||
|
||||
private static func item(
|
||||
_ id: String,
|
||||
_ name: String,
|
||||
_ familyCategory: String,
|
||||
_ regionOrigin: String? = nil,
|
||||
aliases: [String] = [],
|
||||
terms: [String] = []
|
||||
) -> GenreStyleCatalogItem {
|
||||
GenreStyleCatalogItem(
|
||||
id: id,
|
||||
name: name,
|
||||
familyCategory: familyCategory,
|
||||
regionOrigin: regionOrigin,
|
||||
aliases: aliases,
|
||||
searchTerms: terms
|
||||
)
|
||||
}
|
||||
|
||||
private static func searchableTerms(for item: GenreStyleCatalogItem) -> [String] {
|
||||
searchTokens(for: [item.name, item.familyCategory, item.regionOrigin ?? ""] + 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import MusicAssistantCore
|
||||
import XCTest
|
||||
|
||||
final class LocalGenreStyleCatalogTests: XCTestCase {
|
||||
func testWorldwideCatalogCoversMajorGenreAndStyleFamilies() {
|
||||
let catalog = LocalGenreStyleCatalog()
|
||||
let expectedFamilies: Set<String> = [
|
||||
"Pop & Contemporary",
|
||||
"Rock & Alternative",
|
||||
"Electronic & Dance",
|
||||
"Hip Hop, R&B & Soul",
|
||||
"Jazz & Blues",
|
||||
"Country, Folk & Roots",
|
||||
"Latin & Caribbean",
|
||||
"Middle East & North Africa",
|
||||
"South Asian",
|
||||
"East Asian",
|
||||
"Southeast Asia & Oceania",
|
||||
"African",
|
||||
"Reggae & Caribbean",
|
||||
"Classical & Cinematic"
|
||||
]
|
||||
|
||||
XCTAssertGreaterThan(catalog.items.count, 180)
|
||||
XCTAssertTrue(expectedFamilies.isSubset(of: Set(catalog.familyCategories)))
|
||||
XCTAssertTrue(catalog.regionsOfOrigin.contains("Arab World"))
|
||||
XCTAssertTrue(catalog.regionsOfOrigin.contains("West Africa"))
|
||||
XCTAssertTrue(catalog.regionsOfOrigin.contains("South Asia"))
|
||||
XCTAssertTrue(catalog.regionsOfOrigin.contains("Latin America"))
|
||||
}
|
||||
|
||||
func testCatalogSearchesAliasesArabicTermsAndCategoryRegionFilters() {
|
||||
let catalog = LocalGenreStyleCatalog()
|
||||
|
||||
XCTAssertEqual(catalog.styles(matching: "dnb").map(\.id), ["drum-and-bass"])
|
||||
XCTAssertEqual(catalog.styles(matching: "بوب عربي").map(\.id), ["arabic-pop"])
|
||||
XCTAssertEqual(
|
||||
catalog.styles(
|
||||
matching: "",
|
||||
familyCategory: "Latin & Caribbean",
|
||||
regionOrigin: "Brazil"
|
||||
).map(\.id),
|
||||
["baile-funk", "bossa-nova", "samba"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ macOS App
|
||||
├── Application Services
|
||||
│ ├── AI Director
|
||||
│ ├── Prompt Compiler
|
||||
│ ├── Genre & Style Catalog
|
||||
│ ├── Instrument Catalog
|
||||
│ ├── Arabic Pronunciation Processor
|
||||
│ ├── Project Versioning
|
||||
@@ -109,3 +110,11 @@ the Suno Style Prompt.
|
||||
The current architecture does not include instrument images, audio
|
||||
previews, sound samples, playback, Freesound, an Instrument API or
|
||||
external API calls for instrument data.
|
||||
|
||||
## Genre & Style Selector Boundary
|
||||
|
||||
Genre and style catalog data is structured local data, separate from
|
||||
SwiftUI views. The Presentation layer may search, filter and toggle
|
||||
selections, while the catalog service owns stable ids, categories,
|
||||
optional regional metadata and search aliases. Selections are stored as
|
||||
existing `GenreStyle` values on the SongProject.
|
||||
|
||||
@@ -41,6 +41,14 @@ InstrumentCatalogItem
|
||||
- aliases[]
|
||||
- searchTerms[]
|
||||
|
||||
GenreStyleCatalogItem
|
||||
- id
|
||||
- name
|
||||
- familyCategory
|
||||
- regionOrigin (optional)
|
||||
- aliases[]
|
||||
- searchTerms[]
|
||||
|
||||
SongSection
|
||||
- id
|
||||
- type (intro, verse, preChorus, chorus, bridge, outro, custom)
|
||||
|
||||
@@ -7,8 +7,8 @@ finalized.
|
||||
- Exact Swift/SwiftUI architecture conventions for the repository.
|
||||
- OpenAI model(s) and API endpoint strategy.
|
||||
- Exact private AI rules/system prompt.
|
||||
- Complete genre/style taxonomy and whether it is curated locally or
|
||||
AI-assisted.
|
||||
- Resolved: genres/styles use a large curated local catalog with
|
||||
category/region browsing, search aliases and checkbox multi-select.
|
||||
- Complete worldwide instrument catalog taxonomy, family/category
|
||||
groups, region/origin coverage, aliases/search terms and maintenance
|
||||
strategy.
|
||||
|
||||
@@ -21,6 +21,8 @@ and writing engine, then prepares final lyrics and style instructions.
|
||||
## Song Controls
|
||||
|
||||
- Multiple genres/styles can be blended.
|
||||
- Genres and styles can be selected from a large worldwide catalog with
|
||||
search, category/region browsing and checkbox-based multi-select.
|
||||
- Instruments can be selected from a large worldwide instrument
|
||||
catalog.
|
||||
- Instruments can be searched by name, browsed by family/category and
|
||||
@@ -75,6 +77,14 @@ Instrument images, audio previews, sound samples, audio playback,
|
||||
Freesound, an Instrument API and external API calls for instrument data
|
||||
are not part of the current product requirement.
|
||||
|
||||
## Genre & Style Selector
|
||||
|
||||
The application provides a large, curated local catalog of worldwide
|
||||
music genres and styles. Users can search it, browse by category and
|
||||
region/origin where useful, and select or deselect multiple entries with
|
||||
checkboxes. Catalog data is structured and separate from SwiftUI views;
|
||||
it does not require an external API.
|
||||
|
||||
## Project Management
|
||||
|
||||
Every song is stored as a project. Important changes can create versions
|
||||
|
||||
@@ -131,3 +131,4 @@ lyrics and style prompt in the project.
|
||||
## Current Improvements
|
||||
|
||||
- [x] Add confirmed project deletion from the project browser.
|
||||
- [x] Add a worldwide genre and style selector with checkbox multi-select.
|
||||
|
||||
Reference in New Issue
Block a user