Remove external music generation handoff

This commit is contained in:
diyaa
2026-09-15 12:16:08 +02:00
parent cd066d0a82
commit 1ebb5aadc3
11 changed files with 22 additions and 409 deletions
@@ -1,141 +0,0 @@
import MusicAssistantCore
import SwiftUI
import WebKit
struct SunoHandoffSheet: View {
let payload: SunoHandoffPayload
let onComplete: (SunoFieldFillReport) -> Void
@Environment(\.dismiss) private var dismiss
@State private var report: SunoFieldFillReport?
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Send to Suno")
.font(.headline)
Spacer()
Button("Close") {
dismiss()
}
}
.padding(12)
Divider()
SunoCreateWebView(payload: payload) { report in
self.report = report
onComplete(report)
}
if let report {
HStack {
Label(statusText(for: report), systemImage: statusIcon(for: report))
.font(.caption)
.foregroundStyle(statusColor(for: report))
Spacer()
}
.padding(12)
}
}
.frame(minWidth: 900, minHeight: 650)
}
private func statusText(for report: SunoFieldFillReport) -> String {
report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
? "Some fields need manual copying."
: "Suno fields are ready."
}
private func statusIcon(for report: SunoFieldFillReport) -> String {
report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
? "exclamationmark.triangle.fill"
: "checkmark.circle.fill"
}
private func statusColor(for report: SunoFieldFillReport) -> Color {
report.results.values.contains { if case .copyRequired = $0 { return true }; return false }
? .orange
: .green
}
}
private struct SunoCreateWebView: NSViewRepresentable {
let payload: SunoHandoffPayload
let onReport: (SunoFieldFillReport) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(payload: payload, onReport: onReport)
}
func makeNSView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default()
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: SunoHandoffDestination.createPageURL))
return webView
}
func updateNSView(_ webView: WKWebView, context: Context) {}
final class Coordinator: NSObject, WKNavigationDelegate {
let payload: SunoHandoffPayload
let onReport: (SunoFieldFillReport) -> Void
init(payload: SunoHandoffPayload, onReport: @escaping (SunoFieldFillReport) -> Void) {
self.payload = payload
self.onReport = onReport
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
webView.evaluateJavaScript(fillScript()) { value, _ in
guard let json = value as? String,
let data = json.data(using: .utf8),
let values = try? JSONSerialization.jsonObject(with: data) as? [String: Bool] else {
self.onReport(.copyRequired(for: SunoHandoffField.allCases, reason: "Suno fields could not be inspected."))
return
}
let results: [SunoHandoffField: SunoFieldFillResult] = Dictionary(
uniqueKeysWithValues: SunoHandoffField.allCases.map { field in
(field, values[field.rawValue] == true
? SunoFieldFillResult.filled
: SunoFieldFillResult.copyRequired("The \(field.displayName) field was not found."))
}
)
self.onReport(SunoFieldFillReport(results: results))
}
}
private func fillScript() -> String {
let payloadData = try! JSONEncoder().encode(payload)
let payloadJSON = String(data: payloadData, encoding: .utf8)!
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
return """
(() => {
const payload = JSON.parse('\(payloadJSON)');
const fields = { title: false, lyrics: false, stylePrompt: false };
const custom = [...document.querySelectorAll('button,[role="button"]')].find(e => /custom/i.test(e.innerText));
if (custom) custom.click();
const candidates = [...document.querySelectorAll('input,textarea,[contenteditable="true"]')];
const label = e => [e.getAttribute('aria-label'), e.getAttribute('placeholder'), e.name, e.id].filter(Boolean).join(' ');
const set = (terms, value, key) => {
const element = candidates.find(e => terms.some(term => label(e).toLowerCase().includes(term)));
if (!element || !value) return;
if (element.isContentEditable) element.textContent = value; else element.value = value;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
fields[key] = true;
};
set(['title'], payload.title, 'title');
set(['lyric'], payload.lyrics, 'lyrics');
set(['style','prompt'], payload.stylePrompt, 'stylePrompt');
return JSON.stringify(fields);
})();
"""
}
}
}
@@ -1,4 +1,3 @@
import AppKit
import MusicAssistantCore import MusicAssistantCore
import SwiftUI import SwiftUI
@@ -7,8 +6,6 @@ struct FinalReviewView: View {
@State private var newFieldKey = "" @State private var newFieldKey = ""
@State private var newFieldValue = "" @State private var newFieldValue = ""
@State private var handoffStatus: HandoffStatus?
@State private var sunoPayload: SunoHandoffPayload?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -34,7 +31,7 @@ struct FinalReviewView: View {
Divider() Divider()
HStack { HStack {
Text("Suno fields") Text("Final song output")
.font(.headline) .font(.headline)
Spacer() Spacer()
@@ -46,31 +43,11 @@ struct FinalReviewView: View {
} }
.disabled(project.sunoOutput != nil) .disabled(project.sunoOutput != nil)
Button(action: sendToSuno) {
Label("Send to Suno", systemImage: "arrow.up.forward.app")
}
} }
if let handoffStatus { TextField("Lyrics", text: sunoLyricsBinding, axis: .vertical)
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)
.lineLimit(4...10) .lineLimit(4...10)
TextField("Suno style prompt", text: sunoStylePromptBinding, axis: .vertical) TextField("Style prompt", text: sunoStylePromptBinding, axis: .vertical)
.lineLimit(3...8) .lineLimit(3...8)
if !additionalFieldKeys.isEmpty { if !additionalFieldKeys.isEmpty {
@@ -110,16 +87,6 @@ struct FinalReviewView: View {
.help("Add field") .help("Add field")
} }
} }
.sheet(isPresented: Binding(
get: { sunoPayload != nil },
set: { if !$0 { sunoPayload = nil } }
)) {
if let sunoPayload {
SunoHandoffSheet(payload: sunoPayload) { report in
handoffStatus = .fieldFill(report)
}
}
}
} }
private var additionalFieldKeys: [String] { private var additionalFieldKeys: [String] {
@@ -200,95 +167,6 @@ struct FinalReviewView: View {
) )
} }
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
}
sunoPayload = SunoHandoffPayload(
title: project.title,
lyrics: output.lyricsText,
stylePrompt: output.stylePrompt
)
}
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 {
@@ -6,6 +6,6 @@ public struct ProductBoundarySummary: Equatable, Sendable {
} }
public static let initial = ProductBoundarySummary( public static let initial = ProductBoundarySummary(
description: "Create and refine song project concepts, then prepare lyrics and style instructions for Suno handoff without starting generation." description: "Create and refine song project concepts, then prepare final lyrics and style instructions."
) )
} }
@@ -1,72 +0,0 @@
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: Codable, 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
}
@@ -10,7 +10,7 @@ public enum SunoOutputValidationError: Error, Equatable, LocalizedError, Sendabl
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
case .missingContent: case .missingContent:
return "Add lyrics or a style prompt before sending the song to Suno." return "Add lyrics or a style prompt before finalizing the song output."
} }
} }
} }
@@ -2,10 +2,10 @@ import MusicAssistantCore
import XCTest import XCTest
final class ProductBoundarySummaryTests: XCTestCase { final class ProductBoundarySummaryTests: XCTestCase {
func testInitialBoundaryKeepsSunoGenerationOutsideTheApp() { func testInitialBoundaryExcludesExternalGenerationControl() {
let summary = ProductBoundarySummary.initial.description let summary = ProductBoundarySummary.initial.description
XCTAssertTrue(summary.contains("Suno")) XCTAssertTrue(summary.contains("final lyrics and style instructions"))
XCTAssertTrue(summary.contains("without starting generation")) XCTAssertFalse(summary.contains("Suno"))
} }
} }
@@ -1,20 +0,0 @@
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")
}
}
+4 -12
View File
@@ -29,8 +29,7 @@ macOS App
│ ├── Project Versioning │ ├── Project Versioning
│ └── Validation │ └── Validation
├── Integrations ├── Integrations
│ ├── OpenAI │ └── OpenAI
│ └── Suno Field-Fill Integration
└── Persistence └── Persistence
└── Local project/version storage └── Local project/version storage
``` ```
@@ -82,17 +81,10 @@ and explicitly apply the processed text before any Suno handoff.
and providers can be changed later. and providers can be changed later.
- Do not log secrets or full authorization headers. - Do not log secrets or full authorization headers.
## Suno Boundary ## External Generation Boundary
The initial requirement is an app-managed web surface for the Suno The application prepares final lyrics and style prompts but does not
Create page in Custom mode. This avoids relying on a particular user open, embed or control an external music-generation service.
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
+1 -7
View File
@@ -15,13 +15,7 @@ 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.
- Resolved: Suno handoff presents the Create page in an app-managed web - External music-generation handoff is not in the current scope.
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. - Project storage technology and whether cloud sync is required later.
- Whether users supply their own OpenAI/API credentials or the product - Whether users supply their own OpenAI/API credentials or the product
owner supplies service credentials. owner supplies service credentials.
+4 -16
View File
@@ -4,8 +4,7 @@
A native macOS application that helps users design a complete song A native macOS application that helps users design a complete song
concept through a natural chat interface, using OpenAI as the planning concept through a natural chat interface, using OpenAI as the planning
and writing engine, then prepares the final lyrics and style and writing engine, then prepares final lyrics and style instructions.
instructions for Suno.
## Core Flow ## Core Flow
@@ -18,12 +17,6 @@ instructions for Suno.
decisions. decisions.
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**.
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 ## Song Controls
@@ -76,7 +69,7 @@ views. Each catalog entry should support structured metadata:
Selected instruments must be available as part of the Song Project and Selected instruments must be available as part of the Song Project and
later available to the OpenAI/song-generation layer so they can later available to the OpenAI/song-generation layer so they can
influence arrangement, instrument roles, entry/exit timing, song influence arrangement, instrument roles, entry/exit timing, song
structure where relevant and the Suno Style Prompt. structure where relevant and the final style prompt.
Instrument images, audio previews, sound samples, audio playback, Instrument images, audio previews, sound samples, audio playback,
Freesound, an Instrument API and external API calls for instrument data Freesound, an Instrument API and external API calls for instrument data
@@ -94,10 +87,5 @@ 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 The macOS app prepares and stores the final lyrics and style prompt only.
app opens the browser-based Suno Create page in Custom mode and fills It does not open, embed or control an external music-generation website.
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.
+5 -11
View File
@@ -105,15 +105,10 @@ requirement is missing and blocks implementation, record it in
- [x] Validate output before handoff. - [x] Validate output before handoff.
- [x] Keep compiler independent from UI and OpenAI client. - [x] Keep compiler independent from UI and OpenAI client.
## Phase 8 --- Suno Handoff ## Removed --- External Generation Handoff
- [x] Implement explicit `Send to Suno` action. - [x] Removed opening, embedding and controlling external
- [x] Open the appropriate Suno creation surface. music-generation websites.
- [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.
- [x] Keep integration isolated because Suno UI can change.
## Phase 9 --- Quality ## Phase 9 --- Quality
@@ -124,12 +119,11 @@ requirement is missing and blocks implementation, record it in
- [ ] Test AI output against locked user choices. - [ ] Test AI output against locked user choices.
- [ ] Test selected instruments influence compiled song-generation - [ ] Test selected instruments influence compiled song-generation
context. context.
- [ ] Test Suno handoff without generation.
## Definition of MVP Done ## Definition of MVP Done
A user can create a macOS song project through chat, manually or A user can create a macOS song project through chat, manually or
automatically configure the agreed song parameters, generate/edit lyrics automatically configure the agreed song parameters, generate/edit lyrics
and song planning through OpenAI, select instruments, review and song planning through OpenAI, select instruments, review
the complete project, save versions, and send the approved lyrics/style the complete project, save versions, and retain the approved final
data to Suno where the app fills fields without initiating generation. lyrics and style prompt in the project.