Compare commits
10
Commits
1bf84ca049
...
e7a46772ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7a46772ec | ||
|
|
9a0dd452bd | ||
|
|
6a99266e75 | ||
|
|
3865f77dc0 | ||
|
|
f5ab97e6a0 | ||
|
|
49a03a0700 | ||
|
|
eb61bd076c | ||
|
|
ae88c6e533 | ||
|
|
a39bdbe6fc | ||
|
|
bf1ccbe8a9 |
@@ -0,0 +1,108 @@
|
|||||||
|
import MusicAssistantCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ArabicLyricsReviewView: View {
|
||||||
|
@Binding var project: SongProject
|
||||||
|
|
||||||
|
private let processor = ArabicPronunciationProcessor()
|
||||||
|
|
||||||
|
@State private var processedLyrics = ""
|
||||||
|
@State private var processingNotes: [ArabicPronunciationProcessingNote] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text("Arabic lyrics review")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: refreshProcessedLyrics) {
|
||||||
|
Label("Refresh processed lyrics", systemImage: "arrow.clockwise")
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Refresh processed lyrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Original lyrics")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
TextEditor(text: .constant(project.lyrics.text))
|
||||||
|
.font(.body)
|
||||||
|
.frame(minHeight: 96)
|
||||||
|
.disabled(true)
|
||||||
|
|
||||||
|
Text("Processed lyrics")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
TextEditor(text: $processedLyrics)
|
||||||
|
.font(.body)
|
||||||
|
.frame(minHeight: 96)
|
||||||
|
|
||||||
|
ForEach(Array(processingNotes.enumerated()), id: \.offset) { entry in
|
||||||
|
Label(entry.element.title, systemImage: entry.element.systemImage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: applyProcessedLyrics) {
|
||||||
|
Label("Use processed lyrics", systemImage: "checkmark.circle")
|
||||||
|
}
|
||||||
|
.disabled(!canApplyProcessedLyrics)
|
||||||
|
}
|
||||||
|
.onAppear(perform: refreshProcessedLyrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canApplyProcessedLyrics: Bool {
|
||||||
|
let trimmedLyrics = processedLyrics.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return !trimmedLyrics.isEmpty && processedLyrics != project.lyrics.text
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshProcessedLyrics() {
|
||||||
|
let result = processor.process(
|
||||||
|
lyrics: project.lyrics.text,
|
||||||
|
settings: project.arabicPronunciationSettings
|
||||||
|
)
|
||||||
|
processedLyrics = result.text
|
||||||
|
processingNotes = result.notes
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyProcessedLyrics() {
|
||||||
|
project.applyReviewedArabicLyrics(processedLyrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ArabicPronunciationProcessingNote {
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .processingDisabled:
|
||||||
|
return "Arabic processing is disabled"
|
||||||
|
case .diacritizationPolicyUnspecified:
|
||||||
|
return "Select a diacritics policy to continue"
|
||||||
|
case .pronunciationTargetedDiacriticsNeedReview:
|
||||||
|
return "Targeted diacritics need review"
|
||||||
|
case .fullTashkeelNeedsReview:
|
||||||
|
return "Full tashkeel needs review"
|
||||||
|
case .tanweenAdditionNeedsReview:
|
||||||
|
return "Tanween addition needs review"
|
||||||
|
case let .preservedSpellingsProtected(count):
|
||||||
|
return "Protected \(count) preserved spelling\(count == 1 ? "" : "s")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .processingDisabled, .diacritizationPolicyUnspecified:
|
||||||
|
return "info.circle"
|
||||||
|
case .pronunciationTargetedDiacriticsNeedReview,
|
||||||
|
.fullTashkeelNeedsReview,
|
||||||
|
.tanweenAdditionNeedsReview:
|
||||||
|
return "exclamationmark.triangle"
|
||||||
|
case .preservedSpellingsProtected:
|
||||||
|
return "checkmark.shield"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import MusicAssistantCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ArabicPronunciationSettingsView: View {
|
||||||
|
@Binding var settings: ArabicPronunciationSettings
|
||||||
|
|
||||||
|
@State private var newPreservedSpelling = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Toggle("Enable Arabic pronunciation processing", isOn: $settings.isEnabled)
|
||||||
|
|
||||||
|
Picker("Diacritics", selection: $settings.diacritizationPolicy) {
|
||||||
|
Text("Unspecified").tag(ArabicDiacritizationPolicy.unspecified)
|
||||||
|
Text("Pronunciation targeted").tag(ArabicDiacritizationPolicy.pronunciationTargeted)
|
||||||
|
Text("Full tashkeel").tag(ArabicDiacritizationPolicy.fullTashkeel)
|
||||||
|
}
|
||||||
|
.disabled(!settings.isEnabled)
|
||||||
|
|
||||||
|
Picker("Tanween", selection: $settings.tanweenPolicy) {
|
||||||
|
Text("Unspecified").tag(ArabicTanweenPolicy.unspecified)
|
||||||
|
Text("Preserve user input").tag(ArabicTanweenPolicy.preserveUserInput)
|
||||||
|
Text("Add when pronunciation needs it").tag(ArabicTanweenPolicy.addWhenPronunciationRequires)
|
||||||
|
Text("Remove when unwanted").tag(ArabicTanweenPolicy.removeWhenUnwanted)
|
||||||
|
}
|
||||||
|
.disabled(!settings.isEnabled)
|
||||||
|
|
||||||
|
TextField("Pronunciation notes", text: userNotesBinding, axis: .vertical)
|
||||||
|
.lineLimit(2...4)
|
||||||
|
.disabled(!settings.isEnabled)
|
||||||
|
|
||||||
|
if settings.preservedSpellings.isEmpty {
|
||||||
|
Text("No preserved spellings")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(settings.preservedSpellings.indices, id: \.self) { index in
|
||||||
|
HStack {
|
||||||
|
Text(settings.preservedSpellings[index])
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
settings.preservedSpellings.remove(at: index)
|
||||||
|
} label: {
|
||||||
|
Label("Remove preserved spelling", systemImage: "minus.circle")
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Remove preserved spelling")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
TextField("Preserve spelling", text: $newPreservedSpelling)
|
||||||
|
.onSubmit(addPreservedSpelling)
|
||||||
|
|
||||||
|
Button(action: addPreservedSpelling) {
|
||||||
|
Label("Add preserved spelling", systemImage: "plus.circle.fill")
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(trimmedNewPreservedSpelling.isEmpty)
|
||||||
|
.help("Add preserved spelling")
|
||||||
|
}
|
||||||
|
.disabled(!settings.isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var userNotesBinding: Binding<String> {
|
||||||
|
Binding {
|
||||||
|
settings.userNotes ?? ""
|
||||||
|
} set: { newValue in
|
||||||
|
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
settings.userNotes = trimmedValue.isEmpty ? nil : trimmedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var trimmedNewPreservedSpelling: String {
|
||||||
|
newPreservedSpelling.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addPreservedSpelling() {
|
||||||
|
let spelling = trimmedNewPreservedSpelling
|
||||||
|
guard !spelling.isEmpty, !settings.preservedSpellings.contains(spelling) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.preservedSpellings.append(spelling)
|
||||||
|
newPreservedSpelling = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ struct FinalReviewView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
ArabicLyricsReviewView(project: $project)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
TextField("Approved lyrics", text: $project.lyrics.text, axis: .vertical)
|
TextField("Approved lyrics", text: $project.lyrics.text, axis: .vertical)
|
||||||
.lineLimit(4...10)
|
.lineLimit(4...10)
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ struct ProjectInspectorView: View {
|
|||||||
TextField("Title", text: $project.title)
|
TextField("Title", text: $project.title)
|
||||||
TextField("Idea", text: $project.idea, axis: .vertical)
|
TextField("Idea", text: $project.idea, axis: .vertical)
|
||||||
.lineLimit(2...4)
|
.lineLimit(2...4)
|
||||||
|
Picker("AI mode", selection: $project.conversationMode) {
|
||||||
|
Text("Auto").tag(ConversationMode.auto)
|
||||||
|
Text("Discuss").tag(ConversationMode.discuss)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
TextField("Duration seconds", text: durationSecondsBinding)
|
TextField("Duration seconds", text: durationSecondsBinding)
|
||||||
TextField("Duration note", text: durationDescriptionBinding)
|
TextField("Duration note", text: durationDescriptionBinding)
|
||||||
}
|
}
|
||||||
@@ -98,6 +103,12 @@ struct ProjectInspectorView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Arabic Pronunciation") {
|
||||||
|
ArabicPronunciationSettingsView(
|
||||||
|
settings: $project.arabicPronunciationSettings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Section("Song Structure") {
|
Section("Song Structure") {
|
||||||
SongStructureEditorView(project: $project)
|
SongStructureEditorView(project: $project)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,14 @@ 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 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
|
||||||
@@ -42,11 +45,14 @@ 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,
|
||||||
|
userLocks: SongProjectUserLocks? = SongProjectUserLocks(),
|
||||||
sunoOutput: SunoOutput? = nil,
|
sunoOutput: SunoOutput? = nil,
|
||||||
versions: [SongVersion] = [],
|
versions: [SongVersion] = [],
|
||||||
createdAt: Date = Date(),
|
createdAt: Date = Date(),
|
||||||
@@ -67,11 +73,14 @@ 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.userLocks = userLocks
|
||||||
self.sunoOutput = sunoOutput
|
self.sunoOutput = sunoOutput
|
||||||
self.versions = versions
|
self.versions = versions
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
@@ -80,6 +89,42 @@ 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 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()
|
||||||
@@ -154,6 +199,15 @@ public extension SongProject {
|
|||||||
dialects.removeAll { $0.id == dialectID }
|
dialects.removeAll { $0.id == dialectID }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mutating func applyReviewedArabicLyrics(_ reviewedLyrics: String) {
|
||||||
|
let originalLyrics = lyrics.text
|
||||||
|
lyrics.text = reviewedLyrics
|
||||||
|
|
||||||
|
if sunoOutput?.lyricsText == originalLyrics {
|
||||||
|
sunoOutput?.lyricsText = reviewedLyrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mutating func setBPMManually(_ value: Int) {
|
mutating func setBPMManually(_ value: Int) {
|
||||||
bpm = ManualAutoValue(mode: .manual, value: value)
|
bpm = ManualAutoValue(mode: .manual, value: value)
|
||||||
}
|
}
|
||||||
@@ -364,11 +418,14 @@ 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
|
||||||
|
userLocks = snapshot.userLocks
|
||||||
sunoOutput = snapshot.sunoOutput
|
sunoOutput = snapshot.sunoOutput
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
@@ -764,11 +821,14 @@ 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 userLocks: SongProjectUserLocks?
|
||||||
public var sunoOutput: SunoOutput?
|
public var sunoOutput: SunoOutput?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -786,11 +846,14 @@ 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,
|
||||||
|
userLocks: SongProjectUserLocks? = SongProjectUserLocks(),
|
||||||
sunoOutput: SunoOutput? = nil
|
sunoOutput: SunoOutput? = nil
|
||||||
) {
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -807,11 +870,14 @@ 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.userLocks = userLocks
|
||||||
self.sunoOutput = sunoOutput
|
self.sunoOutput = sunoOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,11 +897,14 @@ 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,
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,13 @@ public protocol OpenAIClientAdapter: Sendable {
|
|||||||
|
|
||||||
func decodeProjectGenerationResult(from data: Data) throws -> SongProjectGenerationResult
|
func decodeProjectGenerationResult(from data: Data) throws -> SongProjectGenerationResult
|
||||||
|
|
||||||
|
func makeSongProjectDiscussionRequest(
|
||||||
|
_ request: SongProjectDiscussionRequest,
|
||||||
|
configuration: OpenAIClientConfiguration
|
||||||
|
) throws -> OpenAIClientRequest
|
||||||
|
|
||||||
|
func decodeSongProjectDiscussionResult(from data: Data) throws -> SongProjectDiscussionResult
|
||||||
|
|
||||||
func makeLyricsRevisionRequest(
|
func makeLyricsRevisionRequest(
|
||||||
_ request: LyricsRevisionRequest,
|
_ request: LyricsRevisionRequest,
|
||||||
configuration: OpenAIClientConfiguration
|
configuration: OpenAIClientConfiguration
|
||||||
@@ -99,6 +106,12 @@ public final class OpenAIClient: AIService, Sendable {
|
|||||||
return try adapter.decodeProjectGenerationResult(from: data)
|
return try adapter.decodeProjectGenerationResult(from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
|
||||||
|
let clientRequest = try adapter.makeSongProjectDiscussionRequest(request, configuration: configuration)
|
||||||
|
let data = try await perform(clientRequest)
|
||||||
|
return try adapter.decodeSongProjectDiscussionResult(from: data)
|
||||||
|
}
|
||||||
|
|
||||||
public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||||
let clientRequest = try adapter.makeLyricsRevisionRequest(request, configuration: configuration)
|
let clientRequest = try adapter.makeLyricsRevisionRequest(request, configuration: configuration)
|
||||||
let data = try await perform(clientRequest)
|
let data = try await perform(clientRequest)
|
||||||
@@ -112,6 +125,8 @@ public final class OpenAIClient: AIService, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func perform(_ clientRequest: OpenAIClientRequest) async throws -> Data {
|
private func perform(_ clientRequest: OpenAIClientRequest) async throws -> Data {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
|
||||||
guard !configuration.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
guard !configuration.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
throw OpenAIClientError.missingAPIKey
|
throw OpenAIClientError.missingAPIKey
|
||||||
}
|
}
|
||||||
@@ -136,15 +151,30 @@ public final class OpenAIClient: AIService, Sendable {
|
|||||||
|
|
||||||
let (data, response) = try await transport.data(for: request)
|
let (data, response) = try await transport.data(for: request)
|
||||||
guard (200..<300).contains(response.statusCode) else {
|
guard (200..<300).contains(response.statusCode) else {
|
||||||
|
if response.statusCode == 429 {
|
||||||
|
throw OpenAIClientError.rateLimited(
|
||||||
|
retryAfter: Self.retryAfterInterval(from: response)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (500..<600).contains(response.statusCode) {
|
||||||
|
throw OpenAIClientError.serverError(response.statusCode)
|
||||||
|
}
|
||||||
throw OpenAIClientError.unacceptableStatusCode(response.statusCode)
|
throw OpenAIClientError.unacceptableStatusCode(response.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func retryAfterInterval(from response: HTTPURLResponse) -> TimeInterval? {
|
||||||
|
guard let value = response.value(forHTTPHeaderField: "Retry-After") else { return nil }
|
||||||
|
return TimeInterval(value.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum OpenAIClientError: Error, Equatable, Sendable {
|
public enum OpenAIClientError: Error, Equatable, Sendable {
|
||||||
case missingAPIKey
|
case missingAPIKey
|
||||||
case invalidResponse
|
case invalidResponse
|
||||||
|
case rateLimited(retryAfter: TimeInterval?)
|
||||||
|
case serverError(Int)
|
||||||
case unacceptableStatusCode(Int)
|
case unacceptableStatusCode(Int)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,21 @@ import Foundation
|
|||||||
|
|
||||||
public protocol AIService: Sendable {
|
public protocol AIService: Sendable {
|
||||||
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult
|
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult
|
||||||
|
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult
|
||||||
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult
|
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult
|
||||||
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult
|
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public extension AIService {
|
||||||
|
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
|
||||||
|
throw AIServiceCapabilityError.discussionNotSupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AIServiceCapabilityError: Error, Equatable, Sendable {
|
||||||
|
case discussionNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
public struct AIConversationMessage: Equatable, Identifiable, Sendable {
|
public struct AIConversationMessage: Equatable, Identifiable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public var role: AIConversationRole
|
public var role: AIConversationRole
|
||||||
@@ -82,6 +93,26 @@ public struct SongProjectGenerationResult: Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct SongProjectDiscussionRequest: Equatable, Sendable {
|
||||||
|
public var context: AIRequestContext
|
||||||
|
public var project: SongProject
|
||||||
|
|
||||||
|
public init(context: AIRequestContext, project: SongProject) {
|
||||||
|
self.context = context
|
||||||
|
self.project = project
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SongProjectDiscussionResult: Equatable, Sendable {
|
||||||
|
public var questions: [String]
|
||||||
|
public var notes: [String]
|
||||||
|
|
||||||
|
public init(questions: [String], notes: [String] = []) {
|
||||||
|
self.questions = questions
|
||||||
|
self.notes = notes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct LyricsRevisionRequest: Equatable, Sendable {
|
public struct LyricsRevisionRequest: Equatable, Sendable {
|
||||||
public var context: AIRequestContext
|
public var context: AIRequestContext
|
||||||
public var project: SongProject
|
public var project: SongProject
|
||||||
@@ -135,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]
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct AIServiceRetryPolicy: Equatable, Sendable {
|
||||||
|
public static let `default` = AIServiceRetryPolicy()
|
||||||
|
|
||||||
|
public var maxAttempts: Int
|
||||||
|
public var baseDelay: TimeInterval
|
||||||
|
public var maximumDelay: TimeInterval
|
||||||
|
|
||||||
|
public init(
|
||||||
|
maxAttempts: Int = 3,
|
||||||
|
baseDelay: TimeInterval = 0.5,
|
||||||
|
maximumDelay: TimeInterval = 8
|
||||||
|
) {
|
||||||
|
self.maxAttempts = max(1, maxAttempts)
|
||||||
|
self.baseDelay = max(0, baseDelay)
|
||||||
|
self.maximumDelay = max(0, maximumDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delay(forFailedAttempt attempt: Int, retryAfter: TimeInterval? = nil) -> TimeInterval {
|
||||||
|
if let retryAfter {
|
||||||
|
return max(0, retryAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
let multiplier = pow(2, Double(max(0, attempt - 1)))
|
||||||
|
return min(maximumDelay, baseDelay * multiplier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol AIServiceRetryScheduler: Sendable {
|
||||||
|
func sleep(for interval: TimeInterval) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TaskAIServiceRetryScheduler: AIServiceRetryScheduler {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func sleep(for interval: TimeInterval) async throws {
|
||||||
|
guard interval > 0 else { return }
|
||||||
|
let maximumInterval = TimeInterval(UInt64.max / 1_000_000_000)
|
||||||
|
let clampedInterval = min(interval, maximumInterval)
|
||||||
|
let nanoseconds = UInt64((clampedInterval * 1_000_000_000).rounded())
|
||||||
|
try await Task.sleep(nanoseconds: nanoseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class RetryingAIService: AIService, Sendable {
|
||||||
|
private let baseService: any AIService
|
||||||
|
private let policy: AIServiceRetryPolicy
|
||||||
|
private let scheduler: any AIServiceRetryScheduler
|
||||||
|
|
||||||
|
public init(
|
||||||
|
baseService: any AIService,
|
||||||
|
policy: AIServiceRetryPolicy = .default,
|
||||||
|
scheduler: any AIServiceRetryScheduler = TaskAIServiceRetryScheduler()
|
||||||
|
) {
|
||||||
|
self.baseService = baseService
|
||||||
|
self.policy = policy
|
||||||
|
self.scheduler = scheduler
|
||||||
|
}
|
||||||
|
|
||||||
|
public func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
|
||||||
|
try await perform { try await self.baseService.generateSongProject(from: request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
|
||||||
|
try await perform { try await self.baseService.discussSongProject(from: request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||||
|
try await perform { try await self.baseService.reviseLyrics(from: request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
|
||||||
|
try await perform { try await self.baseService.proposeProjectUpdate(from: request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func perform<Value: Sendable>(
|
||||||
|
_ operation: @escaping @Sendable () async throws -> Value
|
||||||
|
) async throws -> Value {
|
||||||
|
var attempt = 0
|
||||||
|
|
||||||
|
while true {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
attempt += 1
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try await operation()
|
||||||
|
} catch is CancellationError {
|
||||||
|
throw CancellationError()
|
||||||
|
} catch {
|
||||||
|
guard attempt < policy.maxAttempts,
|
||||||
|
let delay = retryDelay(for: error, failedAttempt: attempt) else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try await scheduler.sleep(for: delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retryDelay(for error: Error, failedAttempt attempt: Int) -> TimeInterval? {
|
||||||
|
if let error = error as? OpenAIClientError {
|
||||||
|
switch error {
|
||||||
|
case let .rateLimited(retryAfter):
|
||||||
|
return policy.delay(forFailedAttempt: attempt, retryAfter: retryAfter)
|
||||||
|
case .serverError:
|
||||||
|
return policy.delay(forFailedAttempt: attempt)
|
||||||
|
case .missingAPIKey, .invalidResponse, .unacceptableStatusCode:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = error as? URLError {
|
||||||
|
switch error.code {
|
||||||
|
case .timedOut, .networkConnectionLost, .notConnectedToInternet, .cannotConnectToHost, .cannotFindHost, .dnsLookupFailed:
|
||||||
|
return policy.delay(forFailedAttempt: attempt)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,10 @@ public final class ApplicationRuleInjectingAIService: AIService, Sendable {
|
|||||||
try await baseService.generateSongProject(from: requestWithInjectedRules(request))
|
try await baseService.generateSongProject(from: requestWithInjectedRules(request))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
|
||||||
|
try await baseService.discussSongProject(from: requestWithInjectedRules(request))
|
||||||
|
}
|
||||||
|
|
||||||
public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||||
try await baseService.reviseLyrics(from: requestWithInjectedRules(request))
|
try await baseService.reviseLyrics(from: requestWithInjectedRules(request))
|
||||||
}
|
}
|
||||||
@@ -59,6 +63,12 @@ public final class ApplicationRuleInjectingAIService: AIService, Sendable {
|
|||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func requestWithInjectedRules(_ request: SongProjectDiscussionRequest) throws -> SongProjectDiscussionRequest {
|
||||||
|
var request = request
|
||||||
|
request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules())
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
private func requestWithInjectedRules(_ request: LyricsRevisionRequest) throws -> LyricsRevisionRequest {
|
private func requestWithInjectedRules(_ request: LyricsRevisionRequest) throws -> LyricsRevisionRequest {
|
||||||
var request = request
|
var request = request
|
||||||
request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules())
|
request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules())
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
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 && !project.isUpdateScopeLocked(.structure) {
|
||||||
|
scopes.append(.structure)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !project.isUpdateScopeLocked(.arrangement) &&
|
||||||
|
project.instruments.contains(where: { $0.selected && $0.autoArrangementEnabled }) {
|
||||||
|
scopes.append(.arrangement)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !project.isUpdateScopeLocked(.musicalParameters) &&
|
||||||
|
(project.bpm?.mode != .manual ||
|
||||||
|
project.key?.mode != .manual ||
|
||||||
|
project.scale?.mode != .manual ||
|
||||||
|
project.maqam?.mode != .manual) {
|
||||||
|
scopes.append(.musicalParameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
if project.isProductionAutomatic && !project.isUpdateScopeLocked(.productionDirections) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public final class SongProjectDiscussionDirector: Sendable {
|
||||||
|
private let aiService: any AIService
|
||||||
|
|
||||||
|
public init(aiService: any AIService) {
|
||||||
|
self.aiService = aiService
|
||||||
|
}
|
||||||
|
|
||||||
|
public func discuss(
|
||||||
|
project: SongProject,
|
||||||
|
userMessage: String,
|
||||||
|
conversation: [AIConversationMessage] = [],
|
||||||
|
localeIdentifier: String? = nil
|
||||||
|
) async throws -> SongProjectDiscussionResult {
|
||||||
|
guard project.conversationMode == .discuss else {
|
||||||
|
throw SongProjectDiscussionDirectorError.discussionModeNotEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedMessage = userMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedMessage.isEmpty else {
|
||||||
|
throw SongProjectDiscussionDirectorError.emptyUserMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = try await aiService.discussSongProject(
|
||||||
|
from: SongProjectDiscussionRequest(
|
||||||
|
context: AIRequestContext(
|
||||||
|
userInstruction: trimmedMessage,
|
||||||
|
conversation: conversation,
|
||||||
|
localeIdentifier: localeIdentifier
|
||||||
|
),
|
||||||
|
project: project
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SongProjectDiscussionResult(
|
||||||
|
questions: result.questions
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty },
|
||||||
|
notes: result.notes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SongProjectDiscussionDirectorError: Error, Equatable, Sendable {
|
||||||
|
case discussionModeNotEnabled
|
||||||
|
case emptyUserMessage
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ArabicPronunciationProcessor: Sendable {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func process(
|
||||||
|
lyrics: String,
|
||||||
|
settings: ArabicPronunciationSettings
|
||||||
|
) -> ArabicPronunciationProcessingResult {
|
||||||
|
guard settings.isEnabled else {
|
||||||
|
return ArabicPronunciationProcessingResult(
|
||||||
|
text: lyrics,
|
||||||
|
notes: [.processingDisabled]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let protection = protectPreservedSpellings(
|
||||||
|
in: lyrics,
|
||||||
|
preservedSpellings: settings.preservedSpellings
|
||||||
|
)
|
||||||
|
var processedText = protection.text.precomposedStringWithCanonicalMapping
|
||||||
|
var notes = processingNotes(for: processedText, settings: settings)
|
||||||
|
|
||||||
|
if settings.tanweenPolicy == .removeWhenUnwanted {
|
||||||
|
processedText = removeTanween(from: processedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.tanweenPolicy == .addWhenPronunciationRequires,
|
||||||
|
containsArabicLetter(in: processedText),
|
||||||
|
!containsTanween(in: processedText) {
|
||||||
|
notes.append(.tanweenAdditionNeedsReview)
|
||||||
|
}
|
||||||
|
|
||||||
|
processedText = restorePreservedSpellings(
|
||||||
|
in: processedText,
|
||||||
|
replacements: protection.replacements
|
||||||
|
)
|
||||||
|
|
||||||
|
if protection.matchCount > 0 {
|
||||||
|
notes.append(.preservedSpellingsProtected(count: protection.matchCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ArabicPronunciationProcessingResult(
|
||||||
|
text: processedText,
|
||||||
|
notes: notes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processingNotes(
|
||||||
|
for text: String,
|
||||||
|
settings: ArabicPronunciationSettings
|
||||||
|
) -> [ArabicPronunciationProcessingNote] {
|
||||||
|
guard containsArabicLetter(in: text) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
switch settings.diacritizationPolicy {
|
||||||
|
case .unspecified:
|
||||||
|
return [.diacritizationPolicyUnspecified]
|
||||||
|
case .pronunciationTargeted where !containsDiacritics(in: text):
|
||||||
|
return [.pronunciationTargetedDiacriticsNeedReview]
|
||||||
|
case .fullTashkeel where !containsDiacritics(in: text):
|
||||||
|
return [.fullTashkeelNeedsReview]
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func protectPreservedSpellings(
|
||||||
|
in text: String,
|
||||||
|
preservedSpellings: [String]
|
||||||
|
) -> PreservedSpellingProtection {
|
||||||
|
let spellings = Set(
|
||||||
|
preservedSpellings.map {
|
||||||
|
$0.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.sorted {
|
||||||
|
if $0.count == $1.count {
|
||||||
|
return $0 < $1
|
||||||
|
}
|
||||||
|
return $0.count > $1.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var protectedText = text
|
||||||
|
var replacements: [PreservedSpellingReplacement] = []
|
||||||
|
var matchCount = 0
|
||||||
|
|
||||||
|
for (index, spelling) in spellings.enumerated() {
|
||||||
|
let occurrenceCount = protectedText.components(separatedBy: spelling).count - 1
|
||||||
|
guard occurrenceCount > 0 else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let placeholder = uniquePlaceholder(
|
||||||
|
for: index,
|
||||||
|
in: protectedText
|
||||||
|
)
|
||||||
|
protectedText = protectedText.replacingOccurrences(
|
||||||
|
of: spelling,
|
||||||
|
with: placeholder
|
||||||
|
)
|
||||||
|
replacements.append(
|
||||||
|
PreservedSpellingReplacement(
|
||||||
|
placeholder: placeholder,
|
||||||
|
spelling: spelling
|
||||||
|
)
|
||||||
|
)
|
||||||
|
matchCount += occurrenceCount
|
||||||
|
}
|
||||||
|
|
||||||
|
return PreservedSpellingProtection(
|
||||||
|
text: protectedText,
|
||||||
|
replacements: replacements,
|
||||||
|
matchCount: matchCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uniquePlaceholder(for index: Int, in text: String) -> String {
|
||||||
|
var collisionIndex = 0
|
||||||
|
var placeholder = "[[music-assistant-preserved-\(index)]]"
|
||||||
|
|
||||||
|
while text.contains(placeholder) {
|
||||||
|
collisionIndex += 1
|
||||||
|
placeholder = "[[music-assistant-preserved-\(index)-\(collisionIndex)]]"
|
||||||
|
}
|
||||||
|
|
||||||
|
return placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restorePreservedSpellings(
|
||||||
|
in text: String,
|
||||||
|
replacements: [PreservedSpellingReplacement]
|
||||||
|
) -> String {
|
||||||
|
replacements.reduce(text) { result, replacement in
|
||||||
|
result.replacingOccurrences(
|
||||||
|
of: replacement.placeholder,
|
||||||
|
with: replacement.spelling
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeTanween(from text: String) -> String {
|
||||||
|
String(text.unicodeScalars.filter { scalar in
|
||||||
|
!isTanween(scalar)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func containsArabicLetter(in text: String) -> Bool {
|
||||||
|
text.unicodeScalars.contains { scalar in
|
||||||
|
let isArabicBlock = (0x0600...0x06FF).contains(scalar.value)
|
||||||
|
|| (0x0750...0x077F).contains(scalar.value)
|
||||||
|
|| (0x08A0...0x08FF).contains(scalar.value)
|
||||||
|
return isArabicBlock && CharacterSet.letters.contains(scalar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func containsDiacritics(in text: String) -> Bool {
|
||||||
|
text.unicodeScalars.contains(where: isArabicDiacritic)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func containsTanween(in text: String) -> Bool {
|
||||||
|
text.unicodeScalars.contains(where: isTanween)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isArabicDiacritic(_ scalar: Unicode.Scalar) -> Bool {
|
||||||
|
(0x064B...0x065F).contains(scalar.value) || scalar.value == 0x0670
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isTanween(_ scalar: Unicode.Scalar) -> Bool {
|
||||||
|
(0x064B...0x064D).contains(scalar.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PreservedSpellingProtection {
|
||||||
|
let text: String
|
||||||
|
let replacements: [PreservedSpellingReplacement]
|
||||||
|
let matchCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PreservedSpellingReplacement {
|
||||||
|
let placeholder: String
|
||||||
|
let spelling: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ArabicPronunciationProcessingResult: Equatable, Sendable {
|
||||||
|
public var text: String
|
||||||
|
public var notes: [ArabicPronunciationProcessingNote]
|
||||||
|
|
||||||
|
public init(text: String, notes: [ArabicPronunciationProcessingNote] = []) {
|
||||||
|
self.text = text
|
||||||
|
self.notes = notes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ArabicPronunciationProcessingNote: Equatable, Sendable {
|
||||||
|
case processingDisabled
|
||||||
|
case diacritizationPolicyUnspecified
|
||||||
|
case pronunciationTargetedDiacriticsNeedReview
|
||||||
|
case fullTashkeelNeedsReview
|
||||||
|
case tanweenAdditionNeedsReview
|
||||||
|
case preservedSpellingsProtected(count: Int)
|
||||||
|
}
|
||||||
@@ -24,6 +24,24 @@ final class AIServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(result.followUpQuestions, ["Should the chorus be bigger?"])
|
XCTAssertEqual(result.followUpQuestions, ["Should the chorus be bigger?"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testProviderIndependentServiceReturnsDiscussionQuestionsWithoutProjectUpdate() async throws {
|
||||||
|
let service = MockAIService()
|
||||||
|
let project = SongProject(
|
||||||
|
title: "Discussion Project",
|
||||||
|
idea: "Plan the chorus",
|
||||||
|
conversationMode: .discuss
|
||||||
|
)
|
||||||
|
let request = SongProjectDiscussionRequest(
|
||||||
|
context: AIRequestContext(userInstruction: "Ask what is still missing."),
|
||||||
|
project: project
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = try await service.discussSongProject(from: request)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.questions, ["Which vocal delivery should lead the chorus?"])
|
||||||
|
XCTAssertEqual(result.notes, ["Discussion only; no project update was proposed."])
|
||||||
|
}
|
||||||
|
|
||||||
func testProviderIndependentServiceRevisesLyricsWithStructuredResult() async throws {
|
func testProviderIndependentServiceRevisesLyricsWithStructuredResult() async throws {
|
||||||
let service = MockAIService()
|
let service = MockAIService()
|
||||||
let project = SongProject(title: "Lyric Project", idea: "Improve words")
|
let project = SongProject(title: "Lyric Project", idea: "Improve words")
|
||||||
@@ -71,6 +89,13 @@ private struct MockAIService: AIService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
|
||||||
|
SongProjectDiscussionResult(
|
||||||
|
questions: ["Which vocal delivery should lead the chorus?"],
|
||||||
|
notes: ["Discussion only; no project update was proposed."]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||||
LyricsRevisionResult(
|
LyricsRevisionResult(
|
||||||
lyrics: Lyrics(text: "\(request.sourceLyrics.text)\n\(request.context.userInstruction)"),
|
lyrics: Lyrics(text: "\(request.sourceLyrics.text)\n\(request.context.userInstruction)"),
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ final class ApplicationRuleInjectionTests: XCTestCase {
|
|||||||
context: AIRequestContext(userInstruction: "Generate.")
|
context: AIRequestContext(userInstruction: "Generate.")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
_ = try await service.discussSongProject(
|
||||||
|
from: SongProjectDiscussionRequest(
|
||||||
|
context: AIRequestContext(userInstruction: "Discuss."),
|
||||||
|
project: project
|
||||||
|
)
|
||||||
|
)
|
||||||
_ = try await service.reviseLyrics(
|
_ = try await service.reviseLyrics(
|
||||||
from: LyricsRevisionRequest(
|
from: LyricsRevisionRequest(
|
||||||
context: AIRequestContext(userInstruction: "Improve."),
|
context: AIRequestContext(userInstruction: "Improve."),
|
||||||
@@ -37,6 +43,7 @@ final class ApplicationRuleInjectionTests: XCTestCase {
|
|||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
recordedRuleContents,
|
recordedRuleContents,
|
||||||
[
|
[
|
||||||
|
"Use private product rules.",
|
||||||
"Use private product rules.",
|
"Use private product rules.",
|
||||||
"Use private product rules.",
|
"Use private product rules.",
|
||||||
"Use private product rules."
|
"Use private product rules."
|
||||||
@@ -90,6 +97,11 @@ private actor RecordingAIService: AIService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
|
||||||
|
contexts.append(request.context)
|
||||||
|
return SongProjectDiscussionResult(questions: ["Which direction should we take?"])
|
||||||
|
}
|
||||||
|
|
||||||
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||||
contexts.append(request.context)
|
contexts.append(request.context)
|
||||||
return LyricsRevisionResult(lyrics: request.sourceLyrics)
|
return LyricsRevisionResult(lyrics: request.sourceLyrics)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import MusicAssistantCore
|
||||||
|
|
||||||
|
struct ArabicLyricsFixture {
|
||||||
|
let dialectName: String
|
||||||
|
let lyrics: String
|
||||||
|
let settings: ArabicPronunciationSettings
|
||||||
|
let expectedLyrics: String
|
||||||
|
let expectedNotes: [ArabicPronunciationProcessingNote]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ArabicLyricsFixtures {
|
||||||
|
static let all: [ArabicLyricsFixture] = [
|
||||||
|
levantine,
|
||||||
|
egyptian,
|
||||||
|
gulf,
|
||||||
|
maghrebi
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let levantine = ArabicLyricsFixture(
|
||||||
|
dialectName: "Levantine Arabic",
|
||||||
|
lyrics: "\u{0634}\u{064F}\u{0648} \u{0628}\u{064E}\u{062F}\u{0651}\u{064E}\u{0643}",
|
||||||
|
settings: ArabicPronunciationSettings(
|
||||||
|
isEnabled: true,
|
||||||
|
diacritizationPolicy: .pronunciationTargeted,
|
||||||
|
tanweenPolicy: .preserveUserInput,
|
||||||
|
preservedSpellings: ["\u{0634}\u{064F}\u{0648}"]
|
||||||
|
),
|
||||||
|
expectedLyrics: "\u{0634}\u{064F}\u{0648} \u{0628}\u{064E}\u{062F}\u{064E}\u{0651}\u{0643}",
|
||||||
|
expectedNotes: [.preservedSpellingsProtected(count: 1)]
|
||||||
|
)
|
||||||
|
|
||||||
|
private static let egyptian = ArabicLyricsFixture(
|
||||||
|
dialectName: "Egyptian Arabic",
|
||||||
|
lyrics: "\u{0625}\u{0650}\u{0632}\u{0651}\u{064E}\u{0627}\u{064A}\u{064E}\u{0643}",
|
||||||
|
settings: ArabicPronunciationSettings(
|
||||||
|
isEnabled: true,
|
||||||
|
diacritizationPolicy: .pronunciationTargeted
|
||||||
|
),
|
||||||
|
expectedLyrics: "\u{0625}\u{0650}\u{0632}\u{064E}\u{0651}\u{0627}\u{064A}\u{064E}\u{0643}",
|
||||||
|
expectedNotes: []
|
||||||
|
)
|
||||||
|
|
||||||
|
private static let gulf = ArabicLyricsFixture(
|
||||||
|
dialectName: "Gulf Arabic",
|
||||||
|
lyrics: "\u{0648}\u{064E}\u{064A}\u{0652}\u{0646}\u{064E}\u{0643} \u{0634}\u{064F}\u{0643}\u{0652}\u{0631}\u{064B}\u{0627}",
|
||||||
|
settings: ArabicPronunciationSettings(
|
||||||
|
isEnabled: true,
|
||||||
|
diacritizationPolicy: .pronunciationTargeted,
|
||||||
|
tanweenPolicy: .removeWhenUnwanted
|
||||||
|
),
|
||||||
|
expectedLyrics: "\u{0648}\u{064E}\u{064A}\u{0652}\u{0646}\u{064E}\u{0643} \u{0634}\u{064F}\u{0643}\u{0652}\u{0631}\u{0627}",
|
||||||
|
expectedNotes: []
|
||||||
|
)
|
||||||
|
|
||||||
|
private static let maghrebi = ArabicLyricsFixture(
|
||||||
|
dialectName: "Maghrebi Arabic",
|
||||||
|
lyrics: "\u{0634}\u{0646}\u{0648} \u{0628}\u{063A}\u{064A}\u{062A}\u{064A}",
|
||||||
|
settings: ArabicPronunciationSettings(
|
||||||
|
isEnabled: true,
|
||||||
|
diacritizationPolicy: .fullTashkeel
|
||||||
|
),
|
||||||
|
expectedLyrics: "\u{0634}\u{0646}\u{0648} \u{0628}\u{063A}\u{064A}\u{062A}\u{064A}",
|
||||||
|
expectedNotes: [.fullTashkeelNeedsReview]
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import MusicAssistantCore
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class ArabicPronunciationProcessorTests: XCTestCase {
|
||||||
|
private let processor = ArabicPronunciationProcessor()
|
||||||
|
|
||||||
|
func testDisabledProcessingLeavesLyricsUntouched() {
|
||||||
|
let lyrics = "\u{0645}\u{0651}\u{064E}\u{0631}\u{062D}\u{064E}\u{0628}\u{064B}\u{0627}"
|
||||||
|
|
||||||
|
let result = processor.process(
|
||||||
|
lyrics: lyrics,
|
||||||
|
settings: ArabicPronunciationSettings(isEnabled: false)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.text, lyrics)
|
||||||
|
XCTAssertEqual(result.notes, [.processingDisabled])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testProcessingNormalizesSuppliedDiacritics() {
|
||||||
|
let lyrics = "\u{0645}\u{0651}\u{064E}\u{0631}\u{062D}\u{064E}\u{0628}\u{064B}\u{0627}"
|
||||||
|
|
||||||
|
let result = processor.process(
|
||||||
|
lyrics: lyrics,
|
||||||
|
settings: ArabicPronunciationSettings(
|
||||||
|
isEnabled: true,
|
||||||
|
diacritizationPolicy: .pronunciationTargeted
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
result.text,
|
||||||
|
"\u{0645}\u{064E}\u{0651}\u{0631}\u{062D}\u{064E}\u{0628}\u{064B}\u{0627}"
|
||||||
|
)
|
||||||
|
XCTAssertTrue(result.notes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemovingTanweenKeepsOtherDiacritics() {
|
||||||
|
let result = processor.process(
|
||||||
|
lyrics: "\u{0634}\u{064F}\u{0643}\u{0652}\u{0631}\u{064B}\u{0627}",
|
||||||
|
settings: ArabicPronunciationSettings(
|
||||||
|
isEnabled: true,
|
||||||
|
tanweenPolicy: .removeWhenUnwanted
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.text, "\u{0634}\u{064F}\u{0643}\u{0652}\u{0631}\u{0627}")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFullTashkeelDoesNotInventMissingMarks() {
|
||||||
|
let lyrics = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"
|
||||||
|
|
||||||
|
let result = processor.process(
|
||||||
|
lyrics: lyrics,
|
||||||
|
settings: ArabicPronunciationSettings(
|
||||||
|
isEnabled: true,
|
||||||
|
diacritizationPolicy: .fullTashkeel,
|
||||||
|
tanweenPolicy: .addWhenPronunciationRequires
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.text, lyrics)
|
||||||
|
XCTAssertEqual(
|
||||||
|
result.notes,
|
||||||
|
[.fullTashkeelNeedsReview, .tanweenAdditionNeedsReview]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreservedSpellingsKeepTheirTanweenWhileOtherTextIsProcessed() {
|
||||||
|
let protectedSpelling = "\u{0647}\u{064F}\u{062F}\u{064B}\u{0649}"
|
||||||
|
let otherText = "\u{0634}\u{064F}\u{0643}\u{0652}\u{0631}\u{064B}\u{0627}"
|
||||||
|
let lyrics = "\(protectedSpelling) \(otherText) \(protectedSpelling)"
|
||||||
|
|
||||||
|
let result = processor.process(
|
||||||
|
lyrics: lyrics,
|
||||||
|
settings: ArabicPronunciationSettings(
|
||||||
|
isEnabled: true,
|
||||||
|
diacritizationPolicy: .pronunciationTargeted,
|
||||||
|
tanweenPolicy: .removeWhenUnwanted,
|
||||||
|
preservedSpellings: [protectedSpelling]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
result.text,
|
||||||
|
"\(protectedSpelling) \u{0634}\u{064F}\u{0643}\u{0652}\u{0631}\u{0627} \(protectedSpelling)"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result.notes, [.preservedSpellingsProtected(count: 2)])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreservedSpellingsKeepTheirOriginalDiacriticOrder() {
|
||||||
|
let preservedSpelling = "\u{0645}\u{0651}\u{064E}"
|
||||||
|
let otherText = "\u{0628}\u{0651}\u{064E}"
|
||||||
|
|
||||||
|
let result = processor.process(
|
||||||
|
lyrics: "\(preservedSpelling) \(otherText)",
|
||||||
|
settings: ArabicPronunciationSettings(
|
||||||
|
isEnabled: true,
|
||||||
|
diacritizationPolicy: .pronunciationTargeted,
|
||||||
|
preservedSpellings: [preservedSpelling]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.text, "\(preservedSpelling) \u{0628}\u{064E}\u{0651}")
|
||||||
|
XCTAssertEqual(result.notes, [.preservedSpellingsProtected(count: 1)])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testArabicDialectFixturesCoverExpectedProcessingBehavior() {
|
||||||
|
XCTAssertEqual(ArabicLyricsFixtures.all.count, 4)
|
||||||
|
XCTAssertEqual(
|
||||||
|
Set(ArabicLyricsFixtures.all.map(\.dialectName)).count,
|
||||||
|
ArabicLyricsFixtures.all.count
|
||||||
|
)
|
||||||
|
|
||||||
|
for fixture in ArabicLyricsFixtures.all {
|
||||||
|
let result = processor.process(
|
||||||
|
lyrics: fixture.lyrics,
|
||||||
|
settings: fixture.settings
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
result.text.unicodeScalars.map(\.value),
|
||||||
|
fixture.expectedLyrics.unicodeScalars.map(\.value),
|
||||||
|
fixture.dialectName
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result.notes, fixture.expectedNotes, fixture.dialectName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
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(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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import MusicAssistantCore
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class InstrumentCatalogItemTests: XCTestCase {
|
||||||
|
func testCatalogItemStoresStructuredInstrumentMetadata() {
|
||||||
|
let item = InstrumentCatalogItem(
|
||||||
|
id: "oud",
|
||||||
|
name: "Oud",
|
||||||
|
familyCategory: "Plucked strings",
|
||||||
|
regionOrigin: "Middle East",
|
||||||
|
aliases: ["Arabic lute"],
|
||||||
|
searchTerms: ["ud", "عود"]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(item.id, "oud")
|
||||||
|
XCTAssertEqual(item.name, "Oud")
|
||||||
|
XCTAssertEqual(item.familyCategory, "Plucked strings")
|
||||||
|
XCTAssertEqual(item.regionOrigin, "Middle East")
|
||||||
|
XCTAssertEqual(item.aliases, ["Arabic lute"])
|
||||||
|
XCTAssertEqual(item.searchTerms, ["ud", "عود"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCatalogItemSupportsOptionalMetadataAndRoundTripsThroughJSON() throws {
|
||||||
|
let item = InstrumentCatalogItem(
|
||||||
|
id: "synthesizer",
|
||||||
|
name: "Synthesizer",
|
||||||
|
familyCategory: "Electronic"
|
||||||
|
)
|
||||||
|
|
||||||
|
let data = try JSONEncoder().encode(item)
|
||||||
|
let decodedItem = try JSONDecoder().decode(InstrumentCatalogItem.self, from: data)
|
||||||
|
|
||||||
|
XCTAssertNil(item.regionOrigin)
|
||||||
|
XCTAssertEqual(item.aliases, [])
|
||||||
|
XCTAssertEqual(item.searchTerms, [])
|
||||||
|
XCTAssertEqual(decodedItem, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,34 @@ final class OpenAIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(recordedURL, overrideURL)
|
XCTAssertEqual(recordedURL, overrideURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDiscussSongProjectUsesAdapterAndReturnsQuestions() async throws {
|
||||||
|
let transport = RecordingOpenAITransport(
|
||||||
|
statusCode: 200,
|
||||||
|
responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Should the chorus be intimate or anthemic?"))
|
||||||
|
)
|
||||||
|
let configuration = OpenAIClientConfiguration(
|
||||||
|
apiKey: "test-api-key",
|
||||||
|
endpointURL: URL(string: "https://api.example.test/v1/configured")!,
|
||||||
|
model: "configured-model"
|
||||||
|
)
|
||||||
|
let client = OpenAIClient(
|
||||||
|
configuration: configuration,
|
||||||
|
adapter: MockOpenAIClientAdapter(),
|
||||||
|
transport: transport
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = try await client.discussSongProject(
|
||||||
|
from: SongProjectDiscussionRequest(
|
||||||
|
context: AIRequestContext(userInstruction: "Ask before deciding."),
|
||||||
|
project: SongProject(title: "Discussion", idea: "Plan this", conversationMode: .discuss)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let requestBodyString = await transport.requestBodyString
|
||||||
|
|
||||||
|
XCTAssertEqual(result.questions, ["Should the chorus be intimate or anthemic?"])
|
||||||
|
XCTAssertEqual(requestBodyString, "discuss|configured-model|Ask before deciding.")
|
||||||
|
}
|
||||||
|
|
||||||
func testClientRejectsMissingAPIKeyBeforeSendingRequest() async throws {
|
func testClientRejectsMissingAPIKeyBeforeSendingRequest() async throws {
|
||||||
let transport = RecordingOpenAITransport(
|
let transport = RecordingOpenAITransport(
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
@@ -100,7 +128,7 @@ final class OpenAIClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(recordedRequestCount, 0)
|
XCTAssertEqual(recordedRequestCount, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testClientReportsUnacceptableStatusWithoutLeakingResponseBody() async throws {
|
func testClientReportsRateLimitWithoutLeakingResponseBody() async throws {
|
||||||
let transport = RecordingOpenAITransport(
|
let transport = RecordingOpenAITransport(
|
||||||
statusCode: 429,
|
statusCode: 429,
|
||||||
responseData: Data("secret server detail".utf8)
|
responseData: Data("secret server detail".utf8)
|
||||||
@@ -122,7 +150,32 @@ final class OpenAIClientTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
XCTFail("Expected status error to throw.")
|
XCTFail("Expected status error to throw.")
|
||||||
} catch let error as OpenAIClientError {
|
} catch let error as OpenAIClientError {
|
||||||
XCTAssertEqual(error, .unacceptableStatusCode(429))
|
XCTAssertEqual(error, .rateLimited(retryAfter: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClientReadsRetryAfterForRateLimitedResponses() async throws {
|
||||||
|
let transport = RecordingOpenAITransport(
|
||||||
|
statusCode: 429,
|
||||||
|
responseData: Data(),
|
||||||
|
responseHeaders: ["Retry-After": "3.5"]
|
||||||
|
)
|
||||||
|
let client = OpenAIClient(
|
||||||
|
configuration: OpenAIClientConfiguration(
|
||||||
|
apiKey: "test-api-key",
|
||||||
|
endpointURL: URL(string: "https://api.example.test/v1/configured")!
|
||||||
|
),
|
||||||
|
adapter: MockOpenAIClientAdapter(),
|
||||||
|
transport: transport
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await client.generateSongProject(
|
||||||
|
from: SongProjectGenerationRequest(context: AIRequestContext(userInstruction: "Generate."))
|
||||||
|
)
|
||||||
|
XCTFail("Expected rate limit error to throw.")
|
||||||
|
} catch let error as OpenAIClientError {
|
||||||
|
XCTAssertEqual(error, .rateLimited(retryAfter: 3.5))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,10 +184,16 @@ private actor RecordingOpenAITransport: OpenAIHTTPTransport {
|
|||||||
private(set) var recordedRequests: [URLRequest] = []
|
private(set) var recordedRequests: [URLRequest] = []
|
||||||
private let statusCode: Int
|
private let statusCode: Int
|
||||||
private let responseData: Data
|
private let responseData: Data
|
||||||
|
private let responseHeaders: [String: String]?
|
||||||
|
|
||||||
init(statusCode: Int, responseData: Data) {
|
init(
|
||||||
|
statusCode: Int,
|
||||||
|
responseData: Data,
|
||||||
|
responseHeaders: [String: String]? = nil
|
||||||
|
) {
|
||||||
self.statusCode = statusCode
|
self.statusCode = statusCode
|
||||||
self.responseData = responseData
|
self.responseData = responseData
|
||||||
|
self.responseHeaders = responseHeaders
|
||||||
}
|
}
|
||||||
|
|
||||||
var requestBodyString: String? {
|
var requestBodyString: String? {
|
||||||
@@ -148,7 +207,7 @@ private actor RecordingOpenAITransport: OpenAIHTTPTransport {
|
|||||||
url: request.url!,
|
url: request.url!,
|
||||||
statusCode: statusCode,
|
statusCode: statusCode,
|
||||||
httpVersion: "HTTP/1.1",
|
httpVersion: "HTTP/1.1",
|
||||||
headerFields: nil
|
headerFields: responseHeaders
|
||||||
)!
|
)!
|
||||||
return (responseData, response)
|
return (responseData, response)
|
||||||
}
|
}
|
||||||
@@ -175,6 +234,21 @@ private struct MockOpenAIClientAdapter: OpenAIClientAdapter {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeSongProjectDiscussionRequest(
|
||||||
|
_ request: SongProjectDiscussionRequest,
|
||||||
|
configuration: OpenAIClientConfiguration
|
||||||
|
) throws -> OpenAIClientRequest {
|
||||||
|
OpenAIClientRequest(
|
||||||
|
url: overrideURL,
|
||||||
|
body: Data("discuss|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeSongProjectDiscussionResult(from data: Data) throws -> SongProjectDiscussionResult {
|
||||||
|
let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data)
|
||||||
|
return SongProjectDiscussionResult(questions: [response.text])
|
||||||
|
}
|
||||||
|
|
||||||
func makeLyricsRevisionRequest(
|
func makeLyricsRevisionRequest(
|
||||||
_ request: LyricsRevisionRequest,
|
_ request: LyricsRevisionRequest,
|
||||||
configuration: OpenAIClientConfiguration
|
configuration: OpenAIClientConfiguration
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import MusicAssistantCore
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class RetryingAIServiceTests: XCTestCase {
|
||||||
|
func testRetriesTemporaryServerFailureThenReturnsResult() async throws {
|
||||||
|
let baseService = ScriptedRetryAIService(
|
||||||
|
outcomes: [.serverFailure(503), .serverFailure(503), .success("Generated")]
|
||||||
|
)
|
||||||
|
let scheduler = RecordingRetryScheduler()
|
||||||
|
let service = RetryingAIService(
|
||||||
|
baseService: baseService,
|
||||||
|
policy: AIServiceRetryPolicy(maxAttempts: 3, baseDelay: 0.25, maximumDelay: 2),
|
||||||
|
scheduler: scheduler
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = try await service.generateSongProject(
|
||||||
|
from: SongProjectGenerationRequest(context: AIRequestContext(userInstruction: "Generate."))
|
||||||
|
)
|
||||||
|
let attemptCount = await baseService.generateAttemptCount
|
||||||
|
let delays = await scheduler.delays
|
||||||
|
|
||||||
|
XCTAssertEqual(result.project.title, "Generated")
|
||||||
|
XCTAssertEqual(attemptCount, 3)
|
||||||
|
XCTAssertEqual(delays, [0.25, 0.5])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHonorsProviderRateLimitDelayBeforeRetrying() async throws {
|
||||||
|
let baseService = ScriptedRetryAIService(
|
||||||
|
outcomes: [.rateLimited(4), .success("Generated")]
|
||||||
|
)
|
||||||
|
let scheduler = RecordingRetryScheduler()
|
||||||
|
let service = RetryingAIService(
|
||||||
|
baseService: baseService,
|
||||||
|
policy: AIServiceRetryPolicy(maxAttempts: 3, baseDelay: 0.25, maximumDelay: 2),
|
||||||
|
scheduler: scheduler
|
||||||
|
)
|
||||||
|
|
||||||
|
_ = try await service.generateSongProject(
|
||||||
|
from: SongProjectGenerationRequest(context: AIRequestContext(userInstruction: "Generate."))
|
||||||
|
)
|
||||||
|
let delays = await scheduler.delays
|
||||||
|
|
||||||
|
XCTAssertEqual(delays, [4])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDoesNotRetryPermanentFailures() async {
|
||||||
|
let baseService = ScriptedRetryAIService(outcomes: [.missingAPIKey])
|
||||||
|
let scheduler = RecordingRetryScheduler()
|
||||||
|
let service = RetryingAIService(baseService: baseService, scheduler: scheduler)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await service.generateSongProject(
|
||||||
|
from: SongProjectGenerationRequest(context: AIRequestContext(userInstruction: "Generate."))
|
||||||
|
)
|
||||||
|
XCTFail("Expected missing key error to throw.")
|
||||||
|
} catch let error as OpenAIClientError {
|
||||||
|
XCTAssertEqual(error, .missingAPIKey)
|
||||||
|
} catch {
|
||||||
|
XCTFail("Expected OpenAIClientError.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let attemptCount = await baseService.generateAttemptCount
|
||||||
|
let delays = await scheduler.delays
|
||||||
|
XCTAssertEqual(attemptCount, 1)
|
||||||
|
XCTAssertTrue(delays.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCancellationWhileWaitingStopsFurtherRetries() async {
|
||||||
|
let baseService = ScriptedRetryAIService(outcomes: [.serverFailure(503), .success("Unused")])
|
||||||
|
let scheduler = RecordingRetryScheduler(shouldCancel: true)
|
||||||
|
let service = RetryingAIService(baseService: baseService, scheduler: scheduler)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await service.generateSongProject(
|
||||||
|
from: SongProjectGenerationRequest(context: AIRequestContext(userInstruction: "Generate."))
|
||||||
|
)
|
||||||
|
XCTFail("Expected cancellation to throw.")
|
||||||
|
} catch is CancellationError {
|
||||||
|
} catch {
|
||||||
|
XCTFail("Expected CancellationError.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let attemptCount = await baseService.generateAttemptCount
|
||||||
|
XCTAssertEqual(attemptCount, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum RetryOutcome: Sendable {
|
||||||
|
case serverFailure(Int)
|
||||||
|
case rateLimited(TimeInterval?)
|
||||||
|
case missingAPIKey
|
||||||
|
case success(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor ScriptedRetryAIService: AIService {
|
||||||
|
private var outcomes: [RetryOutcome]
|
||||||
|
private(set) var generateAttemptCount = 0
|
||||||
|
|
||||||
|
init(outcomes: [RetryOutcome]) {
|
||||||
|
self.outcomes = outcomes
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
|
||||||
|
generateAttemptCount += 1
|
||||||
|
guard !outcomes.isEmpty else {
|
||||||
|
return SongProjectGenerationResult(project: SongProject(title: "Unexpected", idea: request.context.userInstruction))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch outcomes.removeFirst() {
|
||||||
|
case let .serverFailure(statusCode):
|
||||||
|
throw OpenAIClientError.serverError(statusCode)
|
||||||
|
case let .rateLimited(retryAfter):
|
||||||
|
throw OpenAIClientError.rateLimited(retryAfter: retryAfter)
|
||||||
|
case .missingAPIKey:
|
||||||
|
throw OpenAIClientError.missingAPIKey
|
||||||
|
case let .success(title):
|
||||||
|
return SongProjectGenerationResult(project: SongProject(title: title, idea: request.context.userInstruction))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||||
|
LyricsRevisionResult(lyrics: request.sourceLyrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
|
||||||
|
SongProjectUpdateResult(project: request.project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor RecordingRetryScheduler: AIServiceRetryScheduler {
|
||||||
|
private(set) var delays: [TimeInterval] = []
|
||||||
|
private let shouldCancel: Bool
|
||||||
|
|
||||||
|
init(shouldCancel: Bool = false) {
|
||||||
|
self.shouldCancel = shouldCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
func sleep(for interval: TimeInterval) async throws {
|
||||||
|
delays.append(interval)
|
||||||
|
if shouldCancel {
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import MusicAssistantCore
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class SongProjectDiscussionDirectorTests: XCTestCase {
|
||||||
|
func testDiscussBuildsARequestAndReturnsTrimmedQuestionsWithoutChangingProject() async throws {
|
||||||
|
let project = SongProject(
|
||||||
|
id: "discussion-project",
|
||||||
|
title: "Discussion Project",
|
||||||
|
idea: "Plan a cinematic chorus",
|
||||||
|
conversationMode: .discuss,
|
||||||
|
createdAt: Date(timeIntervalSince1970: 10),
|
||||||
|
updatedAt: Date(timeIntervalSince1970: 20)
|
||||||
|
)
|
||||||
|
let aiService = RecordingDiscussionAIService(
|
||||||
|
result: SongProjectDiscussionResult(
|
||||||
|
questions: [" Should the chorus use a choir? ", " "],
|
||||||
|
notes: ["Awaiting the user's choice."]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let director = SongProjectDiscussionDirector(aiService: aiService)
|
||||||
|
let conversation = [AIConversationMessage(role: .assistant, content: "What mood should lead?")]
|
||||||
|
|
||||||
|
let result = try await director.discuss(
|
||||||
|
project: project,
|
||||||
|
userMessage: " Keep the verses intimate. ",
|
||||||
|
conversation: conversation,
|
||||||
|
localeIdentifier: "en_US"
|
||||||
|
)
|
||||||
|
let request = await aiService.recordedRequests.first
|
||||||
|
|
||||||
|
XCTAssertEqual(request?.context.userInstruction, "Keep the verses intimate.")
|
||||||
|
XCTAssertEqual(request?.context.conversation, conversation)
|
||||||
|
XCTAssertEqual(request?.context.localeIdentifier, "en_US")
|
||||||
|
XCTAssertEqual(request?.project, project)
|
||||||
|
XCTAssertEqual(result.questions, ["Should the chorus use a choir?"])
|
||||||
|
XCTAssertEqual(result.notes, ["Awaiting the user's choice."])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDiscussRejectsAutoModeAndBlankMessagesBeforeCallingAI() async throws {
|
||||||
|
let aiService = RecordingDiscussionAIService(
|
||||||
|
result: SongProjectDiscussionResult(questions: ["Unused"])
|
||||||
|
)
|
||||||
|
let director = SongProjectDiscussionDirector(aiService: aiService)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await director.discuss(
|
||||||
|
project: SongProject(title: "Auto", idea: "Auto", conversationMode: .auto),
|
||||||
|
userMessage: "Ask a question."
|
||||||
|
)
|
||||||
|
XCTFail("Expected discussion mode validation to fail.")
|
||||||
|
} catch let error as SongProjectDiscussionDirectorError {
|
||||||
|
XCTAssertEqual(error, .discussionModeNotEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await director.discuss(
|
||||||
|
project: SongProject(title: "Discuss", idea: "Discuss", conversationMode: .discuss),
|
||||||
|
userMessage: " \n "
|
||||||
|
)
|
||||||
|
XCTFail("Expected blank message validation to fail.")
|
||||||
|
} catch let error as SongProjectDiscussionDirectorError {
|
||||||
|
XCTAssertEqual(error, .emptyUserMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestCount = await aiService.recordedRequests.count
|
||||||
|
XCTAssertEqual(requestCount, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor RecordingDiscussionAIService: AIService {
|
||||||
|
private(set) var recordedRequests: [SongProjectDiscussionRequest] = []
|
||||||
|
private let result: SongProjectDiscussionResult
|
||||||
|
|
||||||
|
init(result: SongProjectDiscussionResult) {
|
||||||
|
self.result = result
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
|
||||||
|
SongProjectGenerationResult(project: request.seedProject ?? SongProject(title: "Unused", idea: "Unused"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func discussSongProject(from request: SongProjectDiscussionRequest) async throws -> SongProjectDiscussionResult {
|
||||||
|
recordedRequests.append(request)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||||
|
LyricsRevisionResult(lyrics: request.sourceLyrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
|
||||||
|
SongProjectUpdateResult(project: request.project)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -369,6 +369,36 @@ final class SongProjectModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(project.languageIDs, ["english"])
|
XCTAssertEqual(project.languageIDs, ["english"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testApplyingReviewedArabicLyricsUpdatesMatchingSunoLyrics() {
|
||||||
|
var project = SongProject(
|
||||||
|
id: "project-arabic-review",
|
||||||
|
title: "Arabic Review",
|
||||||
|
idea: "Review pronunciation",
|
||||||
|
lyrics: Lyrics(text: "Original lyrics"),
|
||||||
|
sunoOutput: SunoOutput(lyricsText: "Original lyrics", stylePrompt: "Arabic pop")
|
||||||
|
)
|
||||||
|
|
||||||
|
project.applyReviewedArabicLyrics("Reviewed lyrics")
|
||||||
|
|
||||||
|
XCTAssertEqual(project.lyrics.text, "Reviewed lyrics")
|
||||||
|
XCTAssertEqual(project.sunoOutput?.lyricsText, "Reviewed lyrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApplyingReviewedArabicLyricsPreservesEditedSunoLyrics() {
|
||||||
|
var project = SongProject(
|
||||||
|
id: "project-arabic-review-manual-output",
|
||||||
|
title: "Arabic Review",
|
||||||
|
idea: "Review pronunciation",
|
||||||
|
lyrics: Lyrics(text: "Original lyrics"),
|
||||||
|
sunoOutput: SunoOutput(lyricsText: "Manually edited output", stylePrompt: "Arabic pop")
|
||||||
|
)
|
||||||
|
|
||||||
|
project.applyReviewedArabicLyrics("Reviewed lyrics")
|
||||||
|
|
||||||
|
XCTAssertEqual(project.lyrics.text, "Reviewed lyrics")
|
||||||
|
XCTAssertEqual(project.sunoOutput?.lyricsText, "Manually edited output")
|
||||||
|
}
|
||||||
|
|
||||||
func testMusicalParametersSupportManualAndAutoModes() {
|
func testMusicalParametersSupportManualAndAutoModes() {
|
||||||
var project = SongProject(
|
var project = SongProject(
|
||||||
id: "project-8",
|
id: "project-8",
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,12 +40,38 @@ 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.
|
||||||
|
When a Song Project uses Discuss mode, the AI Director sends the current
|
||||||
|
project and conversation context to the provider and returns follow-up
|
||||||
|
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.
|
||||||
|
AI provider calls can be wrapped by a configurable retry service. It retries only
|
||||||
|
temporary network, server and rate-limit failures, honors a provider-supplied
|
||||||
|
retry delay when available, and propagates cancellation without retrying.
|
||||||
|
|
||||||
## Prompt Compiler
|
## Prompt Compiler
|
||||||
|
|
||||||
A deterministic layer converts the approved SongProject into the final
|
A deterministic layer converts the approved SongProject into the final
|
||||||
Suno-facing lyrics/style content. User choices override AI suggestions.
|
Suno-facing lyrics/style content. User choices override AI suggestions.
|
||||||
|
|
||||||
|
## Arabic Pronunciation Processor
|
||||||
|
|
||||||
|
The Arabic Pronunciation Processor is a local Application Service. It
|
||||||
|
normalizes user-supplied Arabic diacritics and applies the selected
|
||||||
|
tanween policy without choosing a diacritization policy for the user.
|
||||||
|
It must not invent missing vowel marks or tanween when no reliable
|
||||||
|
linguistic source is available; instead, it returns a review note so a
|
||||||
|
later user-review flow can present the unresolved text. This processor
|
||||||
|
does not use external services or APIs. Before processing, it protects
|
||||||
|
the user's exact preserved spellings and dialect phrases so normalization
|
||||||
|
or tanween removal cannot alter them. The Presentation layer presents
|
||||||
|
both the original and processed lyrics in final review; the user may edit
|
||||||
|
and explicitly apply the processed text before any Suno handoff.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Never commit API keys to source control.
|
- Never commit API keys to source control.
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ SongProject
|
|||||||
- key
|
- key
|
||||||
- scale
|
- scale
|
||||||
- maqam
|
- maqam
|
||||||
|
- structureMode: manual | auto
|
||||||
- sections[]
|
- sections[]
|
||||||
- instruments[]
|
- instruments[]
|
||||||
- vocalists[]
|
- vocalists[]
|
||||||
- lyrics
|
- lyrics
|
||||||
- productionDirections[]
|
- productionDirections[]
|
||||||
|
- productionMode: manual | auto
|
||||||
|
- userLocks[]
|
||||||
- sunoOutput
|
- sunoOutput
|
||||||
- versions[]
|
- versions[]
|
||||||
- createdAt
|
- createdAt
|
||||||
@@ -59,6 +62,26 @@ 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.
|
||||||
|
|
||||||
|
Discuss mode behavior
|
||||||
|
- When conversationMode is discuss, the AI receives the current Song Project
|
||||||
|
and conversation context, then returns follow-up questions before a project
|
||||||
|
decision is applied.
|
||||||
|
- 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)
|
||||||
|
|||||||
+10
-10
@@ -59,23 +59,23 @@ 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.
|
- [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.
|
- [x] Add error, retry, cancellation and rate-limit handling.
|
||||||
|
|
||||||
## Phase 5 --- Arabic Lyrics Processing
|
## Phase 5 --- Arabic Lyrics Processing
|
||||||
|
|
||||||
- [ ] Add Arabic-specific settings UI.
|
- [x] Add Arabic-specific settings UI.
|
||||||
- [ ] Support diacritics/harakat/tanween processing.
|
- [x] Support diacritics/harakat/tanween processing.
|
||||||
- [ ] Preserve intentional spelling/dialect choices where possible.
|
- [x] Preserve intentional spelling/dialect choices where possible.
|
||||||
- [ ] Allow user to compare/edit processed Arabic before Suno handoff.
|
- [x] Allow user to compare/edit processed Arabic before Suno handoff.
|
||||||
- [ ] Add Arabic test fixtures covering multiple dialects.
|
- [x] Add Arabic test fixtures covering multiple dialects.
|
||||||
|
|
||||||
## Phase 6 --- Instrument Selector & Catalog
|
## Phase 6 --- Instrument Selector & Catalog
|
||||||
|
|
||||||
- [ ] Create structured catalog models for instrument id, name,
|
- [x] Create structured catalog models for instrument id, name,
|
||||||
family/category, optional region/origin and aliases/search terms.
|
family/category, optional region/origin and aliases/search terms.
|
||||||
- [ ] Provide large worldwide catalog data covering Western, Middle
|
- [ ] Provide large worldwide catalog data covering Western, Middle
|
||||||
Eastern, African, South Asian, East Asian, Southeast Asian, Latin
|
Eastern, African, South Asian, East Asian, Southeast Asian, Latin
|
||||||
|
|||||||
Reference in New Issue
Block a user