Compare commits

..
10 Commits
Author SHA1 Message Date
diyaa e7a46772ec Test instrument catalog metadata 2026-09-13 23:37:48 +02:00
diyaa 9a0dd452bd Add Arabic dialect test fixtures 2026-09-13 23:22:49 +02:00
diyaa 6a99266e75 Add Arabic lyrics review 2026-09-13 22:42:50 +02:00
diyaa 3865f77dc0 Preserve Arabic spelling choices 2026-09-13 22:33:57 +02:00
diyaa f5ab97e6a0 Add Arabic pronunciation processing 2026-09-13 21:44:42 +02:00
diyaa 49a03a0700 Add Arabic pronunciation settings UI 2026-09-13 21:31:15 +02:00
diyaa eb61bd076c Add resilient AI service handling 2026-09-13 21:28:13 +02:00
diyaa ae88c6e533 Enforce user locks for AI updates 2026-09-13 21:20:57 +02:00
diyaa a39bdbe6fc Implement optional discussion mode 2026-09-13 21:13:52 +02:00
diyaa bf1ccbe8a9 Implement automatic song project decisions 2026-09-13 20:59:25 +02:00
33 changed files with 2314 additions and 67 deletions
@@ -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 {
VStack(alignment: .leading, spacing: 12) {
ArabicLyricsReviewView(project: $project)
Divider()
TextField("Approved lyrics", text: $project.lyrics.text, axis: .vertical)
.lineLimit(4...10)
@@ -38,6 +38,11 @@ struct ProjectInspectorView: View {
TextField("Title", text: $project.title)
TextField("Idea", text: $project.idea, axis: .vertical)
.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 note", text: durationDescriptionBinding)
}
@@ -98,6 +103,12 @@ struct ProjectInspectorView: View {
)
}
Section("Arabic Pronunciation") {
ArabicPronunciationSettingsView(
settings: $project.arabicPronunciationSettings
)
}
Section("Song Structure") {
SongStructureEditorView(project: $project)
}
@@ -16,11 +16,14 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
public var key: ManualAutoValue<String>?
public var scale: ManualAutoValue<String>?
public var maqam: ManualAutoValue<String>?
public var structureMode: ControlMode?
public var sections: [SongSection]
public var instruments: [InstrumentTrack]
public var vocalists: [Vocalist]
public var lyrics: Lyrics
public var productionDirections: [ProductionDirection]
public var productionMode: ControlMode?
public var userLocks: SongProjectUserLocks?
public var sunoOutput: SunoOutput?
public var versions: [SongVersion]
public let createdAt: Date
@@ -42,11 +45,14 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
key: ManualAutoValue<String>? = nil,
scale: ManualAutoValue<String>? = nil,
maqam: ManualAutoValue<String>? = nil,
structureMode: ControlMode? = .auto,
sections: [SongSection] = [],
instruments: [InstrumentTrack] = [],
vocalists: [Vocalist] = [],
lyrics: Lyrics = Lyrics(),
productionDirections: [ProductionDirection] = [],
productionMode: ControlMode? = .auto,
userLocks: SongProjectUserLocks? = SongProjectUserLocks(),
sunoOutput: SunoOutput? = nil,
versions: [SongVersion] = [],
createdAt: Date = Date(),
@@ -67,11 +73,14 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
self.key = key
self.scale = scale
self.maqam = maqam
self.structureMode = structureMode
self.sections = sections
self.instruments = instruments
self.vocalists = vocalists
self.lyrics = lyrics
self.productionDirections = productionDirections
self.productionMode = productionMode
self.userLocks = userLocks
self.sunoOutput = sunoOutput
self.versions = versions
self.createdAt = createdAt
@@ -80,6 +89,42 @@ public struct SongProject: Codable, Equatable, Identifiable, Sendable {
}
public extension SongProject {
var isStructureAutomatic: Bool {
structureMode != .manual
}
var isProductionAutomatic: Bool {
productionMode != .manual
}
mutating func setStructureMode(_ mode: ControlMode) {
structureMode = mode
}
mutating func setProductionMode(_ mode: ControlMode) {
productionMode = mode
}
var 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] {
sections
.enumerated()
@@ -154,6 +199,15 @@ public extension SongProject {
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) {
bpm = ManualAutoValue(mode: .manual, value: value)
}
@@ -364,11 +418,14 @@ public extension SongProject {
key = snapshot.key
scale = snapshot.scale
maqam = snapshot.maqam
structureMode = snapshot.structureMode
sections = snapshot.sections
instruments = snapshot.instruments
vocalists = snapshot.vocalists
lyrics = snapshot.lyrics
productionDirections = snapshot.productionDirections
productionMode = snapshot.productionMode
userLocks = snapshot.userLocks
sunoOutput = snapshot.sunoOutput
self.updatedAt = updatedAt
}
@@ -764,11 +821,14 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
public var key: ManualAutoValue<String>?
public var scale: ManualAutoValue<String>?
public var maqam: ManualAutoValue<String>?
public var structureMode: ControlMode?
public var sections: [SongSection]
public var instruments: [InstrumentTrack]
public var vocalists: [Vocalist]
public var lyrics: Lyrics
public var productionDirections: [ProductionDirection]
public var productionMode: ControlMode?
public var userLocks: SongProjectUserLocks?
public var sunoOutput: SunoOutput?
public init(
@@ -786,11 +846,14 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
key: ManualAutoValue<String>? = nil,
scale: ManualAutoValue<String>? = nil,
maqam: ManualAutoValue<String>? = nil,
structureMode: ControlMode? = .auto,
sections: [SongSection] = [],
instruments: [InstrumentTrack] = [],
vocalists: [Vocalist] = [],
lyrics: Lyrics = Lyrics(),
productionDirections: [ProductionDirection] = [],
productionMode: ControlMode? = .auto,
userLocks: SongProjectUserLocks? = SongProjectUserLocks(),
sunoOutput: SunoOutput? = nil
) {
self.title = title
@@ -807,11 +870,14 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
self.key = key
self.scale = scale
self.maqam = maqam
self.structureMode = structureMode
self.sections = sections
self.instruments = instruments
self.vocalists = vocalists
self.lyrics = lyrics
self.productionDirections = productionDirections
self.productionMode = productionMode
self.userLocks = userLocks
self.sunoOutput = sunoOutput
}
@@ -831,11 +897,14 @@ public struct SongProjectSnapshot: Codable, Equatable, Sendable {
key: project.key,
scale: project.scale,
maqam: project.maqam,
structureMode: project.structureMode,
sections: project.sections,
instruments: project.instruments,
vocalists: project.vocalists,
lyrics: project.lyrics,
productionDirections: project.productionDirections,
productionMode: project.productionMode,
userLocks: project.userLocks,
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 makeSongProjectDiscussionRequest(
_ request: SongProjectDiscussionRequest,
configuration: OpenAIClientConfiguration
) throws -> OpenAIClientRequest
func decodeSongProjectDiscussionResult(from data: Data) throws -> SongProjectDiscussionResult
func makeLyricsRevisionRequest(
_ request: LyricsRevisionRequest,
configuration: OpenAIClientConfiguration
@@ -99,6 +106,12 @@ public final class OpenAIClient: AIService, Sendable {
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 {
let clientRequest = try adapter.makeLyricsRevisionRequest(request, configuration: configuration)
let data = try await perform(clientRequest)
@@ -112,6 +125,8 @@ public final class OpenAIClient: AIService, Sendable {
}
private func perform(_ clientRequest: OpenAIClientRequest) async throws -> Data {
try Task.checkCancellation()
guard !configuration.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw OpenAIClientError.missingAPIKey
}
@@ -136,15 +151,30 @@ public final class OpenAIClient: AIService, Sendable {
let (data, response) = try await transport.data(for: request)
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)
}
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 {
case missingAPIKey
case invalidResponse
case rateLimited(retryAfter: TimeInterval?)
case serverError(Int)
case unacceptableStatusCode(Int)
}
@@ -2,10 +2,21 @@ import Foundation
public protocol AIService: Sendable {
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 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 let id: String
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 var context: AIRequestContext
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 var project: SongProject
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))
}
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 {
try await baseService.reviseLyrics(from: requestWithInjectedRules(request))
}
@@ -59,6 +63,12 @@ public final class ApplicationRuleInjectingAIService: AIService, Sendable {
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 {
var request = request
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
}
guard !project.isUpdateScopeLocked(.lyrics) else {
return SongProjectUpdateResult(project: project)
}
let request = LyricsRevisionRequest(
context: AIRequestContext(
userInstruction: trimmedInstruction,
@@ -42,14 +46,14 @@ public final class ExistingLyricsRevisionDirector: Sendable {
)
let result = try await aiService.reviseLyrics(from: request)
var updatedProject = project
updatedProject.lyrics = result.lyrics
updatedProject.updatedAt = revisedAt
return SongProjectUpdateResult(
project: updatedProject,
var proposedProject = project
proposedProject.lyrics = result.lyrics
return SongProjectAIUpdateMerger().apply(
snapshot: SongProjectSnapshot(project: proposedProject),
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 SongProject(
id: seedProject.id,
title: generatedProject.title,
idea: normalizedIdea,
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,
let update = SongProjectAIUpdateMerger().apply(
snapshot: SongProjectSnapshot(project: generatedProject),
changedScopes: SongProjectUpdateScope.allCases,
to: seedProject,
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,
updatedAt: Date = Date()
) -> SongProjectUpdateResult {
var updatedProject = existingProject
updatedProject.restoreSnapshot(project, updatedAt: updatedAt)
return SongProjectUpdateResult(
project: updatedProject,
SongProjectAIUpdateMerger().apply(
snapshot: project,
changedScopes: changedScopes,
notes: notes,
followUpQuestions: followUpQuestions
followUpQuestions: followUpQuestions,
to: existingProject,
updatedAt: updatedAt
)
}
}
@@ -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?"])
}
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 {
let service = MockAIService()
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 {
LyricsRevisionResult(
lyrics: Lyrics(text: "\(request.sourceLyrics.text)\n\(request.context.userInstruction)"),
@@ -17,6 +17,12 @@ final class ApplicationRuleInjectionTests: XCTestCase {
context: AIRequestContext(userInstruction: "Generate.")
)
)
_ = try await service.discussSongProject(
from: SongProjectDiscussionRequest(
context: AIRequestContext(userInstruction: "Discuss."),
project: project
)
)
_ = try await service.reviseLyrics(
from: LyricsRevisionRequest(
context: AIRequestContext(userInstruction: "Improve."),
@@ -37,6 +43,7 @@ final class ApplicationRuleInjectionTests: XCTestCase {
XCTAssertEqual(
recordedRuleContents,
[
"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 {
contexts.append(request.context)
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
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 {
@@ -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)
}
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 {
let transport = RecordingOpenAITransport(
statusCode: 200,
@@ -100,7 +128,7 @@ final class OpenAIClientTests: XCTestCase {
XCTAssertEqual(recordedRequestCount, 0)
}
func testClientReportsUnacceptableStatusWithoutLeakingResponseBody() async throws {
func testClientReportsRateLimitWithoutLeakingResponseBody() async throws {
let transport = RecordingOpenAITransport(
statusCode: 429,
responseData: Data("secret server detail".utf8)
@@ -122,7 +150,32 @@ final class OpenAIClientTests: XCTestCase {
)
XCTFail("Expected status error to throw.")
} 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 let statusCode: Int
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.responseData = responseData
self.responseHeaders = responseHeaders
}
var requestBodyString: String? {
@@ -148,7 +207,7 @@ private actor RecordingOpenAITransport: OpenAIHTTPTransport {
url: request.url!,
statusCode: statusCode,
httpVersion: "HTTP/1.1",
headerFields: nil
headerFields: responseHeaders
)!
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(
_ request: LyricsRevisionRequest,
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
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 {
@@ -369,6 +369,36 @@ final class SongProjectModelTests: XCTestCase {
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() {
var project = SongProject(
id: "project-8",
@@ -105,10 +105,69 @@ final class StructuredAIResponseSchemaTests: XCTestCase {
XCTAssertEqual(result.project.createdAt, createdAt)
XCTAssertEqual(result.project.updatedAt, updatedAt)
XCTAssertEqual(result.project.versions, [version])
XCTAssertEqual(result.project.title, "Updated")
XCTAssertEqual(result.project.title, "Original")
XCTAssertEqual(result.project.lyrics.text, "Updated lyrics")
XCTAssertEqual(result.changedScopes, [.genres, .lyrics])
XCTAssertEqual(result.notes, ["Proposed focused update."])
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])
}
}
+26
View File
@@ -40,12 +40,38 @@ macOS App
OpenAI receives the user's intent plus private application rules and
structured project state. AI output should be requested as structured
data wherever possible, not treated as an unstructured chat transcript.
For automatic decisions, the AI Director requests only enabled automatic
scopes and merges the response into the current project while preserving
manual structure, arrangement, musical-parameter and production choices.
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
A deterministic layer converts the approved SongProject into the final
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
- Never commit API keys to source control.
+23
View File
@@ -20,11 +20,14 @@ SongProject
- key
- scale
- maqam
- structureMode: manual | auto
- sections[]
- instruments[]
- vocalists[]
- lyrics
- productionDirections[]
- productionMode: manual | auto
- userLocks[]
- sunoOutput
- versions[]
- createdAt
@@ -59,6 +62,26 @@ InstrumentTrack
- autoArrangementEnabled
- placements[]
Auto-decision behavior
- The AI may update song sections only when structureMode is auto.
- The AI may update an instrument's role, playing style and placements only
when autoArrangementEnabled is true for that selected instrument.
- The AI may fill BPM, key, scale and maqam only when their individual
ManualAutoValue mode is auto.
- The AI may update production directions only when productionMode is auto.
- Manual values remain authoritative when automatic decisions are applied.
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
- sectionId (optional)
- startTime (optional)
+10 -10
View File
@@ -59,23 +59,23 @@ requirement is missing and blocks implementation, record it in
- [x] Define structured AI response schema for SongProject updates.
- [x] Implement idea → complete project generation.
- [x] Implement existing lyrics → correction/improvement flow.
- [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam
- [x] Implement Auto mode for structure, arrangement, BPM/key/maqam
and production decisions.
- [ ] Implement optional Discuss mode.
- [ ] Enforce user-lock/manual-value precedence over AI output.
- [ ] Add error, retry, cancellation and rate-limit handling.
- [x] Implement optional Discuss mode.
- [x] Enforce user-lock/manual-value precedence over AI output.
- [x] Add error, retry, cancellation and rate-limit handling.
## Phase 5 --- Arabic Lyrics Processing
- [ ] Add Arabic-specific settings UI.
- [ ] Support diacritics/harakat/tanween processing.
- [ ] Preserve intentional spelling/dialect choices where possible.
- [ ] Allow user to compare/edit processed Arabic before Suno handoff.
- [ ] Add Arabic test fixtures covering multiple dialects.
- [x] Add Arabic-specific settings UI.
- [x] Support diacritics/harakat/tanween processing.
- [x] Preserve intentional spelling/dialect choices where possible.
- [x] Allow user to compare/edit processed Arabic before Suno handoff.
- [x] Add Arabic test fixtures covering multiple dialects.
## 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.
- [ ] Provide large worldwide catalog data covering Western, Middle
Eastern, African, South Asian, East Asian, Southeast Asian, Latin