Compare commits

...
16 Commits
33 changed files with 2001 additions and 521 deletions
+3 -2
View File
@@ -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
View File
@@ -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.
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.