Compare commits
2
Commits
bbf5f191f2
...
1bf84ca049
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bf84ca049 | ||
|
|
5e1d3d7246 |
@@ -1,50 +1,8 @@
|
|||||||
<<<<<<< HEAD
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ProjectBrowserView()
|
ProjectBrowserView()
|
||||||
=======
|
|
||||||
import MusicAssistantCore
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct RootView: View {
|
|
||||||
private let appName = "AI Music Studio"
|
|
||||||
private let boundarySummary = ProductBoundarySummary.initial
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationSplitView {
|
|
||||||
List {
|
|
||||||
Label("Projects", systemImage: "music.note.list")
|
|
||||||
Label("Settings", systemImage: "gearshape")
|
|
||||||
}
|
|
||||||
.navigationTitle(appName)
|
|
||||||
} detail: {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
Text("New Song Project")
|
|
||||||
.font(.largeTitle.weight(.semibold))
|
|
||||||
|
|
||||||
Text(boundarySummary.description)
|
|
||||||
.font(.body)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.frame(maxWidth: 560, alignment: .leading)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Foundation status")
|
|
||||||
.font(.headline)
|
|
||||||
|
|
||||||
Label("Presentation, Domain, Services, Integrations, and Persistence boundaries are separated.", systemImage: "checkmark.circle")
|
|
||||||
Label("External providers are represented by protocols only.", systemImage: "lock.shield")
|
|
||||||
Label("Secrets are expected from local environment configuration.", systemImage: "key")
|
|
||||||
}
|
|
||||||
.labelStyle(.titleAndIcon)
|
|
||||||
}
|
|
||||||
.padding(32)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
||||||
}
|
|
||||||
>>>>>>> origin/master
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Foundation
|
|||||||
|
|
||||||
public struct AppConfiguration: Equatable, Sendable {
|
public struct AppConfiguration: Equatable, Sendable {
|
||||||
public let openAIAPIKey: String?
|
public let openAIAPIKey: String?
|
||||||
<<<<<<< HEAD
|
|
||||||
public let privateApplicationRules: PrivateApplicationRules?
|
public let privateApplicationRules: PrivateApplicationRules?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -11,21 +10,12 @@ public struct AppConfiguration: Equatable, Sendable {
|
|||||||
) {
|
) {
|
||||||
self.openAIAPIKey = openAIAPIKey
|
self.openAIAPIKey = openAIAPIKey
|
||||||
self.privateApplicationRules = privateApplicationRules
|
self.privateApplicationRules = privateApplicationRules
|
||||||
=======
|
|
||||||
|
|
||||||
public init(openAIAPIKey: String?) {
|
|
||||||
self.openAIAPIKey = openAIAPIKey
|
|
||||||
>>>>>>> origin/master
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func development(environment: [String: String] = ProcessInfo.processInfo.environment) -> AppConfiguration {
|
public static func development(environment: [String: String] = ProcessInfo.processInfo.environment) -> AppConfiguration {
|
||||||
AppConfiguration(
|
AppConfiguration(
|
||||||
<<<<<<< HEAD
|
|
||||||
openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"],
|
openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"],
|
||||||
privateApplicationRules: PrivateApplicationRules(content: environment["MUSIC_ASSISTANT_PRIVATE_AI_RULES"])
|
privateApplicationRules: PrivateApplicationRules(content: environment["MUSIC_ASSISTANT_PRIVATE_AI_RULES"])
|
||||||
=======
|
|
||||||
openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"]
|
|
||||||
>>>>>>> origin/master
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,7 +343,6 @@ public extension SongProject {
|
|||||||
versions.first { $0.id == versionID }
|
versions.first { $0.id == versionID }
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
mutating func restoreVersion(id versionID: String, restoredAt: Date = Date()) -> Bool {
|
mutating func restoreVersion(id versionID: String, restoredAt: Date = Date()) -> Bool {
|
||||||
guard let version = version(id: versionID) else { return false }
|
guard let version = version(id: versionID) else { return false }
|
||||||
restoreSnapshot(version.snapshot, updatedAt: restoredAt)
|
restoreSnapshot(version.snapshot, updatedAt: restoredAt)
|
||||||
@@ -374,8 +373,6 @@ public extension SongProject {
|
|||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> origin/master
|
|
||||||
mutating func appendSection(_ section: SongSection) {
|
mutating func appendSection(_ section: SongSection) {
|
||||||
var orderedSection = section
|
var orderedSection = section
|
||||||
orderedSection.order = nextSectionOrder
|
orderedSection.order = nextSectionOrder
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
<<<<<<< HEAD
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public protocol AIService: Sendable {
|
public protocol AIService: Sendable {
|
||||||
@@ -167,24 +166,5 @@ public struct SongProjectUpdateResult: Equatable, Sendable {
|
|||||||
self.changedScopes = changedScopes
|
self.changedScopes = changedScopes
|
||||||
self.notes = notes
|
self.notes = notes
|
||||||
self.followUpQuestions = followUpQuestions
|
self.followUpQuestions = followUpQuestions
|
||||||
=======
|
|
||||||
public protocol AIService: Sendable {
|
|
||||||
func planSongProject(from request: SongPlanningRequest) async throws -> SongPlanningDraft
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SongPlanningRequest: Equatable, Sendable {
|
|
||||||
public let userIntent: String
|
|
||||||
|
|
||||||
public init(userIntent: String) {
|
|
||||||
self.userIntent = userIntent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct SongPlanningDraft: Equatable, Sendable {
|
|
||||||
public let summary: String
|
|
||||||
|
|
||||||
public init(summary: String) {
|
|
||||||
self.summary = summary
|
|
||||||
>>>>>>> origin/master
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public final class SongProjectGenerationDirector: Sendable {
|
||||||
|
private let aiService: any AIService
|
||||||
|
|
||||||
|
public init(aiService: any AIService) {
|
||||||
|
self.aiService = aiService
|
||||||
|
}
|
||||||
|
|
||||||
|
public func generateCompleteProject(
|
||||||
|
fromIdea idea: String,
|
||||||
|
seedProject: SongProject? = nil,
|
||||||
|
discussionMode: ConversationMode = .auto,
|
||||||
|
conversation: [AIConversationMessage] = [],
|
||||||
|
localeIdentifier: String? = nil,
|
||||||
|
generatedAt: Date = Date()
|
||||||
|
) async throws -> SongProjectGenerationResult {
|
||||||
|
let trimmedIdea = idea.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedIdea.isEmpty else {
|
||||||
|
throw SongProjectGenerationDirectorError.emptyIdea
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = SongProjectGenerationRequest(
|
||||||
|
context: AIRequestContext(
|
||||||
|
userInstruction: trimmedIdea,
|
||||||
|
conversation: conversation,
|
||||||
|
localeIdentifier: localeIdentifier
|
||||||
|
),
|
||||||
|
seedProject: seedProject,
|
||||||
|
discussionMode: discussionMode
|
||||||
|
)
|
||||||
|
let result = try await aiService.generateSongProject(from: request)
|
||||||
|
let project = normalizeGeneratedProject(
|
||||||
|
result.project,
|
||||||
|
idea: trimmedIdea,
|
||||||
|
seedProject: seedProject,
|
||||||
|
discussionMode: discussionMode,
|
||||||
|
generatedAt: generatedAt
|
||||||
|
)
|
||||||
|
|
||||||
|
return SongProjectGenerationResult(
|
||||||
|
project: project,
|
||||||
|
notes: result.notes,
|
||||||
|
followUpQuestions: result.followUpQuestions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizeGeneratedProject(
|
||||||
|
_ generatedProject: SongProject,
|
||||||
|
idea: String,
|
||||||
|
seedProject: SongProject?,
|
||||||
|
discussionMode: ConversationMode,
|
||||||
|
generatedAt: Date
|
||||||
|
) -> SongProject {
|
||||||
|
let normalizedIdea = generatedProject.idea.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
? idea
|
||||||
|
: generatedProject.idea
|
||||||
|
|
||||||
|
guard let seedProject else {
|
||||||
|
var project = generatedProject
|
||||||
|
project.idea = normalizedIdea
|
||||||
|
project.conversationMode = discussionMode
|
||||||
|
project.updatedAt = generatedAt
|
||||||
|
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,
|
||||||
|
updatedAt: generatedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SongProjectGenerationDirectorError: Error, Equatable, Sendable {
|
||||||
|
case emptyIdea
|
||||||
|
}
|
||||||
@@ -4,28 +4,18 @@ import XCTest
|
|||||||
final class AppConfigurationTests: XCTestCase {
|
final class AppConfigurationTests: XCTestCase {
|
||||||
func testDevelopmentConfigurationReadsKnownEnvironmentKeys() {
|
func testDevelopmentConfigurationReadsKnownEnvironmentKeys() {
|
||||||
let configuration = AppConfiguration.development(environment: [
|
let configuration = AppConfiguration.development(environment: [
|
||||||
<<<<<<< HEAD
|
|
||||||
"MUSIC_ASSISTANT_OPENAI_API_KEY": "openai-test-key",
|
"MUSIC_ASSISTANT_OPENAI_API_KEY": "openai-test-key",
|
||||||
"MUSIC_ASSISTANT_PRIVATE_AI_RULES": " private product rules "
|
"MUSIC_ASSISTANT_PRIVATE_AI_RULES": " private product rules "
|
||||||
])
|
])
|
||||||
|
|
||||||
XCTAssertEqual(configuration.openAIAPIKey, "openai-test-key")
|
XCTAssertEqual(configuration.openAIAPIKey, "openai-test-key")
|
||||||
XCTAssertEqual(configuration.privateApplicationRules?.content, "private product rules")
|
XCTAssertEqual(configuration.privateApplicationRules?.content, "private product rules")
|
||||||
=======
|
|
||||||
"MUSIC_ASSISTANT_OPENAI_API_KEY": "openai-test-key"
|
|
||||||
])
|
|
||||||
|
|
||||||
XCTAssertEqual(configuration.openAIAPIKey, "openai-test-key")
|
|
||||||
>>>>>>> origin/master
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDevelopmentConfigurationAllowsMissingSecrets() {
|
func testDevelopmentConfigurationAllowsMissingSecrets() {
|
||||||
let configuration = AppConfiguration.development(environment: [:])
|
let configuration = AppConfiguration.development(environment: [:])
|
||||||
|
|
||||||
XCTAssertNil(configuration.openAIAPIKey)
|
XCTAssertNil(configuration.openAIAPIKey)
|
||||||
<<<<<<< HEAD
|
|
||||||
XCTAssertNil(configuration.privateApplicationRules)
|
XCTAssertNil(configuration.privateApplicationRules)
|
||||||
=======
|
|
||||||
>>>>>>> origin/master
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import MusicAssistantCore
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class SongProjectGenerationDirectorTests: XCTestCase {
|
||||||
|
func testGenerateCompleteProjectBuildsGenerationRequestFromTrimmedIdea() async throws {
|
||||||
|
let aiService = RecordingGenerationAIService(
|
||||||
|
generationResult: SongProjectGenerationResult(
|
||||||
|
project: SongProject(
|
||||||
|
title: "Generated Song",
|
||||||
|
idea: "Hopeful cinematic pop",
|
||||||
|
genres: [GenreStyle(id: "cinematic-pop", name: "Cinematic Pop")]
|
||||||
|
),
|
||||||
|
notes: ["Completed from idea."]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let director = SongProjectGenerationDirector(aiService: aiService)
|
||||||
|
let conversation = [
|
||||||
|
AIConversationMessage(role: .user, content: "Use piano.")
|
||||||
|
]
|
||||||
|
|
||||||
|
let result = try await director.generateCompleteProject(
|
||||||
|
fromIdea: " Hopeful cinematic pop ",
|
||||||
|
discussionMode: .discuss,
|
||||||
|
conversation: conversation,
|
||||||
|
localeIdentifier: "en_US",
|
||||||
|
generatedAt: Date(timeIntervalSince1970: 200)
|
||||||
|
)
|
||||||
|
let recordedRequest = await aiService.recordedGenerationRequests.first
|
||||||
|
|
||||||
|
XCTAssertEqual(recordedRequest?.context.userInstruction, "Hopeful cinematic pop")
|
||||||
|
XCTAssertEqual(recordedRequest?.context.conversation, conversation)
|
||||||
|
XCTAssertEqual(recordedRequest?.context.localeIdentifier, "en_US")
|
||||||
|
XCTAssertEqual(recordedRequest?.discussionMode, .discuss)
|
||||||
|
XCTAssertNil(recordedRequest?.seedProject)
|
||||||
|
XCTAssertEqual(result.project.conversationMode, .discuss)
|
||||||
|
XCTAssertEqual(result.project.updatedAt, Date(timeIntervalSince1970: 200))
|
||||||
|
XCTAssertEqual(result.notes, ["Completed from idea."])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGenerateCompleteProjectPreservesSeedProjectIdentityAndVersions() async throws {
|
||||||
|
let createdAt = Date(timeIntervalSince1970: 10)
|
||||||
|
let generatedAt = Date(timeIntervalSince1970: 300)
|
||||||
|
var seedProject = SongProject(
|
||||||
|
id: "seed-project",
|
||||||
|
title: "Draft",
|
||||||
|
idea: "Original idea",
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
)
|
||||||
|
let version = seedProject.createVersion(
|
||||||
|
id: "seed-version",
|
||||||
|
label: "Before generation",
|
||||||
|
createdAt: createdAt
|
||||||
|
)
|
||||||
|
let aiService = RecordingGenerationAIService(
|
||||||
|
generationResult: SongProjectGenerationResult(
|
||||||
|
project: SongProject(
|
||||||
|
id: "generated-id",
|
||||||
|
title: "Generated Complete Project",
|
||||||
|
idea: "",
|
||||||
|
sections: [
|
||||||
|
SongSection(id: "verse", type: .verse, title: "Verse", order: 0)
|
||||||
|
],
|
||||||
|
lyrics: Lyrics(text: "Generated lyrics")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let director = SongProjectGenerationDirector(aiService: aiService)
|
||||||
|
|
||||||
|
let result = try await director.generateCompleteProject(
|
||||||
|
fromIdea: "Build from this idea",
|
||||||
|
seedProject: seedProject,
|
||||||
|
generatedAt: generatedAt
|
||||||
|
)
|
||||||
|
let recordedRequest = await aiService.recordedGenerationRequests.first
|
||||||
|
|
||||||
|
XCTAssertEqual(recordedRequest?.seedProject, seedProject)
|
||||||
|
XCTAssertEqual(result.project.id, "seed-project")
|
||||||
|
XCTAssertEqual(result.project.createdAt, createdAt)
|
||||||
|
XCTAssertEqual(result.project.updatedAt, generatedAt)
|
||||||
|
XCTAssertEqual(result.project.versions, [version])
|
||||||
|
XCTAssertEqual(result.project.title, "Generated Complete Project")
|
||||||
|
XCTAssertEqual(result.project.idea, "Build from this idea")
|
||||||
|
XCTAssertEqual(result.project.sections.first?.id, "verse")
|
||||||
|
XCTAssertEqual(result.project.lyrics.text, "Generated lyrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGenerateCompleteProjectRejectsBlankIdeaBeforeCallingAI() async {
|
||||||
|
let aiService = RecordingGenerationAIService(
|
||||||
|
generationResult: SongProjectGenerationResult(
|
||||||
|
project: SongProject(title: "Unused", idea: "Unused")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let director = SongProjectGenerationDirector(aiService: aiService)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await director.generateCompleteProject(fromIdea: " \n ")
|
||||||
|
XCTFail("Expected blank idea to throw.")
|
||||||
|
} catch let error as SongProjectGenerationDirectorError {
|
||||||
|
XCTAssertEqual(error, .emptyIdea)
|
||||||
|
} catch {
|
||||||
|
XCTFail("Expected SongProjectGenerationDirectorError.")
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestCount = await aiService.recordedGenerationRequests.count
|
||||||
|
XCTAssertEqual(requestCount, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor RecordingGenerationAIService: AIService {
|
||||||
|
private(set) var recordedGenerationRequests: [SongProjectGenerationRequest] = []
|
||||||
|
private let generationResult: SongProjectGenerationResult
|
||||||
|
|
||||||
|
init(generationResult: SongProjectGenerationResult) {
|
||||||
|
self.generationResult = generationResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
|
||||||
|
recordedGenerationRequests.append(request)
|
||||||
|
return generationResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||||
|
LyricsRevisionResult(lyrics: request.sourceLyrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
|
||||||
|
SongProjectUpdateResult(project: request.project)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,7 +119,6 @@ final class SongProjectModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(project.genres.map(\.id), ["folk", "electronic"])
|
XCTAssertEqual(project.genres.map(\.id), ["folk", "electronic"])
|
||||||
}
|
}
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
func testRestoresPreviousVersionWithoutDestroyingLaterVersions() {
|
func testRestoresPreviousVersionWithoutDestroyingLaterVersions() {
|
||||||
let createdAt = Date(timeIntervalSince1970: 100)
|
let createdAt = Date(timeIntervalSince1970: 100)
|
||||||
let restoredAt = Date(timeIntervalSince1970: 900)
|
let restoredAt = Date(timeIntervalSince1970: 900)
|
||||||
@@ -178,8 +177,6 @@ final class SongProjectModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(project, restoredProject)
|
XCTAssertEqual(project, restoredProject)
|
||||||
}
|
}
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> origin/master
|
|
||||||
func testGenreStylesAreModeledAsMultiSelect() {
|
func testGenreStylesAreModeledAsMultiSelect() {
|
||||||
let arabicPop = GenreStyle(id: "arabic-pop", name: "Arabic Pop")
|
let arabicPop = GenreStyle(id: "arabic-pop", name: "Arabic Pop")
|
||||||
let synthwave = GenreStyle(id: "synthwave", name: "Synthwave")
|
let synthwave = GenreStyle(id: "synthwave", name: "Synthwave")
|
||||||
|
|||||||
+2
-26
@@ -35,7 +35,6 @@ requirement is missing and blocks implementation, record it in
|
|||||||
- [x] Create new/open/save song projects locally.
|
- [x] Create new/open/save song projects locally.
|
||||||
- [x] Implement project list.
|
- [x] Implement project list.
|
||||||
- [x] Implement immutable or snapshot-based version history.
|
- [x] Implement immutable or snapshot-based version history.
|
||||||
<<<<<<< HEAD
|
|
||||||
- [x] Restore a previous version without destroying later versions.
|
- [x] Restore a previous version without destroying later versions.
|
||||||
- [x] Add autosave strategy that does not create excessive versions.
|
- [x] Add autosave strategy that does not create excessive versions.
|
||||||
|
|
||||||
@@ -50,39 +49,16 @@ requirement is missing and blocks implementation, record it in
|
|||||||
timestamp-based placement.
|
timestamp-based placement.
|
||||||
- [x] Add Manual/Auto toggles for supported controls.
|
- [x] Add Manual/Auto toggles for supported controls.
|
||||||
- [x] Build final review screen where every generated field can be
|
- [x] Build final review screen where every generated field can be
|
||||||
=======
|
|
||||||
- [ ] Restore a previous version without destroying later versions.
|
|
||||||
- [ ] Add autosave strategy that does not create excessive versions.
|
|
||||||
|
|
||||||
## Phase 3 --- Core macOS UI
|
|
||||||
|
|
||||||
- [ ] Build project browser.
|
|
||||||
- [ ] Build natural chat workspace as the primary entry point.
|
|
||||||
- [ ] Build editable project inspector for duration, genres,
|
|
||||||
instruments, vocals and language.
|
|
||||||
- [ ] Build song structure editor.
|
|
||||||
- [ ] Build arrangement editor supporting section-based and
|
|
||||||
timestamp-based placement.
|
|
||||||
- [ ] Add Manual/Auto toggles for supported controls.
|
|
||||||
- [ ] Build final review screen where every generated field can be
|
|
||||||
>>>>>>> origin/master
|
|
||||||
edited.
|
edited.
|
||||||
|
|
||||||
## Phase 4 --- OpenAI Integration
|
## Phase 4 --- OpenAI Integration
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
- [x] Create provider-independent `AIService` interface.
|
- [x] Create provider-independent `AIService` interface.
|
||||||
- [x] Implement OpenAI client.
|
- [x] Implement OpenAI client.
|
||||||
- [x] Define private application-rule injection mechanism.
|
- [x] Define private application-rule injection mechanism.
|
||||||
- [x] Define structured AI response schema for SongProject updates.
|
- [x] Define structured AI response schema for SongProject updates.
|
||||||
=======
|
- [x] Implement idea → complete project generation.
|
||||||
- [ ] Create provider-independent `AIService` interface.
|
- [x] Implement existing lyrics → correction/improvement flow.
|
||||||
- [ ] Implement OpenAI client.
|
|
||||||
- [ ] Define private application-rule injection mechanism.
|
|
||||||
- [ ] Define structured AI response schema for SongProject updates.
|
|
||||||
>>>>>>> origin/master
|
|
||||||
- [ ] Implement idea → complete project generation.
|
|
||||||
- [ ] Implement existing lyrics → correction/improvement flow.
|
|
||||||
- [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam
|
- [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam
|
||||||
and production decisions.
|
and production decisions.
|
||||||
- [ ] Implement optional Discuss mode.
|
- [ ] Implement optional Discuss mode.
|
||||||
|
|||||||
Reference in New Issue
Block a user