Enforce user locks for AI updates
This commit is contained in:
@@ -23,6 +23,7 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
|
|||||||
public var lyrics: Lyrics
|
public var lyrics: Lyrics
|
||||||
public var productionDirections: [ProductionDirection]
|
public var productionDirections: [ProductionDirection]
|
||||||
public var productionMode: ControlMode?
|
public var productionMode: ControlMode?
|
||||||
|
public var userLocks: SongProjectUserLocks?
|
||||||
public var sunoOutput: SunoOutput?
|
public var sunoOutput: SunoOutput?
|
||||||
public var versions: [SongVersion]
|
public var versions: [SongVersion]
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
@@ -51,6 +52,7 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
|
|||||||
lyrics: Lyrics = Lyrics(),
|
lyrics: Lyrics = Lyrics(),
|
||||||
productionDirections: [ProductionDirection] = [],
|
productionDirections: [ProductionDirection] = [],
|
||||||
productionMode: ControlMode? = .auto,
|
productionMode: ControlMode? = .auto,
|
||||||
|
userLocks: SongProjectUserLocks? = SongProjectUserLocks(),
|
||||||
sunoOutput: SunoOutput? = nil,
|
sunoOutput: SunoOutput? = nil,
|
||||||
versions: [SongVersion] = [],
|
versions: [SongVersion] = [],
|
||||||
createdAt: Date = Date(),
|
createdAt: Date = Date(),
|
||||||
@@ -78,6 +80,7 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
|
|||||||
self.lyrics = lyrics
|
self.lyrics = lyrics
|
||||||
self.productionDirections = productionDirections
|
self.productionDirections = productionDirections
|
||||||
self.productionMode = productionMode
|
self.productionMode = productionMode
|
||||||
|
self.userLocks = userLocks
|
||||||
self.sunoOutput = sunoOutput
|
self.sunoOutput = sunoOutput
|
||||||
self.versions = versions
|
self.versions = versions
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
@@ -102,6 +105,26 @@ public extension SongProject {
|
|||||||
productionMode = mode
|
productionMode = mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lockedUpdateScopes: [SongProjectUpdateScope] {
|
||||||
|
userLocks?.scopes ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUpdateScopeLocked(_ scope: SongProjectUpdateScope) -> Bool {
|
||||||
|
userLocks?.contains(scope) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func lockUpdateScope(_ scope: SongProjectUpdateScope) {
|
||||||
|
var userLocks = userLocks ?? SongProjectUserLocks()
|
||||||
|
userLocks.insert(scope)
|
||||||
|
self.userLocks = userLocks
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func unlockUpdateScope(_ scope: SongProjectUpdateScope) {
|
||||||
|
guard var userLocks else { return }
|
||||||
|
userLocks.remove(scope)
|
||||||
|
self.userLocks = userLocks
|
||||||
|
}
|
||||||
|
|
||||||
var orderedSections: [SongSection] {
|
var orderedSections: [SongSection] {
|
||||||
sections
|
sections
|
||||||
.enumerated()
|
.enumerated()
|
||||||
@@ -393,6 +416,7 @@ public extension SongProject {
|
|||||||
lyrics = snapshot.lyrics
|
lyrics = snapshot.lyrics
|
||||||
productionDirections = snapshot.productionDirections
|
productionDirections = snapshot.productionDirections
|
||||||
productionMode = snapshot.productionMode
|
productionMode = snapshot.productionMode
|
||||||
|
userLocks = snapshot.userLocks
|
||||||
sunoOutput = snapshot.sunoOutput
|
sunoOutput = snapshot.sunoOutput
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
@@ -795,6 +819,7 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
|
|||||||
public var lyrics: Lyrics
|
public var lyrics: Lyrics
|
||||||
public var productionDirections: [ProductionDirection]
|
public var productionDirections: [ProductionDirection]
|
||||||
public var productionMode: ControlMode?
|
public var productionMode: ControlMode?
|
||||||
|
public var userLocks: SongProjectUserLocks?
|
||||||
public var sunoOutput: SunoOutput?
|
public var sunoOutput: SunoOutput?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -819,6 +844,7 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
|
|||||||
lyrics: Lyrics = Lyrics(),
|
lyrics: Lyrics = Lyrics(),
|
||||||
productionDirections: [ProductionDirection] = [],
|
productionDirections: [ProductionDirection] = [],
|
||||||
productionMode: ControlMode? = .auto,
|
productionMode: ControlMode? = .auto,
|
||||||
|
userLocks: SongProjectUserLocks? = SongProjectUserLocks(),
|
||||||
sunoOutput: SunoOutput? = nil
|
sunoOutput: SunoOutput? = nil
|
||||||
) {
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -842,6 +868,7 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
|
|||||||
self.lyrics = lyrics
|
self.lyrics = lyrics
|
||||||
self.productionDirections = productionDirections
|
self.productionDirections = productionDirections
|
||||||
self.productionMode = productionMode
|
self.productionMode = productionMode
|
||||||
|
self.userLocks = userLocks
|
||||||
self.sunoOutput = sunoOutput
|
self.sunoOutput = sunoOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,6 +895,7 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
|
|||||||
lyrics: project.lyrics,
|
lyrics: project.lyrics,
|
||||||
productionDirections: project.productionDirections,
|
productionDirections: project.productionDirections,
|
||||||
productionMode: project.productionMode,
|
productionMode: project.productionMode,
|
||||||
|
userLocks: project.userLocks,
|
||||||
sunoOutput: project.sunoOutput
|
sunoOutput: project.sunoOutput
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum SongProjectUpdateScope: String, CaseIterable, Codable, Equatable, Hashable, Sendable {
|
||||||
|
case duration
|
||||||
|
case genres
|
||||||
|
case instruments
|
||||||
|
case vocals
|
||||||
|
case languages
|
||||||
|
case lyrics
|
||||||
|
case structure
|
||||||
|
case arrangement
|
||||||
|
case musicalParameters
|
||||||
|
case emotionalArc
|
||||||
|
case productionDirections
|
||||||
|
case sunoOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SongProjectUserLocks: Codable, Equatable, Sendable {
|
||||||
|
public var scopes: [SongProjectUpdateScope]
|
||||||
|
|
||||||
|
public init(scopes: [SongProjectUpdateScope] = []) {
|
||||||
|
self.scopes = Self.uniqueScopes(scopes)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func contains(_ scope: SongProjectUpdateScope) -> Bool {
|
||||||
|
scopes.contains(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func insert(_ scope: SongProjectUpdateScope) {
|
||||||
|
guard !contains(scope) else { return }
|
||||||
|
scopes.append(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func remove(_ scope: SongProjectUpdateScope) {
|
||||||
|
scopes.removeAll { $0 == scope }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func uniqueScopes(_ scopes: [SongProjectUpdateScope]) -> [SongProjectUpdateScope] {
|
||||||
|
var seen = Set<SongProjectUpdateScope>()
|
||||||
|
return scopes.filter { seen.insert($0).inserted }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,21 +166,6 @@ public struct SongProjectUpdateRequest: Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum SongProjectUpdateScope: String, Codable, Equatable, Sendable {
|
|
||||||
case duration
|
|
||||||
case genres
|
|
||||||
case instruments
|
|
||||||
case vocals
|
|
||||||
case languages
|
|
||||||
case lyrics
|
|
||||||
case structure
|
|
||||||
case arrangement
|
|
||||||
case musicalParameters
|
|
||||||
case emotionalArc
|
|
||||||
case productionDirections
|
|
||||||
case sunoOutput
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SongProjectUpdateResult: Equatable, Sendable {
|
public struct SongProjectUpdateResult: Equatable, Sendable {
|
||||||
public var project: SongProject
|
public var project: SongProject
|
||||||
public var changedScopes: [SongProjectUpdateScope]
|
public var changedScopes: [SongProjectUpdateScope]
|
||||||
|
|||||||
@@ -52,22 +52,24 @@ public final class AutoSongProjectDecisionsDirector: Sendable {
|
|||||||
private func automaticScopes(for project: SongProject) -> [SongProjectUpdateScope] {
|
private func automaticScopes(for project: SongProject) -> [SongProjectUpdateScope] {
|
||||||
var scopes: [SongProjectUpdateScope] = []
|
var scopes: [SongProjectUpdateScope] = []
|
||||||
|
|
||||||
if project.isStructureAutomatic {
|
if project.isStructureAutomatic && !project.isUpdateScopeLocked(.structure) {
|
||||||
scopes.append(.structure)
|
scopes.append(.structure)
|
||||||
}
|
}
|
||||||
|
|
||||||
if project.instruments.contains(where: { $0.selected && $0.autoArrangementEnabled }) {
|
if !project.isUpdateScopeLocked(.arrangement) &&
|
||||||
|
project.instruments.contains(where: { $0.selected && $0.autoArrangementEnabled }) {
|
||||||
scopes.append(.arrangement)
|
scopes.append(.arrangement)
|
||||||
}
|
}
|
||||||
|
|
||||||
if project.bpm?.mode != .manual ||
|
if !project.isUpdateScopeLocked(.musicalParameters) &&
|
||||||
|
(project.bpm?.mode != .manual ||
|
||||||
project.key?.mode != .manual ||
|
project.key?.mode != .manual ||
|
||||||
project.scale?.mode != .manual ||
|
project.scale?.mode != .manual ||
|
||||||
project.maqam?.mode != .manual {
|
project.maqam?.mode != .manual) {
|
||||||
scopes.append(.musicalParameters)
|
scopes.append(.musicalParameters)
|
||||||
}
|
}
|
||||||
|
|
||||||
if project.isProductionAutomatic {
|
if project.isProductionAutomatic && !project.isUpdateScopeLocked(.productionDirections) {
|
||||||
scopes.append(.productionDirections)
|
scopes.append(.productionDirections)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ public final class ExistingLyricsRevisionDirector: Sendable {
|
|||||||
throw ExistingLyricsRevisionDirectorError.emptySourceLyrics
|
throw ExistingLyricsRevisionDirectorError.emptySourceLyrics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard !project.isUpdateScopeLocked(.lyrics) else {
|
||||||
|
return SongProjectUpdateResult(project: project)
|
||||||
|
}
|
||||||
|
|
||||||
let request = LyricsRevisionRequest(
|
let request = LyricsRevisionRequest(
|
||||||
context: AIRequestContext(
|
context: AIRequestContext(
|
||||||
userInstruction: trimmedInstruction,
|
userInstruction: trimmedInstruction,
|
||||||
@@ -42,14 +46,14 @@ public final class ExistingLyricsRevisionDirector: Sendable {
|
|||||||
)
|
)
|
||||||
let result = try await aiService.reviseLyrics(from: request)
|
let result = try await aiService.reviseLyrics(from: request)
|
||||||
|
|
||||||
var updatedProject = project
|
var proposedProject = project
|
||||||
updatedProject.lyrics = result.lyrics
|
proposedProject.lyrics = result.lyrics
|
||||||
updatedProject.updatedAt = revisedAt
|
return SongProjectAIUpdateMerger().apply(
|
||||||
|
snapshot: SongProjectSnapshot(project: proposedProject),
|
||||||
return SongProjectUpdateResult(
|
|
||||||
project: updatedProject,
|
|
||||||
changedScopes: [.lyrics],
|
changedScopes: [.lyrics],
|
||||||
notes: result.notes
|
notes: result.notes,
|
||||||
|
to: project,
|
||||||
|
updatedAt: revisedAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct SongProjectAIUpdateMerger: Sendable {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func apply(
|
||||||
|
snapshot: SongProjectSnapshot,
|
||||||
|
changedScopes: [SongProjectUpdateScope],
|
||||||
|
notes: [String] = [],
|
||||||
|
followUpQuestions: [String] = [],
|
||||||
|
to existingProject: SongProject,
|
||||||
|
updatedAt: Date = Date()
|
||||||
|
) -> SongProjectUpdateResult {
|
||||||
|
var updatedProject = existingProject
|
||||||
|
var appliedScopes: [SongProjectUpdateScope] = []
|
||||||
|
|
||||||
|
for scope in uniqueScopes(changedScopes) where !existingProject.isUpdateScopeLocked(scope) {
|
||||||
|
let projectBeforeScope = updatedProject
|
||||||
|
apply(scope, snapshot: snapshot, to: &updatedProject)
|
||||||
|
|
||||||
|
if didChange(scope, from: projectBeforeScope, to: updatedProject) {
|
||||||
|
appliedScopes.append(scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedProject.updatedAt = updatedAt
|
||||||
|
return SongProjectUpdateResult(
|
||||||
|
project: updatedProject,
|
||||||
|
changedScopes: appliedScopes,
|
||||||
|
notes: notes,
|
||||||
|
followUpQuestions: followUpQuestions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(
|
||||||
|
_ scope: SongProjectUpdateScope,
|
||||||
|
snapshot: SongProjectSnapshot,
|
||||||
|
to project: inout SongProject
|
||||||
|
) {
|
||||||
|
switch scope {
|
||||||
|
case .duration:
|
||||||
|
project.duration = snapshot.duration
|
||||||
|
case .genres:
|
||||||
|
project.genres = snapshot.genres
|
||||||
|
case .instruments:
|
||||||
|
project.instruments = mergedInstrumentSelections(
|
||||||
|
current: project.instruments,
|
||||||
|
proposed: snapshot.instruments
|
||||||
|
)
|
||||||
|
case .vocals:
|
||||||
|
project.vocalists = snapshot.vocalists
|
||||||
|
case .languages:
|
||||||
|
project.languages = snapshot.languages
|
||||||
|
project.dialects = snapshot.dialects
|
||||||
|
project.arabicPronunciationSettings = snapshot.arabicPronunciationSettings
|
||||||
|
case .lyrics:
|
||||||
|
project.lyrics = snapshot.lyrics
|
||||||
|
case .structure:
|
||||||
|
guard project.isStructureAutomatic else { return }
|
||||||
|
project.sections = sections(
|
||||||
|
snapshot.sections,
|
||||||
|
preservingProductionDirectionsFrom: project.sections
|
||||||
|
)
|
||||||
|
case .arrangement:
|
||||||
|
project.instruments = mergedArrangement(
|
||||||
|
current: project.instruments,
|
||||||
|
proposed: snapshot.instruments
|
||||||
|
)
|
||||||
|
case .musicalParameters:
|
||||||
|
project.bpm = automaticValue(current: project.bpm, proposed: snapshot.bpm)
|
||||||
|
project.key = automaticValue(current: project.key, proposed: snapshot.key)
|
||||||
|
project.scale = automaticValue(current: project.scale, proposed: snapshot.scale)
|
||||||
|
project.maqam = automaticValue(current: project.maqam, proposed: snapshot.maqam)
|
||||||
|
case .emotionalArc:
|
||||||
|
project.emotionalArc = snapshot.emotionalArc
|
||||||
|
case .productionDirections:
|
||||||
|
guard project.isProductionAutomatic else { return }
|
||||||
|
project.productionDirections = snapshot.productionDirections
|
||||||
|
project.sections = sections(
|
||||||
|
project.sections,
|
||||||
|
applyingProductionDirectionsFrom: snapshot.sections
|
||||||
|
)
|
||||||
|
case .sunoOutput:
|
||||||
|
project.sunoOutput = snapshot.sunoOutput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mergedInstrumentSelections(
|
||||||
|
current: [InstrumentTrack],
|
||||||
|
proposed: [InstrumentTrack]
|
||||||
|
) -> [InstrumentTrack] {
|
||||||
|
var proposedByID: [String: InstrumentTrack] = [:]
|
||||||
|
for proposedTrack in proposed {
|
||||||
|
proposedByID[proposedTrack.instrumentId] = proposedTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
let mergedTracks = current.map { currentTrack in
|
||||||
|
guard currentTrack.autoArrangementEnabled,
|
||||||
|
let proposedTrack = proposedByID[currentTrack.instrumentId]
|
||||||
|
else {
|
||||||
|
return currentTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
var mergedTrack = currentTrack
|
||||||
|
mergedTrack.selected = proposedTrack.selected
|
||||||
|
mergedTrack.variant = proposedTrack.variant
|
||||||
|
return mergedTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
var knownInstrumentIDs = Set(current.map(\.instrumentId))
|
||||||
|
let newTracks = proposed.filter { knownInstrumentIDs.insert($0.instrumentId).inserted }
|
||||||
|
return mergedTracks + newTracks
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mergedArrangement(
|
||||||
|
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(
|
||||||
|
_ proposed: [SongSection],
|
||||||
|
preservingProductionDirectionsFrom current: [SongSection]
|
||||||
|
) -> [SongSection] {
|
||||||
|
var currentByID: [String: SongSection] = [:]
|
||||||
|
for currentSection in current {
|
||||||
|
currentByID[currentSection.id] = currentSection
|
||||||
|
}
|
||||||
|
|
||||||
|
return proposed.map { section in
|
||||||
|
var updatedSection = section
|
||||||
|
updatedSection.productionDirection = currentByID[section.id]?.productionDirection
|
||||||
|
return updatedSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func didChange(
|
||||||
|
_ scope: SongProjectUpdateScope,
|
||||||
|
from current: SongProject,
|
||||||
|
to updated: SongProject
|
||||||
|
) -> Bool {
|
||||||
|
switch scope {
|
||||||
|
case .duration:
|
||||||
|
return current.duration != updated.duration
|
||||||
|
case .genres:
|
||||||
|
return current.genres != updated.genres
|
||||||
|
case .instruments, .arrangement:
|
||||||
|
return current.instruments != updated.instruments
|
||||||
|
case .vocals:
|
||||||
|
return current.vocalists != updated.vocalists
|
||||||
|
case .languages:
|
||||||
|
return current.languages != updated.languages ||
|
||||||
|
current.dialects != updated.dialects ||
|
||||||
|
current.arabicPronunciationSettings != updated.arabicPronunciationSettings
|
||||||
|
case .lyrics:
|
||||||
|
return current.lyrics != updated.lyrics
|
||||||
|
case .structure:
|
||||||
|
return sectionsDifferIgnoringProductionDirections(current.sections, updated.sections)
|
||||||
|
case .musicalParameters:
|
||||||
|
return current.bpm != updated.bpm ||
|
||||||
|
current.key != updated.key ||
|
||||||
|
current.scale != updated.scale ||
|
||||||
|
current.maqam != updated.maqam
|
||||||
|
case .emotionalArc:
|
||||||
|
return current.emotionalArc != updated.emotionalArc
|
||||||
|
case .productionDirections:
|
||||||
|
return current.productionDirections != updated.productionDirections ||
|
||||||
|
productionDirectionsDiffer(in: current.sections, and: updated.sections)
|
||||||
|
case .sunoOutput:
|
||||||
|
return current.sunoOutput != updated.sunoOutput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uniqueScopes(_ scopes: [SongProjectUpdateScope]) -> [SongProjectUpdateScope] {
|
||||||
|
var seen = Set<SongProjectUpdateScope>()
|
||||||
|
return scopes.filter { seen.insert($0).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,32 +64,18 @@ public final class SongProjectGenerationDirector: Sendable {
|
|||||||
return project
|
return project
|
||||||
}
|
}
|
||||||
|
|
||||||
return SongProject(
|
let update = SongProjectAIUpdateMerger().apply(
|
||||||
id: seedProject.id,
|
snapshot: SongProjectSnapshot(project: generatedProject),
|
||||||
title: generatedProject.title,
|
changedScopes: SongProjectUpdateScope.allCases,
|
||||||
idea: normalizedIdea,
|
to: seedProject,
|
||||||
duration: generatedProject.duration,
|
|
||||||
conversationMode: discussionMode,
|
|
||||||
languages: generatedProject.languages,
|
|
||||||
dialects: generatedProject.dialects,
|
|
||||||
arabicPronunciationSettings: generatedProject.arabicPronunciationSettings,
|
|
||||||
genres: generatedProject.genres,
|
|
||||||
moods: generatedProject.moods,
|
|
||||||
emotionalArc: generatedProject.emotionalArc,
|
|
||||||
bpm: generatedProject.bpm,
|
|
||||||
key: generatedProject.key,
|
|
||||||
scale: generatedProject.scale,
|
|
||||||
maqam: generatedProject.maqam,
|
|
||||||
sections: generatedProject.sections,
|
|
||||||
instruments: generatedProject.instruments,
|
|
||||||
vocalists: generatedProject.vocalists,
|
|
||||||
lyrics: generatedProject.lyrics,
|
|
||||||
productionDirections: generatedProject.productionDirections,
|
|
||||||
sunoOutput: generatedProject.sunoOutput,
|
|
||||||
versions: seedProject.versions,
|
|
||||||
createdAt: seedProject.createdAt,
|
|
||||||
updatedAt: generatedAt
|
updatedAt: generatedAt
|
||||||
)
|
)
|
||||||
|
var project = update.project
|
||||||
|
project.title = generatedProject.title
|
||||||
|
project.idea = normalizedIdea
|
||||||
|
project.conversationMode = discussionMode
|
||||||
|
project.moods = generatedProject.moods
|
||||||
|
return project
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,14 +24,13 @@ public struct StructuredSongProjectUpdateResponse: Codable, Equatable, Sendable
|
|||||||
updating existingProject: SongProject,
|
updating existingProject: SongProject,
|
||||||
updatedAt: Date = Date()
|
updatedAt: Date = Date()
|
||||||
) -> SongProjectUpdateResult {
|
) -> SongProjectUpdateResult {
|
||||||
var updatedProject = existingProject
|
SongProjectAIUpdateMerger().apply(
|
||||||
updatedProject.restoreSnapshot(project, updatedAt: updatedAt)
|
snapshot: project,
|
||||||
|
|
||||||
return SongProjectUpdateResult(
|
|
||||||
project: updatedProject,
|
|
||||||
changedScopes: changedScopes,
|
changedScopes: changedScopes,
|
||||||
notes: notes,
|
notes: notes,
|
||||||
followUpQuestions: followUpQuestions
|
followUpQuestions: followUpQuestions,
|
||||||
|
to: existingProject,
|
||||||
|
updatedAt: updatedAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,29 @@ final class AutoSongProjectDecisionsDirectorTests: XCTestCase {
|
|||||||
XCTAssertEqual(result.project.productionDirections, project.productionDirections)
|
XCTAssertEqual(result.project.productionDirections, project.productionDirections)
|
||||||
XCTAssertEqual(result.changedScopes, [SongProjectUpdateScope.structure])
|
XCTAssertEqual(result.changedScopes, [SongProjectUpdateScope.structure])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAutomaticDecisionsDoNotCallAIForLockedAutomaticScopes() async throws {
|
||||||
|
let project = SongProject(
|
||||||
|
title: "Locked Auto Project",
|
||||||
|
idea: "Do not update the tempo",
|
||||||
|
bpm: ManualAutoValue(mode: .auto),
|
||||||
|
key: ManualAutoValue(mode: .manual, value: "C"),
|
||||||
|
scale: ManualAutoValue(mode: .manual, value: "minor"),
|
||||||
|
maqam: ManualAutoValue(mode: .manual, value: "Rast"),
|
||||||
|
structureMode: .manual,
|
||||||
|
productionMode: .manual,
|
||||||
|
userLocks: SongProjectUserLocks(scopes: [.musicalParameters])
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private actor RecordingAutoDecisionsAIService: AIService {
|
private actor RecordingAutoDecisionsAIService: AIService {
|
||||||
|
|||||||
@@ -137,6 +137,30 @@ final class ExistingLyricsRevisionDirectorTests: XCTestCase {
|
|||||||
let requestCount = await aiService.recordedRevisionRequests.count
|
let requestCount = await aiService.recordedRevisionRequests.count
|
||||||
XCTAssertEqual(requestCount, 0)
|
XCTAssertEqual(requestCount, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testReviseExistingLyricsDoesNotCallAIWhenLyricsAreLocked() async throws {
|
||||||
|
let aiService = RecordingLyricsAIService(
|
||||||
|
revisionResult: LyricsRevisionResult(lyrics: Lyrics(text: "Unused"))
|
||||||
|
)
|
||||||
|
let director = ExistingLyricsRevisionDirector(aiService: aiService)
|
||||||
|
let project = SongProject(
|
||||||
|
title: "Locked Lyrics",
|
||||||
|
idea: "Keep the words",
|
||||||
|
lyrics: Lyrics(text: "User-approved lyrics"),
|
||||||
|
userLocks: SongProjectUserLocks(scopes: [.lyrics])
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = try await director.reviseExistingLyrics(
|
||||||
|
in: project,
|
||||||
|
instruction: "Improve the chorus.",
|
||||||
|
mode: .improve
|
||||||
|
)
|
||||||
|
let requestCount = await aiService.recordedRevisionRequests.count
|
||||||
|
|
||||||
|
XCTAssertEqual(requestCount, 0)
|
||||||
|
XCTAssertEqual(result.project, project)
|
||||||
|
XCTAssertTrue(result.changedScopes.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private actor RecordingLyricsAIService: AIService {
|
private actor RecordingLyricsAIService: AIService {
|
||||||
|
|||||||
@@ -105,6 +105,42 @@ final class SongProjectGenerationDirectorTests: XCTestCase {
|
|||||||
let requestCount = await aiService.recordedGenerationRequests.count
|
let requestCount = await aiService.recordedGenerationRequests.count
|
||||||
XCTAssertEqual(requestCount, 0)
|
XCTAssertEqual(requestCount, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testGenerateCompleteProjectPreservesLockedAndManualSeedValues() async throws {
|
||||||
|
let seedProject = SongProject(
|
||||||
|
id: "locked-seed",
|
||||||
|
title: "Seed",
|
||||||
|
idea: "Original",
|
||||||
|
genres: [GenreStyle(id: "arabic-pop", name: "Arabic Pop")],
|
||||||
|
bpm: ManualAutoValue(mode: .manual, value: 96),
|
||||||
|
lyrics: Lyrics(text: "User-approved lyrics"),
|
||||||
|
userLocks: SongProjectUserLocks(scopes: [.genres, .lyrics])
|
||||||
|
)
|
||||||
|
let aiService = RecordingGenerationAIService(
|
||||||
|
generationResult: SongProjectGenerationResult(
|
||||||
|
project: SongProject(
|
||||||
|
title: "Generated Title",
|
||||||
|
idea: "Generated idea",
|
||||||
|
genres: [GenreStyle(id: "rock", name: "Rock")],
|
||||||
|
bpm: ManualAutoValue(mode: .auto, value: 120),
|
||||||
|
lyrics: Lyrics(text: "AI lyrics")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let director = SongProjectGenerationDirector(aiService: aiService)
|
||||||
|
|
||||||
|
let result = try await director.generateCompleteProject(
|
||||||
|
fromIdea: "Generate around the locked choices",
|
||||||
|
seedProject: seedProject
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.project.title, "Generated Title")
|
||||||
|
XCTAssertEqual(result.project.idea, "Generated idea")
|
||||||
|
XCTAssertEqual(result.project.genres, seedProject.genres)
|
||||||
|
XCTAssertEqual(result.project.lyrics, seedProject.lyrics)
|
||||||
|
XCTAssertEqual(result.project.bpm, seedProject.bpm)
|
||||||
|
XCTAssertEqual(result.project.userLocks, seedProject.userLocks)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private actor RecordingGenerationAIService: AIService {
|
private actor RecordingGenerationAIService: AIService {
|
||||||
|
|||||||
@@ -105,10 +105,69 @@ final class StructuredAIResponseSchemaTests: XCTestCase {
|
|||||||
XCTAssertEqual(result.project.createdAt, createdAt)
|
XCTAssertEqual(result.project.createdAt, createdAt)
|
||||||
XCTAssertEqual(result.project.updatedAt, updatedAt)
|
XCTAssertEqual(result.project.updatedAt, updatedAt)
|
||||||
XCTAssertEqual(result.project.versions, [version])
|
XCTAssertEqual(result.project.versions, [version])
|
||||||
XCTAssertEqual(result.project.title, "Updated")
|
XCTAssertEqual(result.project.title, "Original")
|
||||||
XCTAssertEqual(result.project.lyrics.text, "Updated lyrics")
|
XCTAssertEqual(result.project.lyrics.text, "Updated lyrics")
|
||||||
XCTAssertEqual(result.changedScopes, [.genres, .lyrics])
|
XCTAssertEqual(result.changedScopes, [.genres, .lyrics])
|
||||||
XCTAssertEqual(result.notes, ["Proposed focused update."])
|
XCTAssertEqual(result.notes, ["Proposed focused update."])
|
||||||
XCTAssertEqual(result.followUpQuestions, ["Keep it minimal?"])
|
XCTAssertEqual(result.followUpQuestions, ["Keep it minimal?"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testStructuredResponsePreservesLockedScopesAndManualValues() {
|
||||||
|
let section = SongSection(id: "verse", type: .verse, title: "Manual Verse")
|
||||||
|
let manualInstrument = InstrumentTrack(
|
||||||
|
instrumentId: "oud",
|
||||||
|
selected: true,
|
||||||
|
role: "Manual lead",
|
||||||
|
autoArrangementEnabled: false,
|
||||||
|
placements: [InstrumentPlacement(sectionId: "verse", direction: "Manual entry")]
|
||||||
|
)
|
||||||
|
let existingProject = SongProject(
|
||||||
|
title: "Original Title",
|
||||||
|
idea: "Original Idea",
|
||||||
|
genres: [GenreStyle(id: "arabic-pop", name: "Arabic Pop")],
|
||||||
|
bpm: ManualAutoValue(mode: .manual, value: 96),
|
||||||
|
key: ManualAutoValue(mode: .auto),
|
||||||
|
structureMode: .manual,
|
||||||
|
sections: [section],
|
||||||
|
instruments: [manualInstrument],
|
||||||
|
lyrics: Lyrics(text: "Locked lyrics"),
|
||||||
|
userLocks: SongProjectUserLocks(scopes: [.genres, .lyrics])
|
||||||
|
)
|
||||||
|
let response = StructuredSongProjectUpdateResponse(
|
||||||
|
project: SongProjectSnapshot(
|
||||||
|
title: "AI Title",
|
||||||
|
idea: "AI Idea",
|
||||||
|
genres: [GenreStyle(id: "rock", name: "Rock")],
|
||||||
|
bpm: ManualAutoValue(mode: .auto, value: 120),
|
||||||
|
key: ManualAutoValue(mode: .auto, value: "A"),
|
||||||
|
sections: [SongSection(id: "chorus", type: .chorus, title: "AI Chorus")],
|
||||||
|
instruments: [
|
||||||
|
InstrumentTrack(
|
||||||
|
instrumentId: "oud",
|
||||||
|
selected: true,
|
||||||
|
role: "AI lead",
|
||||||
|
placements: [InstrumentPlacement(sectionId: "chorus", direction: "AI entry")]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
lyrics: Lyrics(text: "AI lyrics")
|
||||||
|
),
|
||||||
|
changedScopes: [.genres, .lyrics, .instruments, .musicalParameters, .structure, .arrangement]
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = response.makeUpdateResult(
|
||||||
|
updating: existingProject,
|
||||||
|
updatedAt: Date(timeIntervalSince1970: 500)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.project.title, "Original Title")
|
||||||
|
XCTAssertEqual(result.project.idea, "Original Idea")
|
||||||
|
XCTAssertEqual(result.project.genres, existingProject.genres)
|
||||||
|
XCTAssertEqual(result.project.lyrics, existingProject.lyrics)
|
||||||
|
XCTAssertEqual(result.project.bpm, existingProject.bpm)
|
||||||
|
XCTAssertEqual(result.project.key, ManualAutoValue(mode: .auto, value: "A"))
|
||||||
|
XCTAssertEqual(result.project.sections, existingProject.sections)
|
||||||
|
XCTAssertEqual(result.project.instruments, existingProject.instruments)
|
||||||
|
XCTAssertEqual(result.project.userLocks, existingProject.userLocks)
|
||||||
|
XCTAssertEqual(result.changedScopes, [.musicalParameters])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ manual structure, arrangement, musical-parameter and production choices.
|
|||||||
When a Song Project uses Discuss mode, the AI Director sends the current
|
When a Song Project uses Discuss mode, the AI Director sends the current
|
||||||
project and conversation context to the provider and returns follow-up
|
project and conversation context to the provider and returns follow-up
|
||||||
questions without applying a project update.
|
questions without applying a project update.
|
||||||
|
All structured AI updates pass through a scope-aware merger. It applies only
|
||||||
|
the scopes declared by the response, ignores user-locked scopes and preserves
|
||||||
|
manual control values.
|
||||||
|
|
||||||
## Prompt Compiler
|
## Prompt Compiler
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ SongProject
|
|||||||
- lyrics
|
- lyrics
|
||||||
- productionDirections[]
|
- productionDirections[]
|
||||||
- productionMode: manual | auto
|
- productionMode: manual | auto
|
||||||
|
- userLocks[]
|
||||||
- sunoOutput
|
- sunoOutput
|
||||||
- versions[]
|
- versions[]
|
||||||
- createdAt
|
- createdAt
|
||||||
@@ -76,6 +77,11 @@ Discuss mode behavior
|
|||||||
decision is applied.
|
decision is applied.
|
||||||
- A discussion response does not itself modify the Song Project.
|
- A discussion response does not itself modify the Song Project.
|
||||||
|
|
||||||
|
User-lock behavior
|
||||||
|
- userLocks records update scopes explicitly locked by the user.
|
||||||
|
- AI updates for locked scopes are ignored; every other accepted AI update must
|
||||||
|
still preserve Manual/Auto choices.
|
||||||
|
|
||||||
InstrumentPlacement
|
InstrumentPlacement
|
||||||
- sectionId (optional)
|
- sectionId (optional)
|
||||||
- startTime (optional)
|
- startTime (optional)
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@ requirement is missing and blocks implementation, record it in
|
|||||||
- [x] 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.
|
||||||
- [x] Implement optional Discuss mode.
|
- [x] Implement optional Discuss mode.
|
||||||
- [ ] Enforce user-lock/manual-value precedence over AI output.
|
- [x] Enforce user-lock/manual-value precedence over AI output.
|
||||||
- [ ] Add error, retry, cancellation and rate-limit handling.
|
- [ ] Add error, retry, cancellation and rate-limit handling.
|
||||||
|
|
||||||
## Phase 5 --- Arabic Lyrics Processing
|
## Phase 5 --- Arabic Lyrics Processing
|
||||||
|
|||||||
Reference in New Issue
Block a user