Persist selections and compile song output

This commit is contained in:
diyaa
2026-09-14 21:56:26 +02:00
parent 35703f89a8
commit 7bf4df79f9
10 changed files with 490 additions and 11 deletions
@@ -156,9 +156,12 @@ struct FinalReviewView: View {
private func ensureSunoOutput() {
guard project.sunoOutput == nil else { return }
let compiledOutput = (try? SongProjectPromptCompiler().compile(project: project))
?? CompiledSunoOutput(lyricsText: project.lyrics.text, stylePrompt: "")
project.sunoOutput = SunoOutput(
lyricsText: project.lyrics.text,
stylePrompt: "",
lyricsText: compiledOutput.lyricsText,
stylePrompt: compiledOutput.stylePrompt,
generatedAt: Date()
)
}
@@ -3,18 +3,23 @@ import SwiftUI
struct InstrumentBrowserView: View {
@Binding private var project: SongProject
private let saveProject: (SongProject) async -> Bool
private let catalog: LocalInstrumentCatalog
@State private var searchText = ""
@State private var selectedFamilyCategory: String?
@State private var selectedRegionOrigin: String?
@State private var hasUnsavedSelectionChanges = false
@State private var isSaving = false
@Environment(\.dismiss) private var dismiss
init(
project: Binding<SongProject>,
saveProject: @escaping (SongProject) async -> Bool = { _ in true },
catalog: LocalInstrumentCatalog = LocalInstrumentCatalog()
) {
_project = project
self.saveProject = saveProject
self.catalog = catalog
}
@@ -81,12 +86,33 @@ struct InstrumentBrowserView: View {
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
dismiss()
Task {
await saveSelectionChangesIfNeeded()
dismiss()
}
}
.disabled(isSaving)
}
if isSaving {
ToolbarItem(placement: .status) {
ProgressView()
.controlSize(.small)
}
}
}
}
.frame(minWidth: 460, minHeight: 520)
.onDisappear {
guard hasUnsavedSelectionChanges, !isSaving else { return }
let projectToSave = project
hasUnsavedSelectionChanges = false
Task {
_ = await saveProject(projectToSave)
}
}
}
private func selectionBinding(for instrument: InstrumentCatalogItem) -> Binding<Bool> {
@@ -98,8 +124,18 @@ struct InstrumentBrowserView: View {
isSelected: isSelected,
variant: instrument.name
)
hasUnsavedSelectionChanges = true
}
}
private func saveSelectionChangesIfNeeded() async {
guard hasUnsavedSelectionChanges else { return }
isSaving = true
let didSave = await saveProject(project)
hasUnsavedSelectionChanges = !didSave
isSaving = false
}
}
#Preview {
@@ -146,7 +146,7 @@ struct ProjectInspectorView: View {
}
.background(Color(nsColor: .windowBackgroundColor))
.sheet(isPresented: $isInstrumentBrowserPresented) {
InstrumentBrowserView(project: $project)
InstrumentBrowserView(project: $project, saveProject: saveProject)
}
}
@@ -65,15 +65,20 @@ public struct SongProjectGenerationRequest: Equatable, Sendable {
public var context: AIRequestContext
public var seedProject: SongProject?
public var discussionMode: ConversationMode
public var songGenerationContext: SongGenerationContext
public init(
context: AIRequestContext,
seedProject: SongProject? = nil,
discussionMode: ConversationMode = .auto
discussionMode: ConversationMode = .auto,
songGenerationContext: SongGenerationContext? = nil
) {
self.context = context
self.seedProject = seedProject
self.discussionMode = discussionMode
self.songGenerationContext = songGenerationContext
?? seedProject.map(SongGenerationContext.init(project:))
?? .empty
}
}
@@ -96,10 +101,16 @@ public struct SongProjectGenerationResult: Equatable, Sendable {
public struct SongProjectDiscussionRequest: Equatable, Sendable {
public var context: AIRequestContext
public var project: SongProject
public var songGenerationContext: SongGenerationContext
public init(context: AIRequestContext, project: SongProject) {
public init(
context: AIRequestContext,
project: SongProject,
songGenerationContext: SongGenerationContext? = nil
) {
self.context = context
self.project = project
self.songGenerationContext = songGenerationContext ?? SongGenerationContext(project: project)
}
}
@@ -118,17 +129,20 @@ public struct LyricsRevisionRequest: Equatable, Sendable {
public var project: SongProject
public var sourceLyrics: Lyrics
public var mode: LyricsRevisionMode
public var songGenerationContext: SongGenerationContext
public init(
context: AIRequestContext,
project: SongProject,
sourceLyrics: Lyrics,
mode: LyricsRevisionMode
mode: LyricsRevisionMode,
songGenerationContext: SongGenerationContext? = nil
) {
self.context = context
self.project = project
self.sourceLyrics = sourceLyrics
self.mode = mode
self.songGenerationContext = songGenerationContext ?? SongGenerationContext(project: project)
}
}
@@ -154,15 +168,18 @@ public struct SongProjectUpdateRequest: Equatable, Sendable {
public var context: AIRequestContext
public var project: SongProject
public var allowedScopes: [SongProjectUpdateScope]
public var songGenerationContext: SongGenerationContext
public init(
context: AIRequestContext,
project: SongProject,
allowedScopes: [SongProjectUpdateScope]
allowedScopes: [SongProjectUpdateScope],
songGenerationContext: SongGenerationContext? = nil
) {
self.context = context
self.project = project
self.allowedScopes = allowedScopes
self.songGenerationContext = songGenerationContext ?? SongGenerationContext(project: project)
}
}
@@ -13,3 +13,83 @@ public struct CompiledSunoOutput: Equatable, Sendable {
self.stylePrompt = stylePrompt
}
}
public struct SongProjectPromptCompiler: PromptCompiling {
public init() {}
public func compile(project: SongProject) throws -> CompiledSunoOutput {
CompiledSunoOutput(
lyricsText: lyricsText(for: project),
stylePrompt: stylePrompt(for: project)
)
}
private func lyricsText(for project: SongProject) -> String {
if !project.lyrics.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return project.lyrics.text
}
return project.orderedSections
.compactMap { section in
let lyrics = project.lyrics.sectionTexts[section.id] ?? section.lyrics
let trimmedLyrics = lyrics.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedLyrics.isEmpty else { return nil }
return "[\(section.title)]\n\(trimmedLyrics)"
}
.joined(separator: "\n\n")
}
private func stylePrompt(for project: SongProject) -> String {
let context = SongGenerationContext(project: project)
var components: [String] = []
append(project.genres.map(\.name).joined(separator: " + "), to: &components)
append(project.moods.map(\.name).joined(separator: ", "), to: &components)
append(context.sunoStyleInstrumentPhrase, to: &components)
append(vocalPhrase(for: project), to: &components)
append(musicalParametersPhrase(for: project), to: &components)
append(project.productionDirections.map(\.text).joined(separator: ", "), to: &components)
return components.joined(separator: "; ")
}
private func vocalPhrase(for project: SongProject) -> String {
project.vocalists
.map { vocalist in
[
vocalist.label,
vocalist.voiceType,
vocalist.genderSelection,
vocalist.performanceStyle
]
.compactMap { normalized($0) }
.joined(separator: " ")
}
.filter { !$0.isEmpty }
.joined(separator: ", ")
}
private func musicalParametersPhrase(for project: SongProject) -> String {
var parameters: [String] = []
if let bpm = project.bpm?.value {
parameters.append("\(bpm) BPM")
}
append(project.key?.value.map { "key \($0)" }, to: &parameters)
append(project.scale?.value.map { "\($0) scale" }, to: &parameters)
append(project.maqam?.value.map { "maqam \($0)" }, to: &parameters)
return parameters.joined(separator: ", ")
}
private func append(_ value: String?, to components: inout [String]) {
guard let normalizedValue = normalized(value) else { return }
components.append(normalizedValue)
}
private func normalized(_ value: String?) -> String? {
let trimmedValue = value?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedValue?.isEmpty == false ? trimmedValue : nil
}
}
@@ -0,0 +1,171 @@
import Foundation
public struct SongGenerationContext: Codable, Equatable, Sendable {
public static let empty = SongGenerationContext(selectedInstruments: [])
public var selectedInstruments: [SelectedInstrumentContext]
public init(selectedInstruments: [SelectedInstrumentContext]) {
self.selectedInstruments = selectedInstruments
}
public init(project: SongProject) {
let sectionsByID = Dictionary(uniqueKeysWithValues: project.sections.map { ($0.id, $0) })
selectedInstruments = project.selectedInstrumentTracks.map { track in
SelectedInstrumentContext(
instrumentId: track.instrumentId,
displayName: Self.displayName(for: track),
playingStyle: Self.normalized(track.playingStyle),
role: Self.normalized(track.role),
autoArrangementEnabled: track.autoArrangementEnabled,
placements: track.placements.map { placement in
SelectedInstrumentPlacementContext(
sectionId: placement.sectionId,
sectionTitle: placement.sectionId.flatMap { sectionsByID[$0]?.title },
sectionType: placement.sectionId.flatMap { sectionsByID[$0]?.type },
startTime: placement.startTime,
endTime: placement.endTime,
direction: Self.normalized(placement.direction)
)
}
)
}
}
public var hasSelectedInstruments: Bool {
!selectedInstruments.isEmpty
}
public var sunoStyleInstrumentPhrase: String {
selectedInstruments
.map(\.stylePromptPhrase)
.joined(separator: ", ")
}
private static func displayName(for track: InstrumentTrack) -> String {
normalized(track.variant) ?? track.instrumentId
}
private static func normalized(_ value: String?) -> String? {
let trimmedValue = value?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedValue?.isEmpty == false ? trimmedValue : nil
}
}
public struct SelectedInstrumentContext: Codable, Equatable, Sendable {
public var instrumentId: String
public var displayName: String
public var playingStyle: String?
public var role: String?
public var autoArrangementEnabled: Bool
public var placements: [SelectedInstrumentPlacementContext]
public init(
instrumentId: String,
displayName: String,
playingStyle: String? = nil,
role: String? = nil,
autoArrangementEnabled: Bool = true,
placements: [SelectedInstrumentPlacementContext] = []
) {
self.instrumentId = instrumentId
self.displayName = displayName
self.playingStyle = playingStyle
self.role = role
self.autoArrangementEnabled = autoArrangementEnabled
self.placements = placements
}
public var stylePromptPhrase: String {
var details: [String] = []
if let role {
details.append(role)
}
if let playingStyle {
details.append(playingStyle)
}
let timingPhrase = placements
.map(\.stylePromptPhrase)
.filter { !$0.isEmpty }
.joined(separator: "; ")
if !timingPhrase.isEmpty {
details.append(timingPhrase)
}
guard !details.isEmpty else {
return displayName
}
return "\(displayName) (\(details.joined(separator: ", ")))"
}
}
public struct SelectedInstrumentPlacementContext: Codable, Equatable, Sendable {
public var sectionId: String?
public var sectionTitle: String?
public var sectionType: SongSectionType?
public var startTime: TimeInterval?
public var endTime: TimeInterval?
public var direction: String?
public init(
sectionId: String? = nil,
sectionTitle: String? = nil,
sectionType: SongSectionType? = nil,
startTime: TimeInterval? = nil,
endTime: TimeInterval? = nil,
direction: String? = nil
) {
self.sectionId = sectionId
self.sectionTitle = sectionTitle
self.sectionType = sectionType
self.startTime = startTime
self.endTime = endTime
self.direction = direction
}
public var stylePromptPhrase: String {
var parts: [String] = []
if let sectionTitle, !sectionTitle.isEmpty {
parts.append(sectionTitle)
} else if let sectionType {
parts.append(sectionType.rawValue)
}
if let timing = timingPhrase {
parts.append(timing)
}
if let direction, !direction.isEmpty {
parts.append(direction)
}
return parts.joined(separator: " ")
}
private var timingPhrase: String? {
switch (startTime, endTime) {
case let (start?, end?):
return "\(Self.formattedTime(start))-\(Self.formattedTime(end))"
case let (start?, nil):
return "from \(Self.formattedTime(start))"
case let (nil, end?):
return "until \(Self.formattedTime(end))"
case (nil, nil):
return nil
}
}
private static func formattedTime(_ time: TimeInterval) -> String {
let roundedTime = time.rounded()
if roundedTime == time {
return "\(Int(roundedTime))s"
}
return String(format: "%.1fs", time)
}
}