Implement initial song project domain models

This commit is contained in:
Codex
2026-09-13 13:18:08 +02:00
parent 57867bb3cc
commit 1993800f27
19 changed files with 1359 additions and 4 deletions
+2
View File
@@ -0,0 +1,2 @@
# Local development only. Do not commit real values.
MUSIC_ASSISTANT_OPENAI_API_KEY=
+7
View File
@@ -0,0 +1,7 @@
.build/
.swiftpm/
DerivedData/
.DS_Store
.env
*.xcuserdata
*.xcuserstate
+16
View File
@@ -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.
+10
View File
@@ -0,0 +1,10 @@
.PHONY: build dev test
build:
swift build
dev:
swift run music-assistant
test:
swift test
+37
View File
@@ -0,0 +1,37 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "music-assistant",
defaultLocalization: "en",
platforms: [
.macOS(.v14)
],
products: [
.executable(
name: "music-assistant",
targets: ["MusicAssistantApp"]
),
.library(
name: "MusicAssistantCore",
targets: ["MusicAssistantCore"]
)
],
targets: [
.executableTarget(
name: "MusicAssistantApp",
dependencies: ["MusicAssistantCore"],
path: "Sources/MusicAssistantApp"
),
.target(
name: "MusicAssistantCore",
path: "Sources/MusicAssistantCore"
),
.testTarget(
name: "MusicAssistantCoreTests",
dependencies: ["MusicAssistantCore"],
path: "Tests/MusicAssistantCoreTests"
)
]
)
@@ -0,0 +1,11 @@
import SwiftUI
@main
struct MusicAssistantApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
.windowStyle(.titleBar)
}
}
@@ -0,0 +1,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
View File
@@ -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.
+325
View File
@@ -0,0 +1,325 @@
# Build Files Template for music-assistant
Use this document as the prompt/instruction file for an agent when you create the
`music-assistant` Swift macOS app project.
The target project does not need to match the Servery path. These files should be
created in the root of the future `music-assistant` project, next to
`Package.swift`.
## Agent Request
Copy this request into the agent working inside the new project:
```text
Create the same build-command structure for this Swift macOS app.
App name:
music-assistant
Product name:
music-assistant
Bundle identifier:
com.music-assistant.app
Requirements:
1. Create `Makefile` with `build`, `dev`, and `test` commands.
2. Create `clean_rebuild.sh` for a clean release build, packaging, signing,
installing into `/Applications`, and launching the app.
3. Create `dev_rebuild.sh` for a fast incremental debug build, packaging,
installing into `/Applications`, and launching the app.
4. Assume these files live in the project root next to `Package.swift`.
5. Use the app icon at `Resources/music-assistant.icns`.
6. If the app has additional resources, copy them into
`Contents/Resources` during packaging.
7. Make both shell scripts executable.
8. Keep the implementation small and local. Do not refactor unrelated project
files.
9. After creating the files, verify that the scripts reference the correct app
name, product name, bundle identifier, icon path, and install path.
```
## Makefile
Create this file as `Makefile`:
```makefile
.PHONY: build dev test
build:
./clean_rebuild.sh
dev:
./dev_rebuild.sh
test:
swift test
```
## dev_rebuild.sh
Create this file as `dev_rebuild.sh`:
```zsh
#!/bin/zsh
# Fast development rebuild: incremental DEBUG build, repackage, relaunch.
#
# This skips `swift package clean` and release-mode optimizations, so it reuses
# the incremental build cache and finishes quickly. Use it while iterating.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
APP_NAME="music-assistant"
PRODUCT_NAME="music-assistant"
APP_BUNDLE_NAME="${APP_NAME}.app"
BUILD_DIR="${PROJECT_ROOT}/.build"
APP_BUNDLE="${BUILD_DIR}/${APP_BUNDLE_NAME}"
INSTALL_PATH="/Applications/${APP_BUNDLE_NAME}"
CONTENTS_DIR="${APP_BUNDLE}/Contents"
MACOS_DIR="${CONTENTS_DIR}/MacOS"
RESOURCES_DIR="${CONTENTS_DIR}/Resources"
APP_ICON="${PROJECT_ROOT}/Resources/music-assistant.icns"
fail() {
print -u2 -- "Error: $1"
exit 1
}
step() {
print -- "==> $1"
}
[[ -f "${PROJECT_ROOT}/Package.swift" ]] || fail "Package.swift was not found in ${PROJECT_ROOT}."
[[ -f "${APP_ICON}" ]] || fail "App icon was not found at ${APP_ICON}."
command -v swift >/dev/null 2>&1 || fail "Swift is required. Install Xcode or the Xcode Command Line Tools."
command -v codesign >/dev/null 2>&1 || fail "codesign is required and should be available with Xcode."
cd "${PROJECT_ROOT}"
step "Stopping any running ${APP_NAME} application"
if pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1; then
pkill -x "${PRODUCT_NAME}" || fail "Could not stop the running ${APP_NAME} application."
for _ in {1..20}; do
pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 || break
sleep 0.1
done
pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 && fail "${APP_NAME} is still running. Quit it and retry."
fi
step "Building ${PRODUCT_NAME} in debug mode (incremental)"
swift build || fail "Debug build failed. Review the Swift compiler diagnostics above."
BIN_PATH="$(swift build --show-bin-path 2>/dev/null | tail -n1)"
SOURCE_EXECUTABLE="${BIN_PATH}/${PRODUCT_NAME}"
[[ -x "${SOURCE_EXECUTABLE}" ]] || fail "Built executable was not found at ${SOURCE_EXECUTABLE}."
step "Packaging ${APP_BUNDLE_NAME}"
rm -rf "${APP_BUNDLE}"
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
cp "${SOURCE_EXECUTABLE}" "${MACOS_DIR}/${PRODUCT_NAME}"
cp "${APP_ICON}" "${RESOURCES_DIR}/music-assistant.icns"
cat > "${CONTENTS_DIR}/Info.plist" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>music-assistant</string>
<key>CFBundleExecutable</key>
<string>music-assistant</string>
<key>CFBundleIdentifier</key>
<string>com.music-assistant.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleIconFile</key>
<string>music-assistant</string>
<key>CFBundleName</key>
<string>music-assistant</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>
PLIST
plutil -lint "${CONTENTS_DIR}/Info.plist" >/dev/null || fail "Generated Info.plist is invalid."
codesign --force --sign - --timestamp=none "${APP_BUNDLE}" || fail "Ad-hoc signing of ${APP_BUNDLE_NAME} failed."
[[ -x "${MACOS_DIR}/${PRODUCT_NAME}" ]] || fail "Packaged executable is missing."
step "Installing ${APP_BUNDLE_NAME} in /Applications"
rm -rf "${INSTALL_PATH}"
ditto "${APP_BUNDLE}" "${INSTALL_PATH}" || fail "Could not install ${APP_BUNDLE_NAME} in /Applications. Check permissions and retry."
step "Launching ${APP_NAME}"
open -n "${INSTALL_PATH}" || fail "${APP_NAME} was installed but could not be opened."
print -- "==> ${APP_NAME} was rebuilt (debug), installed, and launched successfully."
```
## clean_rebuild.sh
Create this file as `clean_rebuild.sh`:
```zsh
#!/bin/zsh
# Build, package, install, and launch the music-assistant macOS application.
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)"
APP_NAME="music-assistant"
PRODUCT_NAME="music-assistant"
APP_BUNDLE_NAME="${APP_NAME}.app"
BUILD_DIR="${PROJECT_ROOT}/.build"
APP_BUNDLE="${BUILD_DIR}/${APP_BUNDLE_NAME}"
INSTALL_PATH="/Applications/${APP_BUNDLE_NAME}"
CONTENTS_DIR="${APP_BUNDLE}/Contents"
MACOS_DIR="${CONTENTS_DIR}/MacOS"
RESOURCES_DIR="${CONTENTS_DIR}/Resources"
APP_ICON="${PROJECT_ROOT}/Resources/music-assistant.icns"
fail() {
print -u2 -- "Error: $1"
exit 1
}
step() {
print -- "==> $1"
}
[[ -f "${PROJECT_ROOT}/Package.swift" ]] || fail "Package.swift was not found in ${PROJECT_ROOT}."
[[ -f "${APP_ICON}" ]] || fail "App icon was not found at ${APP_ICON}."
command -v swift >/dev/null 2>&1 || fail "Swift is required. Install Xcode or the Xcode Command Line Tools."
command -v codesign >/dev/null 2>&1 || fail "codesign is required and should be available with Xcode."
cd "${PROJECT_ROOT}"
step "Stopping any running ${APP_NAME} application"
if pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1; then
pkill -x "${PRODUCT_NAME}" || fail "Could not stop the running ${APP_NAME} application."
for _ in {1..20}; do
pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 || break
sleep 0.1
done
pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 && fail "${APP_NAME} is still running. Quit it and retry."
fi
step "Removing stale installed copy"
if [[ -e "${INSTALL_PATH}" ]]; then
rm -rf "${INSTALL_PATH}" || fail "Could not remove ${INSTALL_PATH}. Check permissions and retry."
fi
step "Cleaning local Swift build artifacts"
swift package clean || fail "Swift Package Manager could not clean the local build artifacts."
step "Building ${PRODUCT_NAME} in release mode"
BIN_PATH="$(swift build -c release --show-bin-path 2>/dev/null | tail -n1)"
SOURCE_EXECUTABLE="${BIN_PATH}/${PRODUCT_NAME}"
built=0
for attempt in 1 2 3; do
swift build -c release || fail "Release build failed. Review the Swift compiler diagnostics above."
if [[ -x "${SOURCE_EXECUTABLE}" ]]; then built=1; break; fi
print -- "==> Executable not present after build (attempt ${attempt}); retrying"
sleep 1
done
[[ "${built}" -eq 1 ]] || fail "Built executable was not found at ${SOURCE_EXECUTABLE}."
step "Packaging ${APP_BUNDLE_NAME}"
rm -rf "${APP_BUNDLE}"
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
cp "${SOURCE_EXECUTABLE}" "${MACOS_DIR}/${PRODUCT_NAME}"
cp "${APP_ICON}" "${RESOURCES_DIR}/music-assistant.icns"
cat > "${CONTENTS_DIR}/Info.plist" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>music-assistant</string>
<key>CFBundleExecutable</key>
<string>music-assistant</string>
<key>CFBundleIdentifier</key>
<string>com.music-assistant.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleIconFile</key>
<string>music-assistant</string>
<key>CFBundleName</key>
<string>music-assistant</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>
PLIST
plutil -lint "${CONTENTS_DIR}/Info.plist" >/dev/null || fail "Generated Info.plist is invalid."
codesign --force --sign - --timestamp=none "${APP_BUNDLE}" || fail "Ad-hoc signing of ${APP_BUNDLE_NAME} failed."
codesign --verify --deep --strict "${APP_BUNDLE}" || fail "Packaged ${APP_BUNDLE_NAME} failed code-signature verification."
[[ -x "${MACOS_DIR}/${PRODUCT_NAME}" ]] || fail "Packaged executable is missing."
step "Installing ${APP_BUNDLE_NAME} in /Applications"
ditto "${APP_BUNDLE}" "${INSTALL_PATH}" || fail "Could not install ${APP_BUNDLE_NAME} in /Applications. Check permissions and retry."
INSTALLED_EXECUTABLE="${INSTALL_PATH}/Contents/MacOS/${PRODUCT_NAME}"
[[ -x "${INSTALLED_EXECUTABLE}" ]] || fail "Installed executable is missing at ${INSTALLED_EXECUTABLE}."
SOURCE_HASH="$(shasum -a 256 "${MACOS_DIR}/${PRODUCT_NAME}" | awk '{print $1}')"
INSTALLED_HASH="$(shasum -a 256 "${INSTALLED_EXECUTABLE}" | awk '{print $1}')"
[[ "${SOURCE_HASH}" == "${INSTALLED_HASH}" ]] || fail "Installed executable does not match the newly built executable."
step "Launching ${APP_NAME}"
open -n "${INSTALL_PATH}" || fail "${APP_NAME} was installed but could not be opened."
print -- "==> ${APP_NAME} was rebuilt, installed, and launched successfully."
```
## Required Terminal Step
After the files are created in the new project, run:
```bash
chmod +x clean_rebuild.sh dev_rebuild.sh
```
Then use:
```bash
make dev
make build
make test
```
## Notes
- The future project must have `Package.swift`.
- The future project must have an icon at `Resources/music-assistant.icns`.
- If the executable product name in `Package.swift` is different, change
`PRODUCT_NAME` in both scripts.
- If the app needs permissions, add the matching usage-description keys to the
generated `Info.plist`.