diff --git a/Sources/MusicAssistantCore/Services/AI/ExistingLyricsRevisionDirector.swift b/Sources/MusicAssistantCore/Services/AI/ExistingLyricsRevisionDirector.swift new file mode 100644 index 0000000..99878e1 --- /dev/null +++ b/Sources/MusicAssistantCore/Services/AI/ExistingLyricsRevisionDirector.swift @@ -0,0 +1,61 @@ +import Foundation + +public final class ExistingLyricsRevisionDirector: Sendable { + private let aiService: any AIService + + public init(aiService: any AIService) { + self.aiService = aiService + } + + public func reviseExistingLyrics( + in project: SongProject, + sourceLyrics: Lyrics? = nil, + instruction: String, + mode: LyricsRevisionMode, + conversation: [AIConversationMessage] = [], + localeIdentifier: String? = nil, + revisedAt: Date = Date() + ) async throws -> SongProjectUpdateResult { + guard mode != .writeFromIdea else { + throw ExistingLyricsRevisionDirectorError.unsupportedMode(mode) + } + + let trimmedInstruction = instruction.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedInstruction.isEmpty else { + throw ExistingLyricsRevisionDirectorError.emptyInstruction + } + + let lyricsToRevise = sourceLyrics ?? project.lyrics + guard !lyricsToRevise.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ExistingLyricsRevisionDirectorError.emptySourceLyrics + } + + let request = LyricsRevisionRequest( + context: AIRequestContext( + userInstruction: trimmedInstruction, + conversation: conversation, + localeIdentifier: localeIdentifier + ), + project: project, + sourceLyrics: lyricsToRevise, + mode: mode + ) + let result = try await aiService.reviseLyrics(from: request) + + var updatedProject = project + updatedProject.lyrics = result.lyrics + updatedProject.updatedAt = revisedAt + + return SongProjectUpdateResult( + project: updatedProject, + changedScopes: [.lyrics], + notes: result.notes + ) + } +} + +public enum ExistingLyricsRevisionDirectorError: Error, Equatable, Sendable { + case emptyInstruction + case emptySourceLyrics + case unsupportedMode(LyricsRevisionMode) +} diff --git a/Tests/MusicAssistantCoreTests/ExistingLyricsRevisionDirectorTests.swift b/Tests/MusicAssistantCoreTests/ExistingLyricsRevisionDirectorTests.swift new file mode 100644 index 0000000..2615f85 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/ExistingLyricsRevisionDirectorTests.swift @@ -0,0 +1,162 @@ +import MusicAssistantCore +import XCTest + +final class ExistingLyricsRevisionDirectorTests: XCTestCase { + func testReviseExistingLyricsBuildsRevisionRequestAndUpdatesProjectLyrics() async throws { + let revisedAt = Date(timeIntervalSince1970: 500) + let project = SongProject( + id: "lyric-project", + title: "Lyric Project", + idea: "Make the hook stronger", + lyrics: Lyrics(text: "Original lines"), + createdAt: Date(timeIntervalSince1970: 100), + updatedAt: Date(timeIntervalSince1970: 200) + ) + let aiService = RecordingLyricsAIService( + revisionResult: LyricsRevisionResult( + lyrics: Lyrics(text: "Revised lines"), + notes: ["Improved hook phrasing."] + ) + ) + let director = ExistingLyricsRevisionDirector(aiService: aiService) + let conversation = [ + AIConversationMessage(role: .user, content: "Keep it direct.") + ] + + let result = try await director.reviseExistingLyrics( + in: project, + instruction: " Improve the chorus. ", + mode: .improve, + conversation: conversation, + localeIdentifier: "en_US", + revisedAt: revisedAt + ) + let recordedRequest = await aiService.recordedRevisionRequests.first + + XCTAssertEqual(recordedRequest?.context.userInstruction, "Improve the chorus.") + XCTAssertEqual(recordedRequest?.context.conversation, conversation) + XCTAssertEqual(recordedRequest?.context.localeIdentifier, "en_US") + XCTAssertEqual(recordedRequest?.project, project) + XCTAssertEqual(recordedRequest?.sourceLyrics, Lyrics(text: "Original lines")) + XCTAssertEqual(recordedRequest?.mode, .improve) + XCTAssertEqual(result.project.id, project.id) + XCTAssertEqual(result.project.createdAt, project.createdAt) + XCTAssertEqual(result.project.lyrics, Lyrics(text: "Revised lines")) + XCTAssertEqual(result.project.updatedAt, revisedAt) + XCTAssertEqual(result.changedScopes, [.lyrics]) + XCTAssertEqual(result.notes, ["Improved hook phrasing."]) + } + + func testReviseExistingLyricsCanUseExplicitSourceLyricsWithoutMutatingOtherProjectFields() async throws { + let project = SongProject( + title: "Existing Project", + idea: "Complete second verse", + genres: [GenreStyle(id: "pop", name: "Pop")], + lyrics: Lyrics(text: "Stored project lyrics") + ) + let sourceLyrics = Lyrics(text: "External draft lyrics") + let aiService = RecordingLyricsAIService( + revisionResult: LyricsRevisionResult( + lyrics: Lyrics(text: "Completed external draft") + ) + ) + let director = ExistingLyricsRevisionDirector(aiService: aiService) + + let result = try await director.reviseExistingLyrics( + in: project, + sourceLyrics: sourceLyrics, + instruction: "Complete this draft.", + mode: .complete, + revisedAt: Date(timeIntervalSince1970: 900) + ) + let recordedRequest = await aiService.recordedRevisionRequests.first + + XCTAssertEqual(recordedRequest?.sourceLyrics, sourceLyrics) + XCTAssertEqual(result.project.title, project.title) + XCTAssertEqual(result.project.idea, project.idea) + XCTAssertEqual(result.project.genres, project.genres) + XCTAssertEqual(result.project.lyrics.text, "Completed external draft") + } + + func testReviseExistingLyricsRejectsBlankSourceBeforeCallingAI() async { + let aiService = RecordingLyricsAIService( + revisionResult: LyricsRevisionResult(lyrics: Lyrics(text: "Unused")) + ) + let director = ExistingLyricsRevisionDirector(aiService: aiService) + + do { + _ = try await director.reviseExistingLyrics( + in: SongProject(title: "Blank", idea: "Blank", lyrics: Lyrics(text: " \n ")), + instruction: "Improve.", + mode: .improve + ) + XCTFail("Expected blank source lyrics to throw.") + } catch let error as ExistingLyricsRevisionDirectorError { + XCTAssertEqual(error, .emptySourceLyrics) + } catch { + XCTFail("Expected ExistingLyricsRevisionDirectorError.") + } + + let requestCount = await aiService.recordedRevisionRequests.count + XCTAssertEqual(requestCount, 0) + } + + func testReviseExistingLyricsRejectsBlankInstructionAndWriteFromIdeaMode() async { + let aiService = RecordingLyricsAIService( + revisionResult: LyricsRevisionResult(lyrics: Lyrics(text: "Unused")) + ) + let director = ExistingLyricsRevisionDirector(aiService: aiService) + let project = SongProject(title: "Song", idea: "Idea", lyrics: Lyrics(text: "Draft")) + + do { + _ = try await director.reviseExistingLyrics( + in: project, + instruction: " ", + mode: .correct + ) + XCTFail("Expected blank instruction to throw.") + } catch let error as ExistingLyricsRevisionDirectorError { + XCTAssertEqual(error, .emptyInstruction) + } catch { + XCTFail("Expected ExistingLyricsRevisionDirectorError.") + } + + do { + _ = try await director.reviseExistingLyrics( + in: project, + instruction: "Write from idea.", + mode: .writeFromIdea + ) + XCTFail("Expected unsupported mode to throw.") + } catch let error as ExistingLyricsRevisionDirectorError { + XCTAssertEqual(error, .unsupportedMode(.writeFromIdea)) + } catch { + XCTFail("Expected ExistingLyricsRevisionDirectorError.") + } + + let requestCount = await aiService.recordedRevisionRequests.count + XCTAssertEqual(requestCount, 0) + } +} + +private actor RecordingLyricsAIService: AIService { + private(set) var recordedRevisionRequests: [LyricsRevisionRequest] = [] + private let revisionResult: LyricsRevisionResult + + init(revisionResult: LyricsRevisionResult) { + self.revisionResult = revisionResult + } + + 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 { + recordedRevisionRequests.append(request) + return revisionResult + } + + func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult { + SongProjectUpdateResult(project: request.project) + } +} diff --git a/docs/TASKS.md b/docs/TASKS.md index 897a206..7417ea2 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -58,7 +58,7 @@ requirement is missing and blocks implementation, record it in - [x] Define private application-rule injection mechanism. - [x] Define structured AI response schema for SongProject updates. - [x] Implement idea → complete project generation. -- [ ] Implement existing lyrics → correction/improvement flow. +- [x] Implement existing lyrics → correction/improvement flow. - [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam and production decisions. - [ ] Implement optional Discuss mode.