Remove external music generation handoff
This commit is contained in:
@@ -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 SwiftUI
|
||||
|
||||
@@ -7,8 +6,6 @@ struct FinalReviewView: View {
|
||||
|
||||
@State private var newFieldKey = ""
|
||||
@State private var newFieldValue = ""
|
||||
@State private var handoffStatus: HandoffStatus?
|
||||
@State private var sunoPayload: SunoHandoffPayload?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -34,7 +31,7 @@ struct FinalReviewView: View {
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Text("Suno fields")
|
||||
Text("Final song output")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
@@ -46,31 +43,11 @@ struct FinalReviewView: View {
|
||||
}
|
||||
.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("Lyrics", text: sunoLyricsBinding, axis: .vertical)
|
||||
.lineLimit(4...10)
|
||||
TextField("Suno style prompt", text: sunoStylePromptBinding, axis: .vertical)
|
||||
TextField("Style prompt", text: sunoStylePromptBinding, axis: .vertical)
|
||||
.lineLimit(3...8)
|
||||
|
||||
if !additionalFieldKeys.isEmpty {
|
||||
@@ -110,16 +87,6 @@ struct FinalReviewView: View {
|
||||
.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] {
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,6 +6,6 @@ public struct ProductBoundarySummary: Equatable, Sendable {
|
||||
}
|
||||
|
||||
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? {
|
||||
switch self {
|
||||
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
|
||||
|
||||
final class ProductBoundarySummaryTests: XCTestCase {
|
||||
func testInitialBoundaryKeepsSunoGenerationOutsideTheApp() {
|
||||
func testInitialBoundaryExcludesExternalGenerationControl() {
|
||||
let summary = ProductBoundarySummary.initial.description
|
||||
|
||||
XCTAssertTrue(summary.contains("Suno"))
|
||||
XCTAssertTrue(summary.contains("without starting generation"))
|
||||
XCTAssertTrue(summary.contains("final lyrics and style instructions"))
|
||||
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
@@ -29,8 +29,7 @@ macOS App
|
||||
│ ├── Project Versioning
|
||||
│ └── Validation
|
||||
├── Integrations
|
||||
│ ├── OpenAI
|
||||
│ └── Suno Field-Fill Integration
|
||||
│ └── OpenAI
|
||||
└── Persistence
|
||||
└── Local project/version storage
|
||||
```
|
||||
@@ -82,17 +81,10 @@ and explicitly apply the processed text before any Suno handoff.
|
||||
and providers can be changed later.
|
||||
- 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
|
||||
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.
|
||||
The application prepares final lyrics and style prompts but does not
|
||||
open, embed or control an external music-generation service.
|
||||
|
||||
## Instrument Selector Boundary
|
||||
|
||||
|
||||
@@ -15,13 +15,7 @@ finalized.
|
||||
- Exact list of supported languages/dialects for MVP.
|
||||
- Arabic diacritization policy: full tashkeel vs
|
||||
pronunciation-targeted tashkeel.
|
||||
- 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.
|
||||
- External music-generation handoff is not in the current scope.
|
||||
- Project storage technology and whether cloud sync is required later.
|
||||
- Whether users supply their own OpenAI/API credentials or the product
|
||||
owner supplies service credentials.
|
||||
|
||||
+4
-16
@@ -4,8 +4,7 @@
|
||||
|
||||
A native macOS application that helps users design a complete song
|
||||
concept through a natural chat interface, using OpenAI as the planning
|
||||
and writing engine, then prepares the final lyrics and style
|
||||
instructions for Suno.
|
||||
and writing engine, then prepares final lyrics and style instructions.
|
||||
|
||||
## Core Flow
|
||||
|
||||
@@ -18,12 +17,6 @@ instructions for Suno.
|
||||
decisions.
|
||||
4. User reviews and manually edits every generated component.
|
||||
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
|
||||
|
||||
@@ -76,7 +69,7 @@ views. Each catalog entry should support structured metadata:
|
||||
Selected instruments must be available as part of the Song Project and
|
||||
later available to the OpenAI/song-generation layer so they can
|
||||
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,
|
||||
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
|
||||
|
||||
Suno remains responsible for music generation and playback. The macOS
|
||||
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.
|
||||
The macOS app prepares and stores the final lyrics and style prompt only.
|
||||
It does not open, embed or control an external music-generation website.
|
||||
|
||||
+5
-11
@@ -105,15 +105,10 @@ requirement is missing and blocks implementation, record it in
|
||||
- [x] Validate output before handoff.
|
||||
- [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] 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.
|
||||
- [x] Keep integration isolated because Suno UI can change.
|
||||
- [x] Removed opening, embedding and controlling external
|
||||
music-generation websites.
|
||||
|
||||
## Phase 9 --- Quality
|
||||
|
||||
@@ -124,12 +119,11 @@ requirement is missing and blocks implementation, record it in
|
||||
- [ ] Test AI output against locked user choices.
|
||||
- [ ] Test selected instruments influence compiled song-generation
|
||||
context.
|
||||
- [ ] Test Suno handoff without generation.
|
||||
|
||||
## Definition of MVP Done
|
||||
|
||||
A user can create a macOS song project through chat, manually or
|
||||
automatically configure the agreed song parameters, generate/edit lyrics
|
||||
and song planning through OpenAI, select instruments, review
|
||||
the complete project, save versions, and send the approved lyrics/style
|
||||
data to Suno where the app fills fields without initiating generation.
|
||||
the complete project, save versions, and retain the approved final
|
||||
lyrics and style prompt in the project.
|
||||
|
||||
Reference in New Issue
Block a user