From 1993800f277fa274dee881cee64e7510a6f58a95 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 13:18:08 +0200 Subject: [PATCH] Implement initial song project domain models --- .env.example | 2 + .gitignore | 7 + DEVELOPMENT.md | 16 + Makefile | 10 + Package.swift | 37 ++ .../MusicAssistantApp/MusicAssistantApp.swift | 11 + .../Presentation/RootView.swift | 45 ++ .../Configuration/AppConfiguration.swift | 15 + .../Domain/ProductBoundarySummary.swift | 11 + .../Domain/SongProject.swift | 610 ++++++++++++++++++ .../Suno/SunoHandoffService.swift | 3 + .../Persistence/ProjectStore.swift | 6 + .../Services/AI/AIService.swift | 19 + .../PromptCompiler/PromptCompiling.swift | 15 + .../AppConfigurationTests.swift | 18 + .../ProductBoundarySummaryTests.swift | 11 + .../SongProjectModelTests.swift | 194 ++++++ docs/TASKS.md | 8 +- music-assistant-build-files.md | 325 ++++++++++ 19 files changed, 1359 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DEVELOPMENT.md create mode 100644 Makefile create mode 100644 Package.swift create mode 100644 Sources/MusicAssistantApp/MusicAssistantApp.swift create mode 100644 Sources/MusicAssistantApp/Presentation/RootView.swift create mode 100644 Sources/MusicAssistantCore/Configuration/AppConfiguration.swift create mode 100644 Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift create mode 100644 Sources/MusicAssistantCore/Domain/SongProject.swift create mode 100644 Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift create mode 100644 Sources/MusicAssistantCore/Persistence/ProjectStore.swift create mode 100644 Sources/MusicAssistantCore/Services/AI/AIService.swift create mode 100644 Sources/MusicAssistantCore/Services/PromptCompiler/PromptCompiling.swift create mode 100644 Tests/MusicAssistantCoreTests/AppConfigurationTests.swift create mode 100644 Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift create mode 100644 Tests/MusicAssistantCoreTests/SongProjectModelTests.swift create mode 100644 music-assistant-build-files.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2b046ac --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Local development only. Do not commit real values. +MUSIC_ASSISTANT_OPENAI_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b0e8d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.build/ +.swiftpm/ +DerivedData/ +.DS_Store +.env +*.xcuserdata +*.xcuserstate diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..0b2c848 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,16 @@ +# 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a6e0f26 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: build dev test + +build: + swift build + +dev: + swift run music-assistant + +test: + swift test diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..b2c40a8 --- /dev/null +++ b/Package.swift @@ -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" + ) + ] +) diff --git a/Sources/MusicAssistantApp/MusicAssistantApp.swift b/Sources/MusicAssistantApp/MusicAssistantApp.swift new file mode 100644 index 0000000..039bb7d --- /dev/null +++ b/Sources/MusicAssistantApp/MusicAssistantApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct MusicAssistantApp: App { + var body: some Scene { + WindowGroup { + RootView() + } + .windowStyle(.titleBar) + } +} diff --git a/Sources/MusicAssistantApp/Presentation/RootView.swift b/Sources/MusicAssistantApp/Presentation/RootView.swift new file mode 100644 index 0000000..b2605e5 --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/RootView.swift @@ -0,0 +1,45 @@ +import MusicAssistantCore +import SwiftUI + +struct RootView: View { + private let appName = "AI Music Studio" + private let boundarySummary = ProductBoundarySummary.initial + + var body: some View { + NavigationSplitView { + List { + Label("Projects", systemImage: "music.note.list") + Label("Settings", systemImage: "gearshape") + } + .navigationTitle(appName) + } detail: { + VStack(alignment: .leading, spacing: 16) { + Text("New Song Project") + .font(.largeTitle.weight(.semibold)) + + Text(boundarySummary.description) + .font(.body) + .foregroundStyle(.secondary) + .frame(maxWidth: 560, alignment: .leading) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Foundation status") + .font(.headline) + + Label("Presentation, Domain, Services, Integrations, and Persistence boundaries are separated.", systemImage: "checkmark.circle") + Label("External providers are represented by protocols only.", systemImage: "lock.shield") + Label("Secrets are expected from local environment configuration.", systemImage: "key") + } + .labelStyle(.titleAndIcon) + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } +} + +#Preview { + RootView() +} diff --git a/Sources/MusicAssistantCore/Configuration/AppConfiguration.swift b/Sources/MusicAssistantCore/Configuration/AppConfiguration.swift new file mode 100644 index 0000000..9317a60 --- /dev/null +++ b/Sources/MusicAssistantCore/Configuration/AppConfiguration.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct AppConfiguration: Equatable, Sendable { + public let openAIAPIKey: String? + + public init(openAIAPIKey: String?) { + self.openAIAPIKey = openAIAPIKey + } + + public static func development(environment: [String: String] = ProcessInfo.processInfo.environment) -> AppConfiguration { + AppConfiguration( + openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"] + ) + } +} diff --git a/Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift b/Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift new file mode 100644 index 0000000..6cf9515 --- /dev/null +++ b/Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift @@ -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." + ) +} diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift new file mode 100644 index 0000000..aab5e60 --- /dev/null +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -0,0 +1,610 @@ +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? + public var key: ManualAutoValue? + public var scale: ManualAutoValue? + public var maqam: ManualAutoValue? + 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? = nil, + key: ManualAutoValue? = nil, + scale: ManualAutoValue? = nil, + maqam: ManualAutoValue? = 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 { + 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 selectedInstrumentTracks: [InstrumentTrack] { + instruments.filter(\.selected) + } + + var selectedInstrumentIDs: Set { + 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] + ) + ) + } + } + + 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 + } + } +} + +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: 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 userNotes: String? + public var preservedSpellings: [String] + + public init(isEnabled: Bool = false, userNotes: String? = nil, preservedSpellings: [String] = []) { + self.isEnabled = isEnabled + self.userNotes = userNotes + self.preservedSpellings = preservedSpellings + } +} + +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? + public var key: ManualAutoValue? + public var scale: ManualAutoValue? + public var maqam: ManualAutoValue? + 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? = nil, + key: ManualAutoValue? = nil, + scale: ManualAutoValue? = nil, + maqam: ManualAutoValue? = 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 + ) + } +} diff --git a/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift b/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift new file mode 100644 index 0000000..ce52025 --- /dev/null +++ b/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift @@ -0,0 +1,3 @@ +public protocol SunoHandoffService: Sendable { + func handOff(_ output: CompiledSunoOutput) async throws +} diff --git a/Sources/MusicAssistantCore/Persistence/ProjectStore.swift b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift new file mode 100644 index 0000000..2b4715f --- /dev/null +++ b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift @@ -0,0 +1,6 @@ +public protocol ProjectStore: Sendable { + associatedtype Project + + func save(_ project: Project) async throws + func loadProjectIDs() async throws -> [String] +} diff --git a/Sources/MusicAssistantCore/Services/AI/AIService.swift b/Sources/MusicAssistantCore/Services/AI/AIService.swift new file mode 100644 index 0000000..1aae9e6 --- /dev/null +++ b/Sources/MusicAssistantCore/Services/AI/AIService.swift @@ -0,0 +1,19 @@ +public protocol AIService: Sendable { + func planSongProject(from request: SongPlanningRequest) async throws -> SongPlanningDraft +} + +public struct SongPlanningRequest: Equatable, Sendable { + public let userIntent: String + + public init(userIntent: String) { + self.userIntent = userIntent + } +} + +public struct SongPlanningDraft: Equatable, Sendable { + public let summary: String + + public init(summary: String) { + self.summary = summary + } +} diff --git a/Sources/MusicAssistantCore/Services/PromptCompiler/PromptCompiling.swift b/Sources/MusicAssistantCore/Services/PromptCompiler/PromptCompiling.swift new file mode 100644 index 0000000..60cc6f7 --- /dev/null +++ b/Sources/MusicAssistantCore/Services/PromptCompiler/PromptCompiling.swift @@ -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 + } +} diff --git a/Tests/MusicAssistantCoreTests/AppConfigurationTests.swift b/Tests/MusicAssistantCoreTests/AppConfigurationTests.swift new file mode 100644 index 0000000..4566867 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/AppConfigurationTests.swift @@ -0,0 +1,18 @@ +import MusicAssistantCore +import XCTest + +final class AppConfigurationTests: XCTestCase { + func testDevelopmentConfigurationReadsKnownEnvironmentKeys() { + let configuration = AppConfiguration.development(environment: [ + "MUSIC_ASSISTANT_OPENAI_API_KEY": "openai-test-key" + ]) + + XCTAssertEqual(configuration.openAIAPIKey, "openai-test-key") + } + + func testDevelopmentConfigurationAllowsMissingSecrets() { + let configuration = AppConfiguration.development(environment: [:]) + + XCTAssertNil(configuration.openAIAPIKey) + } +} diff --git a/Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift b/Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift new file mode 100644 index 0000000..c7e2468 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift @@ -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")) + } +} diff --git a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift new file mode 100644 index 0000000..896e4a5 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift @@ -0,0 +1,194 @@ +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 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]) + } +} diff --git a/docs/TASKS.md b/docs/TASKS.md index f253bc6..d921947 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -19,11 +19,11 @@ requirement is missing and blocks implementation, record it in ## Phase 1 --- Domain Model -- [ ] Implement `SongProject` and supporting models from +- [x] Implement `SongProject` and supporting models from `DATA_MODEL.md`. -- [ ] Model genres/styles as multi-select. -- [ ] Model song sections with order and optional timestamps. -- [ ] Model instruments, variants, roles and placements. +- [x] Model genres/styles as multi-select. +- [x] Model song sections with order and optional timestamps. +- [x] Model instruments, variants, roles and placements. - [ ] Model multiple vocalists and section assignments. - [ ] Model language/dialect and Arabic pronunciation settings. - [ ] Model BPM, key, scale and maqam with Manual/Auto state. diff --git a/music-assistant-build-files.md b/music-assistant-build-files.md new file mode 100644 index 0000000..2fc2677 --- /dev/null +++ b/music-assistant-build-files.md @@ -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' + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + music-assistant + CFBundleExecutable + music-assistant + CFBundleIdentifier + com.music-assistant.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleIconFile + music-assistant + CFBundleName + music-assistant + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +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' + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + music-assistant + CFBundleExecutable + music-assistant + CFBundleIdentifier + com.music-assistant.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleIconFile + music-assistant + CFBundleName + music-assistant + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +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`.