Implement idea to song project generation

This commit is contained in:
diyaa
2026-09-13 20:48:11 +02:00
parent bbf5f191f2
commit 5e1d3d7246
9 changed files with 229 additions and 113 deletions
@@ -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
}
}
@@ -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
)
}
}
@@ -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
@@ -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
}
}
@@ -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 {
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
}
}
@@ -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"])
}
<<<<<<< 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")
+1 -25
View File
@@ -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.