Implement automatic song project decisions

This commit is contained in:
diyaa
2026-09-13 20:59:25 +02:00
parent 1bf84ca049
commit bf1ccbe8a9
6 changed files with 494 additions and 1 deletions
@@ -16,11 +16,13 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
public var key: ManualAutoValue<String>?
public var scale: ManualAutoValue<String>?
public var maqam: ManualAutoValue<String>?
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<String>? = nil,
scale: ManualAutoValue<String>? = nil,
maqam: ManualAutoValue<String>? = 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<String>?
public var scale: ManualAutoValue<String>?
public var maqam: ManualAutoValue<String>?
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<String>? = nil,
scale: ManualAutoValue<String>? = nil,
maqam: ManualAutoValue<String>? = 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
)
}
@@ -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<Value>(
current: ManualAutoValue<Value>?,
proposed: ManualAutoValue<Value>?
) -> ManualAutoValue<Value>? 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
}
}
}
@@ -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
}
}
+3
View File
@@ -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
+11
View File
@@ -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)
+1 -1
View File
@@ -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.