Compare commits

...
58 Commits
Author SHA1 Message Date
diyaa f9af5c21d8 Refine macOS app visual hierarchy 2026-09-16 00:18:32 +02:00
diyaa c8242a844a Add language catalog selector 2026-09-15 23:20:48 +02:00
diyaa b43c5416f2 Add vocalist preset selector 2026-09-15 19:07:55 +02:00
diyaa 297b791a3e Add worldwide genre style selector 2026-09-15 18:49:34 +02:00
diyaa 03bfd3ef75 Add confirmed project deletion 2026-09-15 13:06:27 +02:00
diyaa bef00b19e1 Fix musical parameter auto mode switching 2026-09-15 12:54:38 +02:00
diyaa 0307df9442 Align agent guidance with external handoff removal 2026-09-15 12:50:05 +02:00
diyaa 2d07ee70d8 Correct final review terminology 2026-09-15 12:46:34 +02:00
diyaa 2145732b66 Record instrument generation context coverage 2026-09-15 12:40:28 +02:00
diyaa 9b4db4413b Record locked AI output coverage 2026-09-15 12:39:49 +02:00
diyaa fac99e2c2d Test project version recovery 2026-09-15 12:38:56 +02:00
diyaa 6023214cb3 Add keyboard navigation shortcuts 2026-09-15 12:37:11 +02:00
diyaa f199bcf114 Add AI request error states 2026-09-15 12:36:41 +02:00
diyaa f42d782629 Validate SongProject consistency 2026-09-15 12:34:48 +02:00
diyaa 1ebb5aadc3 Remove external music generation handoff 2026-09-15 12:16:08 +02:00
diyaa cd066d0a82 Embed Suno handoff web surface 2026-09-14 22:51:16 +02:00
diyaa 3ed4046d73 Use app-managed Suno web surface 2026-09-14 22:46:38 +02:00
diyaa de0b8e5972 Add Suno field copy fallback 2026-09-14 22:43:18 +02:00
diyaa d7901ef9d7 Avoid repeated accessibility prompts 2026-09-14 22:39:35 +02:00
diyaa 52978a375d Target the Suno browser for field filling 2026-09-14 22:35:12 +02:00
diyaa 3fced12bef Fill Suno fields with accessibility adapter 2026-09-14 22:29:53 +02:00
diyaa 448d390671 Add explicit Suno handoff action 2026-09-14 22:22:38 +02:00
diyaa 61267e201e Document Suno browser handoff decision 2026-09-14 22:20:22 +02:00
diyaa e4b899d83b Document prompt compiler boundary 2026-09-14 22:08:58 +02:00
diyaa c7640470c6 Validate compiled Suno output 2026-09-14 22:08:04 +02:00
diyaa 9e6e94e7cb Compile complete Suno style prompts 2026-09-14 22:06:32 +02:00
diyaa 4fa477bdb0 Add section performance directives 2026-09-14 22:04:35 +02:00
diyaa 7bf4df79f9 Persist selections and compile song output 2026-09-14 21:56:26 +02:00
diyaa 35703f89a8 Test instrument selector persistence 2026-09-14 21:52:07 +02:00
diyaa 2b5ba2b535 Confirm manual arrangement behavior 2026-09-14 21:47:07 +02:00
diyaa 04d4093a3c Add instrument checkbox selection 2026-09-14 06:20:07 +02:00
diyaa 6c6aeae5bb Add instrument region filtering 2026-09-14 01:08:30 +02:00
diyaa 9ddac746e5 Add instrument family filtering 2026-09-14 01:04:44 +02:00
diyaa ecbf07d1ea Add searchable instrument browser 2026-09-14 00:55:41 +02:00
diyaa b2bf04e78b Confirm catalog data separation 2026-09-14 00:31:11 +02:00
diyaa b51102b65d Add worldwide instrument catalog 2026-09-13 23:52:07 +02:00
diyaa e7a46772ec Test instrument catalog metadata 2026-09-13 23:37:48 +02:00
diyaa 9a0dd452bd Add Arabic dialect test fixtures 2026-09-13 23:22:49 +02:00
diyaa 6a99266e75 Add Arabic lyrics review 2026-09-13 22:42:50 +02:00
diyaa 3865f77dc0 Preserve Arabic spelling choices 2026-09-13 22:33:57 +02:00
diyaa f5ab97e6a0 Add Arabic pronunciation processing 2026-09-13 21:44:42 +02:00
diyaa 49a03a0700 Add Arabic pronunciation settings UI 2026-09-13 21:31:15 +02:00
diyaa eb61bd076c Add resilient AI service handling 2026-09-13 21:28:13 +02:00
diyaa ae88c6e533 Enforce user locks for AI updates 2026-09-13 21:20:57 +02:00
diyaa a39bdbe6fc Implement optional discussion mode 2026-09-13 21:13:52 +02:00
diyaa bf1ccbe8a9 Implement automatic song project decisions 2026-09-13 20:59:25 +02:00
diyaa 1bf84ca049 Implement existing lyrics revision flow 2026-09-13 20:51:31 +02:00
diyaa 5e1d3d7246 Implement idea to song project generation 2026-09-13 20:48:11 +02:00
diyaa bbf5f191f2 Merge master into main 2026-09-13 20:23:35 +02:00
diyaa 18d7d56345 Initial commit 2026-09-13 20:21:10 +02:00
diya dce0880576 Add macOS app packaging script 2026-09-13 14:40:09 +02:00
Codex 8bdebc5c24 Implement snapshot version history 2026-09-13 14:28:43 +02:00
Codex e28d45ae18 Implement song project list 2026-09-13 14:17:38 +02:00
Codex 60b4f1b916 Implement local song project store 2026-09-13 13:56:59 +02:00
Codex 55235ba90d Add song project serialization tests 2026-09-13 13:50:13 +02:00
Codex ece652b2e4 Model emotional arc and production directions 2026-09-13 13:48:05 +02:00
Codex 20c541421f Model manual auto musical parameters 2026-09-13 13:33:30 +02:00
Codex c3ebf95e53 Model language and pronunciation settings 2026-09-13 13:29:15 +02:00
77 changed files with 9548 additions and 149 deletions
+3 -2
View File
@@ -48,8 +48,9 @@ Use these documents as the project references:
Integrations. Integrations.
- Keep OpenAI, instrument catalog/selector, and Suno integrations separated from - Keep OpenAI, instrument catalog/selector, and Suno integrations separated from
UI logic as much as possible. UI logic as much as possible.
- In the current scope, Suno handoff fills the fields only and does not press - In the current scope, the app prepares and stores final lyrics and style
Generate. 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 modify files outside the task scope without a clear reason.
- Do not perform a broad refactor while implementing a small task unless it is - Do not perform a broad refactor while implementing a small task unless it is
explicitly required. explicitly required.
+7
View File
@@ -14,3 +14,10 @@ Current environment keys:
Provider integrations are intentionally protocol-only in the repository Provider integrations are intentionally protocol-only in the repository
foundation phase. foundation phase.
## Packaging
Run `make install-app` to build the release executable, package it as
`AI Music Studio.app`, install it in `/Applications`, and launch it.
Set `APP_ICON=/path/to/icon.icns` when packaging with a custom icon.
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build dev test .PHONY: build dev test install-app
build: build:
swift build swift build
@@ -8,3 +8,6 @@ dev:
test: test:
swift test swift test
install-app:
scripts/build-install-launch.zsh
@@ -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 = ""
}
}
@@ -0,0 +1,193 @@
import MusicAssistantCore
import SwiftUI
struct ArrangementEditorView: View {
@Binding var project: SongProject
var body: some View {
VStack(alignment: .leading, spacing: 12) {
let selectedInstrumentIndices = project.instruments.indices.filter { project.instruments[$0].selected }
if selectedInstrumentIndices.isEmpty {
Text("Select instruments before arranging entries.")
.foregroundStyle(.secondary)
} else {
ForEach(selectedInstrumentIndices, id: \.self) { index in
InstrumentArrangementCard(
instrument: $project.instruments[index],
sections: project.orderedSections
)
}
}
}
}
}
private struct InstrumentArrangementCard: View {
@Binding var instrument: InstrumentTrack
let sections: [SongSection]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(instrumentDisplayName)
.font(.headline)
.lineLimit(1)
Spacer()
Button(action: addPlacement) {
Label("Add placement", systemImage: "plus.circle.fill")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.help("Add placement")
}
Toggle("Auto arrangement", isOn: $instrument.autoArrangementEnabled)
TextField("Role", text: optionalStringBinding(\.role))
TextField("Playing style", text: optionalStringBinding(\.playingStyle))
if instrument.placements.isEmpty {
Text("No placements yet")
.foregroundStyle(.secondary)
} else {
ForEach(instrument.placements.indices, id: \.self) { placementIndex in
InstrumentPlacementEditor(
placement: $instrument.placements[placementIndex],
sections: sections,
removeAction: {
instrument.placements.remove(at: placementIndex)
}
)
}
}
}
.padding(10)
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
}
private var instrumentDisplayName: String {
if let variant = instrument.variant, !variant.isEmpty {
return variant
}
return instrument.instrumentId
}
private func addPlacement() {
instrument.placements.append(
InstrumentPlacement(sectionId: sections.first?.id)
)
}
private func optionalStringBinding(_ keyPath: WritableKeyPath<InstrumentTrack, String?>) -> Binding<String> {
Binding {
instrument[keyPath: keyPath] ?? ""
} set: { newValue in
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
instrument[keyPath: keyPath] = trimmedValue.isEmpty ? nil : trimmedValue
}
}
}
private struct InstrumentPlacementEditor: View {
@Binding var placement: InstrumentPlacement
let sections: [SongSection]
let removeAction: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Picker("Section", selection: sectionIDBinding) {
Text("No section").tag("")
ForEach(sections) { section in
Text(sectionLabel(for: section)).tag(section.id)
}
}
Spacer()
Button(action: removeAction) {
Label("Remove placement", systemImage: "trash")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.foregroundStyle(.red)
.help("Remove placement")
}
TextField("Start seconds", text: optionalTimeBinding(\.startTime))
TextField("End seconds", text: optionalTimeBinding(\.endTime))
TextField("Direction", text: optionalStringBinding(\.direction), axis: .vertical)
.lineLimit(1...3)
}
.padding(8)
.background(Color(nsColor: .windowBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
}
private var sectionIDBinding: Binding<String> {
Binding {
placement.sectionId ?? ""
} set: { newValue in
placement.sectionId = newValue.isEmpty ? nil : newValue
}
}
private func optionalTimeBinding(_ keyPath: WritableKeyPath<InstrumentPlacement, TimeInterval?>) -> Binding<String> {
Binding {
guard let value = placement[keyPath: keyPath] else { return "" }
return String(format: "%.0f", value)
} set: { newValue in
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
placement[keyPath: keyPath] = trimmedValue.isEmpty ? nil : TimeInterval(trimmedValue)
}
}
private func optionalStringBinding(_ keyPath: WritableKeyPath<InstrumentPlacement, String?>) -> Binding<String> {
Binding {
placement[keyPath: keyPath] ?? ""
} set: { newValue in
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
placement[keyPath: keyPath] = trimmedValue.isEmpty ? nil : trimmedValue
}
}
private func sectionLabel(for section: SongSection) -> String {
"\(section.order + 1). \(section.title) (\(section.type.displayName))"
}
}
#Preview {
ArrangementEditorPreview()
}
private struct ArrangementEditorPreview: View {
@State private var project = SongProject(
title: "Arrangement Preview",
idea: "Place instruments",
sections: [
SongSection(type: .intro, title: "Intro"),
SongSection(type: .verse, title: "Verse", order: 1)
],
instruments: [
InstrumentTrack(
instrumentId: "piano",
selected: true,
variant: "Piano",
role: "Harmony bed",
placements: [
InstrumentPlacement(sectionId: nil, startTime: 0, endTime: 12, direction: "Soft opening")
]
)
]
)
var body: some View {
Form {
ArrangementEditorView(project: $project)
}
.formStyle(.grouped)
.frame(width: 380)
}
}
@@ -0,0 +1,266 @@
import MusicAssistantCore
import SwiftUI
struct ChatWorkspaceView: View {
@State private var project: SongProject
let saveProject: (SongProject) async -> Bool
@State private var messages: [ChatWorkspaceMessage]
@State private var draftMessage = ""
@FocusState private var isComposerFocused: Bool
init(project: SongProject, saveProject: @escaping (SongProject) async -> Bool = { _ in true }) {
_project = State(initialValue: project)
self.saveProject = saveProject
_messages = State(initialValue: Self.initialMessages(for: project))
}
var body: some View {
HSplitView {
VStack(spacing: 0) {
ChatWorkspaceHeader(project: project)
Divider()
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
ForEach(messages) { message in
ChatMessageBubble(message: message)
.id(message.id)
}
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Color(nsColor: .textBackgroundColor))
.onChange(of: messages.count) { _, _ in
guard let lastMessageID = messages.last?.id else { return }
withAnimation(.snappy) {
proxy.scrollTo(lastMessageID, anchor: .bottom)
}
}
}
Divider()
ChatComposer(
text: $draftMessage,
isFocused: $isComposerFocused,
sendAction: sendMessage
)
.padding(.horizontal, 20)
.padding(.vertical, 14)
}
.frame(minWidth: 520, maxWidth: .infinity, maxHeight: .infinity)
ProjectInspectorView(project: $project, saveProject: saveProject)
.frame(minWidth: 300, idealWidth: 340, maxWidth: 420, maxHeight: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
isComposerFocused = true
}
}
private func sendMessage() {
let trimmedMessage = draftMessage.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedMessage.isEmpty else { return }
messages.append(ChatWorkspaceMessage(role: .user, text: trimmedMessage))
draftMessage = ""
}
private static func initialMessages(for project: SongProject) -> [ChatWorkspaceMessage] {
let prompt: String
if project.idea.isEmpty {
prompt = "What kind of song are we making?"
} else {
prompt = project.idea
}
return [
ChatWorkspaceMessage(role: .assistant, text: prompt)
]
}
}
private struct ChatWorkspaceHeader: View {
let project: SongProject
var body: some View {
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(.title2.weight(.semibold))
.lineLimit(1)
Text(project.idea.isEmpty ? "Song Project" : project.idea)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 12)
Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
ViewThatFits(in: .horizontal) {
HStack(spacing: 12) {
projectMetrics
}
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 104), spacing: 10)],
alignment: .leading,
spacing: 8
) {
projectMetrics
}
}
}
.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")
}
}
private struct ProjectMetricLabel: View {
let title: String
let value: Int
let systemImage: String
var body: some View {
Label {
Text("\(value) \(title)")
} icon: {
Image(systemName: systemImage)
}
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.labelStyle(.titleAndIcon)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
}
}
private struct ChatMessageBubble: View {
let message: ChatWorkspaceMessage
var body: some View {
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)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.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)
}
}
.frame(maxWidth: .infinity, alignment: message.role == .user ? .trailing : .leading)
}
}
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
let sendAction: () -> Void
var body: some View {
HStack(alignment: .bottom, spacing: 10) {
TextField("Describe the song idea, lyrics, mood, or arrangement", text: $text, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(1...5)
.focused(isFocused)
.onSubmit(sendAction)
Button(action: sendAction) {
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))
}
}
private struct ChatWorkspaceMessage: Identifiable, Equatable {
enum Role: Equatable {
case assistant
case user
}
let id: UUID
let role: Role
let text: String
init(id: UUID = UUID(), role: Role, text: String) {
self.id = id
self.role = role
self.text = text
}
}
#Preview {
ChatWorkspaceView(
project: SongProject(
title: "Untitled Song",
idea: "A cinematic Arabic pop song with oud, violin, and a hopeful chorus."
)
)
}
@@ -0,0 +1,199 @@
import MusicAssistantCore
import SwiftUI
struct FinalReviewView: View {
@Binding var project: SongProject
@State private var newFieldKey = ""
@State private var newFieldValue = ""
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)
if !project.orderedSections.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Section lyrics")
.font(.headline)
ForEach(project.orderedSections) { section in
TextField(section.title, text: sectionLyricsBinding(for: section.id), axis: .vertical)
.lineLimit(2...5)
}
}
}
Divider()
HStack {
Text("Final song output")
.font(.headline)
Spacer()
Button {
ensureSunoOutput()
} label: {
Label("Prepare fields", systemImage: "wand.and.stars")
}
.disabled(project.sunoOutput != nil)
}
TextField("Lyrics", text: sunoLyricsBinding, axis: .vertical)
.lineLimit(4...10)
TextField("Style prompt", text: sunoStylePromptBinding, axis: .vertical)
.lineLimit(3...8)
if !additionalFieldKeys.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Additional fields")
.font(.headline)
ForEach(additionalFieldKeys, id: \.self) { key in
HStack(alignment: .top, spacing: 8) {
TextField("Field", text: additionalFieldKeyBinding(for: key))
TextField("Value", text: additionalFieldValueBinding(for: key), axis: .vertical)
.lineLimit(1...4)
Button {
project.sunoOutput?.additionalFields.removeValue(forKey: key)
} label: {
Label("Remove field", systemImage: "minus.circle")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.help("Remove field")
}
}
}
}
HStack {
TextField("Field name", text: $newFieldKey)
TextField("Value", text: $newFieldValue)
Button(action: addAdditionalField) {
Label("Add field", systemImage: "plus.circle.fill")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.disabled(newFieldKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.help("Add field")
}
}
}
private var additionalFieldKeys: [String] {
guard let additionalFields = project.sunoOutput?.additionalFields else { return [] }
return additionalFields.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
private var sunoLyricsBinding: Binding<String> {
Binding {
project.sunoOutput?.lyricsText ?? ""
} set: { newValue in
ensureSunoOutput()
project.sunoOutput?.lyricsText = newValue
}
}
private var sunoStylePromptBinding: Binding<String> {
Binding {
project.sunoOutput?.stylePrompt ?? ""
} set: { newValue in
ensureSunoOutput()
project.sunoOutput?.stylePrompt = newValue
}
}
private func sectionLyricsBinding(for sectionID: String) -> Binding<String> {
Binding {
project.lyrics.sectionTexts[sectionID] ?? ""
} set: { newValue in
if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
project.lyrics.sectionTexts.removeValue(forKey: sectionID)
} else {
project.lyrics.sectionTexts[sectionID] = newValue
}
}
}
private func additionalFieldKeyBinding(for key: String) -> Binding<String> {
Binding {
key
} set: { newKey in
let trimmedKey = newKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedKey.isEmpty, trimmedKey != key else { return }
ensureSunoOutput()
let value = project.sunoOutput?.additionalFields.removeValue(forKey: key) ?? ""
project.sunoOutput?.additionalFields[trimmedKey] = value
}
}
private func additionalFieldValueBinding(for key: String) -> Binding<String> {
Binding {
project.sunoOutput?.additionalFields[key] ?? ""
} set: { newValue in
ensureSunoOutput()
project.sunoOutput?.additionalFields[key] = newValue
}
}
private func addAdditionalField() {
let key = newFieldKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
ensureSunoOutput()
project.sunoOutput?.additionalFields[key] = newFieldValue
newFieldKey = ""
newFieldValue = ""
}
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: compiledOutput.lyricsText,
stylePrompt: compiledOutput.stylePrompt,
generatedAt: Date()
)
}
}
#Preview {
FinalReviewPreview()
}
private struct FinalReviewPreview: View {
@State private var project = SongProject(
title: "Review",
idea: "Review generated fields",
sections: [
SongSection(id: "verse", type: .verse, title: "Verse"),
SongSection(id: "chorus", type: .chorus, title: "Chorus", order: 1)
],
lyrics: Lyrics(text: "Draft lyrics", sectionTexts: ["verse": "Verse text"]),
sunoOutput: SunoOutput(
lyricsText: "Draft lyrics",
stylePrompt: "Cinematic pop with oud",
additionalFields: ["mood": "Hopeful"]
)
)
var body: some View {
Form {
FinalReviewView(project: $project)
}
.formStyle(.grouped)
.frame(width: 420)
}
}
@@ -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)
}
}
@@ -0,0 +1,156 @@
import MusicAssistantCore
import SwiftUI
struct MusicalParametersEditorView: View {
@Binding var project: SongProject
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ManualAutoIntegerRow(
title: "BPM",
value: manualAutoIntegerBinding(\.bpm)
)
ManualAutoTextRow(
title: "Key",
value: manualAutoTextBinding(\.key),
placeholder: "D"
)
ManualAutoTextRow(
title: "Scale",
value: manualAutoTextBinding(\.scale),
placeholder: "minor"
)
ManualAutoTextRow(
title: "Maqam",
value: manualAutoTextBinding(\.maqam),
placeholder: "Hijaz"
)
}
}
private func manualAutoIntegerBinding(
_ keyPath: WritableKeyPath<SongProject, ManualAutoValue<Int>?>
) -> Binding<ManualAutoValue<Int>> {
Binding {
project[keyPath: keyPath] ?? ManualAutoValue(mode: .auto)
} set: { newValue in
project[keyPath: keyPath] = newValue
}
}
private func manualAutoTextBinding(
_ keyPath: WritableKeyPath<SongProject, ManualAutoValue<String>?>
) -> Binding<ManualAutoValue<String>> {
Binding {
project[keyPath: keyPath] ?? ManualAutoValue(mode: .auto)
} set: { newValue in
project[keyPath: keyPath] = newValue
}
}
}
private struct ManualAutoIntegerRow: View {
let title: String
@Binding var value: ManualAutoValue<Int>
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ManualAutoPicker(title: title, mode: modeBinding)
if value.mode == .manual {
TextField(title, text: integerTextBinding)
}
}
}
private var modeBinding: Binding<ControlMode> {
Binding {
value.mode
} set: { newMode in
value = ManualAutoValue(
mode: newMode,
value: newMode == .manual ? value.value : nil
)
}
}
private var integerTextBinding: Binding<String> {
Binding {
value.value.map(String.init) ?? ""
} set: { newValue in
value.value = Int(newValue.trimmingCharacters(in: .whitespacesAndNewlines))
}
}
}
private struct ManualAutoTextRow: View {
let title: String
@Binding var value: ManualAutoValue<String>
let placeholder: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ManualAutoPicker(title: title, mode: modeBinding)
if value.mode == .manual {
TextField(placeholder, text: textBinding)
}
}
}
private var modeBinding: Binding<ControlMode> {
Binding {
value.mode
} set: { newMode in
value = ManualAutoValue(
mode: newMode,
value: newMode == .manual ? value.value : nil
)
}
}
private var textBinding: Binding<String> {
Binding {
value.value ?? ""
} set: { newValue in
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
value.value = trimmedValue.isEmpty ? nil : trimmedValue
}
}
}
private struct ManualAutoPicker: View {
let title: String
@Binding var mode: ControlMode
var body: some View {
Picker(title, selection: $mode) {
Text("Auto").tag(ControlMode.auto)
Text("Manual").tag(ControlMode.manual)
}
.pickerStyle(.segmented)
}
}
#Preview {
MusicalParametersEditorPreview()
}
private struct MusicalParametersEditorPreview: View {
@State private var project = SongProject(
title: "Parameters",
idea: "Preview",
bpm: ManualAutoValue(mode: .manual, value: 96),
key: ManualAutoValue(mode: .auto),
scale: ManualAutoValue(mode: .manual, value: "minor"),
maqam: ManualAutoValue(mode: .auto)
)
var body: some View {
Form {
MusicalParametersEditorView(project: $project)
}
.formStyle(.grouped)
.frame(width: 340)
}
}
@@ -0,0 +1,363 @@
import Foundation
import MusicAssistantCore
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))
}
var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
ProjectLibraryHeader(
projectCount: viewModel.projects.count,
isCreatingProject: viewModel.isCreatingProject
) {
Task {
if let projectID = await viewModel.createProject() {
selectedProjectID = projectID
}
}
}
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: {
ProjectBrowserDetailView(
project: viewModel.selectedProject,
errorMessage: viewModel.errorMessage,
isLoading: viewModel.isOpeningProject,
saveProject: viewModel.saveProject
)
}
.task {
await viewModel.loadProjects()
selectedProjectID = selectedProjectID ?? viewModel.projects.first?.id
if let selectedProjectID {
await viewModel.openProject(id: selectedProjectID)
}
}
.onChange(of: selectedProjectID) { _, projectID in
guard let projectID else {
viewModel.clearSelection()
return
}
Task {
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 {
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))
VStack(alignment: .leading, spacing: 4) {
Text(project.title)
.font(.headline)
.lineLimit(1)
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)
}
}
private struct ProjectBrowserDetailView: View {
let project: SongProject?
let errorMessage: String?
let isLoading: Bool
let saveProject: (SongProject) async -> Bool
var body: some View {
Group {
if isLoading {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let project {
ChatWorkspaceView(project: project, saveProject: saveProject)
.id(project.id)
} else {
ContentUnavailableView(
"Select a Project",
systemImage: "music.note",
description: Text("Choose a saved song project from the browser.")
)
}
}
.overlay(alignment: .bottom) {
if let errorMessage {
Text(errorMessage)
.font(.callout)
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.red, in: RoundedRectangle(cornerRadius: 8))
.padding()
}
}
}
}
@MainActor
final class ProjectBrowserViewModel: ObservableObject {
@Published private(set) var projects: [SongProjectListItem] = []
@Published private(set) var selectedProject: SongProject?
@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
init(store: any ProjectStore) {
self.store = store
}
func loadProjects() async {
isLoading = true
errorMessage = nil
do {
projects = try await store.loadProjectList()
} catch {
errorMessage = "Unable to load projects."
}
isLoading = false
}
func openProject(id projectID: String) async {
isOpeningProject = true
errorMessage = nil
do {
selectedProject = try await store.open(id: projectID)
} catch {
selectedProject = nil
errorMessage = "Unable to open this project."
}
isOpeningProject = false
}
func createProject() async -> String? {
isCreatingProject = true
errorMessage = nil
let now = Date()
let project = SongProject(
title: "Untitled Song",
idea: "",
createdAt: now,
updatedAt: now
)
do {
try await store.create(project)
projects = try await store.loadProjectList()
selectedProject = project
isCreatingProject = false
return project.id
} catch {
errorMessage = "Unable to create a new project."
isCreatingProject = false
return nil
}
}
func saveProject(_ project: SongProject) async -> Bool {
var projectToSave = project
projectToSave.updatedAt = Date()
errorMessage = nil
do {
try await store.save(projectToSave)
projects = try await store.loadProjectList()
selectedProject = projectToSave
return true
} catch {
errorMessage = "Unable to save this project."
return false
}
}
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
}
}
enum ProjectBrowserStorage {
static var projectsDirectoryURL: URL {
let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
return baseURL
.appendingPathComponent("AI Music Studio", isDirectory: true)
.appendingPathComponent("Projects", isDirectory: true)
}
}
#Preview {
ProjectBrowserView()
}
@@ -0,0 +1,359 @@
import MusicAssistantCore
import SwiftUI
struct ProjectInspectorView: View {
@Binding var project: SongProject
let saveProject: (SongProject) async -> Bool
@State private var newInstrumentName = ""
@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(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()
Button {
Task {
await save()
}
} label: {
Label("Save", systemImage: "tray.and.arrow.down")
}
.keyboardShortcut("s", modifiers: .command)
.disabled(isSaving)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(.bar)
Divider()
Form {
Section("Overview") {
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)
}
)
}
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)
.lineLimit(1)
}
}
AddTextRow(
placeholder: "Add instrument",
text: $newInstrumentName,
addAction: addInstrument
)
}
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)
}
)
}
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)
}
)
}
Section("Arabic Pronunciation") {
ArabicPronunciationSettingsView(
settings: $project.arabicPronunciationSettings
)
}
Section("Song Structure") {
SongStructureEditorView(project: $project)
}
Section("Musical Parameters") {
MusicalParametersEditorView(project: $project)
}
Section("Arrangement") {
ArrangementEditorView(project: $project)
}
Section("Final Review") {
FinalReviewView(project: $project)
}
}
.formStyle(.grouped)
if let saveStatus {
Divider()
Label(saveStatus.title, systemImage: saveStatus.systemImage)
.font(.caption)
.foregroundStyle(saveStatus.foregroundStyle)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.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> {
Binding {
project.duration?.seconds.map(String.init) ?? ""
} set: { newValue in
var duration = project.duration ?? SongDuration()
duration.seconds = Int(newValue.trimmingCharacters(in: .whitespacesAndNewlines))
project.duration = duration.isEmpty ? nil : duration
}
}
private var durationDescriptionBinding: Binding<String> {
Binding {
project.duration?.description ?? ""
} set: { newValue in
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
var duration = project.duration ?? SongDuration()
duration.description = trimmedValue.isEmpty ? nil : trimmedValue
project.duration = duration.isEmpty ? nil : duration
}
}
private func addInstrument() {
let name = trimmed(newInstrumentName)
guard !name.isEmpty else { return }
project.selectInstrument(InstrumentTrack(instrumentId: stableID(from: name), variant: name))
newInstrumentName = ""
}
private func save() async {
isSaving = true
let didSave = await saveProject(project)
isSaving = false
saveStatus = didSave ? .saved : .failed
}
private func trimmed(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func stableID(from value: String) -> String {
let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-"))
let lowercasedValue = value.lowercased()
let scalars = lowercasedValue.unicodeScalars.map { scalar in
allowedCharacters.contains(scalar) ? String(scalar) : "-"
}
let collapsed = scalars.joined()
.split(separator: "-")
.joined(separator: "-")
return collapsed.isEmpty ? UUID().uuidString : collapsed
}
}
private struct EditableTokenList: View {
let values: [String]
let removeAction: (Int) -> Void
var body: some View {
if values.isEmpty {
Text("None yet")
.foregroundStyle(.secondary)
} else {
ForEach(Array(values.enumerated()), id: \.offset) { index, value in
HStack {
Text(value)
.lineLimit(1)
Spacer()
Button {
removeAction(index)
} label: {
Label("Remove", systemImage: "minus.circle")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.help("Remove")
}
}
}
}
}
private struct AddTextRow: View {
let placeholder: String
@Binding var text: String
let addAction: () -> Void
var body: some View {
HStack {
TextField(placeholder, text: $text)
.onSubmit(addAction)
Button(action: addAction) {
Label("Add", systemImage: "plus.circle.fill")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.help("Add")
}
}
}
private enum SaveStatus {
case saved
case failed
var title: String {
switch self {
case .saved:
return "Saved"
case .failed:
return "Save failed"
}
}
var systemImage: String {
switch self {
case .saved:
return "checkmark.circle.fill"
case .failed:
return "exclamationmark.triangle.fill"
}
}
var foregroundStyle: Color {
switch self {
case .saved:
return .green
case .failed:
return .red
}
}
}
private extension SongDuration {
var isEmpty: Bool {
seconds == nil && (description?.isEmpty ?? true)
}
}
#Preview {
ProjectInspectorPreview()
}
private struct ProjectInspectorPreview: View {
@State private var project = SongProject(
title: "Inspector Preview",
idea: "A compact editor for the current song project.",
duration: SongDuration(seconds: 180, description: "about three minutes"),
languages: [LanguageSelection(id: "english", name: "English")],
genres: [GenreStyle(id: "pop", name: "Pop")],
bpm: ManualAutoValue(mode: .manual, value: 96),
key: ManualAutoValue(mode: .auto),
scale: ManualAutoValue(mode: .manual, value: "minor"),
maqam: ManualAutoValue(mode: .auto),
sections: [
SongSection(type: .intro, title: "Intro"),
SongSection(type: .verse, title: "Verse", order: 1)
],
instruments: [
InstrumentTrack(
instrumentId: "piano",
variant: "Piano",
placements: [
InstrumentPlacement(sectionId: nil, startTime: 0, endTime: 12)
]
)
],
vocalists: [Vocalist(id: "lead", label: "Lead vocalist")],
lyrics: Lyrics(text: "Draft lyric idea"),
sunoOutput: SunoOutput(
lyricsText: "Draft lyric idea",
stylePrompt: "Pop with piano"
)
)
var body: some View {
ProjectInspectorView(project: $project, saveProject: { _ in true })
}
}
@@ -1,42 +1,8 @@
import MusicAssistantCore
import SwiftUI import SwiftUI
struct RootView: View { struct RootView: View {
private let appName = "AI Music Studio"
private let boundarySummary = ProductBoundarySummary.initial
var body: some View { var body: some View {
NavigationSplitView { ProjectBrowserView()
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)
}
} }
} }
@@ -0,0 +1,210 @@
import MusicAssistantCore
import SwiftUI
struct SongStructureEditorView: View {
@Binding var project: SongProject
@State private var newSectionTitle = ""
@State private var newSectionType: SongSectionType = .verse
var body: some View {
VStack(alignment: .leading, spacing: 12) {
if project.sections.isEmpty {
Text("No sections yet")
.foregroundStyle(.secondary)
} else {
ForEach(project.orderedSections) { section in
SongSectionEditorCard(
section: binding(for: section),
moveUpAction: {
moveSection(id: section.id, by: -1)
},
moveDownAction: {
moveSection(id: section.id, by: 1)
},
removeAction: {
removeSection(id: section.id)
}
)
}
}
Divider()
VStack(alignment: .leading, spacing: 8) {
Picker("Type", selection: $newSectionType) {
ForEach(SongSectionType.editorCases, id: \.self) { type in
Text(type.displayName).tag(type)
}
}
HStack {
TextField("New section title", text: $newSectionTitle)
.onSubmit(addSection)
Button(action: addSection) {
Label("Add section", systemImage: "plus.circle.fill")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.disabled(newSectionTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.help("Add section")
}
}
}
}
private func binding(for section: SongSection) -> Binding<SongSection> {
Binding {
project.sections.first { $0.id == section.id } ?? section
} set: { updatedSection in
guard let index = project.sections.firstIndex(where: { $0.id == section.id }) else { return }
project.sections[index] = updatedSection
project.normalizeSectionOrder()
}
}
private func addSection() {
let trimmedTitle = newSectionTitle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTitle.isEmpty else { return }
project.appendSection(
SongSection(
type: newSectionType,
title: trimmedTitle
)
)
newSectionTitle = ""
}
private func moveSection(id sectionID: String, by offset: Int) {
let orderedSections = project.orderedSections
guard let currentIndex = orderedSections.firstIndex(where: { $0.id == sectionID }) else { return }
project.moveSection(id: sectionID, toOrder: currentIndex + offset)
}
private func removeSection(id sectionID: String) {
project.sections.removeAll { $0.id == sectionID }
project.normalizeSectionOrder()
}
}
private struct SongSectionEditorCard: View {
@Binding var section: SongSection
let moveUpAction: () -> Void
let moveDownAction: () -> Void
let removeAction: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text(section.title.isEmpty ? "Untitled section" : section.title)
.font(.headline)
.lineLimit(1)
Spacer()
Button(action: moveUpAction) {
Label("Move up", systemImage: "chevron.up")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.help("Move up")
Button(action: moveDownAction) {
Label("Move down", systemImage: "chevron.down")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.help("Move down")
Button(action: removeAction) {
Label("Remove", systemImage: "trash")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.foregroundStyle(.red)
.help("Remove")
}
Picker("Type", selection: $section.type) {
ForEach(SongSectionType.editorCases, id: \.self) { type in
Text(type.displayName).tag(type)
}
}
TextField("Title", text: $section.title)
TextField("Start seconds", text: optionalTimeBinding(\.startTime))
TextField("End seconds", text: optionalTimeBinding(\.endTime))
TextField("Lyrics or notes", text: $section.lyrics, axis: .vertical)
.lineLimit(2...4)
}
.padding(10)
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
}
private func optionalTimeBinding(_ keyPath: WritableKeyPath<SongSection, TimeInterval?>) -> Binding<String> {
Binding {
guard let value = section[keyPath: keyPath] else { return "" }
return String(format: "%.0f", value)
} set: { newValue in
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
section[keyPath: keyPath] = trimmedValue.isEmpty ? nil : TimeInterval(trimmedValue)
}
}
}
extension SongSectionType {
static let editorCases: [SongSectionType] = [
.intro,
.verse,
.preChorus,
.chorus,
.bridge,
.outro,
.custom
]
var displayName: String {
switch self {
case .intro:
return "Intro"
case .verse:
return "Verse"
case .preChorus:
return "Pre-Chorus"
case .chorus:
return "Chorus"
case .bridge:
return "Bridge"
case .outro:
return "Outro"
case .custom:
return "Custom"
}
}
}
#Preview {
SongStructureEditorPreview()
}
private struct SongStructureEditorPreview: View {
@State private var project = SongProject(
title: "Structure Preview",
idea: "Edit sections",
sections: [
SongSection(type: .intro, title: "Intro"),
SongSection(type: .verse, title: "Verse", order: 1, startTime: 12, endTime: 42),
SongSection(type: .chorus, title: "Chorus", order: 2)
]
)
var body: some View {
Form {
SongStructureEditorView(project: $project)
}
.formStyle(.grouped)
.frame(width: 360)
}
}
@@ -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,14 +2,20 @@ import Foundation
public struct AppConfiguration: Equatable, Sendable { public struct AppConfiguration: Equatable, Sendable {
public let openAIAPIKey: String? public let openAIAPIKey: String?
public let privateApplicationRules: PrivateApplicationRules?
public init(openAIAPIKey: String?) { public init(
openAIAPIKey: String?,
privateApplicationRules: PrivateApplicationRules? = nil
) {
self.openAIAPIKey = openAIAPIKey self.openAIAPIKey = openAIAPIKey
self.privateApplicationRules = privateApplicationRules
} }
public static func development(environment: [String: String] = ProcessInfo.processInfo.environment) -> AppConfiguration { public static func development(environment: [String: String] = ProcessInfo.processInfo.environment) -> AppConfiguration {
AppConfiguration( AppConfiguration(
openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"] openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"],
privateApplicationRules: PrivateApplicationRules(content: environment["MUSIC_ASSISTANT_PRIVATE_AI_RULES"])
) )
} }
} }
@@ -6,6 +6,6 @@ public struct ProductBoundarySummary: Equatable, Sendable {
} }
public static let initial = ProductBoundarySummary( 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 key: ManualAutoValue<String>?
public var scale: ManualAutoValue<String>? public var scale: ManualAutoValue<String>?
public var maqam: ManualAutoValue<String>? public var maqam: ManualAutoValue<String>?
public var structureMode: ControlMode?
public var sections: [SongSection] public var sections: [SongSection]
public var instruments: [InstrumentTrack] public var instruments: [InstrumentTrack]
public var vocalists: [Vocalist] public var vocalists: [Vocalist]
public var lyrics: Lyrics public var lyrics: Lyrics
public var productionDirections: [ProductionDirection] public var productionDirections: [ProductionDirection]
public var productionMode: ControlMode?
public var userLocks: SongProjectUserLocks?
public var sunoOutput: SunoOutput? public var sunoOutput: SunoOutput?
public var versions: [SongVersion] public var versions: [SongVersion]
public let createdAt: Date public let createdAt: Date
@@ -42,11 +45,14 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
key: ManualAutoValue<String>? = nil, key: ManualAutoValue<String>? = nil,
scale: ManualAutoValue<String>? = nil, scale: ManualAutoValue<String>? = nil,
maqam: ManualAutoValue<String>? = nil, maqam: ManualAutoValue<String>? = nil,
structureMode: ControlMode? = .auto,
sections: [SongSection] = [], sections: [SongSection] = [],
instruments: [InstrumentTrack] = [], instruments: [InstrumentTrack] = [],
vocalists: [Vocalist] = [], vocalists: [Vocalist] = [],
lyrics: Lyrics = Lyrics(), lyrics: Lyrics = Lyrics(),
productionDirections: [ProductionDirection] = [], productionDirections: [ProductionDirection] = [],
productionMode: ControlMode? = .auto,
userLocks: SongProjectUserLocks? = SongProjectUserLocks(),
sunoOutput: SunoOutput? = nil, sunoOutput: SunoOutput? = nil,
versions: [SongVersion] = [], versions: [SongVersion] = [],
createdAt: Date = Date(), createdAt: Date = Date(),
@@ -67,11 +73,14 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
self.key = key self.key = key
self.scale = scale self.scale = scale
self.maqam = maqam self.maqam = maqam
self.structureMode = structureMode
self.sections = sections self.sections = sections
self.instruments = instruments self.instruments = instruments
self.vocalists = vocalists self.vocalists = vocalists
self.lyrics = lyrics self.lyrics = lyrics
self.productionDirections = productionDirections self.productionDirections = productionDirections
self.productionMode = productionMode
self.userLocks = userLocks
self.sunoOutput = sunoOutput self.sunoOutput = sunoOutput
self.versions = versions self.versions = versions
self.createdAt = createdAt self.createdAt = createdAt
@@ -80,6 +89,42 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
} }
public extension SongProject { 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] { var orderedSections: [SongSection] {
sections sections
.enumerated() .enumerated()
@@ -109,6 +154,132 @@ public extension SongProject {
genres.removeAll { $0.id == id } genres.removeAll { $0.id == id }
} }
var languageIDs: Set<String> {
Set(languages.map(\.id))
}
var dialectIDs: Set<String> {
Set(dialects.map(\.id))
}
func language(for languageID: String) -> LanguageSelection? {
languages.first { $0.id == languageID }
}
func dialect(for dialectID: String) -> DialectSelection? {
dialects.first { $0.id == dialectID }
}
func dialects(forLanguageID languageID: String) -> [DialectSelection] {
dialects.filter { $0.languageID == languageID }
}
mutating func upsertLanguage(_ language: LanguageSelection) {
if let index = languages.firstIndex(where: { $0.id == language.id }) {
languages[index] = language
} else {
languages.append(language)
}
}
mutating func removeLanguage(id languageID: String) {
languages.removeAll { $0.id == languageID }
dialects.removeAll { $0.languageID == languageID }
}
mutating func upsertDialect(_ dialect: DialectSelection) {
if let index = dialects.firstIndex(where: { $0.id == dialect.id }) {
dialects[index] = dialect
} else {
dialects.append(dialect)
}
}
mutating func removeDialect(id dialectID: String) {
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)
}
mutating func setBPMAutomatic() {
bpm = ManualAutoValue(mode: .auto)
}
mutating func setKeyManually(_ value: String) {
key = ManualAutoValue(mode: .manual, value: value)
}
mutating func setKeyAutomatic() {
key = ManualAutoValue(mode: .auto)
}
mutating func setScaleManually(_ value: String) {
scale = ManualAutoValue(mode: .manual, value: value)
}
mutating func setScaleAutomatic() {
scale = ManualAutoValue(mode: .auto)
}
mutating func setMaqamManually(_ value: String) {
maqam = ManualAutoValue(mode: .manual, value: value)
}
mutating func setMaqamAutomatic() {
maqam = ManualAutoValue(mode: .auto)
}
func emotionalArcPoint(for pointID: String) -> EmotionalArcPoint? {
emotionalArc.first { $0.id == pointID }
}
func emotionalArcPoints(forSectionID sectionID: String) -> [EmotionalArcPoint] {
emotionalArc.filter { $0.sectionID == sectionID }
}
mutating func upsertEmotionalArcPoint(_ point: EmotionalArcPoint) {
if let index = emotionalArc.firstIndex(where: { $0.id == point.id }) {
emotionalArc[index] = point
} else {
emotionalArc.append(point)
}
}
mutating func removeEmotionalArcPoint(id pointID: String) {
emotionalArc.removeAll { $0.id == pointID }
}
func productionDirection(for directionID: String) -> ProductionDirection? {
productionDirections.first { $0.id == directionID }
}
func productionDirections(forSectionID sectionID: String) -> [ProductionDirection] {
productionDirections.filter { $0.sectionID == sectionID }
}
mutating func upsertProductionDirection(_ direction: ProductionDirection) {
if let index = productionDirections.firstIndex(where: { $0.id == direction.id }) {
productionDirections[index] = direction
} else {
productionDirections.append(direction)
}
}
mutating func removeProductionDirection(id directionID: String) {
productionDirections.removeAll { $0.id == directionID }
}
var selectedInstrumentTracks: [InstrumentTrack] { var selectedInstrumentTracks: [InstrumentTrack] {
instruments.filter(\.selected) instruments.filter(\.selected)
} }
@@ -141,6 +312,33 @@ public extension SongProject {
instruments[index].selected = false 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( mutating func configureInstrument(
id instrumentId: String, id instrumentId: String,
variant: String?, variant: String?,
@@ -207,6 +405,58 @@ public extension SongProject {
vocalists[index].assignedSections.removeAll { $0 == sectionID } vocalists[index].assignedSections.removeAll { $0 == sectionID }
} }
mutating func createVersion(
id: String = UUID().uuidString,
label: String,
createdAt: Date = Date()
) -> SongVersion {
let version = SongVersion(
id: id,
label: label,
snapshot: SongProjectSnapshot(project: self),
createdAt: createdAt
)
versions.append(version)
return version
}
func version(id versionID: String) -> SongVersion? {
versions.first { $0.id == versionID }
}
mutating func restoreVersion(id versionID: String, restoredAt: Date = Date()) -> Bool {
guard let version = version(id: versionID) else { return false }
restoreSnapshot(version.snapshot, updatedAt: restoredAt)
return true
}
mutating func restoreSnapshot(_ snapshot: SongProjectSnapshot, updatedAt: Date = Date()) {
title = snapshot.title
idea = snapshot.idea
duration = snapshot.duration
conversationMode = snapshot.conversationMode
languages = snapshot.languages
dialects = snapshot.dialects
arabicPronunciationSettings = snapshot.arabicPronunciationSettings
genres = snapshot.genres
moods = snapshot.moods
emotionalArc = snapshot.emotionalArc
bpm = snapshot.bpm
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
}
mutating func appendSection(_ section: SongSection) { mutating func appendSection(_ section: SongSection) {
var orderedSection = section var orderedSection = section
orderedSection.order = nextSectionOrder orderedSection.order = nextSectionOrder
@@ -285,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 struct DialectSelection: Codable, Equatable, Identifiable, Sendable {
public let id: String public let id: String
public var name: String public var name: String
@@ -299,16 +575,39 @@ public struct DialectSelection: Codable, Equatable, Identifiable, Sendable {
public struct ArabicPronunciationSettings: Codable, Equatable, Sendable { public struct ArabicPronunciationSettings: Codable, Equatable, Sendable {
public var isEnabled: Bool public var isEnabled: Bool
public var diacritizationPolicy: ArabicDiacritizationPolicy
public var tanweenPolicy: ArabicTanweenPolicy
public var userNotes: String? public var userNotes: String?
public var preservedSpellings: [String] public var preservedSpellings: [String]
public init(isEnabled: Bool = false, userNotes: String? = nil, preservedSpellings: [String] = []) { public init(
isEnabled: Bool = false,
diacritizationPolicy: ArabicDiacritizationPolicy = .unspecified,
tanweenPolicy: ArabicTanweenPolicy = .unspecified,
userNotes: String? = nil,
preservedSpellings: [String] = []
) {
self.isEnabled = isEnabled self.isEnabled = isEnabled
self.diacritizationPolicy = diacritizationPolicy
self.tanweenPolicy = tanweenPolicy
self.userNotes = userNotes self.userNotes = userNotes
self.preservedSpellings = preservedSpellings self.preservedSpellings = preservedSpellings
} }
} }
public enum ArabicDiacritizationPolicy: String, Codable, Equatable, Sendable {
case unspecified
case pronunciationTargeted
case fullTashkeel
}
public enum ArabicTanweenPolicy: String, Codable, Equatable, Sendable {
case unspecified
case preserveUserInput
case addWhenPronunciationRequires
case removeWhenUnwanted
}
public struct GenreStyle: Codable, Equatable, Identifiable, Sendable { public struct GenreStyle: Codable, Equatable, Identifiable, Sendable {
public let id: String public let id: String
public var name: String public var name: String
@@ -321,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 struct MoodTag: Codable, Equatable, Identifiable, Sendable {
public let id: String public let id: String
public var name: String public var name: String
@@ -497,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 struct Vocalist: Codable, Equatable, Identifiable, Sendable {
public let id: String public let id: String
public var label: String public var label: String
@@ -575,11 +947,14 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
public var key: ManualAutoValue<String>? public var key: ManualAutoValue<String>?
public var scale: ManualAutoValue<String>? public var scale: ManualAutoValue<String>?
public var maqam: ManualAutoValue<String>? public var maqam: ManualAutoValue<String>?
public var structureMode: ControlMode?
public var sections: [SongSection] public var sections: [SongSection]
public var instruments: [InstrumentTrack] public var instruments: [InstrumentTrack]
public var vocalists: [Vocalist] public var vocalists: [Vocalist]
public var lyrics: Lyrics public var lyrics: Lyrics
public var productionDirections: [ProductionDirection] public var productionDirections: [ProductionDirection]
public var productionMode: ControlMode?
public var userLocks: SongProjectUserLocks?
public var sunoOutput: SunoOutput? public var sunoOutput: SunoOutput?
public init( public init(
@@ -597,11 +972,14 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
key: ManualAutoValue<String>? = nil, key: ManualAutoValue<String>? = nil,
scale: ManualAutoValue<String>? = nil, scale: ManualAutoValue<String>? = nil,
maqam: ManualAutoValue<String>? = nil, maqam: ManualAutoValue<String>? = nil,
structureMode: ControlMode? = .auto,
sections: [SongSection] = [], sections: [SongSection] = [],
instruments: [InstrumentTrack] = [], instruments: [InstrumentTrack] = [],
vocalists: [Vocalist] = [], vocalists: [Vocalist] = [],
lyrics: Lyrics = Lyrics(), lyrics: Lyrics = Lyrics(),
productionDirections: [ProductionDirection] = [], productionDirections: [ProductionDirection] = [],
productionMode: ControlMode? = .auto,
userLocks: SongProjectUserLocks? = SongProjectUserLocks(),
sunoOutput: SunoOutput? = nil sunoOutput: SunoOutput? = nil
) { ) {
self.title = title self.title = title
@@ -618,11 +996,14 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
self.key = key self.key = key
self.scale = scale self.scale = scale
self.maqam = maqam self.maqam = maqam
self.structureMode = structureMode
self.sections = sections self.sections = sections
self.instruments = instruments self.instruments = instruments
self.vocalists = vocalists self.vocalists = vocalists
self.lyrics = lyrics self.lyrics = lyrics
self.productionDirections = productionDirections self.productionDirections = productionDirections
self.productionMode = productionMode
self.userLocks = userLocks
self.sunoOutput = sunoOutput self.sunoOutput = sunoOutput
} }
@@ -642,11 +1023,14 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
key: project.key, key: project.key,
scale: project.scale, scale: project.scale,
maqam: project.maqam, maqam: project.maqam,
structureMode: project.structureMode,
sections: project.sections, sections: project.sections,
instruments: project.instruments, instruments: project.instruments,
vocalists: project.vocalists, vocalists: project.vocalists,
lyrics: project.lyrics, lyrics: project.lyrics,
productionDirections: project.productionDirections, productionDirections: project.productionDirections,
productionMode: project.productionMode,
userLocks: project.userLocks,
sunoOutput: project.sunoOutput 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 }
}
}
@@ -0,0 +1,180 @@
import Foundation
public struct OpenAIClientConfiguration: Equatable, Sendable {
public var apiKey: String
public var endpointURL: URL
public var model: String?
public var organizationID: String?
public var projectID: String?
public init(
apiKey: String,
endpointURL: URL,
model: String? = nil,
organizationID: String? = nil,
projectID: String? = nil
) {
self.apiKey = apiKey
self.endpointURL = endpointURL
self.model = model
self.organizationID = organizationID
self.projectID = projectID
}
}
public struct OpenAIClientRequest: Equatable, Sendable {
public var method: String
public var url: URL?
public var body: Data
public var additionalHeaders: [String: String]
public init(
method: String = "POST",
url: URL? = nil,
body: Data,
additionalHeaders: [String: String] = [:]
) {
self.method = method
self.url = url
self.body = body
self.additionalHeaders = additionalHeaders
}
}
public protocol OpenAIClientAdapter: Sendable {
func makeProjectGenerationRequest(
_ request: SongProjectGenerationRequest,
configuration: OpenAIClientConfiguration
) throws -> OpenAIClientRequest
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
) throws -> OpenAIClientRequest
func decodeLyricsRevisionResult(from data: Data) throws -> LyricsRevisionResult
func makeProjectUpdateRequest(
_ request: SongProjectUpdateRequest,
configuration: OpenAIClientConfiguration
) throws -> OpenAIClientRequest
func decodeProjectUpdateResult(from data: Data) throws -> SongProjectUpdateResult
}
public protocol OpenAIHTTPTransport: Sendable {
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
}
extension URLSession: OpenAIHTTPTransport {
public func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let (data, response) = try await data(for: request, delegate: nil)
guard let httpResponse = response as? HTTPURLResponse else {
throw OpenAIClientError.invalidResponse
}
return (data, httpResponse)
}
}
public final class OpenAIClient: AIService, Sendable {
private let configuration: OpenAIClientConfiguration
private let adapter: any OpenAIClientAdapter
private let transport: any OpenAIHTTPTransport
public init(
configuration: OpenAIClientConfiguration,
adapter: any OpenAIClientAdapter,
transport: any OpenAIHTTPTransport = URLSession.shared
) {
self.configuration = configuration
self.adapter = adapter
self.transport = transport
}
public func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
let clientRequest = try adapter.makeProjectGenerationRequest(request, configuration: configuration)
let data = try await perform(clientRequest)
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)
return try adapter.decodeLyricsRevisionResult(from: data)
}
public func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
let clientRequest = try adapter.makeProjectUpdateRequest(request, configuration: configuration)
let data = try await perform(clientRequest)
return try adapter.decodeProjectUpdateResult(from: data)
}
private func perform(_ clientRequest: OpenAIClientRequest) async throws -> Data {
try Task.checkCancellation()
guard !configuration.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw OpenAIClientError.missingAPIKey
}
var request = URLRequest(url: clientRequest.url ?? configuration.endpointURL)
request.httpMethod = clientRequest.method
request.httpBody = clientRequest.body
request.setValue("Bearer \(configuration.apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let organizationID = configuration.organizationID {
request.setValue(organizationID, forHTTPHeaderField: "OpenAI-Organization")
}
if let projectID = configuration.projectID {
request.setValue(projectID, forHTTPHeaderField: "OpenAI-Project")
}
for (header, value) in clientRequest.additionalHeaders {
request.setValue(value, forHTTPHeaderField: header)
}
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
}
@@ -1,6 +1,133 @@
public protocol ProjectStore: Sendable { import Foundation
associatedtype Project
func save(_ project: Project) async throws 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 loadProjectIDs() async throws -> [String]
func loadProjectList() async throws -> [SongProjectListItem]
}
public struct SongProjectListItem: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var title: String
public var idea: String
public var createdAt: Date
public var updatedAt: Date
public init(id: String, title: String, idea: String, createdAt: Date, updatedAt: Date) {
self.id = id
self.title = title
self.idea = idea
self.createdAt = createdAt
self.updatedAt = updatedAt
}
public init(project: SongProject) {
self.init(
id: project.id,
title: project.title,
idea: project.idea,
createdAt: project.createdAt,
updatedAt: project.updatedAt
)
}
}
public actor LocalSongProjectStore: ProjectStore {
private let directoryURL: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(directoryURL: URL) {
self.directoryURL = directoryURL
self.encoder = JSONEncoder()
self.decoder = JSONDecoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
}
public func create(_ project: SongProject) async throws {
try ensureDirectoryExists()
let url = projectURL(for: project.id)
guard !FileManager.default.fileExists(atPath: url.path) else {
throw LocalSongProjectStoreError.projectAlreadyExists(project.id)
}
try saveEncoded(project, to: url)
}
public func save(_ project: SongProject) async throws {
try ensureDirectoryExists()
try saveEncoded(project, to: projectURL(for: project.id))
}
public func open(id: String) async throws -> SongProject {
let url = projectURL(for: id)
guard FileManager.default.fileExists(atPath: url.path) else {
throw LocalSongProjectStoreError.projectNotFound(id)
}
let data = try Data(contentsOf: url)
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)
}
public func loadProjectList() async throws -> [SongProjectListItem] {
try ensureDirectoryExists()
return try projectFileURLs()
.map { url in
let data = try Data(contentsOf: url)
let project = try decoder.decode(SongProject.self, from: data)
return SongProjectListItem(project: project)
}
.sorted { left, right in
if left.updatedAt == right.updatedAt {
return left.title.localizedStandardCompare(right.title) == .orderedAscending
}
return left.updatedAt > right.updatedAt
}
}
private func ensureDirectoryExists() throws {
try FileManager.default.createDirectory(
at: directoryURL,
withIntermediateDirectories: true
)
}
private func saveEncoded(_ project: SongProject, to url: URL) throws {
let data = try encoder.encode(project)
try data.write(to: url, options: [.atomic])
}
private func projectFileURLs() throws -> [URL] {
try FileManager.default.contentsOfDirectory(
at: directoryURL,
includingPropertiesForKeys: nil
)
.filter { $0.pathExtension == Self.fileExtension }
}
private func projectURL(for id: String) -> URL {
directoryURL
.appendingPathComponent(id)
.appendingPathExtension(Self.fileExtension)
}
private static let fileExtension = "json"
}
public enum LocalSongProjectStoreError: Error, Equatable, Sendable {
case projectAlreadyExists(String)
case projectNotFound(String)
} }
@@ -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,19 +1,203 @@
import Foundation
public protocol AIService: Sendable { public protocol AIService: Sendable {
func planSongProject(from request: SongPlanningRequest) async throws -> SongPlanningDraft 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 struct SongPlanningRequest: Equatable, Sendable { public extension AIService {
public let userIntent: String func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
throw AIServiceCapabilityError.discussionNotSupported
public init(userIntent: String) {
self.userIntent = userIntent
} }
} }
public struct SongPlanningDraft: Equatable, Sendable { public enum AIServiceCapabilityError: Error, Equatable, Sendable {
public let summary: String case discussionNotSupported
}
public init(summary: String) { public struct AIConversationMessage: Equatable, Identifiable, Sendable {
self.summary = summary public let id: String
public var role: AIConversationRole
public var content: String
public init(
id: String = UUID().uuidString,
role: AIConversationRole,
content: String
) {
self.id = id
self.role = role
self.content = content
}
}
public enum AIConversationRole: String, Equatable, Sendable {
case user
case assistant
case system
}
public struct AIRequestContext: Equatable, Sendable {
public var userInstruction: String
public var conversation: [AIConversationMessage]
public var localeIdentifier: String?
internal var privateApplicationRules: PrivateApplicationRules?
public init(
userInstruction: String,
conversation: [AIConversationMessage] = [],
localeIdentifier: String? = nil
) {
self.userInstruction = userInstruction
self.conversation = conversation
self.localeIdentifier = localeIdentifier
self.privateApplicationRules = nil
}
internal mutating func injectPrivateApplicationRules(_ rules: PrivateApplicationRules?) {
privateApplicationRules = rules
}
}
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,
songGenerationContext: SongGenerationContext? = nil
) {
self.context = context
self.seedProject = seedProject
self.discussionMode = discussionMode
self.songGenerationContext = songGenerationContext
?? seedProject.map(SongGenerationContext.init(project:))
?? .empty
}
}
public struct SongProjectGenerationResult: Equatable, Sendable {
public var project: SongProject
public var notes: [String]
public var followUpQuestions: [String]
public init(
project: SongProject,
notes: [String] = [],
followUpQuestions: [String] = []
) {
self.project = project
self.notes = notes
self.followUpQuestions = followUpQuestions
}
}
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,
songGenerationContext: SongGenerationContext? = nil
) {
self.context = context
self.project = project
self.sourceLyrics = sourceLyrics
self.mode = mode
self.songGenerationContext = songGenerationContext ?? SongGenerationContext(project: project)
}
}
public enum LyricsRevisionMode: String, Equatable, Sendable {
case writeFromIdea
case correct
case improve
case restructure
case complete
}
public struct LyricsRevisionResult: Equatable, Sendable {
public var lyrics: Lyrics
public var notes: [String]
public init(lyrics: Lyrics, notes: [String] = []) {
self.lyrics = lyrics
self.notes = notes
}
}
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],
songGenerationContext: SongGenerationContext? = nil
) {
self.context = context
self.project = project
self.allowedScopes = allowedScopes
self.songGenerationContext = songGenerationContext ?? SongGenerationContext(project: project)
}
}
public struct SongProjectUpdateResult: Equatable, Sendable {
public var project: SongProject
public var changedScopes: [SongProjectUpdateScope]
public var notes: [String]
public var followUpQuestions: [String]
public init(
project: SongProject,
changedScopes: [SongProjectUpdateScope] = [],
notes: [String] = [],
followUpQuestions: [String] = []
) {
self.project = project
self.changedScopes = changedScopes
self.notes = notes
self.followUpQuestions = followUpQuestions
} }
} }
@@ -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
}
}
@@ -0,0 +1,83 @@
import Foundation
public struct PrivateApplicationRules: Equatable, Sendable {
public let content: String
public init?(content: String?) {
guard let content else { return nil }
let trimmedContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedContent.isEmpty else { return nil }
self.content = trimmedContent
}
}
public protocol ApplicationRuleProviding: Sendable {
func privateApplicationRules() throws -> PrivateApplicationRules?
}
public struct StaticApplicationRuleProvider: ApplicationRuleProviding {
private let rules: PrivateApplicationRules?
public init(rules: PrivateApplicationRules?) {
self.rules = rules
}
public func privateApplicationRules() throws -> PrivateApplicationRules? {
rules
}
}
public final class ApplicationRuleInjectingAIService: AIService, Sendable {
private let baseService: any AIService
private let ruleProvider: any ApplicationRuleProviding
public init(
baseService: any AIService,
ruleProvider: any ApplicationRuleProviding
) {
self.baseService = baseService
self.ruleProvider = ruleProvider
}
public func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
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))
}
public func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
try await baseService.proposeProjectUpdate(from: requestWithInjectedRules(request))
}
private func requestWithInjectedRules(_ request: SongProjectGenerationRequest) throws -> SongProjectGenerationRequest {
var request = request
request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules())
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())
return request
}
private func requestWithInjectedRules(_ request: SongProjectUpdateRequest) throws -> SongProjectUpdateRequest {
var request = request
request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules())
return request
}
}
@@ -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
}
@@ -0,0 +1,36 @@
import Foundation
public struct StructuredSongProjectUpdateResponse: Codable, Equatable, Sendable {
public static let schemaName = "song_project_update_response"
public var project: SongProjectSnapshot
public var changedScopes: [SongProjectUpdateScope]
public var notes: [String]
public var followUpQuestions: [String]
public init(
project: SongProjectSnapshot,
changedScopes: [SongProjectUpdateScope] = [],
notes: [String] = [],
followUpQuestions: [String] = []
) {
self.project = project
self.changedScopes = changedScopes
self.notes = notes
self.followUpQuestions = followUpQuestions
}
public func makeUpdateResult(
updating existingProject: SongProject,
updatedAt: Date = Date()
) -> SongProjectUpdateResult {
SongProjectAIUpdateMerger().apply(
snapshot: project,
changedScopes: changedScopes,
notes: notes,
followUpQuestions: followUpQuestions,
to: existingProject,
updatedAt: updatedAt
)
}
}
@@ -0,0 +1,204 @@
import Foundation
public struct ArabicPronunciationProcessor: Sendable {
public init() {}
public func process(
lyrics: String,
settings: ArabicPronunciationSettings
) -> ArabicPronunciationProcessingResult {
guard settings.isEnabled else {
return ArabicPronunciationProcessingResult(
text: lyrics,
notes: [.processingDisabled]
)
}
let protection = protectPreservedSpellings(
in: lyrics,
preservedSpellings: settings.preservedSpellings
)
var processedText = protection.text.precomposedStringWithCanonicalMapping
var notes = processingNotes(for: processedText, settings: settings)
if settings.tanweenPolicy == .removeWhenUnwanted {
processedText = removeTanween(from: processedText)
}
if settings.tanweenPolicy == .addWhenPronunciationRequires,
containsArabicLetter(in: processedText),
!containsTanween(in: processedText) {
notes.append(.tanweenAdditionNeedsReview)
}
processedText = restorePreservedSpellings(
in: processedText,
replacements: protection.replacements
)
if protection.matchCount > 0 {
notes.append(.preservedSpellingsProtected(count: protection.matchCount))
}
return ArabicPronunciationProcessingResult(
text: processedText,
notes: notes
)
}
private func processingNotes(
for text: String,
settings: ArabicPronunciationSettings
) -> [ArabicPronunciationProcessingNote] {
guard containsArabicLetter(in: text) else {
return []
}
switch settings.diacritizationPolicy {
case .unspecified:
return [.diacritizationPolicyUnspecified]
case .pronunciationTargeted where !containsDiacritics(in: text):
return [.pronunciationTargetedDiacriticsNeedReview]
case .fullTashkeel where !containsDiacritics(in: text):
return [.fullTashkeelNeedsReview]
default:
return []
}
}
private func protectPreservedSpellings(
in text: String,
preservedSpellings: [String]
) -> PreservedSpellingProtection {
let spellings = Set(
preservedSpellings.map {
$0.trimmingCharacters(in: .whitespacesAndNewlines)
}
)
.filter { !$0.isEmpty }
.sorted {
if $0.count == $1.count {
return $0 < $1
}
return $0.count > $1.count
}
var protectedText = text
var replacements: [PreservedSpellingReplacement] = []
var matchCount = 0
for (index, spelling) in spellings.enumerated() {
let occurrenceCount = protectedText.components(separatedBy: spelling).count - 1
guard occurrenceCount > 0 else {
continue
}
let placeholder = uniquePlaceholder(
for: index,
in: protectedText
)
protectedText = protectedText.replacingOccurrences(
of: spelling,
with: placeholder
)
replacements.append(
PreservedSpellingReplacement(
placeholder: placeholder,
spelling: spelling
)
)
matchCount += occurrenceCount
}
return PreservedSpellingProtection(
text: protectedText,
replacements: replacements,
matchCount: matchCount
)
}
private func uniquePlaceholder(for index: Int, in text: String) -> String {
var collisionIndex = 0
var placeholder = "[[music-assistant-preserved-\(index)]]"
while text.contains(placeholder) {
collisionIndex += 1
placeholder = "[[music-assistant-preserved-\(index)-\(collisionIndex)]]"
}
return placeholder
}
private func restorePreservedSpellings(
in text: String,
replacements: [PreservedSpellingReplacement]
) -> String {
replacements.reduce(text) { result, replacement in
result.replacingOccurrences(
of: replacement.placeholder,
with: replacement.spelling
)
}
}
private func removeTanween(from text: String) -> String {
String(text.unicodeScalars.filter { scalar in
!isTanween(scalar)
})
}
private func containsArabicLetter(in text: String) -> Bool {
text.unicodeScalars.contains { scalar in
let isArabicBlock = (0x0600...0x06FF).contains(scalar.value)
|| (0x0750...0x077F).contains(scalar.value)
|| (0x08A0...0x08FF).contains(scalar.value)
return isArabicBlock && CharacterSet.letters.contains(scalar)
}
}
private func containsDiacritics(in text: String) -> Bool {
text.unicodeScalars.contains(where: isArabicDiacritic)
}
private func containsTanween(in text: String) -> Bool {
text.unicodeScalars.contains(where: isTanween)
}
private func isArabicDiacritic(_ scalar: Unicode.Scalar) -> Bool {
(0x064B...0x065F).contains(scalar.value) || scalar.value == 0x0670
}
private func isTanween(_ scalar: Unicode.Scalar) -> Bool {
(0x064B...0x064D).contains(scalar.value)
}
}
private struct PreservedSpellingProtection {
let text: String
let replacements: [PreservedSpellingReplacement]
let matchCount: Int
}
private struct PreservedSpellingReplacement {
let placeholder: String
let spelling: String
}
public struct ArabicPronunciationProcessingResult: Equatable, Sendable {
public var text: String
public var notes: [ArabicPronunciationProcessingNote]
public init(text: String, notes: [ArabicPronunciationProcessingNote] = []) {
self.text = text
self.notes = notes
}
}
public enum ArabicPronunciationProcessingNote: Equatable, Sendable {
case processingDisabled
case diacritizationPolicyUnspecified
case pronunciationTargetedDiacriticsNeedReview
case fullTashkeelNeedsReview
case tanweenAdditionNeedsReview
case preservedSpellingsProtected(count: Int)
}
@@ -0,0 +1,338 @@
import Foundation
public struct LocalGenreStyleCatalog: Sendable {
public let items: [GenreStyleCatalogItem]
public init(items: [GenreStyleCatalogItem] = LocalGenreStyleCatalog.worldwideItems) {
self.items = items
}
public var familyCategories: [String] {
Array(Set(items.map(\.familyCategory)))
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
public var regionsOfOrigin: [String] {
Array(Set(items.compactMap(\.regionOrigin)))
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
public func styles(
matching query: String,
familyCategory: String? = nil,
regionOrigin: String? = nil
) -> [GenreStyleCatalogItem] {
let searchTokens = Self.searchTokens(for: query)
let selectedFamilyCategory = familyCategory?.trimmingCharacters(in: .whitespacesAndNewlines)
let selectedRegionOrigin = regionOrigin?.trimmingCharacters(in: .whitespacesAndNewlines)
return items
.filter { item in
let matchesSearch = searchTokens.isEmpty || searchTokens.allSatisfy { token in
Self.searchableTerms(for: item).contains { $0.contains(token) }
}
let matchesFamily = selectedFamilyCategory?.isEmpty != false || item.familyCategory == selectedFamilyCategory
let matchesRegion = selectedRegionOrigin?.isEmpty != false || item.regionOrigin == selectedRegionOrigin
return matchesSearch && matchesFamily && matchesRegion
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
public static let worldwideItems: [GenreStyleCatalogItem] =
popAndContemporary + rockAndAlternative + electronicAndDance + urbanAndSoul + jazzAndBlues +
countryFolkAndRoots + latinAndCaribbean + middleEasternAndNorthAfrican + southAsian + eastAsian +
southeastAsianAndOceania + african + reggaeAndCaribbean + classicalAndCinematic
private static let popAndContemporary: [GenreStyleCatalogItem] = [
item("pop", "Pop", "Pop & Contemporary", "Global"),
item("adult-contemporary", "Adult Contemporary", "Pop & Contemporary", "Western"),
item("art-pop", "Art Pop", "Pop & Contemporary", "Western"),
item("baroque-pop", "Baroque Pop", "Pop & Contemporary", "Western"),
item("chamber-pop", "Chamber Pop", "Pop & Contemporary", "Western"),
item("dance-pop", "Dance Pop", "Pop & Contemporary", "Western"),
item("dream-pop", "Dream Pop", "Pop & Contemporary", "Western"),
item("electropop", "Electropop", "Pop & Contemporary", "Western"),
item("hyperpop", "Hyperpop", "Pop & Contemporary", "Western"),
item("indie-pop", "Indie Pop", "Pop & Contemporary", "Western"),
item("power-pop", "Power Pop", "Pop & Contemporary", "Western"),
item("synth-pop", "Synth Pop", "Pop & Contemporary", "Western"),
item("teen-pop", "Teen Pop", "Pop & Contemporary", "Western"),
item("soft-pop", "Soft Pop", "Pop & Contemporary", "Western"),
item("singer-songwriter", "Singer-Songwriter", "Pop & Contemporary", "Western")
]
private static let rockAndAlternative: [GenreStyleCatalogItem] = [
item("rock", "Rock", "Rock & Alternative", "Western"),
item("alternative-rock", "Alternative Rock", "Rock & Alternative", "Western", aliases: ["alt rock"]),
item("classic-rock", "Classic Rock", "Rock & Alternative", "Western"),
item("hard-rock", "Hard Rock", "Rock & Alternative", "Western"),
item("indie-rock", "Indie Rock", "Rock & Alternative", "Western"),
item("progressive-rock", "Progressive Rock", "Rock & Alternative", "Western", aliases: ["prog rock"]),
item("psychedelic-rock", "Psychedelic Rock", "Rock & Alternative", "Western", aliases: ["psych rock"]),
item("garage-rock", "Garage Rock", "Rock & Alternative", "Western"),
item("surf-rock", "Surf Rock", "Rock & Alternative", "Western"),
item("post-rock", "Post-Rock", "Rock & Alternative", "Western"),
item("math-rock", "Math Rock", "Rock & Alternative", "Western"),
item("grunge", "Grunge", "Rock & Alternative", "Western"),
item("shoegaze", "Shoegaze", "Rock & Alternative", "Western"),
item("emo", "Emo", "Rock & Alternative", "Western"),
item("punk", "Punk", "Rock & Alternative", "Western"),
item("pop-punk", "Pop Punk", "Rock & Alternative", "Western"),
item("post-punk", "Post-Punk", "Rock & Alternative", "Western"),
item("metal", "Metal", "Rock & Alternative", "Western"),
item("heavy-metal", "Heavy Metal", "Rock & Alternative", "Western"),
item("power-metal", "Power Metal", "Rock & Alternative", "Western"),
item("progressive-metal", "Progressive Metal", "Rock & Alternative", "Western"),
item("thrash-metal", "Thrash Metal", "Rock & Alternative", "Western"),
item("death-metal", "Death Metal", "Rock & Alternative", "Western"),
item("black-metal", "Black Metal", "Rock & Alternative", "Western"),
item("doom-metal", "Doom Metal", "Rock & Alternative", "Western"),
item("folk-rock", "Folk Rock", "Rock & Alternative", "Western"),
item("blues-rock", "Blues Rock", "Rock & Alternative", "Western")
]
private static let electronicAndDance: [GenreStyleCatalogItem] = [
item("electronic", "Electronic", "Electronic & Dance", "Global"),
item("edm", "EDM", "Electronic & Dance", "Global", aliases: ["electronic dance music"]),
item("ambient", "Ambient", "Electronic & Dance", "Western"),
item("downtempo", "Downtempo", "Electronic & Dance", "Western"),
item("chillout", "Chillout", "Electronic & Dance", "Western"),
item("lofi", "Lo-Fi", "Electronic & Dance", "Western", aliases: ["lofi"]),
item("synthwave", "Synthwave", "Electronic & Dance", "Western"),
item("vaporwave", "Vaporwave", "Electronic & Dance", "Western"),
item("electro", "Electro", "Electronic & Dance", "Western"),
item("industrial", "Industrial", "Electronic & Dance", "Western"),
item("house", "House", "Electronic & Dance", "Western"),
item("deep-house", "Deep House", "Electronic & Dance", "Western"),
item("tech-house", "Tech House", "Electronic & Dance", "Western"),
item("progressive-house", "Progressive House", "Electronic & Dance", "Western"),
item("future-house", "Future House", "Electronic & Dance", "Western"),
item("techno", "Techno", "Electronic & Dance", "Western"),
item("minimal-techno", "Minimal Techno", "Electronic & Dance", "Western"),
item("trance", "Trance", "Electronic & Dance", "Western"),
item("progressive-trance", "Progressive Trance", "Electronic & Dance", "Western"),
item("psytrance", "Psytrance", "Electronic & Dance", "Western"),
item("drum-and-bass", "Drum and Bass", "Electronic & Dance", "UK", aliases: ["dnb"]),
item("jungle", "Jungle", "Electronic & Dance", "UK"),
item("dubstep", "Dubstep", "Electronic & Dance", "UK"),
item("uk-garage", "UK Garage", "Electronic & Dance", "UK"),
item("breakbeat", "Breakbeat", "Electronic & Dance", "Western"),
item("hardstyle", "Hardstyle", "Electronic & Dance", "Europe"),
item("moombahton", "Moombahton", "Electronic & Dance", "Latin America")
]
private static let urbanAndSoul: [GenreStyleCatalogItem] = [
item("hip-hop", "Hip Hop", "Hip Hop, R&B & Soul", "Global", aliases: ["hiphop"]),
item("rap", "Rap", "Hip Hop, R&B & Soul", "Global"),
item("boom-bap", "Boom Bap", "Hip Hop, R&B & Soul", "US"),
item("trap", "Trap", "Hip Hop, R&B & Soul", "US"),
item("drill", "Drill", "Hip Hop, R&B & Soul", "US/UK"),
item("conscious-hip-hop", "Conscious Hip Hop", "Hip Hop, R&B & Soul", "US"),
item("cloud-rap", "Cloud Rap", "Hip Hop, R&B & Soul", "US"),
item("lofi-hip-hop", "Lo-Fi Hip Hop", "Hip Hop, R&B & Soul", "Global"),
item("r-and-b", "R&B", "Hip Hop, R&B & Soul", "US", aliases: ["rhythm and blues"]),
item("contemporary-r-and-b", "Contemporary R&B", "Hip Hop, R&B & Soul", "US"),
item("soul", "Soul", "Hip Hop, R&B & Soul", "US"),
item("neo-soul", "Neo Soul", "Hip Hop, R&B & Soul", "US"),
item("funk", "Funk", "Hip Hop, R&B & Soul", "US"),
item("disco", "Disco", "Hip Hop, R&B & Soul", "US"),
item("nu-disco", "Nu Disco", "Hip Hop, R&B & Soul", "Western")
]
private static let jazzAndBlues: [GenreStyleCatalogItem] = [
item("jazz", "Jazz", "Jazz & Blues", "US"),
item("swing", "Swing", "Jazz & Blues", "US"),
item("bebop", "Bebop", "Jazz & Blues", "US"),
item("cool-jazz", "Cool Jazz", "Jazz & Blues", "US"),
item("hard-bop", "Hard Bop", "Jazz & Blues", "US"),
item("modal-jazz", "Modal Jazz", "Jazz & Blues", "US"),
item("jazz-fusion", "Jazz Fusion", "Jazz & Blues", "US"),
item("smooth-jazz", "Smooth Jazz", "Jazz & Blues", "US"),
item("vocal-jazz", "Vocal Jazz", "Jazz & Blues", "US"),
item("big-band", "Big Band", "Jazz & Blues", "US"),
item("blues", "Blues", "Jazz & Blues", "US"),
item("delta-blues", "Delta Blues", "Jazz & Blues", "US"),
item("chicago-blues", "Chicago Blues", "Jazz & Blues", "US"),
item("gospel", "Gospel", "Jazz & Blues", "US")
]
private static let countryFolkAndRoots: [GenreStyleCatalogItem] = [
item("country", "Country", "Country, Folk & Roots", "US"),
item("modern-country", "Modern Country", "Country, Folk & Roots", "US"),
item("americana", "Americana", "Country, Folk & Roots", "US"),
item("bluegrass", "Bluegrass", "Country, Folk & Roots", "US"),
item("folk", "Folk", "Country, Folk & Roots", "Global"),
item("indie-folk", "Indie Folk", "Country, Folk & Roots", "Western"),
item("celtic", "Celtic", "Country, Folk & Roots", "Ireland/Scotland"),
item("roots", "Roots", "Country, Folk & Roots", "Global"),
item("traditional", "Traditional", "Country, Folk & Roots", "Global"),
item("world", "World", "Country, Folk & Roots", "Global", aliases: ["world music"]),
item("skiffle", "Skiffle", "Country, Folk & Roots", "UK")
]
private static let latinAndCaribbean: [GenreStyleCatalogItem] = [
item("latin-pop", "Latin Pop", "Latin & Caribbean", "Latin America"),
item("reggaeton", "Reggaeton", "Latin & Caribbean", "Caribbean"),
item("salsa", "Salsa", "Latin & Caribbean", "Caribbean"),
item("bachata", "Bachata", "Latin & Caribbean", "Caribbean"),
item("merengue", "Merengue", "Latin & Caribbean", "Caribbean"),
item("cumbia", "Cumbia", "Latin & Caribbean", "Latin America"),
item("bossa-nova", "Bossa Nova", "Latin & Caribbean", "Brazil"),
item("samba", "Samba", "Latin & Caribbean", "Brazil"),
item("tango", "Tango", "Latin & Caribbean", "Argentina/Uruguay"),
item("bolero", "Bolero", "Latin & Caribbean", "Latin America"),
item("mariachi", "Mariachi", "Latin & Caribbean", "Mexico"),
item("norteno", "Norteno", "Latin & Caribbean", "Mexico"),
item("banda", "Banda", "Latin & Caribbean", "Mexico"),
item("corrido", "Corrido", "Latin & Caribbean", "Mexico"),
item("baile-funk", "Baile Funk", "Latin & Caribbean", "Brazil"),
item("dembow", "Dembow", "Latin & Caribbean", "Caribbean")
]
private static let middleEasternAndNorthAfrican: [GenreStyleCatalogItem] = [
item("arabic-pop", "Arabic Pop", "Middle East & North Africa", "Arab World", aliases: ["arab pop"], terms: ["بوب عربي"]),
item("arabic-traditional", "Arabic Traditional", "Middle East & North Africa", "Arab World", terms: ["موسيقى عربية", "طرب"]),
item("khaleeji", "Khaleeji", "Middle East & North Africa", "Gulf", aliases: ["gulf pop"], terms: ["خليجي"]),
item("levantine-pop", "Levantine Pop", "Middle East & North Africa", "Levant", terms: ["شامي"]),
item("egyptian-pop", "Egyptian Pop", "Middle East & North Africa", "Egypt", terms: ["مصري"]),
item("iraqi-pop", "Iraqi Pop", "Middle East & North Africa", "Iraq", terms: ["عراقي"]),
item("maghrebi-pop", "Maghrebi Pop", "Middle East & North Africa", "North Africa", terms: ["مغاربي"]),
item("rai", "Rai", "Middle East & North Africa", "Algeria/Morocco"),
item("chaabi", "Chaabi", "Middle East & North Africa", "North Africa"),
item("gnawa", "Gnawa", "Middle East & North Africa", "Morocco"),
item("andalusian", "Andalusian", "Middle East & North Africa", "North Africa"),
item("turkish-pop", "Turkish Pop", "Middle East & North Africa", "Turkey"),
item("turkish-folk", "Turkish Folk", "Middle East & North Africa", "Turkey"),
item("persian-pop", "Persian Pop", "Middle East & North Africa", "Iran"),
item("persian-traditional", "Persian Traditional", "Middle East & North Africa", "Iran"),
item("kurdish", "Kurdish", "Middle East & North Africa", "Kurdistan"),
item("armenian", "Armenian", "Middle East & North Africa", "Armenia")
]
private static let southAsian: [GenreStyleCatalogItem] = [
item("bollywood", "Bollywood", "South Asian", "India", aliases: ["filmi"]),
item("hindustani-classical", "Hindustani Classical", "South Asian", "India"),
item("carnatic", "Carnatic", "South Asian", "South India"),
item("bhangra", "Bhangra", "South Asian", "Punjab"),
item("punjabi-pop", "Punjabi Pop", "South Asian", "Punjab"),
item("ghazal", "Ghazal", "South Asian", "South Asia"),
item("qawwali", "Qawwali", "South Asian", "South Asia"),
item("bhajan", "Bhajan", "South Asian", "India"),
item("sufi", "Sufi", "South Asian", "South Asia"),
item("pakistani-pop", "Pakistani Pop", "South Asian", "Pakistan"),
item("bangladeshi-folk", "Bangladeshi Folk", "South Asian", "Bangladesh")
]
private static let eastAsian: [GenreStyleCatalogItem] = [
item("k-pop", "K-Pop", "East Asian", "Korea", aliases: ["kpop"]),
item("korean-traditional", "Korean Traditional", "East Asian", "Korea"),
item("j-pop", "J-Pop", "East Asian", "Japan", aliases: ["jpop"]),
item("japanese-city-pop", "Japanese City Pop", "East Asian", "Japan", aliases: ["city pop"]),
item("enka", "Enka", "East Asian", "Japan"),
item("anime-music", "Anime Music", "East Asian", "Japan"),
item("c-pop", "C-Pop", "East Asian", "China", aliases: ["cpop"]),
item("mandopop", "Mandopop", "East Asian", "China/Taiwan"),
item("cantopop", "Cantopop", "East Asian", "Hong Kong"),
item("chinese-traditional", "Chinese Traditional", "East Asian", "China"),
item("taiwanese-pop", "Taiwanese Pop", "East Asian", "Taiwan")
]
private static let southeastAsianAndOceania: [GenreStyleCatalogItem] = [
item("indonesian-pop", "Indonesian Pop", "Southeast Asia & Oceania", "Indonesia"),
item("dangdut", "Dangdut", "Southeast Asia & Oceania", "Indonesia"),
item("gamelan", "Gamelan", "Southeast Asia & Oceania", "Indonesia"),
item("malay-pop", "Malay Pop", "Southeast Asia & Oceania", "Malaysia"),
item("thai-pop", "Thai Pop", "Southeast Asia & Oceania", "Thailand"),
item("luk-thung", "Luk Thung", "Southeast Asia & Oceania", "Thailand"),
item("vietnamese-pop", "Vietnamese Pop", "Southeast Asia & Oceania", "Vietnam"),
item("filipino-pop", "Filipino Pop", "Southeast Asia & Oceania", "Philippines"),
item("pinoy-rock", "Pinoy Rock", "Southeast Asia & Oceania", "Philippines"),
item("maori", "Maori", "Southeast Asia & Oceania", "New Zealand"),
item("pacific", "Pacific", "Southeast Asia & Oceania", "Oceania"),
item("australian-indigenous", "Australian Indigenous", "Southeast Asia & Oceania", "Australia")
]
private static let african: [GenreStyleCatalogItem] = [
item("afrobeats", "Afrobeats", "African", "West Africa"),
item("afropop", "Afropop", "African", "Africa"),
item("afro-house", "Afro House", "African", "Africa"),
item("amapiano", "Amapiano", "African", "South Africa"),
item("highlife", "Highlife", "African", "West Africa"),
item("juju", "Juju", "African", "Nigeria"),
item("fuji", "Fuji", "African", "Nigeria"),
item("benga", "Benga", "African", "East Africa"),
item("soukous", "Soukous", "African", "Central Africa"),
item("kwaito", "Kwaito", "African", "South Africa"),
item("mbalax", "Mbalax", "African", "Senegal"),
item("taarab", "Taarab", "African", "East Africa"),
item("ethiopian", "Ethiopian", "African", "Ethiopia"),
item("sudanese", "Sudanese", "African", "Sudan"),
item("desert-blues", "Desert Blues", "African", "Sahara")
]
private static let reggaeAndCaribbean: [GenreStyleCatalogItem] = [
item("reggae", "Reggae", "Reggae & Caribbean", "Jamaica"),
item("roots-reggae", "Roots Reggae", "Reggae & Caribbean", "Jamaica"),
item("dub", "Dub", "Reggae & Caribbean", "Jamaica"),
item("dancehall", "Dancehall", "Reggae & Caribbean", "Jamaica"),
item("ska", "Ska", "Reggae & Caribbean", "Jamaica"),
item("rocksteady", "Rocksteady", "Reggae & Caribbean", "Jamaica"),
item("lovers-rock", "Lovers Rock", "Reggae & Caribbean", "Jamaica"),
item("calypso", "Calypso", "Reggae & Caribbean", "Caribbean"),
item("soca", "Soca", "Reggae & Caribbean", "Caribbean")
]
private static let classicalAndCinematic: [GenreStyleCatalogItem] = [
item("classical", "Classical", "Classical & Cinematic", "Western"),
item("baroque", "Baroque", "Classical & Cinematic", "Europe"),
item("classical-period", "Classical Period", "Classical & Cinematic", "Europe"),
item("romantic", "Romantic", "Classical & Cinematic", "Europe"),
item("contemporary-classical", "Contemporary Classical", "Classical & Cinematic", "Global"),
item("orchestral", "Orchestral", "Classical & Cinematic", "Western"),
item("chamber-music", "Chamber Music", "Classical & Cinematic", "Western"),
item("opera", "Opera", "Classical & Cinematic", "Europe"),
item("choral", "Choral", "Classical & Cinematic", "Global"),
item("cinematic", "Cinematic", "Classical & Cinematic", "Global"),
item("soundtrack", "Soundtrack", "Classical & Cinematic", "Global"),
item("musical-theater", "Musical Theater", "Classical & Cinematic", "Western"),
item("new-age", "New Age", "Classical & Cinematic", "Western"),
item("minimalism", "Minimalism", "Classical & Cinematic", "Western")
]
private static func item(
_ id: String,
_ name: String,
_ familyCategory: String,
_ regionOrigin: String? = nil,
aliases: [String] = [],
terms: [String] = []
) -> GenreStyleCatalogItem {
GenreStyleCatalogItem(
id: id,
name: name,
familyCategory: familyCategory,
regionOrigin: regionOrigin,
aliases: aliases,
searchTerms: terms
)
}
private static func searchableTerms(for item: GenreStyleCatalogItem) -> [String] {
searchTokens(for: [item.name, item.familyCategory, item.regionOrigin ?? ""] + item.aliases + item.searchTerms)
}
private static func searchTokens(for query: String) -> [String] {
searchTokens(for: [query])
}
private static func searchTokens(for values: [String]) -> [String] {
values
.joined(separator: " ")
.folding(
options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive],
locale: Locale(identifier: "en_US_POSIX")
)
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "/" })
.map(String.init)
}
}
@@ -0,0 +1,336 @@
import Foundation
public struct LocalInstrumentCatalog: Sendable {
public let items: [InstrumentCatalogItem]
public init(items: [InstrumentCatalogItem] = LocalInstrumentCatalog.worldwideItems) {
self.items = items
}
public var familyCategories: [String] {
Array(Set(items.map(\.familyCategory)))
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
public var regionsOfOrigin: [String] {
Array(Set(items.compactMap(\.regionOrigin)))
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
public func instruments(
matching query: String,
familyCategory: String? = nil,
regionOrigin: String? = nil
) -> [InstrumentCatalogItem] {
let searchTokens = Self.searchTokens(for: query)
let selectedFamilyCategory = familyCategory?.trimmingCharacters(in: .whitespacesAndNewlines)
let selectedRegionOrigin = regionOrigin?.trimmingCharacters(in: .whitespacesAndNewlines)
return items
.filter { item in
let matchesSearch = searchTokens.isEmpty || searchTokens.allSatisfy { token in
Self.searchableTerms(for: item).contains { $0.contains(token) }
}
let matchesFamily = selectedFamilyCategory?.isEmpty != false || item.familyCategory == selectedFamilyCategory
let matchesRegion = selectedRegionOrigin?.isEmpty != false || item.regionOrigin == selectedRegionOrigin
return matchesSearch && matchesFamily && matchesRegion
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
public static let worldwideItems: [InstrumentCatalogItem] =
western + middleEastern + african + southAsian + eastAsian + southeastAsian + latinAmerican + folkAndModern
private static let western: [InstrumentCatalogItem] = [
item("piano", "Piano", "Keyboard", "Western"),
item("upright-piano", "Upright Piano", "Keyboard", "Western"),
item("grand-piano", "Grand Piano", "Keyboard", "Western"),
item("electric-piano", "Electric Piano", "Keyboard", "Western"),
item("harpsichord", "Harpsichord", "Keyboard", "Western"),
item("celesta", "Celesta", "Keyboard", "Western"),
item("pipe-organ", "Pipe Organ", "Keyboard", "Western"),
item("electric-organ", "Electric Organ", "Keyboard", "Western"),
item("accordion", "Accordion", "Free reed", "Europe"),
item("concertina", "Concertina", "Free reed", "Europe"),
item("melodica", "Melodica", "Free reed", "Western"),
item("violin", "Violin", "Bowed strings", "Western", aliases: ["fiddle"]),
item("viola", "Viola", "Bowed strings", "Western"),
item("cello", "Cello", "Bowed strings", "Western", aliases: ["violoncello"]),
item("double-bass", "Double Bass", "Bowed strings", "Western", aliases: ["upright bass"]),
item("concert-harp", "Concert Harp", "Plucked strings", "Western"),
item("classical-guitar", "Classical Guitar", "Plucked strings", "Western", aliases: ["nylon-string guitar"]),
item("acoustic-guitar", "Acoustic Guitar", "Plucked strings", "Western"),
item("electric-guitar", "Electric Guitar", "Plucked strings", "Western"),
item("bass-guitar", "Bass Guitar", "Plucked strings", "Western"),
item("twelve-string-guitar", "Twelve-string Guitar", "Plucked strings", "Western"),
item("mandolin", "Mandolin", "Plucked strings", "Europe"),
item("banjo", "Banjo", "Plucked strings", "North America"),
item("ukulele", "Ukulele", "Plucked strings", "Oceania"),
item("resonator-guitar", "Resonator Guitar", "Plucked strings", "North America", aliases: ["dobro"]),
item("lap-steel-guitar", "Lap Steel Guitar", "Plucked strings", "North America"),
item("pedal-steel-guitar", "Pedal Steel Guitar", "Plucked strings", "North America"),
item("flute", "Concert Flute", "Woodwinds", "Western", aliases: ["flute"]),
item("piccolo", "Piccolo", "Woodwinds", "Western"),
item("recorder", "Recorder", "Woodwinds", "Europe"),
item("oboe", "Oboe", "Woodwinds", "Western"),
item("english-horn", "English Horn", "Woodwinds", "Western", aliases: ["cor anglais"]),
item("bassoon", "Bassoon", "Woodwinds", "Western"),
item("contrabassoon", "Contrabassoon", "Woodwinds", "Western"),
item("clarinet", "Clarinet", "Woodwinds", "Western"),
item("bass-clarinet", "Bass Clarinet", "Woodwinds", "Western"),
item("soprano-saxophone", "Soprano Saxophone", "Woodwinds", "Western", aliases: ["soprano sax"]),
item("alto-saxophone", "Alto Saxophone", "Woodwinds", "Western", aliases: ["alto sax"]),
item("tenor-saxophone", "Tenor Saxophone", "Woodwinds", "Western", aliases: ["tenor sax"]),
item("baritone-saxophone", "Baritone Saxophone", "Woodwinds", "Western", aliases: ["baritone sax"]),
item("trumpet", "Trumpet", "Brass", "Western"),
item("cornet", "Cornet", "Brass", "Western"),
item("flugelhorn", "Flugelhorn", "Brass", "Western"),
item("trombone", "Trombone", "Brass", "Western"),
item("bass-trombone", "Bass Trombone", "Brass", "Western"),
item("french-horn", "French Horn", "Brass", "Western", aliases: ["horn"]),
item("tuba", "Tuba", "Brass", "Western"),
item("euphonium", "Euphonium", "Brass", "Western"),
item("sousaphone", "Sousaphone", "Brass", "North America"),
item("drum-kit", "Drum Kit", "Percussion", "Western", aliases: ["drum set"]),
item("snare-drum", "Snare Drum", "Percussion", "Western"),
item("bass-drum", "Bass Drum", "Percussion", "Western", aliases: ["kick drum"]),
item("timpani", "Timpani", "Percussion", "Western", aliases: ["kettle drums"]),
item("xylophone", "Xylophone", "Mallet percussion", "Western"),
item("marimba", "Marimba", "Mallet percussion", "Central America"),
item("vibraphone", "Vibraphone", "Mallet percussion", "Western", aliases: ["vibes"]),
item("glockenspiel", "Glockenspiel", "Mallet percussion", "Europe"),
item("tubular-bells", "Tubular Bells", "Mallet percussion", "Western", aliases: ["orchestral chimes"]),
item("triangle", "Triangle", "Percussion", "Western"),
item("tambourine", "Tambourine", "Frame drums", "Western"),
item("cowbell", "Cowbell", "Percussion", "Western"),
item("cymbals", "Cymbals", "Percussion", "Western"),
item("gong", "Gong", "Percussion", "East Asia"),
item("castanets", "Castanets", "Percussion", "Spain"),
item("claves", "Claves", "Percussion", "Latin America"),
item("harmonica", "Harmonica", "Free reed", "Western", aliases: ["mouth organ"])
]
private static let middleEastern: [InstrumentCatalogItem] = [
item("oud", "Oud", "Plucked strings", "Middle East", aliases: ["ud"], terms: ["عود"]),
item("qanun", "Qanun", "Zithers", "Middle East", aliases: ["kanun"], terms: ["قانون"]),
item("ney", "Ney", "End-blown flutes", "Middle East", aliases: ["nai"], terms: ["ناي"]),
item("daf", "Daf", "Frame drums", "Middle East", terms: ["دف"]),
item("riq", "Riq", "Frame drums", "Middle East", aliases: ["riqq"], terms: ["رق"]),
item("darbuka", "Darbuka", "Goblet drums", "Middle East", aliases: ["doumbek", "tabla"], terms: ["دربكة"]),
item("kamancheh", "Kamancheh", "Bowed strings", "Middle East", aliases: ["kamanche"]),
item("santur", "Santur", "Hammered zithers", "Middle East"),
item("baglama", "Baglama", "Plucked strings", "Middle East", aliases: ["saz"]),
item("buzuq", "Buzuq", "Plucked strings", "Middle East", aliases: ["bozuk"]),
item("mijwiz", "Mijwiz", "Double reeds", "Middle East"),
item("arghul", "Arghul", "Double reeds", "Middle East"),
item("zurna", "Zurna", "Double reeds", "Middle East"),
item("naqqara", "Naqqara", "Kettle drums", "Middle East"),
item("tombak", "Tombak", "Goblet drums", "Middle East", aliases: ["zarb"]),
item("setar", "Setar", "Plucked strings", "Middle East"),
item("tar", "Tar", "Plucked strings", "Middle East"),
item("duduk", "Duduk", "Double reeds", "Middle East"),
item("kemence", "Kemenche", "Bowed strings", "Middle East"),
item("saz-bagpipes", "Tulum", "Bagpipes", "Middle East")
]
private static let african: [InstrumentCatalogItem] = [
item("djembe", "Djembe", "Hand drums", "West Africa"),
item("talking-drum", "Talking Drum", "Tension drums", "West Africa", aliases: ["dundun"]),
item("kora", "Kora", "Harps", "West Africa"),
item("balafon", "Balafon", "Mallet percussion", "West Africa"),
item("mbira", "Mbira", "Lamellophones", "Southern Africa", aliases: ["thumb piano"]),
item("kalimba", "Kalimba", "Lamellophones", "Africa"),
item("shekere", "Shekere", "Shakers", "West Africa"),
item("udu", "Udu", "Clay drums", "West Africa"),
item("ngoni", "Ngoni", "Plucked strings", "West Africa"),
item("xalam", "Xalam", "Plucked strings", "West Africa"),
item("dunun", "Dunun", "Bass drums", "West Africa"),
item("ashiko", "Ashiko", "Hand drums", "West Africa"),
item("marovany", "Marovany", "Box zithers", "Madagascar"),
item("endongo", "Endongo", "Harps", "East Africa"),
item("masenqo", "Masenqo", "Bowed strings", "East Africa"),
item("krin", "Krin", "Slit drums", "West Africa"),
item("agogo", "Agogo Bells", "Percussion", "West Africa"),
item("goje", "Goje", "Bowed strings", "West Africa"),
item("adungu", "Adungu", "Harps", "East Africa"),
item("ekwe", "Ekwe", "Slit drums", "West Africa")
]
private static let southAsian: [InstrumentCatalogItem] = [
item("sitar", "Sitar", "Plucked strings", "South Asia"),
item("tabla", "Tabla", "Hand drums", "South Asia"),
item("tanpura", "Tanpura", "Drone strings", "South Asia", aliases: ["tambura"]),
item("sarod", "Sarod", "Plucked strings", "South Asia"),
item("bansuri", "Bansuri", "Side-blown flutes", "South Asia"),
item("shehnai", "Shehnai", "Double reeds", "South Asia"),
item("santoor", "Santoor", "Hammered zithers", "South Asia"),
item("harmonium", "Harmonium", "Free reed", "South Asia"),
item("mridangam", "Mridangam", "Hand drums", "South Asia"),
item("veena", "Veena", "Plucked strings", "South Asia"),
item("sarangi", "Sarangi", "Bowed strings", "South Asia"),
item("dholak", "Dholak", "Hand drums", "South Asia"),
item("dhol", "Dhol", "Bass drums", "South Asia"),
item("ghatam", "Ghatam", "Clay drums", "South Asia"),
item("kanjira", "Kanjira", "Frame drums", "South Asia"),
item("nadaswaram", "Nadaswaram", "Double reeds", "South Asia"),
item("esraj", "Esraj", "Bowed strings", "South Asia"),
item("pakhawaj", "Pakhawaj", "Hand drums", "South Asia"),
item("khol", "Khol", "Hand drums", "South Asia"),
item("ektara", "Ektara", "Drone strings", "South Asia"),
item("rabab", "Rabab", "Plucked strings", "South Asia"),
item("morsing", "Morsing", "Jaw harps", "South Asia"),
item("nagada", "Nagada", "Kettle drums", "South Asia")
]
private static let eastAsian: [InstrumentCatalogItem] = [
item("erhu", "Erhu", "Bowed strings", "East Asia"),
item("guzheng", "Guzheng", "Zithers", "East Asia", aliases: ["Chinese zither"]),
item("pipa", "Pipa", "Plucked strings", "East Asia"),
item("dizi", "Dizi", "Side-blown flutes", "East Asia"),
item("xiao", "Xiao", "End-blown flutes", "East Asia"),
item("suona", "Suona", "Double reeds", "East Asia"),
item("sheng", "Sheng", "Mouth organs", "East Asia"),
item("yangqin", "Yangqin", "Hammered zithers", "East Asia"),
item("ruan", "Ruan", "Plucked strings", "East Asia"),
item("guqin", "Guqin", "Zithers", "East Asia"),
item("liuqin", "Liuqin", "Plucked strings", "East Asia"),
item("hulusi", "Hulusi", "Free reed", "East Asia"),
item("morin-khuur", "Morin Khuur", "Bowed strings", "Central Asia", aliases: ["horsehead fiddle"]),
item("koto", "Koto", "Zithers", "Japan"),
item("shamisen", "Shamisen", "Plucked strings", "Japan"),
item("shakuhachi", "Shakuhachi", "End-blown flutes", "Japan"),
item("biwa", "Biwa", "Plucked strings", "Japan"),
item("taiko", "Taiko", "Drums", "Japan"),
item("hichiriki", "Hichiriki", "Double reeds", "Japan"),
item("ryuteki", "Ryuteki", "Side-blown flutes", "Japan"),
item("gayageum", "Gayageum", "Zithers", "Korea"),
item("geomungo", "Geomungo", "Zithers", "Korea"),
item("haegeum", "Haegeum", "Bowed strings", "Korea"),
item("daegeum", "Daegeum", "Side-blown flutes", "Korea"),
item("janggu", "Janggu", "Hourglass drums", "Korea"),
item("piri", "Piri", "Double reeds", "Korea"),
item("ajaeng", "Ajaeng", "Bowed strings", "Korea"),
item("bawu", "Bawu", "Free reed", "East Asia"),
item("konghou", "Konghou", "Harps", "East Asia")
]
private static let southeastAsian: [InstrumentCatalogItem] = [
item("angklung", "Angklung", "Shaken idiophones", "Southeast Asia"),
item("gamelan", "Gamelan", "Metallophones", "Southeast Asia"),
item("kendang", "Kendang", "Hand drums", "Southeast Asia"),
item("suling", "Suling", "Side-blown flutes", "Southeast Asia"),
item("rebab", "Rebab", "Bowed strings", "Southeast Asia"),
item("kulintang", "Kulintang", "Gong chimes", "Southeast Asia"),
item("agung", "Agung", "Gongs", "Southeast Asia"),
item("khene", "Khene", "Mouth organs", "Southeast Asia"),
item("ranat-ek", "Ranat Ek", "Mallet percussion", "Southeast Asia"),
item("saw-duang", "Saw Duang", "Bowed strings", "Southeast Asia"),
item("tro", "Tro", "Bowed strings", "Southeast Asia"),
item("saung-gauk", "Saung Gauk", "Harps", "Southeast Asia"),
item("pat-waing", "Pat Waing", "Drums", "Southeast Asia"),
item("rondalla", "Rondalla", "Plucked strings", "Southeast Asia"),
item("bandurria", "Bandurria", "Plucked strings", "Southeast Asia"),
item("tinikling-bamboo", "Bamboo Percussion", "Percussion", "Southeast Asia"),
item("sape", "Sape", "Plucked strings", "Southeast Asia"),
item("serunai", "Serunai", "Double reeds", "Southeast Asia")
]
private static let latinAmerican: [InstrumentCatalogItem] = [
item("conga", "Conga", "Hand drums", "Latin America"),
item("bongo", "Bongo", "Hand drums", "Latin America"),
item("timbales", "Timbales", "Drums", "Latin America"),
item("cajon", "Cajon", "Box drums", "Latin America"),
item("guiro", "Guiro", "Scrapers", "Latin America"),
item("maracas", "Maracas", "Shakers", "Latin America"),
item("cuica", "Cuica", "Friction drums", "Latin America"),
item("berimbau", "Berimbau", "Musical bows", "Brazil"),
item("bandoneon", "Bandoneon", "Free reed", "South America"),
item("charango", "Charango", "Plucked strings", "Andes"),
item("cuatro", "Cuatro", "Plucked strings", "Latin America"),
item("tres", "Tres", "Plucked strings", "Cuba"),
item("vihuela", "Vihuela", "Plucked strings", "Mexico"),
item("requinto", "Requinto", "Plucked strings", "Latin America"),
item("zampogna", "Zampona", "Panpipes", "Andes", aliases: ["siku"]),
item("quena", "Quena", "End-blown flutes", "Andes"),
item("bombo-leguero", "Bombo Leguero", "Bass drums", "South America"),
item("surdo", "Surdo", "Bass drums", "Brazil"),
item("pandeiro", "Pandeiro", "Frame drums", "Brazil"),
item("candombe-drums", "Candombe Drums", "Hand drums", "South America"),
item("tambora", "Tambora", "Bass drums", "Caribbean"),
item("guira", "Guira", "Scrapers", "Caribbean"),
item("bata-drums", "Bata Drums", "Hand drums", "Caribbean"),
item("arpa-llanera", "Arpa Llanera", "Harps", "Latin America"),
item("marimbola", "Marimbola", "Lamellophones", "Caribbean"),
item("tiple", "Tiple", "Plucked strings", "South America")
]
private static let folkAndModern: [InstrumentCatalogItem] = [
item("bagpipes", "Bagpipes", "Bagpipes", "Europe"),
item("tin-whistle", "Tin Whistle", "Folk winds", "Ireland", aliases: ["penny whistle"]),
item("bodhran", "Bodhran", "Frame drums", "Ireland"),
item("dulcimer", "Appalachian Dulcimer", "Folk strings", "North America"),
item("hammered-dulcimer", "Hammered Dulcimer", "Hammered zithers", "Europe"),
item("hurdy-gurdy", "Hurdy-gurdy", "Folk strings", "Europe"),
item("nyckelharpa", "Nyckelharpa", "Bowed strings", "Europe"),
item("balalaika", "Balalaika", "Plucked strings", "Europe"),
item("domra", "Domra", "Plucked strings", "Europe"),
item("bouzouki", "Bouzouki", "Plucked strings", "Greece"),
item("cimbalom", "Cimbalom", "Hammered zithers", "Europe"),
item("jaw-harp", "Jaw Harp", "Jaw harps", "Folk", aliases: ["mouth harp"]),
item("didgeridoo", "Didgeridoo", "Aerophones", "Oceania"),
item("clapsticks", "Clapsticks", "Percussion", "Oceania"),
item("native-american-flute", "Native American Flute", "End-blown flutes", "North America"),
item("fiddle", "Fiddle", "Folk strings", "Folk"),
item("washboards", "Washboard", "Percussion", "North America"),
item("musical-saw", "Musical Saw", "Folk instruments", "Folk"),
item("theremin", "Theremin", "Electronic", "Modern"),
item("modular-synthesizer", "Modular Synthesizer", "Electronic", "Modern", aliases: ["modular synth"]),
item("synthesizer", "Synthesizer", "Electronic", "Modern", aliases: ["synth"]),
item("drum-machine", "Drum Machine", "Electronic", "Modern"),
item("sampler", "Sampler", "Electronic", "Modern"),
item("turntables", "Turntables", "Electronic", "Modern", aliases: ["DJ decks"]),
item("midi-controller", "MIDI Controller", "Electronic", "Modern"),
item("electric-violin", "Electric Violin", "Electronic strings", "Modern"),
item("electric-cello", "Electric Cello", "Electronic strings", "Modern"),
item("electronic-drum-pad", "Electronic Drum Pad", "Electronic percussion", "Modern"),
item("vocoder", "Vocoder", "Electronic", "Modern"),
item("keytar", "Keytar", "Electronic", "Modern")
]
private static func item(
_ id: String,
_ name: String,
_ familyCategory: String,
_ regionOrigin: String? = nil,
aliases: [String] = [],
terms: [String] = []
) -> InstrumentCatalogItem {
InstrumentCatalogItem(
id: id,
name: name,
familyCategory: familyCategory,
regionOrigin: regionOrigin,
aliases: aliases,
searchTerms: terms
)
}
private static func searchableTerms(for item: InstrumentCatalogItem) -> [String] {
searchTokens(for: [item.name] + item.aliases + item.searchTerms)
}
private static func searchTokens(for query: String) -> [String] {
searchTokens(for: [query])
}
private static func searchTokens(for values: [String]) -> [String] {
values
.joined(separator: " ")
.folding(
options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive],
locale: Locale(identifier: "en_US_POSIX")
)
.split(whereSeparator: { $0.isWhitespace || $0 == "-" })
.map(String.init)
}
}
@@ -0,0 +1,172 @@
import Foundation
public struct LocalLanguageCatalog: Sendable {
public let items: [LanguageCatalogItem]
public init(items: [LanguageCatalogItem] = LocalLanguageCatalog.worldwideItems) {
self.items = items
}
public var familyCategories: [String] {
Array(Set(items.map(\.familyCategory)))
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
public func languages(
matching query: String,
familyCategory: String? = nil
) -> [LanguageCatalogItem] {
let searchTokens = Self.searchTokens(for: query)
let selectedFamilyCategory = familyCategory?.trimmingCharacters(in: .whitespacesAndNewlines)
return items
.filter { item in
let matchesSearch = searchTokens.isEmpty || searchTokens.allSatisfy { token in
Self.searchableTerms(for: item).contains { $0.contains(token) }
}
let matchesFamily = selectedFamilyCategory?.isEmpty != false || item.familyCategory == selectedFamilyCategory
return matchesSearch && matchesFamily
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
public static let worldwideItems: [LanguageCatalogItem] =
middleEastAndNorthAfrica + europe + southAsia + eastAsia + southeastAsia + africa + americas + oceaniaAndPacific
private static let middleEastAndNorthAfrica: [LanguageCatalogItem] = [
item("arabic", "Arabic", "Middle East & North Africa", aliases: ["العربية"], terms: ["عربي"]),
item("hebrew", "Hebrew", "Middle East & North Africa", aliases: ["עברית"]),
item("persian", "Persian", "Middle East & North Africa", aliases: ["Farsi", "فارسی"]),
item("turkish", "Turkish", "Middle East & North Africa", aliases: ["Türkçe"]),
item("kurdish", "Kurdish", "Middle East & North Africa", aliases: ["Kurdî"]),
item("armenian", "Armenian", "Middle East & North Africa"),
item("azerbaijani", "Azerbaijani", "Middle East & North Africa", aliases: ["Azeri"]),
item("tamazight", "Tamazight", "Middle East & North Africa", aliases: ["Berber"])
]
private static let europe: [LanguageCatalogItem] = [
item("english", "English", "Europe", aliases: ["English language"]),
item("german", "German", "Europe", aliases: ["Deutsch"]),
item("french", "French", "Europe", aliases: ["Français"]),
item("spanish", "Spanish", "Europe", aliases: ["Español"]),
item("portuguese", "Portuguese", "Europe", aliases: ["Português"]),
item("italian", "Italian", "Europe", aliases: ["Italiano"]),
item("russian", "Russian", "Europe", aliases: ["Русский"]),
item("ukrainian", "Ukrainian", "Europe", aliases: ["Українська"]),
item("polish", "Polish", "Europe", aliases: ["Polski"]),
item("dutch", "Dutch", "Europe", aliases: ["Nederlands"]),
item("swedish", "Swedish", "Europe", aliases: ["Svenska"]),
item("norwegian", "Norwegian", "Europe", aliases: ["Norsk"]),
item("danish", "Danish", "Europe", aliases: ["Dansk"]),
item("finnish", "Finnish", "Europe", aliases: ["Suomi"]),
item("greek", "Greek", "Europe", aliases: ["Ελληνικά"]),
item("romanian", "Romanian", "Europe", aliases: ["Română"]),
item("czech", "Czech", "Europe", aliases: ["Čeština"]),
item("hungarian", "Hungarian", "Europe", aliases: ["Magyar"]),
item("serbian", "Serbian", "Europe"),
item("croatian", "Croatian", "Europe"),
item("bulgarian", "Bulgarian", "Europe")
]
private static let southAsia: [LanguageCatalogItem] = [
item("hindi", "Hindi", "South Asia", aliases: ["हिन्दी"]),
item("urdu", "Urdu", "South Asia", aliases: ["اردو"]),
item("punjabi", "Punjabi", "South Asia", aliases: ["ਪੰਜਾਬੀ"]),
item("bengali", "Bengali", "South Asia", aliases: ["বাংলা"]),
item("tamil", "Tamil", "South Asia", aliases: ["தமிழ்"]),
item("telugu", "Telugu", "South Asia", aliases: ["తెలుగు"]),
item("marathi", "Marathi", "South Asia", aliases: ["मराठी"]),
item("gujarati", "Gujarati", "South Asia", aliases: ["ગુજરાતી"]),
item("malayalam", "Malayalam", "South Asia", aliases: ["മലയാളം"]),
item("kannada", "Kannada", "South Asia", aliases: ["ಕನ್ನಡ"]),
item("sinhala", "Sinhala", "South Asia"),
item("nepali", "Nepali", "South Asia", aliases: ["नेपाली"]),
item("pashto", "Pashto", "South Asia", aliases: ["پښتو"])
]
private static let eastAsia: [LanguageCatalogItem] = [
item("mandarin-chinese", "Mandarin Chinese", "East Asia", aliases: ["Mandarin", "中文", "普通话"]),
item("cantonese", "Cantonese", "East Asia", aliases: ["廣東話"]),
item("japanese", "Japanese", "East Asia", aliases: ["日本語"]),
item("korean", "Korean", "East Asia", aliases: ["한국어"]),
item("mongolian", "Mongolian", "East Asia")
]
private static let southeastAsia: [LanguageCatalogItem] = [
item("indonesian", "Indonesian", "Southeast Asia", aliases: ["Bahasa Indonesia"]),
item("malay", "Malay", "Southeast Asia", aliases: ["Bahasa Melayu"]),
item("vietnamese", "Vietnamese", "Southeast Asia", aliases: ["Tiếng Việt"]),
item("thai", "Thai", "Southeast Asia", aliases: ["ไทย"]),
item("tagalog", "Tagalog", "Southeast Asia", aliases: ["Filipino"]),
item("burmese", "Burmese", "Southeast Asia", aliases: ["Myanmar"]),
item("khmer", "Khmer", "Southeast Asia"),
item("lao", "Lao", "Southeast Asia"),
item("javanese", "Javanese", "Southeast Asia")
]
private static let africa: [LanguageCatalogItem] = [
item("swahili", "Swahili", "Africa", aliases: ["Kiswahili"]),
item("amharic", "Amharic", "Africa", aliases: ["አማርኛ"]),
item("hausa", "Hausa", "Africa"),
item("yoruba", "Yoruba", "Africa"),
item("igbo", "Igbo", "Africa"),
item("zulu", "Zulu", "Africa"),
item("xhosa", "Xhosa", "Africa"),
item("somali", "Somali", "Africa"),
item("oromo", "Oromo", "Africa"),
item("afrikaans", "Afrikaans", "Africa"),
item("wolof", "Wolof", "Africa"),
item("shona", "Shona", "Africa")
]
private static let americas: [LanguageCatalogItem] = [
item("haitian-creole", "Haitian Creole", "Americas", aliases: ["Kreyòl"]),
item("quechua", "Quechua", "Americas"),
item("guarani", "Guarani", "Americas"),
item("navajo", "Navajo", "Americas"),
item("inuktitut", "Inuktitut", "Americas")
]
private static let oceaniaAndPacific: [LanguageCatalogItem] = [
item("maori", "Maori", "Oceania & Pacific", aliases: ["Te Reo Māori"]),
item("hawaiian", "Hawaiian", "Oceania & Pacific"),
item("samoan", "Samoan", "Oceania & Pacific"),
item("tongan", "Tongan", "Oceania & Pacific"),
item("fijian", "Fijian", "Oceania & Pacific")
]
private static func item(
_ id: String,
_ name: String,
_ familyCategory: String,
aliases: [String] = [],
terms: [String] = []
) -> LanguageCatalogItem {
LanguageCatalogItem(
id: id,
name: name,
familyCategory: familyCategory,
aliases: aliases,
searchTerms: terms
)
}
private static func searchableTerms(for item: LanguageCatalogItem) -> [String] {
searchTokens(for: [item.name, item.familyCategory] + item.aliases + item.searchTerms)
}
private static func searchTokens(for query: String) -> [String] {
searchTokens(for: [query])
}
private static func searchTokens(for values: [String]) -> [String] {
values
.joined(separator: " ")
.folding(
options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive],
locale: Locale(identifier: "en_US_POSIX")
)
.split(whereSeparator: { $0.isWhitespace || $0 == "-" })
.map(String.init)
}
}
@@ -0,0 +1,65 @@
import Foundation
public struct ProjectAutosavePolicy: Equatable, Sendable {
public static let `default` = ProjectAutosavePolicy()
public var minimumVersionInterval: TimeInterval
public var versionLabel: String
public init(
minimumVersionInterval: TimeInterval = 300,
versionLabel: String = "Autosave checkpoint"
) {
self.minimumVersionInterval = minimumVersionInterval
self.versionLabel = versionLabel
}
}
public struct ProjectAutosaveResult: Equatable, Sendable {
public var project: SongProject
public var didCreateVersion: Bool
public init(project: SongProject, didCreateVersion: Bool) {
self.project = project
self.didCreateVersion = didCreateVersion
}
}
public actor ProjectAutosaveService {
private let store: any ProjectStore
private let policy: ProjectAutosavePolicy
public init(store: any ProjectStore, policy: ProjectAutosavePolicy = .default) {
self.store = store
self.policy = policy
}
public func autosave(_ project: SongProject, at savedAt: Date = Date()) async throws -> ProjectAutosaveResult {
var autosavedProject = project
var didCreateVersion = false
if shouldCreateAutosaveVersion(for: autosavedProject, at: savedAt) {
_ = autosavedProject.createVersion(label: policy.versionLabel, createdAt: savedAt)
didCreateVersion = true
}
autosavedProject.updatedAt = savedAt
try await store.save(autosavedProject)
return ProjectAutosaveResult(project: autosavedProject, didCreateVersion: didCreateVersion)
}
private func shouldCreateAutosaveVersion(for project: SongProject, at savedAt: Date) -> Bool {
let currentSnapshot = SongProjectSnapshot(project: project)
guard let latestVersion = project.versions.last else {
return true
}
guard latestVersion.snapshot != currentSnapshot else {
return false
}
return savedAt.timeIntervalSince(latestVersion.createdAt) >= policy.minimumVersionInterval
}
}
@@ -13,3 +13,159 @@ public struct CompiledSunoOutput: Equatable, Sendable {
self.stylePrompt = stylePrompt self.stylePrompt = stylePrompt
} }
} }
public struct SongProjectPromptCompiler: PromptCompiling {
public init() {}
public func compile(project: SongProject) throws -> CompiledSunoOutput {
CompiledSunoOutput(
lyricsText: lyricsText(for: project),
stylePrompt: stylePrompt(for: project)
)
}
private func lyricsText(for project: SongProject) -> String {
if !project.lyrics.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return project.lyrics.text
}
return project.orderedSections
.compactMap { sectionLyricsBlock(for: $0, project: project) }
.joined(separator: "\n\n")
}
private func sectionLyricsBlock(for section: SongSection, project: SongProject) -> String? {
let lyrics = project.lyrics.sectionTexts[section.id] ?? section.lyrics
let trimmedLyrics = lyrics.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedLyrics.isEmpty else { return nil }
let performanceDirective = normalized(section.vocalDirection)
let header = performanceDirective.map { "[\(section.title) - \($0)]" } ?? "[\(section.title)]"
return "\(header)\n\(trimmedLyrics)"
}
private func stylePrompt(for project: SongProject) -> String {
let context = SongGenerationContext(project: project)
var components: [String] = []
append(project.genres.map(\.name).joined(separator: " + "), to: &components)
append(moodPhrase(for: project), to: &components)
append(context.sunoStyleInstrumentPhrase, to: &components)
append(vocalPhrase(for: project), to: &components)
append(musicalParametersPhrase(for: project), to: &components)
append(emotionPhrase(for: project), to: &components)
append(productionPhrase(for: project), to: &components)
return components.joined(separator: "; ")
}
private func vocalPhrase(for project: SongProject) -> String {
project.vocalists
.map { vocalist in
[
vocalist.label,
vocalist.voiceType,
vocalist.genderSelection,
vocalist.performanceStyle
]
.compactMap { normalized($0) }
.joined(separator: " ")
}
.filter { !$0.isEmpty }
.joined(separator: ", ")
}
private func musicalParametersPhrase(for project: SongProject) -> String {
var parameters: [String] = []
if let bpm = project.bpm?.value {
parameters.append("\(bpm) BPM")
}
append(project.key?.value.map { "key \($0)" }, to: &parameters)
append(project.scale?.value.map { "\($0) scale" }, to: &parameters)
append(project.maqam?.value.map { "maqam \($0)" }, to: &parameters)
return parameters.joined(separator: ", ")
}
private func moodPhrase(for project: SongProject) -> String {
let moods = project.moods.map(\.name).compactMap(normalized)
guard !moods.isEmpty else { return "" }
return "mood: \(moods.joined(separator: ", "))"
}
private func emotionPhrase(for project: SongProject) -> String {
let sectionEmotions = project.orderedSections.compactMap { section in
directedPhrase(
sectionTitle: section.title,
emotion: section.emotion,
energy: section.energy,
direction: nil
)
}
let arcEmotions = project.emotionalArc.compactMap { point in
directedPhrase(
sectionTitle: sectionTitle(for: point.sectionID, in: project),
emotion: point.emotion,
energy: point.energy,
direction: point.direction
)
}
let details = unique(sectionEmotions + arcEmotions)
guard !details.isEmpty else { return "" }
return "emotion: \(details.joined(separator: "; "))"
}
private func productionPhrase(for project: SongProject) -> String {
let sectionDirections = project.orderedSections.compactMap { section in
directedPhrase(sectionTitle: section.title, direction: section.productionDirection)
}
let projectDirections = project.productionDirections.compactMap { direction in
directedPhrase(
sectionTitle: sectionTitle(for: direction.sectionID, in: project),
direction: direction.text
)
}
let details = unique(sectionDirections + projectDirections)
guard !details.isEmpty else { return "" }
return "production: \(details.joined(separator: "; "))"
}
private func directedPhrase(
sectionTitle: String?,
emotion: String? = nil,
energy: Int? = nil,
direction: String? = nil
) -> String? {
var details = [normalized(emotion), energy.map { "energy \($0)" }, normalized(direction)]
.compactMap { $0 }
guard !details.isEmpty else { return nil }
if let sectionTitle = normalized(sectionTitle) {
details.insert(sectionTitle, at: 0)
}
return details.joined(separator: ", ")
}
private func sectionTitle(for sectionID: String?, in project: SongProject) -> String? {
guard let sectionID else { return nil }
return project.sections.first { $0.id == sectionID }?.title
}
private func unique(_ values: [String]) -> [String] {
var seen = Set<String>()
return values.filter { seen.insert($0).inserted }
}
private func append(_ value: String?, to components: inout [String]) {
guard let normalizedValue = normalized(value) else { return }
components.append(normalizedValue)
}
private func normalized(_ value: String?) -> String? {
let trimmedValue = value?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedValue?.isEmpty == false ? trimmedValue : nil
}
}
@@ -0,0 +1,29 @@
import Foundation
public protocol SunoOutputValidating: Sendable {
func validate(_ output: CompiledSunoOutput) throws
}
public enum SunoOutputValidationError: Error, Equatable, LocalizedError, Sendable {
case missingContent
public var errorDescription: String? {
switch self {
case .missingContent:
return "Add lyrics or a style prompt before finalizing the song output."
}
}
}
public struct CompiledSunoOutputValidator: SunoOutputValidating {
public init() {}
public func validate(_ output: CompiledSunoOutput) throws {
let lyrics = output.lyricsText.trimmingCharacters(in: .whitespacesAndNewlines)
let style = output.stylePrompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !lyrics.isEmpty || !style.isEmpty else {
throw SunoOutputValidationError.missingContent
}
}
}
@@ -0,0 +1,171 @@
import Foundation
public struct SongGenerationContext: Codable, Equatable, Sendable {
public static let empty = SongGenerationContext(selectedInstruments: [])
public var selectedInstruments: [SelectedInstrumentContext]
public init(selectedInstruments: [SelectedInstrumentContext]) {
self.selectedInstruments = selectedInstruments
}
public init(project: SongProject) {
let sectionsByID = Dictionary(uniqueKeysWithValues: project.sections.map { ($0.id, $0) })
selectedInstruments = project.selectedInstrumentTracks.map { track in
SelectedInstrumentContext(
instrumentId: track.instrumentId,
displayName: Self.displayName(for: track),
playingStyle: Self.normalized(track.playingStyle),
role: Self.normalized(track.role),
autoArrangementEnabled: track.autoArrangementEnabled,
placements: track.placements.map { placement in
SelectedInstrumentPlacementContext(
sectionId: placement.sectionId,
sectionTitle: placement.sectionId.flatMap { sectionsByID[$0]?.title },
sectionType: placement.sectionId.flatMap { sectionsByID[$0]?.type },
startTime: placement.startTime,
endTime: placement.endTime,
direction: Self.normalized(placement.direction)
)
}
)
}
}
public var hasSelectedInstruments: Bool {
!selectedInstruments.isEmpty
}
public var sunoStyleInstrumentPhrase: String {
selectedInstruments
.map(\.stylePromptPhrase)
.joined(separator: ", ")
}
private static func displayName(for track: InstrumentTrack) -> String {
normalized(track.variant) ?? track.instrumentId
}
private static func normalized(_ value: String?) -> String? {
let trimmedValue = value?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedValue?.isEmpty == false ? trimmedValue : nil
}
}
public struct SelectedInstrumentContext: Codable, Equatable, Sendable {
public var instrumentId: String
public var displayName: String
public var playingStyle: String?
public var role: String?
public var autoArrangementEnabled: Bool
public var placements: [SelectedInstrumentPlacementContext]
public init(
instrumentId: String,
displayName: String,
playingStyle: String? = nil,
role: String? = nil,
autoArrangementEnabled: Bool = true,
placements: [SelectedInstrumentPlacementContext] = []
) {
self.instrumentId = instrumentId
self.displayName = displayName
self.playingStyle = playingStyle
self.role = role
self.autoArrangementEnabled = autoArrangementEnabled
self.placements = placements
}
public var stylePromptPhrase: String {
var details: [String] = []
if let role {
details.append(role)
}
if let playingStyle {
details.append(playingStyle)
}
let timingPhrase = placements
.map(\.stylePromptPhrase)
.filter { !$0.isEmpty }
.joined(separator: "; ")
if !timingPhrase.isEmpty {
details.append(timingPhrase)
}
guard !details.isEmpty else {
return displayName
}
return "\(displayName) (\(details.joined(separator: ", ")))"
}
}
public struct SelectedInstrumentPlacementContext: Codable, Equatable, Sendable {
public var sectionId: String?
public var sectionTitle: String?
public var sectionType: SongSectionType?
public var startTime: TimeInterval?
public var endTime: TimeInterval?
public var direction: String?
public init(
sectionId: String? = nil,
sectionTitle: String? = nil,
sectionType: SongSectionType? = nil,
startTime: TimeInterval? = nil,
endTime: TimeInterval? = nil,
direction: String? = nil
) {
self.sectionId = sectionId
self.sectionTitle = sectionTitle
self.sectionType = sectionType
self.startTime = startTime
self.endTime = endTime
self.direction = direction
}
public var stylePromptPhrase: String {
var parts: [String] = []
if let sectionTitle, !sectionTitle.isEmpty {
parts.append(sectionTitle)
} else if let sectionType {
parts.append(sectionType.rawValue)
}
if let timing = timingPhrase {
parts.append(timing)
}
if let direction, !direction.isEmpty {
parts.append(direction)
}
return parts.joined(separator: " ")
}
private var timingPhrase: String? {
switch (startTime, endTime) {
case let (start?, end?):
return "\(Self.formattedTime(start))-\(Self.formattedTime(end))"
case let (start?, nil):
return "from \(Self.formattedTime(start))"
case let (nil, end?):
return "until \(Self.formattedTime(end))"
case (nil, nil):
return nil
}
}
private static func formattedTime(_ time: TimeInterval) -> String {
let roundedTime = time.rounded()
if roundedTime == time {
return "\(Int(roundedTime))s"
}
return String(format: "%.1fs", time)
}
}
@@ -0,0 +1,83 @@
import Foundation
public protocol SongProjectValidating: Sendable {
func validate(_ project: SongProject) -> [SongProjectValidationIssue]
}
public enum SongProjectValidationIssue: Equatable, Sendable {
case invalidDuration
case invalidBPM(Int)
case duplicateSectionID(String)
case invalidSectionTiming(String)
case invalidInstrumentTiming(String)
case unknownSectionReference(String)
}
public struct SongProjectValidator: SongProjectValidating {
public init() {}
public func validate(_ project: SongProject) -> [SongProjectValidationIssue] {
var issues: [SongProjectValidationIssue] = []
let sectionIDs = Set(project.sections.map(\.id))
if project.duration?.seconds.map({ $0 <= 0 }) == true {
issues.append(.invalidDuration)
}
if let bpm = project.bpm?.value, bpm <= 0 {
issues.append(.invalidBPM(bpm))
}
for section in project.sections {
if project.sections.filter({ $0.id == section.id }).count > 1 {
append(.duplicateSectionID(section.id), to: &issues)
}
if invalidTiming(start: section.startTime, end: section.endTime) {
issues.append(.invalidSectionTiming(section.id))
}
}
for instrument in project.instruments {
for placement in instrument.placements {
if invalidTiming(start: placement.startTime, end: placement.endTime) {
issues.append(.invalidInstrumentTiming(instrument.instrumentId))
}
if let sectionID = placement.sectionId, !sectionIDs.contains(sectionID) {
append(.unknownSectionReference(sectionID), to: &issues)
}
}
}
for vocalist in project.vocalists {
for sectionID in vocalist.assignedSections where !sectionIDs.contains(sectionID) {
append(.unknownSectionReference(sectionID), to: &issues)
}
}
for point in project.emotionalArc {
if let sectionID = point.sectionID, !sectionIDs.contains(sectionID) {
append(.unknownSectionReference(sectionID), to: &issues)
}
}
for direction in project.productionDirections {
if let sectionID = direction.sectionID, !sectionIDs.contains(sectionID) {
append(.unknownSectionReference(sectionID), to: &issues)
}
}
return issues
}
private func invalidTiming(start: TimeInterval?, end: TimeInterval?) -> Bool {
guard let start, let end else { return start.map { $0 < 0 } ?? end.map { $0 < 0 } ?? false }
return start < 0 || end < 0 || end <= start
}
private func append(_ issue: SongProjectValidationIssue, to issues: inout [SongProjectValidationIssue]) {
guard !issues.contains(issue) else { return }
issues.append(issue)
}
}
@@ -0,0 +1,129 @@
import Foundation
public struct LocalVocalistPresetCatalog: Sendable {
public let items: [VocalistPreset]
public init(items: [VocalistPreset] = LocalVocalistPresetCatalog.defaultItems) {
self.items = items
}
public var familyCategories: [String] {
Array(Set(items.map(\.familyCategory)))
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
public func vocalists(
matching query: String,
familyCategory: String? = nil
) -> [VocalistPreset] {
let searchTokens = Self.searchTokens(for: query)
let selectedFamilyCategory = familyCategory?.trimmingCharacters(in: .whitespacesAndNewlines)
return items
.filter { item in
let matchesSearch = searchTokens.isEmpty || searchTokens.allSatisfy { token in
Self.searchableTerms(for: item).contains { $0.contains(token) }
}
let matchesFamily = selectedFamilyCategory?.isEmpty != false || item.familyCategory == selectedFamilyCategory
return matchesSearch && matchesFamily
}
.sorted { $0.label.localizedCaseInsensitiveCompare($1.label) == .orderedAscending }
}
public static let defaultItems: [VocalistPreset] = leadVoices + rapAndSpoken + harmonyAndGroups + arabicAndTraditional
private static let leadVoices: [VocalistPreset] = [
item("lead-female", "Female Lead Vocal", "Lead Voices", "Global", "melodic lead", "female", "expressive lead", aliases: ["female singer"]),
item("lead-male", "Male Lead Vocal", "Lead Voices", "Global", "melodic lead", "male", "expressive lead", aliases: ["male singer"]),
item("lead-neutral", "Neutral Lead Vocal", "Lead Voices", "Global", "melodic lead", "neutral", "expressive lead"),
item("soprano", "Soprano Lead", "Lead Voices", "Global", "soprano", "female", "clear high lead"),
item("mezzo-soprano", "Mezzo-Soprano Lead", "Lead Voices", "Global", "mezzo-soprano", "female", "warm lead"),
item("alto", "Alto Lead", "Lead Voices", "Global", "alto", "female", "warm low lead"),
item("tenor", "Tenor Lead", "Lead Voices", "Global", "tenor", "male", "clear lead"),
item("baritone", "Baritone Lead", "Lead Voices", "Global", "baritone", "male", "warm low lead"),
item("bass-vocal", "Bass Lead", "Lead Voices", "Global", "bass", "male", "deep lead"),
item("child-vocal", "Child Vocal", "Lead Voices", "Global", "child voice", "child", "clear youthful lead")
]
private static let rapAndSpoken: [VocalistPreset] = [
item("male-rapper", "Male Rap Vocal", "Rap & Spoken", "Global", "rap", "male", "rhythmic rap", aliases: ["male rapper"], terms: ["راب"]),
item("female-rapper", "Female Rap Vocal", "Rap & Spoken", "Global", "rap", "female", "rhythmic rap", aliases: ["female rapper"], terms: ["راب"]),
item("neutral-rapper", "Neutral Rap Vocal", "Rap & Spoken", "Global", "rap", "neutral", "rhythmic rap", terms: ["راب"]),
item("spoken-word", "Spoken Word", "Rap & Spoken", "Global", "spoken word", "neutral", "narrated delivery", aliases: ["narration"]),
item("melodic-rap", "Melodic Rap Vocal", "Rap & Spoken", "Global", "rap/singing", "neutral", "melodic rap")
]
private static let harmonyAndGroups: [VocalistPreset] = [
item("female-harmony", "Female Harmony Vocal", "Harmony & Groups", "Global", "harmony", "female", "soft harmony", aliases: ["female backing vocal"]),
item("male-harmony", "Male Harmony Vocal", "Harmony & Groups", "Global", "harmony", "male", "soft harmony", aliases: ["male backing vocal"]),
item("mixed-harmony", "Mixed Harmony Vocals", "Harmony & Groups", "Global", "harmony", "mixed", "layered harmony"),
item("female-choir", "Female Choir", "Harmony & Groups", "Global", "choir", "female", "layered choir"),
item("male-choir", "Male Choir", "Harmony & Groups", "Global", "choir", "male", "layered choir"),
item("mixed-choir", "Mixed Choir", "Harmony & Groups", "Global", "choir", "mixed", "wide choral arrangement"),
item("children-choir", "Children's Choir", "Harmony & Groups", "Global", "children choir", "mixed", "bright choir"),
item("duet", "Duet Leads", "Harmony & Groups", "Global", "duet", "mixed", "call and response"),
item("vocal-ensemble", "Vocal Ensemble", "Harmony & Groups", "Global", "ensemble", "mixed", "layered ensemble")
]
private static let arabicAndTraditional: [VocalistPreset] = [
item("arabic-male-lead", "Arabic Male Lead", "Arabic & Traditional", "Arab World", "ornamented male lead", "male", "Arabic melodic delivery", terms: ["صوت عربي رجالي"]),
item("arabic-female-lead", "Arabic Female Lead", "Arabic & Traditional", "Arab World", "ornamented female lead", "female", "Arabic melodic delivery", terms: ["صوت عربي نسائي"]),
item("khaleeji-male-lead", "Khaleeji Male Lead", "Arabic & Traditional", "Gulf", "male lead", "male", "Khaleeji delivery", terms: ["خليجي"]),
item("khaleeji-female-lead", "Khaleeji Female Lead", "Arabic & Traditional", "Gulf", "female lead", "female", "Khaleeji delivery", terms: ["خليجي"]),
item("arabic-chorus", "Arabic Chorus", "Arabic & Traditional", "Arab World", "chorus", "mixed", "Arabic group response", terms: ["كورال عربي"]),
item("sufi-ensemble", "Sufi Vocal Ensemble", "Arabic & Traditional", "Middle East & South Asia", "ensemble", "mixed", "devotional call and response"),
item("folk-storyteller", "Folk Storyteller", "Arabic & Traditional", "Global", "spoken/sung folk vocal", "neutral", "narrative folk delivery")
]
private static func item(
_ id: String,
_ label: String,
_ familyCategory: String,
_ regionOrigin: String? = nil,
_ voiceType: String? = nil,
_ genderSelection: String? = nil,
_ performanceStyle: String? = nil,
aliases: [String] = [],
terms: [String] = []
) -> VocalistPreset {
VocalistPreset(
id: id,
label: label,
familyCategory: familyCategory,
regionOrigin: regionOrigin,
voiceType: voiceType,
genderSelection: genderSelection,
performanceStyle: performanceStyle,
aliases: aliases,
searchTerms: terms
)
}
private static func searchableTerms(for item: VocalistPreset) -> [String] {
searchTokens(
for: [
item.label,
item.familyCategory,
item.regionOrigin ?? "",
item.voiceType ?? "",
item.genderSelection ?? "",
item.performanceStyle ?? ""
] + item.aliases + item.searchTerms
)
}
private static func searchTokens(for query: String) -> [String] {
searchTokens(for: [query])
}
private static func searchTokens(for values: [String]) -> [String] {
values
.joined(separator: " ")
.folding(
options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive],
locale: Locale(identifier: "en_US_POSIX")
)
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "/" })
.map(String.init)
}
}
@@ -0,0 +1,15 @@
import MusicAssistantCore
import XCTest
final class AIRequestStateTests: XCTestCase {
func testPresenterMapsOfflineAndProviderFailures() {
let presenter = AIProviderErrorPresenter()
XCTAssertEqual(presenter.state(for: URLError(.notConnectedToInternet)), .offline)
XCTAssertEqual(presenter.state(for: OpenAIClientError.missingAPIKey), .missingConfiguration)
XCTAssertEqual(presenter.state(for: OpenAIClientError.rateLimited(retryAfter: 30)), .rateLimited(retryAfter: 30))
XCTAssertEqual(presenter.state(for: OpenAIClientError.serverError(503)), .unavailable)
XCTAssertEqual(presenter.state(for: OpenAIClientError.invalidResponse), .invalidResponse)
XCTAssertEqual(presenter.state(for: AIServiceCapabilityError.discussionNotSupported), .unsupportedCapability)
}
}
@@ -0,0 +1,121 @@
import MusicAssistantCore
import XCTest
final class AIServiceTests: XCTestCase {
func testProviderIndependentServiceGeneratesStructuredProjectResult() async throws {
let service = MockAIService()
let request = SongProjectGenerationRequest(
context: AIRequestContext(
userInstruction: "Write a hopeful cinematic pop song.",
conversation: [
AIConversationMessage(role: .user, content: "Use piano and violin.")
],
localeIdentifier: "en_US"
),
discussionMode: .discuss
)
let result = try await service.generateSongProject(from: request)
XCTAssertEqual(result.project.title, "Generated Song")
XCTAssertEqual(result.project.idea, request.context.userInstruction)
XCTAssertEqual(result.project.conversationMode, .discuss)
XCTAssertEqual(result.notes, ["Generated by a provider-independent mock."])
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")
let sourceLyrics = Lyrics(text: "rough draft")
let request = LyricsRevisionRequest(
context: AIRequestContext(userInstruction: "Make it stronger."),
project: project,
sourceLyrics: sourceLyrics,
mode: .improve
)
let result = try await service.reviseLyrics(from: request)
XCTAssertEqual(result.lyrics.text, "rough draft\nMake it stronger.")
XCTAssertEqual(result.notes, ["improve"])
}
func testProviderIndependentServiceProposesScopedProjectUpdates() async throws {
let service = MockAIService()
let project = SongProject(title: "Scoped Project", idea: "Add tempo")
let request = SongProjectUpdateRequest(
context: AIRequestContext(userInstruction: "Set tempo manually."),
project: project,
allowedScopes: [.musicalParameters]
)
let result = try await service.proposeProjectUpdate(from: request)
XCTAssertEqual(result.project.bpm, ManualAutoValue(mode: .manual, value: 96))
XCTAssertEqual(result.changedScopes, [.musicalParameters])
XCTAssertEqual(result.notes, ["Updated only requested scopes."])
}
}
private struct MockAIService: AIService {
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
SongProjectGenerationResult(
project: SongProject(
title: "Generated Song",
idea: request.context.userInstruction,
conversationMode: request.discussionMode
),
notes: ["Generated by a provider-independent mock."],
followUpQuestions: ["Should the chorus be bigger?"]
)
}
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)"),
notes: [request.mode.rawValue]
)
}
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
var project = request.project
var changedScopes: [SongProjectUpdateScope] = []
if request.allowedScopes.contains(.musicalParameters) {
project.setBPMManually(96)
changedScopes.append(.musicalParameters)
}
return SongProjectUpdateResult(
project: project,
changedScopes: changedScopes,
notes: ["Updated only requested scopes."]
)
}
}
@@ -4,15 +4,18 @@ import XCTest
final class AppConfigurationTests: XCTestCase { final class AppConfigurationTests: XCTestCase {
func testDevelopmentConfigurationReadsKnownEnvironmentKeys() { func testDevelopmentConfigurationReadsKnownEnvironmentKeys() {
let configuration = AppConfiguration.development(environment: [ let configuration = AppConfiguration.development(environment: [
"MUSIC_ASSISTANT_OPENAI_API_KEY": "openai-test-key" "MUSIC_ASSISTANT_OPENAI_API_KEY": "openai-test-key",
"MUSIC_ASSISTANT_PRIVATE_AI_RULES": " private product rules "
]) ])
XCTAssertEqual(configuration.openAIAPIKey, "openai-test-key") XCTAssertEqual(configuration.openAIAPIKey, "openai-test-key")
XCTAssertEqual(configuration.privateApplicationRules?.content, "private product rules")
} }
func testDevelopmentConfigurationAllowsMissingSecrets() { func testDevelopmentConfigurationAllowsMissingSecrets() {
let configuration = AppConfiguration.development(environment: [:]) let configuration = AppConfiguration.development(environment: [:])
XCTAssertNil(configuration.openAIAPIKey) XCTAssertNil(configuration.openAIAPIKey)
XCTAssertNil(configuration.privateApplicationRules)
} }
} }
@@ -0,0 +1,114 @@
@testable import MusicAssistantCore
import XCTest
final class ApplicationRuleInjectionTests: XCTestCase {
func testInjectingServiceAddsPrivateRulesToEveryAIRequestType() async throws {
let baseService = RecordingAIService()
let service = ApplicationRuleInjectingAIService(
baseService: baseService,
ruleProvider: StaticApplicationRuleProvider(
rules: PrivateApplicationRules(content: "Use private product rules.")
)
)
let project = SongProject(title: "Rule Test", idea: "Test rules")
_ = try await service.generateSongProject(
from: SongProjectGenerationRequest(
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."),
project: project,
sourceLyrics: Lyrics(text: "Draft"),
mode: .improve
)
)
_ = try await service.proposeProjectUpdate(
from: SongProjectUpdateRequest(
context: AIRequestContext(userInstruction: "Update."),
project: project,
allowedScopes: [.lyrics]
)
)
let recordedRuleContents = await baseService.recordedRuleContents()
XCTAssertEqual(
recordedRuleContents,
[
"Use private product rules.",
"Use private product rules.",
"Use private product rules.",
"Use private product rules."
]
)
}
func testInjectingServiceDoesNotAddPrivateRulesToVisibleConversation() async throws {
let baseService = RecordingAIService()
let service = ApplicationRuleInjectingAIService(
baseService: baseService,
ruleProvider: StaticApplicationRuleProvider(
rules: PrivateApplicationRules(content: "Hidden owner rules")
)
)
let visibleMessage = AIConversationMessage(role: .user, content: "Visible user text.")
_ = try await service.generateSongProject(
from: SongProjectGenerationRequest(
context: AIRequestContext(
userInstruction: "Generate.",
conversation: [visibleMessage]
)
)
)
let recordedConversation = await baseService.recordedConversations().first
XCTAssertEqual(recordedConversation, [visibleMessage])
}
func testBlankPrivateRulesAreIgnored() {
XCTAssertNil(PrivateApplicationRules(content: " \n "))
}
}
private actor RecordingAIService: AIService {
private var contexts: [AIRequestContext] = []
func recordedRuleContents() -> [String?] {
contexts.map { $0.privateApplicationRules?.content }
}
func recordedConversations() -> [[AIConversationMessage]] {
contexts.map(\.conversation)
}
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
contexts.append(request.context)
return SongProjectGenerationResult(
project: SongProject(title: "Generated", idea: request.context.userInstruction)
)
}
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)
}
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
contexts.append(request.context)
return SongProjectUpdateResult(project: request.project)
}
}
@@ -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"])
}
}
@@ -0,0 +1,210 @@
import Foundation
import MusicAssistantCore
import XCTest
final class LocalSongProjectStoreTests: XCTestCase {
private var temporaryDirectoryURL: URL!
override func setUpWithError() throws {
temporaryDirectoryURL = FileManager.default.temporaryDirectory
.appendingPathComponent("music-assistant-store-tests")
.appendingPathComponent(UUID().uuidString)
}
override func tearDownWithError() throws {
if let temporaryDirectoryURL {
try? FileManager.default.removeItem(at: temporaryDirectoryURL)
}
temporaryDirectoryURL = nil
}
func testCreatesOpensSavesAndListsSongProjectsLocally() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
let createdAt = Date(timeIntervalSince1970: 100)
let updatedAt = Date(timeIntervalSince1970: 200)
var project = SongProject(
id: "local-project",
title: "Local Project",
idea: "Persist this song locally",
genres: [GenreStyle(id: "pop", name: "Pop")],
createdAt: createdAt,
updatedAt: updatedAt
)
try await store.create(project)
let createdProjectIDs = try await store.loadProjectIDs()
let openedProject = try await store.open(id: project.id)
XCTAssertEqual(createdProjectIDs, ["local-project"])
XCTAssertEqual(openedProject, project)
project.title = "Updated Local Project"
project.setBPMManually(100)
try await store.save(project)
let updatedProject = try await store.open(id: project.id)
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(
id: "older-project",
title: "Older",
idea: "Created first",
createdAt: Date(timeIntervalSince1970: 100),
updatedAt: Date(timeIntervalSince1970: 150)
)
let newer = SongProject(
id: "newer-project",
title: "Newer",
idea: "Updated later",
createdAt: Date(timeIntervalSince1970: 200),
updatedAt: Date(timeIntervalSince1970: 300)
)
try await store.create(older)
try await store.create(newer)
let projectList = try await store.loadProjectList()
let projectIDs = try await store.loadProjectIDs()
XCTAssertEqual(
projectList,
[
SongProjectListItem(project: newer),
SongProjectListItem(project: older)
]
)
XCTAssertEqual(projectIDs, ["newer-project", "older-project"])
}
func testCreateDoesNotOverwriteExistingProject() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
let project = SongProject(
id: "duplicate-project",
title: "Duplicate",
idea: "Do not overwrite"
)
try await store.create(project)
do {
try await store.create(project)
XCTFail("Expected duplicate project creation to fail.")
} catch let error as LocalSongProjectStoreError {
XCTAssertEqual(error, .projectAlreadyExists(project.id))
}
}
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)
do {
_ = try await store.open(id: "missing")
XCTFail("Expected missing project open to fail.")
} catch let error as LocalSongProjectStoreError {
XCTAssertEqual(error, .projectNotFound("missing"))
}
}
}
@@ -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))
}
}
@@ -0,0 +1,287 @@
import Foundation
import MusicAssistantCore
import XCTest
final class OpenAIClientTests: XCTestCase {
func testGenerateSongProjectUsesConfiguredEndpointAndHeaders() async throws {
let transport = RecordingOpenAITransport(
statusCode: 200,
responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Generated"))
)
let configuration = OpenAIClientConfiguration(
apiKey: "test-api-key",
endpointURL: URL(string: "https://api.example.test/v1/configured")!,
model: "configured-model",
organizationID: "org-test",
projectID: "project-test"
)
let client = OpenAIClient(
configuration: configuration,
adapter: MockOpenAIClientAdapter(),
transport: transport
)
let result = try await client.generateSongProject(
from: SongProjectGenerationRequest(
context: AIRequestContext(userInstruction: "Generate this."),
discussionMode: .auto
)
)
let request = await transport.recordedRequests.first
XCTAssertEqual(result.project.title, "Generated")
XCTAssertEqual(request?.url, configuration.endpointURL)
XCTAssertEqual(request?.httpMethod, "POST")
XCTAssertEqual(request?.value(forHTTPHeaderField: "Authorization"), "Bearer test-api-key")
XCTAssertEqual(request?.value(forHTTPHeaderField: "OpenAI-Organization"), "org-test")
XCTAssertEqual(request?.value(forHTTPHeaderField: "OpenAI-Project"), "project-test")
XCTAssertEqual(request?.value(forHTTPHeaderField: "X-Adapter"), "mock")
XCTAssertEqual(request?.value(forHTTPHeaderField: "Content-Type"), "application/json")
let requestBodyString = await transport.requestBodyString
XCTAssertEqual(requestBodyString, "generate|configured-model|Generate this.")
}
func testClientUsesAdapterURLOverrideWhenProvided() async throws {
let overrideURL = URL(string: "https://api.example.test/v1/override")!
let transport = RecordingOpenAITransport(
statusCode: 200,
responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Updated"))
)
let client = OpenAIClient(
configuration: OpenAIClientConfiguration(
apiKey: "test-api-key",
endpointURL: URL(string: "https://api.example.test/v1/configured")!
),
adapter: MockOpenAIClientAdapter(overrideURL: overrideURL),
transport: transport
)
_ = try await client.proposeProjectUpdate(
from: SongProjectUpdateRequest(
context: AIRequestContext(userInstruction: "Update it."),
project: SongProject(title: "Original", idea: "Original idea"),
allowedScopes: [.lyrics]
)
)
let recordedURL = await transport.recordedRequests.first?.url
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,
responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Never sent"))
)
let client = OpenAIClient(
configuration: OpenAIClientConfiguration(
apiKey: " ",
endpointURL: URL(string: "https://api.example.test/v1/configured")!
),
adapter: MockOpenAIClientAdapter(),
transport: transport
)
do {
_ = try await client.reviseLyrics(
from: LyricsRevisionRequest(
context: AIRequestContext(userInstruction: "Improve."),
project: SongProject(title: "Song", idea: "Idea"),
sourceLyrics: Lyrics(text: "Draft"),
mode: .improve
)
)
XCTFail("Expected missing API key to throw.")
} catch let error as OpenAIClientError {
XCTAssertEqual(error, .missingAPIKey)
}
let recordedRequestCount = await transport.recordedRequests.count
XCTAssertEqual(recordedRequestCount, 0)
}
func testClientReportsRateLimitWithoutLeakingResponseBody() async throws {
let transport = RecordingOpenAITransport(
statusCode: 429,
responseData: Data("secret server detail".utf8)
)
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 status error to throw.")
} catch let error as OpenAIClientError {
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))
}
}
}
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,
responseHeaders: [String: String]? = nil
) {
self.statusCode = statusCode
self.responseData = responseData
self.responseHeaders = responseHeaders
}
var requestBodyString: String? {
guard let body = recordedRequests.first?.httpBody else { return nil }
return String(data: body, encoding: .utf8)
}
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
recordedRequests.append(request)
let response = HTTPURLResponse(
url: request.url!,
statusCode: statusCode,
httpVersion: "HTTP/1.1",
headerFields: responseHeaders
)!
return (responseData, response)
}
}
private struct MockOpenAIClientAdapter: OpenAIClientAdapter {
var overrideURL: URL?
func makeProjectGenerationRequest(
_ request: SongProjectGenerationRequest,
configuration: OpenAIClientConfiguration
) throws -> OpenAIClientRequest {
OpenAIClientRequest(
url: overrideURL,
body: Data("generate|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8),
additionalHeaders: ["X-Adapter": "mock"]
)
}
func decodeProjectGenerationResult(from data: Data) throws -> SongProjectGenerationResult {
let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data)
return SongProjectGenerationResult(
project: SongProject(title: response.text, idea: response.text)
)
}
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
) throws -> OpenAIClientRequest {
OpenAIClientRequest(
url: overrideURL,
body: Data("lyrics|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8)
)
}
func decodeLyricsRevisionResult(from data: Data) throws -> LyricsRevisionResult {
let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data)
return LyricsRevisionResult(lyrics: Lyrics(text: response.text))
}
func makeProjectUpdateRequest(
_ request: SongProjectUpdateRequest,
configuration: OpenAIClientConfiguration
) throws -> OpenAIClientRequest {
OpenAIClientRequest(
url: overrideURL,
body: Data("update|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8)
)
}
func decodeProjectUpdateResult(from data: Data) throws -> SongProjectUpdateResult {
let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data)
return SongProjectUpdateResult(
project: SongProject(title: response.text, idea: response.text)
)
}
}
private struct MockOpenAIResponse: Codable {
var text: String
}
@@ -2,10 +2,10 @@ import MusicAssistantCore
import XCTest import XCTest
final class ProductBoundarySummaryTests: XCTestCase { final class ProductBoundarySummaryTests: XCTestCase {
func testInitialBoundaryKeepsSunoGenerationOutsideTheApp() { func testInitialBoundaryExcludesExternalGenerationControl() {
let summary = ProductBoundarySummary.initial.description let summary = ProductBoundarySummary.initial.description
XCTAssertTrue(summary.contains("Suno")) XCTAssertTrue(summary.contains("final lyrics and style instructions"))
XCTAssertTrue(summary.contains("without starting generation")) XCTAssertFalse(summary.contains("Suno"))
} }
} }
@@ -0,0 +1,104 @@
import Foundation
import MusicAssistantCore
import XCTest
final class ProjectAutosaveServiceTests: XCTestCase {
private var temporaryDirectoryURL: URL!
override func setUpWithError() throws {
temporaryDirectoryURL = FileManager.default.temporaryDirectory
.appendingPathComponent("music-assistant-autosave-tests")
.appendingPathComponent(UUID().uuidString)
}
override func tearDownWithError() throws {
if let temporaryDirectoryURL {
try? FileManager.default.removeItem(at: temporaryDirectoryURL)
}
temporaryDirectoryURL = nil
}
func testAutosavePersistsCurrentProjectWithoutCreatingExcessiveVersions() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
let autosaver = ProjectAutosaveService(
store: store,
policy: ProjectAutosavePolicy(minimumVersionInterval: 300)
)
var project = SongProject(
id: "autosaved-project",
title: "Draft",
idea: "Initial idea",
updatedAt: Date(timeIntervalSince1970: 50)
)
try await store.create(project)
let firstAutosave = try await autosaver.autosave(
project,
at: Date(timeIntervalSince1970: 100)
)
XCTAssertTrue(firstAutosave.didCreateVersion)
XCTAssertEqual(firstAutosave.project.versions.count, 1)
XCTAssertEqual(firstAutosave.project.versions.first?.snapshot.title, "Draft")
XCTAssertEqual(firstAutosave.project.updatedAt, Date(timeIntervalSince1970: 100))
project = firstAutosave.project
project.title = "Draft with small edit"
let rapidAutosave = try await autosaver.autosave(
project,
at: Date(timeIntervalSince1970: 120)
)
XCTAssertFalse(rapidAutosave.didCreateVersion)
XCTAssertEqual(rapidAutosave.project.versions.count, 1)
XCTAssertEqual(rapidAutosave.project.title, "Draft with small edit")
XCTAssertEqual(rapidAutosave.project.updatedAt, Date(timeIntervalSince1970: 120))
let storedRapidAutosave = try await store.open(id: project.id)
XCTAssertEqual(storedRapidAutosave, rapidAutosave.project)
project = rapidAutosave.project
project.idea = "Later autosaved idea"
let laterAutosave = try await autosaver.autosave(
project,
at: Date(timeIntervalSince1970: 420)
)
XCTAssertTrue(laterAutosave.didCreateVersion)
XCTAssertEqual(laterAutosave.project.versions.count, 2)
XCTAssertEqual(laterAutosave.project.versions.last?.snapshot.idea, "Later autosaved idea")
XCTAssertEqual(laterAutosave.project.updatedAt, Date(timeIntervalSince1970: 420))
}
func testAutosaveSkipsVersionWhenCurrentSnapshotMatchesLatestVersion() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
let autosaver = ProjectAutosaveService(
store: store,
policy: ProjectAutosavePolicy(minimumVersionInterval: 300)
)
var project = SongProject(
id: "unchanged-autosave-project",
title: "Stable",
idea: "No meaningful changes"
)
_ = project.createVersion(
id: "version-1",
label: "Stable checkpoint",
createdAt: Date(timeIntervalSince1970: 100)
)
try await store.create(project)
let autosave = try await autosaver.autosave(
project,
at: Date(timeIntervalSince1970: 1_000)
)
XCTAssertFalse(autosave.didCreateVersion)
XCTAssertEqual(autosave.project.versions.count, 1)
XCTAssertEqual(autosave.project.updatedAt, Date(timeIntervalSince1970: 1_000))
}
}
@@ -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)
}
}
@@ -1,3 +1,4 @@
import Foundation
import MusicAssistantCore import MusicAssistantCore
import XCTest import XCTest
@@ -88,6 +89,94 @@ final class SongProjectModelTests: XCTestCase {
XCTAssertEqual(version.snapshot.instruments, project.instruments) XCTAssertEqual(version.snapshot.instruments, project.instruments)
} }
func testVersionHistoryStoresIndependentSnapshots() {
var project = SongProject(
id: "versioned-project",
title: "First Title",
idea: "Original idea",
genres: [GenreStyle(id: "folk", name: "Folk")]
)
let createdAt = Date(timeIntervalSince1970: 500)
let version = project.createVersion(
id: "version-1",
label: "Initial snapshot",
createdAt: createdAt
)
project.title = "Changed Title"
project.idea = "Changed idea"
project.selectGenreStyle(GenreStyle(id: "electronic", name: "Electronic"))
XCTAssertEqual(project.versions.count, 1)
XCTAssertEqual(project.version(id: "version-1"), version)
XCTAssertEqual(project.versions.first?.label, "Initial snapshot")
XCTAssertEqual(project.versions.first?.createdAt, createdAt)
XCTAssertEqual(project.versions.first?.snapshot.title, "First Title")
XCTAssertEqual(project.versions.first?.snapshot.idea, "Original idea")
XCTAssertEqual(project.versions.first?.snapshot.genres, [GenreStyle(id: "folk", name: "Folk")])
XCTAssertEqual(project.title, "Changed Title")
XCTAssertEqual(project.genres.map(\.id), ["folk", "electronic"])
}
func testRestoresPreviousVersionWithoutDestroyingLaterVersions() {
let createdAt = Date(timeIntervalSince1970: 100)
let restoredAt = Date(timeIntervalSince1970: 900)
var project = SongProject(
id: "restorable-project",
title: "First Title",
idea: "Original idea",
duration: SongDuration(seconds: 180),
genres: [GenreStyle(id: "folk", name: "Folk")],
instruments: [InstrumentTrack(instrumentId: "piano")],
lyrics: Lyrics(text: "Original lyrics"),
createdAt: createdAt,
updatedAt: Date(timeIntervalSince1970: 200)
)
_ = project.createVersion(
id: "version-1",
label: "Original direction",
createdAt: Date(timeIntervalSince1970: 300)
)
project.title = "Second Title"
project.idea = "Second idea"
project.duration = SongDuration(seconds: 210)
project.selectGenreStyle(GenreStyle(id: "electronic", name: "Electronic"))
project.selectInstrument(InstrumentTrack(instrumentId: "oud"))
project.lyrics = Lyrics(text: "Second lyrics")
_ = project.createVersion(
id: "version-2",
label: "Later direction",
createdAt: Date(timeIntervalSince1970: 400)
)
project.title = "Current Title"
project.idea = "Current idea"
project.duration = SongDuration(seconds: 240)
project.deselectGenreStyle(id: "folk")
project.lyrics = Lyrics(text: "Current lyrics")
let versionsBeforeRestore = project.versions
XCTAssertTrue(project.restoreVersion(id: "version-1", restoredAt: restoredAt))
XCTAssertEqual(project.title, "First Title")
XCTAssertEqual(project.idea, "Original idea")
XCTAssertEqual(project.duration, SongDuration(seconds: 180))
XCTAssertEqual(project.genres, [GenreStyle(id: "folk", name: "Folk")])
XCTAssertEqual(project.instruments, [InstrumentTrack(instrumentId: "piano")])
XCTAssertEqual(project.lyrics, Lyrics(text: "Original lyrics"))
XCTAssertEqual(project.createdAt, createdAt)
XCTAssertEqual(project.updatedAt, restoredAt)
XCTAssertEqual(project.versions, versionsBeforeRestore)
XCTAssertEqual(project.version(id: "version-2")?.snapshot.title, "Second Title")
let restoredProject = project
XCTAssertFalse(project.restoreVersion(id: "missing-version", restoredAt: Date(timeIntervalSince1970: 1_000)))
XCTAssertEqual(project, restoredProject)
}
func testGenreStylesAreModeledAsMultiSelect() { func testGenreStylesAreModeledAsMultiSelect() {
let arabicPop = GenreStyle(id: "arabic-pop", name: "Arabic Pop") let arabicPop = GenreStyle(id: "arabic-pop", name: "Arabic Pop")
let synthwave = GenreStyle(id: "synthwave", name: "Synthwave") let synthwave = GenreStyle(id: "synthwave", name: "Synthwave")
@@ -192,6 +281,40 @@ final class SongProjectModelTests: XCTestCase {
XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [introPlacement]) 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() { func testMultipleVocalistsCanBeAssignedToSections() {
var project = SongProject( var project = SongProject(
id: "project-6", id: "project-6",
@@ -235,4 +358,268 @@ final class SongProjectModelTests: XCTestCase {
XCTAssertNil(project.vocalist(for: "harmony")) XCTAssertNil(project.vocalist(for: "harmony"))
} }
func testLanguagesDialectsAndArabicPronunciationSettingsAreModeled() {
var project = SongProject(
id: "project-7",
title: "Multilingual Song",
idea: "Arabic and English hook"
)
let arabic = LanguageSelection(id: "arabic", name: "Arabic")
let english = LanguageSelection(id: "english", name: "English")
let levantine = DialectSelection(id: "levantine", name: "Levantine", languageID: arabic.id)
let gulf = DialectSelection(id: "gulf", name: "Gulf", languageID: arabic.id)
project.upsertLanguage(arabic)
project.upsertLanguage(english)
project.upsertLanguage(LanguageSelection(id: "english", name: "English"))
project.upsertDialect(levantine)
project.upsertDialect(gulf)
project.arabicPronunciationSettings = ArabicPronunciationSettings(
isEnabled: true,
diacritizationPolicy: .pronunciationTargeted,
tanweenPolicy: .preserveUserInput,
userNotes: "Preserve dialect phrasing",
preservedSpellings: ["ya habibi"]
)
XCTAssertEqual(project.languageIDs, ["arabic", "english"])
XCTAssertEqual(project.dialectIDs, ["levantine", "gulf"])
XCTAssertEqual(project.language(for: arabic.id), arabic)
XCTAssertEqual(project.dialects(forLanguageID: arabic.id), [levantine, gulf])
XCTAssertTrue(project.arabicPronunciationSettings.isEnabled)
XCTAssertEqual(project.arabicPronunciationSettings.diacritizationPolicy, .pronunciationTargeted)
XCTAssertEqual(project.arabicPronunciationSettings.tanweenPolicy, .preserveUserInput)
project.removeDialect(id: gulf.id)
XCTAssertNil(project.dialect(for: gulf.id))
XCTAssertEqual(project.dialects(forLanguageID: arabic.id), [levantine])
project.removeLanguage(id: arabic.id)
XCTAssertNil(project.language(for: arabic.id))
XCTAssertEqual(project.dialects(forLanguageID: arabic.id), [])
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",
title: "Musical Parameters",
idea: "Control tempo and harmony"
)
project.setBPMManually(104)
project.setKeyManually("D")
project.setScaleAutomatic()
project.setMaqamManually("Bayati")
XCTAssertEqual(project.bpm, ManualAutoValue(mode: .manual, value: 104))
XCTAssertEqual(project.key, ManualAutoValue(mode: .manual, value: "D"))
XCTAssertEqual(project.scale, ManualAutoValue(mode: .auto))
XCTAssertEqual(project.maqam, ManualAutoValue(mode: .manual, value: "Bayati"))
project.setBPMAutomatic()
project.setKeyAutomatic()
project.setScaleManually("minor")
project.setMaqamAutomatic()
XCTAssertEqual(project.bpm, ManualAutoValue(mode: .auto))
XCTAssertEqual(project.key, ManualAutoValue(mode: .auto))
XCTAssertEqual(project.scale, ManualAutoValue(mode: .manual, value: "minor"))
XCTAssertEqual(project.maqam, ManualAutoValue(mode: .auto))
}
func testEmotionalArcAndProductionDirectionsAreModeled() {
var project = SongProject(
id: "project-9",
title: "Arc",
idea: "Build from intimate to explosive"
)
let verseArc = EmotionalArcPoint(
id: "arc-verse",
sectionID: "verse",
emotion: "intimate",
energy: 3,
direction: "restrained"
)
let chorusArc = EmotionalArcPoint(
id: "arc-chorus",
sectionID: "chorus",
emotion: "euphoric",
energy: 9,
direction: "wide and bright"
)
let verseProduction = ProductionDirection(
id: "prod-verse",
sectionID: "verse",
text: "keep drums minimal"
)
let chorusProduction = ProductionDirection(
id: "prod-chorus",
sectionID: "chorus",
text: "add layered harmonies and bigger percussion"
)
project.upsertEmotionalArcPoint(verseArc)
project.upsertEmotionalArcPoint(chorusArc)
project.upsertEmotionalArcPoint(
EmotionalArcPoint(
id: "arc-verse",
sectionID: "verse",
emotion: "focused",
energy: 4,
direction: "slightly warmer"
)
)
project.upsertProductionDirection(verseProduction)
project.upsertProductionDirection(chorusProduction)
XCTAssertEqual(project.emotionalArc.count, 2)
XCTAssertEqual(project.emotionalArcPoint(for: "arc-verse")?.emotion, "focused")
XCTAssertEqual(project.emotionalArcPoints(forSectionID: "chorus"), [chorusArc])
XCTAssertEqual(project.productionDirections(forSectionID: "verse"), [verseProduction])
XCTAssertEqual(project.productionDirection(for: "prod-chorus"), chorusProduction)
project.removeEmotionalArcPoint(id: "arc-chorus")
project.removeProductionDirection(id: "prod-verse")
XCTAssertNil(project.emotionalArcPoint(for: "arc-chorus"))
XCTAssertNil(project.productionDirection(for: "prod-verse"))
}
func testSongProjectRoundTripsThroughJSONSerialization() throws {
let createdAt = Date(timeIntervalSince1970: 1_700_000_000)
let updatedAt = Date(timeIntervalSince1970: 1_700_000_120)
let generatedAt = Date(timeIntervalSince1970: 1_700_000_240)
let section = SongSection(
id: "verse",
type: .verse,
title: "Verse",
order: 0,
startTime: 8,
endTime: 38,
lyrics: "Draft verse",
emotion: "reflective",
energy: 4,
vocalDirection: "close vocal",
productionDirection: "subtle pad"
)
let instrument = InstrumentTrack(
instrumentId: "oud",
selected: true,
variant: "Arabic oud",
playingStyle: "picked",
role: "lead motif",
autoArrangementEnabled: false,
placements: [
InstrumentPlacement(
sectionId: section.id,
startTime: section.startTime,
endTime: section.endTime,
direction: "answer the vocal"
)
]
)
var project = SongProject(
id: "serialized-project",
title: "Serializable Song",
idea: "A multilingual cinematic song",
duration: SongDuration(seconds: 210, description: "about three and a half minutes"),
conversationMode: .discuss,
languages: [LanguageSelection(id: "arabic", name: "Arabic")],
dialects: [DialectSelection(id: "levantine", name: "Levantine", languageID: "arabic")],
arabicPronunciationSettings: ArabicPronunciationSettings(
isEnabled: true,
diacritizationPolicy: .pronunciationTargeted,
tanweenPolicy: .preserveUserInput,
userNotes: "Keep dialect phrasing natural",
preservedSpellings: ["habibi"]
),
genres: [GenreStyle(id: "cinematic-pop", name: "Cinematic Pop", aliases: ["orchestral pop"])],
moods: [MoodTag(id: "yearning", name: "Yearning")],
emotionalArc: [
EmotionalArcPoint(
id: "arc-verse",
sectionID: section.id,
emotion: "reflective",
energy: 4,
direction: "slowly opening"
)
],
bpm: ManualAutoValue(mode: .manual, value: 92),
key: ManualAutoValue(mode: .manual, value: "D"),
scale: ManualAutoValue(mode: .auto),
maqam: ManualAutoValue(mode: .manual, value: "Hijaz"),
sections: [section],
instruments: [instrument],
vocalists: [
Vocalist(
id: "lead",
label: "Lead",
voiceType: "tenor",
genderSelection: "male",
performanceStyle: "sung",
assignedSections: [section.id]
)
],
lyrics: Lyrics(text: "Full lyric draft", sectionTexts: [section.id: "Draft verse"]),
productionDirections: [
ProductionDirection(id: "prod-verse", sectionID: section.id, text: "keep percussion sparse")
],
sunoOutput: SunoOutput(
lyricsText: "Full lyric draft",
stylePrompt: "Cinematic pop with oud",
additionalFields: ["style": "cinematic"],
generatedAt: generatedAt
),
createdAt: createdAt,
updatedAt: updatedAt
)
project.versions = [
SongVersion(
id: "version-1",
label: "Initial approved direction",
snapshot: SongProjectSnapshot(project: project),
createdAt: updatedAt
)
]
let encoder = JSONEncoder()
let data = try encoder.encode(project)
let decodedProject = try JSONDecoder().decode(SongProject.self, from: data)
XCTAssertEqual(decodedProject, project)
}
} }
@@ -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)
}
}
@@ -0,0 +1,173 @@
import MusicAssistantCore
import XCTest
final class StructuredAIResponseSchemaTests: XCTestCase {
func testStructuredSongProjectUpdateResponseDecodesFromJSON() throws {
let json = """
{
"project": {
"title": "Updated Song",
"idea": "A brighter chorus",
"conversationMode": "auto",
"languages": [{"id": "english", "name": "English"}],
"dialects": [],
"arabicPronunciationSettings": {
"isEnabled": false,
"diacritizationPolicy": "unspecified",
"tanweenPolicy": "unspecified",
"preservedSpellings": []
},
"genres": [{"id": "pop", "name": "Pop", "aliases": []}],
"moods": [{"id": "hopeful", "name": "Hopeful"}],
"emotionalArc": [],
"sections": [
{
"id": "chorus",
"type": "chorus",
"title": "Chorus",
"order": 0,
"lyrics": "Lift the hook higher"
}
],
"instruments": [
{
"instrumentId": "piano",
"selected": true,
"autoArrangementEnabled": true,
"placements": []
}
],
"vocalists": [],
"lyrics": {
"text": "Lift the hook higher",
"sectionTexts": {"chorus": "Lift the hook higher"}
},
"productionDirections": [],
"sunoOutput": {
"lyricsText": "Lift the hook higher",
"stylePrompt": "Hopeful pop with piano",
"additionalFields": {},
"generatedAt": "2026-09-13T18:00:00Z"
}
},
"changedScopes": ["lyrics", "structure", "sunoOutput"],
"notes": ["Updated requested creative fields."],
"followUpQuestions": ["Should the final chorus repeat?"]
}
""".data(using: .utf8)!
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let response = try decoder.decode(StructuredSongProjectUpdateResponse.self, from: json)
XCTAssertEqual(StructuredSongProjectUpdateResponse.schemaName, "song_project_update_response")
XCTAssertEqual(response.project.title, "Updated Song")
XCTAssertEqual(response.project.sections.first?.type, .chorus)
XCTAssertEqual(response.project.instruments.first?.instrumentId, "piano")
XCTAssertEqual(response.changedScopes, [.lyrics, .structure, .sunoOutput])
XCTAssertEqual(response.notes, ["Updated requested creative fields."])
XCTAssertEqual(response.followUpQuestions, ["Should the final chorus repeat?"])
}
func testStructuredSongProjectUpdateResponseCreatesUpdateResultWhilePreservingProjectIdentityAndVersions() {
let createdAt = Date(timeIntervalSince1970: 100)
let updatedAt = Date(timeIntervalSince1970: 200)
var existingProject = SongProject(
id: "project-id",
title: "Original",
idea: "Original idea",
createdAt: createdAt,
updatedAt: createdAt
)
let version = existingProject.createVersion(
id: "version-id",
label: "Before AI update",
createdAt: createdAt
)
let response = StructuredSongProjectUpdateResponse(
project: SongProjectSnapshot(
title: "Updated",
idea: "Updated idea",
genres: [GenreStyle(id: "cinematic", name: "Cinematic")],
lyrics: Lyrics(text: "Updated lyrics")
),
changedScopes: [.genres, .lyrics],
notes: ["Proposed focused update."],
followUpQuestions: ["Keep it minimal?"]
)
let result = response.makeUpdateResult(
updating: existingProject,
updatedAt: updatedAt
)
XCTAssertEqual(result.project.id, "project-id")
XCTAssertEqual(result.project.createdAt, createdAt)
XCTAssertEqual(result.project.updatedAt, updatedAt)
XCTAssertEqual(result.project.versions, [version])
XCTAssertEqual(result.project.title, "Original")
XCTAssertEqual(result.project.lyrics.text, "Updated lyrics")
XCTAssertEqual(result.changedScopes, [.genres, .lyrics])
XCTAssertEqual(result.notes, ["Proposed focused update."])
XCTAssertEqual(result.followUpQuestions, ["Keep it minimal?"])
}
func testStructuredResponsePreservesLockedScopesAndManualValues() {
let section = SongSection(id: "verse", type: .verse, title: "Manual Verse")
let manualInstrument = InstrumentTrack(
instrumentId: "oud",
selected: true,
role: "Manual lead",
autoArrangementEnabled: false,
placements: [InstrumentPlacement(sectionId: "verse", direction: "Manual entry")]
)
let existingProject = SongProject(
title: "Original Title",
idea: "Original Idea",
genres: [GenreStyle(id: "arabic-pop", name: "Arabic Pop")],
bpm: ManualAutoValue(mode: .manual, value: 96),
key: ManualAutoValue(mode: .auto),
structureMode: .manual,
sections: [section],
instruments: [manualInstrument],
lyrics: Lyrics(text: "Locked lyrics"),
userLocks: SongProjectUserLocks(scopes: [.genres, .lyrics])
)
let response = StructuredSongProjectUpdateResponse(
project: SongProjectSnapshot(
title: "AI Title",
idea: "AI Idea",
genres: [GenreStyle(id: "rock", name: "Rock")],
bpm: ManualAutoValue(mode: .auto, value: 120),
key: ManualAutoValue(mode: .auto, value: "A"),
sections: [SongSection(id: "chorus", type: .chorus, title: "AI Chorus")],
instruments: [
InstrumentTrack(
instrumentId: "oud",
selected: true,
role: "AI lead",
placements: [InstrumentPlacement(sectionId: "chorus", direction: "AI entry")]
)
],
lyrics: Lyrics(text: "AI lyrics")
),
changedScopes: [.genres, .lyrics, .instruments, .musicalParameters, .structure, .arrangement]
)
let result = response.makeUpdateResult(
updating: existingProject,
updatedAt: Date(timeIntervalSince1970: 500)
)
XCTAssertEqual(result.project.title, "Original Title")
XCTAssertEqual(result.project.idea, "Original Idea")
XCTAssertEqual(result.project.genres, existingProject.genres)
XCTAssertEqual(result.project.lyrics, existingProject.lyrics)
XCTAssertEqual(result.project.bpm, existingProject.bpm)
XCTAssertEqual(result.project.key, ManualAutoValue(mode: .auto, value: "A"))
XCTAssertEqual(result.project.sections, existingProject.sections)
XCTAssertEqual(result.project.instruments, existingProject.instruments)
XCTAssertEqual(result.project.userLocks, existingProject.userLocks)
XCTAssertEqual(result.changedScopes, [.musicalParameters])
}
}
@@ -0,0 +1,19 @@
import MusicAssistantCore
import XCTest
final class SunoOutputValidationTests: XCTestCase {
func testValidatorRejectsOutputWithoutSunoContent() {
let output = CompiledSunoOutput(lyricsText: " \n ", stylePrompt: "\t")
XCTAssertThrowsError(try CompiledSunoOutputValidator().validate(output)) { error in
XCTAssertEqual(error as? SunoOutputValidationError, .missingContent)
}
}
func testValidatorAllowsLyricsOrAStylePrompt() throws {
let validator = CompiledSunoOutputValidator()
XCTAssertNoThrow(try validator.validate(CompiledSunoOutput(lyricsText: "Lyrics", stylePrompt: "")))
XCTAssertNoThrow(try validator.validate(CompiledSunoOutput(lyricsText: "", stylePrompt: "Cinematic folk")))
}
}
+59 -7
View File
@@ -24,15 +24,17 @@ macOS App
├── Application Services ├── Application Services
│ ├── AI Director │ ├── AI Director
│ ├── Prompt Compiler │ ├── Prompt Compiler
│ ├── Genre & Style Catalog
│ ├── Instrument Catalog │ ├── Instrument Catalog
│ ├── Vocalist Catalog
│ ├── Language Catalog
│ ├── Arabic Pronunciation Processor │ ├── Arabic Pronunciation Processor
│ ├── Project Versioning │ ├── Project Versioning
│ └── Validation │ └── Validation
├── Integrations ├── Integrations
│ ├── OpenAI │ └── OpenAI
│ └── Suno Field-Fill Integration
└── Persistence └── Persistence
└── Local project/version storage └── Local project/version storage and deletion
``` ```
## AI Director ## AI Director
@@ -40,11 +42,40 @@ macOS App
OpenAI receives the user's intent plus private application rules and OpenAI receives the user's intent plus private application rules and
structured project state. AI output should be requested as structured structured project state. AI output should be requested as structured
data wherever possible, not treated as an unstructured chat transcript. 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 ## Prompt Compiler
A deterministic layer converts the approved SongProject into the final A deterministic layer converts the approved SongProject into the final
Suno-facing lyrics/style content. User choices override AI suggestions. 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 ## Security
@@ -54,11 +85,10 @@ Suno-facing lyrics/style content. User choices override AI suggestions.
and providers can be changed later. and providers can be changed later.
- Do not log secrets or full authorization headers. - Do not log secrets or full authorization headers.
## Suno Boundary ## External Generation Boundary
The initial requirement is browser/site handoff that fills fields but The application prepares final lyrics and style prompts but does not
does not press Generate. Treat this integration as replaceable because open, embed or control an external music-generation service.
website UI/behavior can change.
## Instrument Selector Boundary ## Instrument Selector Boundary
@@ -83,3 +113,25 @@ the Suno Style Prompt.
The current architecture does not include instrument images, audio The current architecture does not include instrument images, audio
previews, sound samples, playback, Freesound, an Instrument API or previews, sound samples, playback, Freesound, an Instrument API or
external API calls for instrument data. external API calls for instrument data.
## Genre & Style Selector Boundary
Genre and style catalog data is structured local data, separate from
SwiftUI views. The Presentation layer may search, filter and toggle
selections, while the catalog service owns stable ids, categories,
optional regional metadata and search aliases. Selections are stored as
existing `GenreStyle` values on the SongProject.
## Vocalist Selector Boundary
Vocalist presets are local structured data, separate from SwiftUI views.
The catalog service supplies stable ids and preset metadata, while the
Presentation layer searches and toggles the corresponding `Vocalist`
values on the SongProject.
## Language Selector Boundary
Language catalog data is local structured data, separate from SwiftUI
views. The catalog service owns stable ids, regional categories and
search terms; the Presentation layer toggles the corresponding
`LanguageSelection` values on the SongProject.
+49
View File
@@ -20,11 +20,14 @@ SongProject
- key - key
- scale - scale
- maqam - maqam
- structureMode: manual | auto
- sections[] - sections[]
- instruments[] - instruments[]
- vocalists[] - vocalists[]
- lyrics - lyrics
- productionDirections[] - productionDirections[]
- productionMode: manual | auto
- userLocks[]
- sunoOutput - sunoOutput
- versions[] - versions[]
- createdAt - createdAt
@@ -38,6 +41,32 @@ InstrumentCatalogItem
- aliases[] - aliases[]
- searchTerms[] - 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 SongSection
- id - id
- type (intro, verse, preChorus, chorus, bridge, outro, custom) - type (intro, verse, preChorus, chorus, bridge, outro, custom)
@@ -59,6 +88,26 @@ InstrumentTrack
- autoArrangementEnabled - autoArrangementEnabled
- placements[] - 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 InstrumentPlacement
- sectionId (optional) - sectionId (optional)
- startTime (optional) - startTime (optional)
+6 -6
View File
@@ -7,17 +7,17 @@ finalized.
- Exact Swift/SwiftUI architecture conventions for the repository. - Exact Swift/SwiftUI architecture conventions for the repository.
- OpenAI model(s) and API endpoint strategy. - OpenAI model(s) and API endpoint strategy.
- Exact private AI rules/system prompt. - Exact private AI rules/system prompt.
- Complete genre/style taxonomy and whether it is curated locally or - Resolved: genres/styles use a large curated local catalog with
AI-assisted. category/region browsing, search aliases and checkbox multi-select.
- Complete worldwide instrument catalog taxonomy, family/category - Complete worldwide instrument catalog taxonomy, family/category
groups, region/origin coverage, aliases/search terms and maintenance groups, region/origin coverage, aliases/search terms and maintenance
strategy. 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 - Arabic diacritization policy: full tashkeel vs
pronunciation-targeted tashkeel. pronunciation-targeted tashkeel.
- Exact Suno fields to populate and supported handoff mechanism. - External music-generation handoff is not in the current scope.
- Whether Suno integration is permitted/reliable under the intended
account/workflow and current terms.
- Project storage technology and whether cloud sync is required later. - Project storage technology and whether cloud sync is required later.
- Whether users supply their own OpenAI/API credentials or the product - Whether users supply their own OpenAI/API credentials or the product
owner supplies service credentials. owner supplies service credentials.
+34 -10
View File
@@ -4,8 +4,7 @@
A native macOS application that helps users design a complete song A native macOS application that helps users design a complete song
concept through a natural chat interface, using OpenAI as the planning concept through a natural chat interface, using OpenAI as the planning
and writing engine, then prepares the final lyrics and style and writing engine, then prepares final lyrics and style instructions.
instructions for Suno.
## Core Flow ## Core Flow
@@ -18,13 +17,12 @@ instructions for Suno.
decisions. decisions.
4. User reviews and manually edits every generated component. 4. User reviews and manually edits every generated component.
5. Project versions are saved. 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 ## Song Controls
- Multiple genres/styles can be blended. - 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 - Instruments can be selected from a large worldwide instrument
catalog. catalog.
- Instruments can be searched by name, browsed by family/category and - 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. - Song structure supports Manual and Auto modes.
- BPM, key/scale and maqam support Manual and Auto modes. - BPM, key/scale and maqam support Manual and Auto modes.
- Multiple vocalists/voices can exist in one song. - Multiple vocalists/voices can exist in one song.
- Vocalists can be selected from a local catalog of voice roles and
presets using checkbox multi-select.
- User controls vocal gender/type, rap/singing mode, delivery and - User controls vocal gender/type, rap/singing mode, delivery and
section-specific performance. section-specific performance.
- Multiple languages and dialects can be used in one song. - Multiple languages and dialects can be used in one song.
- 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 - Arabic receives dedicated diacritics/harakat/tanween handling for
pronunciation. pronunciation.
- Energy and emotional progression can change throughout the song. - 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 Selected instruments must be available as part of the Song Project and
later available to the OpenAI/song-generation layer so they can later available to the OpenAI/song-generation layer so they can
influence arrangement, instrument roles, entry/exit timing, song 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, Instrument images, audio previews, sound samples, audio playback,
Freesound, an Instrument API and external API calls for instrument data Freesound, an Instrument API and external API calls for instrument data
are not part of the current product requirement. 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 ## Project Management
Every song is stored as a project. Important changes can create versions Every song is stored as a project. Important changes can create versions
so previous states remain recoverable. so previous states remain recoverable. A project can be deleted after
explicit user confirmation.
## AI Rules ## AI Rules
@@ -91,6 +116,5 @@ Normal users cannot view or modify these rules in the initial version.
## Initial Boundary ## Initial Boundary
Suno remains responsible for music generation and playback. The macOS The macOS app prepares and stores the final lyrics and style prompt only.
app prepares the project and fills Suno fields only. Generated songs do It does not open, embed or control an external music-generation website.
not need to return to the app in the initial version.
+66 -64
View File
@@ -25,111 +25,113 @@ requirement is missing and blocks implementation, record it in
- [x] Model song sections with order and optional timestamps. - [x] Model song sections with order and optional timestamps.
- [x] Model instruments, variants, roles and placements. - [x] Model instruments, variants, roles and placements.
- [x] Model multiple vocalists and section assignments. - [x] Model multiple vocalists and section assignments.
- [ ] Model language/dialect and Arabic pronunciation settings. - [x] Model language/dialect and Arabic pronunciation settings.
- [ ] Model BPM, key, scale and maqam with Manual/Auto state. - [x] Model BPM, key, scale and maqam with Manual/Auto state.
- [ ] Model emotional arc and production directions. - [x] Model emotional arc and production directions.
- [ ] Add serialization tests. - [x] Add serialization tests.
## Phase 2 --- Project Persistence & Versions ## Phase 2 --- Project Persistence & Versions
- [ ] Create new/open/save song projects locally. - [x] Create new/open/save song projects locally.
- [ ] Implement project list. - [x] Implement project list.
- [ ] Implement immutable or snapshot-based version history. - [x] Implement immutable or snapshot-based version history.
- [ ] Restore a previous version without destroying later versions. - [x] Restore a previous version without destroying later versions.
- [ ] Add autosave strategy that does not create excessive versions. - [x] Add autosave strategy that does not create excessive versions.
## Phase 3 --- Core macOS UI ## Phase 3 --- Core macOS UI
- [ ] Build project browser. - [x] Build project browser.
- [ ] Build natural chat workspace as the primary entry point. - [x] Build natural chat workspace as the primary entry point.
- [ ] Build editable project inspector for duration, genres, - [x] Build editable project inspector for duration, genres,
instruments, vocals and language. instruments, vocals and language.
- [ ] Build song structure editor. - [x] Build song structure editor.
- [ ] Build arrangement editor supporting section-based and - [x] Build arrangement editor supporting section-based and
timestamp-based placement. timestamp-based placement.
- [ ] Add Manual/Auto toggles for supported controls. - [x] Add Manual/Auto toggles for supported controls.
- [ ] Build final review screen where every generated field can be - [x] Build final review screen where every generated field can be
edited. edited.
## Phase 4 --- OpenAI Integration ## Phase 4 --- OpenAI Integration
- [ ] Create provider-independent `AIService` interface. - [x] Create provider-independent `AIService` interface.
- [ ] Implement OpenAI client. - [x] Implement OpenAI client.
- [ ] Define private application-rule injection mechanism. - [x] Define private application-rule injection mechanism.
- [ ] Define structured AI response schema for SongProject updates. - [x] Define structured AI response schema for SongProject updates.
- [ ] Implement idea → complete project generation. - [x] Implement idea → complete project generation.
- [ ] Implement existing lyrics → correction/improvement flow. - [x] Implement existing lyrics → correction/improvement flow.
- [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam - [x] Implement Auto mode for structure, arrangement, BPM/key/maqam
and production decisions. and production decisions.
- [ ] Implement optional Discuss mode. - [x] Implement optional Discuss mode.
- [ ] Enforce user-lock/manual-value precedence over AI output. - [x] Enforce user-lock/manual-value precedence over AI output.
- [ ] Add error, retry, cancellation and rate-limit handling. - [x] Add error, retry, cancellation and rate-limit handling.
## Phase 5 --- Arabic Lyrics Processing ## Phase 5 --- Arabic Lyrics Processing
- [ ] Add Arabic-specific settings UI. - [x] Add Arabic-specific settings UI.
- [ ] Support diacritics/harakat/tanween processing. - [x] Support diacritics/harakat/tanween processing.
- [ ] Preserve intentional spelling/dialect choices where possible. - [x] Preserve intentional spelling/dialect choices where possible.
- [ ] Allow user to compare/edit processed Arabic before Suno handoff. - [x] Allow user to compare/edit processed Arabic before final review.
- [ ] Add Arabic test fixtures covering multiple dialects. - [x] Add Arabic test fixtures covering multiple dialects.
## Phase 6 --- Instrument Selector & Catalog ## 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. 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 Eastern, African, South Asian, East Asian, Southeast Asian, Latin
American, traditional, folk, orchestral, electronic and modern American, traditional, folk, orchestral, electronic and modern
instruments. instruments.
- [ ] Keep catalog data out of SwiftUI views. - [x] Keep catalog data out of SwiftUI views.
- [ ] Build searchable instrument browser. - [x] Build searchable instrument browser.
- [ ] Add browsing/filtering by family/category. - [x] Add browsing/filtering by family/category.
- [ ] Add browsing/filtering by region/origin where useful. - [x] Add browsing/filtering by region/origin where useful.
- [ ] Add checkbox-based multi-select and deselect behavior. - [x] Add checkbox-based multi-select and deselect behavior.
- [ ] Persist selected instruments on the current Song Project. - [x] Persist selected instruments on the current Song Project.
- [ ] Make selected instruments available to OpenAI/song-generation - [x] Make selected instruments available to OpenAI/song-generation
logic for arrangement, roles, entry/exit timing, relevant structure logic for arrangement, roles, entry/exit timing, relevant structure
decisions and Suno Style Prompt generation. decisions and Suno Style Prompt generation.
- [ ] Preserve existing Manual/Auto arrangement behavior. - [x] Preserve existing Manual/Auto arrangement behavior.
- [ ] Add tests for catalog search, category/region filtering, - [x] Add tests for catalog search, category/region filtering,
selection persistence and SongProject serialization. selection persistence and SongProject serialization.
## Phase 7 --- Prompt Compiler ## Phase 7 --- Prompt Compiler
- [ ] Create deterministic compiler from approved SongProject → Suno - [x] Create deterministic compiler from approved SongProject → Suno
output. output.
- [ ] Generate lyrics text with section/performance directives where - [x] Generate lyrics text with section/performance directives where
appropriate. appropriate.
- [ ] Generate style prompt from genre blend, instrumentation, vocals, - [x] Generate style prompt from genre blend, instrumentation, vocals,
tempo, harmony, emotion and production instructions. tempo, harmony, emotion and production instructions.
- [ ] Validate output before handoff. - [x] Validate output before handoff.
- [ ] Keep compiler independent from UI and OpenAI client. - [x] Keep compiler independent from UI and OpenAI client.
## Phase 8 --- Suno Handoff ## Removed --- External Generation Handoff
- [ ] Implement explicit `Send to Suno` action. - [x] Removed opening, embedding and controlling external
- [ ] Open the appropriate Suno creation surface. music-generation websites.
- [ ] 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.
## Phase 9 --- Quality ## Phase 9 --- Quality
- [ ] Add validation for contradictory/invalid project settings. - [x] Add validation for contradictory/invalid project settings.
- [ ] Add loading, offline and provider-error states. - [x] Add loading, offline and provider-error states.
- [ ] Add accessibility labels and keyboard navigation. - [x] Add accessibility labels and keyboard navigation.
- [ ] Test project/version recovery. - [x] Test project/version recovery.
- [ ] Test AI output against locked user choices. - [x] Test AI output against locked user choices.
- [ ] Test selected instruments influence compiled song-generation - [x] Test selected instruments influence compiled song-generation
context. context.
- [ ] Test Suno handoff without generation.
## Definition of MVP Done ## Definition of MVP Done
A user can create a macOS song project through chat, manually or A user can create a macOS song project through chat, manually or
automatically configure the agreed song parameters, generate/edit lyrics automatically configure the agreed song parameters, generate/edit lyrics
and song planning through OpenAI, select instruments, review and song planning through OpenAI, select instruments, review
the complete project, save versions, and send the approved lyrics/style the complete project, save versions, and retain the approved final
data to Suno where the app fills fields without initiating generation. 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.
+156
View File
@@ -0,0 +1,156 @@
#!/bin/zsh
# Build, package, install, and launch the AI Music Studio macOS application.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
APP_DISPLAY_NAME="${APP_DISPLAY_NAME:-AI Music Studio}"
EXECUTABLE_NAME="${EXECUTABLE_NAME:-music-assistant}"
BUNDLE_IDENTIFIER="${BUNDLE_IDENTIFIER:-com.musicassistant.app}"
APP_BUNDLE_NAME="${APP_DISPLAY_NAME}.app"
BUILD_DIR="${PROJECT_ROOT}/.build/app"
APP_BUNDLE="${BUILD_DIR}/${APP_BUNDLE_NAME}"
INSTALL_DIR="${INSTALL_DIR:-/Applications}"
INSTALL_PATH="${INSTALL_DIR}/${APP_BUNDLE_NAME}"
CONTENTS_DIR="${APP_BUNDLE}/Contents"
MACOS_DIR="${CONTENTS_DIR}/MacOS"
RESOURCES_DIR="${CONTENTS_DIR}/Resources"
fail() {
print -u2 -- "Error: $1"
exit 1
}
step() {
print -- "==> $1"
}
find_app_icon() {
if [[ -n "${APP_ICON:-}" ]]; then
[[ -f "${APP_ICON}" ]] || fail "App icon was not found at ${APP_ICON}."
print -- "${APP_ICON}"
return
fi
local candidate
for candidate in \
"${PROJECT_ROOT}/Resources/AI Music Studio.icns" \
"${PROJECT_ROOT}/Resources/AppIcon.icns" \
"${PROJECT_ROOT}/Resources/MusicAssistant.icns"; do
if [[ -f "${candidate}" ]]; then
print -- "${candidate}"
return
fi
done
}
[[ -f "${PROJECT_ROOT}/Package.swift" ]] || fail "Package.swift was not found in ${PROJECT_ROOT}."
command -v swift >/dev/null 2>&1 || fail "Swift is required. Install Xcode or the Xcode Command Line Tools."
command -v codesign >/dev/null 2>&1 || fail "codesign is required and should be available with Xcode."
command -v ditto >/dev/null 2>&1 || fail "ditto is required and should be available on macOS."
command -v open >/dev/null 2>&1 || fail "open is required and should be available on macOS."
cd "${PROJECT_ROOT}"
step "Stopping any running ${APP_DISPLAY_NAME} application"
if pgrep -x "${EXECUTABLE_NAME}" >/dev/null 2>&1; then
pkill -x "${EXECUTABLE_NAME}" || fail "Could not stop the running ${APP_DISPLAY_NAME} application."
for _ in {1..20}; do
pgrep -x "${EXECUTABLE_NAME}" >/dev/null 2>&1 || break
sleep 0.1
done
pgrep -x "${EXECUTABLE_NAME}" >/dev/null 2>&1 && fail "${APP_DISPLAY_NAME} is still running. Quit it and retry."
fi
step "Removing stale installed copy"
if [[ -e "${INSTALL_PATH}" ]]; then
rm -rf "${INSTALL_PATH}" || fail "Could not remove ${INSTALL_PATH}. Check permissions and retry."
fi
step "Cleaning local Swift build artifacts"
swift package clean || fail "Swift Package Manager could not clean the local build artifacts."
step "Building ${EXECUTABLE_NAME} in release mode"
BIN_PATH="$(swift build -c release --show-bin-path 2>/dev/null | tail -n1)"
SOURCE_EXECUTABLE="${BIN_PATH}/${EXECUTABLE_NAME}"
built=0
for attempt in 1 2 3; do
swift build -c release || fail "Release build failed. Review the Swift compiler diagnostics above."
if [[ -x "${SOURCE_EXECUTABLE}" ]]; then
built=1
break
fi
print -- "==> Executable not present after build (attempt ${attempt}); retrying"
sleep 1
done
[[ "${built}" -eq 1 ]] || fail "Built executable was not found at ${SOURCE_EXECUTABLE}."
step "Packaging ${APP_BUNDLE_NAME}"
rm -rf "${APP_BUNDLE}"
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
cp "${SOURCE_EXECUTABLE}" "${MACOS_DIR}/${EXECUTABLE_NAME}"
ICON_PATH="$(find_app_icon)"
PLIST_ICON_ENTRY=""
if [[ -n "${ICON_PATH}" ]]; then
ICON_FILE_NAME="$(basename "${ICON_PATH}")"
ICON_FILE_BASE="${ICON_FILE_NAME:r}"
cp "${ICON_PATH}" "${RESOURCES_DIR}/${ICON_FILE_NAME}"
PLIST_ICON_ENTRY=$' <key>CFBundleIconFile</key>\n <string>'"${ICON_FILE_BASE}"$'</string>'
else
step "No app icon found; packaging without a custom icon"
fi
cat > "${CONTENTS_DIR}/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>${APP_DISPLAY_NAME}</string>
<key>CFBundleExecutable</key>
<string>${EXECUTABLE_NAME}</string>
<key>CFBundleIdentifier</key>
<string>${BUNDLE_IDENTIFIER}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
${PLIST_ICON_ENTRY}
<key>CFBundleName</key>
<string>${APP_DISPLAY_NAME}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>
PLIST
plutil -lint "${CONTENTS_DIR}/Info.plist" >/dev/null || fail "Generated Info.plist is invalid."
codesign --force --sign - --timestamp=none "${APP_BUNDLE}" || fail "Ad-hoc signing of ${APP_BUNDLE_NAME} failed."
codesign --verify --deep --strict "${APP_BUNDLE}" || fail "Packaged ${APP_BUNDLE_NAME} failed code-signature verification."
[[ -x "${MACOS_DIR}/${EXECUTABLE_NAME}" ]] || fail "Packaged executable is missing."
step "Installing ${APP_BUNDLE_NAME} in ${INSTALL_DIR}"
ditto "${APP_BUNDLE}" "${INSTALL_PATH}" || fail "Could not install ${APP_BUNDLE_NAME} in ${INSTALL_DIR}. Check permissions and retry."
INSTALLED_EXECUTABLE="${INSTALL_PATH}/Contents/MacOS/${EXECUTABLE_NAME}"
[[ -x "${INSTALLED_EXECUTABLE}" ]] || fail "Installed executable is missing at ${INSTALLED_EXECUTABLE}."
SOURCE_HASH="$(shasum -a 256 "${MACOS_DIR}/${EXECUTABLE_NAME}" | awk '{print $1}')"
INSTALLED_HASH="$(shasum -a 256 "${INSTALLED_EXECUTABLE}" | awk '{print $1}')"
[[ "${SOURCE_HASH}" == "${INSTALLED_HASH}" ]] || fail "Installed executable does not match the newly built executable."
step "Launching ${APP_DISPLAY_NAME}"
open -n "${INSTALL_PATH}" || fail "${APP_DISPLAY_NAME} was installed but could not be opened."
print -- "==> ${APP_DISPLAY_NAME} was rebuilt, installed, and launched successfully."