Implement automatic song project decisions
This commit is contained in:
@@ -16,11 +16,13 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
|
|||||||
public var key: ManualAutoValue<String>?
|
public var key: ManualAutoValue<String>?
|
||||||
public var scale: ManualAutoValue<String>?
|
public var scale: ManualAutoValue<String>?
|
||||||
public var maqam: ManualAutoValue<String>?
|
public var maqam: ManualAutoValue<String>?
|
||||||
|
public var structureMode: ControlMode?
|
||||||
public var sections: [SongSection]
|
public var sections: [SongSection]
|
||||||
public var instruments: [InstrumentTrack]
|
public var instruments: [InstrumentTrack]
|
||||||
public var vocalists: [Vocalist]
|
public var vocalists: [Vocalist]
|
||||||
public var lyrics: Lyrics
|
public var lyrics: Lyrics
|
||||||
public var productionDirections: [ProductionDirection]
|
public var productionDirections: [ProductionDirection]
|
||||||
|
public var productionMode: ControlMode?
|
||||||
public var sunoOutput: SunoOutput?
|
public var sunoOutput: SunoOutput?
|
||||||
public var versions: [SongVersion]
|
public var versions: [SongVersion]
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
@@ -42,11 +44,13 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
|
|||||||
key: ManualAutoValue<String>? = nil,
|
key: ManualAutoValue<String>? = nil,
|
||||||
scale: ManualAutoValue<String>? = nil,
|
scale: ManualAutoValue<String>? = nil,
|
||||||
maqam: ManualAutoValue<String>? = nil,
|
maqam: ManualAutoValue<String>? = nil,
|
||||||
|
structureMode: ControlMode? = .auto,
|
||||||
sections: [SongSection] = [],
|
sections: [SongSection] = [],
|
||||||
instruments: [InstrumentTrack] = [],
|
instruments: [InstrumentTrack] = [],
|
||||||
vocalists: [Vocalist] = [],
|
vocalists: [Vocalist] = [],
|
||||||
lyrics: Lyrics = Lyrics(),
|
lyrics: Lyrics = Lyrics(),
|
||||||
productionDirections: [ProductionDirection] = [],
|
productionDirections: [ProductionDirection] = [],
|
||||||
|
productionMode: ControlMode? = .auto,
|
||||||
sunoOutput: SunoOutput? = nil,
|
sunoOutput: SunoOutput? = nil,
|
||||||
versions: [SongVersion] = [],
|
versions: [SongVersion] = [],
|
||||||
createdAt: Date = Date(),
|
createdAt: Date = Date(),
|
||||||
@@ -67,11 +71,13 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
|
|||||||
self.key = key
|
self.key = key
|
||||||
self.scale = scale
|
self.scale = scale
|
||||||
self.maqam = maqam
|
self.maqam = maqam
|
||||||
|
self.structureMode = structureMode
|
||||||
self.sections = sections
|
self.sections = sections
|
||||||
self.instruments = instruments
|
self.instruments = instruments
|
||||||
self.vocalists = vocalists
|
self.vocalists = vocalists
|
||||||
self.lyrics = lyrics
|
self.lyrics = lyrics
|
||||||
self.productionDirections = productionDirections
|
self.productionDirections = productionDirections
|
||||||
|
self.productionMode = productionMode
|
||||||
self.sunoOutput = sunoOutput
|
self.sunoOutput = sunoOutput
|
||||||
self.versions = versions
|
self.versions = versions
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
@@ -80,6 +86,22 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public extension SongProject {
|
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] {
|
var orderedSections: [SongSection] {
|
||||||
sections
|
sections
|
||||||
.enumerated()
|
.enumerated()
|
||||||
@@ -364,11 +386,13 @@ public extension SongProject {
|
|||||||
key = snapshot.key
|
key = snapshot.key
|
||||||
scale = snapshot.scale
|
scale = snapshot.scale
|
||||||
maqam = snapshot.maqam
|
maqam = snapshot.maqam
|
||||||
|
structureMode = snapshot.structureMode
|
||||||
sections = snapshot.sections
|
sections = snapshot.sections
|
||||||
instruments = snapshot.instruments
|
instruments = snapshot.instruments
|
||||||
vocalists = snapshot.vocalists
|
vocalists = snapshot.vocalists
|
||||||
lyrics = snapshot.lyrics
|
lyrics = snapshot.lyrics
|
||||||
productionDirections = snapshot.productionDirections
|
productionDirections = snapshot.productionDirections
|
||||||
|
productionMode = snapshot.productionMode
|
||||||
sunoOutput = snapshot.sunoOutput
|
sunoOutput = snapshot.sunoOutput
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
@@ -764,11 +788,13 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
|
|||||||
public var key: ManualAutoValue<String>?
|
public var key: ManualAutoValue<String>?
|
||||||
public var scale: ManualAutoValue<String>?
|
public var scale: ManualAutoValue<String>?
|
||||||
public var maqam: ManualAutoValue<String>?
|
public var maqam: ManualAutoValue<String>?
|
||||||
|
public var structureMode: ControlMode?
|
||||||
public var sections: [SongSection]
|
public var sections: [SongSection]
|
||||||
public var instruments: [InstrumentTrack]
|
public var instruments: [InstrumentTrack]
|
||||||
public var vocalists: [Vocalist]
|
public var vocalists: [Vocalist]
|
||||||
public var lyrics: Lyrics
|
public var lyrics: Lyrics
|
||||||
public var productionDirections: [ProductionDirection]
|
public var productionDirections: [ProductionDirection]
|
||||||
|
public var productionMode: ControlMode?
|
||||||
public var sunoOutput: SunoOutput?
|
public var sunoOutput: SunoOutput?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -786,11 +812,13 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
|
|||||||
key: ManualAutoValue<String>? = nil,
|
key: ManualAutoValue<String>? = nil,
|
||||||
scale: ManualAutoValue<String>? = nil,
|
scale: ManualAutoValue<String>? = nil,
|
||||||
maqam: ManualAutoValue<String>? = nil,
|
maqam: ManualAutoValue<String>? = nil,
|
||||||
|
structureMode: ControlMode? = .auto,
|
||||||
sections: [SongSection] = [],
|
sections: [SongSection] = [],
|
||||||
instruments: [InstrumentTrack] = [],
|
instruments: [InstrumentTrack] = [],
|
||||||
vocalists: [Vocalist] = [],
|
vocalists: [Vocalist] = [],
|
||||||
lyrics: Lyrics = Lyrics(),
|
lyrics: Lyrics = Lyrics(),
|
||||||
productionDirections: [ProductionDirection] = [],
|
productionDirections: [ProductionDirection] = [],
|
||||||
|
productionMode: ControlMode? = .auto,
|
||||||
sunoOutput: SunoOutput? = nil
|
sunoOutput: SunoOutput? = nil
|
||||||
) {
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -807,11 +835,13 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
|
|||||||
self.key = key
|
self.key = key
|
||||||
self.scale = scale
|
self.scale = scale
|
||||||
self.maqam = maqam
|
self.maqam = maqam
|
||||||
|
self.structureMode = structureMode
|
||||||
self.sections = sections
|
self.sections = sections
|
||||||
self.instruments = instruments
|
self.instruments = instruments
|
||||||
self.vocalists = vocalists
|
self.vocalists = vocalists
|
||||||
self.lyrics = lyrics
|
self.lyrics = lyrics
|
||||||
self.productionDirections = productionDirections
|
self.productionDirections = productionDirections
|
||||||
|
self.productionMode = productionMode
|
||||||
self.sunoOutput = sunoOutput
|
self.sunoOutput = sunoOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,11 +861,13 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
|
|||||||
key: project.key,
|
key: project.key,
|
||||||
scale: project.scale,
|
scale: project.scale,
|
||||||
maqam: project.maqam,
|
maqam: project.maqam,
|
||||||
|
structureMode: project.structureMode,
|
||||||
sections: project.sections,
|
sections: project.sections,
|
||||||
instruments: project.instruments,
|
instruments: project.instruments,
|
||||||
vocalists: project.vocalists,
|
vocalists: project.vocalists,
|
||||||
lyrics: project.lyrics,
|
lyrics: project.lyrics,
|
||||||
productionDirections: project.productionDirections,
|
productionDirections: project.productionDirections,
|
||||||
|
productionMode: project.productionMode,
|
||||||
sunoOutput: project.sunoOutput
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,9 @@ macOS App
|
|||||||
OpenAI receives the user's intent plus private application rules and
|
OpenAI receives the user's intent plus private application rules and
|
||||||
structured project state. AI output should be requested as structured
|
structured project state. AI output should be requested as structured
|
||||||
data wherever possible, not treated as an unstructured chat transcript.
|
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
|
## Prompt Compiler
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ SongProject
|
|||||||
- key
|
- key
|
||||||
- scale
|
- scale
|
||||||
- maqam
|
- maqam
|
||||||
|
- structureMode: manual | auto
|
||||||
- sections[]
|
- sections[]
|
||||||
- instruments[]
|
- instruments[]
|
||||||
- vocalists[]
|
- vocalists[]
|
||||||
- lyrics
|
- lyrics
|
||||||
- productionDirections[]
|
- productionDirections[]
|
||||||
|
- productionMode: manual | auto
|
||||||
- sunoOutput
|
- sunoOutput
|
||||||
- versions[]
|
- versions[]
|
||||||
- createdAt
|
- createdAt
|
||||||
@@ -59,6 +61,15 @@ InstrumentTrack
|
|||||||
- autoArrangementEnabled
|
- autoArrangementEnabled
|
||||||
- placements[]
|
- 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
|
InstrumentPlacement
|
||||||
- sectionId (optional)
|
- sectionId (optional)
|
||||||
- startTime (optional)
|
- startTime (optional)
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@ requirement is missing and blocks implementation, record it in
|
|||||||
- [x] Define structured AI response schema for SongProject updates.
|
- [x] Define structured AI response schema for SongProject updates.
|
||||||
- [x] Implement idea → complete project generation.
|
- [x] Implement idea → complete project generation.
|
||||||
- [x] Implement existing lyrics → correction/improvement flow.
|
- [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.
|
and production decisions.
|
||||||
- [ ] Implement optional Discuss mode.
|
- [ ] Implement optional Discuss mode.
|
||||||
- [ ] Enforce user-lock/manual-value precedence over AI output.
|
- [ ] Enforce user-lock/manual-value precedence over AI output.
|
||||||
|
|||||||
Reference in New Issue
Block a user