Compare commits
48
Commits
bbf5f191f2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9af5c21d8 | ||
|
|
c8242a844a | ||
|
|
b43c5416f2 | ||
|
|
297b791a3e | ||
|
|
03bfd3ef75 | ||
|
|
bef00b19e1 | ||
|
|
0307df9442 | ||
|
|
2d07ee70d8 | ||
|
|
2145732b66 | ||
|
|
9b4db4413b | ||
|
|
fac99e2c2d | ||
|
|
6023214cb3 | ||
|
|
f199bcf114 | ||
|
|
f42d782629 | ||
|
|
1ebb5aadc3 | ||
|
|
cd066d0a82 | ||
|
|
3ed4046d73 | ||
|
|
de0b8e5972 | ||
|
|
d7901ef9d7 | ||
|
|
52978a375d | ||
|
|
3fced12bef | ||
|
|
448d390671 | ||
|
|
61267e201e | ||
|
|
e4b899d83b | ||
|
|
c7640470c6 | ||
|
|
9e6e94e7cb | ||
|
|
4fa477bdb0 | ||
|
|
7bf4df79f9 | ||
|
|
35703f89a8 | ||
|
|
2b5ba2b535 | ||
|
|
04d4093a3c | ||
|
|
6c6aeae5bb | ||
|
|
9ddac746e5 | ||
|
|
ecbf07d1ea | ||
|
|
b2bf04e78b | ||
|
|
b51102b65d | ||
|
|
e7a46772ec | ||
|
|
9a0dd452bd | ||
|
|
6a99266e75 | ||
|
|
3865f77dc0 | ||
|
|
f5ab97e6a0 | ||
|
|
49a03a0700 | ||
|
|
eb61bd076c | ||
|
|
ae88c6e533 | ||
|
|
a39bdbe6fc | ||
|
|
bf1ccbe8a9 | ||
|
|
1bf84ca049 | ||
|
|
5e1d3d7246 |
@@ -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)
|
||||
|
||||
@@ -1,50 +1,8 @@
|
||||
<<<<<<< HEAD
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
var body: some View {
|
||||
ProjectBrowserView()
|
||||
=======
|
||||
import MusicAssistantCore
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
private let appName = "AI Music Studio"
|
||||
private let boundarySummary = ProductBoundarySummary.initial
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
Label("Projects", systemImage: "music.note.list")
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
.navigationTitle(appName)
|
||||
} detail: {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("New Song Project")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
|
||||
Text(boundarySummary.description)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 560, alignment: .leading)
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Foundation status")
|
||||
.font(.headline)
|
||||
|
||||
Label("Presentation, Domain, Services, Integrations, and Persistence boundaries are separated.", systemImage: "checkmark.circle")
|
||||
Label("External providers are represented by protocols only.", systemImage: "lock.shield")
|
||||
Label("Secrets are expected from local environment configuration.", systemImage: "key")
|
||||
}
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.padding(32)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
>>>>>>> origin/master
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import Foundation
|
||||
|
||||
public struct AppConfiguration: Equatable, Sendable {
|
||||
public let openAIAPIKey: String?
|
||||
<<<<<<< HEAD
|
||||
public let privateApplicationRules: PrivateApplicationRules?
|
||||
|
||||
public init(
|
||||
@@ -11,21 +10,12 @@ public struct AppConfiguration: Equatable, Sendable {
|
||||
) {
|
||||
self.openAIAPIKey = openAIAPIKey
|
||||
self.privateApplicationRules = privateApplicationRules
|
||||
=======
|
||||
|
||||
public init(openAIAPIKey: String?) {
|
||||
self.openAIAPIKey = openAIAPIKey
|
||||
>>>>>>> origin/master
|
||||
}
|
||||
|
||||
public static func development(environment: [String: String] = ProcessInfo.processInfo.environment) -> AppConfiguration {
|
||||
AppConfiguration(
|
||||
<<<<<<< HEAD
|
||||
openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"],
|
||||
privateApplicationRules: PrivateApplicationRules(content: environment["MUSIC_ASSISTANT_PRIVATE_AI_RULES"])
|
||||
=======
|
||||
openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"]
|
||||
>>>>>>> origin/master
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
@@ -343,7 +424,6 @@ public extension SongProject {
|
||||
versions.first { $0.id == versionID }
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
mutating func restoreVersion(id versionID: String, restoredAt: Date = Date()) -> Bool {
|
||||
guard let version = version(id: versionID) else { return false }
|
||||
restoreSnapshot(version.snapshot, updatedAt: restoredAt)
|
||||
@@ -365,17 +445,18 @@ 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
|
||||
}
|
||||
|
||||
=======
|
||||
>>>>>>> origin/master
|
||||
mutating func appendSection(_ section: SongSection) {
|
||||
var orderedSection = section
|
||||
orderedSection.order = nextSectionOrder
|
||||
@@ -454,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
|
||||
@@ -513,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
|
||||
@@ -689,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
|
||||
@@ -767,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(
|
||||
@@ -789,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
|
||||
@@ -810,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
|
||||
}
|
||||
|
||||
@@ -834,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
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
<<<<<<< HEAD
|
||||
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
|
||||
@@ -55,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,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]
|
||||
@@ -167,24 +199,5 @@ public struct SongProjectUpdateResult: Equatable, Sendable {
|
||||
self.changedScopes = changedScopes
|
||||
self.notes = notes
|
||||
self.followUpQuestions = followUpQuestions
|
||||
=======
|
||||
public protocol AIService: Sendable {
|
||||
func planSongProject(from request: SongPlanningRequest) async throws -> SongPlanningDraft
|
||||
}
|
||||
|
||||
public struct SongPlanningRequest: Equatable, Sendable {
|
||||
public let userIntent: String
|
||||
|
||||
public init(userIntent: String) {
|
||||
self.userIntent = userIntent
|
||||
}
|
||||
}
|
||||
|
||||
public struct SongPlanningDraft: Equatable, Sendable {
|
||||
public let summary: String
|
||||
|
||||
public init(summary: String) {
|
||||
self.summary = summary
|
||||
>>>>>>> origin/master
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
public final class ExistingLyricsRevisionDirector: Sendable {
|
||||
private let aiService: any AIService
|
||||
|
||||
public init(aiService: any AIService) {
|
||||
self.aiService = aiService
|
||||
}
|
||||
|
||||
public func reviseExistingLyrics(
|
||||
in project: SongProject,
|
||||
sourceLyrics: Lyrics? = nil,
|
||||
instruction: String,
|
||||
mode: LyricsRevisionMode,
|
||||
conversation: [AIConversationMessage] = [],
|
||||
localeIdentifier: String? = nil,
|
||||
revisedAt: Date = Date()
|
||||
) async throws -> SongProjectUpdateResult {
|
||||
guard mode != .writeFromIdea else {
|
||||
throw ExistingLyricsRevisionDirectorError.unsupportedMode(mode)
|
||||
}
|
||||
|
||||
let trimmedInstruction = instruction.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedInstruction.isEmpty else {
|
||||
throw ExistingLyricsRevisionDirectorError.emptyInstruction
|
||||
}
|
||||
|
||||
let lyricsToRevise = sourceLyrics ?? project.lyrics
|
||||
guard !lyricsToRevise.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw ExistingLyricsRevisionDirectorError.emptySourceLyrics
|
||||
}
|
||||
|
||||
guard !project.isUpdateScopeLocked(.lyrics) else {
|
||||
return SongProjectUpdateResult(project: project)
|
||||
}
|
||||
|
||||
let request = LyricsRevisionRequest(
|
||||
context: AIRequestContext(
|
||||
userInstruction: trimmedInstruction,
|
||||
conversation: conversation,
|
||||
localeIdentifier: localeIdentifier
|
||||
),
|
||||
project: project,
|
||||
sourceLyrics: lyricsToRevise,
|
||||
mode: mode
|
||||
)
|
||||
let result = try await aiService.reviseLyrics(from: request)
|
||||
|
||||
var proposedProject = project
|
||||
proposedProject.lyrics = result.lyrics
|
||||
return SongProjectAIUpdateMerger().apply(
|
||||
snapshot: SongProjectSnapshot(project: proposedProject),
|
||||
changedScopes: [.lyrics],
|
||||
notes: result.notes,
|
||||
to: project,
|
||||
updatedAt: revisedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public enum ExistingLyricsRevisionDirectorError: Error, Equatable, Sendable {
|
||||
case emptyInstruction
|
||||
case emptySourceLyrics
|
||||
case unsupportedMode(LyricsRevisionMode)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
|
||||
public final class SongProjectGenerationDirector: Sendable {
|
||||
private let aiService: any AIService
|
||||
|
||||
public init(aiService: any AIService) {
|
||||
self.aiService = aiService
|
||||
}
|
||||
|
||||
public func generateCompleteProject(
|
||||
fromIdea idea: String,
|
||||
seedProject: SongProject? = nil,
|
||||
discussionMode: ConversationMode = .auto,
|
||||
conversation: [AIConversationMessage] = [],
|
||||
localeIdentifier: String? = nil,
|
||||
generatedAt: Date = Date()
|
||||
) async throws -> SongProjectGenerationResult {
|
||||
let trimmedIdea = idea.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedIdea.isEmpty else {
|
||||
throw SongProjectGenerationDirectorError.emptyIdea
|
||||
}
|
||||
|
||||
let request = SongProjectGenerationRequest(
|
||||
context: AIRequestContext(
|
||||
userInstruction: trimmedIdea,
|
||||
conversation: conversation,
|
||||
localeIdentifier: localeIdentifier
|
||||
),
|
||||
seedProject: seedProject,
|
||||
discussionMode: discussionMode
|
||||
)
|
||||
let result = try await aiService.generateSongProject(from: request)
|
||||
let project = normalizeGeneratedProject(
|
||||
result.project,
|
||||
idea: trimmedIdea,
|
||||
seedProject: seedProject,
|
||||
discussionMode: discussionMode,
|
||||
generatedAt: generatedAt
|
||||
)
|
||||
|
||||
return SongProjectGenerationResult(
|
||||
project: project,
|
||||
notes: result.notes,
|
||||
followUpQuestions: result.followUpQuestions
|
||||
)
|
||||
}
|
||||
|
||||
private func normalizeGeneratedProject(
|
||||
_ generatedProject: SongProject,
|
||||
idea: String,
|
||||
seedProject: SongProject?,
|
||||
discussionMode: ConversationMode,
|
||||
generatedAt: Date
|
||||
) -> SongProject {
|
||||
let normalizedIdea = generatedProject.idea.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? idea
|
||||
: generatedProject.idea
|
||||
|
||||
guard let seedProject else {
|
||||
var project = generatedProject
|
||||
project.idea = normalizedIdea
|
||||
project.conversationMode = discussionMode
|
||||
project.updatedAt = generatedAt
|
||||
return project
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
public enum SongProjectGenerationDirectorError: Error, Equatable, Sendable {
|
||||
case emptyIdea
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+204
@@ -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: ¶meters)
|
||||
append(project.scale?.value.map { "\($0) scale" }, to: ¶meters)
|
||||
append(project.maqam?.value.map { "maqam \($0)" }, to: ¶meters)
|
||||
|
||||
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)"),
|
||||
|
||||
@@ -4,28 +4,18 @@ import XCTest
|
||||
final class AppConfigurationTests: XCTestCase {
|
||||
func testDevelopmentConfigurationReadsKnownEnvironmentKeys() {
|
||||
let configuration = AppConfiguration.development(environment: [
|
||||
<<<<<<< HEAD
|
||||
"MUSIC_ASSISTANT_OPENAI_API_KEY": "openai-test-key",
|
||||
"MUSIC_ASSISTANT_PRIVATE_AI_RULES": " private product rules "
|
||||
])
|
||||
|
||||
XCTAssertEqual(configuration.openAIAPIKey, "openai-test-key")
|
||||
XCTAssertEqual(configuration.privateApplicationRules?.content, "private product rules")
|
||||
=======
|
||||
"MUSIC_ASSISTANT_OPENAI_API_KEY": "openai-test-key"
|
||||
])
|
||||
|
||||
XCTAssertEqual(configuration.openAIAPIKey, "openai-test-key")
|
||||
>>>>>>> origin/master
|
||||
}
|
||||
|
||||
func testDevelopmentConfigurationAllowsMissingSecrets() {
|
||||
let configuration = AppConfiguration.development(environment: [:])
|
||||
|
||||
XCTAssertNil(configuration.openAIAPIKey)
|
||||
<<<<<<< HEAD
|
||||
XCTAssertNil(configuration.privateApplicationRules)
|
||||
=======
|
||||
>>>>>>> origin/master
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import MusicAssistantCore
|
||||
import XCTest
|
||||
|
||||
final class ExistingLyricsRevisionDirectorTests: XCTestCase {
|
||||
func testReviseExistingLyricsBuildsRevisionRequestAndUpdatesProjectLyrics() async throws {
|
||||
let revisedAt = Date(timeIntervalSince1970: 500)
|
||||
let project = SongProject(
|
||||
id: "lyric-project",
|
||||
title: "Lyric Project",
|
||||
idea: "Make the hook stronger",
|
||||
lyrics: Lyrics(text: "Original lines"),
|
||||
createdAt: Date(timeIntervalSince1970: 100),
|
||||
updatedAt: Date(timeIntervalSince1970: 200)
|
||||
)
|
||||
let aiService = RecordingLyricsAIService(
|
||||
revisionResult: LyricsRevisionResult(
|
||||
lyrics: Lyrics(text: "Revised lines"),
|
||||
notes: ["Improved hook phrasing."]
|
||||
)
|
||||
)
|
||||
let director = ExistingLyricsRevisionDirector(aiService: aiService)
|
||||
let conversation = [
|
||||
AIConversationMessage(role: .user, content: "Keep it direct.")
|
||||
]
|
||||
|
||||
let result = try await director.reviseExistingLyrics(
|
||||
in: project,
|
||||
instruction: " Improve the chorus. ",
|
||||
mode: .improve,
|
||||
conversation: conversation,
|
||||
localeIdentifier: "en_US",
|
||||
revisedAt: revisedAt
|
||||
)
|
||||
let recordedRequest = await aiService.recordedRevisionRequests.first
|
||||
|
||||
XCTAssertEqual(recordedRequest?.context.userInstruction, "Improve the chorus.")
|
||||
XCTAssertEqual(recordedRequest?.context.conversation, conversation)
|
||||
XCTAssertEqual(recordedRequest?.context.localeIdentifier, "en_US")
|
||||
XCTAssertEqual(recordedRequest?.project, project)
|
||||
XCTAssertEqual(recordedRequest?.sourceLyrics, Lyrics(text: "Original lines"))
|
||||
XCTAssertEqual(recordedRequest?.mode, .improve)
|
||||
XCTAssertEqual(result.project.id, project.id)
|
||||
XCTAssertEqual(result.project.createdAt, project.createdAt)
|
||||
XCTAssertEqual(result.project.lyrics, Lyrics(text: "Revised lines"))
|
||||
XCTAssertEqual(result.project.updatedAt, revisedAt)
|
||||
XCTAssertEqual(result.changedScopes, [.lyrics])
|
||||
XCTAssertEqual(result.notes, ["Improved hook phrasing."])
|
||||
}
|
||||
|
||||
func testReviseExistingLyricsCanUseExplicitSourceLyricsWithoutMutatingOtherProjectFields() async throws {
|
||||
let project = SongProject(
|
||||
title: "Existing Project",
|
||||
idea: "Complete second verse",
|
||||
genres: [GenreStyle(id: "pop", name: "Pop")],
|
||||
lyrics: Lyrics(text: "Stored project lyrics")
|
||||
)
|
||||
let sourceLyrics = Lyrics(text: "External draft lyrics")
|
||||
let aiService = RecordingLyricsAIService(
|
||||
revisionResult: LyricsRevisionResult(
|
||||
lyrics: Lyrics(text: "Completed external draft")
|
||||
)
|
||||
)
|
||||
let director = ExistingLyricsRevisionDirector(aiService: aiService)
|
||||
|
||||
let result = try await director.reviseExistingLyrics(
|
||||
in: project,
|
||||
sourceLyrics: sourceLyrics,
|
||||
instruction: "Complete this draft.",
|
||||
mode: .complete,
|
||||
revisedAt: Date(timeIntervalSince1970: 900)
|
||||
)
|
||||
let recordedRequest = await aiService.recordedRevisionRequests.first
|
||||
|
||||
XCTAssertEqual(recordedRequest?.sourceLyrics, sourceLyrics)
|
||||
XCTAssertEqual(result.project.title, project.title)
|
||||
XCTAssertEqual(result.project.idea, project.idea)
|
||||
XCTAssertEqual(result.project.genres, project.genres)
|
||||
XCTAssertEqual(result.project.lyrics.text, "Completed external draft")
|
||||
}
|
||||
|
||||
func testReviseExistingLyricsRejectsBlankSourceBeforeCallingAI() async {
|
||||
let aiService = RecordingLyricsAIService(
|
||||
revisionResult: LyricsRevisionResult(lyrics: Lyrics(text: "Unused"))
|
||||
)
|
||||
let director = ExistingLyricsRevisionDirector(aiService: aiService)
|
||||
|
||||
do {
|
||||
_ = try await director.reviseExistingLyrics(
|
||||
in: SongProject(title: "Blank", idea: "Blank", lyrics: Lyrics(text: " \n ")),
|
||||
instruction: "Improve.",
|
||||
mode: .improve
|
||||
)
|
||||
XCTFail("Expected blank source lyrics to throw.")
|
||||
} catch let error as ExistingLyricsRevisionDirectorError {
|
||||
XCTAssertEqual(error, .emptySourceLyrics)
|
||||
} catch {
|
||||
XCTFail("Expected ExistingLyricsRevisionDirectorError.")
|
||||
}
|
||||
|
||||
let requestCount = await aiService.recordedRevisionRequests.count
|
||||
XCTAssertEqual(requestCount, 0)
|
||||
}
|
||||
|
||||
func testReviseExistingLyricsRejectsBlankInstructionAndWriteFromIdeaMode() async {
|
||||
let aiService = RecordingLyricsAIService(
|
||||
revisionResult: LyricsRevisionResult(lyrics: Lyrics(text: "Unused"))
|
||||
)
|
||||
let director = ExistingLyricsRevisionDirector(aiService: aiService)
|
||||
let project = SongProject(title: "Song", idea: "Idea", lyrics: Lyrics(text: "Draft"))
|
||||
|
||||
do {
|
||||
_ = try await director.reviseExistingLyrics(
|
||||
in: project,
|
||||
instruction: " ",
|
||||
mode: .correct
|
||||
)
|
||||
XCTFail("Expected blank instruction to throw.")
|
||||
} catch let error as ExistingLyricsRevisionDirectorError {
|
||||
XCTAssertEqual(error, .emptyInstruction)
|
||||
} catch {
|
||||
XCTFail("Expected ExistingLyricsRevisionDirectorError.")
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await director.reviseExistingLyrics(
|
||||
in: project,
|
||||
instruction: "Write from idea.",
|
||||
mode: .writeFromIdea
|
||||
)
|
||||
XCTFail("Expected unsupported mode to throw.")
|
||||
} catch let error as ExistingLyricsRevisionDirectorError {
|
||||
XCTAssertEqual(error, .unsupportedMode(.writeFromIdea))
|
||||
} catch {
|
||||
XCTFail("Expected ExistingLyricsRevisionDirectorError.")
|
||||
}
|
||||
|
||||
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 {
|
||||
private(set) var recordedRevisionRequests: [LyricsRevisionRequest] = []
|
||||
private let revisionResult: LyricsRevisionResult
|
||||
|
||||
init(revisionResult: LyricsRevisionResult) {
|
||||
self.revisionResult = revisionResult
|
||||
}
|
||||
|
||||
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 {
|
||||
recordedRevisionRequests.append(request)
|
||||
return revisionResult
|
||||
}
|
||||
|
||||
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
|
||||
SongProjectUpdateResult(project: request.project)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import MusicAssistantCore
|
||||
import XCTest
|
||||
|
||||
final class SongProjectGenerationDirectorTests: XCTestCase {
|
||||
func testGenerateCompleteProjectBuildsGenerationRequestFromTrimmedIdea() async throws {
|
||||
let aiService = RecordingGenerationAIService(
|
||||
generationResult: SongProjectGenerationResult(
|
||||
project: SongProject(
|
||||
title: "Generated Song",
|
||||
idea: "Hopeful cinematic pop",
|
||||
genres: [GenreStyle(id: "cinematic-pop", name: "Cinematic Pop")]
|
||||
),
|
||||
notes: ["Completed from idea."]
|
||||
)
|
||||
)
|
||||
let director = SongProjectGenerationDirector(aiService: aiService)
|
||||
let conversation = [
|
||||
AIConversationMessage(role: .user, content: "Use piano.")
|
||||
]
|
||||
|
||||
let result = try await director.generateCompleteProject(
|
||||
fromIdea: " Hopeful cinematic pop ",
|
||||
discussionMode: .discuss,
|
||||
conversation: conversation,
|
||||
localeIdentifier: "en_US",
|
||||
generatedAt: Date(timeIntervalSince1970: 200)
|
||||
)
|
||||
let recordedRequest = await aiService.recordedGenerationRequests.first
|
||||
|
||||
XCTAssertEqual(recordedRequest?.context.userInstruction, "Hopeful cinematic pop")
|
||||
XCTAssertEqual(recordedRequest?.context.conversation, conversation)
|
||||
XCTAssertEqual(recordedRequest?.context.localeIdentifier, "en_US")
|
||||
XCTAssertEqual(recordedRequest?.discussionMode, .discuss)
|
||||
XCTAssertNil(recordedRequest?.seedProject)
|
||||
XCTAssertEqual(result.project.conversationMode, .discuss)
|
||||
XCTAssertEqual(result.project.updatedAt, Date(timeIntervalSince1970: 200))
|
||||
XCTAssertEqual(result.notes, ["Completed from idea."])
|
||||
}
|
||||
|
||||
func testGenerateCompleteProjectPreservesSeedProjectIdentityAndVersions() async throws {
|
||||
let createdAt = Date(timeIntervalSince1970: 10)
|
||||
let generatedAt = Date(timeIntervalSince1970: 300)
|
||||
var seedProject = SongProject(
|
||||
id: "seed-project",
|
||||
title: "Draft",
|
||||
idea: "Original idea",
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt
|
||||
)
|
||||
let version = seedProject.createVersion(
|
||||
id: "seed-version",
|
||||
label: "Before generation",
|
||||
createdAt: createdAt
|
||||
)
|
||||
let aiService = RecordingGenerationAIService(
|
||||
generationResult: SongProjectGenerationResult(
|
||||
project: SongProject(
|
||||
id: "generated-id",
|
||||
title: "Generated Complete Project",
|
||||
idea: "",
|
||||
sections: [
|
||||
SongSection(id: "verse", type: .verse, title: "Verse", order: 0)
|
||||
],
|
||||
lyrics: Lyrics(text: "Generated lyrics")
|
||||
)
|
||||
)
|
||||
)
|
||||
let director = SongProjectGenerationDirector(aiService: aiService)
|
||||
|
||||
let result = try await director.generateCompleteProject(
|
||||
fromIdea: "Build from this idea",
|
||||
seedProject: seedProject,
|
||||
generatedAt: generatedAt
|
||||
)
|
||||
let recordedRequest = await aiService.recordedGenerationRequests.first
|
||||
|
||||
XCTAssertEqual(recordedRequest?.seedProject, seedProject)
|
||||
XCTAssertEqual(result.project.id, "seed-project")
|
||||
XCTAssertEqual(result.project.createdAt, createdAt)
|
||||
XCTAssertEqual(result.project.updatedAt, generatedAt)
|
||||
XCTAssertEqual(result.project.versions, [version])
|
||||
XCTAssertEqual(result.project.title, "Generated Complete Project")
|
||||
XCTAssertEqual(result.project.idea, "Build from this idea")
|
||||
XCTAssertEqual(result.project.sections.first?.id, "verse")
|
||||
XCTAssertEqual(result.project.lyrics.text, "Generated lyrics")
|
||||
}
|
||||
|
||||
func testGenerateCompleteProjectRejectsBlankIdeaBeforeCallingAI() async {
|
||||
let aiService = RecordingGenerationAIService(
|
||||
generationResult: SongProjectGenerationResult(
|
||||
project: SongProject(title: "Unused", idea: "Unused")
|
||||
)
|
||||
)
|
||||
let director = SongProjectGenerationDirector(aiService: aiService)
|
||||
|
||||
do {
|
||||
_ = try await director.generateCompleteProject(fromIdea: " \n ")
|
||||
XCTFail("Expected blank idea to throw.")
|
||||
} catch let error as SongProjectGenerationDirectorError {
|
||||
XCTAssertEqual(error, .emptyIdea)
|
||||
} catch {
|
||||
XCTFail("Expected SongProjectGenerationDirectorError.")
|
||||
}
|
||||
|
||||
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 {
|
||||
private(set) var recordedGenerationRequests: [SongProjectGenerationRequest] = []
|
||||
private let generationResult: SongProjectGenerationResult
|
||||
|
||||
init(generationResult: SongProjectGenerationResult) {
|
||||
self.generationResult = generationResult
|
||||
}
|
||||
|
||||
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
|
||||
recordedGenerationRequests.append(request)
|
||||
return generationResult
|
||||
}
|
||||
|
||||
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||
LyricsRevisionResult(lyrics: request.sourceLyrics)
|
||||
}
|
||||
|
||||
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
|
||||
SongProjectUpdateResult(project: request.project)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,6 @@ final class SongProjectModelTests: XCTestCase {
|
||||
XCTAssertEqual(project.genres.map(\.id), ["folk", "electronic"])
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
func testRestoresPreviousVersionWithoutDestroyingLaterVersions() {
|
||||
let createdAt = Date(timeIntervalSince1970: 100)
|
||||
let restoredAt = Date(timeIntervalSince1970: 900)
|
||||
@@ -178,8 +177,6 @@ final class SongProjectModelTests: XCTestCase {
|
||||
XCTAssertEqual(project, restoredProject)
|
||||
}
|
||||
|
||||
=======
|
||||
>>>>>>> origin/master
|
||||
func testGenreStylesAreModeledAsMultiSelect() {
|
||||
let arabicPop = GenreStyle(id: "arabic-pop", name: "Arabic Pop")
|
||||
let synthwave = GenreStyle(id: "synthwave", name: "Synthwave")
|
||||
@@ -284,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",
|
||||
@@ -372,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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
+46
-68
@@ -35,7 +35,6 @@ requirement is missing and blocks implementation, record it in
|
||||
- [x] Create new/open/save song projects locally.
|
||||
- [x] Implement project list.
|
||||
- [x] Implement immutable or snapshot-based version history.
|
||||
<<<<<<< HEAD
|
||||
- [x] Restore a previous version without destroying later versions.
|
||||
- [x] Add autosave strategy that does not create excessive versions.
|
||||
|
||||
@@ -50,110 +49,89 @@ requirement is missing and blocks implementation, record it in
|
||||
timestamp-based placement.
|
||||
- [x] Add Manual/Auto toggles for supported controls.
|
||||
- [x] Build final review screen where every generated field can be
|
||||
=======
|
||||
- [ ] Restore a previous version without destroying later versions.
|
||||
- [ ] Add autosave strategy that does not create excessive versions.
|
||||
|
||||
## Phase 3 --- Core macOS UI
|
||||
|
||||
- [ ] Build project browser.
|
||||
- [ ] Build natural chat workspace as the primary entry point.
|
||||
- [ ] Build editable project inspector for duration, genres,
|
||||
instruments, vocals and language.
|
||||
- [ ] Build song structure editor.
|
||||
- [ ] Build arrangement editor supporting section-based and
|
||||
timestamp-based placement.
|
||||
- [ ] Add Manual/Auto toggles for supported controls.
|
||||
- [ ] Build final review screen where every generated field can be
|
||||
>>>>>>> origin/master
|
||||
edited.
|
||||
|
||||
## Phase 4 --- OpenAI Integration
|
||||
|
||||
<<<<<<< HEAD
|
||||
- [x] Create provider-independent `AIService` interface.
|
||||
- [x] Implement OpenAI client.
|
||||
- [x] Define private application-rule injection mechanism.
|
||||
- [x] Define structured AI response schema for SongProject updates.
|
||||
=======
|
||||
- [ ] Create provider-independent `AIService` interface.
|
||||
- [ ] Implement OpenAI client.
|
||||
- [ ] Define private application-rule injection mechanism.
|
||||
- [ ] Define structured AI response schema for SongProject updates.
|
||||
>>>>>>> origin/master
|
||||
- [ ] Implement idea → complete project generation.
|
||||
- [ ] Implement existing lyrics → correction/improvement flow.
|
||||
- [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam
|
||||
- [x] Implement idea → complete project generation.
|
||||
- [x] Implement existing lyrics → correction/improvement flow.
|
||||
- [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.
|
||||
|
||||
Reference in New Issue
Block a user