Compare commits

...
46 Commits
Author SHA1 Message Date
diyaa f9af5c21d8 Refine macOS app visual hierarchy 2026-09-16 00:18:32 +02:00
diyaa c8242a844a Add language catalog selector 2026-09-15 23:20:48 +02:00
diyaa b43c5416f2 Add vocalist preset selector 2026-09-15 19:07:55 +02:00
diyaa 297b791a3e Add worldwide genre style selector 2026-09-15 18:49:34 +02:00
diyaa 03bfd3ef75 Add confirmed project deletion 2026-09-15 13:06:27 +02:00
diyaa bef00b19e1 Fix musical parameter auto mode switching 2026-09-15 12:54:38 +02:00
diyaa 0307df9442 Align agent guidance with external handoff removal 2026-09-15 12:50:05 +02:00
diyaa 2d07ee70d8 Correct final review terminology 2026-09-15 12:46:34 +02:00
diyaa 2145732b66 Record instrument generation context coverage 2026-09-15 12:40:28 +02:00
diyaa 9b4db4413b Record locked AI output coverage 2026-09-15 12:39:49 +02:00
diyaa fac99e2c2d Test project version recovery 2026-09-15 12:38:56 +02:00
diyaa 6023214cb3 Add keyboard navigation shortcuts 2026-09-15 12:37:11 +02:00
diyaa f199bcf114 Add AI request error states 2026-09-15 12:36:41 +02:00
diyaa f42d782629 Validate SongProject consistency 2026-09-15 12:34:48 +02:00
diyaa 1ebb5aadc3 Remove external music generation handoff 2026-09-15 12:16:08 +02:00
diyaa cd066d0a82 Embed Suno handoff web surface 2026-09-14 22:51:16 +02:00
diyaa 3ed4046d73 Use app-managed Suno web surface 2026-09-14 22:46:38 +02:00
diyaa de0b8e5972 Add Suno field copy fallback 2026-09-14 22:43:18 +02:00
diyaa d7901ef9d7 Avoid repeated accessibility prompts 2026-09-14 22:39:35 +02:00
diyaa 52978a375d Target the Suno browser for field filling 2026-09-14 22:35:12 +02:00
diyaa 3fced12bef Fill Suno fields with accessibility adapter 2026-09-14 22:29:53 +02:00
diyaa 448d390671 Add explicit Suno handoff action 2026-09-14 22:22:38 +02:00
diyaa 61267e201e Document Suno browser handoff decision 2026-09-14 22:20:22 +02:00
diyaa e4b899d83b Document prompt compiler boundary 2026-09-14 22:08:58 +02:00
diyaa c7640470c6 Validate compiled Suno output 2026-09-14 22:08:04 +02:00
diyaa 9e6e94e7cb Compile complete Suno style prompts 2026-09-14 22:06:32 +02:00
diyaa 4fa477bdb0 Add section performance directives 2026-09-14 22:04:35 +02:00
diyaa 7bf4df79f9 Persist selections and compile song output 2026-09-14 21:56:26 +02:00
diyaa 35703f89a8 Test instrument selector persistence 2026-09-14 21:52:07 +02:00
diyaa 2b5ba2b535 Confirm manual arrangement behavior 2026-09-14 21:47:07 +02:00
diyaa 04d4093a3c Add instrument checkbox selection 2026-09-14 06:20:07 +02:00
diyaa 6c6aeae5bb Add instrument region filtering 2026-09-14 01:08:30 +02:00
diyaa 9ddac746e5 Add instrument family filtering 2026-09-14 01:04:44 +02:00
diyaa ecbf07d1ea Add searchable instrument browser 2026-09-14 00:55:41 +02:00
diyaa b2bf04e78b Confirm catalog data separation 2026-09-14 00:31:11 +02:00
diyaa b51102b65d Add worldwide instrument catalog 2026-09-13 23:52:07 +02:00
diyaa e7a46772ec Test instrument catalog metadata 2026-09-13 23:37:48 +02:00
diyaa 9a0dd452bd Add Arabic dialect test fixtures 2026-09-13 23:22:49 +02:00
diyaa 6a99266e75 Add Arabic lyrics review 2026-09-13 22:42:50 +02:00
diyaa 3865f77dc0 Preserve Arabic spelling choices 2026-09-13 22:33:57 +02:00
diyaa f5ab97e6a0 Add Arabic pronunciation processing 2026-09-13 21:44:42 +02:00
diyaa 49a03a0700 Add Arabic pronunciation settings UI 2026-09-13 21:31:15 +02:00
diyaa eb61bd076c Add resilient AI service handling 2026-09-13 21:28:13 +02:00
diyaa ae88c6e533 Enforce user locks for AI updates 2026-09-13 21:20:57 +02:00
diyaa a39bdbe6fc Implement optional discussion mode 2026-09-13 21:13:52 +02:00
diyaa bf1ccbe8a9 Implement automatic song project decisions 2026-09-13 20:59:25 +02:00
67 changed files with 5673 additions and 247 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.
@@ -0,0 +1,108 @@
import MusicAssistantCore
import SwiftUI
struct ArabicLyricsReviewView: View {
@Binding var project: SongProject
private let processor = ArabicPronunciationProcessor()
@State private var processedLyrics = ""
@State private var processingNotes: [ArabicPronunciationProcessingNote] = []
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Arabic lyrics review")
.font(.headline)
Spacer()
Button(action: refreshProcessedLyrics) {
Label("Refresh processed lyrics", systemImage: "arrow.clockwise")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.help("Refresh processed lyrics")
}
Text("Original lyrics")
.font(.subheadline)
.foregroundStyle(.secondary)
TextEditor(text: .constant(project.lyrics.text))
.font(.body)
.frame(minHeight: 96)
.disabled(true)
Text("Processed lyrics")
.font(.subheadline)
.foregroundStyle(.secondary)
TextEditor(text: $processedLyrics)
.font(.body)
.frame(minHeight: 96)
ForEach(Array(processingNotes.enumerated()), id: \.offset) { entry in
Label(entry.element.title, systemImage: entry.element.systemImage)
.font(.caption)
.foregroundStyle(.secondary)
}
Button(action: applyProcessedLyrics) {
Label("Use processed lyrics", systemImage: "checkmark.circle")
}
.disabled(!canApplyProcessedLyrics)
}
.onAppear(perform: refreshProcessedLyrics)
}
private var canApplyProcessedLyrics: Bool {
let trimmedLyrics = processedLyrics.trimmingCharacters(in: .whitespacesAndNewlines)
return !trimmedLyrics.isEmpty && processedLyrics != project.lyrics.text
}
private func refreshProcessedLyrics() {
let result = processor.process(
lyrics: project.lyrics.text,
settings: project.arabicPronunciationSettings
)
processedLyrics = result.text
processingNotes = result.notes
}
private func applyProcessedLyrics() {
project.applyReviewedArabicLyrics(processedLyrics)
}
}
private extension ArabicPronunciationProcessingNote {
var title: String {
switch self {
case .processingDisabled:
return "Arabic processing is disabled"
case .diacritizationPolicyUnspecified:
return "Select a diacritics policy to continue"
case .pronunciationTargetedDiacriticsNeedReview:
return "Targeted diacritics need review"
case .fullTashkeelNeedsReview:
return "Full tashkeel needs review"
case .tanweenAdditionNeedsReview:
return "Tanween addition needs review"
case let .preservedSpellingsProtected(count):
return "Protected \(count) preserved spelling\(count == 1 ? "" : "s")"
}
}
var systemImage: String {
switch self {
case .processingDisabled, .diacritizationPolicyUnspecified:
return "info.circle"
case .pronunciationTargetedDiacriticsNeedReview,
.fullTashkeelNeedsReview,
.tanweenAdditionNeedsReview:
return "exclamationmark.triangle"
case .preservedSpellingsProtected:
return "checkmark.shield"
}
}
}
@@ -0,0 +1,91 @@
import MusicAssistantCore
import SwiftUI
struct ArabicPronunciationSettingsView: View {
@Binding var settings: ArabicPronunciationSettings
@State private var newPreservedSpelling = ""
var body: some View {
Toggle("Enable Arabic pronunciation processing", isOn: $settings.isEnabled)
Picker("Diacritics", selection: $settings.diacritizationPolicy) {
Text("Unspecified").tag(ArabicDiacritizationPolicy.unspecified)
Text("Pronunciation targeted").tag(ArabicDiacritizationPolicy.pronunciationTargeted)
Text("Full tashkeel").tag(ArabicDiacritizationPolicy.fullTashkeel)
}
.disabled(!settings.isEnabled)
Picker("Tanween", selection: $settings.tanweenPolicy) {
Text("Unspecified").tag(ArabicTanweenPolicy.unspecified)
Text("Preserve user input").tag(ArabicTanweenPolicy.preserveUserInput)
Text("Add when pronunciation needs it").tag(ArabicTanweenPolicy.addWhenPronunciationRequires)
Text("Remove when unwanted").tag(ArabicTanweenPolicy.removeWhenUnwanted)
}
.disabled(!settings.isEnabled)
TextField("Pronunciation notes", text: userNotesBinding, axis: .vertical)
.lineLimit(2...4)
.disabled(!settings.isEnabled)
if settings.preservedSpellings.isEmpty {
Text("No preserved spellings")
.foregroundStyle(.secondary)
} else {
ForEach(settings.preservedSpellings.indices, id: \.self) { index in
HStack {
Text(settings.preservedSpellings[index])
.lineLimit(1)
Spacer()
Button {
settings.preservedSpellings.remove(at: index)
} label: {
Label("Remove preserved spelling", systemImage: "minus.circle")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.help("Remove preserved spelling")
}
}
}
HStack {
TextField("Preserve spelling", text: $newPreservedSpelling)
.onSubmit(addPreservedSpelling)
Button(action: addPreservedSpelling) {
Label("Add preserved spelling", systemImage: "plus.circle.fill")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.disabled(trimmedNewPreservedSpelling.isEmpty)
.help("Add preserved spelling")
}
.disabled(!settings.isEnabled)
}
private var userNotesBinding: Binding<String> {
Binding {
settings.userNotes ?? ""
} set: { newValue in
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
settings.userNotes = trimmedValue.isEmpty ? nil : trimmedValue
}
}
private var trimmedNewPreservedSpelling: String {
newPreservedSpelling.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func addPreservedSpelling() {
let spelling = trimmedNewPreservedSpelling
guard !spelling.isEmpty, !settings.preservedSpellings.contains(spelling) else {
return
}
settings.preservedSpellings.append(spelling)
newPreservedSpelling = ""
}
}
@@ -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))
}
}
@@ -9,6 +9,10 @@ struct FinalReviewView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ArabicLyricsReviewView(project: $project)
Divider()
TextField("Approved lyrics", text: $project.lyrics.text, axis: .vertical)
.lineLimit(4...10)
@@ -27,7 +31,7 @@ struct FinalReviewView: View {
Divider()
HStack {
Text("Suno fields")
Text("Final song output")
.font(.headline)
Spacer()
@@ -38,11 +42,12 @@ struct FinalReviewView: View {
Label("Prepare fields", systemImage: "wand.and.stars")
}
.disabled(project.sunoOutput != nil)
}
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 {
@@ -152,12 +157,16 @@ struct FinalReviewView: View {
private func ensureSunoOutput() {
guard project.sunoOutput == nil else { return }
let compiledOutput = (try? SongProjectPromptCompiler().compile(project: project))
?? CompiledSunoOutput(lyricsText: project.lyrics.text, stylePrompt: "")
project.sunoOutput = SunoOutput(
lyricsText: project.lyrics.text,
stylePrompt: "",
lyricsText: compiledOutput.lyricsText,
stylePrompt: compiledOutput.stylePrompt,
generatedAt: Date()
)
}
}
#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,154 @@
import MusicAssistantCore
import SwiftUI
struct InstrumentBrowserView: View {
@Binding private var project: SongProject
private let saveProject: (SongProject) async -> Bool
private let catalog: LocalInstrumentCatalog
@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: LocalInstrumentCatalog = LocalInstrumentCatalog()
) {
_project = project
self.saveProject = saveProject
self.catalog = catalog
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
HStack {
Picker("Family", selection: $selectedFamilyCategory) {
Text("All families").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.instruments(
matching: searchText,
familyCategory: selectedFamilyCategory,
regionOrigin: selectedRegionOrigin
),
id: \.id
) { instrument in
Toggle(isOn: selectionBinding(for: instrument)) {
VStack(alignment: .leading, spacing: 4) {
Text(instrument.name)
.font(.body.weight(.medium))
HStack(spacing: 6) {
Text(instrument.familyCategory)
if let regionOrigin = instrument.regionOrigin {
Text(regionOrigin)
}
}
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.toggleStyle(.checkbox)
.padding(.vertical, 2)
}
}
.navigationTitle("Instrument Catalog")
.searchable(text: $searchText, prompt: "Search instruments")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
Task {
await saveSelectionChangesIfNeeded()
dismiss()
}
}
.disabled(isSaving)
}
if isSaving {
ToolbarItem(placement: .status) {
ProgressView()
.controlSize(.small)
}
}
}
}
.frame(minWidth: 460, minHeight: 520)
.onDisappear {
guard hasUnsavedSelectionChanges, !isSaving else { return }
let projectToSave = project
hasUnsavedSelectionChanges = false
Task {
_ = await saveProject(projectToSave)
}
}
}
private func selectionBinding(for instrument: InstrumentCatalogItem) -> Binding<Bool> {
Binding {
project.isInstrumentSelected(id: instrument.id)
} set: { isSelected in
project.setInstrumentSelected(
id: instrument.id,
isSelected: isSelected,
variant: instrument.name
)
hasUnsavedSelectionChanges = true
}
}
private func saveSelectionChangesIfNeeded() async {
guard hasUnsavedSelectionChanges else { return }
isSaving = true
let didSave = await saveProject(project)
hasUnsavedSelectionChanges = !didSave
isSaving = false
}
}
#Preview {
InstrumentBrowserPreview()
}
private struct InstrumentBrowserPreview: View {
@State private var project = SongProject(
title: "Instrument Browser Preview",
idea: "Browse the instrument catalog."
)
var body: some View {
InstrumentBrowserView(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,18 +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()
@@ -27,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()
@@ -38,25 +53,37 @@ struct ProjectInspectorView: View {
TextField("Title", text: $project.title)
TextField("Idea", text: $project.idea, axis: .vertical)
.lineLimit(2...4)
Picker("AI mode", selection: $project.conversationMode) {
Text("Auto").tag(ConversationMode.auto)
Text("Discuss").tag(ConversationMode.discuss)
}
.pickerStyle(.segmented)
TextField("Duration seconds", text: durationSecondsBinding)
TextField("Duration note", text: durationDescriptionBinding)
}
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") {
Button {
isInstrumentBrowserPresented = true
} label: {
Label("Browse catalog", systemImage: "magnifyingglass")
}
ForEach($project.instruments, id: \.instrumentId) { $instrument in
Toggle(isOn: $instrument.selected) {
Text(instrument.variant?.isEmpty == false ? instrument.variant ?? instrument.instrumentId : instrument.instrumentId)
@@ -71,30 +98,38 @@ 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") {
ArabicPronunciationSettingsView(
settings: $project.arabicPronunciationSettings
)
}
@@ -127,6 +162,18 @@ struct ProjectInspectorView: View {
}
}
.background(Color(nsColor: .windowBackgroundColor))
.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> {
@@ -150,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 }
@@ -164,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."
)
}
@@ -16,11 +16,14 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
public var key: ManualAutoValue<String>?
public var scale: ManualAutoValue<String>?
public var maqam: ManualAutoValue<String>?
public var structureMode: ControlMode?
public var sections: [SongSection]
public var instruments: [InstrumentTrack]
public var vocalists: [Vocalist]
public var lyrics: Lyrics
public var productionDirections: [ProductionDirection]
public var productionMode: ControlMode?
public var userLocks: SongProjectUserLocks?
public var sunoOutput: SunoOutput?
public var versions: [SongVersion]
public let createdAt: Date
@@ -42,11 +45,14 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
key: ManualAutoValue<String>? = nil,
scale: ManualAutoValue<String>? = nil,
maqam: ManualAutoValue<String>? = nil,
structureMode: ControlMode? = .auto,
sections: [SongSection] = [],
instruments: [InstrumentTrack] = [],
vocalists: [Vocalist] = [],
lyrics: Lyrics = Lyrics(),
productionDirections: [ProductionDirection] = [],
productionMode: ControlMode? = .auto,
userLocks: SongProjectUserLocks? = SongProjectUserLocks(),
sunoOutput: SunoOutput? = nil,
versions: [SongVersion] = [],
createdAt: Date = Date(),
@@ -67,11 +73,14 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
self.key = key
self.scale = scale
self.maqam = maqam
self.structureMode = structureMode
self.sections = sections
self.instruments = instruments
self.vocalists = vocalists
self.lyrics = lyrics
self.productionDirections = productionDirections
self.productionMode = productionMode
self.userLocks = userLocks
self.sunoOutput = sunoOutput
self.versions = versions
self.createdAt = createdAt
@@ -80,6 +89,42 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
}
public extension SongProject {
var isStructureAutomatic: Bool {
structureMode != .manual
}
var isProductionAutomatic: Bool {
productionMode != .manual
}
mutating func setStructureMode(_ mode: ControlMode) {
structureMode = mode
}
mutating func setProductionMode(_ mode: ControlMode) {
productionMode = mode
}
var lockedUpdateScopes: [SongProjectUpdateScope] {
userLocks?.scopes ?? []
}
func isUpdateScopeLocked(_ scope: SongProjectUpdateScope) -> Bool {
userLocks?.contains(scope) ?? false
}
mutating func lockUpdateScope(_ scope: SongProjectUpdateScope) {
var userLocks = userLocks ?? SongProjectUserLocks()
userLocks.insert(scope)
self.userLocks = userLocks
}
mutating func unlockUpdateScope(_ scope: SongProjectUpdateScope) {
guard var userLocks else { return }
userLocks.remove(scope)
self.userLocks = userLocks
}
var orderedSections: [SongSection] {
sections
.enumerated()
@@ -154,6 +199,15 @@ public extension SongProject {
dialects.removeAll { $0.id == dialectID }
}
mutating func applyReviewedArabicLyrics(_ reviewedLyrics: String) {
let originalLyrics = lyrics.text
lyrics.text = reviewedLyrics
if sunoOutput?.lyricsText == originalLyrics {
sunoOutput?.lyricsText = reviewedLyrics
}
}
mutating func setBPMManually(_ value: Int) {
bpm = ManualAutoValue(mode: .manual, value: value)
}
@@ -258,6 +312,33 @@ public extension SongProject {
instruments[index].selected = false
}
mutating func setInstrumentSelected(
id instrumentId: String,
isSelected: Bool,
variant: String? = nil
) {
guard isSelected else {
deselectInstrument(id: instrumentId)
return
}
if let index = instruments.firstIndex(where: { $0.instrumentId == instrumentId }) {
instruments[index].selected = true
if instruments[index].variant == nil {
instruments[index].variant = variant
}
} else {
instruments.append(
InstrumentTrack(
instrumentId: instrumentId,
selected: true,
variant: variant
)
)
}
}
mutating func configureInstrument(
id instrumentId: String,
variant: String?,
@@ -364,11 +445,14 @@ public extension SongProject {
key = snapshot.key
scale = snapshot.scale
maqam = snapshot.maqam
structureMode = snapshot.structureMode
sections = snapshot.sections
instruments = snapshot.instruments
vocalists = snapshot.vocalists
lyrics = snapshot.lyrics
productionDirections = snapshot.productionDirections
productionMode = snapshot.productionMode
userLocks = snapshot.userLocks
sunoOutput = snapshot.sunoOutput
self.updatedAt = updatedAt
}
@@ -451,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
@@ -510,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
@@ -686,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
@@ -764,11 +947,14 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
public var key: ManualAutoValue<String>?
public var scale: ManualAutoValue<String>?
public var maqam: ManualAutoValue<String>?
public var structureMode: ControlMode?
public var sections: [SongSection]
public var instruments: [InstrumentTrack]
public var vocalists: [Vocalist]
public var lyrics: Lyrics
public var productionDirections: [ProductionDirection]
public var productionMode: ControlMode?
public var userLocks: SongProjectUserLocks?
public var sunoOutput: SunoOutput?
public init(
@@ -786,11 +972,14 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
key: ManualAutoValue<String>? = nil,
scale: ManualAutoValue<String>? = nil,
maqam: ManualAutoValue<String>? = nil,
structureMode: ControlMode? = .auto,
sections: [SongSection] = [],
instruments: [InstrumentTrack] = [],
vocalists: [Vocalist] = [],
lyrics: Lyrics = Lyrics(),
productionDirections: [ProductionDirection] = [],
productionMode: ControlMode? = .auto,
userLocks: SongProjectUserLocks? = SongProjectUserLocks(),
sunoOutput: SunoOutput? = nil
) {
self.title = title
@@ -807,11 +996,14 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
self.key = key
self.scale = scale
self.maqam = maqam
self.structureMode = structureMode
self.sections = sections
self.instruments = instruments
self.vocalists = vocalists
self.lyrics = lyrics
self.productionDirections = productionDirections
self.productionMode = productionMode
self.userLocks = userLocks
self.sunoOutput = sunoOutput
}
@@ -831,11 +1023,14 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
key: project.key,
scale: project.scale,
maqam: project.maqam,
structureMode: project.structureMode,
sections: project.sections,
instruments: project.instruments,
vocalists: project.vocalists,
lyrics: project.lyrics,
productionDirections: project.productionDirections,
productionMode: project.productionMode,
userLocks: project.userLocks,
sunoOutput: project.sunoOutput
)
}
@@ -0,0 +1,42 @@
import Foundation
public enum SongProjectUpdateScope: String, CaseIterable, Codable, Equatable, Hashable, Sendable {
case duration
case genres
case instruments
case vocals
case languages
case lyrics
case structure
case arrangement
case musicalParameters
case emotionalArc
case productionDirections
case sunoOutput
}
public struct SongProjectUserLocks: Codable, Equatable, Sendable {
public var scopes: [SongProjectUpdateScope]
public init(scopes: [SongProjectUpdateScope] = []) {
self.scopes = Self.uniqueScopes(scopes)
}
public func contains(_ scope: SongProjectUpdateScope) -> Bool {
scopes.contains(scope)
}
public mutating func insert(_ scope: SongProjectUpdateScope) {
guard !contains(scope) else { return }
scopes.append(scope)
}
public mutating func remove(_ scope: SongProjectUpdateScope) {
scopes.removeAll { $0 == scope }
}
private static func uniqueScopes(_ scopes: [SongProjectUpdateScope]) -> [SongProjectUpdateScope] {
var seen = Set<SongProjectUpdateScope>()
return scopes.filter { seen.insert($0).inserted }
}
}
@@ -49,6 +49,13 @@ public protocol OpenAIClientAdapter: Sendable {
func decodeProjectGenerationResult(from data: Data) throws -> SongProjectGenerationResult
func makeSongProjectDiscussionRequest(
_ request: SongProjectDiscussionRequest,
configuration: OpenAIClientConfiguration
) throws -> OpenAIClientRequest
func decodeSongProjectDiscussionResult(from data: Data) throws -> SongProjectDiscussionResult
func makeLyricsRevisionRequest(
_ request: LyricsRevisionRequest,
configuration: OpenAIClientConfiguration
@@ -99,6 +106,12 @@ public final class OpenAIClient: AIService, Sendable {
return try adapter.decodeProjectGenerationResult(from: data)
}
public func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
let clientRequest = try adapter.makeSongProjectDiscussionRequest(request, configuration: configuration)
let data = try await perform(clientRequest)
return try adapter.decodeSongProjectDiscussionResult(from: data)
}
public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
let clientRequest = try adapter.makeLyricsRevisionRequest(request, configuration: configuration)
let data = try await perform(clientRequest)
@@ -112,6 +125,8 @@ public final class OpenAIClient: AIService, Sendable {
}
private func perform(_ clientRequest: OpenAIClientRequest) async throws -> Data {
try Task.checkCancellation()
guard !configuration.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw OpenAIClientError.missingAPIKey
}
@@ -136,15 +151,30 @@ public final class OpenAIClient: AIService, Sendable {
let (data, response) = try await transport.data(for: request)
guard (200..<300).contains(response.statusCode) else {
if response.statusCode == 429 {
throw OpenAIClientError.rateLimited(
retryAfter: Self.retryAfterInterval(from: response)
)
}
if (500..<600).contains(response.statusCode) {
throw OpenAIClientError.serverError(response.statusCode)
}
throw OpenAIClientError.unacceptableStatusCode(response.statusCode)
}
return data
}
private static func retryAfterInterval(from response: HTTPURLResponse) -> TimeInterval? {
guard let value = response.value(forHTTPHeaderField: "Retry-After") else { return nil }
return TimeInterval(value.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
public enum OpenAIClientError: Error, Equatable, Sendable {
case missingAPIKey
case invalidResponse
case rateLimited(retryAfter: TimeInterval?)
case serverError(Int)
case unacceptableStatusCode(Int)
}
@@ -1,3 +0,0 @@
public protocol SunoHandoffService: Sendable {
func handOff(_ output: CompiledSunoOutput) async throws
}
@@ -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
}
}
@@ -2,10 +2,21 @@ import Foundation
public protocol AIService: Sendable {
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult
}
public extension AIService {
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
throw AIServiceCapabilityError.discussionNotSupported
}
}
public enum AIServiceCapabilityError: Error, Equatable, Sendable {
case discussionNotSupported
}
public struct AIConversationMessage: Equatable, Identifiable, Sendable {
public let id: String
public var role: AIConversationRole
@@ -54,15 +65,20 @@ public struct SongProjectGenerationRequest: Equatable, Sendable {
public var context: AIRequestContext
public var seedProject: SongProject?
public var discussionMode: ConversationMode
public var songGenerationContext: SongGenerationContext
public init(
context: AIRequestContext,
seedProject: SongProject? = nil,
discussionMode: ConversationMode = .auto
discussionMode: ConversationMode = .auto,
songGenerationContext: SongGenerationContext? = nil
) {
self.context = context
self.seedProject = seedProject
self.discussionMode = discussionMode
self.songGenerationContext = songGenerationContext
?? seedProject.map(SongGenerationContext.init(project:))
?? .empty
}
}
@@ -82,22 +98,51 @@ public struct SongProjectGenerationResult: Equatable, Sendable {
}
}
public struct SongProjectDiscussionRequest: Equatable, Sendable {
public var context: AIRequestContext
public var project: SongProject
public var songGenerationContext: SongGenerationContext
public init(
context: AIRequestContext,
project: SongProject,
songGenerationContext: SongGenerationContext? = nil
) {
self.context = context
self.project = project
self.songGenerationContext = songGenerationContext ?? SongGenerationContext(project: project)
}
}
public struct SongProjectDiscussionResult: Equatable, Sendable {
public var questions: [String]
public var notes: [String]
public init(questions: [String], notes: [String] = []) {
self.questions = questions
self.notes = notes
}
}
public struct LyricsRevisionRequest: Equatable, Sendable {
public var context: AIRequestContext
public var project: SongProject
public var sourceLyrics: Lyrics
public var mode: LyricsRevisionMode
public var songGenerationContext: SongGenerationContext
public init(
context: AIRequestContext,
project: SongProject,
sourceLyrics: Lyrics,
mode: LyricsRevisionMode
mode: LyricsRevisionMode,
songGenerationContext: SongGenerationContext? = nil
) {
self.context = context
self.project = project
self.sourceLyrics = sourceLyrics
self.mode = mode
self.songGenerationContext = songGenerationContext ?? SongGenerationContext(project: project)
}
}
@@ -123,33 +168,21 @@ public struct SongProjectUpdateRequest: Equatable, Sendable {
public var context: AIRequestContext
public var project: SongProject
public var allowedScopes: [SongProjectUpdateScope]
public var songGenerationContext: SongGenerationContext
public init(
context: AIRequestContext,
project: SongProject,
allowedScopes: [SongProjectUpdateScope]
allowedScopes: [SongProjectUpdateScope],
songGenerationContext: SongGenerationContext? = nil
) {
self.context = context
self.project = project
self.allowedScopes = allowedScopes
self.songGenerationContext = songGenerationContext ?? SongGenerationContext(project: project)
}
}
public enum SongProjectUpdateScope: String, Codable, Equatable, Sendable {
case duration
case genres
case instruments
case vocals
case languages
case lyrics
case structure
case arrangement
case musicalParameters
case emotionalArc
case productionDirections
case sunoOutput
}
public struct SongProjectUpdateResult: Equatable, Sendable {
public var project: SongProject
public var changedScopes: [SongProjectUpdateScope]
@@ -0,0 +1,124 @@
import Foundation
public struct AIServiceRetryPolicy: Equatable, Sendable {
public static let `default` = AIServiceRetryPolicy()
public var maxAttempts: Int
public var baseDelay: TimeInterval
public var maximumDelay: TimeInterval
public init(
maxAttempts: Int = 3,
baseDelay: TimeInterval = 0.5,
maximumDelay: TimeInterval = 8
) {
self.maxAttempts = max(1, maxAttempts)
self.baseDelay = max(0, baseDelay)
self.maximumDelay = max(0, maximumDelay)
}
func delay(forFailedAttempt attempt: Int, retryAfter: TimeInterval? = nil) -> TimeInterval {
if let retryAfter {
return max(0, retryAfter)
}
let multiplier = pow(2, Double(max(0, attempt - 1)))
return min(maximumDelay, baseDelay * multiplier)
}
}
public protocol AIServiceRetryScheduler: Sendable {
func sleep(for interval: TimeInterval) async throws
}
public struct TaskAIServiceRetryScheduler: AIServiceRetryScheduler {
public init() {}
public func sleep(for interval: TimeInterval) async throws {
guard interval > 0 else { return }
let maximumInterval = TimeInterval(UInt64.max / 1_000_000_000)
let clampedInterval = min(interval, maximumInterval)
let nanoseconds = UInt64((clampedInterval * 1_000_000_000).rounded())
try await Task.sleep(nanoseconds: nanoseconds)
}
}
public final class RetryingAIService: AIService, Sendable {
private let baseService: any AIService
private let policy: AIServiceRetryPolicy
private let scheduler: any AIServiceRetryScheduler
public init(
baseService: any AIService,
policy: AIServiceRetryPolicy = .default,
scheduler: any AIServiceRetryScheduler = TaskAIServiceRetryScheduler()
) {
self.baseService = baseService
self.policy = policy
self.scheduler = scheduler
}
public func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
try await perform { try await self.baseService.generateSongProject(from: request) }
}
public func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
try await perform { try await self.baseService.discussSongProject(from: request) }
}
public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
try await perform { try await self.baseService.reviseLyrics(from: request) }
}
public func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
try await perform { try await self.baseService.proposeProjectUpdate(from: request) }
}
private func perform<Value: Sendable>(
_ operation: @escaping @Sendable () async throws -> Value
) async throws -> Value {
var attempt = 0
while true {
try Task.checkCancellation()
attempt += 1
do {
return try await operation()
} catch is CancellationError {
throw CancellationError()
} catch {
guard attempt < policy.maxAttempts,
let delay = retryDelay(for: error, failedAttempt: attempt) else {
throw error
}
try await scheduler.sleep(for: delay)
}
}
}
private func retryDelay(for error: Error, failedAttempt attempt: Int) -> TimeInterval? {
if let error = error as? OpenAIClientError {
switch error {
case let .rateLimited(retryAfter):
return policy.delay(forFailedAttempt: attempt, retryAfter: retryAfter)
case .serverError:
return policy.delay(forFailedAttempt: attempt)
case .missingAPIKey, .invalidResponse, .unacceptableStatusCode:
return nil
}
}
if let error = error as? URLError {
switch error.code {
case .timedOut, .networkConnectionLost, .notConnectedToInternet, .cannotConnectToHost, .cannotFindHost, .dnsLookupFailed:
return policy.delay(forFailedAttempt: attempt)
default:
return nil
}
}
return nil
}
}
@@ -45,6 +45,10 @@ public final class ApplicationRuleInjectingAIService: AIService, Sendable {
try await baseService.generateSongProject(from: requestWithInjectedRules(request))
}
public func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
try await baseService.discussSongProject(from: requestWithInjectedRules(request))
}
public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
try await baseService.reviseLyrics(from: requestWithInjectedRules(request))
}
@@ -59,6 +63,12 @@ public final class ApplicationRuleInjectingAIService: AIService, Sendable {
return request
}
private func requestWithInjectedRules(_ request: SongProjectDiscussionRequest) throws -> SongProjectDiscussionRequest {
var request = request
request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules())
return request
}
private func requestWithInjectedRules(_ request: LyricsRevisionRequest) throws -> LyricsRevisionRequest {
var request = request
request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules())
@@ -0,0 +1,236 @@
import Foundation
public final class AutoSongProjectDecisionsDirector: Sendable {
private let aiService: any AIService
public init(aiService: any AIService) {
self.aiService = aiService
}
public func applyAutomaticDecisions(
to project: SongProject,
instruction: String? = nil,
conversation: [AIConversationMessage] = [],
localeIdentifier: String? = nil,
updatedAt: Date = Date()
) async throws -> SongProjectUpdateResult {
let allowedScopes = automaticScopes(for: project)
guard !allowedScopes.isEmpty else {
return SongProjectUpdateResult(
project: project,
notes: ["No automatic decisions are enabled."]
)
}
let trimmedInstruction = instruction?.trimmingCharacters(in: .whitespacesAndNewlines)
let request = SongProjectUpdateRequest(
context: AIRequestContext(
userInstruction: trimmedInstruction?.isEmpty == false
? trimmedInstruction!
: "Complete the enabled automatic song-project decisions.",
conversation: conversation,
localeIdentifier: localeIdentifier
),
project: project,
allowedScopes: allowedScopes
)
let result = try await aiService.proposeProjectUpdate(from: request)
var updatedProject = mergeAutomaticDecisions(
from: result.project,
into: project
)
updatedProject.updatedAt = updatedAt
return SongProjectUpdateResult(
project: updatedProject,
changedScopes: changedScopes(from: project, to: updatedProject),
notes: result.notes,
followUpQuestions: result.followUpQuestions
)
}
private func automaticScopes(for project: SongProject) -> [SongProjectUpdateScope] {
var scopes: [SongProjectUpdateScope] = []
if project.isStructureAutomatic && !project.isUpdateScopeLocked(.structure) {
scopes.append(.structure)
}
if !project.isUpdateScopeLocked(.arrangement) &&
project.instruments.contains(where: { $0.selected && $0.autoArrangementEnabled }) {
scopes.append(.arrangement)
}
if !project.isUpdateScopeLocked(.musicalParameters) &&
(project.bpm?.mode != .manual ||
project.key?.mode != .manual ||
project.scale?.mode != .manual ||
project.maqam?.mode != .manual) {
scopes.append(.musicalParameters)
}
if project.isProductionAutomatic && !project.isUpdateScopeLocked(.productionDirections) {
scopes.append(.productionDirections)
}
return scopes
}
private func mergeAutomaticDecisions(
from proposedProject: SongProject,
into project: SongProject
) -> SongProject {
var updatedProject = project
if project.isStructureAutomatic {
updatedProject.sections = proposedProject.sections
}
updatedProject.instruments = mergedInstruments(
current: project.instruments,
proposed: proposedProject.instruments
)
updatedProject.bpm = automaticValue(current: project.bpm, proposed: proposedProject.bpm)
updatedProject.key = automaticValue(current: project.key, proposed: proposedProject.key)
updatedProject.scale = automaticValue(current: project.scale, proposed: proposedProject.scale)
updatedProject.maqam = automaticValue(current: project.maqam, proposed: proposedProject.maqam)
if project.isProductionAutomatic {
updatedProject.productionDirections = proposedProject.productionDirections
updatedProject.sections = sections(
updatedProject.sections,
applyingProductionDirectionsFrom: proposedProject.sections
)
} else {
updatedProject.sections = sections(
updatedProject.sections,
preservingProductionDirectionsFrom: project.sections
)
}
return updatedProject
}
private func mergedInstruments(
current: [InstrumentTrack],
proposed: [InstrumentTrack]
) -> [InstrumentTrack] {
var proposedByID: [String: InstrumentTrack] = [:]
for proposedTrack in proposed {
proposedByID[proposedTrack.instrumentId] = proposedTrack
}
return current.map { currentTrack in
guard currentTrack.selected,
currentTrack.autoArrangementEnabled,
let proposedTrack = proposedByID[currentTrack.instrumentId]
else {
return currentTrack
}
var mergedTrack = currentTrack
mergedTrack.playingStyle = proposedTrack.playingStyle
mergedTrack.role = proposedTrack.role
mergedTrack.placements = proposedTrack.placements
return mergedTrack
}
}
private func automaticValue<Value>(
current: ManualAutoValue<Value>?,
proposed: ManualAutoValue<Value>?
) -> ManualAutoValue<Value>? where Value: Codable & Equatable & Sendable {
guard current?.mode != .manual, let proposed else {
return current
}
return ManualAutoValue(mode: .auto, value: proposed.value)
}
private func sections(
_ current: [SongSection],
applyingProductionDirectionsFrom proposed: [SongSection]
) -> [SongSection] {
var proposedByID: [String: SongSection] = [:]
for proposedSection in proposed {
proposedByID[proposedSection.id] = proposedSection
}
return current.map { section in
guard let proposedSection = proposedByID[section.id] else { return section }
var updatedSection = section
updatedSection.productionDirection = proposedSection.productionDirection
return updatedSection
}
}
private func sections(
_ current: [SongSection],
preservingProductionDirectionsFrom existing: [SongSection]
) -> [SongSection] {
var existingByID: [String: SongSection] = [:]
for existingSection in existing {
existingByID[existingSection.id] = existingSection
}
return current.map { section in
var updatedSection = section
updatedSection.productionDirection = existingByID[section.id]?.productionDirection
return updatedSection
}
}
private func changedScopes(
from project: SongProject,
to updatedProject: SongProject
) -> [SongProjectUpdateScope] {
var scopes: [SongProjectUpdateScope] = []
if project.isStructureAutomatic &&
sectionsDifferIgnoringProductionDirections(project.sections, updatedProject.sections) {
scopes.append(.structure)
}
if project.instruments != updatedProject.instruments {
scopes.append(.arrangement)
}
if project.bpm != updatedProject.bpm ||
project.key != updatedProject.key ||
project.scale != updatedProject.scale ||
project.maqam != updatedProject.maqam {
scopes.append(.musicalParameters)
}
if project.isProductionAutomatic &&
(project.productionDirections != updatedProject.productionDirections ||
productionDirectionsDiffer(in: project.sections, and: updatedProject.sections)) {
scopes.append(.productionDirections)
}
return scopes
}
private func sectionsDifferIgnoringProductionDirections(
_ current: [SongSection],
_ updated: [SongSection]
) -> Bool {
guard current.count == updated.count else { return true }
return zip(current, updated).contains { currentSection, updatedSection in
var currentWithoutProduction = currentSection
var updatedWithoutProduction = updatedSection
currentWithoutProduction.productionDirection = nil
updatedWithoutProduction.productionDirection = nil
return currentWithoutProduction != updatedWithoutProduction
}
}
private func productionDirectionsDiffer(
in current: [SongSection],
and updated: [SongSection]
) -> Bool {
guard current.count == updated.count else { return true }
return zip(current, updated).contains { currentSection, updatedSection in
currentSection.productionDirection != updatedSection.productionDirection
}
}
}
@@ -30,6 +30,10 @@ public final class ExistingLyricsRevisionDirector: Sendable {
throw ExistingLyricsRevisionDirectorError.emptySourceLyrics
}
guard !project.isUpdateScopeLocked(.lyrics) else {
return SongProjectUpdateResult(project: project)
}
let request = LyricsRevisionRequest(
context: AIRequestContext(
userInstruction: trimmedInstruction,
@@ -42,14 +46,14 @@ public final class ExistingLyricsRevisionDirector: Sendable {
)
let result = try await aiService.reviseLyrics(from: request)
var updatedProject = project
updatedProject.lyrics = result.lyrics
updatedProject.updatedAt = revisedAt
return SongProjectUpdateResult(
project: updatedProject,
var proposedProject = project
proposedProject.lyrics = result.lyrics
return SongProjectAIUpdateMerger().apply(
snapshot: SongProjectSnapshot(project: proposedProject),
changedScopes: [.lyrics],
notes: result.notes
notes: result.notes,
to: project,
updatedAt: revisedAt
)
}
}
@@ -0,0 +1,251 @@
import Foundation
public struct SongProjectAIUpdateMerger: Sendable {
public init() {}
public func apply(
snapshot: SongProjectSnapshot,
changedScopes: [SongProjectUpdateScope],
notes: [String] = [],
followUpQuestions: [String] = [],
to existingProject: SongProject,
updatedAt: Date = Date()
) -> SongProjectUpdateResult {
var updatedProject = existingProject
var appliedScopes: [SongProjectUpdateScope] = []
for scope in uniqueScopes(changedScopes) where !existingProject.isUpdateScopeLocked(scope) {
let projectBeforeScope = updatedProject
apply(scope, snapshot: snapshot, to: &updatedProject)
if didChange(scope, from: projectBeforeScope, to: updatedProject) {
appliedScopes.append(scope)
}
}
updatedProject.updatedAt = updatedAt
return SongProjectUpdateResult(
project: updatedProject,
changedScopes: appliedScopes,
notes: notes,
followUpQuestions: followUpQuestions
)
}
private func apply(
_ scope: SongProjectUpdateScope,
snapshot: SongProjectSnapshot,
to project: inout SongProject
) {
switch scope {
case .duration:
project.duration = snapshot.duration
case .genres:
project.genres = snapshot.genres
case .instruments:
project.instruments = mergedInstrumentSelections(
current: project.instruments,
proposed: snapshot.instruments
)
case .vocals:
project.vocalists = snapshot.vocalists
case .languages:
project.languages = snapshot.languages
project.dialects = snapshot.dialects
project.arabicPronunciationSettings = snapshot.arabicPronunciationSettings
case .lyrics:
project.lyrics = snapshot.lyrics
case .structure:
guard project.isStructureAutomatic else { return }
project.sections = sections(
snapshot.sections,
preservingProductionDirectionsFrom: project.sections
)
case .arrangement:
project.instruments = mergedArrangement(
current: project.instruments,
proposed: snapshot.instruments
)
case .musicalParameters:
project.bpm = automaticValue(current: project.bpm, proposed: snapshot.bpm)
project.key = automaticValue(current: project.key, proposed: snapshot.key)
project.scale = automaticValue(current: project.scale, proposed: snapshot.scale)
project.maqam = automaticValue(current: project.maqam, proposed: snapshot.maqam)
case .emotionalArc:
project.emotionalArc = snapshot.emotionalArc
case .productionDirections:
guard project.isProductionAutomatic else { return }
project.productionDirections = snapshot.productionDirections
project.sections = sections(
project.sections,
applyingProductionDirectionsFrom: snapshot.sections
)
case .sunoOutput:
project.sunoOutput = snapshot.sunoOutput
}
}
private func mergedInstrumentSelections(
current: [InstrumentTrack],
proposed: [InstrumentTrack]
) -> [InstrumentTrack] {
var proposedByID: [String: InstrumentTrack] = [:]
for proposedTrack in proposed {
proposedByID[proposedTrack.instrumentId] = proposedTrack
}
let mergedTracks = current.map { currentTrack in
guard currentTrack.autoArrangementEnabled,
let proposedTrack = proposedByID[currentTrack.instrumentId]
else {
return currentTrack
}
var mergedTrack = currentTrack
mergedTrack.selected = proposedTrack.selected
mergedTrack.variant = proposedTrack.variant
return mergedTrack
}
var knownInstrumentIDs = Set(current.map(\.instrumentId))
let newTracks = proposed.filter { knownInstrumentIDs.insert($0.instrumentId).inserted }
return mergedTracks + newTracks
}
private func mergedArrangement(
current: [InstrumentTrack],
proposed: [InstrumentTrack]
) -> [InstrumentTrack] {
var proposedByID: [String: InstrumentTrack] = [:]
for proposedTrack in proposed {
proposedByID[proposedTrack.instrumentId] = proposedTrack
}
return current.map { currentTrack in
guard currentTrack.selected,
currentTrack.autoArrangementEnabled,
let proposedTrack = proposedByID[currentTrack.instrumentId]
else {
return currentTrack
}
var mergedTrack = currentTrack
mergedTrack.playingStyle = proposedTrack.playingStyle
mergedTrack.role = proposedTrack.role
mergedTrack.placements = proposedTrack.placements
return mergedTrack
}
}
private func automaticValue<Value>(
current: ManualAutoValue<Value>?,
proposed: ManualAutoValue<Value>?
) -> ManualAutoValue<Value>? where Value: Codable & Equatable & Sendable {
guard current?.mode != .manual, let proposed else {
return current
}
return ManualAutoValue(mode: .auto, value: proposed.value)
}
private func sections(
_ current: [SongSection],
applyingProductionDirectionsFrom proposed: [SongSection]
) -> [SongSection] {
var proposedByID: [String: SongSection] = [:]
for proposedSection in proposed {
proposedByID[proposedSection.id] = proposedSection
}
return current.map { section in
guard let proposedSection = proposedByID[section.id] else { return section }
var updatedSection = section
updatedSection.productionDirection = proposedSection.productionDirection
return updatedSection
}
}
private func sections(
_ proposed: [SongSection],
preservingProductionDirectionsFrom current: [SongSection]
) -> [SongSection] {
var currentByID: [String: SongSection] = [:]
for currentSection in current {
currentByID[currentSection.id] = currentSection
}
return proposed.map { section in
var updatedSection = section
updatedSection.productionDirection = currentByID[section.id]?.productionDirection
return updatedSection
}
}
private func didChange(
_ scope: SongProjectUpdateScope,
from current: SongProject,
to updated: SongProject
) -> Bool {
switch scope {
case .duration:
return current.duration != updated.duration
case .genres:
return current.genres != updated.genres
case .instruments, .arrangement:
return current.instruments != updated.instruments
case .vocals:
return current.vocalists != updated.vocalists
case .languages:
return current.languages != updated.languages ||
current.dialects != updated.dialects ||
current.arabicPronunciationSettings != updated.arabicPronunciationSettings
case .lyrics:
return current.lyrics != updated.lyrics
case .structure:
return sectionsDifferIgnoringProductionDirections(current.sections, updated.sections)
case .musicalParameters:
return current.bpm != updated.bpm ||
current.key != updated.key ||
current.scale != updated.scale ||
current.maqam != updated.maqam
case .emotionalArc:
return current.emotionalArc != updated.emotionalArc
case .productionDirections:
return current.productionDirections != updated.productionDirections ||
productionDirectionsDiffer(in: current.sections, and: updated.sections)
case .sunoOutput:
return current.sunoOutput != updated.sunoOutput
}
}
private func uniqueScopes(_ scopes: [SongProjectUpdateScope]) -> [SongProjectUpdateScope] {
var seen = Set<SongProjectUpdateScope>()
return scopes.filter { seen.insert($0).inserted }
}
private func sectionsDifferIgnoringProductionDirections(
_ current: [SongSection],
_ updated: [SongSection]
) -> Bool {
guard current.count == updated.count else { return true }
return zip(current, updated).contains { currentSection, updatedSection in
var currentWithoutProduction = currentSection
var updatedWithoutProduction = updatedSection
currentWithoutProduction.productionDirection = nil
updatedWithoutProduction.productionDirection = nil
return currentWithoutProduction != updatedWithoutProduction
}
}
private func productionDirectionsDiffer(
in current: [SongSection],
and updated: [SongSection]
) -> Bool {
guard current.count == updated.count else { return true }
return zip(current, updated).contains { currentSection, updatedSection in
currentSection.productionDirection != updatedSection.productionDirection
}
}
}
@@ -0,0 +1,48 @@
import Foundation
public final class SongProjectDiscussionDirector: Sendable {
private let aiService: any AIService
public init(aiService: any AIService) {
self.aiService = aiService
}
public func discuss(
project: SongProject,
userMessage: String,
conversation: [AIConversationMessage] = [],
localeIdentifier: String? = nil
) async throws -> SongProjectDiscussionResult {
guard project.conversationMode == .discuss else {
throw SongProjectDiscussionDirectorError.discussionModeNotEnabled
}
let trimmedMessage = userMessage.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedMessage.isEmpty else {
throw SongProjectDiscussionDirectorError.emptyUserMessage
}
let result = try await aiService.discussSongProject(
from: SongProjectDiscussionRequest(
context: AIRequestContext(
userInstruction: trimmedMessage,
conversation: conversation,
localeIdentifier: localeIdentifier
),
project: project
)
)
return SongProjectDiscussionResult(
questions: result.questions
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty },
notes: result.notes
)
}
}
public enum SongProjectDiscussionDirectorError: Error, Equatable, Sendable {
case discussionModeNotEnabled
case emptyUserMessage
}
@@ -64,32 +64,18 @@ public final class SongProjectGenerationDirector: Sendable {
return project
}
return SongProject(
id: seedProject.id,
title: generatedProject.title,
idea: normalizedIdea,
duration: generatedProject.duration,
conversationMode: discussionMode,
languages: generatedProject.languages,
dialects: generatedProject.dialects,
arabicPronunciationSettings: generatedProject.arabicPronunciationSettings,
genres: generatedProject.genres,
moods: generatedProject.moods,
emotionalArc: generatedProject.emotionalArc,
bpm: generatedProject.bpm,
key: generatedProject.key,
scale: generatedProject.scale,
maqam: generatedProject.maqam,
sections: generatedProject.sections,
instruments: generatedProject.instruments,
vocalists: generatedProject.vocalists,
lyrics: generatedProject.lyrics,
productionDirections: generatedProject.productionDirections,
sunoOutput: generatedProject.sunoOutput,
versions: seedProject.versions,
createdAt: seedProject.createdAt,
let update = SongProjectAIUpdateMerger().apply(
snapshot: SongProjectSnapshot(project: generatedProject),
changedScopes: SongProjectUpdateScope.allCases,
to: seedProject,
updatedAt: generatedAt
)
var project = update.project
project.title = generatedProject.title
project.idea = normalizedIdea
project.conversationMode = discussionMode
project.moods = generatedProject.moods
return project
}
}
@@ -24,14 +24,13 @@ public struct StructuredSongProjectUpdateResponse: Codable, Equatable, Sendable
updating existingProject: SongProject,
updatedAt: Date = Date()
) -> SongProjectUpdateResult {
var updatedProject = existingProject
updatedProject.restoreSnapshot(project, updatedAt: updatedAt)
return SongProjectUpdateResult(
project: updatedProject,
SongProjectAIUpdateMerger().apply(
snapshot: project,
changedScopes: changedScopes,
notes: notes,
followUpQuestions: followUpQuestions
followUpQuestions: followUpQuestions,
to: existingProject,
updatedAt: updatedAt
)
}
}
@@ -0,0 +1,204 @@
import Foundation
public struct ArabicPronunciationProcessor: Sendable {
public init() {}
public func process(
lyrics: String,
settings: ArabicPronunciationSettings
) -> ArabicPronunciationProcessingResult {
guard settings.isEnabled else {
return ArabicPronunciationProcessingResult(
text: lyrics,
notes: [.processingDisabled]
)
}
let protection = protectPreservedSpellings(
in: lyrics,
preservedSpellings: settings.preservedSpellings
)
var processedText = protection.text.precomposedStringWithCanonicalMapping
var notes = processingNotes(for: processedText, settings: settings)
if settings.tanweenPolicy == .removeWhenUnwanted {
processedText = removeTanween(from: processedText)
}
if settings.tanweenPolicy == .addWhenPronunciationRequires,
containsArabicLetter(in: processedText),
!containsTanween(in: processedText) {
notes.append(.tanweenAdditionNeedsReview)
}
processedText = restorePreservedSpellings(
in: processedText,
replacements: protection.replacements
)
if protection.matchCount > 0 {
notes.append(.preservedSpellingsProtected(count: protection.matchCount))
}
return ArabicPronunciationProcessingResult(
text: processedText,
notes: notes
)
}
private func processingNotes(
for text: String,
settings: ArabicPronunciationSettings
) -> [ArabicPronunciationProcessingNote] {
guard containsArabicLetter(in: text) else {
return []
}
switch settings.diacritizationPolicy {
case .unspecified:
return [.diacritizationPolicyUnspecified]
case .pronunciationTargeted where !containsDiacritics(in: text):
return [.pronunciationTargetedDiacriticsNeedReview]
case .fullTashkeel where !containsDiacritics(in: text):
return [.fullTashkeelNeedsReview]
default:
return []
}
}
private func protectPreservedSpellings(
in text: String,
preservedSpellings: [String]
) -> PreservedSpellingProtection {
let spellings = Set(
preservedSpellings.map {
$0.trimmingCharacters(in: .whitespacesAndNewlines)
}
)
.filter { !$0.isEmpty }
.sorted {
if $0.count == $1.count {
return $0 < $1
}
return $0.count > $1.count
}
var protectedText = text
var replacements: [PreservedSpellingReplacement] = []
var matchCount = 0
for (index, spelling) in spellings.enumerated() {
let occurrenceCount = protectedText.components(separatedBy: spelling).count - 1
guard occurrenceCount > 0 else {
continue
}
let placeholder = uniquePlaceholder(
for: index,
in: protectedText
)
protectedText = protectedText.replacingOccurrences(
of: spelling,
with: placeholder
)
replacements.append(
PreservedSpellingReplacement(
placeholder: placeholder,
spelling: spelling
)
)
matchCount += occurrenceCount
}
return PreservedSpellingProtection(
text: protectedText,
replacements: replacements,
matchCount: matchCount
)
}
private func uniquePlaceholder(for index: Int, in text: String) -> String {
var collisionIndex = 0
var placeholder = "[[music-assistant-preserved-\(index)]]"
while text.contains(placeholder) {
collisionIndex += 1
placeholder = "[[music-assistant-preserved-\(index)-\(collisionIndex)]]"
}
return placeholder
}
private func restorePreservedSpellings(
in text: String,
replacements: [PreservedSpellingReplacement]
) -> String {
replacements.reduce(text) { result, replacement in
result.replacingOccurrences(
of: replacement.placeholder,
with: replacement.spelling
)
}
}
private func removeTanween(from text: String) -> String {
String(text.unicodeScalars.filter { scalar in
!isTanween(scalar)
})
}
private func containsArabicLetter(in text: String) -> Bool {
text.unicodeScalars.contains { scalar in
let isArabicBlock = (0x0600...0x06FF).contains(scalar.value)
|| (0x0750...0x077F).contains(scalar.value)
|| (0x08A0...0x08FF).contains(scalar.value)
return isArabicBlock && CharacterSet.letters.contains(scalar)
}
}
private func containsDiacritics(in text: String) -> Bool {
text.unicodeScalars.contains(where: isArabicDiacritic)
}
private func containsTanween(in text: String) -> Bool {
text.unicodeScalars.contains(where: isTanween)
}
private func isArabicDiacritic(_ scalar: Unicode.Scalar) -> Bool {
(0x064B...0x065F).contains(scalar.value) || scalar.value == 0x0670
}
private func isTanween(_ scalar: Unicode.Scalar) -> Bool {
(0x064B...0x064D).contains(scalar.value)
}
}
private struct PreservedSpellingProtection {
let text: String
let replacements: [PreservedSpellingReplacement]
let matchCount: Int
}
private struct PreservedSpellingReplacement {
let placeholder: String
let spelling: String
}
public struct ArabicPronunciationProcessingResult: Equatable, Sendable {
public var text: String
public var notes: [ArabicPronunciationProcessingNote]
public init(text: String, notes: [ArabicPronunciationProcessingNote] = []) {
self.text = text
self.notes = notes
}
}
public enum ArabicPronunciationProcessingNote: Equatable, Sendable {
case processingDisabled
case diacritizationPolicyUnspecified
case pronunciationTargetedDiacriticsNeedReview
case fullTashkeelNeedsReview
case tanweenAdditionNeedsReview
case preservedSpellingsProtected(count: Int)
}
@@ -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,336 @@
import Foundation
public struct LocalInstrumentCatalog: Sendable {
public let items: [InstrumentCatalogItem]
public init(items: [InstrumentCatalogItem] = LocalInstrumentCatalog.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 instruments(
matching query: String,
familyCategory: String? = nil,
regionOrigin: String? = nil
) -> [InstrumentCatalogItem] {
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: [InstrumentCatalogItem] =
western + middleEastern + african + southAsian + eastAsian + southeastAsian + latinAmerican + folkAndModern
private static let western: [InstrumentCatalogItem] = [
item("piano", "Piano", "Keyboard", "Western"),
item("upright-piano", "Upright Piano", "Keyboard", "Western"),
item("grand-piano", "Grand Piano", "Keyboard", "Western"),
item("electric-piano", "Electric Piano", "Keyboard", "Western"),
item("harpsichord", "Harpsichord", "Keyboard", "Western"),
item("celesta", "Celesta", "Keyboard", "Western"),
item("pipe-organ", "Pipe Organ", "Keyboard", "Western"),
item("electric-organ", "Electric Organ", "Keyboard", "Western"),
item("accordion", "Accordion", "Free reed", "Europe"),
item("concertina", "Concertina", "Free reed", "Europe"),
item("melodica", "Melodica", "Free reed", "Western"),
item("violin", "Violin", "Bowed strings", "Western", aliases: ["fiddle"]),
item("viola", "Viola", "Bowed strings", "Western"),
item("cello", "Cello", "Bowed strings", "Western", aliases: ["violoncello"]),
item("double-bass", "Double Bass", "Bowed strings", "Western", aliases: ["upright bass"]),
item("concert-harp", "Concert Harp", "Plucked strings", "Western"),
item("classical-guitar", "Classical Guitar", "Plucked strings", "Western", aliases: ["nylon-string guitar"]),
item("acoustic-guitar", "Acoustic Guitar", "Plucked strings", "Western"),
item("electric-guitar", "Electric Guitar", "Plucked strings", "Western"),
item("bass-guitar", "Bass Guitar", "Plucked strings", "Western"),
item("twelve-string-guitar", "Twelve-string Guitar", "Plucked strings", "Western"),
item("mandolin", "Mandolin", "Plucked strings", "Europe"),
item("banjo", "Banjo", "Plucked strings", "North America"),
item("ukulele", "Ukulele", "Plucked strings", "Oceania"),
item("resonator-guitar", "Resonator Guitar", "Plucked strings", "North America", aliases: ["dobro"]),
item("lap-steel-guitar", "Lap Steel Guitar", "Plucked strings", "North America"),
item("pedal-steel-guitar", "Pedal Steel Guitar", "Plucked strings", "North America"),
item("flute", "Concert Flute", "Woodwinds", "Western", aliases: ["flute"]),
item("piccolo", "Piccolo", "Woodwinds", "Western"),
item("recorder", "Recorder", "Woodwinds", "Europe"),
item("oboe", "Oboe", "Woodwinds", "Western"),
item("english-horn", "English Horn", "Woodwinds", "Western", aliases: ["cor anglais"]),
item("bassoon", "Bassoon", "Woodwinds", "Western"),
item("contrabassoon", "Contrabassoon", "Woodwinds", "Western"),
item("clarinet", "Clarinet", "Woodwinds", "Western"),
item("bass-clarinet", "Bass Clarinet", "Woodwinds", "Western"),
item("soprano-saxophone", "Soprano Saxophone", "Woodwinds", "Western", aliases: ["soprano sax"]),
item("alto-saxophone", "Alto Saxophone", "Woodwinds", "Western", aliases: ["alto sax"]),
item("tenor-saxophone", "Tenor Saxophone", "Woodwinds", "Western", aliases: ["tenor sax"]),
item("baritone-saxophone", "Baritone Saxophone", "Woodwinds", "Western", aliases: ["baritone sax"]),
item("trumpet", "Trumpet", "Brass", "Western"),
item("cornet", "Cornet", "Brass", "Western"),
item("flugelhorn", "Flugelhorn", "Brass", "Western"),
item("trombone", "Trombone", "Brass", "Western"),
item("bass-trombone", "Bass Trombone", "Brass", "Western"),
item("french-horn", "French Horn", "Brass", "Western", aliases: ["horn"]),
item("tuba", "Tuba", "Brass", "Western"),
item("euphonium", "Euphonium", "Brass", "Western"),
item("sousaphone", "Sousaphone", "Brass", "North America"),
item("drum-kit", "Drum Kit", "Percussion", "Western", aliases: ["drum set"]),
item("snare-drum", "Snare Drum", "Percussion", "Western"),
item("bass-drum", "Bass Drum", "Percussion", "Western", aliases: ["kick drum"]),
item("timpani", "Timpani", "Percussion", "Western", aliases: ["kettle drums"]),
item("xylophone", "Xylophone", "Mallet percussion", "Western"),
item("marimba", "Marimba", "Mallet percussion", "Central America"),
item("vibraphone", "Vibraphone", "Mallet percussion", "Western", aliases: ["vibes"]),
item("glockenspiel", "Glockenspiel", "Mallet percussion", "Europe"),
item("tubular-bells", "Tubular Bells", "Mallet percussion", "Western", aliases: ["orchestral chimes"]),
item("triangle", "Triangle", "Percussion", "Western"),
item("tambourine", "Tambourine", "Frame drums", "Western"),
item("cowbell", "Cowbell", "Percussion", "Western"),
item("cymbals", "Cymbals", "Percussion", "Western"),
item("gong", "Gong", "Percussion", "East Asia"),
item("castanets", "Castanets", "Percussion", "Spain"),
item("claves", "Claves", "Percussion", "Latin America"),
item("harmonica", "Harmonica", "Free reed", "Western", aliases: ["mouth organ"])
]
private static let middleEastern: [InstrumentCatalogItem] = [
item("oud", "Oud", "Plucked strings", "Middle East", aliases: ["ud"], terms: ["عود"]),
item("qanun", "Qanun", "Zithers", "Middle East", aliases: ["kanun"], terms: ["قانون"]),
item("ney", "Ney", "End-blown flutes", "Middle East", aliases: ["nai"], terms: ["ناي"]),
item("daf", "Daf", "Frame drums", "Middle East", terms: ["دف"]),
item("riq", "Riq", "Frame drums", "Middle East", aliases: ["riqq"], terms: ["رق"]),
item("darbuka", "Darbuka", "Goblet drums", "Middle East", aliases: ["doumbek", "tabla"], terms: ["دربكة"]),
item("kamancheh", "Kamancheh", "Bowed strings", "Middle East", aliases: ["kamanche"]),
item("santur", "Santur", "Hammered zithers", "Middle East"),
item("baglama", "Baglama", "Plucked strings", "Middle East", aliases: ["saz"]),
item("buzuq", "Buzuq", "Plucked strings", "Middle East", aliases: ["bozuk"]),
item("mijwiz", "Mijwiz", "Double reeds", "Middle East"),
item("arghul", "Arghul", "Double reeds", "Middle East"),
item("zurna", "Zurna", "Double reeds", "Middle East"),
item("naqqara", "Naqqara", "Kettle drums", "Middle East"),
item("tombak", "Tombak", "Goblet drums", "Middle East", aliases: ["zarb"]),
item("setar", "Setar", "Plucked strings", "Middle East"),
item("tar", "Tar", "Plucked strings", "Middle East"),
item("duduk", "Duduk", "Double reeds", "Middle East"),
item("kemence", "Kemenche", "Bowed strings", "Middle East"),
item("saz-bagpipes", "Tulum", "Bagpipes", "Middle East")
]
private static let african: [InstrumentCatalogItem] = [
item("djembe", "Djembe", "Hand drums", "West Africa"),
item("talking-drum", "Talking Drum", "Tension drums", "West Africa", aliases: ["dundun"]),
item("kora", "Kora", "Harps", "West Africa"),
item("balafon", "Balafon", "Mallet percussion", "West Africa"),
item("mbira", "Mbira", "Lamellophones", "Southern Africa", aliases: ["thumb piano"]),
item("kalimba", "Kalimba", "Lamellophones", "Africa"),
item("shekere", "Shekere", "Shakers", "West Africa"),
item("udu", "Udu", "Clay drums", "West Africa"),
item("ngoni", "Ngoni", "Plucked strings", "West Africa"),
item("xalam", "Xalam", "Plucked strings", "West Africa"),
item("dunun", "Dunun", "Bass drums", "West Africa"),
item("ashiko", "Ashiko", "Hand drums", "West Africa"),
item("marovany", "Marovany", "Box zithers", "Madagascar"),
item("endongo", "Endongo", "Harps", "East Africa"),
item("masenqo", "Masenqo", "Bowed strings", "East Africa"),
item("krin", "Krin", "Slit drums", "West Africa"),
item("agogo", "Agogo Bells", "Percussion", "West Africa"),
item("goje", "Goje", "Bowed strings", "West Africa"),
item("adungu", "Adungu", "Harps", "East Africa"),
item("ekwe", "Ekwe", "Slit drums", "West Africa")
]
private static let southAsian: [InstrumentCatalogItem] = [
item("sitar", "Sitar", "Plucked strings", "South Asia"),
item("tabla", "Tabla", "Hand drums", "South Asia"),
item("tanpura", "Tanpura", "Drone strings", "South Asia", aliases: ["tambura"]),
item("sarod", "Sarod", "Plucked strings", "South Asia"),
item("bansuri", "Bansuri", "Side-blown flutes", "South Asia"),
item("shehnai", "Shehnai", "Double reeds", "South Asia"),
item("santoor", "Santoor", "Hammered zithers", "South Asia"),
item("harmonium", "Harmonium", "Free reed", "South Asia"),
item("mridangam", "Mridangam", "Hand drums", "South Asia"),
item("veena", "Veena", "Plucked strings", "South Asia"),
item("sarangi", "Sarangi", "Bowed strings", "South Asia"),
item("dholak", "Dholak", "Hand drums", "South Asia"),
item("dhol", "Dhol", "Bass drums", "South Asia"),
item("ghatam", "Ghatam", "Clay drums", "South Asia"),
item("kanjira", "Kanjira", "Frame drums", "South Asia"),
item("nadaswaram", "Nadaswaram", "Double reeds", "South Asia"),
item("esraj", "Esraj", "Bowed strings", "South Asia"),
item("pakhawaj", "Pakhawaj", "Hand drums", "South Asia"),
item("khol", "Khol", "Hand drums", "South Asia"),
item("ektara", "Ektara", "Drone strings", "South Asia"),
item("rabab", "Rabab", "Plucked strings", "South Asia"),
item("morsing", "Morsing", "Jaw harps", "South Asia"),
item("nagada", "Nagada", "Kettle drums", "South Asia")
]
private static let eastAsian: [InstrumentCatalogItem] = [
item("erhu", "Erhu", "Bowed strings", "East Asia"),
item("guzheng", "Guzheng", "Zithers", "East Asia", aliases: ["Chinese zither"]),
item("pipa", "Pipa", "Plucked strings", "East Asia"),
item("dizi", "Dizi", "Side-blown flutes", "East Asia"),
item("xiao", "Xiao", "End-blown flutes", "East Asia"),
item("suona", "Suona", "Double reeds", "East Asia"),
item("sheng", "Sheng", "Mouth organs", "East Asia"),
item("yangqin", "Yangqin", "Hammered zithers", "East Asia"),
item("ruan", "Ruan", "Plucked strings", "East Asia"),
item("guqin", "Guqin", "Zithers", "East Asia"),
item("liuqin", "Liuqin", "Plucked strings", "East Asia"),
item("hulusi", "Hulusi", "Free reed", "East Asia"),
item("morin-khuur", "Morin Khuur", "Bowed strings", "Central Asia", aliases: ["horsehead fiddle"]),
item("koto", "Koto", "Zithers", "Japan"),
item("shamisen", "Shamisen", "Plucked strings", "Japan"),
item("shakuhachi", "Shakuhachi", "End-blown flutes", "Japan"),
item("biwa", "Biwa", "Plucked strings", "Japan"),
item("taiko", "Taiko", "Drums", "Japan"),
item("hichiriki", "Hichiriki", "Double reeds", "Japan"),
item("ryuteki", "Ryuteki", "Side-blown flutes", "Japan"),
item("gayageum", "Gayageum", "Zithers", "Korea"),
item("geomungo", "Geomungo", "Zithers", "Korea"),
item("haegeum", "Haegeum", "Bowed strings", "Korea"),
item("daegeum", "Daegeum", "Side-blown flutes", "Korea"),
item("janggu", "Janggu", "Hourglass drums", "Korea"),
item("piri", "Piri", "Double reeds", "Korea"),
item("ajaeng", "Ajaeng", "Bowed strings", "Korea"),
item("bawu", "Bawu", "Free reed", "East Asia"),
item("konghou", "Konghou", "Harps", "East Asia")
]
private static let southeastAsian: [InstrumentCatalogItem] = [
item("angklung", "Angklung", "Shaken idiophones", "Southeast Asia"),
item("gamelan", "Gamelan", "Metallophones", "Southeast Asia"),
item("kendang", "Kendang", "Hand drums", "Southeast Asia"),
item("suling", "Suling", "Side-blown flutes", "Southeast Asia"),
item("rebab", "Rebab", "Bowed strings", "Southeast Asia"),
item("kulintang", "Kulintang", "Gong chimes", "Southeast Asia"),
item("agung", "Agung", "Gongs", "Southeast Asia"),
item("khene", "Khene", "Mouth organs", "Southeast Asia"),
item("ranat-ek", "Ranat Ek", "Mallet percussion", "Southeast Asia"),
item("saw-duang", "Saw Duang", "Bowed strings", "Southeast Asia"),
item("tro", "Tro", "Bowed strings", "Southeast Asia"),
item("saung-gauk", "Saung Gauk", "Harps", "Southeast Asia"),
item("pat-waing", "Pat Waing", "Drums", "Southeast Asia"),
item("rondalla", "Rondalla", "Plucked strings", "Southeast Asia"),
item("bandurria", "Bandurria", "Plucked strings", "Southeast Asia"),
item("tinikling-bamboo", "Bamboo Percussion", "Percussion", "Southeast Asia"),
item("sape", "Sape", "Plucked strings", "Southeast Asia"),
item("serunai", "Serunai", "Double reeds", "Southeast Asia")
]
private static let latinAmerican: [InstrumentCatalogItem] = [
item("conga", "Conga", "Hand drums", "Latin America"),
item("bongo", "Bongo", "Hand drums", "Latin America"),
item("timbales", "Timbales", "Drums", "Latin America"),
item("cajon", "Cajon", "Box drums", "Latin America"),
item("guiro", "Guiro", "Scrapers", "Latin America"),
item("maracas", "Maracas", "Shakers", "Latin America"),
item("cuica", "Cuica", "Friction drums", "Latin America"),
item("berimbau", "Berimbau", "Musical bows", "Brazil"),
item("bandoneon", "Bandoneon", "Free reed", "South America"),
item("charango", "Charango", "Plucked strings", "Andes"),
item("cuatro", "Cuatro", "Plucked strings", "Latin America"),
item("tres", "Tres", "Plucked strings", "Cuba"),
item("vihuela", "Vihuela", "Plucked strings", "Mexico"),
item("requinto", "Requinto", "Plucked strings", "Latin America"),
item("zampogna", "Zampona", "Panpipes", "Andes", aliases: ["siku"]),
item("quena", "Quena", "End-blown flutes", "Andes"),
item("bombo-leguero", "Bombo Leguero", "Bass drums", "South America"),
item("surdo", "Surdo", "Bass drums", "Brazil"),
item("pandeiro", "Pandeiro", "Frame drums", "Brazil"),
item("candombe-drums", "Candombe Drums", "Hand drums", "South America"),
item("tambora", "Tambora", "Bass drums", "Caribbean"),
item("guira", "Guira", "Scrapers", "Caribbean"),
item("bata-drums", "Bata Drums", "Hand drums", "Caribbean"),
item("arpa-llanera", "Arpa Llanera", "Harps", "Latin America"),
item("marimbola", "Marimbola", "Lamellophones", "Caribbean"),
item("tiple", "Tiple", "Plucked strings", "South America")
]
private static let folkAndModern: [InstrumentCatalogItem] = [
item("bagpipes", "Bagpipes", "Bagpipes", "Europe"),
item("tin-whistle", "Tin Whistle", "Folk winds", "Ireland", aliases: ["penny whistle"]),
item("bodhran", "Bodhran", "Frame drums", "Ireland"),
item("dulcimer", "Appalachian Dulcimer", "Folk strings", "North America"),
item("hammered-dulcimer", "Hammered Dulcimer", "Hammered zithers", "Europe"),
item("hurdy-gurdy", "Hurdy-gurdy", "Folk strings", "Europe"),
item("nyckelharpa", "Nyckelharpa", "Bowed strings", "Europe"),
item("balalaika", "Balalaika", "Plucked strings", "Europe"),
item("domra", "Domra", "Plucked strings", "Europe"),
item("bouzouki", "Bouzouki", "Plucked strings", "Greece"),
item("cimbalom", "Cimbalom", "Hammered zithers", "Europe"),
item("jaw-harp", "Jaw Harp", "Jaw harps", "Folk", aliases: ["mouth harp"]),
item("didgeridoo", "Didgeridoo", "Aerophones", "Oceania"),
item("clapsticks", "Clapsticks", "Percussion", "Oceania"),
item("native-american-flute", "Native American Flute", "End-blown flutes", "North America"),
item("fiddle", "Fiddle", "Folk strings", "Folk"),
item("washboards", "Washboard", "Percussion", "North America"),
item("musical-saw", "Musical Saw", "Folk instruments", "Folk"),
item("theremin", "Theremin", "Electronic", "Modern"),
item("modular-synthesizer", "Modular Synthesizer", "Electronic", "Modern", aliases: ["modular synth"]),
item("synthesizer", "Synthesizer", "Electronic", "Modern", aliases: ["synth"]),
item("drum-machine", "Drum Machine", "Electronic", "Modern"),
item("sampler", "Sampler", "Electronic", "Modern"),
item("turntables", "Turntables", "Electronic", "Modern", aliases: ["DJ decks"]),
item("midi-controller", "MIDI Controller", "Electronic", "Modern"),
item("electric-violin", "Electric Violin", "Electronic strings", "Modern"),
item("electric-cello", "Electric Cello", "Electronic strings", "Modern"),
item("electronic-drum-pad", "Electronic Drum Pad", "Electronic percussion", "Modern"),
item("vocoder", "Vocoder", "Electronic", "Modern"),
item("keytar", "Keytar", "Electronic", "Modern")
]
private static func item(
_ id: String,
_ name: String,
_ familyCategory: String,
_ regionOrigin: String? = nil,
aliases: [String] = [],
terms: [String] = []
) -> InstrumentCatalogItem {
InstrumentCatalogItem(
id: id,
name: name,
familyCategory: familyCategory,
regionOrigin: regionOrigin,
aliases: aliases,
searchTerms: terms
)
}
private static func searchableTerms(for item: InstrumentCatalogItem) -> [String] {
searchTokens(for: [item.name] + 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)
}
}
@@ -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)
}
}
@@ -13,3 +13,159 @@ public struct CompiledSunoOutput: Equatable, Sendable {
self.stylePrompt = stylePrompt
}
}
public struct SongProjectPromptCompiler: PromptCompiling {
public init() {}
public func compile(project: SongProject) throws -> CompiledSunoOutput {
CompiledSunoOutput(
lyricsText: lyricsText(for: project),
stylePrompt: stylePrompt(for: project)
)
}
private func lyricsText(for project: SongProject) -> String {
if !project.lyrics.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return project.lyrics.text
}
return project.orderedSections
.compactMap { sectionLyricsBlock(for: $0, project: project) }
.joined(separator: "\n\n")
}
private func sectionLyricsBlock(for section: SongSection, project: SongProject) -> String? {
let lyrics = project.lyrics.sectionTexts[section.id] ?? section.lyrics
let trimmedLyrics = lyrics.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedLyrics.isEmpty else { return nil }
let performanceDirective = normalized(section.vocalDirection)
let header = performanceDirective.map { "[\(section.title) - \($0)]" } ?? "[\(section.title)]"
return "\(header)\n\(trimmedLyrics)"
}
private func stylePrompt(for project: SongProject) -> String {
let context = SongGenerationContext(project: project)
var components: [String] = []
append(project.genres.map(\.name).joined(separator: " + "), to: &components)
append(moodPhrase(for: project), to: &components)
append(context.sunoStyleInstrumentPhrase, to: &components)
append(vocalPhrase(for: project), to: &components)
append(musicalParametersPhrase(for: project), to: &components)
append(emotionPhrase(for: project), to: &components)
append(productionPhrase(for: project), to: &components)
return components.joined(separator: "; ")
}
private func vocalPhrase(for project: SongProject) -> String {
project.vocalists
.map { vocalist in
[
vocalist.label,
vocalist.voiceType,
vocalist.genderSelection,
vocalist.performanceStyle
]
.compactMap { normalized($0) }
.joined(separator: " ")
}
.filter { !$0.isEmpty }
.joined(separator: ", ")
}
private func musicalParametersPhrase(for project: SongProject) -> String {
var parameters: [String] = []
if let bpm = project.bpm?.value {
parameters.append("\(bpm) BPM")
}
append(project.key?.value.map { "key \($0)" }, to: &parameters)
append(project.scale?.value.map { "\($0) scale" }, to: &parameters)
append(project.maqam?.value.map { "maqam \($0)" }, to: &parameters)
return parameters.joined(separator: ", ")
}
private func moodPhrase(for project: SongProject) -> String {
let moods = project.moods.map(\.name).compactMap(normalized)
guard !moods.isEmpty else { return "" }
return "mood: \(moods.joined(separator: ", "))"
}
private func emotionPhrase(for project: SongProject) -> String {
let sectionEmotions = project.orderedSections.compactMap { section in
directedPhrase(
sectionTitle: section.title,
emotion: section.emotion,
energy: section.energy,
direction: nil
)
}
let arcEmotions = project.emotionalArc.compactMap { point in
directedPhrase(
sectionTitle: sectionTitle(for: point.sectionID, in: project),
emotion: point.emotion,
energy: point.energy,
direction: point.direction
)
}
let details = unique(sectionEmotions + arcEmotions)
guard !details.isEmpty else { return "" }
return "emotion: \(details.joined(separator: "; "))"
}
private func productionPhrase(for project: SongProject) -> String {
let sectionDirections = project.orderedSections.compactMap { section in
directedPhrase(sectionTitle: section.title, direction: section.productionDirection)
}
let projectDirections = project.productionDirections.compactMap { direction in
directedPhrase(
sectionTitle: sectionTitle(for: direction.sectionID, in: project),
direction: direction.text
)
}
let details = unique(sectionDirections + projectDirections)
guard !details.isEmpty else { return "" }
return "production: \(details.joined(separator: "; "))"
}
private func directedPhrase(
sectionTitle: String?,
emotion: String? = nil,
energy: Int? = nil,
direction: String? = nil
) -> String? {
var details = [normalized(emotion), energy.map { "energy \($0)" }, normalized(direction)]
.compactMap { $0 }
guard !details.isEmpty else { return nil }
if let sectionTitle = normalized(sectionTitle) {
details.insert(sectionTitle, at: 0)
}
return details.joined(separator: ", ")
}
private func sectionTitle(for sectionID: String?, in project: SongProject) -> String? {
guard let sectionID else { return nil }
return project.sections.first { $0.id == sectionID }?.title
}
private func unique(_ values: [String]) -> [String] {
var seen = Set<String>()
return values.filter { seen.insert($0).inserted }
}
private func append(_ value: String?, to components: inout [String]) {
guard let normalizedValue = normalized(value) else { return }
components.append(normalizedValue)
}
private func normalized(_ value: String?) -> String? {
let trimmedValue = value?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedValue?.isEmpty == false ? trimmedValue : nil
}
}
@@ -0,0 +1,29 @@
import Foundation
public protocol SunoOutputValidating: Sendable {
func validate(_ output: CompiledSunoOutput) throws
}
public enum SunoOutputValidationError: Error, Equatable, LocalizedError, Sendable {
case missingContent
public var errorDescription: String? {
switch self {
case .missingContent:
return "Add lyrics or a style prompt before finalizing the song output."
}
}
}
public struct CompiledSunoOutputValidator: SunoOutputValidating {
public init() {}
public func validate(_ output: CompiledSunoOutput) throws {
let lyrics = output.lyricsText.trimmingCharacters(in: .whitespacesAndNewlines)
let style = output.stylePrompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !lyrics.isEmpty || !style.isEmpty else {
throw SunoOutputValidationError.missingContent
}
}
}
@@ -0,0 +1,171 @@
import Foundation
public struct SongGenerationContext: Codable, Equatable, Sendable {
public static let empty = SongGenerationContext(selectedInstruments: [])
public var selectedInstruments: [SelectedInstrumentContext]
public init(selectedInstruments: [SelectedInstrumentContext]) {
self.selectedInstruments = selectedInstruments
}
public init(project: SongProject) {
let sectionsByID = Dictionary(uniqueKeysWithValues: project.sections.map { ($0.id, $0) })
selectedInstruments = project.selectedInstrumentTracks.map { track in
SelectedInstrumentContext(
instrumentId: track.instrumentId,
displayName: Self.displayName(for: track),
playingStyle: Self.normalized(track.playingStyle),
role: Self.normalized(track.role),
autoArrangementEnabled: track.autoArrangementEnabled,
placements: track.placements.map { placement in
SelectedInstrumentPlacementContext(
sectionId: placement.sectionId,
sectionTitle: placement.sectionId.flatMap { sectionsByID[$0]?.title },
sectionType: placement.sectionId.flatMap { sectionsByID[$0]?.type },
startTime: placement.startTime,
endTime: placement.endTime,
direction: Self.normalized(placement.direction)
)
}
)
}
}
public var hasSelectedInstruments: Bool {
!selectedInstruments.isEmpty
}
public var sunoStyleInstrumentPhrase: String {
selectedInstruments
.map(\.stylePromptPhrase)
.joined(separator: ", ")
}
private static func displayName(for track: InstrumentTrack) -> String {
normalized(track.variant) ?? track.instrumentId
}
private static func normalized(_ value: String?) -> String? {
let trimmedValue = value?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedValue?.isEmpty == false ? trimmedValue : nil
}
}
public struct SelectedInstrumentContext: Codable, Equatable, Sendable {
public var instrumentId: String
public var displayName: String
public var playingStyle: String?
public var role: String?
public var autoArrangementEnabled: Bool
public var placements: [SelectedInstrumentPlacementContext]
public init(
instrumentId: String,
displayName: String,
playingStyle: String? = nil,
role: String? = nil,
autoArrangementEnabled: Bool = true,
placements: [SelectedInstrumentPlacementContext] = []
) {
self.instrumentId = instrumentId
self.displayName = displayName
self.playingStyle = playingStyle
self.role = role
self.autoArrangementEnabled = autoArrangementEnabled
self.placements = placements
}
public var stylePromptPhrase: String {
var details: [String] = []
if let role {
details.append(role)
}
if let playingStyle {
details.append(playingStyle)
}
let timingPhrase = placements
.map(\.stylePromptPhrase)
.filter { !$0.isEmpty }
.joined(separator: "; ")
if !timingPhrase.isEmpty {
details.append(timingPhrase)
}
guard !details.isEmpty else {
return displayName
}
return "\(displayName) (\(details.joined(separator: ", ")))"
}
}
public struct SelectedInstrumentPlacementContext: Codable, Equatable, Sendable {
public var sectionId: String?
public var sectionTitle: String?
public var sectionType: SongSectionType?
public var startTime: TimeInterval?
public var endTime: TimeInterval?
public var direction: String?
public init(
sectionId: String? = nil,
sectionTitle: String? = nil,
sectionType: SongSectionType? = nil,
startTime: TimeInterval? = nil,
endTime: TimeInterval? = nil,
direction: String? = nil
) {
self.sectionId = sectionId
self.sectionTitle = sectionTitle
self.sectionType = sectionType
self.startTime = startTime
self.endTime = endTime
self.direction = direction
}
public var stylePromptPhrase: String {
var parts: [String] = []
if let sectionTitle, !sectionTitle.isEmpty {
parts.append(sectionTitle)
} else if let sectionType {
parts.append(sectionType.rawValue)
}
if let timing = timingPhrase {
parts.append(timing)
}
if let direction, !direction.isEmpty {
parts.append(direction)
}
return parts.joined(separator: " ")
}
private var timingPhrase: String? {
switch (startTime, endTime) {
case let (start?, end?):
return "\(Self.formattedTime(start))-\(Self.formattedTime(end))"
case let (start?, nil):
return "from \(Self.formattedTime(start))"
case let (nil, end?):
return "until \(Self.formattedTime(end))"
case (nil, nil):
return nil
}
}
private static func formattedTime(_ time: TimeInterval) -> String {
let roundedTime = time.rounded()
if roundedTime == time {
return "\(Int(roundedTime))s"
}
return String(format: "%.1fs", time)
}
}
@@ -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)
}
}
@@ -24,6 +24,24 @@ final class AIServiceTests: XCTestCase {
XCTAssertEqual(result.followUpQuestions, ["Should the chorus be bigger?"])
}
func testProviderIndependentServiceReturnsDiscussionQuestionsWithoutProjectUpdate() async throws {
let service = MockAIService()
let project = SongProject(
title: "Discussion Project",
idea: "Plan the chorus",
conversationMode: .discuss
)
let request = SongProjectDiscussionRequest(
context: AIRequestContext(userInstruction: "Ask what is still missing."),
project: project
)
let result = try await service.discussSongProject(from: request)
XCTAssertEqual(result.questions, ["Which vocal delivery should lead the chorus?"])
XCTAssertEqual(result.notes, ["Discussion only; no project update was proposed."])
}
func testProviderIndependentServiceRevisesLyricsWithStructuredResult() async throws {
let service = MockAIService()
let project = SongProject(title: "Lyric Project", idea: "Improve words")
@@ -71,6 +89,13 @@ private struct MockAIService: AIService {
)
}
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
SongProjectDiscussionResult(
questions: ["Which vocal delivery should lead the chorus?"],
notes: ["Discussion only; no project update was proposed."]
)
}
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
LyricsRevisionResult(
lyrics: Lyrics(text: "\(request.sourceLyrics.text)\n\(request.context.userInstruction)"),
@@ -17,6 +17,12 @@ final class ApplicationRuleInjectionTests: XCTestCase {
context: AIRequestContext(userInstruction: "Generate.")
)
)
_ = try await service.discussSongProject(
from: SongProjectDiscussionRequest(
context: AIRequestContext(userInstruction: "Discuss."),
project: project
)
)
_ = try await service.reviseLyrics(
from: LyricsRevisionRequest(
context: AIRequestContext(userInstruction: "Improve."),
@@ -37,6 +43,7 @@ final class ApplicationRuleInjectionTests: XCTestCase {
XCTAssertEqual(
recordedRuleContents,
[
"Use private product rules.",
"Use private product rules.",
"Use private product rules.",
"Use private product rules."
@@ -90,6 +97,11 @@ private actor RecordingAIService: AIService {
)
}
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
contexts.append(request.context)
return SongProjectDiscussionResult(questions: ["Which direction should we take?"])
}
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
contexts.append(request.context)
return LyricsRevisionResult(lyrics: request.sourceLyrics)
@@ -0,0 +1,65 @@
import MusicAssistantCore
struct ArabicLyricsFixture {
let dialectName: String
let lyrics: String
let settings: ArabicPronunciationSettings
let expectedLyrics: String
let expectedNotes: [ArabicPronunciationProcessingNote]
}
enum ArabicLyricsFixtures {
static let all: [ArabicLyricsFixture] = [
levantine,
egyptian,
gulf,
maghrebi
]
private static let levantine = ArabicLyricsFixture(
dialectName: "Levantine Arabic",
lyrics: "\u{0634}\u{064F}\u{0648} \u{0628}\u{064E}\u{062F}\u{0651}\u{064E}\u{0643}",
settings: ArabicPronunciationSettings(
isEnabled: true,
diacritizationPolicy: .pronunciationTargeted,
tanweenPolicy: .preserveUserInput,
preservedSpellings: ["\u{0634}\u{064F}\u{0648}"]
),
expectedLyrics: "\u{0634}\u{064F}\u{0648} \u{0628}\u{064E}\u{062F}\u{064E}\u{0651}\u{0643}",
expectedNotes: [.preservedSpellingsProtected(count: 1)]
)
private static let egyptian = ArabicLyricsFixture(
dialectName: "Egyptian Arabic",
lyrics: "\u{0625}\u{0650}\u{0632}\u{0651}\u{064E}\u{0627}\u{064A}\u{064E}\u{0643}",
settings: ArabicPronunciationSettings(
isEnabled: true,
diacritizationPolicy: .pronunciationTargeted
),
expectedLyrics: "\u{0625}\u{0650}\u{0632}\u{064E}\u{0651}\u{0627}\u{064A}\u{064E}\u{0643}",
expectedNotes: []
)
private static let gulf = ArabicLyricsFixture(
dialectName: "Gulf Arabic",
lyrics: "\u{0648}\u{064E}\u{064A}\u{0652}\u{0646}\u{064E}\u{0643} \u{0634}\u{064F}\u{0643}\u{0652}\u{0631}\u{064B}\u{0627}",
settings: ArabicPronunciationSettings(
isEnabled: true,
diacritizationPolicy: .pronunciationTargeted,
tanweenPolicy: .removeWhenUnwanted
),
expectedLyrics: "\u{0648}\u{064E}\u{064A}\u{0652}\u{0646}\u{064E}\u{0643} \u{0634}\u{064F}\u{0643}\u{0652}\u{0631}\u{0627}",
expectedNotes: []
)
private static let maghrebi = ArabicLyricsFixture(
dialectName: "Maghrebi Arabic",
lyrics: "\u{0634}\u{0646}\u{0648} \u{0628}\u{063A}\u{064A}\u{062A}\u{064A}",
settings: ArabicPronunciationSettings(
isEnabled: true,
diacritizationPolicy: .fullTashkeel
),
expectedLyrics: "\u{0634}\u{0646}\u{0648} \u{0628}\u{063A}\u{064A}\u{062A}\u{064A}",
expectedNotes: [.fullTashkeelNeedsReview]
)
}
@@ -0,0 +1,128 @@
import MusicAssistantCore
import XCTest
final class ArabicPronunciationProcessorTests: XCTestCase {
private let processor = ArabicPronunciationProcessor()
func testDisabledProcessingLeavesLyricsUntouched() {
let lyrics = "\u{0645}\u{0651}\u{064E}\u{0631}\u{062D}\u{064E}\u{0628}\u{064B}\u{0627}"
let result = processor.process(
lyrics: lyrics,
settings: ArabicPronunciationSettings(isEnabled: false)
)
XCTAssertEqual(result.text, lyrics)
XCTAssertEqual(result.notes, [.processingDisabled])
}
func testProcessingNormalizesSuppliedDiacritics() {
let lyrics = "\u{0645}\u{0651}\u{064E}\u{0631}\u{062D}\u{064E}\u{0628}\u{064B}\u{0627}"
let result = processor.process(
lyrics: lyrics,
settings: ArabicPronunciationSettings(
isEnabled: true,
diacritizationPolicy: .pronunciationTargeted
)
)
XCTAssertEqual(
result.text,
"\u{0645}\u{064E}\u{0651}\u{0631}\u{062D}\u{064E}\u{0628}\u{064B}\u{0627}"
)
XCTAssertTrue(result.notes.isEmpty)
}
func testRemovingTanweenKeepsOtherDiacritics() {
let result = processor.process(
lyrics: "\u{0634}\u{064F}\u{0643}\u{0652}\u{0631}\u{064B}\u{0627}",
settings: ArabicPronunciationSettings(
isEnabled: true,
tanweenPolicy: .removeWhenUnwanted
)
)
XCTAssertEqual(result.text, "\u{0634}\u{064F}\u{0643}\u{0652}\u{0631}\u{0627}")
}
func testFullTashkeelDoesNotInventMissingMarks() {
let lyrics = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"
let result = processor.process(
lyrics: lyrics,
settings: ArabicPronunciationSettings(
isEnabled: true,
diacritizationPolicy: .fullTashkeel,
tanweenPolicy: .addWhenPronunciationRequires
)
)
XCTAssertEqual(result.text, lyrics)
XCTAssertEqual(
result.notes,
[.fullTashkeelNeedsReview, .tanweenAdditionNeedsReview]
)
}
func testPreservedSpellingsKeepTheirTanweenWhileOtherTextIsProcessed() {
let protectedSpelling = "\u{0647}\u{064F}\u{062F}\u{064B}\u{0649}"
let otherText = "\u{0634}\u{064F}\u{0643}\u{0652}\u{0631}\u{064B}\u{0627}"
let lyrics = "\(protectedSpelling) \(otherText) \(protectedSpelling)"
let result = processor.process(
lyrics: lyrics,
settings: ArabicPronunciationSettings(
isEnabled: true,
diacritizationPolicy: .pronunciationTargeted,
tanweenPolicy: .removeWhenUnwanted,
preservedSpellings: [protectedSpelling]
)
)
XCTAssertEqual(
result.text,
"\(protectedSpelling) \u{0634}\u{064F}\u{0643}\u{0652}\u{0631}\u{0627} \(protectedSpelling)"
)
XCTAssertEqual(result.notes, [.preservedSpellingsProtected(count: 2)])
}
func testPreservedSpellingsKeepTheirOriginalDiacriticOrder() {
let preservedSpelling = "\u{0645}\u{0651}\u{064E}"
let otherText = "\u{0628}\u{0651}\u{064E}"
let result = processor.process(
lyrics: "\(preservedSpelling) \(otherText)",
settings: ArabicPronunciationSettings(
isEnabled: true,
diacritizationPolicy: .pronunciationTargeted,
preservedSpellings: [preservedSpelling]
)
)
XCTAssertEqual(result.text, "\(preservedSpelling) \u{0628}\u{064E}\u{0651}")
XCTAssertEqual(result.notes, [.preservedSpellingsProtected(count: 1)])
}
func testArabicDialectFixturesCoverExpectedProcessingBehavior() {
XCTAssertEqual(ArabicLyricsFixtures.all.count, 4)
XCTAssertEqual(
Set(ArabicLyricsFixtures.all.map(\.dialectName)).count,
ArabicLyricsFixtures.all.count
)
for fixture in ArabicLyricsFixtures.all {
let result = processor.process(
lyrics: fixture.lyrics,
settings: fixture.settings
)
XCTAssertEqual(
result.text.unicodeScalars.map(\.value),
fixture.expectedLyrics.unicodeScalars.map(\.value),
fixture.dialectName
)
XCTAssertEqual(result.notes, fixture.expectedNotes, fixture.dialectName)
}
}
}
@@ -0,0 +1,236 @@
import MusicAssistantCore
import XCTest
final class AutoSongProjectDecisionsDirectorTests: XCTestCase {
func testAutomaticDecisionsRequestEnabledScopesAndPreserveManualChoices() async throws {
let currentProject = SongProject(
id: "auto-project",
title: "Current Project",
idea: "Build a cinematic Arabic pop song",
bpm: ManualAutoValue(mode: .auto),
key: ManualAutoValue(mode: .manual, value: "D"),
scale: ManualAutoValue(mode: .auto),
maqam: ManualAutoValue(mode: .manual, value: "Hijaz"),
structureMode: .manual,
sections: [
SongSection(id: "verse", type: .verse, title: "Manual Verse", productionDirection: "Dry vocal")
],
instruments: [
InstrumentTrack(
instrumentId: "piano",
selected: true,
role: "Manual piano role",
autoArrangementEnabled: false,
placements: [InstrumentPlacement(sectionId: "verse", direction: "Manual entry")]
),
InstrumentTrack(
instrumentId: "violin",
selected: true,
autoArrangementEnabled: true
)
],
productionDirections: [ProductionDirection(id: "manual-production", sectionID: "verse", text: "Keep it dry")],
productionMode: .manual,
createdAt: Date(timeIntervalSince1970: 10),
updatedAt: Date(timeIntervalSince1970: 20)
)
let proposedProject = SongProject(
title: "Proposed Project",
idea: "Different",
bpm: ManualAutoValue(mode: .manual, value: 102),
key: ManualAutoValue(mode: .auto, value: "A"),
scale: ManualAutoValue(mode: .manual, value: "minor"),
maqam: ManualAutoValue(mode: .auto, value: "Bayati"),
sections: [
SongSection(id: "chorus", type: .chorus, title: "AI Chorus", productionDirection: "Wide synths")
],
instruments: [
InstrumentTrack(
instrumentId: "piano",
selected: true,
role: "AI piano role",
placements: [InstrumentPlacement(sectionId: "chorus", direction: "AI piano entry")]
),
InstrumentTrack(
instrumentId: "violin",
selected: true,
playingStyle: "legato",
role: "AI countermelody",
placements: [InstrumentPlacement(sectionId: "chorus", direction: "Enter at chorus")]
)
],
productionDirections: [ProductionDirection(id: "ai-production", sectionID: "chorus", text: "Wide synths")]
)
let aiService = RecordingAutoDecisionsAIService(
result: SongProjectUpdateResult(
project: proposedProject,
changedScopes: [.structure, .arrangement, .musicalParameters, .productionDirections],
notes: ["Applied automatic decisions."]
)
)
let director = AutoSongProjectDecisionsDirector(aiService: aiService)
let result = try await director.applyAutomaticDecisions(
to: currentProject,
instruction: " Complete automatic values. ",
localeIdentifier: "en_US",
updatedAt: Date(timeIntervalSince1970: 100)
)
let request = await aiService.recordedRequests.first
XCTAssertEqual(request?.context.userInstruction, "Complete automatic values.")
XCTAssertEqual(request?.context.localeIdentifier, "en_US")
XCTAssertEqual(
request?.allowedScopes ?? [],
[SongProjectUpdateScope.arrangement, .musicalParameters]
)
XCTAssertEqual(result.project.sections, currentProject.sections)
XCTAssertEqual(result.project.productionDirections, currentProject.productionDirections)
XCTAssertEqual(result.project.bpm, ManualAutoValue(mode: .auto, value: 102))
XCTAssertEqual(result.project.key, currentProject.key)
XCTAssertEqual(result.project.scale, ManualAutoValue(mode: .auto, value: "minor"))
XCTAssertEqual(result.project.maqam, currentProject.maqam)
XCTAssertEqual(result.project.instrumentTrack(for: "piano"), currentProject.instrumentTrack(for: "piano"))
XCTAssertEqual(result.project.instrumentTrack(for: "violin")?.role, "AI countermelody")
XCTAssertEqual(result.project.instrumentTrack(for: "violin")?.playingStyle, "legato")
XCTAssertEqual(result.project.instrumentTrack(for: "violin")?.placements, [InstrumentPlacement(sectionId: "chorus", direction: "Enter at chorus")])
XCTAssertEqual(result.project.updatedAt, Date(timeIntervalSince1970: 100))
XCTAssertEqual(
result.changedScopes,
[SongProjectUpdateScope.arrangement, .musicalParameters]
)
XCTAssertEqual(result.notes, ["Applied automatic decisions."])
}
func testAutomaticDecisionsApplyStructureAndProductionWhenTheirModesAreAutomatic() async throws {
let currentProject = SongProject(
title: "Auto Project",
idea: "Let AI plan it",
structureMode: .auto,
sections: [SongSection(id: "intro", type: .intro, title: "Current Intro")],
productionMode: .auto
)
let proposedProject = SongProject(
title: "Proposed",
idea: "Proposed",
sections: [SongSection(id: "verse", type: .verse, title: "AI Verse", productionDirection: "Add texture")],
productionDirections: [ProductionDirection(id: "production", sectionID: "verse", text: "Add texture")]
)
let aiService = RecordingAutoDecisionsAIService(
result: SongProjectUpdateResult(project: proposedProject)
)
let director = AutoSongProjectDecisionsDirector(aiService: aiService)
let result = try await director.applyAutomaticDecisions(to: currentProject, updatedAt: Date(timeIntervalSince1970: 200))
let request = await aiService.recordedRequests.first
XCTAssertEqual(
request?.allowedScopes ?? [],
[.structure, .musicalParameters, .productionDirections]
)
XCTAssertEqual(result.project.sections, proposedProject.sections)
XCTAssertEqual(result.project.productionDirections, proposedProject.productionDirections)
XCTAssertEqual(result.changedScopes, [.structure, .productionDirections])
}
func testAutomaticDecisionsDoNotCallAIWhenEveryDecisionIsManual() async throws {
let project = SongProject(
title: "Manual Project",
idea: "Keep all choices",
bpm: ManualAutoValue(mode: .manual, value: 96),
key: ManualAutoValue(mode: .manual, value: "C"),
scale: ManualAutoValue(mode: .manual, value: "major"),
maqam: ManualAutoValue(mode: .manual, value: "Rast"),
structureMode: .manual,
instruments: [InstrumentTrack(instrumentId: "oud", selected: true, autoArrangementEnabled: false)],
productionMode: .manual
)
let aiService = RecordingAutoDecisionsAIService(
result: SongProjectUpdateResult(project: project)
)
let director = AutoSongProjectDecisionsDirector(aiService: aiService)
let result = try await director.applyAutomaticDecisions(to: project)
let requestCount = await aiService.recordedRequests.count
XCTAssertEqual(requestCount, 0)
XCTAssertEqual(result.project, project)
XCTAssertTrue(result.changedScopes.isEmpty)
XCTAssertEqual(result.notes, ["No automatic decisions are enabled."])
}
func testAutomaticStructureDoesNotReplaceManualProductionDirections() async throws {
let project = SongProject(
title: "Mixed Modes",
idea: "Change the structure only",
bpm: ManualAutoValue(mode: .manual, value: 100),
key: ManualAutoValue(mode: .manual, value: "C"),
scale: ManualAutoValue(mode: .manual, value: "minor"),
maqam: ManualAutoValue(mode: .manual, value: "Rast"),
structureMode: .auto,
sections: [SongSection(id: "verse", type: .verse, title: "Current Verse", productionDirection: "Keep dry")],
productionDirections: [ProductionDirection(id: "manual-production", sectionID: "verse", text: "Keep dry")],
productionMode: .manual
)
let proposedProject = SongProject(
title: "Proposed",
idea: "Proposed",
sections: [SongSection(id: "verse", type: .chorus, title: "AI Chorus", productionDirection: "Add wide synths")],
productionDirections: [ProductionDirection(id: "ai-production", sectionID: "verse", text: "Add wide synths")]
)
let aiService = RecordingAutoDecisionsAIService(result: SongProjectUpdateResult(project: proposedProject))
let director = AutoSongProjectDecisionsDirector(aiService: aiService)
let result = try await director.applyAutomaticDecisions(to: project)
XCTAssertEqual(result.project.sections.first?.type, .chorus)
XCTAssertEqual(result.project.sections.first?.productionDirection, "Keep dry")
XCTAssertEqual(result.project.productionDirections, project.productionDirections)
XCTAssertEqual(result.changedScopes, [SongProjectUpdateScope.structure])
}
func testAutomaticDecisionsDoNotCallAIForLockedAutomaticScopes() async throws {
let project = SongProject(
title: "Locked Auto Project",
idea: "Do not update the tempo",
bpm: ManualAutoValue(mode: .auto),
key: ManualAutoValue(mode: .manual, value: "C"),
scale: ManualAutoValue(mode: .manual, value: "minor"),
maqam: ManualAutoValue(mode: .manual, value: "Rast"),
structureMode: .manual,
productionMode: .manual,
userLocks: SongProjectUserLocks(scopes: [.musicalParameters])
)
let aiService = RecordingAutoDecisionsAIService(result: SongProjectUpdateResult(project: project))
let director = AutoSongProjectDecisionsDirector(aiService: aiService)
let result = try await director.applyAutomaticDecisions(to: project)
let requestCount = await aiService.recordedRequests.count
XCTAssertEqual(requestCount, 0)
XCTAssertEqual(result.project, project)
XCTAssertTrue(result.changedScopes.isEmpty)
}
}
private actor RecordingAutoDecisionsAIService: AIService {
private(set) var recordedRequests: [SongProjectUpdateRequest] = []
private let result: SongProjectUpdateResult
init(result: SongProjectUpdateResult) {
self.result = result
}
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
SongProjectGenerationResult(project: request.seedProject ?? SongProject(title: "Unused", idea: "Unused"))
}
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
LyricsRevisionResult(lyrics: request.sourceLyrics)
}
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
recordedRequests.append(request)
return result
}
}
@@ -137,6 +137,30 @@ final class ExistingLyricsRevisionDirectorTests: XCTestCase {
let requestCount = await aiService.recordedRevisionRequests.count
XCTAssertEqual(requestCount, 0)
}
func testReviseExistingLyricsDoesNotCallAIWhenLyricsAreLocked() async throws {
let aiService = RecordingLyricsAIService(
revisionResult: LyricsRevisionResult(lyrics: Lyrics(text: "Unused"))
)
let director = ExistingLyricsRevisionDirector(aiService: aiService)
let project = SongProject(
title: "Locked Lyrics",
idea: "Keep the words",
lyrics: Lyrics(text: "User-approved lyrics"),
userLocks: SongProjectUserLocks(scopes: [.lyrics])
)
let result = try await director.reviseExistingLyrics(
in: project,
instruction: "Improve the chorus.",
mode: .improve
)
let requestCount = await aiService.recordedRevisionRequests.count
XCTAssertEqual(requestCount, 0)
XCTAssertEqual(result.project, project)
XCTAssertTrue(result.changedScopes.isEmpty)
}
}
private actor RecordingLyricsAIService: AIService {
@@ -0,0 +1,38 @@
import MusicAssistantCore
import XCTest
final class InstrumentCatalogItemTests: XCTestCase {
func testCatalogItemStoresStructuredInstrumentMetadata() {
let item = InstrumentCatalogItem(
id: "oud",
name: "Oud",
familyCategory: "Plucked strings",
regionOrigin: "Middle East",
aliases: ["Arabic lute"],
searchTerms: ["ud", "عود"]
)
XCTAssertEqual(item.id, "oud")
XCTAssertEqual(item.name, "Oud")
XCTAssertEqual(item.familyCategory, "Plucked strings")
XCTAssertEqual(item.regionOrigin, "Middle East")
XCTAssertEqual(item.aliases, ["Arabic lute"])
XCTAssertEqual(item.searchTerms, ["ud", "عود"])
}
func testCatalogItemSupportsOptionalMetadataAndRoundTripsThroughJSON() throws {
let item = InstrumentCatalogItem(
id: "synthesizer",
name: "Synthesizer",
familyCategory: "Electronic"
)
let data = try JSONEncoder().encode(item)
let decodedItem = try JSONDecoder().decode(InstrumentCatalogItem.self, from: data)
XCTAssertNil(item.regionOrigin)
XCTAssertEqual(item.aliases, [])
XCTAssertEqual(item.searchTerms, [])
XCTAssertEqual(decodedItem, item)
}
}
@@ -0,0 +1,65 @@
import Foundation
import MusicAssistantCore
import XCTest
final class InstrumentSelectionPersistenceTests: XCTestCase {
func testSelectedInstrumentsRoundTripThroughSongProjectSerialization() throws {
let section = SongSection(id: "intro", type: .intro, title: "Intro")
let project = SongProject(
id: "serialized-instrument-selection",
title: "Serialized Instrument Selection",
idea: "Preserve selected instruments",
sections: [section],
instruments: [
InstrumentTrack(
instrumentId: "oud",
selected: true,
variant: "Arabic Oud",
playingStyle: "tremolo",
role: "lead motif",
autoArrangementEnabled: false,
placements: [InstrumentPlacement(sectionId: section.id, direction: "Opening")]
),
InstrumentTrack(
instrumentId: "violin",
selected: false,
variant: "Violin"
)
]
)
let encodedProject = try JSONEncoder().encode(project)
let decodedProject = try JSONDecoder().decode(SongProject.self, from: encodedProject)
XCTAssertEqual(decodedProject.instruments, project.instruments)
XCTAssertEqual(decodedProject.selectedInstrumentIDs, ["oud"])
XCTAssertEqual(decodedProject.instrumentTrack(for: "oud")?.placements.first?.sectionId, section.id)
}
func testSelectedInstrumentsPersistWhenProjectIsSavedAndReopened() async throws {
let directoryURL = FileManager.default.temporaryDirectory
.appendingPathComponent("music-assistant-instrument-selection-tests")
.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directoryURL) }
let store = LocalSongProjectStore(directoryURL: directoryURL)
var project = SongProject(
id: "persisted-instrument-selection",
title: "Persisted Instrument Selection",
idea: "Keep catalog selections"
)
project.setInstrumentSelected(id: "oud", isSelected: true, variant: "Oud")
project.setInstrumentSelected(id: "violin", isSelected: true, variant: "Violin")
project.setInstrumentSelected(id: "oud", isSelected: false)
try await store.create(project)
let reopenedProject = try await store.open(id: project.id)
XCTAssertFalse(reopenedProject.isInstrumentSelected(id: "oud"))
XCTAssertTrue(reopenedProject.isInstrumentSelected(id: "violin"))
XCTAssertEqual(reopenedProject.selectedInstrumentIDs, ["violin"])
XCTAssertEqual(reopenedProject.instrumentTrack(for: "oud")?.variant, "Oud")
}
}
@@ -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,65 @@
import MusicAssistantCore
import XCTest
final class LocalInstrumentCatalogTests: XCTestCase {
func testWorldwideCatalogCoversRequiredRegionsAndInstrumentTypes() {
let catalog = LocalInstrumentCatalog()
let expectedRegions = [
"Western", "Middle East", "Africa", "South Asia", "East Asia", "Southeast Asia", "Latin America", "Modern"
]
let expectedInstrumentIDs = [
"piano", "oud", "djembe", "sitar", "erhu", "angklung", "charango", "bagpipes", "synthesizer"
]
XCTAssertGreaterThanOrEqual(catalog.items.count, 200)
XCTAssertTrue(expectedRegions.allSatisfy { region in
catalog.items.contains { $0.regionOrigin?.contains(region) == true }
})
XCTAssertTrue(expectedInstrumentIDs.allSatisfy { id in
catalog.items.contains { $0.id == id }
})
}
func testWorldwideCatalogHasUniqueStableInstrumentIDs() {
let items = LocalInstrumentCatalog.worldwideItems
XCTAssertEqual(Set(items.map(\.id)).count, items.count)
XCTAssertTrue(items.allSatisfy { !$0.name.isEmpty && !$0.familyCategory.isEmpty })
}
func testSearchMatchesNamesAliasesAndArabicSearchTerms() {
let catalog = LocalInstrumentCatalog()
XCTAssertEqual(catalog.instruments(matching: "concert flute").map(\.id), ["flute"])
XCTAssertEqual(catalog.instruments(matching: "nylon string").map(\.id), ["classical-guitar"])
XCTAssertEqual(catalog.instruments(matching: "عود").map(\.id), ["oud"])
}
func testFamilyCategoriesAndFilteringComeFromCatalogMetadata() {
let catalog = LocalInstrumentCatalog()
let pluckedStrings = catalog.instruments(matching: "", familyCategory: "Plucked strings")
XCTAssertTrue(catalog.familyCategories.contains("Plucked strings"))
XCTAssertTrue(pluckedStrings.contains { $0.id == "oud" })
XCTAssertTrue(pluckedStrings.allSatisfy { $0.familyCategory == "Plucked strings" })
XCTAssertTrue(
catalog.instruments(matching: "guitar", familyCategory: "Plucked strings")
.allSatisfy { $0.familyCategory == "Plucked strings" }
)
}
func testRegionsAndFilteringComeFromCatalogMetadata() {
let catalog = LocalInstrumentCatalog()
let middleEasternPluckedStrings = catalog.instruments(
matching: "",
familyCategory: "Plucked strings",
regionOrigin: "Middle East"
)
XCTAssertTrue(catalog.regionsOfOrigin.contains("Middle East"))
XCTAssertFalse(middleEasternPluckedStrings.isEmpty)
XCTAssertTrue(middleEasternPluckedStrings.contains { $0.id == "oud" })
XCTAssertTrue(middleEasternPluckedStrings.allSatisfy { $0.regionOrigin == "Middle East" })
XCTAssertTrue(middleEasternPluckedStrings.allSatisfy { $0.familyCategory == "Plucked strings" })
}
}
@@ -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"])
}
}
@@ -48,6 +48,82 @@ final class LocalSongProjectStoreTests: XCTestCase {
XCTAssertEqual(updatedProject, project)
}
func testPersistsSelectedInstrumentsOnSongProject() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
var project = SongProject(
id: "instrument-selection-project",
title: "Instrument Selection",
idea: "Persist selected catalog instruments"
)
project.setInstrumentSelected(id: "oud", isSelected: true, variant: "Oud")
project.setInstrumentSelected(id: "violin", isSelected: true, variant: "Violin")
project.setInstrumentSelected(id: "oud", isSelected: false)
try await store.create(project)
var openedProject = try await store.open(id: project.id)
XCTAssertFalse(openedProject.isInstrumentSelected(id: "oud"))
XCTAssertTrue(openedProject.isInstrumentSelected(id: "violin"))
XCTAssertEqual(openedProject.instrumentTrack(for: "oud")?.variant, "Oud")
XCTAssertEqual(openedProject.selectedInstrumentIDs, ["violin"])
openedProject.setInstrumentSelected(id: "oud", isSelected: true, variant: "Oud")
try await store.save(openedProject)
let savedProject = try await store.open(id: project.id)
XCTAssertTrue(savedProject.isInstrumentSelected(id: "oud"))
XCTAssertTrue(savedProject.isInstrumentSelected(id: "violin"))
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(
@@ -99,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))
}
}
@@ -68,6 +68,34 @@ final class OpenAIClientTests: XCTestCase {
XCTAssertEqual(recordedURL, overrideURL)
}
func testDiscussSongProjectUsesAdapterAndReturnsQuestions() async throws {
let transport = RecordingOpenAITransport(
statusCode: 200,
responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Should the chorus be intimate or anthemic?"))
)
let configuration = OpenAIClientConfiguration(
apiKey: "test-api-key",
endpointURL: URL(string: "https://api.example.test/v1/configured")!,
model: "configured-model"
)
let client = OpenAIClient(
configuration: configuration,
adapter: MockOpenAIClientAdapter(),
transport: transport
)
let result = try await client.discussSongProject(
from: SongProjectDiscussionRequest(
context: AIRequestContext(userInstruction: "Ask before deciding."),
project: SongProject(title: "Discussion", idea: "Plan this", conversationMode: .discuss)
)
)
let requestBodyString = await transport.requestBodyString
XCTAssertEqual(result.questions, ["Should the chorus be intimate or anthemic?"])
XCTAssertEqual(requestBodyString, "discuss|configured-model|Ask before deciding.")
}
func testClientRejectsMissingAPIKeyBeforeSendingRequest() async throws {
let transport = RecordingOpenAITransport(
statusCode: 200,
@@ -100,7 +128,7 @@ final class OpenAIClientTests: XCTestCase {
XCTAssertEqual(recordedRequestCount, 0)
}
func testClientReportsUnacceptableStatusWithoutLeakingResponseBody() async throws {
func testClientReportsRateLimitWithoutLeakingResponseBody() async throws {
let transport = RecordingOpenAITransport(
statusCode: 429,
responseData: Data("secret server detail".utf8)
@@ -122,7 +150,32 @@ final class OpenAIClientTests: XCTestCase {
)
XCTFail("Expected status error to throw.")
} catch let error as OpenAIClientError {
XCTAssertEqual(error, .unacceptableStatusCode(429))
XCTAssertEqual(error, .rateLimited(retryAfter: nil))
}
}
func testClientReadsRetryAfterForRateLimitedResponses() async throws {
let transport = RecordingOpenAITransport(
statusCode: 429,
responseData: Data(),
responseHeaders: ["Retry-After": "3.5"]
)
let client = OpenAIClient(
configuration: OpenAIClientConfiguration(
apiKey: "test-api-key",
endpointURL: URL(string: "https://api.example.test/v1/configured")!
),
adapter: MockOpenAIClientAdapter(),
transport: transport
)
do {
_ = try await client.generateSongProject(
from: SongProjectGenerationRequest(context: AIRequestContext(userInstruction: "Generate."))
)
XCTFail("Expected rate limit error to throw.")
} catch let error as OpenAIClientError {
XCTAssertEqual(error, .rateLimited(retryAfter: 3.5))
}
}
}
@@ -131,10 +184,16 @@ private actor RecordingOpenAITransport: OpenAIHTTPTransport {
private(set) var recordedRequests: [URLRequest] = []
private let statusCode: Int
private let responseData: Data
private let responseHeaders: [String: String]?
init(statusCode: Int, responseData: Data) {
init(
statusCode: Int,
responseData: Data,
responseHeaders: [String: String]? = nil
) {
self.statusCode = statusCode
self.responseData = responseData
self.responseHeaders = responseHeaders
}
var requestBodyString: String? {
@@ -148,7 +207,7 @@ private actor RecordingOpenAITransport: OpenAIHTTPTransport {
url: request.url!,
statusCode: statusCode,
httpVersion: "HTTP/1.1",
headerFields: nil
headerFields: responseHeaders
)!
return (responseData, response)
}
@@ -175,6 +234,21 @@ private struct MockOpenAIClientAdapter: OpenAIClientAdapter {
)
}
func makeSongProjectDiscussionRequest(
_ request: SongProjectDiscussionRequest,
configuration: OpenAIClientConfiguration
) throws -> OpenAIClientRequest {
OpenAIClientRequest(
url: overrideURL,
body: Data("discuss|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8)
)
}
func decodeSongProjectDiscussionResult(from data: Data) throws -> SongProjectDiscussionResult {
let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data)
return SongProjectDiscussionResult(questions: [response.text])
}
func makeLyricsRevisionRequest(
_ request: LyricsRevisionRequest,
configuration: OpenAIClientConfiguration
@@ -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,144 @@
import MusicAssistantCore
import XCTest
final class RetryingAIServiceTests: XCTestCase {
func testRetriesTemporaryServerFailureThenReturnsResult() async throws {
let baseService = ScriptedRetryAIService(
outcomes: [.serverFailure(503), .serverFailure(503), .success("Generated")]
)
let scheduler = RecordingRetryScheduler()
let service = RetryingAIService(
baseService: baseService,
policy: AIServiceRetryPolicy(maxAttempts: 3, baseDelay: 0.25, maximumDelay: 2),
scheduler: scheduler
)
let result = try await service.generateSongProject(
from: SongProjectGenerationRequest(context: AIRequestContext(userInstruction: "Generate."))
)
let attemptCount = await baseService.generateAttemptCount
let delays = await scheduler.delays
XCTAssertEqual(result.project.title, "Generated")
XCTAssertEqual(attemptCount, 3)
XCTAssertEqual(delays, [0.25, 0.5])
}
func testHonorsProviderRateLimitDelayBeforeRetrying() async throws {
let baseService = ScriptedRetryAIService(
outcomes: [.rateLimited(4), .success("Generated")]
)
let scheduler = RecordingRetryScheduler()
let service = RetryingAIService(
baseService: baseService,
policy: AIServiceRetryPolicy(maxAttempts: 3, baseDelay: 0.25, maximumDelay: 2),
scheduler: scheduler
)
_ = try await service.generateSongProject(
from: SongProjectGenerationRequest(context: AIRequestContext(userInstruction: "Generate."))
)
let delays = await scheduler.delays
XCTAssertEqual(delays, [4])
}
func testDoesNotRetryPermanentFailures() async {
let baseService = ScriptedRetryAIService(outcomes: [.missingAPIKey])
let scheduler = RecordingRetryScheduler()
let service = RetryingAIService(baseService: baseService, scheduler: scheduler)
do {
_ = try await service.generateSongProject(
from: SongProjectGenerationRequest(context: AIRequestContext(userInstruction: "Generate."))
)
XCTFail("Expected missing key error to throw.")
} catch let error as OpenAIClientError {
XCTAssertEqual(error, .missingAPIKey)
} catch {
XCTFail("Expected OpenAIClientError.")
}
let attemptCount = await baseService.generateAttemptCount
let delays = await scheduler.delays
XCTAssertEqual(attemptCount, 1)
XCTAssertTrue(delays.isEmpty)
}
func testCancellationWhileWaitingStopsFurtherRetries() async {
let baseService = ScriptedRetryAIService(outcomes: [.serverFailure(503), .success("Unused")])
let scheduler = RecordingRetryScheduler(shouldCancel: true)
let service = RetryingAIService(baseService: baseService, scheduler: scheduler)
do {
_ = try await service.generateSongProject(
from: SongProjectGenerationRequest(context: AIRequestContext(userInstruction: "Generate."))
)
XCTFail("Expected cancellation to throw.")
} catch is CancellationError {
} catch {
XCTFail("Expected CancellationError.")
}
let attemptCount = await baseService.generateAttemptCount
XCTAssertEqual(attemptCount, 1)
}
}
private enum RetryOutcome: Sendable {
case serverFailure(Int)
case rateLimited(TimeInterval?)
case missingAPIKey
case success(String)
}
private actor ScriptedRetryAIService: AIService {
private var outcomes: [RetryOutcome]
private(set) var generateAttemptCount = 0
init(outcomes: [RetryOutcome]) {
self.outcomes = outcomes
}
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
generateAttemptCount += 1
guard !outcomes.isEmpty else {
return SongProjectGenerationResult(project: SongProject(title: "Unexpected", idea: request.context.userInstruction))
}
switch outcomes.removeFirst() {
case let .serverFailure(statusCode):
throw OpenAIClientError.serverError(statusCode)
case let .rateLimited(retryAfter):
throw OpenAIClientError.rateLimited(retryAfter: retryAfter)
case .missingAPIKey:
throw OpenAIClientError.missingAPIKey
case let .success(title):
return SongProjectGenerationResult(project: SongProject(title: title, idea: request.context.userInstruction))
}
}
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
LyricsRevisionResult(lyrics: request.sourceLyrics)
}
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
SongProjectUpdateResult(project: request.project)
}
}
private actor RecordingRetryScheduler: AIServiceRetryScheduler {
private(set) var delays: [TimeInterval] = []
private let shouldCancel: Bool
init(shouldCancel: Bool = false) {
self.shouldCancel = shouldCancel
}
func sleep(for interval: TimeInterval) async throws {
delays.append(interval)
if shouldCancel {
throw CancellationError()
}
}
}
@@ -0,0 +1,78 @@
import MusicAssistantCore
import XCTest
final class SongGenerationContextTests: XCTestCase {
func testContextIncludesOnlySelectedInstrumentsWithArrangementDetails() {
let project = SongProject(
title: "Instrument Context",
idea: "Use selected instruments in generation",
sections: [
SongSection(id: "intro", type: .intro, title: "Opening"),
SongSection(id: "chorus", type: .chorus, title: "Final Chorus", order: 1)
],
instruments: [
InstrumentTrack(
instrumentId: "oud",
selected: true,
variant: "Arabic Oud",
playingStyle: "tremolo",
role: "lead motif",
autoArrangementEnabled: true,
placements: [
InstrumentPlacement(
sectionId: "intro",
startTime: 0,
endTime: 12,
direction: "solo opening"
)
]
),
InstrumentTrack(
instrumentId: "violin",
selected: false,
variant: "Violin",
role: "deselected counterline"
)
]
)
let context = SongGenerationContext(project: project)
XCTAssertTrue(context.hasSelectedInstruments)
XCTAssertEqual(context.selectedInstruments.map(\.instrumentId), ["oud"])
XCTAssertEqual(context.selectedInstruments.first?.displayName, "Arabic Oud")
XCTAssertEqual(context.selectedInstruments.first?.playingStyle, "tremolo")
XCTAssertEqual(context.selectedInstruments.first?.role, "lead motif")
XCTAssertEqual(context.selectedInstruments.first?.autoArrangementEnabled, true)
XCTAssertEqual(context.selectedInstruments.first?.placements.first?.sectionTitle, "Opening")
XCTAssertEqual(context.selectedInstruments.first?.placements.first?.sectionType, .intro)
XCTAssertEqual(
context.sunoStyleInstrumentPhrase,
"Arabic Oud (lead motif, tremolo, Opening 0s-12s solo opening)"
)
}
func testAIRequestsCarrySongGenerationContextFromProjects() {
let project = SongProject(
title: "Context Request",
idea: "Carry selected instruments",
instruments: [
InstrumentTrack(instrumentId: "qanun", selected: true, variant: "Qanun"),
InstrumentTrack(instrumentId: "piano", selected: false, variant: "Piano")
]
)
let generationRequest = SongProjectGenerationRequest(
context: AIRequestContext(userInstruction: "Generate."),
seedProject: project
)
let updateRequest = SongProjectUpdateRequest(
context: AIRequestContext(userInstruction: "Arrange."),
project: project,
allowedScopes: [.arrangement]
)
XCTAssertEqual(generationRequest.songGenerationContext.selectedInstruments.map(\.instrumentId), ["qanun"])
XCTAssertEqual(updateRequest.songGenerationContext.selectedInstruments.map(\.displayName), ["Qanun"])
}
}
@@ -0,0 +1,94 @@
import MusicAssistantCore
import XCTest
final class SongProjectDiscussionDirectorTests: XCTestCase {
func testDiscussBuildsARequestAndReturnsTrimmedQuestionsWithoutChangingProject() async throws {
let project = SongProject(
id: "discussion-project",
title: "Discussion Project",
idea: "Plan a cinematic chorus",
conversationMode: .discuss,
createdAt: Date(timeIntervalSince1970: 10),
updatedAt: Date(timeIntervalSince1970: 20)
)
let aiService = RecordingDiscussionAIService(
result: SongProjectDiscussionResult(
questions: [" Should the chorus use a choir? ", " "],
notes: ["Awaiting the user's choice."]
)
)
let director = SongProjectDiscussionDirector(aiService: aiService)
let conversation = [AIConversationMessage(role: .assistant, content: "What mood should lead?")]
let result = try await director.discuss(
project: project,
userMessage: " Keep the verses intimate. ",
conversation: conversation,
localeIdentifier: "en_US"
)
let request = await aiService.recordedRequests.first
XCTAssertEqual(request?.context.userInstruction, "Keep the verses intimate.")
XCTAssertEqual(request?.context.conversation, conversation)
XCTAssertEqual(request?.context.localeIdentifier, "en_US")
XCTAssertEqual(request?.project, project)
XCTAssertEqual(result.questions, ["Should the chorus use a choir?"])
XCTAssertEqual(result.notes, ["Awaiting the user's choice."])
}
func testDiscussRejectsAutoModeAndBlankMessagesBeforeCallingAI() async throws {
let aiService = RecordingDiscussionAIService(
result: SongProjectDiscussionResult(questions: ["Unused"])
)
let director = SongProjectDiscussionDirector(aiService: aiService)
do {
_ = try await director.discuss(
project: SongProject(title: "Auto", idea: "Auto", conversationMode: .auto),
userMessage: "Ask a question."
)
XCTFail("Expected discussion mode validation to fail.")
} catch let error as SongProjectDiscussionDirectorError {
XCTAssertEqual(error, .discussionModeNotEnabled)
}
do {
_ = try await director.discuss(
project: SongProject(title: "Discuss", idea: "Discuss", conversationMode: .discuss),
userMessage: " \n "
)
XCTFail("Expected blank message validation to fail.")
} catch let error as SongProjectDiscussionDirectorError {
XCTAssertEqual(error, .emptyUserMessage)
}
let requestCount = await aiService.recordedRequests.count
XCTAssertEqual(requestCount, 0)
}
}
private actor RecordingDiscussionAIService: AIService {
private(set) var recordedRequests: [SongProjectDiscussionRequest] = []
private let result: SongProjectDiscussionResult
init(result: SongProjectDiscussionResult) {
self.result = result
}
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
SongProjectGenerationResult(project: request.seedProject ?? SongProject(title: "Unused", idea: "Unused"))
}
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
recordedRequests.append(request)
return result
}
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
LyricsRevisionResult(lyrics: request.sourceLyrics)
}
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
SongProjectUpdateResult(project: request.project)
}
}
@@ -105,6 +105,42 @@ final class SongProjectGenerationDirectorTests: XCTestCase {
let requestCount = await aiService.recordedGenerationRequests.count
XCTAssertEqual(requestCount, 0)
}
func testGenerateCompleteProjectPreservesLockedAndManualSeedValues() async throws {
let seedProject = SongProject(
id: "locked-seed",
title: "Seed",
idea: "Original",
genres: [GenreStyle(id: "arabic-pop", name: "Arabic Pop")],
bpm: ManualAutoValue(mode: .manual, value: 96),
lyrics: Lyrics(text: "User-approved lyrics"),
userLocks: SongProjectUserLocks(scopes: [.genres, .lyrics])
)
let aiService = RecordingGenerationAIService(
generationResult: SongProjectGenerationResult(
project: SongProject(
title: "Generated Title",
idea: "Generated idea",
genres: [GenreStyle(id: "rock", name: "Rock")],
bpm: ManualAutoValue(mode: .auto, value: 120),
lyrics: Lyrics(text: "AI lyrics")
)
)
)
let director = SongProjectGenerationDirector(aiService: aiService)
let result = try await director.generateCompleteProject(
fromIdea: "Generate around the locked choices",
seedProject: seedProject
)
XCTAssertEqual(result.project.title, "Generated Title")
XCTAssertEqual(result.project.idea, "Generated idea")
XCTAssertEqual(result.project.genres, seedProject.genres)
XCTAssertEqual(result.project.lyrics, seedProject.lyrics)
XCTAssertEqual(result.project.bpm, seedProject.bpm)
XCTAssertEqual(result.project.userLocks, seedProject.userLocks)
}
}
private actor RecordingGenerationAIService: AIService {
@@ -281,6 +281,40 @@ final class SongProjectModelTests: XCTestCase {
XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [introPlacement])
}
func testCatalogSelectionCanBeToggledWithoutReplacingExistingArrangement() {
let oudPlacement = InstrumentPlacement(sectionId: "intro", direction: "Opening melody")
var project = SongProject(
id: "catalog-selection-project",
title: "Catalog Selection",
idea: "Select and deselect instruments",
instruments: [
InstrumentTrack(
instrumentId: "oud",
selected: false,
variant: "Arabic oud",
playingStyle: "tremolo",
role: "main hook",
autoArrangementEnabled: false,
placements: [oudPlacement]
)
]
)
project.setInstrumentSelected(id: "oud", isSelected: true, variant: "Oud")
XCTAssertTrue(project.isInstrumentSelected(id: "oud"))
XCTAssertEqual(project.instrumentTrack(for: "oud")?.variant, "Arabic oud")
XCTAssertEqual(project.instrumentTrack(for: "oud")?.role, "main hook")
XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [oudPlacement])
project.setInstrumentSelected(id: "oud", isSelected: false)
project.setInstrumentSelected(id: "violin", isSelected: true, variant: "Violin")
XCTAssertFalse(project.isInstrumentSelected(id: "oud"))
XCTAssertTrue(project.isInstrumentSelected(id: "violin"))
XCTAssertEqual(project.instrumentTrack(for: "violin")?.variant, "Violin")
}
func testMultipleVocalistsCanBeAssignedToSections() {
var project = SongProject(
id: "project-6",
@@ -369,6 +403,36 @@ final class SongProjectModelTests: XCTestCase {
XCTAssertEqual(project.languageIDs, ["english"])
}
func testApplyingReviewedArabicLyricsUpdatesMatchingSunoLyrics() {
var project = SongProject(
id: "project-arabic-review",
title: "Arabic Review",
idea: "Review pronunciation",
lyrics: Lyrics(text: "Original lyrics"),
sunoOutput: SunoOutput(lyricsText: "Original lyrics", stylePrompt: "Arabic pop")
)
project.applyReviewedArabicLyrics("Reviewed lyrics")
XCTAssertEqual(project.lyrics.text, "Reviewed lyrics")
XCTAssertEqual(project.sunoOutput?.lyricsText, "Reviewed lyrics")
}
func testApplyingReviewedArabicLyricsPreservesEditedSunoLyrics() {
var project = SongProject(
id: "project-arabic-review-manual-output",
title: "Arabic Review",
idea: "Review pronunciation",
lyrics: Lyrics(text: "Original lyrics"),
sunoOutput: SunoOutput(lyricsText: "Manually edited output", stylePrompt: "Arabic pop")
)
project.applyReviewedArabicLyrics("Reviewed lyrics")
XCTAssertEqual(project.lyrics.text, "Reviewed lyrics")
XCTAssertEqual(project.sunoOutput?.lyricsText, "Manually edited output")
}
func testMusicalParametersSupportManualAndAutoModes() {
var project = SongProject(
id: "project-8",
@@ -0,0 +1,145 @@
import MusicAssistantCore
import XCTest
final class SongProjectPromptCompilerTests: XCTestCase {
func testCompilerUsesSelectedInstrumentsInSunoStylePrompt() throws {
let project = SongProject(
title: "Compiled Song",
idea: "Prepare Suno output",
genres: [
GenreStyle(id: "arabic-pop", name: "Arabic Pop"),
GenreStyle(id: "cinematic", name: "Cinematic")
],
moods: [MoodTag(id: "hopeful", name: "Hopeful")],
bpm: ManualAutoValue(mode: .manual, value: 96),
maqam: ManualAutoValue(mode: .manual, value: "Hijaz"),
sections: [
SongSection(id: "chorus", type: .chorus, title: "Chorus", lyrics: "Section chorus")
],
instruments: [
InstrumentTrack(
instrumentId: "oud",
selected: true,
variant: "Arabic Oud",
playingStyle: "picked",
role: "main hook",
placements: [InstrumentPlacement(sectionId: "chorus", direction: "answer the vocal")]
),
InstrumentTrack(
instrumentId: "drum-kit",
selected: false,
variant: "Drum Kit"
)
],
vocalists: [Vocalist(id: "lead", label: "Lead", voiceType: "warm tenor")],
lyrics: Lyrics(text: "Approved lyrics"),
productionDirections: [ProductionDirection(id: "lift", text: "wide chorus lift")]
)
let output = try SongProjectPromptCompiler().compile(project: project)
XCTAssertEqual(output.lyricsText, "Approved lyrics")
XCTAssertTrue(output.stylePrompt.contains("Arabic Pop + Cinematic"))
XCTAssertTrue(output.stylePrompt.contains("Arabic Oud (main hook, picked, Chorus answer the vocal)"))
XCTAssertTrue(output.stylePrompt.contains("Lead warm tenor"))
XCTAssertTrue(output.stylePrompt.contains("96 BPM"))
XCTAssertTrue(output.stylePrompt.contains("maqam Hijaz"))
XCTAssertTrue(output.stylePrompt.contains("wide chorus lift"))
XCTAssertFalse(output.stylePrompt.contains("Drum Kit"))
}
func testCompilerFallsBackToOrderedSectionLyrics() throws {
let project = SongProject(
title: "Section Lyrics",
idea: "Compile sections",
sections: [
SongSection(id: "chorus", type: .chorus, title: "Chorus", order: 1, lyrics: "Hook line"),
SongSection(id: "verse", type: .verse, title: "Verse", order: 0, lyrics: "Verse line")
]
)
let output = try SongProjectPromptCompiler().compile(project: project)
XCTAssertEqual(output.lyricsText, "[Verse]\nVerse line\n\n[Chorus]\nHook line")
}
func testCompilerIncludesSectionPerformanceDirectivesInFallbackLyrics() throws {
let project = SongProject(
title: "Directed Sections",
idea: "Compile performance directions",
sections: [
SongSection(
id: "intro",
type: .intro,
title: "Intro",
lyrics: "First line",
vocalDirection: "whispered lead"
),
SongSection(
id: "chorus",
type: .chorus,
title: "Chorus",
order: 1,
lyrics: "Original hook"
)
],
lyrics: Lyrics(sectionTexts: ["chorus": "Approved hook"])
)
let output = try SongProjectPromptCompiler().compile(project: project)
XCTAssertEqual(
output.lyricsText,
"[Intro - whispered lead]\nFirst line\n\n[Chorus]\nApproved hook"
)
}
func testCompilerIncludesStyleInputsFromTheSongProject() throws {
let project = SongProject(
title: "Full Style",
idea: "Compile every approved style input",
genres: [
GenreStyle(id: "folk", name: "Folk"),
GenreStyle(id: "electronic", name: "Electronic")
],
moods: [MoodTag(id: "warm", name: "Warm")],
emotionalArc: [
EmotionalArcPoint(
id: "chorus-arc",
sectionID: "chorus",
emotion: "joyful",
energy: 8,
direction: "lift into release"
)
],
bpm: ManualAutoValue(mode: .manual, value: 108),
key: ManualAutoValue(mode: .manual, value: "D minor"),
scale: ManualAutoValue(mode: .manual, value: "Dorian"),
sections: [
SongSection(
id: "verse",
type: .verse,
title: "Verse",
emotion: "yearning",
energy: 3,
productionDirection: "intimate opening"
),
SongSection(id: "chorus", type: .chorus, title: "Chorus", order: 1)
],
vocalists: [
Vocalist(id: "lead", label: "Lead", voiceType: "clear alto", performanceStyle: "expressive")
],
productionDirections: [
ProductionDirection(id: "chorus-width", sectionID: "chorus", text: "wide chorus"),
ProductionDirection(id: "master", text: "warm tape saturation")
]
)
let output = try SongProjectPromptCompiler().compile(project: project)
XCTAssertEqual(
output.stylePrompt,
"Folk + Electronic; mood: Warm; Lead clear alto expressive; 108 BPM, key D minor, Dorian scale; emotion: Verse, yearning, energy 3; Chorus, joyful, energy 8, lift into release; production: Verse, intimate opening; Chorus, wide chorus; warm tape saturation"
)
}
}
@@ -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)
}
}
@@ -105,10 +105,69 @@ final class StructuredAIResponseSchemaTests: XCTestCase {
XCTAssertEqual(result.project.createdAt, createdAt)
XCTAssertEqual(result.project.updatedAt, updatedAt)
XCTAssertEqual(result.project.versions, [version])
XCTAssertEqual(result.project.title, "Updated")
XCTAssertEqual(result.project.title, "Original")
XCTAssertEqual(result.project.lyrics.text, "Updated lyrics")
XCTAssertEqual(result.changedScopes, [.genres, .lyrics])
XCTAssertEqual(result.notes, ["Proposed focused update."])
XCTAssertEqual(result.followUpQuestions, ["Keep it minimal?"])
}
func testStructuredResponsePreservesLockedScopesAndManualValues() {
let section = SongSection(id: "verse", type: .verse, title: "Manual Verse")
let manualInstrument = InstrumentTrack(
instrumentId: "oud",
selected: true,
role: "Manual lead",
autoArrangementEnabled: false,
placements: [InstrumentPlacement(sectionId: "verse", direction: "Manual entry")]
)
let existingProject = SongProject(
title: "Original Title",
idea: "Original Idea",
genres: [GenreStyle(id: "arabic-pop", name: "Arabic Pop")],
bpm: ManualAutoValue(mode: .manual, value: 96),
key: ManualAutoValue(mode: .auto),
structureMode: .manual,
sections: [section],
instruments: [manualInstrument],
lyrics: Lyrics(text: "Locked lyrics"),
userLocks: SongProjectUserLocks(scopes: [.genres, .lyrics])
)
let response = StructuredSongProjectUpdateResponse(
project: SongProjectSnapshot(
title: "AI Title",
idea: "AI Idea",
genres: [GenreStyle(id: "rock", name: "Rock")],
bpm: ManualAutoValue(mode: .auto, value: 120),
key: ManualAutoValue(mode: .auto, value: "A"),
sections: [SongSection(id: "chorus", type: .chorus, title: "AI Chorus")],
instruments: [
InstrumentTrack(
instrumentId: "oud",
selected: true,
role: "AI lead",
placements: [InstrumentPlacement(sectionId: "chorus", direction: "AI entry")]
)
],
lyrics: Lyrics(text: "AI lyrics")
),
changedScopes: [.genres, .lyrics, .instruments, .musicalParameters, .structure, .arrangement]
)
let result = response.makeUpdateResult(
updating: existingProject,
updatedAt: Date(timeIntervalSince1970: 500)
)
XCTAssertEqual(result.project.title, "Original Title")
XCTAssertEqual(result.project.idea, "Original Idea")
XCTAssertEqual(result.project.genres, existingProject.genres)
XCTAssertEqual(result.project.lyrics, existingProject.lyrics)
XCTAssertEqual(result.project.bpm, existingProject.bpm)
XCTAssertEqual(result.project.key, ManualAutoValue(mode: .auto, value: "A"))
XCTAssertEqual(result.project.sections, existingProject.sections)
XCTAssertEqual(result.project.instruments, existingProject.instruments)
XCTAssertEqual(result.project.userLocks, existingProject.userLocks)
XCTAssertEqual(result.changedScopes, [.musicalParameters])
}
}
@@ -0,0 +1,19 @@
import MusicAssistantCore
import XCTest
final class SunoOutputValidationTests: XCTestCase {
func testValidatorRejectsOutputWithoutSunoContent() {
let output = CompiledSunoOutput(lyricsText: " \n ", stylePrompt: "\t")
XCTAssertThrowsError(try CompiledSunoOutputValidator().validate(output)) { error in
XCTAssertEqual(error as? SunoOutputValidationError, .missingContent)
}
}
func testValidatorAllowsLyricsOrAStylePrompt() throws {
let validator = CompiledSunoOutputValidator()
XCTAssertNoThrow(try validator.validate(CompiledSunoOutput(lyricsText: "Lyrics", stylePrompt: "")))
XCTAssertNoThrow(try validator.validate(CompiledSunoOutput(lyricsText: "", stylePrompt: "Cinematic folk")))
}
}
+59 -7
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
@@ -40,11 +42,40 @@ macOS App
OpenAI receives the user's intent plus private application rules and
structured project state. AI output should be requested as structured
data wherever possible, not treated as an unstructured chat transcript.
For automatic decisions, the AI Director requests only enabled automatic
scopes and merges the response into the current project while preserving
manual structure, arrangement, musical-parameter and production choices.
When a Song Project uses Discuss mode, the AI Director sends the current
project and conversation context to the provider and returns follow-up
questions without applying a project update.
All structured AI updates pass through a scope-aware merger. It applies only
the scopes declared by the response, ignores user-locked scopes and preserves
manual control values.
AI provider calls can be wrapped by a configurable retry service. It retries only
temporary network, server and rate-limit failures, honors a provider-supplied
retry delay when available, and propagates cancellation without retrying.
## Prompt Compiler
A deterministic layer converts the approved SongProject into the final
Suno-facing lyrics/style content. User choices override AI suggestions.
It depends only on approved domain data and local validation; it does
not invoke the OpenAI client or reference SwiftUI presentation code.
## Arabic Pronunciation Processor
The Arabic Pronunciation Processor is a local Application Service. It
normalizes user-supplied Arabic diacritics and applies the selected
tanween policy without choosing a diacritization policy for the user.
It must not invent missing vowel marks or tanween when no reliable
linguistic source is available; instead, it returns a review note so a
later user-review flow can present the unresolved text. This processor
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 preparing the final local
output.
## Security
@@ -54,11 +85,10 @@ Suno-facing lyrics/style content. User choices override AI suggestions.
and providers can be changed later.
- Do not log secrets or full authorization headers.
## Suno Boundary
## External Generation Boundary
The initial requirement is browser/site handoff that fills fields but
does not press Generate. Treat this integration as replaceable because
website UI/behavior can change.
The application prepares final lyrics and style prompts but does not
open, embed or control an external music-generation service.
## Instrument Selector Boundary
@@ -83,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.
+49
View File
@@ -20,11 +20,14 @@ SongProject
- key
- scale
- maqam
- structureMode: manual | auto
- sections[]
- instruments[]
- vocalists[]
- lyrics
- productionDirections[]
- productionMode: manual | auto
- userLocks[]
- sunoOutput
- versions[]
- createdAt
@@ -38,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)
@@ -59,6 +88,26 @@ InstrumentTrack
- autoArrangementEnabled
- placements[]
Auto-decision behavior
- The AI may update song sections only when structureMode is auto.
- The AI may update an instrument's role, playing style and placements only
when autoArrangementEnabled is true for that selected instrument.
- The AI may fill BPM, key, scale and maqam only when their individual
ManualAutoValue mode is auto.
- The AI may update production directions only when productionMode is auto.
- Manual values remain authoritative when automatic decisions are applied.
Discuss mode behavior
- When conversationMode is discuss, the AI receives the current Song Project
and conversation context, then returns follow-up questions before a project
decision is applied.
- A discussion response does not itself modify the Song Project.
User-lock behavior
- userLocks records update scopes explicitly locked by the user.
- AI updates for locked scopes are ignored; every other accepted AI update must
still preserve Manual/Auto choices.
InstrumentPlacement
- sectionId (optional)
- startTime (optional)
+6 -6
View File
@@ -7,17 +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.
- Exact Suno fields to populate and supported handoff mechanism.
- 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 -10
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,13 +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 opens Suno and fills the appropriate fields. It does not
trigger Generate.
## 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
@@ -38,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.
@@ -73,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
@@ -91,6 +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 prepares the project and fills Suno fields only. 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.
+44 -42
View File
@@ -59,77 +59,79 @@ requirement is missing and blocks implementation, record it in
- [x] Define structured AI response schema for SongProject updates.
- [x] Implement idea → complete project generation.
- [x] Implement existing lyrics → correction/improvement flow.
- [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam
- [x] Implement Auto mode for structure, arrangement, BPM/key/maqam
and production decisions.
- [ ] Implement optional Discuss mode.
- [ ] Enforce user-lock/manual-value precedence over AI output.
- [ ] Add error, retry, cancellation and rate-limit handling.
- [x] Implement optional Discuss mode.
- [x] Enforce user-lock/manual-value precedence over AI output.
- [x] Add error, retry, cancellation and rate-limit handling.
## Phase 5 --- Arabic Lyrics Processing
- [ ] Add Arabic-specific settings UI.
- [ ] Support diacritics/harakat/tanween processing.
- [ ] Preserve intentional spelling/dialect choices where possible.
- [ ] Allow user to compare/edit processed Arabic before Suno handoff.
- [ ] Add Arabic test fixtures covering multiple dialects.
- [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 final review.
- [x] Add Arabic test fixtures covering multiple dialects.
## Phase 6 --- Instrument Selector & Catalog
- [ ] Create structured catalog models for instrument id, name,
- [x] Create structured catalog models for instrument id, name,
family/category, optional region/origin and aliases/search terms.
- [ ] Provide large worldwide catalog data covering Western, Middle
- [x] Provide large worldwide catalog data covering Western, Middle
Eastern, African, South Asian, East Asian, Southeast Asian, Latin
American, traditional, folk, orchestral, electronic and modern
instruments.
- [ ] Keep catalog data out of SwiftUI views.
- [ ] Build searchable instrument browser.
- [ ] Add browsing/filtering by family/category.
- [ ] Add browsing/filtering by region/origin where useful.
- [ ] Add checkbox-based multi-select and deselect behavior.
- [ ] Persist selected instruments on the current Song Project.
- [ ] Make selected instruments available to OpenAI/song-generation
- [x] Keep catalog data out of SwiftUI views.
- [x] Build searchable instrument browser.
- [x] Add browsing/filtering by family/category.
- [x] Add browsing/filtering by region/origin where useful.
- [x] Add checkbox-based multi-select and deselect behavior.
- [x] Persist selected instruments on the current Song Project.
- [x] Make selected instruments available to OpenAI/song-generation
logic for arrangement, roles, entry/exit timing, relevant structure
decisions and Suno Style Prompt generation.
- [ ] Preserve existing Manual/Auto arrangement behavior.
- [ ] Add tests for catalog search, category/region filtering,
- [x] Preserve existing Manual/Auto arrangement behavior.
- [x] Add tests for catalog search, category/region filtering,
selection persistence and SongProject serialization.
## Phase 7 --- Prompt Compiler
- [ ] Create deterministic compiler from approved SongProject → Suno
- [x] Create deterministic compiler from approved SongProject → Suno
output.
- [ ] Generate lyrics text with section/performance directives where
- [x] Generate lyrics text with section/performance directives where
appropriate.
- [ ] Generate style prompt from genre blend, instrumentation, vocals,
- [x] Generate style prompt from genre blend, instrumentation, vocals,
tempo, harmony, emotion and production instructions.
- [ ] Validate output before handoff.
- [ ] Keep compiler independent from UI and OpenAI client.
- [x] Validate output before handoff.
- [x] Keep compiler independent from UI and OpenAI client.
## Phase 8 --- Suno Handoff
## Removed --- External Generation Handoff
- [ ] Implement explicit `Send to Suno` action.
- [ ] Open the appropriate Suno creation surface.
- [ ] Fill supported fields with approved project output.
- [ ] Never trigger Generate automatically.
- [ ] Detect/report when fields cannot be filled rather than silently
failing.
- [ ] 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.