From bf1ccbe8a9c4b64d6919f48291bb444b7851e40b Mon Sep 17 00:00:00 2001 From: diyaa Date: Sun, 13 Sep 2026 20:59:25 +0200 Subject: [PATCH] Implement automatic song project decisions --- .../Domain/SongProject.swift | 32 +++ .../AI/AutoSongProjectDecisionsDirector.swift | 234 ++++++++++++++++++ ...utoSongProjectDecisionsDirectorTests.swift | 213 ++++++++++++++++ docs/ARCHITECTURE.md | 3 + docs/DATA_MODEL.md | 11 + docs/TASKS.md | 2 +- 6 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 Sources/MusicAssistantCore/Services/AI/AutoSongProjectDecisionsDirector.swift create mode 100644 Tests/MusicAssistantCoreTests/AutoSongProjectDecisionsDirectorTests.swift diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift index 169ed81..ae06755 100644 --- a/Sources/MusicAssistantCore/Domain/SongProject.swift +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -16,11 +16,13 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable { public var key: ManualAutoValue? public var scale: ManualAutoValue? public var maqam: ManualAutoValue? + public var structureMode: ControlMode? public var sections: [SongSection] public var instruments: [InstrumentTrack] public var vocalists: [Vocalist] public var lyrics: Lyrics public var productionDirections: [ProductionDirection] + public var productionMode: ControlMode? public var sunoOutput: SunoOutput? public var versions: [SongVersion] public let createdAt: Date @@ -42,11 +44,13 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable { key: ManualAutoValue? = nil, scale: ManualAutoValue? = nil, maqam: ManualAutoValue? = nil, + structureMode: ControlMode? = .auto, sections: [SongSection] = [], instruments: [InstrumentTrack] = [], vocalists: [Vocalist] = [], lyrics: Lyrics = Lyrics(), productionDirections: [ProductionDirection] = [], + productionMode: ControlMode? = .auto, sunoOutput: SunoOutput? = nil, versions: [SongVersion] = [], createdAt: Date = Date(), @@ -67,11 +71,13 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable { self.key = key self.scale = scale self.maqam = maqam + self.structureMode = structureMode self.sections = sections self.instruments = instruments self.vocalists = vocalists self.lyrics = lyrics self.productionDirections = productionDirections + self.productionMode = productionMode self.sunoOutput = sunoOutput self.versions = versions self.createdAt = createdAt @@ -80,6 +86,22 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable { } public extension SongProject { + var isStructureAutomatic: Bool { + structureMode != .manual + } + + var isProductionAutomatic: Bool { + productionMode != .manual + } + + mutating func setStructureMode(_ mode: ControlMode) { + structureMode = mode + } + + mutating func setProductionMode(_ mode: ControlMode) { + productionMode = mode + } + var orderedSections: [SongSection] { sections .enumerated() @@ -364,11 +386,13 @@ public extension SongProject { key = snapshot.key scale = snapshot.scale maqam = snapshot.maqam + structureMode = snapshot.structureMode sections = snapshot.sections instruments = snapshot.instruments vocalists = snapshot.vocalists lyrics = snapshot.lyrics productionDirections = snapshot.productionDirections + productionMode = snapshot.productionMode sunoOutput = snapshot.sunoOutput self.updatedAt = updatedAt } @@ -764,11 +788,13 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable { public var key: ManualAutoValue? public var scale: ManualAutoValue? public var maqam: ManualAutoValue? + public var structureMode: ControlMode? public var sections: [SongSection] public var instruments: [InstrumentTrack] public var vocalists: [Vocalist] public var lyrics: Lyrics public var productionDirections: [ProductionDirection] + public var productionMode: ControlMode? public var sunoOutput: SunoOutput? public init( @@ -786,11 +812,13 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable { key: ManualAutoValue? = nil, scale: ManualAutoValue? = nil, maqam: ManualAutoValue? = nil, + structureMode: ControlMode? = .auto, sections: [SongSection] = [], instruments: [InstrumentTrack] = [], vocalists: [Vocalist] = [], lyrics: Lyrics = Lyrics(), productionDirections: [ProductionDirection] = [], + productionMode: ControlMode? = .auto, sunoOutput: SunoOutput? = nil ) { self.title = title @@ -807,11 +835,13 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable { self.key = key self.scale = scale self.maqam = maqam + self.structureMode = structureMode self.sections = sections self.instruments = instruments self.vocalists = vocalists self.lyrics = lyrics self.productionDirections = productionDirections + self.productionMode = productionMode self.sunoOutput = sunoOutput } @@ -831,11 +861,13 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable { key: project.key, scale: project.scale, maqam: project.maqam, + structureMode: project.structureMode, sections: project.sections, instruments: project.instruments, vocalists: project.vocalists, lyrics: project.lyrics, productionDirections: project.productionDirections, + productionMode: project.productionMode, sunoOutput: project.sunoOutput ) } diff --git a/Sources/MusicAssistantCore/Services/AI/AutoSongProjectDecisionsDirector.swift b/Sources/MusicAssistantCore/Services/AI/AutoSongProjectDecisionsDirector.swift new file mode 100644 index 0000000..5b9dd83 --- /dev/null +++ b/Sources/MusicAssistantCore/Services/AI/AutoSongProjectDecisionsDirector.swift @@ -0,0 +1,234 @@ +import Foundation + +public final class AutoSongProjectDecisionsDirector: Sendable { + private let aiService: any AIService + + public init(aiService: any AIService) { + self.aiService = aiService + } + + public func applyAutomaticDecisions( + to project: SongProject, + instruction: String? = nil, + conversation: [AIConversationMessage] = [], + localeIdentifier: String? = nil, + updatedAt: Date = Date() + ) async throws -> SongProjectUpdateResult { + let allowedScopes = automaticScopes(for: project) + guard !allowedScopes.isEmpty else { + return SongProjectUpdateResult( + project: project, + notes: ["No automatic decisions are enabled."] + ) + } + + let trimmedInstruction = instruction?.trimmingCharacters(in: .whitespacesAndNewlines) + let request = SongProjectUpdateRequest( + context: AIRequestContext( + userInstruction: trimmedInstruction?.isEmpty == false + ? trimmedInstruction! + : "Complete the enabled automatic song-project decisions.", + conversation: conversation, + localeIdentifier: localeIdentifier + ), + project: project, + allowedScopes: allowedScopes + ) + let result = try await aiService.proposeProjectUpdate(from: request) + var updatedProject = mergeAutomaticDecisions( + from: result.project, + into: project + ) + updatedProject.updatedAt = updatedAt + + return SongProjectUpdateResult( + project: updatedProject, + changedScopes: changedScopes(from: project, to: updatedProject), + notes: result.notes, + followUpQuestions: result.followUpQuestions + ) + } + + private func automaticScopes(for project: SongProject) -> [SongProjectUpdateScope] { + var scopes: [SongProjectUpdateScope] = [] + + if project.isStructureAutomatic { + scopes.append(.structure) + } + + if project.instruments.contains(where: { $0.selected && $0.autoArrangementEnabled }) { + scopes.append(.arrangement) + } + + if project.bpm?.mode != .manual || + project.key?.mode != .manual || + project.scale?.mode != .manual || + project.maqam?.mode != .manual { + scopes.append(.musicalParameters) + } + + if project.isProductionAutomatic { + scopes.append(.productionDirections) + } + + return scopes + } + + private func mergeAutomaticDecisions( + from proposedProject: SongProject, + into project: SongProject + ) -> SongProject { + var updatedProject = project + + if project.isStructureAutomatic { + updatedProject.sections = proposedProject.sections + } + + updatedProject.instruments = mergedInstruments( + current: project.instruments, + proposed: proposedProject.instruments + ) + updatedProject.bpm = automaticValue(current: project.bpm, proposed: proposedProject.bpm) + updatedProject.key = automaticValue(current: project.key, proposed: proposedProject.key) + updatedProject.scale = automaticValue(current: project.scale, proposed: proposedProject.scale) + updatedProject.maqam = automaticValue(current: project.maqam, proposed: proposedProject.maqam) + + if project.isProductionAutomatic { + updatedProject.productionDirections = proposedProject.productionDirections + updatedProject.sections = sections( + updatedProject.sections, + applyingProductionDirectionsFrom: proposedProject.sections + ) + } else { + updatedProject.sections = sections( + updatedProject.sections, + preservingProductionDirectionsFrom: project.sections + ) + } + + return updatedProject + } + + private func mergedInstruments( + current: [InstrumentTrack], + proposed: [InstrumentTrack] + ) -> [InstrumentTrack] { + var proposedByID: [String: InstrumentTrack] = [:] + for proposedTrack in proposed { + proposedByID[proposedTrack.instrumentId] = proposedTrack + } + + return current.map { currentTrack in + guard currentTrack.selected, + currentTrack.autoArrangementEnabled, + let proposedTrack = proposedByID[currentTrack.instrumentId] + else { + return currentTrack + } + + var mergedTrack = currentTrack + mergedTrack.playingStyle = proposedTrack.playingStyle + mergedTrack.role = proposedTrack.role + mergedTrack.placements = proposedTrack.placements + return mergedTrack + } + } + + private func automaticValue( + current: ManualAutoValue?, + proposed: ManualAutoValue? + ) -> ManualAutoValue? where Value: Codable & Equatable & Sendable { + guard current?.mode != .manual, let proposed else { + return current + } + + return ManualAutoValue(mode: .auto, value: proposed.value) + } + + private func sections( + _ current: [SongSection], + applyingProductionDirectionsFrom proposed: [SongSection] + ) -> [SongSection] { + var proposedByID: [String: SongSection] = [:] + for proposedSection in proposed { + proposedByID[proposedSection.id] = proposedSection + } + + return current.map { section in + guard let proposedSection = proposedByID[section.id] else { return section } + var updatedSection = section + updatedSection.productionDirection = proposedSection.productionDirection + return updatedSection + } + } + + private func sections( + _ current: [SongSection], + preservingProductionDirectionsFrom existing: [SongSection] + ) -> [SongSection] { + var existingByID: [String: SongSection] = [:] + for existingSection in existing { + existingByID[existingSection.id] = existingSection + } + + return current.map { section in + var updatedSection = section + updatedSection.productionDirection = existingByID[section.id]?.productionDirection + return updatedSection + } + } + + private func changedScopes( + from project: SongProject, + to updatedProject: SongProject + ) -> [SongProjectUpdateScope] { + var scopes: [SongProjectUpdateScope] = [] + + if project.isStructureAutomatic && + sectionsDifferIgnoringProductionDirections(project.sections, updatedProject.sections) { + scopes.append(.structure) + } + if project.instruments != updatedProject.instruments { + scopes.append(.arrangement) + } + if project.bpm != updatedProject.bpm || + project.key != updatedProject.key || + project.scale != updatedProject.scale || + project.maqam != updatedProject.maqam { + scopes.append(.musicalParameters) + } + if project.isProductionAutomatic && + (project.productionDirections != updatedProject.productionDirections || + productionDirectionsDiffer(in: project.sections, and: updatedProject.sections)) { + scopes.append(.productionDirections) + } + + return scopes + } + + private func sectionsDifferIgnoringProductionDirections( + _ current: [SongSection], + _ updated: [SongSection] + ) -> Bool { + guard current.count == updated.count else { return true } + + return zip(current, updated).contains { currentSection, updatedSection in + var currentWithoutProduction = currentSection + var updatedWithoutProduction = updatedSection + currentWithoutProduction.productionDirection = nil + updatedWithoutProduction.productionDirection = nil + return currentWithoutProduction != updatedWithoutProduction + } + } + + private func productionDirectionsDiffer( + in current: [SongSection], + and updated: [SongSection] + ) -> Bool { + guard current.count == updated.count else { return true } + + return zip(current, updated).contains { currentSection, updatedSection in + currentSection.productionDirection != updatedSection.productionDirection + } + } +} diff --git a/Tests/MusicAssistantCoreTests/AutoSongProjectDecisionsDirectorTests.swift b/Tests/MusicAssistantCoreTests/AutoSongProjectDecisionsDirectorTests.swift new file mode 100644 index 0000000..0b44efb --- /dev/null +++ b/Tests/MusicAssistantCoreTests/AutoSongProjectDecisionsDirectorTests.swift @@ -0,0 +1,213 @@ +import MusicAssistantCore +import XCTest + +final class AutoSongProjectDecisionsDirectorTests: XCTestCase { + func testAutomaticDecisionsRequestEnabledScopesAndPreserveManualChoices() async throws { + let currentProject = SongProject( + id: "auto-project", + title: "Current Project", + idea: "Build a cinematic Arabic pop song", + bpm: ManualAutoValue(mode: .auto), + key: ManualAutoValue(mode: .manual, value: "D"), + scale: ManualAutoValue(mode: .auto), + maqam: ManualAutoValue(mode: .manual, value: "Hijaz"), + structureMode: .manual, + sections: [ + SongSection(id: "verse", type: .verse, title: "Manual Verse", productionDirection: "Dry vocal") + ], + instruments: [ + InstrumentTrack( + instrumentId: "piano", + selected: true, + role: "Manual piano role", + autoArrangementEnabled: false, + placements: [InstrumentPlacement(sectionId: "verse", direction: "Manual entry")] + ), + InstrumentTrack( + instrumentId: "violin", + selected: true, + autoArrangementEnabled: true + ) + ], + productionDirections: [ProductionDirection(id: "manual-production", sectionID: "verse", text: "Keep it dry")], + productionMode: .manual, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 20) + ) + let proposedProject = SongProject( + title: "Proposed Project", + idea: "Different", + bpm: ManualAutoValue(mode: .manual, value: 102), + key: ManualAutoValue(mode: .auto, value: "A"), + scale: ManualAutoValue(mode: .manual, value: "minor"), + maqam: ManualAutoValue(mode: .auto, value: "Bayati"), + sections: [ + SongSection(id: "chorus", type: .chorus, title: "AI Chorus", productionDirection: "Wide synths") + ], + instruments: [ + InstrumentTrack( + instrumentId: "piano", + selected: true, + role: "AI piano role", + placements: [InstrumentPlacement(sectionId: "chorus", direction: "AI piano entry")] + ), + InstrumentTrack( + instrumentId: "violin", + selected: true, + playingStyle: "legato", + role: "AI countermelody", + placements: [InstrumentPlacement(sectionId: "chorus", direction: "Enter at chorus")] + ) + ], + productionDirections: [ProductionDirection(id: "ai-production", sectionID: "chorus", text: "Wide synths")] + ) + let aiService = RecordingAutoDecisionsAIService( + result: SongProjectUpdateResult( + project: proposedProject, + changedScopes: [.structure, .arrangement, .musicalParameters, .productionDirections], + notes: ["Applied automatic decisions."] + ) + ) + let director = AutoSongProjectDecisionsDirector(aiService: aiService) + + let result = try await director.applyAutomaticDecisions( + to: currentProject, + instruction: " Complete automatic values. ", + localeIdentifier: "en_US", + updatedAt: Date(timeIntervalSince1970: 100) + ) + let request = await aiService.recordedRequests.first + + XCTAssertEqual(request?.context.userInstruction, "Complete automatic values.") + XCTAssertEqual(request?.context.localeIdentifier, "en_US") + XCTAssertEqual( + request?.allowedScopes ?? [], + [SongProjectUpdateScope.arrangement, .musicalParameters] + ) + XCTAssertEqual(result.project.sections, currentProject.sections) + XCTAssertEqual(result.project.productionDirections, currentProject.productionDirections) + XCTAssertEqual(result.project.bpm, ManualAutoValue(mode: .auto, value: 102)) + XCTAssertEqual(result.project.key, currentProject.key) + XCTAssertEqual(result.project.scale, ManualAutoValue(mode: .auto, value: "minor")) + XCTAssertEqual(result.project.maqam, currentProject.maqam) + XCTAssertEqual(result.project.instrumentTrack(for: "piano"), currentProject.instrumentTrack(for: "piano")) + XCTAssertEqual(result.project.instrumentTrack(for: "violin")?.role, "AI countermelody") + XCTAssertEqual(result.project.instrumentTrack(for: "violin")?.playingStyle, "legato") + XCTAssertEqual(result.project.instrumentTrack(for: "violin")?.placements, [InstrumentPlacement(sectionId: "chorus", direction: "Enter at chorus")]) + XCTAssertEqual(result.project.updatedAt, Date(timeIntervalSince1970: 100)) + XCTAssertEqual( + result.changedScopes, + [SongProjectUpdateScope.arrangement, .musicalParameters] + ) + XCTAssertEqual(result.notes, ["Applied automatic decisions."]) + } + + func testAutomaticDecisionsApplyStructureAndProductionWhenTheirModesAreAutomatic() async throws { + let currentProject = SongProject( + title: "Auto Project", + idea: "Let AI plan it", + structureMode: .auto, + sections: [SongSection(id: "intro", type: .intro, title: "Current Intro")], + productionMode: .auto + ) + let proposedProject = SongProject( + title: "Proposed", + idea: "Proposed", + sections: [SongSection(id: "verse", type: .verse, title: "AI Verse", productionDirection: "Add texture")], + productionDirections: [ProductionDirection(id: "production", sectionID: "verse", text: "Add texture")] + ) + let aiService = RecordingAutoDecisionsAIService( + result: SongProjectUpdateResult(project: proposedProject) + ) + let director = AutoSongProjectDecisionsDirector(aiService: aiService) + + let result = try await director.applyAutomaticDecisions(to: currentProject, updatedAt: Date(timeIntervalSince1970: 200)) + let request = await aiService.recordedRequests.first + + XCTAssertEqual( + request?.allowedScopes ?? [], + [.structure, .musicalParameters, .productionDirections] + ) + XCTAssertEqual(result.project.sections, proposedProject.sections) + XCTAssertEqual(result.project.productionDirections, proposedProject.productionDirections) + XCTAssertEqual(result.changedScopes, [.structure, .productionDirections]) + } + + func testAutomaticDecisionsDoNotCallAIWhenEveryDecisionIsManual() async throws { + let project = SongProject( + title: "Manual Project", + idea: "Keep all choices", + bpm: ManualAutoValue(mode: .manual, value: 96), + key: ManualAutoValue(mode: .manual, value: "C"), + scale: ManualAutoValue(mode: .manual, value: "major"), + maqam: ManualAutoValue(mode: .manual, value: "Rast"), + structureMode: .manual, + instruments: [InstrumentTrack(instrumentId: "oud", selected: true, autoArrangementEnabled: false)], + productionMode: .manual + ) + let aiService = RecordingAutoDecisionsAIService( + result: SongProjectUpdateResult(project: project) + ) + let director = AutoSongProjectDecisionsDirector(aiService: aiService) + + let result = try await director.applyAutomaticDecisions(to: project) + let requestCount = await aiService.recordedRequests.count + + XCTAssertEqual(requestCount, 0) + XCTAssertEqual(result.project, project) + XCTAssertTrue(result.changedScopes.isEmpty) + XCTAssertEqual(result.notes, ["No automatic decisions are enabled."]) + } + + func testAutomaticStructureDoesNotReplaceManualProductionDirections() async throws { + let project = SongProject( + title: "Mixed Modes", + idea: "Change the structure only", + bpm: ManualAutoValue(mode: .manual, value: 100), + key: ManualAutoValue(mode: .manual, value: "C"), + scale: ManualAutoValue(mode: .manual, value: "minor"), + maqam: ManualAutoValue(mode: .manual, value: "Rast"), + structureMode: .auto, + sections: [SongSection(id: "verse", type: .verse, title: "Current Verse", productionDirection: "Keep dry")], + productionDirections: [ProductionDirection(id: "manual-production", sectionID: "verse", text: "Keep dry")], + productionMode: .manual + ) + let proposedProject = SongProject( + title: "Proposed", + idea: "Proposed", + sections: [SongSection(id: "verse", type: .chorus, title: "AI Chorus", productionDirection: "Add wide synths")], + productionDirections: [ProductionDirection(id: "ai-production", sectionID: "verse", text: "Add wide synths")] + ) + let aiService = RecordingAutoDecisionsAIService(result: SongProjectUpdateResult(project: proposedProject)) + let director = AutoSongProjectDecisionsDirector(aiService: aiService) + + let result = try await director.applyAutomaticDecisions(to: project) + + XCTAssertEqual(result.project.sections.first?.type, .chorus) + XCTAssertEqual(result.project.sections.first?.productionDirection, "Keep dry") + XCTAssertEqual(result.project.productionDirections, project.productionDirections) + XCTAssertEqual(result.changedScopes, [SongProjectUpdateScope.structure]) + } +} + +private actor RecordingAutoDecisionsAIService: AIService { + private(set) var recordedRequests: [SongProjectUpdateRequest] = [] + private let result: SongProjectUpdateResult + + init(result: SongProjectUpdateResult) { + self.result = result + } + + func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult { + SongProjectGenerationResult(project: request.seedProject ?? SongProject(title: "Unused", idea: "Unused")) + } + + func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult { + LyricsRevisionResult(lyrics: request.sourceLyrics) + } + + func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult { + recordedRequests.append(request) + return result + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 84556ec..5e084f7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,6 +40,9 @@ macOS App OpenAI receives the user's intent plus private application rules and structured project state. AI output should be requested as structured data wherever possible, not treated as an unstructured chat transcript. +For automatic decisions, the AI Director requests only enabled automatic +scopes and merges the response into the current project while preserving +manual structure, arrangement, musical-parameter and production choices. ## Prompt Compiler diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 31bf731..80b0e22 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -20,11 +20,13 @@ SongProject - key - scale - maqam +- structureMode: manual | auto - sections[] - instruments[] - vocalists[] - lyrics - productionDirections[] +- productionMode: manual | auto - sunoOutput - versions[] - createdAt @@ -59,6 +61,15 @@ InstrumentTrack - autoArrangementEnabled - placements[] +Auto-decision behavior +- The AI may update song sections only when structureMode is auto. +- The AI may update an instrument's role, playing style and placements only + when autoArrangementEnabled is true for that selected instrument. +- The AI may fill BPM, key, scale and maqam only when their individual + ManualAutoValue mode is auto. +- The AI may update production directions only when productionMode is auto. +- Manual values remain authoritative when automatic decisions are applied. + InstrumentPlacement - sectionId (optional) - startTime (optional) diff --git a/docs/TASKS.md b/docs/TASKS.md index 7417ea2..ab46b01 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -59,7 +59,7 @@ requirement is missing and blocks implementation, record it in - [x] Define structured AI response schema for SongProject updates. - [x] Implement idea → complete project generation. - [x] Implement existing lyrics → correction/improvement flow. -- [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam +- [x] Implement Auto mode for structure, arrangement, BPM/key/maqam and production decisions. - [ ] Implement optional Discuss mode. - [ ] Enforce user-lock/manual-value precedence over AI output.