172 lines
6.3 KiB
Swift
172 lines
6.3 KiB
Swift
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<String>()
|
|
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
|
|
}
|
|
}
|