Compare commits

..
16 Commits
23 changed files with 1292 additions and 53 deletions
@@ -0,0 +1,120 @@
import AppKit
import ApplicationServices
import MusicAssistantCore
struct MacOSSunoAccessibilityFieldFiller: SunoFieldFilling {
func fill(_ payload: SunoHandoffPayload) async -> SunoFieldFillReport {
guard AXIsProcessTrusted() else {
return .copyRequired(
for: SunoHandoffField.allCases,
reason: "Allow Accessibility access in System Settings, then reopen AI Music Studio."
)
}
try? await Task.sleep(for: .seconds(2))
guard let application = sunoBrowserApplication() else {
return .copyRequired(for: SunoHandoffField.allCases, reason: "The Suno browser window was not found.")
}
let processIdentifier = application.processIdentifier
pressCustomMode(in: accessibilityElements(for: processIdentifier))
try? await Task.sleep(for: .milliseconds(500))
let elements = accessibilityElements(for: processIdentifier)
return SunoFieldFillReport(results: Dictionary(uniqueKeysWithValues: SunoHandoffField.allCases.map { field in
(field, fill(field, with: payload.value(for: field), in: elements))
}))
}
private func pressCustomMode(in elements: [AXUIElement]) {
guard let customMode = elements.first(where: { element in
role(of: element) == kAXButtonRole as String && labels(for: element).contains { $0.localizedCaseInsensitiveContains("custom") }
}) else {
return
}
AXUIElementPerformAction(customMode, kAXPressAction as CFString)
}
private func fill(_ field: SunoHandoffField, with value: String, in elements: [AXUIElement]) -> SunoFieldFillResult {
guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return .filled
}
guard let element = elements.first(where: { matches(field, element: $0) }) else {
return .copyRequired("The \(field.displayName) field was not found.")
}
var isSettable = DarwinBoolean(false)
guard AXUIElementIsAttributeSettable(element, kAXValueAttribute as CFString, &isSettable) == .success,
isSettable.boolValue,
AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, value as CFTypeRef) == .success else {
return .copyRequired("The \(field.displayName) field could not be filled.")
}
return .filled
}
private func matches(_ field: SunoHandoffField, element: AXUIElement) -> Bool {
let role = role(of: element)
guard role == kAXTextFieldRole as String || role == kAXTextAreaRole as String else { return false }
let labels = labels(for: element)
switch field {
case .title:
return labels.contains { $0.localizedCaseInsensitiveContains("title") }
case .lyrics:
return labels.contains { $0.localizedCaseInsensitiveContains("lyrics") }
case .stylePrompt:
return labels.contains { label in
label.localizedCaseInsensitiveContains("style") || label.localizedCaseInsensitiveContains("prompt")
}
}
}
private func accessibilityElements(for processIdentifier: pid_t) -> [AXUIElement] {
descendants(of: AXUIElementCreateApplication(processIdentifier))
}
private func sunoBrowserApplication() -> NSRunningApplication? {
guard let applicationURL = NSWorkspace.shared.urlForApplication(toOpen: SunoHandoffDestination.createPageURL),
let bundleIdentifier = Bundle(url: applicationURL)?.bundleIdentifier else {
return NSWorkspace.shared.frontmostApplication
}
return NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).first
?? NSWorkspace.shared.frontmostApplication
}
private func descendants(of root: AXUIElement) -> [AXUIElement] {
var elements: [AXUIElement] = []
var pending = [root]
while let element = pending.popLast() {
elements.append(element)
pending.append(contentsOf: children(of: element))
}
return elements
}
private func children(of element: AXUIElement) -> [AXUIElement] {
attribute(kAXChildrenAttribute, from: element) as? [AXUIElement] ?? []
}
private func role(of element: AXUIElement) -> String? {
attribute(kAXRoleAttribute, from: element) as? String
}
private func labels(for element: AXUIElement) -> [String] {
[kAXTitleAttribute, kAXDescriptionAttribute, kAXHelpAttribute, kAXIdentifierAttribute, kAXPlaceholderValueAttribute]
.compactMap { attribute($0, from: element) as? String }
}
private func attribute(_ name: String, from element: AXUIElement) -> Any? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success else { return nil }
return value
}
}
@@ -1,11 +1,23 @@
import AppKit
import MusicAssistantCore
import SwiftUI
struct FinalReviewView: View {
@Binding var project: SongProject
@Environment(\.openURL) private var openURL
private let sunoFieldFiller: any SunoFieldFilling
@State private var newFieldKey = ""
@State private var newFieldValue = ""
@State private var handoffStatus: HandoffStatus?
init(
project: Binding<SongProject>,
sunoFieldFiller: any SunoFieldFilling = MacOSSunoAccessibilityFieldFiller()
) {
_project = project
self.sunoFieldFiller = sunoFieldFiller
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -42,6 +54,27 @@ struct FinalReviewView: View {
Label("Prepare fields", systemImage: "wand.and.stars")
}
.disabled(project.sunoOutput != nil)
Button(action: sendToSuno) {
Label("Send to Suno", systemImage: "arrow.up.forward.app")
}
}
if let handoffStatus {
Label(handoffStatus.title, systemImage: handoffStatus.systemImage)
.font(.caption)
.foregroundStyle(handoffStatus.foregroundStyle)
}
if let fieldsToCopy, let output = project.sunoOutput {
HStack(spacing: 8) {
ForEach(fieldsToCopy) { field in
Button("Copy \(field.displayName)") {
copy(field, from: output)
}
.buttonStyle(.bordered)
}
}
}
TextField("Suno lyrics", text: sunoLyricsBinding, axis: .vertical)
@@ -156,12 +189,115 @@ 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()
)
}
private func sendToSuno() {
ensureSunoOutput()
guard let output = project.sunoOutput else { return }
do {
try CompiledSunoOutputValidator().validate(
CompiledSunoOutput(lyricsText: output.lyricsText, stylePrompt: output.stylePrompt)
)
} catch let validationError as LocalizedError {
handoffStatus = .failed(validationError.errorDescription ?? "Unable to prepare the Suno handoff.")
return
} catch {
handoffStatus = .failed("Unable to prepare the Suno handoff.")
return
}
openURL(SunoHandoffDestination.createPageURL) { accepted in
guard accepted else {
handoffStatus = .failed("Unable to open the Suno Create page.")
return
}
let payload = SunoHandoffPayload(
title: project.title,
lyrics: output.lyricsText,
stylePrompt: output.stylePrompt
)
Task {
handoffStatus = .fieldFill(await sunoFieldFiller.fill(payload))
}
}
}
private var fieldsToCopy: [SunoHandoffField]? {
guard case let .fieldFill(report) = handoffStatus else { return nil }
let fields = SunoHandoffField.allCases.filter {
if case .copyRequired = report.results[$0] { return true }
return false
}
return fields.isEmpty ? nil : fields
}
private func copy(_ field: SunoHandoffField, from output: SunoOutput) {
let value: String
switch field {
case .title:
value = project.title
case .lyrics:
value = output.lyricsText
case .stylePrompt:
value = output.stylePrompt
}
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
}
}
private enum HandoffStatus: Equatable {
case fieldFill(SunoFieldFillReport)
case failed(String)
var title: String {
switch self {
case let .fieldFill(report):
let fieldsToCopy = report.results.compactMap { field, result in
if case .copyRequired = result { return field.displayName }
return nil
}
return fieldsToCopy.isEmpty
? "Suno fields filled in your browser."
: "Copy these fields into Suno: \(fieldsToCopy.joined(separator: ", "))."
case let .failed(message):
return message
}
}
var systemImage: String {
switch self {
case let .fieldFill(report):
return report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
? "exclamationmark.triangle.fill"
: "checkmark.circle.fill"
case .failed:
return "exclamationmark.triangle.fill"
}
}
var foregroundStyle: Color {
switch self {
case let .fieldFill(report):
return report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
? .orange
: .green
case .failed:
return .red
}
}
}
#Preview {
@@ -2,27 +2,51 @@ import MusicAssistantCore
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(catalog: LocalInstrumentCatalog = LocalInstrumentCatalog()) {
init(
project: Binding<SongProject>,
saveProject: @escaping (SongProject) async -> Bool = { _ in true },
catalog: LocalInstrumentCatalog = LocalInstrumentCatalog()
) {
_project = project
self.saveProject = saveProject
self.catalog = catalog
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
Picker("Family", selection: $selectedFamilyCategory) {
Text("All families").tag(String?.none)
HStack {
Picker("Family", selection: $selectedFamilyCategory) {
Text("All families").tag(String?.none)
ForEach(catalog.familyCategories, id: \.self) { familyCategory in
Text(familyCategory).tag(Optional(familyCategory))
ForEach(catalog.familyCategories, id: \.self) { familyCategory in
Text(familyCategory).tag(Optional(familyCategory))
}
}
.pickerStyle(.menu)
Spacer()
Picker("Region", selection: $selectedRegionOrigin) {
Text("All regions").tag(String?.none)
ForEach(catalog.regionsOfOrigin, id: \.self) { regionOrigin in
Text(regionOrigin).tag(Optional(regionOrigin))
}
}
.pickerStyle(.menu)
}
.pickerStyle(.menu)
.padding(.horizontal)
.padding(.vertical, 8)
@@ -31,25 +55,29 @@ struct InstrumentBrowserView: View {
List(
catalog.instruments(
matching: searchText,
familyCategory: selectedFamilyCategory
familyCategory: selectedFamilyCategory,
regionOrigin: selectedRegionOrigin
),
id: \.id
) { instrument in
VStack(alignment: .leading, spacing: 4) {
Text(instrument.name)
.font(.body.weight(.medium))
Toggle(isOn: selectionBinding(for: instrument)) {
VStack(alignment: .leading, spacing: 4) {
Text(instrument.name)
.font(.body.weight(.medium))
HStack(spacing: 6) {
Text(instrument.familyCategory)
HStack(spacing: 6) {
Text(instrument.familyCategory)
if let regionOrigin = instrument.regionOrigin {
Text(regionOrigin)
if let regionOrigin = instrument.regionOrigin {
Text(regionOrigin)
}
}
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.toggleStyle(.checkbox)
.padding(.vertical, 2)
}
}
@@ -58,15 +86,69 @@ 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> {
Binding {
project.isInstrumentSelected(id: instrument.id)
} set: { isSelected in
project.setInstrumentSelected(
id: instrument.id,
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 {
InstrumentBrowserView()
InstrumentBrowserPreview()
}
private struct InstrumentBrowserPreview: View {
@State private var project = SongProject(
title: "Instrument Browser Preview",
idea: "Browse the instrument catalog."
)
var body: some View {
InstrumentBrowserView(project: $project)
}
}
@@ -146,7 +146,7 @@ struct ProjectInspectorView: View {
}
.background(Color(nsColor: .windowBackgroundColor))
.sheet(isPresented: $isInstrumentBrowserPresented) {
InstrumentBrowserView()
InstrumentBrowserView(project: $project, saveProject: saveProject)
}
}
@@ -312,6 +312,33 @@ public extension SongProject {
instruments[index].selected = false
}
mutating func setInstrumentSelected(
id instrumentId: String,
isSelected: Bool,
variant: String? = nil
) {
guard isSelected else {
deselectInstrument(id: instrumentId)
return
}
if let index = instruments.firstIndex(where: { $0.instrumentId == instrumentId }) {
instruments[index].selected = true
if instruments[index].variant == nil {
instruments[index].variant = variant
}
} else {
instruments.append(
InstrumentTrack(
instrumentId: instrumentId,
selected: true,
variant: variant
)
)
}
}
mutating func configureInstrument(
id instrumentId: String,
variant: String?,
@@ -1,3 +1,72 @@
import Foundation
public protocol SunoHandoffService: Sendable {
func handOff(_ output: CompiledSunoOutput) async throws
}
public enum SunoHandoffDestination {
public static let createPageURL = URL(string: "https://suno.com/create")!
}
public struct SunoHandoffPayload: Equatable, Sendable {
public let title: String
public let lyrics: String
public let stylePrompt: String
public init(title: String, lyrics: String, stylePrompt: String) {
self.title = title
self.lyrics = lyrics
self.stylePrompt = stylePrompt
}
public func value(for field: SunoHandoffField) -> String {
switch field {
case .title:
return title
case .lyrics:
return lyrics
case .stylePrompt:
return stylePrompt
}
}
}
public enum SunoHandoffField: String, CaseIterable, Equatable, Identifiable, Sendable {
case title
case lyrics
case stylePrompt
public var id: String { rawValue }
public var displayName: String {
switch self {
case .title:
return "Title"
case .lyrics:
return "Lyrics"
case .stylePrompt:
return "Style prompt"
}
}
}
public enum SunoFieldFillResult: Equatable, Sendable {
case filled
case copyRequired(String)
}
public struct SunoFieldFillReport: Equatable, Sendable {
public let results: [SunoHandoffField: SunoFieldFillResult]
public init(results: [SunoHandoffField: SunoFieldFillResult]) {
self.results = results
}
public static func copyRequired(for fields: [SunoHandoffField], reason: String) -> SunoFieldFillReport {
SunoFieldFillReport(results: Dictionary(uniqueKeysWithValues: fields.map { ($0, .copyRequired(reason)) }))
}
}
public protocol SunoFieldFilling: Sendable {
func fill(_ payload: SunoHandoffPayload) async -> SunoFieldFillReport
}
@@ -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)
}
}
@@ -12,12 +12,19 @@ public struct LocalInstrumentCatalog: Sendable {
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
public var regionsOfOrigin: [String] {
Array(Set(items.compactMap(\.regionOrigin)))
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
public func instruments(
matching query: String,
familyCategory: String? = nil
familyCategory: String? = nil,
regionOrigin: String? = nil
) -> [InstrumentCatalogItem] {
let searchTokens = Self.searchTokens(for: query)
let selectedFamilyCategory = familyCategory?.trimmingCharacters(in: .whitespacesAndNewlines)
let selectedRegionOrigin = regionOrigin?.trimmingCharacters(in: .whitespacesAndNewlines)
return items
.filter { item in
@@ -25,7 +32,8 @@ public struct LocalInstrumentCatalog: Sendable {
Self.searchableTerms(for: item).contains { $0.contains(token) }
}
let matchesFamily = selectedFamilyCategory?.isEmpty != false || item.familyCategory == selectedFamilyCategory
return matchesSearch && matchesFamily
let matchesRegion = selectedRegionOrigin?.isEmpty != false || item.regionOrigin == selectedRegionOrigin
return matchesSearch && matchesFamily && matchesRegion
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
@@ -13,3 +13,159 @@ 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 { 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: &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 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
}
}
@@ -0,0 +1,29 @@
import Foundation
public protocol SunoOutputValidating: Sendable {
func validate(_ output: CompiledSunoOutput) throws
}
public enum SunoOutputValidationError: Error, Equatable, LocalizedError, Sendable {
case missingContent
public var errorDescription: String? {
switch self {
case .missingContent:
return "Add lyrics or a style prompt before sending the song to Suno."
}
}
}
public struct CompiledSunoOutputValidator: SunoOutputValidating {
public init() {}
public func validate(_ output: CompiledSunoOutput) throws {
let lyrics = output.lyricsText.trimmingCharacters(in: .whitespacesAndNewlines)
let style = output.stylePrompt.trimmingCharacters(in: .whitespacesAndNewlines)
guard !lyrics.isEmpty || !style.isEmpty else {
throw SunoOutputValidationError.missingContent
}
}
}
@@ -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)
}
}
@@ -0,0 +1,65 @@
import Foundation
import MusicAssistantCore
import XCTest
final class InstrumentSelectionPersistenceTests: XCTestCase {
func testSelectedInstrumentsRoundTripThroughSongProjectSerialization() throws {
let section = SongSection(id: "intro", type: .intro, title: "Intro")
let project = SongProject(
id: "serialized-instrument-selection",
title: "Serialized Instrument Selection",
idea: "Preserve selected instruments",
sections: [section],
instruments: [
InstrumentTrack(
instrumentId: "oud",
selected: true,
variant: "Arabic Oud",
playingStyle: "tremolo",
role: "lead motif",
autoArrangementEnabled: false,
placements: [InstrumentPlacement(sectionId: section.id, direction: "Opening")]
),
InstrumentTrack(
instrumentId: "violin",
selected: false,
variant: "Violin"
)
]
)
let encodedProject = try JSONEncoder().encode(project)
let decodedProject = try JSONDecoder().decode(SongProject.self, from: encodedProject)
XCTAssertEqual(decodedProject.instruments, project.instruments)
XCTAssertEqual(decodedProject.selectedInstrumentIDs, ["oud"])
XCTAssertEqual(decodedProject.instrumentTrack(for: "oud")?.placements.first?.sectionId, section.id)
}
func testSelectedInstrumentsPersistWhenProjectIsSavedAndReopened() async throws {
let directoryURL = FileManager.default.temporaryDirectory
.appendingPathComponent("music-assistant-instrument-selection-tests")
.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directoryURL) }
let store = LocalSongProjectStore(directoryURL: directoryURL)
var project = SongProject(
id: "persisted-instrument-selection",
title: "Persisted Instrument Selection",
idea: "Keep catalog selections"
)
project.setInstrumentSelected(id: "oud", isSelected: true, variant: "Oud")
project.setInstrumentSelected(id: "violin", isSelected: true, variant: "Violin")
project.setInstrumentSelected(id: "oud", isSelected: false)
try await store.create(project)
let reopenedProject = try await store.open(id: project.id)
XCTAssertFalse(reopenedProject.isInstrumentSelected(id: "oud"))
XCTAssertTrue(reopenedProject.isInstrumentSelected(id: "violin"))
XCTAssertEqual(reopenedProject.selectedInstrumentIDs, ["violin"])
XCTAssertEqual(reopenedProject.instrumentTrack(for: "oud")?.variant, "Oud")
}
}
@@ -47,4 +47,19 @@ final class LocalInstrumentCatalogTests: XCTestCase {
.allSatisfy { $0.familyCategory == "Plucked strings" }
)
}
func testRegionsAndFilteringComeFromCatalogMetadata() {
let catalog = LocalInstrumentCatalog()
let middleEasternPluckedStrings = catalog.instruments(
matching: "",
familyCategory: "Plucked strings",
regionOrigin: "Middle East"
)
XCTAssertTrue(catalog.regionsOfOrigin.contains("Middle East"))
XCTAssertFalse(middleEasternPluckedStrings.isEmpty)
XCTAssertTrue(middleEasternPluckedStrings.contains { $0.id == "oud" })
XCTAssertTrue(middleEasternPluckedStrings.allSatisfy { $0.regionOrigin == "Middle East" })
XCTAssertTrue(middleEasternPluckedStrings.allSatisfy { $0.familyCategory == "Plucked strings" })
}
}
@@ -48,6 +48,35 @@ final class LocalSongProjectStoreTests: XCTestCase {
XCTAssertEqual(updatedProject, project)
}
func testPersistsSelectedInstrumentsOnSongProject() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
var project = SongProject(
id: "instrument-selection-project",
title: "Instrument Selection",
idea: "Persist selected catalog instruments"
)
project.setInstrumentSelected(id: "oud", isSelected: true, variant: "Oud")
project.setInstrumentSelected(id: "violin", isSelected: true, variant: "Violin")
project.setInstrumentSelected(id: "oud", isSelected: false)
try await store.create(project)
var openedProject = try await store.open(id: project.id)
XCTAssertFalse(openedProject.isInstrumentSelected(id: "oud"))
XCTAssertTrue(openedProject.isInstrumentSelected(id: "violin"))
XCTAssertEqual(openedProject.instrumentTrack(for: "oud")?.variant, "Oud")
XCTAssertEqual(openedProject.selectedInstrumentIDs, ["violin"])
openedProject.setInstrumentSelected(id: "oud", isSelected: true, variant: "Oud")
try await store.save(openedProject)
let savedProject = try await store.open(id: project.id)
XCTAssertTrue(savedProject.isInstrumentSelected(id: "oud"))
XCTAssertTrue(savedProject.isInstrumentSelected(id: "violin"))
XCTAssertEqual(savedProject.selectedInstrumentIDs, ["oud", "violin"])
}
func testLoadsProjectListSortedByMostRecentUpdate() async throws {
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
let older = SongProject(
@@ -0,0 +1,78 @@
import MusicAssistantCore
import XCTest
final class SongGenerationContextTests: XCTestCase {
func testContextIncludesOnlySelectedInstrumentsWithArrangementDetails() {
let project = SongProject(
title: "Instrument Context",
idea: "Use selected instruments in generation",
sections: [
SongSection(id: "intro", type: .intro, title: "Opening"),
SongSection(id: "chorus", type: .chorus, title: "Final Chorus", order: 1)
],
instruments: [
InstrumentTrack(
instrumentId: "oud",
selected: true,
variant: "Arabic Oud",
playingStyle: "tremolo",
role: "lead motif",
autoArrangementEnabled: true,
placements: [
InstrumentPlacement(
sectionId: "intro",
startTime: 0,
endTime: 12,
direction: "solo opening"
)
]
),
InstrumentTrack(
instrumentId: "violin",
selected: false,
variant: "Violin",
role: "deselected counterline"
)
]
)
let context = SongGenerationContext(project: project)
XCTAssertTrue(context.hasSelectedInstruments)
XCTAssertEqual(context.selectedInstruments.map(\.instrumentId), ["oud"])
XCTAssertEqual(context.selectedInstruments.first?.displayName, "Arabic Oud")
XCTAssertEqual(context.selectedInstruments.first?.playingStyle, "tremolo")
XCTAssertEqual(context.selectedInstruments.first?.role, "lead motif")
XCTAssertEqual(context.selectedInstruments.first?.autoArrangementEnabled, true)
XCTAssertEqual(context.selectedInstruments.first?.placements.first?.sectionTitle, "Opening")
XCTAssertEqual(context.selectedInstruments.first?.placements.first?.sectionType, .intro)
XCTAssertEqual(
context.sunoStyleInstrumentPhrase,
"Arabic Oud (lead motif, tremolo, Opening 0s-12s solo opening)"
)
}
func testAIRequestsCarrySongGenerationContextFromProjects() {
let project = SongProject(
title: "Context Request",
idea: "Carry selected instruments",
instruments: [
InstrumentTrack(instrumentId: "qanun", selected: true, variant: "Qanun"),
InstrumentTrack(instrumentId: "piano", selected: false, variant: "Piano")
]
)
let generationRequest = SongProjectGenerationRequest(
context: AIRequestContext(userInstruction: "Generate."),
seedProject: project
)
let updateRequest = SongProjectUpdateRequest(
context: AIRequestContext(userInstruction: "Arrange."),
project: project,
allowedScopes: [.arrangement]
)
XCTAssertEqual(generationRequest.songGenerationContext.selectedInstruments.map(\.instrumentId), ["qanun"])
XCTAssertEqual(updateRequest.songGenerationContext.selectedInstruments.map(\.displayName), ["Qanun"])
}
}
@@ -281,6 +281,40 @@ final class SongProjectModelTests: XCTestCase {
XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [introPlacement])
}
func testCatalogSelectionCanBeToggledWithoutReplacingExistingArrangement() {
let oudPlacement = InstrumentPlacement(sectionId: "intro", direction: "Opening melody")
var project = SongProject(
id: "catalog-selection-project",
title: "Catalog Selection",
idea: "Select and deselect instruments",
instruments: [
InstrumentTrack(
instrumentId: "oud",
selected: false,
variant: "Arabic oud",
playingStyle: "tremolo",
role: "main hook",
autoArrangementEnabled: false,
placements: [oudPlacement]
)
]
)
project.setInstrumentSelected(id: "oud", isSelected: true, variant: "Oud")
XCTAssertTrue(project.isInstrumentSelected(id: "oud"))
XCTAssertEqual(project.instrumentTrack(for: "oud")?.variant, "Arabic oud")
XCTAssertEqual(project.instrumentTrack(for: "oud")?.role, "main hook")
XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [oudPlacement])
project.setInstrumentSelected(id: "oud", isSelected: false)
project.setInstrumentSelected(id: "violin", isSelected: true, variant: "Violin")
XCTAssertFalse(project.isInstrumentSelected(id: "oud"))
XCTAssertTrue(project.isInstrumentSelected(id: "violin"))
XCTAssertEqual(project.instrumentTrack(for: "violin")?.variant, "Violin")
}
func testMultipleVocalistsCanBeAssignedToSections() {
var project = SongProject(
id: "project-6",
@@ -0,0 +1,145 @@
import MusicAssistantCore
import XCTest
final class SongProjectPromptCompilerTests: XCTestCase {
func testCompilerUsesSelectedInstrumentsInSunoStylePrompt() throws {
let project = SongProject(
title: "Compiled Song",
idea: "Prepare Suno output",
genres: [
GenreStyle(id: "arabic-pop", name: "Arabic Pop"),
GenreStyle(id: "cinematic", name: "Cinematic")
],
moods: [MoodTag(id: "hopeful", name: "Hopeful")],
bpm: ManualAutoValue(mode: .manual, value: 96),
maqam: ManualAutoValue(mode: .manual, value: "Hijaz"),
sections: [
SongSection(id: "chorus", type: .chorus, title: "Chorus", lyrics: "Section chorus")
],
instruments: [
InstrumentTrack(
instrumentId: "oud",
selected: true,
variant: "Arabic Oud",
playingStyle: "picked",
role: "main hook",
placements: [InstrumentPlacement(sectionId: "chorus", direction: "answer the vocal")]
),
InstrumentTrack(
instrumentId: "drum-kit",
selected: false,
variant: "Drum Kit"
)
],
vocalists: [Vocalist(id: "lead", label: "Lead", voiceType: "warm tenor")],
lyrics: Lyrics(text: "Approved lyrics"),
productionDirections: [ProductionDirection(id: "lift", text: "wide chorus lift")]
)
let output = try SongProjectPromptCompiler().compile(project: project)
XCTAssertEqual(output.lyricsText, "Approved lyrics")
XCTAssertTrue(output.stylePrompt.contains("Arabic Pop + Cinematic"))
XCTAssertTrue(output.stylePrompt.contains("Arabic Oud (main hook, picked, Chorus answer the vocal)"))
XCTAssertTrue(output.stylePrompt.contains("Lead warm tenor"))
XCTAssertTrue(output.stylePrompt.contains("96 BPM"))
XCTAssertTrue(output.stylePrompt.contains("maqam Hijaz"))
XCTAssertTrue(output.stylePrompt.contains("wide chorus lift"))
XCTAssertFalse(output.stylePrompt.contains("Drum Kit"))
}
func testCompilerFallsBackToOrderedSectionLyrics() throws {
let project = SongProject(
title: "Section Lyrics",
idea: "Compile sections",
sections: [
SongSection(id: "chorus", type: .chorus, title: "Chorus", order: 1, lyrics: "Hook line"),
SongSection(id: "verse", type: .verse, title: "Verse", order: 0, lyrics: "Verse line")
]
)
let output = try SongProjectPromptCompiler().compile(project: project)
XCTAssertEqual(output.lyricsText, "[Verse]\nVerse line\n\n[Chorus]\nHook line")
}
func testCompilerIncludesSectionPerformanceDirectivesInFallbackLyrics() throws {
let project = SongProject(
title: "Directed Sections",
idea: "Compile performance directions",
sections: [
SongSection(
id: "intro",
type: .intro,
title: "Intro",
lyrics: "First line",
vocalDirection: "whispered lead"
),
SongSection(
id: "chorus",
type: .chorus,
title: "Chorus",
order: 1,
lyrics: "Original hook"
)
],
lyrics: Lyrics(sectionTexts: ["chorus": "Approved hook"])
)
let output = try SongProjectPromptCompiler().compile(project: project)
XCTAssertEqual(
output.lyricsText,
"[Intro - whispered lead]\nFirst line\n\n[Chorus]\nApproved hook"
)
}
func testCompilerIncludesStyleInputsFromTheSongProject() throws {
let project = SongProject(
title: "Full Style",
idea: "Compile every approved style input",
genres: [
GenreStyle(id: "folk", name: "Folk"),
GenreStyle(id: "electronic", name: "Electronic")
],
moods: [MoodTag(id: "warm", name: "Warm")],
emotionalArc: [
EmotionalArcPoint(
id: "chorus-arc",
sectionID: "chorus",
emotion: "joyful",
energy: 8,
direction: "lift into release"
)
],
bpm: ManualAutoValue(mode: .manual, value: 108),
key: ManualAutoValue(mode: .manual, value: "D minor"),
scale: ManualAutoValue(mode: .manual, value: "Dorian"),
sections: [
SongSection(
id: "verse",
type: .verse,
title: "Verse",
emotion: "yearning",
energy: 3,
productionDirection: "intimate opening"
),
SongSection(id: "chorus", type: .chorus, title: "Chorus", order: 1)
],
vocalists: [
Vocalist(id: "lead", label: "Lead", voiceType: "clear alto", performanceStyle: "expressive")
],
productionDirections: [
ProductionDirection(id: "chorus-width", sectionID: "chorus", text: "wide chorus"),
ProductionDirection(id: "master", text: "warm tape saturation")
]
)
let output = try SongProjectPromptCompiler().compile(project: project)
XCTAssertEqual(
output.stylePrompt,
"Folk + Electronic; mood: Warm; Lead clear alto expressive; 108 BPM, key D minor, Dorian scale; emotion: Verse, yearning, energy 3; Chorus, joyful, energy 8, lift into release; production: Verse, intimate opening; Chorus, wide chorus; warm tape saturation"
)
}
}
@@ -0,0 +1,20 @@
import MusicAssistantCore
import XCTest
final class SunoHandoffDestinationTests: XCTestCase {
func testCreatePageUsesTheOfficialSunoCreateURL() {
let url = SunoHandoffDestination.createPageURL
XCTAssertEqual(url.scheme, "https")
XCTAssertEqual(url.host, "suno.com")
XCTAssertEqual(url.path, "/create")
}
func testPayloadPreservesApprovedValuesForEachSupportedField() {
let payload = SunoHandoffPayload(title: "My Song", lyrics: "Approved lyrics", stylePrompt: "Cinematic folk")
XCTAssertEqual(payload.value(for: .title), "My Song")
XCTAssertEqual(payload.value(for: .lyrics), "Approved lyrics")
XCTAssertEqual(payload.value(for: .stylePrompt), "Cinematic folk")
}
}
@@ -0,0 +1,19 @@
import MusicAssistantCore
import XCTest
final class SunoOutputValidationTests: XCTestCase {
func testValidatorRejectsOutputWithoutSunoContent() {
let output = CompiledSunoOutput(lyricsText: " \n ", stylePrompt: "\t")
XCTAssertThrowsError(try CompiledSunoOutputValidator().validate(output)) { error in
XCTAssertEqual(error as? SunoOutputValidationError, .missingContent)
}
}
func testValidatorAllowsLyricsOrAStylePrompt() throws {
let validator = CompiledSunoOutputValidator()
XCTAssertNoThrow(try validator.validate(CompiledSunoOutput(lyricsText: "Lyrics", stylePrompt: "")))
XCTAssertNoThrow(try validator.validate(CompiledSunoOutput(lyricsText: "", stylePrompt: "Cinematic folk")))
}
}
+11 -3
View File
@@ -57,6 +57,8 @@ retry delay when available, and propagates cancellation without retrying.
A deterministic layer converts the approved SongProject into the final
Suno-facing lyrics/style content. User choices override AI suggestions.
It depends only on approved domain data and local validation; it does
not invoke the OpenAI client or reference SwiftUI presentation code.
## Arabic Pronunciation Processor
@@ -82,9 +84,15 @@ and explicitly apply the processed text before any Suno handoff.
## Suno Boundary
The initial requirement is browser/site handoff that fills fields but
does not press Generate. Treat this integration as replaceable because
website UI/behavior can change.
The initial requirement is an app-managed web surface for the Suno
Create page in Custom mode. This avoids relying on a particular user
browser. The integration attempts to fill the supported title,
lyrics and style fields but does not press Generate. If a field cannot
be filled, it returns a field-specific result so Presentation can report
it and offer the approved text for copying. Treat this integration as
replaceable because website UI/behavior can change. The field filler is
an adapter behind a protocol so it can be replaced without changing
SongProject, prompt compilation or UI review logic.
## Instrument Selector Boundary
+5 -1
View File
@@ -15,7 +15,11 @@ finalized.
- Exact list of supported languages/dialects for MVP.
- Arabic diacritization policy: full tashkeel vs
pronunciation-targeted tashkeel.
- Exact Suno fields to populate and supported handoff mechanism.
- Resolved: Suno handoff presents the Create page in an app-managed web
surface in Custom mode and attempts to fill title, lyrics and style
fields. It never
starts generation. Field-fill failures are reported with the
approved text available for copying.
- Whether Suno integration is permitted/reliable under the intended
account/workflow and current terms.
- Project storage technology and whether cloud sync is required later.
+11 -4
View File
@@ -19,8 +19,11 @@ instructions for Suno.
4. User reviews and manually edits every generated component.
5. Project versions are saved.
6. User presses **Send to Suno**.
7. The app opens Suno and fills the appropriate fields. It does not
trigger Generate.
7. The app presents the Suno Create page in an app-managed web surface
in Custom mode and fills the
supported title, lyrics and style fields. It does not trigger
Generate. If a field cannot be filled, the app reports that result
and keeps its approved text ready for the user to copy.
## Song Controls
@@ -92,5 +95,9 @@ Normal users cannot view or modify these rules in the initial version.
## Initial Boundary
Suno remains responsible for music generation and playback. The macOS
app prepares the project and fills Suno fields only. Generated songs do
not need to return to the app in the initial version.
app opens the browser-based Suno Create page in Custom mode and fills
the supported title, lyrics and style fields only. It never triggers
Generate. If Suno changes its interface and a field cannot be filled,
the app reports that field and keeps the approved text available to
copy. Generated songs do not need to return to the app in the initial
version.
+17 -17
View File
@@ -84,36 +84,36 @@ requirement is missing and blocks implementation, record it in
- [x] Keep catalog data out of SwiftUI views.
- [x] Build searchable instrument browser.
- [x] Add browsing/filtering by family/category.
- [ ] Add browsing/filtering by region/origin where useful.
- [ ] Add checkbox-based multi-select and deselect behavior.
- [ ] Persist selected instruments on the current Song Project.
- [ ] Make selected instruments available to OpenAI/song-generation
- [x] Add browsing/filtering by region/origin where useful.
- [x] Add checkbox-based multi-select and deselect behavior.
- [x] Persist selected instruments on the current Song Project.
- [x] Make selected instruments available to OpenAI/song-generation
logic for arrangement, roles, entry/exit timing, relevant structure
decisions and Suno Style Prompt generation.
- [ ] Preserve existing Manual/Auto arrangement behavior.
- [ ] Add tests for catalog search, category/region filtering,
- [x] Preserve existing Manual/Auto arrangement behavior.
- [x] Add tests for catalog search, category/region filtering,
selection persistence and SongProject serialization.
## Phase 7 --- Prompt Compiler
- [ ] Create deterministic compiler from approved SongProject → Suno
- [x] Create deterministic compiler from approved SongProject → Suno
output.
- [ ] Generate lyrics text with section/performance directives where
- [x] Generate lyrics text with section/performance directives where
appropriate.
- [ ] Generate style prompt from genre blend, instrumentation, vocals,
- [x] Generate style prompt from genre blend, instrumentation, vocals,
tempo, harmony, emotion and production instructions.
- [ ] Validate output before handoff.
- [ ] Keep compiler independent from UI and OpenAI client.
- [x] Validate output before handoff.
- [x] Keep compiler independent from UI and OpenAI client.
## Phase 8 --- Suno Handoff
- [ ] Implement explicit `Send to Suno` action.
- [ ] Open the appropriate Suno creation surface.
- [ ] Fill supported fields with approved project output.
- [ ] Never trigger Generate automatically.
- [ ] Detect/report when fields cannot be filled rather than silently
- [x] Implement explicit `Send to Suno` action.
- [x] Open the appropriate Suno creation surface.
- [x] Fill supported fields with approved project output.
- [x] Never trigger Generate automatically.
- [x] Detect/report when fields cannot be filled rather than silently
failing.
- [ ] Keep integration isolated because Suno UI can change.
- [x] Keep integration isolated because Suno UI can change.
## Phase 9 --- Quality