Initial commit

This commit is contained in:
diyaa
2026-09-13 20:21:10 +02:00
commit 18d7d56345
42 changed files with 5459 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Local development only. Do not commit real values.
MUSIC_ASSISTANT_OPENAI_API_KEY=
+7
View File
@@ -0,0 +1,7 @@
.build/
.swiftpm/
DerivedData/
.DS_Store
.env
*.xcuserdata
*.xcuserstate
+68
View File
@@ -0,0 +1,68 @@
# Agent Guide
This file is the permanent operating guide for any coding agent working on this
project.
## Project Scope
- This project is a native macOS application.
- The minimum deployment target is macOS 14.0.
- Use Swift and SwiftUI according to the architecture documented for this
project.
- Do not invent undocumented features or requirements.
- Any substantial change to the architecture or product scope must be documented
before implementation.
## Required Reading
Before making significant changes, read:
- `docs/PRODUCT.md`
- `docs/ARCHITECTURE.md`
- `docs/DATA_MODEL.md`
- `docs/TASKS.md`
- `docs/OPEN_QUESTIONS.md`
Use these documents as the project references:
- `docs/PRODUCT.md` is the source of truth for product requirements.
- `docs/ARCHITECTURE.md` is the reference for architecture decisions.
- `docs/DATA_MODEL.md` is the reference for data models.
- `docs/TASKS.md` is the reference for implementation phases and tasks.
- `docs/OPEN_QUESTIONS.md` contains decisions that are not resolved yet.
## Decision Rules
- If documents conflict, do not guess. State the conflict before making a
decision.
- Do not decide anything listed in `docs/OPEN_QUESTIONS.md` on your own.
- Do not automatically move to a new phase in `docs/TASKS.md` unless explicitly
asked.
- Update `docs/TASKS.md` when tasks are completed.
- Preserve version history and the Song Project concept as documented.
## Code Standards
- Write organized code that is maintainable and extensible.
- Keep clear separation between UI, Models, Services, Persistence, and
Integrations.
- Keep OpenAI, instrument catalog/selector, and Suno integrations separated from
UI logic as much as possible.
- In the current scope, Suno handoff fills the fields only and does not press
Generate.
- Do not modify files outside the task scope without a clear reason.
- Do not perform a broad refactor while implementing a small task unless it is
explicitly required.
## Secrets And Configuration
- Do not place API keys, tokens, or secrets in source code or Git.
- Read secrets only from secure environment or configuration mechanisms.
- Do not log secrets.
## Verification
- Run a build after code changes.
- Run relevant tests when they are available.
- Do not consider a task complete if the project fails to build because of new
changes.
+23
View File
@@ -0,0 +1,23 @@
# Development Setup
## Secrets
Runtime secrets are read from the local process environment.
Copy `.env.example` to `.env` for local notes if needed, but do not commit
real credentials. The application must not hard-code API keys or authorization
headers in source files.
Current environment keys:
- `MUSIC_ASSISTANT_OPENAI_API_KEY`
Provider integrations are intentionally protocol-only in the repository
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.
+13
View File
@@ -0,0 +1,13 @@
.PHONY: build dev test install-app
build:
swift build
dev:
swift run music-assistant
test:
swift test
install-app:
scripts/build-install-launch.zsh
+37
View File
@@ -0,0 +1,37 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "music-assistant",
defaultLocalization: "en",
platforms: [
.macOS(.v14)
],
products: [
.executable(
name: "music-assistant",
targets: ["MusicAssistantApp"]
),
.library(
name: "MusicAssistantCore",
targets: ["MusicAssistantCore"]
)
],
targets: [
.executableTarget(
name: "MusicAssistantApp",
dependencies: ["MusicAssistantCore"],
path: "Sources/MusicAssistantApp"
),
.target(
name: "MusicAssistantCore",
path: "Sources/MusicAssistantCore"
),
.testTarget(
name: "MusicAssistantCoreTests",
dependencies: ["MusicAssistantCore"],
path: "Tests/MusicAssistantCoreTests"
)
]
)
@@ -0,0 +1,11 @@
import SwiftUI
@main
struct MusicAssistantApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
.windowStyle(.titleBar)
}
}
@@ -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,216 @@
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(.background)
.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(16)
}
.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: 14) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
Text(project.title)
.font(.title.weight(.semibold))
.lineLimit(1)
Text("Song Project")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 10) {
ProjectMetricLabel(title: "Versions", value: project.versions.count, systemImage: "clock.arrow.circlepath")
ProjectMetricLabel(title: "Genres", value: project.genres.count, systemImage: "music.quarternote.3")
ProjectMetricLabel(title: "Instruments", value: project.selectedInstrumentTracks.count, systemImage: "pianokeys")
ProjectMetricLabel(title: "Sections", value: project.sections.count, systemImage: "text.line.first.and.arrowtriangle.forward")
ProjectMetricLabel(title: "Vocalists", value: project.vocalists.count, systemImage: "person.wave.2")
}
}
.padding(.horizontal, 24)
.padding(.vertical, 18)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
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)
}
}
private struct ChatMessageBubble: View {
let message: ChatWorkspaceMessage
var body: some View {
HStack {
if message.role == .user {
Spacer(minLength: 64)
}
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 == .assistant {
Spacer(minLength: 64)
}
}
.frame(maxWidth: .infinity, alignment: message.role == .user ? .trailing : .leading)
}
}
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(.roundedBorder)
.lineLimit(1...5)
.focused(isFocused)
.onSubmit(sendAction)
Button(action: sendAction) {
Label("Send", systemImage: "paperplane.fill")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderedProminent)
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.help("Send")
}
}
}
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,190 @@
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) {
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("Suno fields")
.font(.headline)
Spacer()
Button {
ensureSunoOutput()
} label: {
Label("Prepare fields", systemImage: "wand.and.stars")
}
.disabled(project.sunoOutput != nil)
}
TextField("Suno lyrics", text: sunoLyricsBinding, axis: .vertical)
.lineLimit(4...10)
TextField("Suno 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 }
project.sunoOutput = SunoOutput(
lyricsText: project.lyrics.text,
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,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.mode = newMode
if newMode == .auto {
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.mode = newMode
if newMode == .auto {
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,236 @@
import Foundation
import MusicAssistantCore
import SwiftUI
struct ProjectBrowserView: View {
@StateObject private var viewModel: ProjectBrowserViewModel
@State private var selectedProjectID: String?
init(store: any ProjectStore = LocalSongProjectStore(directoryURL: ProjectBrowserStorage.projectsDirectoryURL)) {
_viewModel = StateObject(wrappedValue: ProjectBrowserViewModel(store: store))
}
var body: some View {
NavigationSplitView {
List(selection: $selectedProjectID) {
Section("Projects") {
ForEach(viewModel.projects) { project in
ProjectListRow(project: project)
.tag(project.id)
}
}
}
.navigationTitle("AI Music Studio")
.overlay {
if viewModel.isLoading {
ProgressView()
} else if viewModel.projects.isEmpty {
ContentUnavailableView(
"No Projects",
systemImage: "music.note.list",
description: Text("Create a song project to begin.")
)
}
}
.toolbar {
ToolbarItem {
Button {
Task {
if let projectID = await viewModel.createProject() {
selectedProjectID = projectID
}
}
} label: {
Label("New Project", systemImage: "plus")
}
.disabled(viewModel.isCreatingProject)
}
}
} 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)
}
}
}
}
private struct ProjectListRow: View {
let project: SongProjectListItem
var body: some View {
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)
}
.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 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 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,333 @@
import MusicAssistantCore
import SwiftUI
struct ProjectInspectorView: View {
@Binding var project: SongProject
let saveProject: (SongProject) async -> Bool
@State private var newGenreName = ""
@State private var newInstrumentName = ""
@State private var newVocalistLabel = ""
@State private var newLanguageName = ""
@State private var isSaving = false
@State private var saveStatus: SaveStatus?
var body: some View {
VStack(spacing: 0) {
HStack {
Label("Inspector", systemImage: "slider.horizontal.3")
.font(.headline)
Spacer()
Button {
Task {
await save()
}
} label: {
Label("Save", systemImage: "tray.and.arrow.down")
}
.disabled(isSaving)
}
.padding(16)
Divider()
Form {
Section("Overview") {
TextField("Title", text: $project.title)
TextField("Idea", text: $project.idea, axis: .vertical)
.lineLimit(2...4)
TextField("Duration seconds", text: durationSecondsBinding)
TextField("Duration note", text: durationDescriptionBinding)
}
Section("Genres") {
EditableTokenList(
values: project.genres.map(\.name),
removeAction: { index in
project.deselectGenreStyle(id: project.genres[index].id)
}
)
AddTextRow(
placeholder: "Add genre",
text: $newGenreName,
addAction: addGenre
)
}
Section("Instruments") {
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") {
EditableTokenList(
values: project.vocalists.map(\.label),
removeAction: { index in
project.removeVocalist(id: project.vocalists[index].id)
}
)
AddTextRow(
placeholder: "Add vocalist",
text: $newVocalistLabel,
addAction: addVocalist
)
}
Section("Languages") {
EditableTokenList(
values: project.languages.map(\.name),
removeAction: { index in
project.removeLanguage(id: project.languages[index].id)
}
)
AddTextRow(
placeholder: "Add language",
text: $newLanguageName,
addAction: addLanguage
)
}
Section("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))
}
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 addGenre() {
let name = trimmed(newGenreName)
guard !name.isEmpty else { return }
project.selectGenreStyle(GenreStyle(id: stableID(from: name), name: name))
newGenreName = ""
}
private func addInstrument() {
let name = trimmed(newInstrumentName)
guard !name.isEmpty else { return }
project.selectInstrument(InstrumentTrack(instrumentId: stableID(from: name), variant: name))
newInstrumentName = ""
}
private func addVocalist() {
let label = trimmed(newVocalistLabel)
guard !label.isEmpty else { return }
project.upsertVocalist(Vocalist(id: stableID(from: label), label: label))
newVocalistLabel = ""
}
private func addLanguage() {
let name = trimmed(newLanguageName)
guard !name.isEmpty else { return }
project.upsertLanguage(LanguageSelection(id: stableID(from: name), name: name))
newLanguageName = ""
}
private func save() async {
isSaving = true
let didSave = await saveProject(project)
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 })
}
}
@@ -0,0 +1,11 @@
import SwiftUI
struct RootView: View {
var body: some View {
ProjectBrowserView()
}
}
#Preview {
RootView()
}
@@ -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,21 @@
import Foundation
public struct AppConfiguration: Equatable, Sendable {
public let openAIAPIKey: String?
public let privateApplicationRules: PrivateApplicationRules?
public init(
openAIAPIKey: String?,
privateApplicationRules: PrivateApplicationRules? = nil
) {
self.openAIAPIKey = openAIAPIKey
self.privateApplicationRules = privateApplicationRules
}
public static func development(environment: [String: String] = ProcessInfo.processInfo.environment) -> AppConfiguration {
AppConfiguration(
openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"],
privateApplicationRules: PrivateApplicationRules(content: environment["MUSIC_ASSISTANT_PRIVATE_AI_RULES"])
)
}
}
@@ -0,0 +1,11 @@
public struct ProductBoundarySummary: Equatable, Sendable {
public let description: String
public init(description: String) {
self.description = description
}
public static let initial = ProductBoundarySummary(
description: "Create and refine song project concepts, then prepare lyrics and style instructions for Suno handoff without starting generation."
)
}
@@ -0,0 +1,842 @@
import Foundation
public struct SongProject: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var title: String
public var idea: String
public var duration: SongDuration?
public var conversationMode: ConversationMode
public var languages: [LanguageSelection]
public var dialects: [DialectSelection]
public var arabicPronunciationSettings: ArabicPronunciationSettings
public var genres: [GenreStyle]
public var moods: [MoodTag]
public var emotionalArc: [EmotionalArcPoint]
public var bpm: ManualAutoValue<Int>?
public var key: ManualAutoValue<String>?
public var scale: ManualAutoValue<String>?
public var maqam: ManualAutoValue<String>?
public var sections: [SongSection]
public var instruments: [InstrumentTrack]
public var vocalists: [Vocalist]
public var lyrics: Lyrics
public var productionDirections: [ProductionDirection]
public var sunoOutput: SunoOutput?
public var versions: [SongVersion]
public let createdAt: Date
public var updatedAt: Date
public init(
id: String = UUID().uuidString,
title: String,
idea: String,
duration: SongDuration? = nil,
conversationMode: ConversationMode = .auto,
languages: [LanguageSelection] = [],
dialects: [DialectSelection] = [],
arabicPronunciationSettings: ArabicPronunciationSettings = ArabicPronunciationSettings(),
genres: [GenreStyle] = [],
moods: [MoodTag] = [],
emotionalArc: [EmotionalArcPoint] = [],
bpm: ManualAutoValue<Int>? = nil,
key: ManualAutoValue<String>? = nil,
scale: ManualAutoValue<String>? = nil,
maqam: ManualAutoValue<String>? = nil,
sections: [SongSection] = [],
instruments: [InstrumentTrack] = [],
vocalists: [Vocalist] = [],
lyrics: Lyrics = Lyrics(),
productionDirections: [ProductionDirection] = [],
sunoOutput: SunoOutput? = nil,
versions: [SongVersion] = [],
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.id = id
self.title = title
self.idea = idea
self.duration = duration
self.conversationMode = conversationMode
self.languages = languages
self.dialects = dialects
self.arabicPronunciationSettings = arabicPronunciationSettings
self.genres = genres
self.moods = moods
self.emotionalArc = emotionalArc
self.bpm = bpm
self.key = key
self.scale = scale
self.maqam = maqam
self.sections = sections
self.instruments = instruments
self.vocalists = vocalists
self.lyrics = lyrics
self.productionDirections = productionDirections
self.sunoOutput = sunoOutput
self.versions = versions
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
public extension SongProject {
var orderedSections: [SongSection] {
sections
.enumerated()
.sorted { left, right in
if left.element.order == right.element.order {
return left.offset < right.offset
}
return left.element.order < right.element.order
}
.map(\.element)
}
var selectedGenreStyleIDs: Set<String> {
Set(genres.map(\.id))
}
func isGenreStyleSelected(_ genreStyle: GenreStyle) -> Bool {
selectedGenreStyleIDs.contains(genreStyle.id)
}
mutating func selectGenreStyle(_ genreStyle: GenreStyle) {
guard !isGenreStyleSelected(genreStyle) else { return }
genres.append(genreStyle)
}
mutating func deselectGenreStyle(id: String) {
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 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] {
instruments.filter(\.selected)
}
var selectedInstrumentIDs: Set<String> {
Set(selectedInstrumentTracks.map(\.instrumentId))
}
func instrumentTrack(for instrumentId: String) -> InstrumentTrack? {
instruments.first { $0.instrumentId == instrumentId }
}
func isInstrumentSelected(id instrumentId: String) -> Bool {
instrumentTrack(for: instrumentId)?.selected == true
}
mutating func selectInstrument(_ instrument: InstrumentTrack) {
var selectedInstrument = instrument
selectedInstrument.selected = true
if let index = instruments.firstIndex(where: { $0.instrumentId == instrument.instrumentId }) {
instruments[index] = selectedInstrument
} else {
instruments.append(selectedInstrument)
}
}
mutating func deselectInstrument(id instrumentId: String) {
guard let index = instruments.firstIndex(where: { $0.instrumentId == instrumentId }) else { return }
instruments[index].selected = false
}
mutating func configureInstrument(
id instrumentId: String,
variant: String?,
playingStyle: String?,
role: String?,
autoArrangementEnabled: Bool
) {
guard let index = instruments.firstIndex(where: { $0.instrumentId == instrumentId }) else { return }
instruments[index].variant = variant
instruments[index].playingStyle = playingStyle
instruments[index].role = role
instruments[index].autoArrangementEnabled = autoArrangementEnabled
}
mutating func addInstrumentPlacement(_ placement: InstrumentPlacement, to instrumentId: String) {
if let index = instruments.firstIndex(where: { $0.instrumentId == instrumentId }) {
instruments[index].placements.append(placement)
} else {
instruments.append(
InstrumentTrack(
instrumentId: instrumentId,
selected: true,
placements: [placement]
)
)
}
}
var vocalistIDs: Set<String> {
Set(vocalists.map(\.id))
}
func vocalist(for vocalistID: String) -> Vocalist? {
vocalists.first { $0.id == vocalistID }
}
func vocalistsAssigned(toSectionID sectionID: String) -> [Vocalist] {
vocalists.filter { $0.assignedSections.contains(sectionID) }
}
mutating func upsertVocalist(_ vocalist: Vocalist) {
var normalizedVocalist = vocalist
normalizedVocalist.assignedSections = Self.uniqueOrderedIDs(vocalist.assignedSections)
if let index = vocalists.firstIndex(where: { $0.id == vocalist.id }) {
vocalists[index] = normalizedVocalist
} else {
vocalists.append(normalizedVocalist)
}
}
mutating func removeVocalist(id vocalistID: String) {
vocalists.removeAll { $0.id == vocalistID }
}
mutating func assignSection(_ sectionID: String, toVocalistID vocalistID: String) {
guard let index = vocalists.firstIndex(where: { $0.id == vocalistID }) else { return }
guard !vocalists[index].assignedSections.contains(sectionID) else { return }
vocalists[index].assignedSections.append(sectionID)
}
mutating func unassignSection(_ sectionID: String, fromVocalistID vocalistID: String) {
guard let index = vocalists.firstIndex(where: { $0.id == vocalistID }) else { return }
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
sections = snapshot.sections
instruments = snapshot.instruments
vocalists = snapshot.vocalists
lyrics = snapshot.lyrics
productionDirections = snapshot.productionDirections
sunoOutput = snapshot.sunoOutput
self.updatedAt = updatedAt
}
mutating func appendSection(_ section: SongSection) {
var orderedSection = section
orderedSection.order = nextSectionOrder
sections.append(orderedSection)
}
mutating func moveSection(id: String, toOrder targetOrder: Int) {
var ordered = orderedSections
guard let sourceIndex = ordered.firstIndex(where: { $0.id == id }) else { return }
let section = ordered.remove(at: sourceIndex)
let boundedTargetOrder = min(max(targetOrder, 0), ordered.count)
ordered.insert(section, at: boundedTargetOrder)
sections = Self.sectionsWithNormalizedOrder(ordered)
}
mutating func normalizeSectionOrder() {
sections = Self.sectionsWithNormalizedOrder(orderedSections)
}
private var nextSectionOrder: Int {
(sections.map(\.order).max() ?? -1) + 1
}
private static func sectionsWithNormalizedOrder(_ sections: [SongSection]) -> [SongSection] {
sections.enumerated().map { index, section in
var orderedSection = section
orderedSection.order = index
return orderedSection
}
}
private static func uniqueOrderedIDs(_ ids: [String]) -> [String] {
var seenIDs = Set<String>()
return ids.filter { seenIDs.insert($0).inserted }
}
}
public enum ConversationMode: String, Codable, Equatable, Sendable {
case auto
case discuss
}
public enum ControlMode: String, Codable, Equatable, Sendable {
case manual
case auto
}
public struct ManualAutoValue<Value: Codable & Equatable & Sendable>: Codable, Equatable, Sendable {
public var mode: ControlMode
public var value: Value?
public init(mode: ControlMode, value: Value? = nil) {
self.mode = mode
self.value = value
}
}
public struct SongDuration: Codable, Equatable, Sendable {
public var seconds: Int?
public var description: String?
public init(seconds: Int? = nil, description: String? = nil) {
self.seconds = seconds
self.description = description
}
}
public struct LanguageSelection: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var name: String
public init(id: String = UUID().uuidString, name: String) {
self.id = id
self.name = name
}
}
public struct DialectSelection: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var name: String
public var languageID: String?
public init(id: String = UUID().uuidString, name: String, languageID: String? = nil) {
self.id = id
self.name = name
self.languageID = languageID
}
}
public struct ArabicPronunciationSettings: Codable, Equatable, Sendable {
public var isEnabled: Bool
public var diacritizationPolicy: ArabicDiacritizationPolicy
public var tanweenPolicy: ArabicTanweenPolicy
public var userNotes: String?
public var preservedSpellings: [String]
public init(
isEnabled: Bool = false,
diacritizationPolicy: ArabicDiacritizationPolicy = .unspecified,
tanweenPolicy: ArabicTanweenPolicy = .unspecified,
userNotes: String? = nil,
preservedSpellings: [String] = []
) {
self.isEnabled = isEnabled
self.diacritizationPolicy = diacritizationPolicy
self.tanweenPolicy = tanweenPolicy
self.userNotes = userNotes
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 let id: String
public var name: String
public var aliases: [String]
public init(id: String = UUID().uuidString, name: String, aliases: [String] = []) {
self.id = id
self.name = name
self.aliases = aliases
}
}
public struct MoodTag: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var name: String
public init(id: String = UUID().uuidString, name: String) {
self.id = id
self.name = name
}
}
public struct EmotionalArcPoint: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var sectionID: String?
public var emotion: String
public var energy: Int?
public var direction: String?
public init(
id: String = UUID().uuidString,
sectionID: String? = nil,
emotion: String,
energy: Int? = nil,
direction: String? = nil
) {
self.id = id
self.sectionID = sectionID
self.emotion = emotion
self.energy = energy
self.direction = direction
}
}
public struct Lyrics: Codable, Equatable, Sendable {
public var text: String
public var sectionTexts: [String: String]
public init(text: String = "", sectionTexts: [String: String] = [:]) {
self.text = text
self.sectionTexts = sectionTexts
}
}
public struct ProductionDirection: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var sectionID: String?
public var text: String
public init(id: String = UUID().uuidString, sectionID: String? = nil, text: String) {
self.id = id
self.sectionID = sectionID
self.text = text
}
}
public struct InstrumentCatalogItem: 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 struct SongSection: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var type: SongSectionType
public var title: String
public var order: Int
public var startTime: TimeInterval?
public var endTime: TimeInterval?
public var lyrics: String
public var emotion: String?
public var energy: Int?
public var vocalDirection: String?
public var productionDirection: String?
public init(
id: String = UUID().uuidString,
type: SongSectionType,
title: String,
order: Int = 0,
startTime: TimeInterval? = nil,
endTime: TimeInterval? = nil,
lyrics: String = "",
emotion: String? = nil,
energy: Int? = nil,
vocalDirection: String? = nil,
productionDirection: String? = nil
) {
self.id = id
self.type = type
self.title = title
self.order = order
self.startTime = startTime
self.endTime = endTime
self.lyrics = lyrics
self.emotion = emotion
self.energy = energy
self.vocalDirection = vocalDirection
self.productionDirection = productionDirection
}
}
public enum SongSectionType: String, Codable, Equatable, Sendable {
case intro
case verse
case preChorus
case chorus
case bridge
case outro
case custom
}
public struct InstrumentTrack: Codable, Equatable, Sendable {
public var instrumentId: String
public var selected: Bool
public var variant: String?
public var playingStyle: String?
public var role: String?
public var autoArrangementEnabled: Bool
public var placements: [InstrumentPlacement]
public init(
instrumentId: String,
selected: Bool = true,
variant: String? = nil,
playingStyle: String? = nil,
role: String? = nil,
autoArrangementEnabled: Bool = true,
placements: [InstrumentPlacement] = []
) {
self.instrumentId = instrumentId
self.selected = selected
self.variant = variant
self.playingStyle = playingStyle
self.role = role
self.autoArrangementEnabled = autoArrangementEnabled
self.placements = placements
}
}
public struct InstrumentPlacement: Codable, Equatable, Sendable {
public var sectionId: String?
public var startTime: TimeInterval?
public var endTime: TimeInterval?
public var direction: String?
public init(
sectionId: String? = nil,
startTime: TimeInterval? = nil,
endTime: TimeInterval? = nil,
direction: String? = nil
) {
self.sectionId = sectionId
self.startTime = startTime
self.endTime = endTime
self.direction = direction
}
}
public struct Vocalist: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var label: String
public var voiceType: String?
public var genderSelection: String?
public var performanceStyle: String?
public var assignedSections: [String]
public init(
id: String = UUID().uuidString,
label: String,
voiceType: String? = nil,
genderSelection: String? = nil,
performanceStyle: String? = nil,
assignedSections: [String] = []
) {
self.id = id
self.label = label
self.voiceType = voiceType
self.genderSelection = genderSelection
self.performanceStyle = performanceStyle
self.assignedSections = assignedSections
}
}
public struct SunoOutput: Codable, Equatable, Sendable {
public var lyricsText: String
public var stylePrompt: String
public var additionalFields: [String: String]
public var generatedAt: Date
public init(
lyricsText: String,
stylePrompt: String,
additionalFields: [String: String] = [:],
generatedAt: Date = Date()
) {
self.lyricsText = lyricsText
self.stylePrompt = stylePrompt
self.additionalFields = additionalFields
self.generatedAt = generatedAt
}
}
public struct SongVersion: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var label: String
public var snapshot: SongProjectSnapshot
public let createdAt: Date
public init(
id: String = UUID().uuidString,
label: String,
snapshot: SongProjectSnapshot,
createdAt: Date = Date()
) {
self.id = id
self.label = label
self.snapshot = snapshot
self.createdAt = createdAt
}
}
public struct SongProjectSnapshot: Codable, Equatable, Sendable {
public var title: String
public var idea: String
public var duration: SongDuration?
public var conversationMode: ConversationMode
public var languages: [LanguageSelection]
public var dialects: [DialectSelection]
public var arabicPronunciationSettings: ArabicPronunciationSettings
public var genres: [GenreStyle]
public var moods: [MoodTag]
public var emotionalArc: [EmotionalArcPoint]
public var bpm: ManualAutoValue<Int>?
public var key: ManualAutoValue<String>?
public var scale: ManualAutoValue<String>?
public var maqam: ManualAutoValue<String>?
public var sections: [SongSection]
public var instruments: [InstrumentTrack]
public var vocalists: [Vocalist]
public var lyrics: Lyrics
public var productionDirections: [ProductionDirection]
public var sunoOutput: SunoOutput?
public init(
title: String,
idea: String,
duration: SongDuration? = nil,
conversationMode: ConversationMode = .auto,
languages: [LanguageSelection] = [],
dialects: [DialectSelection] = [],
arabicPronunciationSettings: ArabicPronunciationSettings = ArabicPronunciationSettings(),
genres: [GenreStyle] = [],
moods: [MoodTag] = [],
emotionalArc: [EmotionalArcPoint] = [],
bpm: ManualAutoValue<Int>? = nil,
key: ManualAutoValue<String>? = nil,
scale: ManualAutoValue<String>? = nil,
maqam: ManualAutoValue<String>? = nil,
sections: [SongSection] = [],
instruments: [InstrumentTrack] = [],
vocalists: [Vocalist] = [],
lyrics: Lyrics = Lyrics(),
productionDirections: [ProductionDirection] = [],
sunoOutput: SunoOutput? = nil
) {
self.title = title
self.idea = idea
self.duration = duration
self.conversationMode = conversationMode
self.languages = languages
self.dialects = dialects
self.arabicPronunciationSettings = arabicPronunciationSettings
self.genres = genres
self.moods = moods
self.emotionalArc = emotionalArc
self.bpm = bpm
self.key = key
self.scale = scale
self.maqam = maqam
self.sections = sections
self.instruments = instruments
self.vocalists = vocalists
self.lyrics = lyrics
self.productionDirections = productionDirections
self.sunoOutput = sunoOutput
}
public init(project: SongProject) {
self.init(
title: project.title,
idea: project.idea,
duration: project.duration,
conversationMode: project.conversationMode,
languages: project.languages,
dialects: project.dialects,
arabicPronunciationSettings: project.arabicPronunciationSettings,
genres: project.genres,
moods: project.moods,
emotionalArc: project.emotionalArc,
bpm: project.bpm,
key: project.key,
scale: project.scale,
maqam: project.maqam,
sections: project.sections,
instruments: project.instruments,
vocalists: project.vocalists,
lyrics: project.lyrics,
productionDirections: project.productionDirections,
sunoOutput: project.sunoOutput
)
}
}
@@ -0,0 +1,150 @@
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 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 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 {
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 {
throw OpenAIClientError.unacceptableStatusCode(response.statusCode)
}
return data
}
}
public enum OpenAIClientError: Error, Equatable, Sendable {
case missingAPIKey
case invalidResponse
case unacceptableStatusCode(Int)
}
@@ -0,0 +1,3 @@
public protocol SunoHandoffService: Sendable {
func handOff(_ output: CompiledSunoOutput) async throws
}
@@ -0,0 +1,124 @@
import Foundation
public protocol ProjectStore: Sendable {
func create(_ project: SongProject) async throws
func save(_ project: SongProject) async throws
func open(id: String) async throws -> SongProject
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 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,170 @@
import Foundation
public protocol AIService: Sendable {
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult
}
public struct AIConversationMessage: Equatable, Identifiable, Sendable {
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 init(
context: AIRequestContext,
seedProject: SongProject? = nil,
discussionMode: ConversationMode = .auto
) {
self.context = context
self.seedProject = seedProject
self.discussionMode = discussionMode
}
}
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 LyricsRevisionRequest: Equatable, Sendable {
public var context: AIRequestContext
public var project: SongProject
public var sourceLyrics: Lyrics
public var mode: LyricsRevisionMode
public init(
context: AIRequestContext,
project: SongProject,
sourceLyrics: Lyrics,
mode: LyricsRevisionMode
) {
self.context = context
self.project = project
self.sourceLyrics = sourceLyrics
self.mode = mode
}
}
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 init(
context: AIRequestContext,
project: SongProject,
allowedScopes: [SongProjectUpdateScope]
) {
self.context = context
self.project = project
self.allowedScopes = allowedScopes
}
}
public enum SongProjectUpdateScope: String, Codable, Equatable, Sendable {
case duration
case genres
case instruments
case vocals
case languages
case lyrics
case structure
case arrangement
case musicalParameters
case emotionalArc
case productionDirections
case sunoOutput
}
public struct SongProjectUpdateResult: Equatable, Sendable {
public var project: SongProject
public var changedScopes: [SongProjectUpdateScope]
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,73 @@
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 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: 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,37 @@
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 {
var updatedProject = existingProject
updatedProject.restoreSnapshot(project, updatedAt: updatedAt)
return SongProjectUpdateResult(
project: updatedProject,
changedScopes: changedScopes,
notes: notes,
followUpQuestions: followUpQuestions
)
}
}
@@ -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
}
}
@@ -0,0 +1,15 @@
public protocol PromptCompiling: Sendable {
associatedtype Project
func compile(project: Project) throws -> CompiledSunoOutput
}
public struct CompiledSunoOutput: Equatable, Sendable {
public let lyricsText: String
public let stylePrompt: String
public init(lyricsText: String, stylePrompt: String) {
self.lyricsText = lyricsText
self.stylePrompt = stylePrompt
}
}
@@ -0,0 +1,96 @@
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 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 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."]
)
}
}
@@ -0,0 +1,21 @@
import MusicAssistantCore
import XCTest
final class AppConfigurationTests: XCTestCase {
func testDevelopmentConfigurationReadsKnownEnvironmentKeys() {
let configuration = AppConfiguration.development(environment: [
"MUSIC_ASSISTANT_OPENAI_API_KEY": "openai-test-key",
"MUSIC_ASSISTANT_PRIVATE_AI_RULES": " private product rules "
])
XCTAssertEqual(configuration.openAIAPIKey, "openai-test-key")
XCTAssertEqual(configuration.privateApplicationRules?.content, "private product rules")
}
func testDevelopmentConfigurationAllowsMissingSecrets() {
let configuration = AppConfiguration.development(environment: [:])
XCTAssertNil(configuration.openAIAPIKey)
XCTAssertNil(configuration.privateApplicationRules)
}
}
@@ -0,0 +1,102 @@
@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.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."
]
)
}
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 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,112 @@
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 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 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,213 @@
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 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 testClientReportsUnacceptableStatusWithoutLeakingResponseBody() 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, .unacceptableStatusCode(429))
}
}
}
private actor RecordingOpenAITransport: OpenAIHTTPTransport {
private(set) var recordedRequests: [URLRequest] = []
private let statusCode: Int
private let responseData: Data
init(statusCode: Int, responseData: Data) {
self.statusCode = statusCode
self.responseData = responseData
}
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: nil
)!
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 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
}
@@ -0,0 +1,11 @@
import MusicAssistantCore
import XCTest
final class ProductBoundarySummaryTests: XCTestCase {
func testInitialBoundaryKeepsSunoGenerationOutsideTheApp() {
let summary = ProductBoundarySummary.initial.description
XCTAssertTrue(summary.contains("Suno"))
XCTAssertTrue(summary.contains("without starting generation"))
}
}
@@ -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,561 @@
import Foundation
import MusicAssistantCore
import XCTest
final class SongProjectModelTests: XCTestCase {
func testSongProjectStoresCoreDomainCollections() {
let section = SongSection(
id: "section-verse-1",
type: .verse,
title: "Verse 1",
startTime: 12,
endTime: 42,
lyrics: "Draft verse",
emotion: "hopeful",
energy: 6,
vocalDirection: "soft lead",
productionDirection: "light percussion"
)
let placement = InstrumentPlacement(
sectionId: section.id,
startTime: section.startTime,
endTime: section.endTime,
direction: "enter after the first line"
)
let instrument = InstrumentTrack(
instrumentId: "oud",
selected: true,
variant: "Arabic oud",
playingStyle: "picked",
role: "main melodic hook",
autoArrangementEnabled: false,
placements: [placement]
)
let vocalist = Vocalist(
id: "lead",
label: "Lead vocalist",
voiceType: "warm tenor",
genderSelection: "male",
performanceStyle: "sung",
assignedSections: [section.id]
)
let project = SongProject(
id: "project-1",
title: "Desert Lights",
idea: "A hopeful Arabic pop song",
duration: SongDuration(seconds: 180),
conversationMode: .discuss,
languages: [LanguageSelection(id: "arabic", name: "Arabic")],
dialects: [DialectSelection(id: "levantine", name: "Levantine", languageID: "arabic")],
arabicPronunciationSettings: ArabicPronunciationSettings(isEnabled: true),
genres: [GenreStyle(id: "arabic-pop", name: "Arabic Pop")],
moods: [MoodTag(id: "hopeful", name: "Hopeful")],
emotionalArc: [EmotionalArcPoint(id: "arc-1", sectionID: section.id, emotion: "hopeful")],
bpm: ManualAutoValue(mode: .manual, value: 96),
key: ManualAutoValue(mode: .auto),
scale: ManualAutoValue(mode: .auto),
maqam: ManualAutoValue(mode: .manual, value: "Hijaz"),
sections: [section],
instruments: [instrument],
vocalists: [vocalist],
lyrics: Lyrics(text: "Draft lyrics", sectionTexts: [section.id: "Draft verse"]),
productionDirections: [ProductionDirection(id: "prod-1", sectionID: section.id, text: "add lift")],
sunoOutput: SunoOutput(lyricsText: "Draft lyrics", stylePrompt: "Arabic pop with oud")
)
XCTAssertEqual(project.sections.first?.type, .verse)
XCTAssertEqual(project.instruments.first?.instrumentId, "oud")
XCTAssertEqual(project.instruments.first?.placements.first?.sectionId, section.id)
XCTAssertEqual(project.vocalists.first?.assignedSections, [section.id])
XCTAssertEqual(project.bpm?.mode, .manual)
XCTAssertEqual(project.maqam?.value, "Hijaz")
}
func testSongProjectSnapshotCopiesVersionableState() {
let project = SongProject(
id: "project-2",
title: "Night Drive",
idea: "Electronic track",
genres: [GenreStyle(id: "synthwave", name: "Synthwave")],
instruments: [InstrumentTrack(instrumentId: "synthesizer")]
)
let snapshot = SongProjectSnapshot(project: project)
let version = SongVersion(label: "Initial direction", snapshot: snapshot)
XCTAssertEqual(version.snapshot.title, project.title)
XCTAssertEqual(version.snapshot.idea, project.idea)
XCTAssertEqual(version.snapshot.genres, project.genres)
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() {
let arabicPop = GenreStyle(id: "arabic-pop", name: "Arabic Pop")
let synthwave = GenreStyle(id: "synthwave", name: "Synthwave")
var project = SongProject(
id: "project-3",
title: "Hybrid",
idea: "Blend traditional melody with electronic production"
)
project.selectGenreStyle(arabicPop)
project.selectGenreStyle(synthwave)
project.selectGenreStyle(arabicPop)
XCTAssertEqual(project.genres, [arabicPop, synthwave])
XCTAssertTrue(project.isGenreStyleSelected(arabicPop))
XCTAssertEqual(project.selectedGenreStyleIDs, ["arabic-pop", "synthwave"])
project.deselectGenreStyle(id: arabicPop.id)
XCTAssertEqual(project.genres, [synthwave])
XCTAssertFalse(project.isGenreStyleSelected(arabicPop))
}
func testSongSectionsKeepExplicitOrderAndOptionalTimestamps() {
var project = SongProject(
id: "project-4",
title: "Section Flow",
idea: "Build a classic song structure"
)
let intro = SongSection(id: "intro", type: .intro, title: "Intro")
let verse = SongSection(
id: "verse",
type: .verse,
title: "Verse",
startTime: 15,
endTime: 45
)
let chorus = SongSection(id: "chorus", type: .chorus, title: "Chorus")
project.appendSection(verse)
project.appendSection(chorus)
project.appendSection(intro)
project.moveSection(id: intro.id, toOrder: 0)
XCTAssertEqual(project.orderedSections.map(\.id), ["intro", "verse", "chorus"])
XCTAssertNil(project.orderedSections.first?.startTime)
XCTAssertNil(project.orderedSections.first?.endTime)
XCTAssertEqual(project.orderedSections[1].startTime, 15)
XCTAssertEqual(project.orderedSections[1].endTime, 45)
XCTAssertEqual(project.orderedSections.map(\.order), [0, 1, 2])
}
func testInstrumentsTrackVariantsRolesSelectionAndPlacements() {
var project = SongProject(
id: "project-5",
title: "Instrument Plan",
idea: "Feature oud and violin with clear entries"
)
let oud = InstrumentTrack(
instrumentId: "oud",
selected: true,
variant: "Arabic oud",
playingStyle: "tremolo",
role: "main hook",
autoArrangementEnabled: false
)
let introPlacement = InstrumentPlacement(
sectionId: "intro",
startTime: 0,
endTime: 12,
direction: "solo intro"
)
let chorusPlacement = InstrumentPlacement(
sectionId: "chorus",
direction: "double the vocal melody"
)
project.selectInstrument(oud)
project.addInstrumentPlacement(introPlacement, to: oud.instrumentId)
project.addInstrumentPlacement(chorusPlacement, to: "violin")
project.configureInstrument(
id: "violin",
variant: "solo violin",
playingStyle: "legato",
role: "countermelody",
autoArrangementEnabled: true
)
XCTAssertEqual(project.selectedInstrumentIDs, ["oud", "violin"])
XCTAssertEqual(project.instrumentTrack(for: "oud")?.variant, "Arabic oud")
XCTAssertEqual(project.instrumentTrack(for: "oud")?.role, "main hook")
XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [introPlacement])
XCTAssertEqual(project.instrumentTrack(for: "violin")?.variant, "solo violin")
XCTAssertEqual(project.instrumentTrack(for: "violin")?.playingStyle, "legato")
XCTAssertEqual(project.instrumentTrack(for: "violin")?.role, "countermelody")
XCTAssertEqual(project.instrumentTrack(for: "violin")?.placements, [chorusPlacement])
project.deselectInstrument(id: "oud")
XCTAssertFalse(project.isInstrumentSelected(id: "oud"))
XCTAssertEqual(project.selectedInstrumentIDs, ["violin"])
XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [introPlacement])
}
func testMultipleVocalistsCanBeAssignedToSections() {
var project = SongProject(
id: "project-6",
title: "Vocal Arrangement",
idea: "Lead and harmony share selected sections"
)
let lead = Vocalist(
id: "lead",
label: "Lead",
voiceType: "tenor",
genderSelection: "male",
performanceStyle: "sung",
assignedSections: ["verse", "verse"]
)
let harmony = Vocalist(
id: "harmony",
label: "Harmony",
voiceType: "alto",
genderSelection: "female",
performanceStyle: "harmony"
)
project.upsertVocalist(lead)
project.upsertVocalist(harmony)
project.assignSection("chorus", toVocalistID: "lead")
project.assignSection("chorus", toVocalistID: "lead")
project.assignSection("chorus", toVocalistID: "harmony")
project.assignSection("bridge", toVocalistID: "harmony")
XCTAssertEqual(project.vocalistIDs, ["lead", "harmony"])
XCTAssertEqual(project.vocalist(for: "lead")?.assignedSections, ["verse", "chorus"])
XCTAssertEqual(project.vocalist(for: "harmony")?.assignedSections, ["chorus", "bridge"])
XCTAssertEqual(project.vocalistsAssigned(toSectionID: "chorus").map(\.id), ["lead", "harmony"])
project.unassignSection("chorus", fromVocalistID: "lead")
XCTAssertEqual(project.vocalist(for: "lead")?.assignedSections, ["verse"])
XCTAssertEqual(project.vocalistsAssigned(toSectionID: "chorus").map(\.id), ["harmony"])
project.removeVocalist(id: "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 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,114 @@
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, "Updated")
XCTAssertEqual(result.project.lyrics.text, "Updated lyrics")
XCTAssertEqual(result.changedScopes, [.genres, .lyrics])
XCTAssertEqual(result.notes, ["Proposed focused update."])
XCTAssertEqual(result.followUpQuestions, ["Keep it minimal?"])
}
}
+85
View File
@@ -0,0 +1,85 @@
# Architecture --- Initial Direction
## Principle
Keep UI, domain data, AI orchestration, external integrations and
persistence separated. Do not hard-code provider behavior into SwiftUI
views.
## Suggested Layers
``` text
macOS App
├── Presentation (SwiftUI)
├── Domain
│ ├── SongProject
│ ├── SongVersion
│ ├── Lyrics
│ ├── SongStructure
│ ├── Genre/Style
│ ├── Instrument
│ ├── Vocal
│ ├── Arrangement
│ └── MusicalParameters
├── Application Services
│ ├── AI Director
│ ├── Prompt Compiler
│ ├── Instrument Catalog
│ ├── Arabic Pronunciation Processor
│ ├── Project Versioning
│ └── Validation
├── Integrations
│ ├── OpenAI
│ └── Suno Field-Fill Integration
└── Persistence
└── Local project/version storage
```
## AI Director
OpenAI receives the user's intent plus private application rules and
structured project state. AI output should be requested as structured
data wherever possible, not treated as an unstructured chat transcript.
## Prompt Compiler
A deterministic layer converts the approved SongProject into the final
Suno-facing lyrics/style content. User choices override AI suggestions.
## Security
- Never commit API keys to source control.
- Keep secrets outside source code.
- Design provider clients behind protocols/interfaces so credentials
and providers can be changed later.
- Do not log secrets or full authorization headers.
## Suno Boundary
The initial requirement is browser/site handoff that fills fields but
does not press Generate. Treat this integration as replaceable because
website UI/behavior can change.
## Instrument Selector Boundary
Instrument catalog data is structured names and metadata, not SwiftUI
view code. The Presentation layer may search, filter and toggle
selections, but catalog loading, search normalization and selected
instrument state should live in Domain/Application Services and the
SongProject model.
Catalog entries should include stable ids, display names,
family/category, optional region/origin and optional aliases/search
terms. The catalog should support worldwide coverage across Western,
Middle Eastern, African, South Asian, East Asian, Southeast Asian, Latin
American, traditional, folk, orchestral, electronic and modern
instruments.
Selected instruments are part of the SongProject and must be available
to the AI Director and Prompt Compiler so they can influence
arrangement, roles, entry/exit timing, relevant structure decisions and
the Suno Style Prompt.
The current architecture does not include instrument images, audio
previews, sound samples, playback, Freesound, an Instrument API or
external API calls for instrument data.
+87
View File
@@ -0,0 +1,87 @@
# Song Project --- Draft Data Model
This schema is intentionally provider-neutral and should evolve before
implementation is locked.
``` text
SongProject
- id
- title
- idea
- duration
- conversationMode: auto | discuss
- languages[]
- dialects[]
- arabicPronunciationSettings
- genres[]
- moods[]
- emotionalArc[]
- bpm
- key
- scale
- maqam
- sections[]
- instruments[]
- vocalists[]
- lyrics
- productionDirections[]
- sunoOutput
- versions[]
- createdAt
- updatedAt
InstrumentCatalogItem
- id
- name
- familyCategory
- regionOrigin (optional)
- aliases[]
- searchTerms[]
SongSection
- id
- type (intro, verse, preChorus, chorus, bridge, outro, custom)
- title
- startTime
- endTime
- lyrics
- emotion
- energy
- vocalDirection
- productionDirection
InstrumentTrack
- instrumentId
- selected
- variant
- playingStyle
- role
- autoArrangementEnabled
- placements[]
InstrumentPlacement
- sectionId (optional)
- startTime (optional)
- endTime (optional)
- direction
Vocalist
- id
- label
- voiceType
- genderSelection
- performanceStyle
- assignedSections[]
SunoOutput
- lyricsText
- stylePrompt
- additionalFields
- generatedAt
```
## Override Rule
Explicit user values are authoritative. AI may fill missing values or
propose changes, but must not silently replace locked/manual user
choices.
+24
View File
@@ -0,0 +1,24 @@
# Open Questions
These items must be resolved before their dependent implementation is
finalized.
- Resolved: minimum macOS deployment target is macOS 14.0.
- Exact Swift/SwiftUI architecture conventions for the repository.
- OpenAI model(s) and API endpoint strategy.
- Exact private AI rules/system prompt.
- Complete genre/style taxonomy and whether it is curated locally or
AI-assisted.
- Complete worldwide instrument catalog taxonomy, family/category
groups, region/origin coverage, aliases/search terms and maintenance
strategy.
- Exact list of supported languages/dialects for MVP.
- Arabic diacritization policy: full tashkeel vs
pronunciation-targeted tashkeel.
- Exact Suno fields to populate and supported handoff mechanism.
- Whether Suno integration is permitted/reliable under the intended
account/workflow and current terms.
- Project storage technology and whether cloud sync is required later.
- Whether users supply their own OpenAI/API credentials or the product
owner supplies service credentials.
- Authentication/subscription/usage-limit requirements, if any.
+96
View File
@@ -0,0 +1,96 @@
# Product Definition --- AI Music Studio for macOS
## Vision
A native macOS application that helps users design a complete song
concept through a natural chat interface, using OpenAI as the planning
and writing engine, then prepares the final lyrics and style
instructions for Suno.
## Core Flow
1. User starts a new song project in a natural chat.
2. User describes the song idea and optionally selects duration,
genres/styles, instruments, vocals, language/dialect, structure and
musical parameters.
3. OpenAI applies product rules and completes missing creative details.
An optional discussion mode lets the AI ask the user before making
decisions.
4. User reviews and manually edits every generated component.
5. Project versions are saved.
6. User presses **Send to Suno**.
7. The app opens Suno and fills the appropriate fields. It does not
trigger Generate.
## Song Controls
- Multiple genres/styles can be blended.
- Instruments can be selected from a large worldwide instrument
catalog.
- Instruments can be searched by name, browsed by family/category and
browsed by region/origin where useful.
- Multiple instruments can be selected and deselected using
checkboxes.
- Selected instruments belong to the current Song Project.
- Each instrument can have a role and entry/exit timing.
- Instrument timing supports both timestamps and song sections.
- Arrangement supports Manual and Auto modes.
- Song structure supports Manual and Auto modes.
- BPM, key/scale and maqam support Manual and Auto modes.
- Multiple vocalists/voices can exist in one song.
- User controls vocal gender/type, rap/singing mode, delivery and
section-specific performance.
- Multiple languages and dialects can be used in one song.
- Arabic receives dedicated diacritics/harakat/tanween handling for
pronunciation.
- Energy and emotional progression can change throughout the song.
- Effects, transitions, build-ups, drops, bass intensity and vocal
processing can be manually specified or AI-assisted.
## Lyrics
The user can either: - provide only an idea and have AI write the
lyrics; or - provide existing lyrics and ask AI to correct, improve,
restructure or complete them.
## Instrument Selector
The application provides a names-and-metadata Instrument Selector. The
catalog should cover as many musical instruments as reasonably possible,
including Western, Middle Eastern, African, South Asian, East Asian,
Southeast Asian, Latin American, traditional, folk, orchestral,
electronic and modern instruments.
Instrument catalog data must not be hardcoded directly inside SwiftUI
views. Each catalog entry should support structured metadata:
- id
- name
- family/category
- optional region/origin
- optional aliases/search terms
Selected instruments must be available as part of the Song Project and
later available to the OpenAI/song-generation layer so they can
influence arrangement, instrument roles, entry/exit timing, song
structure where relevant and the Suno Style Prompt.
Instrument images, audio previews, sound samples, audio playback,
Freesound, an Instrument API and external API calls for instrument data
are not part of the current product requirement.
## Project Management
Every song is stored as a project. Important changes can create versions
so previous states remain recoverable.
## AI Rules
The product owner has private system rules that govern OpenAI behavior.
Normal users cannot view or modify these rules in the initial version.
## Initial Boundary
Suno remains responsible for music generation and playback. The macOS
app prepares the project and fills Suno fields only. Generated songs do
not need to return to the app in the initial version.
+135
View File
@@ -0,0 +1,135 @@
# Agent Task Plan --- AI Music Studio macOS
## Operating Rule
Work incrementally. Do not invent product requirements. When a
requirement is missing and blocks implementation, record it in
`OPEN_QUESTIONS.md` instead of silently deciding it.
## Phase 0 --- Repository Foundation
- [x] Inspect existing repository and document current state.
- [x] Create/confirm native macOS project structure.
- [x] Establish clear Presentation / Domain / Services / Integrations
/ Persistence boundaries.
- [x] Add configuration strategy for development secrets without
committing keys.
- [x] Add basic unit-test target.
- [x] Ensure project builds cleanly.
## Phase 1 --- Domain Model
- [x] Implement `SongProject` and supporting models from
`DATA_MODEL.md`.
- [x] Model genres/styles as multi-select.
- [x] Model song sections with order and optional timestamps.
- [x] Model instruments, variants, roles and placements.
- [x] Model multiple vocalists and section assignments.
- [x] Model language/dialect and Arabic pronunciation settings.
- [x] Model BPM, key, scale and maqam with Manual/Auto state.
- [x] Model emotional arc and production directions.
- [x] Add serialization tests.
## Phase 2 --- Project Persistence & Versions
- [x] Create new/open/save song projects locally.
- [x] Implement project list.
- [x] Implement immutable or snapshot-based version history.
- [x] Restore a previous version without destroying later versions.
- [x] Add autosave strategy that does not create excessive versions.
## Phase 3 --- Core macOS UI
- [x] Build project browser.
- [x] Build natural chat workspace as the primary entry point.
- [x] Build editable project inspector for duration, genres,
instruments, vocals and language.
- [x] Build song structure editor.
- [x] Build arrangement editor supporting section-based and
timestamp-based placement.
- [x] Add Manual/Auto toggles for supported controls.
- [x] Build final review screen where every generated field can be
edited.
## Phase 4 --- OpenAI Integration
- [x] Create provider-independent `AIService` interface.
- [x] Implement OpenAI client.
- [x] Define private application-rule injection mechanism.
- [x] Define structured AI response schema for SongProject updates.
- [ ] Implement idea → complete project generation.
- [ ] Implement existing lyrics → correction/improvement flow.
- [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam
and production decisions.
- [ ] Implement optional Discuss mode.
- [ ] Enforce user-lock/manual-value precedence over AI output.
- [ ] Add error, retry, cancellation and rate-limit handling.
## Phase 5 --- Arabic Lyrics Processing
- [ ] Add Arabic-specific settings UI.
- [ ] Support diacritics/harakat/tanween processing.
- [ ] Preserve intentional spelling/dialect choices where possible.
- [ ] Allow user to compare/edit processed Arabic before Suno handoff.
- [ ] Add Arabic test fixtures covering multiple dialects.
## Phase 6 --- Instrument Selector & Catalog
- [ ] Create structured catalog models for instrument id, name,
family/category, optional region/origin and aliases/search terms.
- [ ] Provide large worldwide catalog data covering Western, Middle
Eastern, African, South Asian, East Asian, Southeast Asian, Latin
American, traditional, folk, orchestral, electronic and modern
instruments.
- [ ] Keep catalog data out of SwiftUI views.
- [ ] Build searchable instrument browser.
- [ ] Add browsing/filtering by family/category.
- [ ] Add browsing/filtering by region/origin where useful.
- [ ] Add checkbox-based multi-select and deselect behavior.
- [ ] Persist selected instruments on the current Song Project.
- [ ] Make selected instruments available to OpenAI/song-generation
logic for arrangement, roles, entry/exit timing, relevant structure
decisions and Suno Style Prompt generation.
- [ ] Preserve existing Manual/Auto arrangement behavior.
- [ ] Add tests for catalog search, category/region filtering,
selection persistence and SongProject serialization.
## Phase 7 --- Prompt Compiler
- [ ] Create deterministic compiler from approved SongProject → Suno
output.
- [ ] Generate lyrics text with section/performance directives where
appropriate.
- [ ] Generate style prompt from genre blend, instrumentation, vocals,
tempo, harmony, emotion and production instructions.
- [ ] Validate output before handoff.
- [ ] Keep compiler independent from UI and OpenAI client.
## Phase 8 --- Suno Handoff
- [ ] Implement explicit `Send to Suno` action.
- [ ] Open the appropriate Suno creation surface.
- [ ] Fill supported fields with approved project output.
- [ ] Never trigger Generate automatically.
- [ ] Detect/report when fields cannot be filled rather than silently
failing.
- [ ] Keep integration isolated because Suno UI can change.
## Phase 9 --- Quality
- [ ] Add validation for contradictory/invalid project settings.
- [ ] Add loading, offline and provider-error states.
- [ ] Add accessibility labels and keyboard navigation.
- [ ] Test project/version recovery.
- [ ] Test AI output against locked user choices.
- [ ] Test selected instruments influence compiled song-generation
context.
- [ ] Test Suno handoff without generation.
## Definition of MVP Done
A user can create a macOS song project through chat, manually or
automatically configure the agreed song parameters, generate/edit lyrics
and song planning through OpenAI, select instruments, review
the complete project, save versions, and send the approved lyrics/style
data to Suno where the app fills fields without initiating generation.
+325
View File
@@ -0,0 +1,325 @@
# Build Files Template for music-assistant
Use this document as the prompt/instruction file for an agent when you create the
`music-assistant` Swift macOS app project.
The target project does not need to match the Servery path. These files should be
created in the root of the future `music-assistant` project, next to
`Package.swift`.
## Agent Request
Copy this request into the agent working inside the new project:
```text
Create the same build-command structure for this Swift macOS app.
App name:
music-assistant
Product name:
music-assistant
Bundle identifier:
com.music-assistant.app
Requirements:
1. Create `Makefile` with `build`, `dev`, and `test` commands.
2. Create `clean_rebuild.sh` for a clean release build, packaging, signing,
installing into `/Applications`, and launching the app.
3. Create `dev_rebuild.sh` for a fast incremental debug build, packaging,
installing into `/Applications`, and launching the app.
4. Assume these files live in the project root next to `Package.swift`.
5. Use the app icon at `Resources/music-assistant.icns`.
6. If the app has additional resources, copy them into
`Contents/Resources` during packaging.
7. Make both shell scripts executable.
8. Keep the implementation small and local. Do not refactor unrelated project
files.
9. After creating the files, verify that the scripts reference the correct app
name, product name, bundle identifier, icon path, and install path.
```
## Makefile
Create this file as `Makefile`:
```makefile
.PHONY: build dev test
build:
./clean_rebuild.sh
dev:
./dev_rebuild.sh
test:
swift test
```
## dev_rebuild.sh
Create this file as `dev_rebuild.sh`:
```zsh
#!/bin/zsh
# Fast development rebuild: incremental DEBUG build, repackage, relaunch.
#
# This skips `swift package clean` and release-mode optimizations, so it reuses
# the incremental build cache and finishes quickly. Use it while iterating.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
APP_NAME="music-assistant"
PRODUCT_NAME="music-assistant"
APP_BUNDLE_NAME="${APP_NAME}.app"
BUILD_DIR="${PROJECT_ROOT}/.build"
APP_BUNDLE="${BUILD_DIR}/${APP_BUNDLE_NAME}"
INSTALL_PATH="/Applications/${APP_BUNDLE_NAME}"
CONTENTS_DIR="${APP_BUNDLE}/Contents"
MACOS_DIR="${CONTENTS_DIR}/MacOS"
RESOURCES_DIR="${CONTENTS_DIR}/Resources"
APP_ICON="${PROJECT_ROOT}/Resources/music-assistant.icns"
fail() {
print -u2 -- "Error: $1"
exit 1
}
step() {
print -- "==> $1"
}
[[ -f "${PROJECT_ROOT}/Package.swift" ]] || fail "Package.swift was not found in ${PROJECT_ROOT}."
[[ -f "${APP_ICON}" ]] || fail "App icon was not found at ${APP_ICON}."
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."
cd "${PROJECT_ROOT}"
step "Stopping any running ${APP_NAME} application"
if pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1; then
pkill -x "${PRODUCT_NAME}" || fail "Could not stop the running ${APP_NAME} application."
for _ in {1..20}; do
pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 || break
sleep 0.1
done
pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 && fail "${APP_NAME} is still running. Quit it and retry."
fi
step "Building ${PRODUCT_NAME} in debug mode (incremental)"
swift build || fail "Debug build failed. Review the Swift compiler diagnostics above."
BIN_PATH="$(swift build --show-bin-path 2>/dev/null | tail -n1)"
SOURCE_EXECUTABLE="${BIN_PATH}/${PRODUCT_NAME}"
[[ -x "${SOURCE_EXECUTABLE}" ]] || 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}/${PRODUCT_NAME}"
cp "${APP_ICON}" "${RESOURCES_DIR}/music-assistant.icns"
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>music-assistant</string>
<key>CFBundleExecutable</key>
<string>music-assistant</string>
<key>CFBundleIdentifier</key>
<string>com.music-assistant.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleIconFile</key>
<string>music-assistant</string>
<key>CFBundleName</key>
<string>music-assistant</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."
[[ -x "${MACOS_DIR}/${PRODUCT_NAME}" ]] || fail "Packaged executable is missing."
step "Installing ${APP_BUNDLE_NAME} in /Applications"
rm -rf "${INSTALL_PATH}"
ditto "${APP_BUNDLE}" "${INSTALL_PATH}" || fail "Could not install ${APP_BUNDLE_NAME} in /Applications. Check permissions and retry."
step "Launching ${APP_NAME}"
open -n "${INSTALL_PATH}" || fail "${APP_NAME} was installed but could not be opened."
print -- "==> ${APP_NAME} was rebuilt (debug), installed, and launched successfully."
```
## clean_rebuild.sh
Create this file as `clean_rebuild.sh`:
```zsh
#!/bin/zsh
# Build, package, install, and launch the music-assistant macOS application.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
APP_NAME="music-assistant"
PRODUCT_NAME="music-assistant"
APP_BUNDLE_NAME="${APP_NAME}.app"
BUILD_DIR="${PROJECT_ROOT}/.build"
APP_BUNDLE="${BUILD_DIR}/${APP_BUNDLE_NAME}"
INSTALL_PATH="/Applications/${APP_BUNDLE_NAME}"
CONTENTS_DIR="${APP_BUNDLE}/Contents"
MACOS_DIR="${CONTENTS_DIR}/MacOS"
RESOURCES_DIR="${CONTENTS_DIR}/Resources"
APP_ICON="${PROJECT_ROOT}/Resources/music-assistant.icns"
fail() {
print -u2 -- "Error: $1"
exit 1
}
step() {
print -- "==> $1"
}
[[ -f "${PROJECT_ROOT}/Package.swift" ]] || fail "Package.swift was not found in ${PROJECT_ROOT}."
[[ -f "${APP_ICON}" ]] || fail "App icon was not found at ${APP_ICON}."
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."
cd "${PROJECT_ROOT}"
step "Stopping any running ${APP_NAME} application"
if pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1; then
pkill -x "${PRODUCT_NAME}" || fail "Could not stop the running ${APP_NAME} application."
for _ in {1..20}; do
pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 || break
sleep 0.1
done
pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 && fail "${APP_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 ${PRODUCT_NAME} in release mode"
BIN_PATH="$(swift build -c release --show-bin-path 2>/dev/null | tail -n1)"
SOURCE_EXECUTABLE="${BIN_PATH}/${PRODUCT_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}/${PRODUCT_NAME}"
cp "${APP_ICON}" "${RESOURCES_DIR}/music-assistant.icns"
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>music-assistant</string>
<key>CFBundleExecutable</key>
<string>music-assistant</string>
<key>CFBundleIdentifier</key>
<string>com.music-assistant.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleIconFile</key>
<string>music-assistant</string>
<key>CFBundleName</key>
<string>music-assistant</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}/${PRODUCT_NAME}" ]] || fail "Packaged executable is missing."
step "Installing ${APP_BUNDLE_NAME} in /Applications"
ditto "${APP_BUNDLE}" "${INSTALL_PATH}" || fail "Could not install ${APP_BUNDLE_NAME} in /Applications. Check permissions and retry."
INSTALLED_EXECUTABLE="${INSTALL_PATH}/Contents/MacOS/${PRODUCT_NAME}"
[[ -x "${INSTALLED_EXECUTABLE}" ]] || fail "Installed executable is missing at ${INSTALLED_EXECUTABLE}."
SOURCE_HASH="$(shasum -a 256 "${MACOS_DIR}/${PRODUCT_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_NAME}"
open -n "${INSTALL_PATH}" || fail "${APP_NAME} was installed but could not be opened."
print -- "==> ${APP_NAME} was rebuilt, installed, and launched successfully."
```
## Required Terminal Step
After the files are created in the new project, run:
```bash
chmod +x clean_rebuild.sh dev_rebuild.sh
```
Then use:
```bash
make dev
make build
make test
```
## Notes
- The future project must have `Package.swift`.
- The future project must have an icon at `Resources/music-assistant.icns`.
- If the executable product name in `Package.swift` is different, change
`PRODUCT_NAME` in both scripts.
- If the app needs permissions, add the matching usage-description keys to the
generated `Info.plist`.
+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."