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
}
}
}