Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9af5c21d8 | ||
|
|
c8242a844a | ||
|
|
b43c5416f2 | ||
|
|
297b791a3e | ||
|
|
03bfd3ef75 | ||
|
|
bef00b19e1 | ||
|
|
0307df9442 | ||
|
|
2d07ee70d8 | ||
|
|
2145732b66 | ||
|
|
9b4db4413b | ||
|
|
fac99e2c2d | ||
|
|
6023214cb3 | ||
|
|
f199bcf114 | ||
|
|
f42d782629 | ||
|
|
1ebb5aadc3 | ||
|
|
cd066d0a82 |
@@ -48,8 +48,9 @@ Use these documents as the project references:
|
||||
Integrations.
|
||||
- Keep OpenAI, instrument catalog/selector, and Suno integrations separated from
|
||||
UI logic as much as possible.
|
||||
- In the current scope, Suno handoff fills the fields only and does not press
|
||||
Generate.
|
||||
- In the current scope, the app prepares and stores final lyrics and style
|
||||
prompts only; it does not open, embed or control an external
|
||||
music-generation website.
|
||||
- Do not modify files outside the task scope without a clear reason.
|
||||
- Do not perform a broad refactor while implementing a small task unless it is
|
||||
explicitly required.
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import MusicAssistantCore
|
||||
|
||||
struct MacOSSunoAccessibilityFieldFiller: SunoFieldFilling {
|
||||
func fill(_ payload: SunoHandoffPayload) async -> SunoFieldFillReport {
|
||||
guard AXIsProcessTrusted() else {
|
||||
return .copyRequired(
|
||||
for: SunoHandoffField.allCases,
|
||||
reason: "Allow Accessibility access in System Settings, then reopen AI Music Studio."
|
||||
)
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
|
||||
guard let application = sunoBrowserApplication() else {
|
||||
return .copyRequired(for: SunoHandoffField.allCases, reason: "The Suno browser window was not found.")
|
||||
}
|
||||
|
||||
let processIdentifier = application.processIdentifier
|
||||
pressCustomMode(in: accessibilityElements(for: processIdentifier))
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
let elements = accessibilityElements(for: processIdentifier)
|
||||
|
||||
return SunoFieldFillReport(results: Dictionary(uniqueKeysWithValues: SunoHandoffField.allCases.map { field in
|
||||
(field, fill(field, with: payload.value(for: field), in: elements))
|
||||
}))
|
||||
}
|
||||
|
||||
private func pressCustomMode(in elements: [AXUIElement]) {
|
||||
guard let customMode = elements.first(where: { element in
|
||||
role(of: element) == kAXButtonRole as String && labels(for: element).contains { $0.localizedCaseInsensitiveContains("custom") }
|
||||
}) else {
|
||||
return
|
||||
}
|
||||
|
||||
AXUIElementPerformAction(customMode, kAXPressAction as CFString)
|
||||
}
|
||||
|
||||
private func fill(_ field: SunoHandoffField, with value: String, in elements: [AXUIElement]) -> SunoFieldFillResult {
|
||||
guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return .filled
|
||||
}
|
||||
|
||||
guard let element = elements.first(where: { matches(field, element: $0) }) else {
|
||||
return .copyRequired("The \(field.displayName) field was not found.")
|
||||
}
|
||||
|
||||
var isSettable = DarwinBoolean(false)
|
||||
guard AXUIElementIsAttributeSettable(element, kAXValueAttribute as CFString, &isSettable) == .success,
|
||||
isSettable.boolValue,
|
||||
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, value as CFTypeRef) == .success else {
|
||||
return .copyRequired("The \(field.displayName) field could not be filled.")
|
||||
}
|
||||
|
||||
return .filled
|
||||
}
|
||||
|
||||
private func matches(_ field: SunoHandoffField, element: AXUIElement) -> Bool {
|
||||
let role = role(of: element)
|
||||
guard role == kAXTextFieldRole as String || role == kAXTextAreaRole as String else { return false }
|
||||
|
||||
let labels = labels(for: element)
|
||||
switch field {
|
||||
case .title:
|
||||
return labels.contains { $0.localizedCaseInsensitiveContains("title") }
|
||||
case .lyrics:
|
||||
return labels.contains { $0.localizedCaseInsensitiveContains("lyrics") }
|
||||
case .stylePrompt:
|
||||
return labels.contains { label in
|
||||
label.localizedCaseInsensitiveContains("style") || label.localizedCaseInsensitiveContains("prompt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func accessibilityElements(for processIdentifier: pid_t) -> [AXUIElement] {
|
||||
descendants(of: AXUIElementCreateApplication(processIdentifier))
|
||||
}
|
||||
|
||||
private func sunoBrowserApplication() -> NSRunningApplication? {
|
||||
guard let applicationURL = NSWorkspace.shared.urlForApplication(toOpen: SunoHandoffDestination.createPageURL),
|
||||
let bundleIdentifier = Bundle(url: applicationURL)?.bundleIdentifier else {
|
||||
return NSWorkspace.shared.frontmostApplication
|
||||
}
|
||||
|
||||
return NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).first
|
||||
?? NSWorkspace.shared.frontmostApplication
|
||||
}
|
||||
|
||||
private func descendants(of root: AXUIElement) -> [AXUIElement] {
|
||||
var elements: [AXUIElement] = []
|
||||
var pending = [root]
|
||||
|
||||
while let element = pending.popLast() {
|
||||
elements.append(element)
|
||||
pending.append(contentsOf: children(of: element))
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
private func children(of element: AXUIElement) -> [AXUIElement] {
|
||||
attribute(kAXChildrenAttribute, from: element) as? [AXUIElement] ?? []
|
||||
}
|
||||
|
||||
private func role(of element: AXUIElement) -> String? {
|
||||
attribute(kAXRoleAttribute, from: element) as? String
|
||||
}
|
||||
|
||||
private func labels(for element: AXUIElement) -> [String] {
|
||||
[kAXTitleAttribute, kAXDescriptionAttribute, kAXHelpAttribute, kAXIdentifierAttribute, kAXPlaceholderValueAttribute]
|
||||
.compactMap { attribute($0, from: element) as? String }
|
||||
}
|
||||
|
||||
private func attribute(_ name: String, from element: AXUIElement) -> Any? {
|
||||
var value: CFTypeRef?
|
||||
guard AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success else { return nil }
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ struct ChatWorkspaceView: View {
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(.background)
|
||||
.background(Color(nsColor: .textBackgroundColor))
|
||||
.onChange(of: messages.count) { _, _ in
|
||||
guard let lastMessageID = messages.last?.id else { return }
|
||||
withAnimation(.snappy) {
|
||||
@@ -49,7 +49,8 @@ struct ChatWorkspaceView: View {
|
||||
isFocused: $isComposerFocused,
|
||||
sendAction: sendMessage
|
||||
)
|
||||
.padding(16)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.frame(minWidth: 520, maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
@@ -89,36 +90,60 @@ private struct ChatWorkspaceHeader: View {
|
||||
let project: SongProject
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "music.note")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(project.title)
|
||||
.font(.title.weight(.semibold))
|
||||
.font(.title2.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
|
||||
Text("Song Project")
|
||||
Text(project.idea.isEmpty ? "Song Project" : project.idea)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer(minLength: 12)
|
||||
|
||||
Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
ProjectMetricLabel(title: "Versions", value: project.versions.count, systemImage: "clock.arrow.circlepath")
|
||||
ProjectMetricLabel(title: "Genres", value: project.genres.count, systemImage: "music.quarternote.3")
|
||||
ProjectMetricLabel(title: "Instruments", value: project.selectedInstrumentTracks.count, systemImage: "pianokeys")
|
||||
ProjectMetricLabel(title: "Sections", value: project.sections.count, systemImage: "text.line.first.and.arrowtriangle.forward")
|
||||
ProjectMetricLabel(title: "Vocalists", value: project.vocalists.count, systemImage: "person.wave.2")
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 12) {
|
||||
projectMetrics
|
||||
}
|
||||
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.adaptive(minimum: 104), spacing: 10)],
|
||||
alignment: .leading,
|
||||
spacing: 8
|
||||
) {
|
||||
projectMetrics
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var projectMetrics: some View {
|
||||
ProjectMetricLabel(title: "Versions", value: project.versions.count, systemImage: "clock.arrow.circlepath")
|
||||
ProjectMetricLabel(title: "Genres", value: project.genres.count, systemImage: "music.quarternote.3")
|
||||
ProjectMetricLabel(title: "Instruments", value: project.selectedInstrumentTracks.count, systemImage: "pianokeys")
|
||||
ProjectMetricLabel(title: "Sections", value: project.sections.count, systemImage: "text.line.first.and.arrowtriangle.forward")
|
||||
ProjectMetricLabel(title: "Vocalists", value: project.vocalists.count, systemImage: "person.wave.2")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +162,7 @@ private struct ProjectMetricLabel: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,11 +170,15 @@ private struct ChatMessageBubble: View {
|
||||
let message: ChatWorkspaceMessage
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if message.role == .user {
|
||||
Spacer(minLength: 64)
|
||||
}
|
||||
|
||||
if message.role == .assistant {
|
||||
ChatRoleMark(role: message.role)
|
||||
}
|
||||
|
||||
Text(message.text)
|
||||
.font(.body)
|
||||
.foregroundStyle(message.role == .user ? .white : .primary)
|
||||
@@ -157,6 +187,10 @@ private struct ChatMessageBubble: View {
|
||||
.background(message.role == .user ? Color.accentColor : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||
.frame(maxWidth: 620, alignment: message.role == .user ? .trailing : .leading)
|
||||
|
||||
if message.role == .user {
|
||||
ChatRoleMark(role: message.role)
|
||||
}
|
||||
|
||||
if message.role == .assistant {
|
||||
Spacer(minLength: 64)
|
||||
}
|
||||
@@ -165,6 +199,19 @@ private struct ChatMessageBubble: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatRoleMark: View {
|
||||
let role: ChatWorkspaceMessage.Role
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: role == .assistant ? "sparkles" : "person.fill")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(role == .assistant ? Color.teal : Color.accentColor)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 7))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatComposer: View {
|
||||
@Binding var text: String
|
||||
var isFocused: FocusState<Bool>.Binding
|
||||
@@ -173,7 +220,7 @@ private struct ChatComposer: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
TextField("Describe the song idea, lyrics, mood, or arrangement", text: $text, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textFieldStyle(.plain)
|
||||
.lineLimit(1...5)
|
||||
.focused(isFocused)
|
||||
.onSubmit(sendAction)
|
||||
@@ -182,10 +229,13 @@ private struct ChatComposer: View {
|
||||
Label("Send", systemImage: "paperplane.fill")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: .command)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.help("Send")
|
||||
}
|
||||
.padding(10)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
import AppKit
|
||||
import MusicAssistantCore
|
||||
import SwiftUI
|
||||
|
||||
struct FinalReviewView: View {
|
||||
@Binding var project: SongProject
|
||||
@Environment(\.openURL) private var openURL
|
||||
private let sunoFieldFiller: any SunoFieldFilling
|
||||
|
||||
@State private var newFieldKey = ""
|
||||
@State private var newFieldValue = ""
|
||||
@State private var handoffStatus: HandoffStatus?
|
||||
|
||||
init(
|
||||
project: Binding<SongProject>,
|
||||
sunoFieldFiller: any SunoFieldFilling = MacOSSunoAccessibilityFieldFiller()
|
||||
) {
|
||||
_project = project
|
||||
self.sunoFieldFiller = sunoFieldFiller
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -43,7 +31,7 @@ struct FinalReviewView: View {
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Text("Suno fields")
|
||||
Text("Final song output")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
@@ -55,31 +43,11 @@ struct FinalReviewView: View {
|
||||
}
|
||||
.disabled(project.sunoOutput != nil)
|
||||
|
||||
Button(action: sendToSuno) {
|
||||
Label("Send to Suno", systemImage: "arrow.up.forward.app")
|
||||
}
|
||||
}
|
||||
|
||||
if let handoffStatus {
|
||||
Label(handoffStatus.title, systemImage: handoffStatus.systemImage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(handoffStatus.foregroundStyle)
|
||||
}
|
||||
|
||||
if let fieldsToCopy, let output = project.sunoOutput {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(fieldsToCopy) { field in
|
||||
Button("Copy \(field.displayName)") {
|
||||
copy(field, from: output)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField("Suno lyrics", text: sunoLyricsBinding, axis: .vertical)
|
||||
TextField("Lyrics", text: sunoLyricsBinding, axis: .vertical)
|
||||
.lineLimit(4...10)
|
||||
TextField("Suno style prompt", text: sunoStylePromptBinding, axis: .vertical)
|
||||
TextField("Style prompt", text: sunoStylePromptBinding, axis: .vertical)
|
||||
.lineLimit(3...8)
|
||||
|
||||
if !additionalFieldKeys.isEmpty {
|
||||
@@ -199,105 +167,6 @@ struct FinalReviewView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func sendToSuno() {
|
||||
ensureSunoOutput()
|
||||
|
||||
guard let output = project.sunoOutput else { return }
|
||||
|
||||
do {
|
||||
try CompiledSunoOutputValidator().validate(
|
||||
CompiledSunoOutput(lyricsText: output.lyricsText, stylePrompt: output.stylePrompt)
|
||||
)
|
||||
} catch let validationError as LocalizedError {
|
||||
handoffStatus = .failed(validationError.errorDescription ?? "Unable to prepare the Suno handoff.")
|
||||
return
|
||||
} catch {
|
||||
handoffStatus = .failed("Unable to prepare the Suno handoff.")
|
||||
return
|
||||
}
|
||||
|
||||
openURL(SunoHandoffDestination.createPageURL) { accepted in
|
||||
guard accepted else {
|
||||
handoffStatus = .failed("Unable to open the Suno Create page.")
|
||||
return
|
||||
}
|
||||
|
||||
let payload = SunoHandoffPayload(
|
||||
title: project.title,
|
||||
lyrics: output.lyricsText,
|
||||
stylePrompt: output.stylePrompt
|
||||
)
|
||||
Task {
|
||||
handoffStatus = .fieldFill(await sunoFieldFiller.fill(payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fieldsToCopy: [SunoHandoffField]? {
|
||||
guard case let .fieldFill(report) = handoffStatus else { return nil }
|
||||
let fields = SunoHandoffField.allCases.filter {
|
||||
if case .copyRequired = report.results[$0] { return true }
|
||||
return false
|
||||
}
|
||||
return fields.isEmpty ? nil : fields
|
||||
}
|
||||
|
||||
private func copy(_ field: SunoHandoffField, from output: SunoOutput) {
|
||||
let value: String
|
||||
switch field {
|
||||
case .title:
|
||||
value = project.title
|
||||
case .lyrics:
|
||||
value = output.lyricsText
|
||||
case .stylePrompt:
|
||||
value = output.stylePrompt
|
||||
}
|
||||
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
}
|
||||
}
|
||||
|
||||
private enum HandoffStatus: Equatable {
|
||||
case fieldFill(SunoFieldFillReport)
|
||||
case failed(String)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case let .fieldFill(report):
|
||||
let fieldsToCopy = report.results.compactMap { field, result in
|
||||
if case .copyRequired = result { return field.displayName }
|
||||
return nil
|
||||
}
|
||||
return fieldsToCopy.isEmpty
|
||||
? "Suno fields filled in your browser."
|
||||
: "Copy these fields into Suno: \(fieldsToCopy.joined(separator: ", "))."
|
||||
case let .failed(message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case let .fieldFill(report):
|
||||
return report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
|
||||
? "exclamationmark.triangle.fill"
|
||||
: "checkmark.circle.fill"
|
||||
case .failed:
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var foregroundStyle: Color {
|
||||
switch self {
|
||||
case let .fieldFill(report):
|
||||
return report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
|
||||
? .orange
|
||||
: .green
|
||||
case .failed:
|
||||
return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import MusicAssistantCore
|
||||
import SwiftUI
|
||||
|
||||
struct LanguageBrowserView: View {
|
||||
@Binding private var project: SongProject
|
||||
private let saveProject: (SongProject) async -> Bool
|
||||
private let catalog: LocalLanguageCatalog
|
||||
|
||||
@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: LocalLanguageCatalog = LocalLanguageCatalog()
|
||||
) {
|
||||
_project = project
|
||||
self.saveProject = saveProject
|
||||
self.catalog = catalog
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(
|
||||
catalog.languages(
|
||||
matching: searchText,
|
||||
familyCategory: selectedFamilyCategory
|
||||
),
|
||||
id: \.id
|
||||
) { language in
|
||||
Toggle(isOn: selectionBinding(for: language)) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(language.name)
|
||||
.font(.body.weight(.medium))
|
||||
|
||||
Text(language.familyCategory)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.toggleStyle(.checkbox)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.navigationTitle("Language Catalog")
|
||||
.searchable(text: $searchText, prompt: "Search languages")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
Picker("Region", selection: $selectedFamilyCategory) {
|
||||
Text("All regions").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: 560)
|
||||
.onDisappear {
|
||||
guard hasUnsavedSelectionChanges, !isSaving else { return }
|
||||
|
||||
let projectToSave = project
|
||||
hasUnsavedSelectionChanges = false
|
||||
|
||||
Task {
|
||||
_ = await saveProject(projectToSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectionBinding(for item: LanguageCatalogItem) -> Binding<Bool> {
|
||||
Binding {
|
||||
project.language(for: item.id) != nil
|
||||
} set: { isSelected in
|
||||
if isSelected {
|
||||
project.upsertLanguage(item.language)
|
||||
} else {
|
||||
project.removeLanguage(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 {
|
||||
LanguageBrowserPreview()
|
||||
}
|
||||
|
||||
private struct LanguageBrowserPreview: View {
|
||||
@State private var project = SongProject(
|
||||
title: "Language Catalog Preview",
|
||||
idea: "Choose song languages."
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
LanguageBrowserView(project: $project)
|
||||
}
|
||||
}
|
||||
@@ -67,10 +67,10 @@ private struct ManualAutoIntegerRow: View {
|
||||
Binding {
|
||||
value.mode
|
||||
} set: { newMode in
|
||||
value.mode = newMode
|
||||
if newMode == .auto {
|
||||
value.value = nil
|
||||
}
|
||||
value = ManualAutoValue(
|
||||
mode: newMode,
|
||||
value: newMode == .manual ? value.value : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,10 +102,10 @@ private struct ManualAutoTextRow: View {
|
||||
Binding {
|
||||
value.mode
|
||||
} set: { newMode in
|
||||
value.mode = newMode
|
||||
if newMode == .auto {
|
||||
value.value = nil
|
||||
}
|
||||
value = ManualAutoValue(
|
||||
mode: newMode,
|
||||
value: newMode == .manual ? value.value : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import SwiftUI
|
||||
struct ProjectBrowserView: View {
|
||||
@StateObject private var viewModel: ProjectBrowserViewModel
|
||||
@State private var selectedProjectID: String?
|
||||
@State private var projectPendingDeletion: SongProjectListItem?
|
||||
|
||||
init(store: any ProjectStore = LocalSongProjectStore(directoryURL: ProjectBrowserStorage.projectsDirectoryURL)) {
|
||||
_viewModel = StateObject(wrappedValue: ProjectBrowserViewModel(store: store))
|
||||
@@ -12,38 +13,45 @@ struct ProjectBrowserView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $selectedProjectID) {
|
||||
Section("Projects") {
|
||||
ForEach(viewModel.projects) { project in
|
||||
ProjectListRow(project: project)
|
||||
.tag(project.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("AI Music Studio")
|
||||
.overlay {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else if viewModel.projects.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Projects",
|
||||
systemImage: "music.note.list",
|
||||
description: Text("Create a song project to begin.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
Task {
|
||||
if let projectID = await viewModel.createProject() {
|
||||
selectedProjectID = projectID
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
ProjectLibraryHeader(
|
||||
projectCount: viewModel.projects.count,
|
||||
isCreatingProject: viewModel.isCreatingProject
|
||||
) {
|
||||
Task {
|
||||
if let projectID = await viewModel.createProject() {
|
||||
selectedProjectID = projectID
|
||||
}
|
||||
} label: {
|
||||
Label("New Project", systemImage: "plus")
|
||||
}
|
||||
.disabled(viewModel.isCreatingProject)
|
||||
}
|
||||
|
||||
List(selection: $selectedProjectID) {
|
||||
Section("Projects") {
|
||||
ForEach(viewModel.projects) { project in
|
||||
ProjectListRow(project: project) {
|
||||
projectPendingDeletion = project
|
||||
}
|
||||
.tag(project.id)
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
projectPendingDeletion = project
|
||||
} label: {
|
||||
Label("Delete Project", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else if viewModel.projects.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Projects",
|
||||
systemImage: "music.note.list",
|
||||
description: Text("Create a song project to begin.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
@@ -71,26 +79,124 @@ struct ProjectBrowserView: View {
|
||||
await viewModel.openProject(id: projectID)
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
deletionAlertTitle,
|
||||
isPresented: deletionAlertBinding
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
guard let project = projectPendingDeletion else { return }
|
||||
Task {
|
||||
await deleteProject(project)
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This permanently removes the project and its saved versions from this Mac.")
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
}
|
||||
|
||||
private var deletionAlertBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { projectPendingDeletion != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
projectPendingDeletion = nil
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var deletionAlertTitle: String {
|
||||
guard let projectPendingDeletion else { return "Delete Project?" }
|
||||
return "Delete \(projectPendingDeletion.title)?"
|
||||
}
|
||||
|
||||
private func deleteProject(_ project: SongProjectListItem) async {
|
||||
guard await viewModel.deleteProject(id: project.id) else { return }
|
||||
|
||||
if selectedProjectID == project.id {
|
||||
selectedProjectID = viewModel.projects.first?.id
|
||||
}
|
||||
projectPendingDeletion = nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProjectLibraryHeader: View {
|
||||
let projectCount: Int
|
||||
let isCreatingProject: Bool
|
||||
let createAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "music.note.list")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("AI Music Studio")
|
||||
.font(.headline)
|
||||
Text("\(projectCount) saved \(projectCount == 1 ? "project" : "projects")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Button(action: createAction) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
.disabled(isCreatingProject)
|
||||
.help("New project")
|
||||
.accessibilityLabel("New project")
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProjectListRow: View {
|
||||
let project: SongProjectListItem
|
||||
let deleteAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(project.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: project.idea.isEmpty ? "music.note" : "music.note.house")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(project.idea.isEmpty ? Color.secondary : Color.accentColor)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
Text(project.idea.isEmpty ? "No idea yet" : project.idea)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(project.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(project.idea.isEmpty ? "No idea yet" : project.idea)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
Button(action: deleteAction) {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(.secondary)
|
||||
.help("Delete project")
|
||||
.accessibilityLabel("Delete \(project.title)")
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
@@ -139,6 +245,7 @@ final class ProjectBrowserViewModel: ObservableObject {
|
||||
@Published private(set) var isLoading = false
|
||||
@Published private(set) var isOpeningProject = false
|
||||
@Published private(set) var isCreatingProject = false
|
||||
@Published private(set) var isDeletingProject = false
|
||||
@Published private(set) var errorMessage: String?
|
||||
|
||||
private let store: any ProjectStore
|
||||
@@ -215,6 +322,26 @@ final class ProjectBrowserViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteProject(id projectID: String) async -> Bool {
|
||||
guard !isDeletingProject else { return false }
|
||||
isDeletingProject = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
try await store.delete(id: projectID)
|
||||
projects = try await store.loadProjectList()
|
||||
if selectedProject?.id == projectID {
|
||||
selectedProject = nil
|
||||
}
|
||||
isDeletingProject = false
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = "Unable to delete this project."
|
||||
isDeletingProject = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
selectedProject = nil
|
||||
}
|
||||
|
||||
@@ -5,19 +5,30 @@ 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 isVocalistBrowserPresented = false
|
||||
@State private var isLanguageBrowserPresented = false
|
||||
@State private var isSaving = false
|
||||
@State private var saveStatus: SaveStatus?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Label("Inspector", systemImage: "slider.horizontal.3")
|
||||
.font(.headline)
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Inspector")
|
||||
.font(.headline)
|
||||
Text("Song controls")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -28,9 +39,12 @@ struct ProjectInspectorView: View {
|
||||
} label: {
|
||||
Label("Save", systemImage: "tray.and.arrow.down")
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: .command)
|
||||
.disabled(isSaving)
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(.bar)
|
||||
|
||||
Divider()
|
||||
|
||||
@@ -49,17 +63,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") {
|
||||
@@ -83,31 +98,33 @@ 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") {
|
||||
Button {
|
||||
isLanguageBrowserPresented = true
|
||||
} label: {
|
||||
Label("Browse languages", systemImage: "character.book.closed")
|
||||
}
|
||||
|
||||
EditableTokenList(
|
||||
values: project.languages.map(\.name),
|
||||
removeAction: { index in
|
||||
project.removeLanguage(id: project.languages[index].id)
|
||||
}
|
||||
)
|
||||
AddTextRow(
|
||||
placeholder: "Add language",
|
||||
text: $newLanguageName,
|
||||
addAction: addLanguage
|
||||
)
|
||||
}
|
||||
|
||||
Section("Arabic Pronunciation") {
|
||||
@@ -148,6 +165,15 @@ struct ProjectInspectorView: View {
|
||||
.sheet(isPresented: $isInstrumentBrowserPresented) {
|
||||
InstrumentBrowserView(project: $project, saveProject: saveProject)
|
||||
}
|
||||
.sheet(isPresented: $isGenreStyleBrowserPresented) {
|
||||
GenreStyleBrowserView(project: $project, saveProject: saveProject)
|
||||
}
|
||||
.sheet(isPresented: $isVocalistBrowserPresented) {
|
||||
VocalistBrowserView(project: $project, saveProject: saveProject)
|
||||
}
|
||||
.sheet(isPresented: $isLanguageBrowserPresented) {
|
||||
LanguageBrowserView(project: $project, saveProject: saveProject)
|
||||
}
|
||||
}
|
||||
|
||||
private var durationSecondsBinding: Binding<String> {
|
||||
@@ -171,13 +197,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 }
|
||||
@@ -185,20 +204,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 }
|
||||
project.upsertLanguage(LanguageSelection(id: stableID(from: name), name: name))
|
||||
newLanguageName = ""
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
isSaving = true
|
||||
let didSave = await saveProject(project)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,6 @@ public struct ProductBoundarySummary: Equatable, Sendable {
|
||||
}
|
||||
|
||||
public static let initial = ProductBoundarySummary(
|
||||
description: "Create and refine song project concepts, then prepare lyrics and style instructions for Suno handoff without starting generation."
|
||||
description: "Create and refine song project concepts, then prepare final lyrics and style instructions."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -535,6 +535,32 @@ public struct LanguageSelection: Codable, Equatable, Identifiable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct LanguageCatalogItem: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var name: String
|
||||
public var familyCategory: String
|
||||
public var aliases: [String]
|
||||
public var searchTerms: [String]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
familyCategory: String,
|
||||
aliases: [String] = [],
|
||||
searchTerms: [String] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.familyCategory = familyCategory
|
||||
self.aliases = aliases
|
||||
self.searchTerms = searchTerms
|
||||
}
|
||||
|
||||
public var language: LanguageSelection {
|
||||
LanguageSelection(id: id, name: name)
|
||||
}
|
||||
}
|
||||
|
||||
public struct DialectSelection: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var name: String
|
||||
@@ -594,6 +620,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
|
||||
@@ -770,6 +825,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
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
public protocol SunoHandoffService: Sendable {
|
||||
func handOff(_ output: CompiledSunoOutput) async throws
|
||||
}
|
||||
|
||||
public enum SunoHandoffDestination {
|
||||
public static let createPageURL = URL(string: "https://suno.com/create")!
|
||||
}
|
||||
|
||||
public struct SunoHandoffPayload: Equatable, Sendable {
|
||||
public let title: String
|
||||
public let lyrics: String
|
||||
public let stylePrompt: String
|
||||
|
||||
public init(title: String, lyrics: String, stylePrompt: String) {
|
||||
self.title = title
|
||||
self.lyrics = lyrics
|
||||
self.stylePrompt = stylePrompt
|
||||
}
|
||||
|
||||
public func value(for field: SunoHandoffField) -> String {
|
||||
switch field {
|
||||
case .title:
|
||||
return title
|
||||
case .lyrics:
|
||||
return lyrics
|
||||
case .stylePrompt:
|
||||
return stylePrompt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum SunoHandoffField: String, CaseIterable, Equatable, Identifiable, Sendable {
|
||||
case title
|
||||
case lyrics
|
||||
case stylePrompt
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .title:
|
||||
return "Title"
|
||||
case .lyrics:
|
||||
return "Lyrics"
|
||||
case .stylePrompt:
|
||||
return "Style prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum SunoFieldFillResult: Equatable, Sendable {
|
||||
case filled
|
||||
case copyRequired(String)
|
||||
}
|
||||
|
||||
public struct SunoFieldFillReport: Equatable, Sendable {
|
||||
public let results: [SunoHandoffField: SunoFieldFillResult]
|
||||
|
||||
public init(results: [SunoHandoffField: SunoFieldFillResult]) {
|
||||
self.results = results
|
||||
}
|
||||
|
||||
public static func copyRequired(for fields: [SunoHandoffField], reason: String) -> SunoFieldFillReport {
|
||||
SunoFieldFillReport(results: Dictionary(uniqueKeysWithValues: fields.map { ($0, .copyRequired(reason)) }))
|
||||
}
|
||||
}
|
||||
|
||||
public protocol SunoFieldFilling: Sendable {
|
||||
func fill(_ payload: SunoHandoffPayload) async -> SunoFieldFillReport
|
||||
}
|
||||
@@ -4,6 +4,7 @@ public protocol ProjectStore: Sendable {
|
||||
func create(_ project: SongProject) async throws
|
||||
func save(_ project: SongProject) async throws
|
||||
func open(id: String) async throws -> SongProject
|
||||
func delete(id: String) async throws
|
||||
func loadProjectIDs() async throws -> [String]
|
||||
func loadProjectList() async throws -> [SongProjectListItem]
|
||||
}
|
||||
@@ -69,6 +70,14 @@ public actor LocalSongProjectStore: ProjectStore {
|
||||
return try decoder.decode(SongProject.self, from: data)
|
||||
}
|
||||
|
||||
public func delete(id: String) async throws {
|
||||
let url = projectURL(for: id)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw LocalSongProjectStoreError.projectNotFound(id)
|
||||
}
|
||||
try FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
public func loadProjectIDs() async throws -> [String] {
|
||||
try await loadProjectList().map(\.id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
public enum AIRequestState: Equatable, Sendable {
|
||||
case idle
|
||||
case loading
|
||||
case failed(AIProviderErrorState)
|
||||
}
|
||||
|
||||
public enum AIProviderErrorState: Equatable, Sendable {
|
||||
case offline
|
||||
case missingConfiguration
|
||||
case rateLimited(retryAfter: TimeInterval?)
|
||||
case unavailable
|
||||
case invalidResponse
|
||||
case unsupportedCapability
|
||||
}
|
||||
|
||||
public struct AIProviderErrorPresenter: Sendable {
|
||||
public init() {}
|
||||
|
||||
public func state(for error: Error) -> AIProviderErrorState {
|
||||
if let urlError = error as? URLError,
|
||||
urlError.code == .notConnectedToInternet || urlError.code == .networkConnectionLost {
|
||||
return .offline
|
||||
}
|
||||
|
||||
if let clientError = error as? OpenAIClientError {
|
||||
switch clientError {
|
||||
case .missingAPIKey:
|
||||
return .missingConfiguration
|
||||
case let .rateLimited(retryAfter):
|
||||
return .rateLimited(retryAfter: retryAfter)
|
||||
case .serverError, .unacceptableStatusCode:
|
||||
return .unavailable
|
||||
case .invalidResponse:
|
||||
return .invalidResponse
|
||||
}
|
||||
}
|
||||
|
||||
if error as? AIServiceCapabilityError != nil {
|
||||
return .unsupportedCapability
|
||||
}
|
||||
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
@@ -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,172 @@
|
||||
import Foundation
|
||||
|
||||
public struct LocalLanguageCatalog: Sendable {
|
||||
public let items: [LanguageCatalogItem]
|
||||
|
||||
public init(items: [LanguageCatalogItem] = LocalLanguageCatalog.worldwideItems) {
|
||||
self.items = items
|
||||
}
|
||||
|
||||
public var familyCategories: [String] {
|
||||
Array(Set(items.map(\.familyCategory)))
|
||||
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
public func languages(
|
||||
matching query: String,
|
||||
familyCategory: String? = nil
|
||||
) -> [LanguageCatalogItem] {
|
||||
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.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
public static let worldwideItems: [LanguageCatalogItem] =
|
||||
middleEastAndNorthAfrica + europe + southAsia + eastAsia + southeastAsia + africa + americas + oceaniaAndPacific
|
||||
|
||||
private static let middleEastAndNorthAfrica: [LanguageCatalogItem] = [
|
||||
item("arabic", "Arabic", "Middle East & North Africa", aliases: ["العربية"], terms: ["عربي"]),
|
||||
item("hebrew", "Hebrew", "Middle East & North Africa", aliases: ["עברית"]),
|
||||
item("persian", "Persian", "Middle East & North Africa", aliases: ["Farsi", "فارسی"]),
|
||||
item("turkish", "Turkish", "Middle East & North Africa", aliases: ["Türkçe"]),
|
||||
item("kurdish", "Kurdish", "Middle East & North Africa", aliases: ["Kurdî"]),
|
||||
item("armenian", "Armenian", "Middle East & North Africa"),
|
||||
item("azerbaijani", "Azerbaijani", "Middle East & North Africa", aliases: ["Azeri"]),
|
||||
item("tamazight", "Tamazight", "Middle East & North Africa", aliases: ["Berber"])
|
||||
]
|
||||
|
||||
private static let europe: [LanguageCatalogItem] = [
|
||||
item("english", "English", "Europe", aliases: ["English language"]),
|
||||
item("german", "German", "Europe", aliases: ["Deutsch"]),
|
||||
item("french", "French", "Europe", aliases: ["Français"]),
|
||||
item("spanish", "Spanish", "Europe", aliases: ["Español"]),
|
||||
item("portuguese", "Portuguese", "Europe", aliases: ["Português"]),
|
||||
item("italian", "Italian", "Europe", aliases: ["Italiano"]),
|
||||
item("russian", "Russian", "Europe", aliases: ["Русский"]),
|
||||
item("ukrainian", "Ukrainian", "Europe", aliases: ["Українська"]),
|
||||
item("polish", "Polish", "Europe", aliases: ["Polski"]),
|
||||
item("dutch", "Dutch", "Europe", aliases: ["Nederlands"]),
|
||||
item("swedish", "Swedish", "Europe", aliases: ["Svenska"]),
|
||||
item("norwegian", "Norwegian", "Europe", aliases: ["Norsk"]),
|
||||
item("danish", "Danish", "Europe", aliases: ["Dansk"]),
|
||||
item("finnish", "Finnish", "Europe", aliases: ["Suomi"]),
|
||||
item("greek", "Greek", "Europe", aliases: ["Ελληνικά"]),
|
||||
item("romanian", "Romanian", "Europe", aliases: ["Română"]),
|
||||
item("czech", "Czech", "Europe", aliases: ["Čeština"]),
|
||||
item("hungarian", "Hungarian", "Europe", aliases: ["Magyar"]),
|
||||
item("serbian", "Serbian", "Europe"),
|
||||
item("croatian", "Croatian", "Europe"),
|
||||
item("bulgarian", "Bulgarian", "Europe")
|
||||
]
|
||||
|
||||
private static let southAsia: [LanguageCatalogItem] = [
|
||||
item("hindi", "Hindi", "South Asia", aliases: ["हिन्दी"]),
|
||||
item("urdu", "Urdu", "South Asia", aliases: ["اردو"]),
|
||||
item("punjabi", "Punjabi", "South Asia", aliases: ["ਪੰਜਾਬੀ"]),
|
||||
item("bengali", "Bengali", "South Asia", aliases: ["বাংলা"]),
|
||||
item("tamil", "Tamil", "South Asia", aliases: ["தமிழ்"]),
|
||||
item("telugu", "Telugu", "South Asia", aliases: ["తెలుగు"]),
|
||||
item("marathi", "Marathi", "South Asia", aliases: ["मराठी"]),
|
||||
item("gujarati", "Gujarati", "South Asia", aliases: ["ગુજરાતી"]),
|
||||
item("malayalam", "Malayalam", "South Asia", aliases: ["മലയാളം"]),
|
||||
item("kannada", "Kannada", "South Asia", aliases: ["ಕನ್ನಡ"]),
|
||||
item("sinhala", "Sinhala", "South Asia"),
|
||||
item("nepali", "Nepali", "South Asia", aliases: ["नेपाली"]),
|
||||
item("pashto", "Pashto", "South Asia", aliases: ["پښتو"])
|
||||
]
|
||||
|
||||
private static let eastAsia: [LanguageCatalogItem] = [
|
||||
item("mandarin-chinese", "Mandarin Chinese", "East Asia", aliases: ["Mandarin", "中文", "普通话"]),
|
||||
item("cantonese", "Cantonese", "East Asia", aliases: ["廣東話"]),
|
||||
item("japanese", "Japanese", "East Asia", aliases: ["日本語"]),
|
||||
item("korean", "Korean", "East Asia", aliases: ["한국어"]),
|
||||
item("mongolian", "Mongolian", "East Asia")
|
||||
]
|
||||
|
||||
private static let southeastAsia: [LanguageCatalogItem] = [
|
||||
item("indonesian", "Indonesian", "Southeast Asia", aliases: ["Bahasa Indonesia"]),
|
||||
item("malay", "Malay", "Southeast Asia", aliases: ["Bahasa Melayu"]),
|
||||
item("vietnamese", "Vietnamese", "Southeast Asia", aliases: ["Tiếng Việt"]),
|
||||
item("thai", "Thai", "Southeast Asia", aliases: ["ไทย"]),
|
||||
item("tagalog", "Tagalog", "Southeast Asia", aliases: ["Filipino"]),
|
||||
item("burmese", "Burmese", "Southeast Asia", aliases: ["Myanmar"]),
|
||||
item("khmer", "Khmer", "Southeast Asia"),
|
||||
item("lao", "Lao", "Southeast Asia"),
|
||||
item("javanese", "Javanese", "Southeast Asia")
|
||||
]
|
||||
|
||||
private static let africa: [LanguageCatalogItem] = [
|
||||
item("swahili", "Swahili", "Africa", aliases: ["Kiswahili"]),
|
||||
item("amharic", "Amharic", "Africa", aliases: ["አማርኛ"]),
|
||||
item("hausa", "Hausa", "Africa"),
|
||||
item("yoruba", "Yoruba", "Africa"),
|
||||
item("igbo", "Igbo", "Africa"),
|
||||
item("zulu", "Zulu", "Africa"),
|
||||
item("xhosa", "Xhosa", "Africa"),
|
||||
item("somali", "Somali", "Africa"),
|
||||
item("oromo", "Oromo", "Africa"),
|
||||
item("afrikaans", "Afrikaans", "Africa"),
|
||||
item("wolof", "Wolof", "Africa"),
|
||||
item("shona", "Shona", "Africa")
|
||||
]
|
||||
|
||||
private static let americas: [LanguageCatalogItem] = [
|
||||
item("haitian-creole", "Haitian Creole", "Americas", aliases: ["Kreyòl"]),
|
||||
item("quechua", "Quechua", "Americas"),
|
||||
item("guarani", "Guarani", "Americas"),
|
||||
item("navajo", "Navajo", "Americas"),
|
||||
item("inuktitut", "Inuktitut", "Americas")
|
||||
]
|
||||
|
||||
private static let oceaniaAndPacific: [LanguageCatalogItem] = [
|
||||
item("maori", "Maori", "Oceania & Pacific", aliases: ["Te Reo Māori"]),
|
||||
item("hawaiian", "Hawaiian", "Oceania & Pacific"),
|
||||
item("samoan", "Samoan", "Oceania & Pacific"),
|
||||
item("tongan", "Tongan", "Oceania & Pacific"),
|
||||
item("fijian", "Fijian", "Oceania & Pacific")
|
||||
]
|
||||
|
||||
private static func item(
|
||||
_ id: String,
|
||||
_ name: String,
|
||||
_ familyCategory: String,
|
||||
aliases: [String] = [],
|
||||
terms: [String] = []
|
||||
) -> LanguageCatalogItem {
|
||||
LanguageCatalogItem(
|
||||
id: id,
|
||||
name: name,
|
||||
familyCategory: familyCategory,
|
||||
aliases: aliases,
|
||||
searchTerms: terms
|
||||
)
|
||||
}
|
||||
|
||||
private static func searchableTerms(for item: LanguageCatalogItem) -> [String] {
|
||||
searchTokens(for: [item.name, item.familyCategory] + 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 == "-" })
|
||||
.map(String.init)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ public enum SunoOutputValidationError: Error, Equatable, LocalizedError, Sendabl
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingContent:
|
||||
return "Add lyrics or a style prompt before sending the song to Suno."
|
||||
return "Add lyrics or a style prompt before finalizing the song output."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
|
||||
public protocol SongProjectValidating: Sendable {
|
||||
func validate(_ project: SongProject) -> [SongProjectValidationIssue]
|
||||
}
|
||||
|
||||
public enum SongProjectValidationIssue: Equatable, Sendable {
|
||||
case invalidDuration
|
||||
case invalidBPM(Int)
|
||||
case duplicateSectionID(String)
|
||||
case invalidSectionTiming(String)
|
||||
case invalidInstrumentTiming(String)
|
||||
case unknownSectionReference(String)
|
||||
}
|
||||
|
||||
public struct SongProjectValidator: SongProjectValidating {
|
||||
public init() {}
|
||||
|
||||
public func validate(_ project: SongProject) -> [SongProjectValidationIssue] {
|
||||
var issues: [SongProjectValidationIssue] = []
|
||||
let sectionIDs = Set(project.sections.map(\.id))
|
||||
|
||||
if project.duration?.seconds.map({ $0 <= 0 }) == true {
|
||||
issues.append(.invalidDuration)
|
||||
}
|
||||
|
||||
if let bpm = project.bpm?.value, bpm <= 0 {
|
||||
issues.append(.invalidBPM(bpm))
|
||||
}
|
||||
|
||||
for section in project.sections {
|
||||
if project.sections.filter({ $0.id == section.id }).count > 1 {
|
||||
append(.duplicateSectionID(section.id), to: &issues)
|
||||
}
|
||||
|
||||
if invalidTiming(start: section.startTime, end: section.endTime) {
|
||||
issues.append(.invalidSectionTiming(section.id))
|
||||
}
|
||||
}
|
||||
|
||||
for instrument in project.instruments {
|
||||
for placement in instrument.placements {
|
||||
if invalidTiming(start: placement.startTime, end: placement.endTime) {
|
||||
issues.append(.invalidInstrumentTiming(instrument.instrumentId))
|
||||
}
|
||||
|
||||
if let sectionID = placement.sectionId, !sectionIDs.contains(sectionID) {
|
||||
append(.unknownSectionReference(sectionID), to: &issues)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for vocalist in project.vocalists {
|
||||
for sectionID in vocalist.assignedSections where !sectionIDs.contains(sectionID) {
|
||||
append(.unknownSectionReference(sectionID), to: &issues)
|
||||
}
|
||||
}
|
||||
|
||||
for point in project.emotionalArc {
|
||||
if let sectionID = point.sectionID, !sectionIDs.contains(sectionID) {
|
||||
append(.unknownSectionReference(sectionID), to: &issues)
|
||||
}
|
||||
}
|
||||
|
||||
for direction in project.productionDirections {
|
||||
if let sectionID = direction.sectionID, !sectionIDs.contains(sectionID) {
|
||||
append(.unknownSectionReference(sectionID), to: &issues)
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
private func invalidTiming(start: TimeInterval?, end: TimeInterval?) -> Bool {
|
||||
guard let start, let end else { return start.map { $0 < 0 } ?? end.map { $0 < 0 } ?? false }
|
||||
return start < 0 || end < 0 || end <= start
|
||||
}
|
||||
|
||||
private func append(_ issue: SongProjectValidationIssue, to issues: inout [SongProjectValidationIssue]) {
|
||||
guard !issues.contains(issue) else { return }
|
||||
issues.append(issue)
|
||||
}
|
||||
}
|
||||
@@ -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,15 @@
|
||||
import MusicAssistantCore
|
||||
import XCTest
|
||||
|
||||
final class AIRequestStateTests: XCTestCase {
|
||||
func testPresenterMapsOfflineAndProviderFailures() {
|
||||
let presenter = AIProviderErrorPresenter()
|
||||
|
||||
XCTAssertEqual(presenter.state(for: URLError(.notConnectedToInternet)), .offline)
|
||||
XCTAssertEqual(presenter.state(for: OpenAIClientError.missingAPIKey), .missingConfiguration)
|
||||
XCTAssertEqual(presenter.state(for: OpenAIClientError.rateLimited(retryAfter: 30)), .rateLimited(retryAfter: 30))
|
||||
XCTAssertEqual(presenter.state(for: OpenAIClientError.serverError(503)), .unavailable)
|
||||
XCTAssertEqual(presenter.state(for: OpenAIClientError.invalidResponse), .invalidResponse)
|
||||
XCTAssertEqual(presenter.state(for: AIServiceCapabilityError.discussionNotSupported), .unsupportedCapability)
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import MusicAssistantCore
|
||||
import XCTest
|
||||
|
||||
final class LocalLanguageCatalogTests: XCTestCase {
|
||||
func testWorldwideCatalogSearchesArabicTermsAndRegionalCategories() {
|
||||
let catalog = LocalLanguageCatalog()
|
||||
let expectedCategories: Set<String> = [
|
||||
"Middle East & North Africa",
|
||||
"Europe",
|
||||
"South Asia",
|
||||
"East Asia",
|
||||
"Southeast Asia",
|
||||
"Africa",
|
||||
"Americas",
|
||||
"Oceania & Pacific"
|
||||
]
|
||||
|
||||
XCTAssertGreaterThan(catalog.items.count, 70)
|
||||
XCTAssertTrue(expectedCategories.isSubset(of: Set(catalog.familyCategories)))
|
||||
XCTAssertEqual(catalog.languages(matching: "عربي").map(\.id), ["arabic"])
|
||||
XCTAssertEqual(catalog.languages(matching: "한국어").map(\.id), ["korean"])
|
||||
XCTAssertEqual(
|
||||
catalog.languages(matching: "", familyCategory: "South Asia").map(\.id),
|
||||
[
|
||||
"bengali",
|
||||
"gujarati",
|
||||
"hindi",
|
||||
"kannada",
|
||||
"malayalam",
|
||||
"marathi",
|
||||
"nepali",
|
||||
"pashto",
|
||||
"punjabi",
|
||||
"sinhala",
|
||||
"tamil",
|
||||
"telugu",
|
||||
"urdu"
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testDeselectingLanguageRemovesItsLinkedDialects() {
|
||||
var project = SongProject(
|
||||
title: "Language Project",
|
||||
idea: "Use Arabic and English",
|
||||
languages: [
|
||||
LanguageSelection(id: "arabic", name: "Arabic"),
|
||||
LanguageSelection(id: "english", name: "English")
|
||||
],
|
||||
dialects: [
|
||||
DialectSelection(id: "levantine", name: "Levantine", languageID: "arabic"),
|
||||
DialectSelection(id: "us-english", name: "US English", languageID: "english")
|
||||
]
|
||||
)
|
||||
|
||||
project.removeLanguage(id: "arabic")
|
||||
|
||||
XCTAssertEqual(project.languages.map(\.id), ["english"])
|
||||
XCTAssertEqual(project.dialects.map(\.id), ["us-english"])
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,53 @@ final class LocalSongProjectStoreTests: XCTestCase {
|
||||
XCTAssertEqual(savedProject.selectedInstrumentIDs, ["oud", "violin"])
|
||||
}
|
||||
|
||||
func testRestoresPersistedProjectVersionAndKeepsVersionHistory() async throws {
|
||||
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
|
||||
let restoredAt = Date(timeIntervalSince1970: 900)
|
||||
var project = SongProject(
|
||||
id: "version-recovery-project",
|
||||
title: "Original Direction",
|
||||
idea: "Start with acoustic instruments",
|
||||
genres: [GenreStyle(id: "folk", name: "Folk")],
|
||||
lyrics: Lyrics(text: "Original lyrics"),
|
||||
createdAt: Date(timeIntervalSince1970: 100),
|
||||
updatedAt: Date(timeIntervalSince1970: 200)
|
||||
)
|
||||
|
||||
_ = project.createVersion(
|
||||
id: "original-version",
|
||||
label: "Original direction",
|
||||
createdAt: Date(timeIntervalSince1970: 300)
|
||||
)
|
||||
project.title = "Later Direction"
|
||||
project.idea = "Add electronic elements"
|
||||
project.selectInstrument(InstrumentTrack(instrumentId: "oud"))
|
||||
project.lyrics = Lyrics(text: "Later lyrics")
|
||||
_ = project.createVersion(
|
||||
id: "later-version",
|
||||
label: "Later direction",
|
||||
createdAt: Date(timeIntervalSince1970: 400)
|
||||
)
|
||||
project.title = "Unsaved direction"
|
||||
|
||||
try await store.create(project)
|
||||
|
||||
var recoveredProject = try await store.open(id: project.id)
|
||||
XCTAssertTrue(recoveredProject.restoreVersion(id: "original-version", restoredAt: restoredAt))
|
||||
try await store.save(recoveredProject)
|
||||
|
||||
let restoredProject = try await store.open(id: project.id)
|
||||
|
||||
XCTAssertEqual(restoredProject.title, "Original Direction")
|
||||
XCTAssertEqual(restoredProject.idea, "Start with acoustic instruments")
|
||||
XCTAssertEqual(restoredProject.genres, [GenreStyle(id: "folk", name: "Folk")])
|
||||
XCTAssertTrue(restoredProject.instruments.isEmpty)
|
||||
XCTAssertEqual(restoredProject.lyrics, Lyrics(text: "Original lyrics"))
|
||||
XCTAssertEqual(restoredProject.updatedAt, restoredAt)
|
||||
XCTAssertEqual(restoredProject.versions.map(\.id), ["original-version", "later-version"])
|
||||
XCTAssertEqual(restoredProject.version(id: "later-version")?.snapshot.title, "Later Direction")
|
||||
}
|
||||
|
||||
func testLoadsProjectListSortedByMostRecentUpdate() async throws {
|
||||
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
|
||||
let older = SongProject(
|
||||
@@ -128,6 +175,28 @@ final class LocalSongProjectStoreTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testDeletesSongProjectAndRemovesItFromTheProjectList() async throws {
|
||||
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
|
||||
let project = SongProject(
|
||||
id: "deletable-project",
|
||||
title: "Delete Me",
|
||||
idea: "Remove this local project"
|
||||
)
|
||||
|
||||
try await store.create(project)
|
||||
try await store.delete(id: project.id)
|
||||
|
||||
let remainingProjects = try await store.loadProjectList()
|
||||
XCTAssertTrue(remainingProjects.isEmpty)
|
||||
|
||||
do {
|
||||
_ = try await store.open(id: project.id)
|
||||
XCTFail("Expected deleted project open to fail.")
|
||||
} catch let error as LocalSongProjectStoreError {
|
||||
XCTAssertEqual(error, .projectNotFound(project.id))
|
||||
}
|
||||
}
|
||||
|
||||
func testOpenMissingProjectThrowsNotFound() async throws {
|
||||
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import MusicAssistantCore
|
||||
import XCTest
|
||||
|
||||
final class ProductBoundarySummaryTests: XCTestCase {
|
||||
func testInitialBoundaryKeepsSunoGenerationOutsideTheApp() {
|
||||
func testInitialBoundaryExcludesExternalGenerationControl() {
|
||||
let summary = ProductBoundarySummary.initial.description
|
||||
|
||||
XCTAssertTrue(summary.contains("Suno"))
|
||||
XCTAssertTrue(summary.contains("without starting generation"))
|
||||
XCTAssertTrue(summary.contains("final lyrics and style instructions"))
|
||||
XCTAssertFalse(summary.contains("Suno"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import MusicAssistantCore
|
||||
import XCTest
|
||||
|
||||
final class SongProjectValidatorTests: XCTestCase {
|
||||
func testValidatorReportsInvalidProjectRelationshipsAndTiming() {
|
||||
let project = SongProject(
|
||||
title: "Invalid Project",
|
||||
idea: "Validate structure",
|
||||
duration: SongDuration(seconds: 0),
|
||||
bpm: ManualAutoValue(mode: .manual, value: 0),
|
||||
sections: [
|
||||
SongSection(id: "verse", type: .verse, title: "Verse", startTime: 12, endTime: 8),
|
||||
SongSection(id: "verse", type: .chorus, title: "Chorus")
|
||||
],
|
||||
instruments: [
|
||||
InstrumentTrack(
|
||||
instrumentId: "piano",
|
||||
placements: [InstrumentPlacement(sectionId: "missing", startTime: -1, endTime: 4)]
|
||||
)
|
||||
],
|
||||
vocalists: [Vocalist(label: "Lead", assignedSections: ["missing"])]
|
||||
)
|
||||
|
||||
let issues = SongProjectValidator().validate(project)
|
||||
|
||||
XCTAssertEqual(
|
||||
issues,
|
||||
[
|
||||
.invalidDuration,
|
||||
.invalidBPM(0),
|
||||
.duplicateSectionID("verse"),
|
||||
.invalidSectionTiming("verse"),
|
||||
.invalidInstrumentTiming("piano"),
|
||||
.unknownSectionReference("missing")
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testValidatorAcceptsConsistentProject() {
|
||||
let project = SongProject(
|
||||
title: "Valid Project",
|
||||
idea: "Validate structure",
|
||||
duration: SongDuration(seconds: 180),
|
||||
bpm: ManualAutoValue(mode: .manual, value: 100),
|
||||
sections: [SongSection(id: "verse", type: .verse, title: "Verse", startTime: 0, endTime: 30)],
|
||||
instruments: [InstrumentTrack(instrumentId: "piano", placements: [InstrumentPlacement(sectionId: "verse", startTime: 0, endTime: 30)])],
|
||||
vocalists: [Vocalist(label: "Lead", assignedSections: ["verse"])]
|
||||
)
|
||||
|
||||
XCTAssertTrue(SongProjectValidator().validate(project).isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import MusicAssistantCore
|
||||
import XCTest
|
||||
|
||||
final class SunoHandoffDestinationTests: XCTestCase {
|
||||
func testCreatePageUsesTheOfficialSunoCreateURL() {
|
||||
let url = SunoHandoffDestination.createPageURL
|
||||
|
||||
XCTAssertEqual(url.scheme, "https")
|
||||
XCTAssertEqual(url.host, "suno.com")
|
||||
XCTAssertEqual(url.path, "/create")
|
||||
}
|
||||
|
||||
func testPayloadPreservesApprovedValuesForEachSupportedField() {
|
||||
let payload = SunoHandoffPayload(title: "My Song", lyrics: "Approved lyrics", stylePrompt: "Cinematic folk")
|
||||
|
||||
XCTAssertEqual(payload.value(for: .title), "My Song")
|
||||
XCTAssertEqual(payload.value(for: .lyrics), "Approved lyrics")
|
||||
XCTAssertEqual(payload.value(for: .stylePrompt), "Cinematic folk")
|
||||
}
|
||||
}
|
||||
+32
-14
@@ -24,15 +24,17 @@ macOS App
|
||||
├── Application Services
|
||||
│ ├── AI Director
|
||||
│ ├── Prompt Compiler
|
||||
│ ├── Genre & Style Catalog
|
||||
│ ├── Instrument Catalog
|
||||
│ ├── Vocalist Catalog
|
||||
│ ├── Language Catalog
|
||||
│ ├── Arabic Pronunciation Processor
|
||||
│ ├── Project Versioning
|
||||
│ └── Validation
|
||||
├── Integrations
|
||||
│ ├── OpenAI
|
||||
│ └── Suno Field-Fill Integration
|
||||
│ └── OpenAI
|
||||
└── Persistence
|
||||
└── Local project/version storage
|
||||
└── Local project/version storage and deletion
|
||||
```
|
||||
|
||||
## AI Director
|
||||
@@ -72,7 +74,8 @@ does not use external services or APIs. Before processing, it protects
|
||||
the user's exact preserved spellings and dialect phrases so normalization
|
||||
or tanween removal cannot alter them. The Presentation layer presents
|
||||
both the original and processed lyrics in final review; the user may edit
|
||||
and explicitly apply the processed text before any Suno handoff.
|
||||
and explicitly apply the processed text before preparing the final local
|
||||
output.
|
||||
|
||||
## Security
|
||||
|
||||
@@ -82,17 +85,10 @@ and explicitly apply the processed text before any Suno handoff.
|
||||
and providers can be changed later.
|
||||
- Do not log secrets or full authorization headers.
|
||||
|
||||
## Suno Boundary
|
||||
## External Generation Boundary
|
||||
|
||||
The initial requirement is an app-managed web surface for the Suno
|
||||
Create page in Custom mode. This avoids relying on a particular user
|
||||
browser. The integration attempts to fill the supported title,
|
||||
lyrics and style fields but does not press Generate. If a field cannot
|
||||
be filled, it returns a field-specific result so Presentation can report
|
||||
it and offer the approved text for copying. Treat this integration as
|
||||
replaceable because website UI/behavior can change. The field filler is
|
||||
an adapter behind a protocol so it can be replaced without changing
|
||||
SongProject, prompt compilation or UI review logic.
|
||||
The application prepares final lyrics and style prompts but does not
|
||||
open, embed or control an external music-generation service.
|
||||
|
||||
## Instrument Selector Boundary
|
||||
|
||||
@@ -117,3 +113,25 @@ 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Language Selector Boundary
|
||||
|
||||
Language catalog data is local structured data, separate from SwiftUI
|
||||
views. The catalog service owns stable ids, regional categories and
|
||||
search terms; the Presentation layer toggles the corresponding
|
||||
`LanguageSelection` values on the SongProject.
|
||||
|
||||
@@ -41,6 +41,32 @@ InstrumentCatalogItem
|
||||
- aliases[]
|
||||
- searchTerms[]
|
||||
|
||||
GenreStyleCatalogItem
|
||||
- id
|
||||
- name
|
||||
- familyCategory
|
||||
- regionOrigin (optional)
|
||||
- aliases[]
|
||||
- searchTerms[]
|
||||
|
||||
VocalistPreset
|
||||
- id
|
||||
- label
|
||||
- familyCategory
|
||||
- regionOrigin (optional)
|
||||
- voiceType (optional)
|
||||
- genderSelection (optional)
|
||||
- performanceStyle (optional)
|
||||
- aliases[]
|
||||
- searchTerms[]
|
||||
|
||||
LanguageCatalogItem
|
||||
- id
|
||||
- name
|
||||
- familyCategory
|
||||
- aliases[]
|
||||
- searchTerms[]
|
||||
|
||||
SongSection
|
||||
- id
|
||||
- type (intro, verse, preChorus, chorus, bridge, outro, custom)
|
||||
|
||||
+6
-10
@@ -7,21 +7,17 @@ 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.
|
||||
- Exact list of supported languages/dialects for MVP.
|
||||
- Resolved: languages use a large curated local catalog with category
|
||||
browsing, search aliases and checkbox multi-select. Exact dialect
|
||||
preset coverage remains to be defined.
|
||||
- Arabic diacritization policy: full tashkeel vs
|
||||
pronunciation-targeted tashkeel.
|
||||
- Resolved: Suno handoff presents the Create page in an app-managed web
|
||||
surface in Custom mode and attempts to fill title, lyrics and style
|
||||
fields. It never
|
||||
starts generation. Field-fill failures are reported with the
|
||||
approved text available for copying.
|
||||
- Whether Suno integration is permitted/reliable under the intended
|
||||
account/workflow and current terms.
|
||||
- External music-generation handoff is not in the current scope.
|
||||
- Project storage technology and whether cloud sync is required later.
|
||||
- Whether users supply their own OpenAI/API credentials or the product
|
||||
owner supplies service credentials.
|
||||
|
||||
+34
-17
@@ -4,8 +4,7 @@
|
||||
|
||||
A native macOS application that helps users design a complete song
|
||||
concept through a natural chat interface, using OpenAI as the planning
|
||||
and writing engine, then prepares the final lyrics and style
|
||||
instructions for Suno.
|
||||
and writing engine, then prepares final lyrics and style instructions.
|
||||
|
||||
## Core Flow
|
||||
|
||||
@@ -18,16 +17,12 @@ instructions for Suno.
|
||||
decisions.
|
||||
4. User reviews and manually edits every generated component.
|
||||
5. Project versions are saved.
|
||||
6. User presses **Send to Suno**.
|
||||
7. The app presents the Suno Create page in an app-managed web surface
|
||||
in Custom mode and fills the
|
||||
supported title, lyrics and style fields. It does not trigger
|
||||
Generate. If a field cannot be filled, the app reports that result
|
||||
and keeps its approved text ready for the user to copy.
|
||||
|
||||
## 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
|
||||
@@ -41,9 +36,13 @@ instructions for Suno.
|
||||
- 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.
|
||||
- Languages can be selected from a large local catalog with search,
|
||||
category browsing and checkbox multi-select.
|
||||
- Arabic receives dedicated diacritics/harakat/tanween handling for
|
||||
pronunciation.
|
||||
- Energy and emotional progression can change throughout the song.
|
||||
@@ -76,16 +75,39 @@ views. Each catalog entry should support structured metadata:
|
||||
Selected instruments must be available as part of the Song Project and
|
||||
later available to the OpenAI/song-generation layer so they can
|
||||
influence arrangement, instrument roles, entry/exit timing, song
|
||||
structure where relevant and the Suno Style Prompt.
|
||||
structure where relevant and the final style prompt.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Language Selector
|
||||
|
||||
The application provides a large curated local language catalog. Users
|
||||
can search, browse by regional category and select or deselect multiple
|
||||
languages with checkboxes. Removing a language also removes any dialect
|
||||
entries attached to it from the Song Project.
|
||||
|
||||
## Project Management
|
||||
|
||||
Every song is stored as a project. Important changes can create versions
|
||||
so previous states remain recoverable.
|
||||
so previous states remain recoverable. A project can be deleted after
|
||||
explicit user confirmation.
|
||||
|
||||
## AI Rules
|
||||
|
||||
@@ -94,10 +116,5 @@ Normal users cannot view or modify these rules in the initial version.
|
||||
|
||||
## Initial Boundary
|
||||
|
||||
Suno remains responsible for music generation and playback. The macOS
|
||||
app opens the browser-based Suno Create page in Custom mode and fills
|
||||
the supported title, lyrics and style fields only. It never triggers
|
||||
Generate. If Suno changes its interface and a field cannot be filled,
|
||||
the app reports that field and keeps the approved text available to
|
||||
copy. Generated songs do not need to return to the app in the initial
|
||||
version.
|
||||
The macOS app prepares and stores the final lyrics and style prompt only.
|
||||
It does not open, embed or control an external music-generation website.
|
||||
|
||||
+20
-18
@@ -70,7 +70,7 @@ requirement is missing and blocks implementation, record it in
|
||||
- [x] Add Arabic-specific settings UI.
|
||||
- [x] Support diacritics/harakat/tanween processing.
|
||||
- [x] Preserve intentional spelling/dialect choices where possible.
|
||||
- [x] Allow user to compare/edit processed Arabic before Suno handoff.
|
||||
- [x] Allow user to compare/edit processed Arabic before final review.
|
||||
- [x] Add Arabic test fixtures covering multiple dialects.
|
||||
|
||||
## Phase 6 --- Instrument Selector & Catalog
|
||||
@@ -105,31 +105,33 @@ requirement is missing and blocks implementation, record it in
|
||||
- [x] Validate output before handoff.
|
||||
- [x] Keep compiler independent from UI and OpenAI client.
|
||||
|
||||
## Phase 8 --- Suno Handoff
|
||||
## Removed --- External Generation Handoff
|
||||
|
||||
- [x] Implement explicit `Send to Suno` action.
|
||||
- [x] Open the appropriate Suno creation surface.
|
||||
- [x] Fill supported fields with approved project output.
|
||||
- [x] Never trigger Generate automatically.
|
||||
- [x] Detect/report when fields cannot be filled rather than silently
|
||||
failing.
|
||||
- [x] Keep integration isolated because Suno UI can change.
|
||||
- [x] Removed opening, embedding and controlling external
|
||||
music-generation websites.
|
||||
|
||||
## Phase 9 --- Quality
|
||||
|
||||
- [ ] Add validation for contradictory/invalid project settings.
|
||||
- [ ] Add loading, offline and provider-error states.
|
||||
- [ ] Add accessibility labels and keyboard navigation.
|
||||
- [ ] Test project/version recovery.
|
||||
- [ ] Test AI output against locked user choices.
|
||||
- [ ] Test selected instruments influence compiled song-generation
|
||||
- [x] Add validation for contradictory/invalid project settings.
|
||||
- [x] Add loading, offline and provider-error states.
|
||||
- [x] Add accessibility labels and keyboard navigation.
|
||||
- [x] Test project/version recovery.
|
||||
- [x] Test AI output against locked user choices.
|
||||
- [x] Test selected instruments influence compiled song-generation
|
||||
context.
|
||||
- [ ] Test Suno handoff without generation.
|
||||
|
||||
## Definition of MVP Done
|
||||
|
||||
A user can create a macOS song project through chat, manually or
|
||||
automatically configure the agreed song parameters, generate/edit lyrics
|
||||
and song planning through OpenAI, select instruments, review
|
||||
the complete project, save versions, and send the approved lyrics/style
|
||||
data to Suno where the app fills fields without initiating generation.
|
||||
the complete project, save versions, and retain the approved final
|
||||
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.
|
||||
- [x] Add a vocalist preset selector with checkbox multi-select.
|
||||
- [x] Add a language selector with checkbox multi-select.
|
||||
- [x] Refine the macOS visual hierarchy for projects, chat and inspector.
|
||||
|
||||
Reference in New Issue
Block a user