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 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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user