diff --git a/Sources/MusicAssistantApp/Presentation/GenreStyleBrowserView.swift b/Sources/MusicAssistantApp/Presentation/GenreStyleBrowserView.swift new file mode 100644 index 0000000..d4eccdf --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/GenreStyleBrowserView.swift @@ -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, + 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 { + 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) + } +} diff --git a/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift b/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift index 8aa5f17..983144d 100644 --- a/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift +++ b/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift @@ -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 { @@ -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 } diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift index 6c3e579..4a781fb 100644 --- a/Sources/MusicAssistantCore/Domain/SongProject.swift +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -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 diff --git a/Sources/MusicAssistantCore/Services/GenreCatalog/LocalGenreStyleCatalog.swift b/Sources/MusicAssistantCore/Services/GenreCatalog/LocalGenreStyleCatalog.swift new file mode 100644 index 0000000..74b2452 --- /dev/null +++ b/Sources/MusicAssistantCore/Services/GenreCatalog/LocalGenreStyleCatalog.swift @@ -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) + } +} diff --git a/Tests/MusicAssistantCoreTests/LocalGenreStyleCatalogTests.swift b/Tests/MusicAssistantCoreTests/LocalGenreStyleCatalogTests.swift new file mode 100644 index 0000000..41aed56 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/LocalGenreStyleCatalogTests.swift @@ -0,0 +1,46 @@ +import MusicAssistantCore +import XCTest + +final class LocalGenreStyleCatalogTests: XCTestCase { + func testWorldwideCatalogCoversMajorGenreAndStyleFamilies() { + let catalog = LocalGenreStyleCatalog() + let expectedFamilies: Set = [ + "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"] + ) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3cf1507..9a73f25 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 940ecf3..cbaee37 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -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) diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md index ee23e28..9329038 100644 --- a/docs/OPEN_QUESTIONS.md +++ b/docs/OPEN_QUESTIONS.md @@ -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. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index f6e38fa..29d1546 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -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 diff --git a/docs/TASKS.md b/docs/TASKS.md index 41af2f0..6ebe691 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -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.