Implement initial song project domain models
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
# Local development only. Do not commit real values.
|
||||||
|
MUSIC_ASSISTANT_OPENAI_API_KEY=
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.build/
|
||||||
|
.swiftpm/
|
||||||
|
DerivedData/
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
*.xcuserdata
|
||||||
|
*.xcuserstate
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.PHONY: build dev test
|
||||||
|
|
||||||
|
build:
|
||||||
|
swift build
|
||||||
|
|
||||||
|
dev:
|
||||||
|
swift run music-assistant
|
||||||
|
|
||||||
|
test:
|
||||||
|
swift test
|
||||||
@@ -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,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()
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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<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 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]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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 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<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,3 @@
|
|||||||
|
public protocol SunoHandoffService: Sendable {
|
||||||
|
func handOff(_ output: CompiledSunoOutput) async throws
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
public protocol ProjectStore: Sendable {
|
||||||
|
associatedtype Project
|
||||||
|
|
||||||
|
func save(_ project: Project) async throws
|
||||||
|
func loadProjectIDs() async throws -> [String]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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])
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -19,11 +19,11 @@ requirement is missing and blocks implementation, record it in
|
|||||||
|
|
||||||
## Phase 1 --- Domain Model
|
## Phase 1 --- Domain Model
|
||||||
|
|
||||||
- [ ] Implement `SongProject` and supporting models from
|
- [x] Implement `SongProject` and supporting models from
|
||||||
`DATA_MODEL.md`.
|
`DATA_MODEL.md`.
|
||||||
- [ ] Model genres/styles as multi-select.
|
- [x] Model genres/styles as multi-select.
|
||||||
- [ ] Model song sections with order and optional timestamps.
|
- [x] Model song sections with order and optional timestamps.
|
||||||
- [ ] Model instruments, variants, roles and placements.
|
- [x] Model instruments, variants, roles and placements.
|
||||||
- [ ] Model multiple vocalists and section assignments.
|
- [ ] Model multiple vocalists and section assignments.
|
||||||
- [ ] Model language/dialect and Arabic pronunciation settings.
|
- [ ] Model language/dialect and Arabic pronunciation settings.
|
||||||
- [ ] Model BPM, key, scale and maqam with Manual/Auto state.
|
- [ ] Model BPM, key, scale and maqam with Manual/Auto state.
|
||||||
|
|||||||
@@ -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`.
|
||||||
Reference in New Issue
Block a user