Add vocalist preset selector
This commit is contained in:
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user