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
@@ -6,10 +6,10 @@ struct ProjectInspectorView: View {
let saveProject: (SongProject) async -> Bool let saveProject: (SongProject) async -> Bool
@State private var newInstrumentName = "" @State private var newInstrumentName = ""
@State private var newVocalistLabel = ""
@State private var newLanguageName = "" @State private var newLanguageName = ""
@State private var isInstrumentBrowserPresented = false @State private var isInstrumentBrowserPresented = false
@State private var isGenreStyleBrowserPresented = false @State private var isGenreStyleBrowserPresented = false
@State private var isVocalistBrowserPresented = false
@State private var isSaving = false @State private var isSaving = false
@State private var saveStatus: SaveStatus? @State private var saveStatus: SaveStatus?
@@ -85,17 +85,18 @@ struct ProjectInspectorView: View {
} }
Section("Vocalists") { Section("Vocalists") {
Button {
isVocalistBrowserPresented = true
} label: {
Label("Browse vocalists", systemImage: "person.2")
}
EditableTokenList( EditableTokenList(
values: project.vocalists.map(\.label), values: project.vocalists.map(\.label),
removeAction: { index in removeAction: { index in
project.removeVocalist(id: project.vocalists[index].id) project.removeVocalist(id: project.vocalists[index].id)
} }
) )
AddTextRow(
placeholder: "Add vocalist",
text: $newVocalistLabel,
addAction: addVocalist
)
} }
Section("Languages") { Section("Languages") {
@@ -153,6 +154,9 @@ struct ProjectInspectorView: View {
.sheet(isPresented: $isGenreStyleBrowserPresented) { .sheet(isPresented: $isGenreStyleBrowserPresented) {
GenreStyleBrowserView(project: $project, saveProject: saveProject) GenreStyleBrowserView(project: $project, saveProject: saveProject)
} }
.sheet(isPresented: $isVocalistBrowserPresented) {
VocalistBrowserView(project: $project, saveProject: saveProject)
}
} }
private var durationSecondsBinding: Binding<String> { private var durationSecondsBinding: Binding<String> {
@@ -183,13 +187,6 @@ struct ProjectInspectorView: View {
newInstrumentName = "" 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() { private func addLanguage() {
let name = trimmed(newLanguageName) let name = trimmed(newLanguageName)
guard !name.isEmpty else { return } guard !name.isEmpty else { return }
@@ -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<SongProject>,
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<Bool> {
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)
}
}
@@ -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 struct Vocalist: Codable, Equatable, Identifiable, Sendable {
public let id: String public let id: String
public var label: 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)
}
}
@@ -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))
}
}
+8
View File
@@ -26,6 +26,7 @@ macOS App
│ ├── Prompt Compiler │ ├── Prompt Compiler
│ ├── Genre & Style Catalog │ ├── Genre & Style Catalog
│ ├── Instrument Catalog │ ├── Instrument Catalog
│ ├── Vocalist Catalog
│ ├── Arabic Pronunciation Processor │ ├── Arabic Pronunciation Processor
│ ├── Project Versioning │ ├── Project Versioning
│ └── Validation │ └── Validation
@@ -118,3 +119,10 @@ SwiftUI views. The Presentation layer may search, filter and toggle
selections, while the catalog service owns stable ids, categories, selections, while the catalog service owns stable ids, categories,
optional regional metadata and search aliases. Selections are stored as optional regional metadata and search aliases. Selections are stored as
existing `GenreStyle` values on the SongProject. 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.
+11
View File
@@ -49,6 +49,17 @@ GenreStyleCatalogItem
- aliases[] - aliases[]
- searchTerms[] - searchTerms[]
VocalistPreset
- id
- label
- familyCategory
- regionOrigin (optional)
- voiceType (optional)
- genderSelection (optional)
- performanceStyle (optional)
- aliases[]
- searchTerms[]
SongSection SongSection
- id - id
- type (intro, verse, preChorus, chorus, bridge, outro, custom) - type (intro, verse, preChorus, chorus, bridge, outro, custom)
+9
View File
@@ -36,6 +36,8 @@ and writing engine, then prepares final lyrics and style instructions.
- Song structure supports Manual and Auto modes. - Song structure supports Manual and Auto modes.
- BPM, key/scale and maqam support Manual and Auto modes. - BPM, key/scale and maqam support Manual and Auto modes.
- Multiple vocalists/voices can exist in one song. - 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 - User controls vocal gender/type, rap/singing mode, delivery and
section-specific performance. section-specific performance.
- Multiple languages and dialects can be used in one song. - 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; checkboxes. Catalog data is structured and separate from SwiftUI views;
it does not require an external API. 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 ## Project Management
Every song is stored as a project. Important changes can create versions Every song is stored as a project. Important changes can create versions
+1
View File
@@ -132,3 +132,4 @@ lyrics and style prompt in the project.
- [x] Add confirmed project deletion from the project browser. - [x] Add confirmed project deletion from the project browser.
- [x] Add a worldwide genre and style selector with checkbox multi-select. - [x] Add a worldwide genre and style selector with checkbox multi-select.
- [x] Add a vocalist preset selector with checkbox multi-select.