diff --git a/Sources/MusicAssistantApp/Presentation/RootView.swift b/Sources/MusicAssistantApp/Presentation/RootView.swift index 0aa6e56..3f1ddf5 100644 --- a/Sources/MusicAssistantApp/Presentation/RootView.swift +++ b/Sources/MusicAssistantApp/Presentation/RootView.swift @@ -1,50 +1,8 @@ -<<<<<<< HEAD import SwiftUI struct RootView: View { var body: some View { 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 } } diff --git a/Sources/MusicAssistantCore/Configuration/AppConfiguration.swift b/Sources/MusicAssistantCore/Configuration/AppConfiguration.swift index 382af01..7389899 100644 --- a/Sources/MusicAssistantCore/Configuration/AppConfiguration.swift +++ b/Sources/MusicAssistantCore/Configuration/AppConfiguration.swift @@ -2,7 +2,6 @@ import Foundation public struct AppConfiguration: Equatable, Sendable { public let openAIAPIKey: String? -<<<<<<< HEAD public let privateApplicationRules: PrivateApplicationRules? public init( @@ -11,21 +10,12 @@ public struct AppConfiguration: Equatable, Sendable { ) { self.openAIAPIKey = openAIAPIKey self.privateApplicationRules = privateApplicationRules -======= - - public init(openAIAPIKey: String?) { - self.openAIAPIKey = openAIAPIKey ->>>>>>> origin/master } public static func development(environment: [String: String] = ProcessInfo.processInfo.environment) -> AppConfiguration { AppConfiguration( -<<<<<<< HEAD openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"], privateApplicationRules: PrivateApplicationRules(content: environment["MUSIC_ASSISTANT_PRIVATE_AI_RULES"]) -======= - openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"] ->>>>>>> origin/master ) } } diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift index 316766a..169ed81 100644 --- a/Sources/MusicAssistantCore/Domain/SongProject.swift +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -343,7 +343,6 @@ public extension SongProject { versions.first { $0.id == versionID } } -<<<<<<< HEAD mutating func restoreVersion(id versionID: String, restoredAt: Date = Date()) -> Bool { guard let version = version(id: versionID) else { return false } restoreSnapshot(version.snapshot, updatedAt: restoredAt) @@ -374,8 +373,6 @@ public extension SongProject { self.updatedAt = updatedAt } -======= ->>>>>>> origin/master mutating func appendSection(_ section: SongSection) { var orderedSection = section orderedSection.order = nextSectionOrder diff --git a/Sources/MusicAssistantCore/Services/AI/AIService.swift b/Sources/MusicAssistantCore/Services/AI/AIService.swift index 564d58c..c57af24 100644 --- a/Sources/MusicAssistantCore/Services/AI/AIService.swift +++ b/Sources/MusicAssistantCore/Services/AI/AIService.swift @@ -1,4 +1,3 @@ -<<<<<<< HEAD import Foundation public protocol AIService: Sendable { @@ -167,24 +166,5 @@ public struct SongProjectUpdateResult: Equatable, Sendable { self.changedScopes = changedScopes self.notes = notes 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 } } diff --git a/Sources/MusicAssistantCore/Services/AI/SongProjectGenerationDirector.swift b/Sources/MusicAssistantCore/Services/AI/SongProjectGenerationDirector.swift new file mode 100644 index 0000000..dae50da --- /dev/null +++ b/Sources/MusicAssistantCore/Services/AI/SongProjectGenerationDirector.swift @@ -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 +} diff --git a/Tests/MusicAssistantCoreTests/AppConfigurationTests.swift b/Tests/MusicAssistantCoreTests/AppConfigurationTests.swift index 480316c..77b8025 100644 --- a/Tests/MusicAssistantCoreTests/AppConfigurationTests.swift +++ b/Tests/MusicAssistantCoreTests/AppConfigurationTests.swift @@ -4,28 +4,18 @@ import XCTest final class AppConfigurationTests: XCTestCase { func testDevelopmentConfigurationReadsKnownEnvironmentKeys() { let configuration = AppConfiguration.development(environment: [ -<<<<<<< HEAD "MUSIC_ASSISTANT_OPENAI_API_KEY": "openai-test-key", "MUSIC_ASSISTANT_PRIVATE_AI_RULES": " private product rules " ]) XCTAssertEqual(configuration.openAIAPIKey, "openai-test-key") 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() { let configuration = AppConfiguration.development(environment: [:]) XCTAssertNil(configuration.openAIAPIKey) -<<<<<<< HEAD XCTAssertNil(configuration.privateApplicationRules) -======= ->>>>>>> origin/master } } diff --git a/Tests/MusicAssistantCoreTests/SongProjectGenerationDirectorTests.swift b/Tests/MusicAssistantCoreTests/SongProjectGenerationDirectorTests.swift new file mode 100644 index 0000000..65b12e2 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/SongProjectGenerationDirectorTests.swift @@ -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) + } +} diff --git a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift index 993daa5..fe9d478 100644 --- a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift +++ b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift @@ -119,7 +119,6 @@ final class SongProjectModelTests: XCTestCase { XCTAssertEqual(project.genres.map(\.id), ["folk", "electronic"]) } -<<<<<<< HEAD func testRestoresPreviousVersionWithoutDestroyingLaterVersions() { let createdAt = Date(timeIntervalSince1970: 100) let restoredAt = Date(timeIntervalSince1970: 900) @@ -178,8 +177,6 @@ final class SongProjectModelTests: XCTestCase { XCTAssertEqual(project, restoredProject) } -======= ->>>>>>> origin/master func testGenreStylesAreModeledAsMultiSelect() { let arabicPop = GenreStyle(id: "arabic-pop", name: "Arabic Pop") let synthwave = GenreStyle(id: "synthwave", name: "Synthwave") diff --git a/docs/TASKS.md b/docs/TASKS.md index e641061..897a206 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -35,7 +35,6 @@ requirement is missing and blocks implementation, record it in - [x] Create new/open/save song projects locally. - [x] Implement project list. - [x] Implement immutable or snapshot-based version history. -<<<<<<< HEAD - [x] Restore a previous version without destroying later versions. - [x] Add autosave strategy that does not create excessive versions. @@ -50,38 +49,15 @@ requirement is missing and blocks implementation, record it in timestamp-based placement. - [x] Add Manual/Auto toggles for supported controls. - [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. ## Phase 4 --- OpenAI Integration -<<<<<<< HEAD - [x] Create provider-independent `AIService` interface. - [x] Implement OpenAI client. - [x] Define private application-rule injection mechanism. - [x] Define structured AI response schema for SongProject updates. -======= -- [ ] Create provider-independent `AIService` interface. -- [ ] Implement OpenAI client. -- [ ] Define private application-rule injection mechanism. -- [ ] Define structured AI response schema for SongProject updates. ->>>>>>> origin/master -- [ ] Implement idea → complete project generation. +- [x] Implement idea → complete project generation. - [ ] Implement existing lyrics → correction/improvement flow. - [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam and production decisions.