Implement automatic song project decisions
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import MusicAssistantCore
|
||||
import XCTest
|
||||
|
||||
final class AutoSongProjectDecisionsDirectorTests: XCTestCase {
|
||||
func testAutomaticDecisionsRequestEnabledScopesAndPreserveManualChoices() async throws {
|
||||
let currentProject = SongProject(
|
||||
id: "auto-project",
|
||||
title: "Current Project",
|
||||
idea: "Build a cinematic Arabic pop song",
|
||||
bpm: ManualAutoValue(mode: .auto),
|
||||
key: ManualAutoValue(mode: .manual, value: "D"),
|
||||
scale: ManualAutoValue(mode: .auto),
|
||||
maqam: ManualAutoValue(mode: .manual, value: "Hijaz"),
|
||||
structureMode: .manual,
|
||||
sections: [
|
||||
SongSection(id: "verse", type: .verse, title: "Manual Verse", productionDirection: "Dry vocal")
|
||||
],
|
||||
instruments: [
|
||||
InstrumentTrack(
|
||||
instrumentId: "piano",
|
||||
selected: true,
|
||||
role: "Manual piano role",
|
||||
autoArrangementEnabled: false,
|
||||
placements: [InstrumentPlacement(sectionId: "verse", direction: "Manual entry")]
|
||||
),
|
||||
InstrumentTrack(
|
||||
instrumentId: "violin",
|
||||
selected: true,
|
||||
autoArrangementEnabled: true
|
||||
)
|
||||
],
|
||||
productionDirections: [ProductionDirection(id: "manual-production", sectionID: "verse", text: "Keep it dry")],
|
||||
productionMode: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 10),
|
||||
updatedAt: Date(timeIntervalSince1970: 20)
|
||||
)
|
||||
let proposedProject = SongProject(
|
||||
title: "Proposed Project",
|
||||
idea: "Different",
|
||||
bpm: ManualAutoValue(mode: .manual, value: 102),
|
||||
key: ManualAutoValue(mode: .auto, value: "A"),
|
||||
scale: ManualAutoValue(mode: .manual, value: "minor"),
|
||||
maqam: ManualAutoValue(mode: .auto, value: "Bayati"),
|
||||
sections: [
|
||||
SongSection(id: "chorus", type: .chorus, title: "AI Chorus", productionDirection: "Wide synths")
|
||||
],
|
||||
instruments: [
|
||||
InstrumentTrack(
|
||||
instrumentId: "piano",
|
||||
selected: true,
|
||||
role: "AI piano role",
|
||||
placements: [InstrumentPlacement(sectionId: "chorus", direction: "AI piano entry")]
|
||||
),
|
||||
InstrumentTrack(
|
||||
instrumentId: "violin",
|
||||
selected: true,
|
||||
playingStyle: "legato",
|
||||
role: "AI countermelody",
|
||||
placements: [InstrumentPlacement(sectionId: "chorus", direction: "Enter at chorus")]
|
||||
)
|
||||
],
|
||||
productionDirections: [ProductionDirection(id: "ai-production", sectionID: "chorus", text: "Wide synths")]
|
||||
)
|
||||
let aiService = RecordingAutoDecisionsAIService(
|
||||
result: SongProjectUpdateResult(
|
||||
project: proposedProject,
|
||||
changedScopes: [.structure, .arrangement, .musicalParameters, .productionDirections],
|
||||
notes: ["Applied automatic decisions."]
|
||||
)
|
||||
)
|
||||
let director = AutoSongProjectDecisionsDirector(aiService: aiService)
|
||||
|
||||
let result = try await director.applyAutomaticDecisions(
|
||||
to: currentProject,
|
||||
instruction: " Complete automatic values. ",
|
||||
localeIdentifier: "en_US",
|
||||
updatedAt: Date(timeIntervalSince1970: 100)
|
||||
)
|
||||
let request = await aiService.recordedRequests.first
|
||||
|
||||
XCTAssertEqual(request?.context.userInstruction, "Complete automatic values.")
|
||||
XCTAssertEqual(request?.context.localeIdentifier, "en_US")
|
||||
XCTAssertEqual(
|
||||
request?.allowedScopes ?? [],
|
||||
[SongProjectUpdateScope.arrangement, .musicalParameters]
|
||||
)
|
||||
XCTAssertEqual(result.project.sections, currentProject.sections)
|
||||
XCTAssertEqual(result.project.productionDirections, currentProject.productionDirections)
|
||||
XCTAssertEqual(result.project.bpm, ManualAutoValue(mode: .auto, value: 102))
|
||||
XCTAssertEqual(result.project.key, currentProject.key)
|
||||
XCTAssertEqual(result.project.scale, ManualAutoValue(mode: .auto, value: "minor"))
|
||||
XCTAssertEqual(result.project.maqam, currentProject.maqam)
|
||||
XCTAssertEqual(result.project.instrumentTrack(for: "piano"), currentProject.instrumentTrack(for: "piano"))
|
||||
XCTAssertEqual(result.project.instrumentTrack(for: "violin")?.role, "AI countermelody")
|
||||
XCTAssertEqual(result.project.instrumentTrack(for: "violin")?.playingStyle, "legato")
|
||||
XCTAssertEqual(result.project.instrumentTrack(for: "violin")?.placements, [InstrumentPlacement(sectionId: "chorus", direction: "Enter at chorus")])
|
||||
XCTAssertEqual(result.project.updatedAt, Date(timeIntervalSince1970: 100))
|
||||
XCTAssertEqual(
|
||||
result.changedScopes,
|
||||
[SongProjectUpdateScope.arrangement, .musicalParameters]
|
||||
)
|
||||
XCTAssertEqual(result.notes, ["Applied automatic decisions."])
|
||||
}
|
||||
|
||||
func testAutomaticDecisionsApplyStructureAndProductionWhenTheirModesAreAutomatic() async throws {
|
||||
let currentProject = SongProject(
|
||||
title: "Auto Project",
|
||||
idea: "Let AI plan it",
|
||||
structureMode: .auto,
|
||||
sections: [SongSection(id: "intro", type: .intro, title: "Current Intro")],
|
||||
productionMode: .auto
|
||||
)
|
||||
let proposedProject = SongProject(
|
||||
title: "Proposed",
|
||||
idea: "Proposed",
|
||||
sections: [SongSection(id: "verse", type: .verse, title: "AI Verse", productionDirection: "Add texture")],
|
||||
productionDirections: [ProductionDirection(id: "production", sectionID: "verse", text: "Add texture")]
|
||||
)
|
||||
let aiService = RecordingAutoDecisionsAIService(
|
||||
result: SongProjectUpdateResult(project: proposedProject)
|
||||
)
|
||||
let director = AutoSongProjectDecisionsDirector(aiService: aiService)
|
||||
|
||||
let result = try await director.applyAutomaticDecisions(to: currentProject, updatedAt: Date(timeIntervalSince1970: 200))
|
||||
let request = await aiService.recordedRequests.first
|
||||
|
||||
XCTAssertEqual(
|
||||
request?.allowedScopes ?? [],
|
||||
[.structure, .musicalParameters, .productionDirections]
|
||||
)
|
||||
XCTAssertEqual(result.project.sections, proposedProject.sections)
|
||||
XCTAssertEqual(result.project.productionDirections, proposedProject.productionDirections)
|
||||
XCTAssertEqual(result.changedScopes, [.structure, .productionDirections])
|
||||
}
|
||||
|
||||
func testAutomaticDecisionsDoNotCallAIWhenEveryDecisionIsManual() async throws {
|
||||
let project = SongProject(
|
||||
title: "Manual Project",
|
||||
idea: "Keep all choices",
|
||||
bpm: ManualAutoValue(mode: .manual, value: 96),
|
||||
key: ManualAutoValue(mode: .manual, value: "C"),
|
||||
scale: ManualAutoValue(mode: .manual, value: "major"),
|
||||
maqam: ManualAutoValue(mode: .manual, value: "Rast"),
|
||||
structureMode: .manual,
|
||||
instruments: [InstrumentTrack(instrumentId: "oud", selected: true, autoArrangementEnabled: false)],
|
||||
productionMode: .manual
|
||||
)
|
||||
let aiService = RecordingAutoDecisionsAIService(
|
||||
result: SongProjectUpdateResult(project: project)
|
||||
)
|
||||
let director = AutoSongProjectDecisionsDirector(aiService: aiService)
|
||||
|
||||
let result = try await director.applyAutomaticDecisions(to: project)
|
||||
let requestCount = await aiService.recordedRequests.count
|
||||
|
||||
XCTAssertEqual(requestCount, 0)
|
||||
XCTAssertEqual(result.project, project)
|
||||
XCTAssertTrue(result.changedScopes.isEmpty)
|
||||
XCTAssertEqual(result.notes, ["No automatic decisions are enabled."])
|
||||
}
|
||||
|
||||
func testAutomaticStructureDoesNotReplaceManualProductionDirections() async throws {
|
||||
let project = SongProject(
|
||||
title: "Mixed Modes",
|
||||
idea: "Change the structure only",
|
||||
bpm: ManualAutoValue(mode: .manual, value: 100),
|
||||
key: ManualAutoValue(mode: .manual, value: "C"),
|
||||
scale: ManualAutoValue(mode: .manual, value: "minor"),
|
||||
maqam: ManualAutoValue(mode: .manual, value: "Rast"),
|
||||
structureMode: .auto,
|
||||
sections: [SongSection(id: "verse", type: .verse, title: "Current Verse", productionDirection: "Keep dry")],
|
||||
productionDirections: [ProductionDirection(id: "manual-production", sectionID: "verse", text: "Keep dry")],
|
||||
productionMode: .manual
|
||||
)
|
||||
let proposedProject = SongProject(
|
||||
title: "Proposed",
|
||||
idea: "Proposed",
|
||||
sections: [SongSection(id: "verse", type: .chorus, title: "AI Chorus", productionDirection: "Add wide synths")],
|
||||
productionDirections: [ProductionDirection(id: "ai-production", sectionID: "verse", text: "Add wide synths")]
|
||||
)
|
||||
let aiService = RecordingAutoDecisionsAIService(result: SongProjectUpdateResult(project: proposedProject))
|
||||
let director = AutoSongProjectDecisionsDirector(aiService: aiService)
|
||||
|
||||
let result = try await director.applyAutomaticDecisions(to: project)
|
||||
|
||||
XCTAssertEqual(result.project.sections.first?.type, .chorus)
|
||||
XCTAssertEqual(result.project.sections.first?.productionDirection, "Keep dry")
|
||||
XCTAssertEqual(result.project.productionDirections, project.productionDirections)
|
||||
XCTAssertEqual(result.changedScopes, [SongProjectUpdateScope.structure])
|
||||
}
|
||||
}
|
||||
|
||||
private actor RecordingAutoDecisionsAIService: AIService {
|
||||
private(set) var recordedRequests: [SongProjectUpdateRequest] = []
|
||||
private let result: SongProjectUpdateResult
|
||||
|
||||
init(result: SongProjectUpdateResult) {
|
||||
self.result = result
|
||||
}
|
||||
|
||||
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
|
||||
SongProjectGenerationResult(project: request.seedProject ?? SongProject(title: "Unused", idea: "Unused"))
|
||||
}
|
||||
|
||||
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||
LyricsRevisionResult(lyrics: request.sourceLyrics)
|
||||
}
|
||||
|
||||
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
|
||||
recordedRequests.append(request)
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user