Compare commits
16
Commits
9ddac746e5
...
3ed4046d73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ed4046d73 | ||
|
|
de0b8e5972 | ||
|
|
d7901ef9d7 | ||
|
|
52978a375d | ||
|
|
3fced12bef | ||
|
|
448d390671 | ||
|
|
61267e201e | ||
|
|
e4b899d83b | ||
|
|
c7640470c6 | ||
|
|
9e6e94e7cb | ||
|
|
4fa477bdb0 | ||
|
|
7bf4df79f9 | ||
|
|
35703f89a8 | ||
|
|
2b5ba2b535 | ||
|
|
04d4093a3c | ||
|
|
6c6aeae5bb |
@@ -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 MusicAssistantCore
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct FinalReviewView: View {
|
struct FinalReviewView: View {
|
||||||
@Binding var project: SongProject
|
@Binding var project: SongProject
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
private let sunoFieldFiller: any SunoFieldFilling
|
||||||
|
|
||||||
@State private var newFieldKey = ""
|
@State private var newFieldKey = ""
|
||||||
@State private var newFieldValue = ""
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
@@ -42,6 +54,27 @@ struct FinalReviewView: View {
|
|||||||
Label("Prepare fields", systemImage: "wand.and.stars")
|
Label("Prepare fields", systemImage: "wand.and.stars")
|
||||||
}
|
}
|
||||||
.disabled(project.sunoOutput != nil)
|
.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)
|
TextField("Suno lyrics", text: sunoLyricsBinding, axis: .vertical)
|
||||||
@@ -156,12 +189,115 @@ struct FinalReviewView: View {
|
|||||||
|
|
||||||
private func ensureSunoOutput() {
|
private func ensureSunoOutput() {
|
||||||
guard project.sunoOutput == nil else { return }
|
guard project.sunoOutput == nil else { return }
|
||||||
|
let compiledOutput = (try? SongProjectPromptCompiler().compile(project: project))
|
||||||
|
?? CompiledSunoOutput(lyricsText: project.lyrics.text, stylePrompt: "")
|
||||||
|
|
||||||
project.sunoOutput = SunoOutput(
|
project.sunoOutput = SunoOutput(
|
||||||
lyricsText: project.lyrics.text,
|
lyricsText: compiledOutput.lyricsText,
|
||||||
stylePrompt: "",
|
stylePrompt: compiledOutput.stylePrompt,
|
||||||
generatedAt: Date()
|
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 {
|
#Preview {
|
||||||
|
|||||||
@@ -2,27 +2,51 @@ import MusicAssistantCore
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct InstrumentBrowserView: View {
|
struct InstrumentBrowserView: View {
|
||||||
|
@Binding private var project: SongProject
|
||||||
|
private let saveProject: (SongProject) async -> Bool
|
||||||
private let catalog: LocalInstrumentCatalog
|
private let catalog: LocalInstrumentCatalog
|
||||||
|
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var selectedFamilyCategory: String?
|
@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
|
@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
|
self.catalog = catalog
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Picker("Family", selection: $selectedFamilyCategory) {
|
HStack {
|
||||||
Text("All families").tag(String?.none)
|
Picker("Family", selection: $selectedFamilyCategory) {
|
||||||
|
Text("All families").tag(String?.none)
|
||||||
|
|
||||||
ForEach(catalog.familyCategories, id: \.self) { familyCategory in
|
ForEach(catalog.familyCategories, id: \.self) { familyCategory in
|
||||||
Text(familyCategory).tag(Optional(familyCategory))
|
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(.horizontal)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
@@ -31,25 +55,29 @@ struct InstrumentBrowserView: View {
|
|||||||
List(
|
List(
|
||||||
catalog.instruments(
|
catalog.instruments(
|
||||||
matching: searchText,
|
matching: searchText,
|
||||||
familyCategory: selectedFamilyCategory
|
familyCategory: selectedFamilyCategory,
|
||||||
|
regionOrigin: selectedRegionOrigin
|
||||||
),
|
),
|
||||||
id: \.id
|
id: \.id
|
||||||
) { instrument in
|
) { instrument in
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
Toggle(isOn: selectionBinding(for: instrument)) {
|
||||||
Text(instrument.name)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
.font(.body.weight(.medium))
|
Text(instrument.name)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(instrument.familyCategory)
|
Text(instrument.familyCategory)
|
||||||
|
|
||||||
if let regionOrigin = instrument.regionOrigin {
|
if let regionOrigin = instrument.regionOrigin {
|
||||||
Text(regionOrigin)
|
Text(regionOrigin)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
}
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,15 +86,69 @@ struct InstrumentBrowserView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Done") {
|
Button("Done") {
|
||||||
dismiss()
|
Task {
|
||||||
|
await saveSelectionChangesIfNeeded()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isSaving)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSaving {
|
||||||
|
ToolbarItem(placement: .status) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(minWidth: 460, minHeight: 520)
|
.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 {
|
#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))
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
.sheet(isPresented: $isInstrumentBrowserPresented) {
|
.sheet(isPresented: $isInstrumentBrowserPresented) {
|
||||||
InstrumentBrowserView()
|
InstrumentBrowserView(project: $project, saveProject: saveProject)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -312,6 +312,33 @@ public extension SongProject {
|
|||||||
instruments[index].selected = false
|
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(
|
mutating func configureInstrument(
|
||||||
id instrumentId: String,
|
id instrumentId: String,
|
||||||
variant: String?,
|
variant: String?,
|
||||||
|
|||||||
@@ -1,3 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
public protocol SunoHandoffService: Sendable {
|
public protocol SunoHandoffService: Sendable {
|
||||||
func handOff(_ output: CompiledSunoOutput) async throws
|
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 context: AIRequestContext
|
||||||
public var seedProject: SongProject?
|
public var seedProject: SongProject?
|
||||||
public var discussionMode: ConversationMode
|
public var discussionMode: ConversationMode
|
||||||
|
public var songGenerationContext: SongGenerationContext
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
context: AIRequestContext,
|
context: AIRequestContext,
|
||||||
seedProject: SongProject? = nil,
|
seedProject: SongProject? = nil,
|
||||||
discussionMode: ConversationMode = .auto
|
discussionMode: ConversationMode = .auto,
|
||||||
|
songGenerationContext: SongGenerationContext? = nil
|
||||||
) {
|
) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.seedProject = seedProject
|
self.seedProject = seedProject
|
||||||
self.discussionMode = discussionMode
|
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 struct SongProjectDiscussionRequest: Equatable, Sendable {
|
||||||
public var context: AIRequestContext
|
public var context: AIRequestContext
|
||||||
public var project: SongProject
|
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.context = context
|
||||||
self.project = project
|
self.project = project
|
||||||
|
self.songGenerationContext = songGenerationContext ?? SongGenerationContext(project: project)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,17 +129,20 @@ public struct LyricsRevisionRequest: Equatable, Sendable {
|
|||||||
public var project: SongProject
|
public var project: SongProject
|
||||||
public var sourceLyrics: Lyrics
|
public var sourceLyrics: Lyrics
|
||||||
public var mode: LyricsRevisionMode
|
public var mode: LyricsRevisionMode
|
||||||
|
public var songGenerationContext: SongGenerationContext
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
context: AIRequestContext,
|
context: AIRequestContext,
|
||||||
project: SongProject,
|
project: SongProject,
|
||||||
sourceLyrics: Lyrics,
|
sourceLyrics: Lyrics,
|
||||||
mode: LyricsRevisionMode
|
mode: LyricsRevisionMode,
|
||||||
|
songGenerationContext: SongGenerationContext? = nil
|
||||||
) {
|
) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.project = project
|
self.project = project
|
||||||
self.sourceLyrics = sourceLyrics
|
self.sourceLyrics = sourceLyrics
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
|
self.songGenerationContext = songGenerationContext ?? SongGenerationContext(project: project)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,15 +168,18 @@ public struct SongProjectUpdateRequest: Equatable, Sendable {
|
|||||||
public var context: AIRequestContext
|
public var context: AIRequestContext
|
||||||
public var project: SongProject
|
public var project: SongProject
|
||||||
public var allowedScopes: [SongProjectUpdateScope]
|
public var allowedScopes: [SongProjectUpdateScope]
|
||||||
|
public var songGenerationContext: SongGenerationContext
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
context: AIRequestContext,
|
context: AIRequestContext,
|
||||||
project: SongProject,
|
project: SongProject,
|
||||||
allowedScopes: [SongProjectUpdateScope]
|
allowedScopes: [SongProjectUpdateScope],
|
||||||
|
songGenerationContext: SongGenerationContext? = nil
|
||||||
) {
|
) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.project = project
|
self.project = project
|
||||||
self.allowedScopes = allowedScopes
|
self.allowedScopes = allowedScopes
|
||||||
|
self.songGenerationContext = songGenerationContext ?? SongGenerationContext(project: project)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,19 @@ public struct LocalInstrumentCatalog: Sendable {
|
|||||||
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var regionsOfOrigin: [String] {
|
||||||
|
Array(Set(items.compactMap(\.regionOrigin)))
|
||||||
|
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
public func instruments(
|
public func instruments(
|
||||||
matching query: String,
|
matching query: String,
|
||||||
familyCategory: String? = nil
|
familyCategory: String? = nil,
|
||||||
|
regionOrigin: String? = nil
|
||||||
) -> [InstrumentCatalogItem] {
|
) -> [InstrumentCatalogItem] {
|
||||||
let searchTokens = Self.searchTokens(for: query)
|
let searchTokens = Self.searchTokens(for: query)
|
||||||
let selectedFamilyCategory = familyCategory?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let selectedFamilyCategory = familyCategory?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let selectedRegionOrigin = regionOrigin?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
.filter { item in
|
.filter { item in
|
||||||
@@ -25,7 +32,8 @@ public struct LocalInstrumentCatalog: Sendable {
|
|||||||
Self.searchableTerms(for: item).contains { $0.contains(token) }
|
Self.searchableTerms(for: item).contains { $0.contains(token) }
|
||||||
}
|
}
|
||||||
let matchesFamily = selectedFamilyCategory?.isEmpty != false || item.familyCategory == selectedFamilyCategory
|
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 }
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,3 +13,159 @@ public struct CompiledSunoOutput: Equatable, Sendable {
|
|||||||
self.stylePrompt = stylePrompt
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" }
|
.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)
|
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 {
|
func testLoadsProjectListSortedByMostRecentUpdate() async throws {
|
||||||
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
|
let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL)
|
||||||
let older = SongProject(
|
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])
|
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() {
|
func testMultipleVocalistsCanBeAssignedToSections() {
|
||||||
var project = SongProject(
|
var project = SongProject(
|
||||||
id: "project-6",
|
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
@@ -57,6 +57,8 @@ retry delay when available, and propagates cancellation without retrying.
|
|||||||
|
|
||||||
A deterministic layer converts the approved SongProject into the final
|
A deterministic layer converts the approved SongProject into the final
|
||||||
Suno-facing lyrics/style content. User choices override AI suggestions.
|
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
|
## Arabic Pronunciation Processor
|
||||||
|
|
||||||
@@ -82,9 +84,15 @@ and explicitly apply the processed text before any Suno handoff.
|
|||||||
|
|
||||||
## Suno Boundary
|
## Suno Boundary
|
||||||
|
|
||||||
The initial requirement is browser/site handoff that fills fields but
|
The initial requirement is an app-managed web surface for the Suno
|
||||||
does not press Generate. Treat this integration as replaceable because
|
Create page in Custom mode. This avoids relying on a particular user
|
||||||
website UI/behavior can change.
|
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
|
## Instrument Selector Boundary
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ finalized.
|
|||||||
- Exact list of supported languages/dialects for MVP.
|
- Exact list of supported languages/dialects for MVP.
|
||||||
- Arabic diacritization policy: full tashkeel vs
|
- Arabic diacritization policy: full tashkeel vs
|
||||||
pronunciation-targeted tashkeel.
|
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
|
- Whether Suno integration is permitted/reliable under the intended
|
||||||
account/workflow and current terms.
|
account/workflow and current terms.
|
||||||
- Project storage technology and whether cloud sync is required later.
|
- Project storage technology and whether cloud sync is required later.
|
||||||
|
|||||||
+11
-4
@@ -19,8 +19,11 @@ instructions for Suno.
|
|||||||
4. User reviews and manually edits every generated component.
|
4. User reviews and manually edits every generated component.
|
||||||
5. Project versions are saved.
|
5. Project versions are saved.
|
||||||
6. User presses **Send to Suno**.
|
6. User presses **Send to Suno**.
|
||||||
7. The app opens Suno and fills the appropriate fields. It does not
|
7. The app presents the Suno Create page in an app-managed web surface
|
||||||
trigger Generate.
|
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
|
## Song Controls
|
||||||
|
|
||||||
@@ -92,5 +95,9 @@ Normal users cannot view or modify these rules in the initial version.
|
|||||||
## Initial Boundary
|
## Initial Boundary
|
||||||
|
|
||||||
Suno remains responsible for music generation and playback. The macOS
|
Suno remains responsible for music generation and playback. The macOS
|
||||||
app prepares the project and fills Suno fields only. Generated songs do
|
app opens the browser-based Suno Create page in Custom mode and fills
|
||||||
not need to return to the app in the initial version.
|
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
@@ -84,36 +84,36 @@ requirement is missing and blocks implementation, record it in
|
|||||||
- [x] Keep catalog data out of SwiftUI views.
|
- [x] Keep catalog data out of SwiftUI views.
|
||||||
- [x] Build searchable instrument browser.
|
- [x] Build searchable instrument browser.
|
||||||
- [x] Add browsing/filtering by family/category.
|
- [x] Add browsing/filtering by family/category.
|
||||||
- [ ] Add browsing/filtering by region/origin where useful.
|
- [x] Add browsing/filtering by region/origin where useful.
|
||||||
- [ ] Add checkbox-based multi-select and deselect behavior.
|
- [x] Add checkbox-based multi-select and deselect behavior.
|
||||||
- [ ] Persist selected instruments on the current Song Project.
|
- [x] Persist selected instruments on the current Song Project.
|
||||||
- [ ] Make selected instruments available to OpenAI/song-generation
|
- [x] Make selected instruments available to OpenAI/song-generation
|
||||||
logic for arrangement, roles, entry/exit timing, relevant structure
|
logic for arrangement, roles, entry/exit timing, relevant structure
|
||||||
decisions and Suno Style Prompt generation.
|
decisions and Suno Style Prompt generation.
|
||||||
- [ ] Preserve existing Manual/Auto arrangement behavior.
|
- [x] Preserve existing Manual/Auto arrangement behavior.
|
||||||
- [ ] Add tests for catalog search, category/region filtering,
|
- [x] Add tests for catalog search, category/region filtering,
|
||||||
selection persistence and SongProject serialization.
|
selection persistence and SongProject serialization.
|
||||||
|
|
||||||
## Phase 7 --- Prompt Compiler
|
## Phase 7 --- Prompt Compiler
|
||||||
|
|
||||||
- [ ] Create deterministic compiler from approved SongProject → Suno
|
- [x] Create deterministic compiler from approved SongProject → Suno
|
||||||
output.
|
output.
|
||||||
- [ ] Generate lyrics text with section/performance directives where
|
- [x] Generate lyrics text with section/performance directives where
|
||||||
appropriate.
|
appropriate.
|
||||||
- [ ] Generate style prompt from genre blend, instrumentation, vocals,
|
- [x] Generate style prompt from genre blend, instrumentation, vocals,
|
||||||
tempo, harmony, emotion and production instructions.
|
tempo, harmony, emotion and production instructions.
|
||||||
- [ ] Validate output before handoff.
|
- [x] Validate output before handoff.
|
||||||
- [ ] Keep compiler independent from UI and OpenAI client.
|
- [x] Keep compiler independent from UI and OpenAI client.
|
||||||
|
|
||||||
## Phase 8 --- Suno Handoff
|
## Phase 8 --- Suno Handoff
|
||||||
|
|
||||||
- [ ] Implement explicit `Send to Suno` action.
|
- [x] Implement explicit `Send to Suno` action.
|
||||||
- [ ] Open the appropriate Suno creation surface.
|
- [x] Open the appropriate Suno creation surface.
|
||||||
- [ ] Fill supported fields with approved project output.
|
- [x] Fill supported fields with approved project output.
|
||||||
- [ ] Never trigger Generate automatically.
|
- [x] Never trigger Generate automatically.
|
||||||
- [ ] Detect/report when fields cannot be filled rather than silently
|
- [x] Detect/report when fields cannot be filled rather than silently
|
||||||
failing.
|
failing.
|
||||||
- [ ] Keep integration isolated because Suno UI can change.
|
- [x] Keep integration isolated because Suno UI can change.
|
||||||
|
|
||||||
## Phase 9 --- Quality
|
## Phase 9 --- Quality
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user