diff --git a/Sources/MusicAssistantApp/Integrations/SunoHandoffSheet.swift b/Sources/MusicAssistantApp/Integrations/SunoHandoffSheet.swift deleted file mode 100644 index fd3fd69..0000000 --- a/Sources/MusicAssistantApp/Integrations/SunoHandoffSheet.swift +++ /dev/null @@ -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); - })(); - """ - } - } -} diff --git a/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift b/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift index 30df502..7ad5890 100644 --- a/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift +++ b/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift @@ -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 { diff --git a/Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift b/Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift index 6cf9515..5ed1bb2 100644 --- a/Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift +++ b/Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift @@ -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." ) } diff --git a/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift b/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift deleted file mode 100644 index 04c46c2..0000000 --- a/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift +++ /dev/null @@ -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 -} diff --git a/Sources/MusicAssistantCore/Services/PromptCompiler/SunoOutputValidation.swift b/Sources/MusicAssistantCore/Services/PromptCompiler/SunoOutputValidation.swift index 775cc41..7566419 100644 --- a/Sources/MusicAssistantCore/Services/PromptCompiler/SunoOutputValidation.swift +++ b/Sources/MusicAssistantCore/Services/PromptCompiler/SunoOutputValidation.swift @@ -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." } } } diff --git a/Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift b/Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift index c7e2468..16d60f5 100644 --- a/Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift +++ b/Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift @@ -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")) } } diff --git a/Tests/MusicAssistantCoreTests/SunoHandoffDestinationTests.swift b/Tests/MusicAssistantCoreTests/SunoHandoffDestinationTests.swift deleted file mode 100644 index a344439..0000000 --- a/Tests/MusicAssistantCoreTests/SunoHandoffDestinationTests.swift +++ /dev/null @@ -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") - } -} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 31fe0fb..1fcb4be 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md index 9bfb38c..ee23e28 100644 --- a/docs/OPEN_QUESTIONS.md +++ b/docs/OPEN_QUESTIONS.md @@ -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. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 05d7e14..0b43b63 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -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. diff --git a/docs/TASKS.md b/docs/TASKS.md index d3eb305..80c14cc 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -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.