diff --git a/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift b/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift index 983144d..7a6d317 100644 --- a/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift +++ b/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift @@ -6,10 +6,10 @@ struct ProjectInspectorView: View { let saveProject: (SongProject) async -> Bool @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 isVocalistBrowserPresented = false @State private var isSaving = false @State private var saveStatus: SaveStatus? @@ -85,17 +85,18 @@ struct ProjectInspectorView: View { } Section("Vocalists") { + Button { + isVocalistBrowserPresented = true + } label: { + Label("Browse vocalists", systemImage: "person.2") + } + EditableTokenList( values: project.vocalists.map(\.label), removeAction: { index in project.removeVocalist(id: project.vocalists[index].id) } ) - AddTextRow( - placeholder: "Add vocalist", - text: $newVocalistLabel, - addAction: addVocalist - ) } Section("Languages") { @@ -153,6 +154,9 @@ struct ProjectInspectorView: View { .sheet(isPresented: $isGenreStyleBrowserPresented) { GenreStyleBrowserView(project: $project, saveProject: saveProject) } + .sheet(isPresented: $isVocalistBrowserPresented) { + VocalistBrowserView(project: $project, saveProject: saveProject) + } } private var durationSecondsBinding: Binding { @@ -183,13 +187,6 @@ struct ProjectInspectorView: View { newInstrumentName = "" } - private func addVocalist() { - let label = trimmed(newVocalistLabel) - guard !label.isEmpty else { return } - project.upsertVocalist(Vocalist(id: stableID(from: label), label: label)) - newVocalistLabel = "" - } - private func addLanguage() { let name = trimmed(newLanguageName) guard !name.isEmpty else { return } diff --git a/Sources/MusicAssistantApp/Presentation/VocalistBrowserView.swift b/Sources/MusicAssistantApp/Presentation/VocalistBrowserView.swift new file mode 100644 index 0000000..d0f5da6 --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/VocalistBrowserView.swift @@ -0,0 +1,135 @@ +import MusicAssistantCore +import SwiftUI + +struct VocalistBrowserView: View { + @Binding private var project: SongProject + private let saveProject: (SongProject) async -> Bool + private let catalog: LocalVocalistPresetCatalog + + @State private var searchText = "" + @State private var selectedFamilyCategory: 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: LocalVocalistPresetCatalog = LocalVocalistPresetCatalog() + ) { + _project = project + self.saveProject = saveProject + self.catalog = catalog + } + + var body: some View { + NavigationStack { + List( + catalog.vocalists( + matching: searchText, + familyCategory: selectedFamilyCategory + ), + id: \.id + ) { preset in + Toggle(isOn: selectionBinding(for: preset)) { + VStack(alignment: .leading, spacing: 4) { + Text(preset.label) + .font(.body.weight(.medium)) + + Text(detailText(for: preset)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .toggleStyle(.checkbox) + .padding(.vertical, 2) + } + .navigationTitle("Vocalist Catalog") + .searchable(text: $searchText, prompt: "Search vocalists") + .toolbar { + ToolbarItem(placement: .automatic) { + Picker("Category", selection: $selectedFamilyCategory) { + Text("All categories").tag(String?.none) + + ForEach(catalog.familyCategories, id: \.self) { familyCategory in + Text(familyCategory).tag(Optional(familyCategory)) + } + } + .pickerStyle(.menu) + } + + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + Task { + await saveSelectionChangesIfNeeded() + dismiss() + } + } + .disabled(isSaving) + } + + if isSaving { + ToolbarItem(placement: .status) { + ProgressView() + .controlSize(.small) + } + } + } + } + .frame(minWidth: 480, minHeight: 520) + .onDisappear { + guard hasUnsavedSelectionChanges, !isSaving else { return } + + let projectToSave = project + hasUnsavedSelectionChanges = false + + Task { + _ = await saveProject(projectToSave) + } + } + } + + private func selectionBinding(for preset: VocalistPreset) -> Binding { + Binding { + project.vocalist(for: preset.id) != nil + } set: { isSelected in + if isSelected { + project.upsertVocalist(preset.vocalist) + } else { + project.removeVocalist(id: preset.id) + } + hasUnsavedSelectionChanges = true + } + } + + private func detailText(for preset: VocalistPreset) -> String { + [preset.familyCategory, preset.voiceType, preset.performanceStyle] + .compactMap { $0 } + .joined(separator: " · ") + } + + private func saveSelectionChangesIfNeeded() async { + guard hasUnsavedSelectionChanges else { return } + + isSaving = true + let didSave = await saveProject(project) + hasUnsavedSelectionChanges = !didSave + isSaving = false + } +} + +#Preview { + VocalistBrowserPreview() +} + +private struct VocalistBrowserPreview: View { + @State private var project = SongProject( + title: "Vocalist Catalog Preview", + idea: "Choose vocalist presets." + ) + + var body: some View { + VocalistBrowserView(project: $project) + } +} diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift index 4a781fb..eab39dd 100644 --- a/Sources/MusicAssistantCore/Domain/SongProject.swift +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -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 diff --git a/Sources/MusicAssistantCore/Services/VocalistCatalog/LocalVocalistPresetCatalog.swift b/Sources/MusicAssistantCore/Services/VocalistCatalog/LocalVocalistPresetCatalog.swift new file mode 100644 index 0000000..bfd36e8 --- /dev/null +++ b/Sources/MusicAssistantCore/Services/VocalistCatalog/LocalVocalistPresetCatalog.swift @@ -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) + } +} diff --git a/Tests/MusicAssistantCoreTests/LocalVocalistPresetCatalogTests.swift b/Tests/MusicAssistantCoreTests/LocalVocalistPresetCatalogTests.swift new file mode 100644 index 0000000..c86bcf7 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/LocalVocalistPresetCatalogTests.swift @@ -0,0 +1,45 @@ +import MusicAssistantCore +import XCTest + +final class LocalVocalistPresetCatalogTests: XCTestCase { + func testCatalogSearchesVocalistRolesAndArabicTerms() { + let catalog = LocalVocalistPresetCatalog() + + XCTAssertEqual( + catalog.vocalists(matching: "soprano").map(\.id), + ["mezzo-soprano", "soprano"] + ) + XCTAssertEqual(catalog.vocalists(matching: "خليجي").map(\.id), ["khaleeji-female-lead", "khaleeji-male-lead"]) + XCTAssertEqual( + catalog.vocalists(matching: "", familyCategory: "Harmony & Groups").map(\.id), + [ + "children-choir", + "duet", + "female-choir", + "female-harmony", + "male-choir", + "male-harmony", + "mixed-choir", + "mixed-harmony", + "vocal-ensemble" + ] + ) + } + + func testPresetAddsAndRemovesCompleteVocalistMetadataOnProject() { + let preset = LocalVocalistPresetCatalog() + .vocalists(matching: "Arabic Female Lead") + .first! + var project = SongProject(title: "Vocalist Project", idea: "Choose a vocalist") + + project.upsertVocalist(preset.vocalist) + + XCTAssertEqual(project.vocalist(for: preset.id), preset.vocalist) + XCTAssertEqual(project.vocalist(for: preset.id)?.voiceType, "ornamented female lead") + XCTAssertEqual(project.vocalist(for: preset.id)?.performanceStyle, "Arabic melodic delivery") + + project.removeVocalist(id: preset.id) + + XCTAssertNil(project.vocalist(for: preset.id)) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9a73f25..b567737 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -26,6 +26,7 @@ macOS App │ ├── Prompt Compiler │ ├── Genre & Style Catalog │ ├── Instrument Catalog +│ ├── Vocalist Catalog │ ├── Arabic Pronunciation Processor │ ├── Project Versioning │ └── Validation @@ -118,3 +119,10 @@ 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. + +## Vocalist Selector Boundary + +Vocalist presets are local structured data, separate from SwiftUI views. +The catalog service supplies stable ids and preset metadata, while the +Presentation layer searches and toggles the corresponding `Vocalist` +values on the SongProject. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index cbaee37..40fd96f 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -49,6 +49,17 @@ GenreStyleCatalogItem - aliases[] - searchTerms[] +VocalistPreset +- id +- label +- familyCategory +- regionOrigin (optional) +- voiceType (optional) +- genderSelection (optional) +- performanceStyle (optional) +- aliases[] +- searchTerms[] + SongSection - id - type (intro, verse, preChorus, chorus, bridge, outro, custom) diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 29d1546..f76931e 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -36,6 +36,8 @@ and writing engine, then prepares final lyrics and style instructions. - Song structure supports Manual and Auto modes. - BPM, key/scale and maqam support Manual and Auto modes. - Multiple vocalists/voices can exist in one song. +- Vocalists can be selected from a local catalog of voice roles and + presets using checkbox multi-select. - User controls vocal gender/type, rap/singing mode, delivery and section-specific performance. - Multiple languages and dialects can be used in one song. @@ -85,6 +87,13 @@ 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. +## Vocalist Selector + +The application provides a local catalog of vocalist roles and voice +presets. Users can search, browse by category and select or deselect +multiple vocalists with checkboxes. Each preset supplies a role label, +voice type, gender selection and performance style for the Song Project. + ## 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 6ebe691..ed81848 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -132,3 +132,4 @@ lyrics and style prompt in the project. - [x] Add confirmed project deletion from the project browser. - [x] Add a worldwide genre and style selector with checkbox multi-select. +- [x] Add a vocalist preset selector with checkbox multi-select.