public protocol PromptCompiling: Sendable { associatedtype Project func compile(project: Project) throws -> CompiledSunoOutput } public struct CompiledSunoOutput: Equatable, Sendable { public let lyricsText: String public let stylePrompt: String public init(lyricsText: String, stylePrompt: String) { self.lyricsText = lyricsText 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 { sectionLyricsBlock(for: $0, project: project) } .joined(separator: "\n\n") } private func sectionLyricsBlock(for section: SongSection, project: SongProject) -> String? { let lyrics = project.lyrics.sectionTexts[section.id] ?? section.lyrics let trimmedLyrics = lyrics.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedLyrics.isEmpty else { return nil } let performanceDirective = normalized(section.vocalDirection) let header = performanceDirective.map { "[\(section.title) - \($0)]" } ?? "[\(section.title)]" return "\(header)\n\(trimmedLyrics)" } 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(moodPhrase(for: project), to: &components) append(context.sunoStyleInstrumentPhrase, to: &components) append(vocalPhrase(for: project), to: &components) append(musicalParametersPhrase(for: project), to: &components) append(emotionPhrase(for: project), to: &components) append(productionPhrase(for: project), 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: ¶meters) append(project.scale?.value.map { "\($0) scale" }, to: ¶meters) append(project.maqam?.value.map { "maqam \($0)" }, to: ¶meters) return parameters.joined(separator: ", ") } private func moodPhrase(for project: SongProject) -> String { let moods = project.moods.map(\.name).compactMap(normalized) guard !moods.isEmpty else { return "" } return "mood: \(moods.joined(separator: ", "))" } private func emotionPhrase(for project: SongProject) -> String { let sectionEmotions = project.orderedSections.compactMap { section in directedPhrase( sectionTitle: section.title, emotion: section.emotion, energy: section.energy, direction: nil ) } let arcEmotions = project.emotionalArc.compactMap { point in directedPhrase( sectionTitle: sectionTitle(for: point.sectionID, in: project), emotion: point.emotion, energy: point.energy, direction: point.direction ) } let details = unique(sectionEmotions + arcEmotions) guard !details.isEmpty else { return "" } return "emotion: \(details.joined(separator: "; "))" } private func productionPhrase(for project: SongProject) -> String { let sectionDirections = project.orderedSections.compactMap { section in directedPhrase(sectionTitle: section.title, direction: section.productionDirection) } let projectDirections = project.productionDirections.compactMap { direction in directedPhrase( sectionTitle: sectionTitle(for: direction.sectionID, in: project), direction: direction.text ) } let details = unique(sectionDirections + projectDirections) guard !details.isEmpty else { return "" } return "production: \(details.joined(separator: "; "))" } private func directedPhrase( sectionTitle: String?, emotion: String? = nil, energy: Int? = nil, direction: String? = nil ) -> String? { var details = [normalized(emotion), energy.map { "energy \($0)" }, normalized(direction)] .compactMap { $0 } guard !details.isEmpty else { return nil } if let sectionTitle = normalized(sectionTitle) { details.insert(sectionTitle, at: 0) } return details.joined(separator: ", ") } private func sectionTitle(for sectionID: String?, in project: SongProject) -> String? { guard let sectionID else { return nil } return project.sections.first { $0.id == sectionID }?.title } private func unique(_ values: [String]) -> [String] { var seen = Set() return values.filter { seen.insert($0).inserted } } 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 } }