commit 18d7d56345d15b771c8cdb50c5c5b5dbed8aa0e2 Author: diyaa Date: Sun Sep 13 20:21:10 2026 +0200 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2b046ac --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Local development only. Do not commit real values. +MUSIC_ASSISTANT_OPENAI_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b0e8d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.build/ +.swiftpm/ +DerivedData/ +.DS_Store +.env +*.xcuserdata +*.xcuserstate diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d5f34e7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,68 @@ +# Agent Guide + +This file is the permanent operating guide for any coding agent working on this +project. + +## Project Scope + +- This project is a native macOS application. +- The minimum deployment target is macOS 14.0. +- Use Swift and SwiftUI according to the architecture documented for this + project. +- Do not invent undocumented features or requirements. +- Any substantial change to the architecture or product scope must be documented + before implementation. + +## Required Reading + +Before making significant changes, read: + +- `docs/PRODUCT.md` +- `docs/ARCHITECTURE.md` +- `docs/DATA_MODEL.md` +- `docs/TASKS.md` +- `docs/OPEN_QUESTIONS.md` + +Use these documents as the project references: + +- `docs/PRODUCT.md` is the source of truth for product requirements. +- `docs/ARCHITECTURE.md` is the reference for architecture decisions. +- `docs/DATA_MODEL.md` is the reference for data models. +- `docs/TASKS.md` is the reference for implementation phases and tasks. +- `docs/OPEN_QUESTIONS.md` contains decisions that are not resolved yet. + +## Decision Rules + +- If documents conflict, do not guess. State the conflict before making a + decision. +- Do not decide anything listed in `docs/OPEN_QUESTIONS.md` on your own. +- Do not automatically move to a new phase in `docs/TASKS.md` unless explicitly + asked. +- Update `docs/TASKS.md` when tasks are completed. +- Preserve version history and the Song Project concept as documented. + +## Code Standards + +- Write organized code that is maintainable and extensible. +- Keep clear separation between UI, Models, Services, Persistence, and + Integrations. +- Keep OpenAI, instrument catalog/selector, and Suno integrations separated from + UI logic as much as possible. +- In the current scope, Suno handoff fills the fields only and does not press + Generate. +- Do not modify files outside the task scope without a clear reason. +- Do not perform a broad refactor while implementing a small task unless it is + explicitly required. + +## Secrets And Configuration + +- Do not place API keys, tokens, or secrets in source code or Git. +- Read secrets only from secure environment or configuration mechanisms. +- Do not log secrets. + +## Verification + +- Run a build after code changes. +- Run relevant tests when they are available. +- Do not consider a task complete if the project fails to build because of new + changes. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..544bdb3 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,23 @@ +# Development Setup + +## Secrets + +Runtime secrets are read from the local process environment. + +Copy `.env.example` to `.env` for local notes if needed, but do not commit +real credentials. The application must not hard-code API keys or authorization +headers in source files. + +Current environment keys: + +- `MUSIC_ASSISTANT_OPENAI_API_KEY` + +Provider integrations are intentionally protocol-only in the repository +foundation phase. + +## Packaging + +Run `make install-app` to build the release executable, package it as +`AI Music Studio.app`, install it in `/Applications`, and launch it. + +Set `APP_ICON=/path/to/icon.icns` when packaging with a custom icon. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..19ea76c --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: build dev test install-app + +build: + swift build + +dev: + swift run music-assistant + +test: + swift test + +install-app: + scripts/build-install-launch.zsh diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..b2c40a8 --- /dev/null +++ b/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "music-assistant", + defaultLocalization: "en", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable( + name: "music-assistant", + targets: ["MusicAssistantApp"] + ), + .library( + name: "MusicAssistantCore", + targets: ["MusicAssistantCore"] + ) + ], + targets: [ + .executableTarget( + name: "MusicAssistantApp", + dependencies: ["MusicAssistantCore"], + path: "Sources/MusicAssistantApp" + ), + .target( + name: "MusicAssistantCore", + path: "Sources/MusicAssistantCore" + ), + .testTarget( + name: "MusicAssistantCoreTests", + dependencies: ["MusicAssistantCore"], + path: "Tests/MusicAssistantCoreTests" + ) + ] +) diff --git a/Sources/MusicAssistantApp/MusicAssistantApp.swift b/Sources/MusicAssistantApp/MusicAssistantApp.swift new file mode 100644 index 0000000..039bb7d --- /dev/null +++ b/Sources/MusicAssistantApp/MusicAssistantApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct MusicAssistantApp: App { + var body: some Scene { + WindowGroup { + RootView() + } + .windowStyle(.titleBar) + } +} diff --git a/Sources/MusicAssistantApp/Presentation/ArrangementEditorView.swift b/Sources/MusicAssistantApp/Presentation/ArrangementEditorView.swift new file mode 100644 index 0000000..49e91c5 --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/ArrangementEditorView.swift @@ -0,0 +1,193 @@ +import MusicAssistantCore +import SwiftUI + +struct ArrangementEditorView: View { + @Binding var project: SongProject + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + let selectedInstrumentIndices = project.instruments.indices.filter { project.instruments[$0].selected } + + if selectedInstrumentIndices.isEmpty { + Text("Select instruments before arranging entries.") + .foregroundStyle(.secondary) + } else { + ForEach(selectedInstrumentIndices, id: \.self) { index in + InstrumentArrangementCard( + instrument: $project.instruments[index], + sections: project.orderedSections + ) + } + } + } + } +} + +private struct InstrumentArrangementCard: View { + @Binding var instrument: InstrumentTrack + let sections: [SongSection] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(instrumentDisplayName) + .font(.headline) + .lineLimit(1) + + Spacer() + + Button(action: addPlacement) { + Label("Add placement", systemImage: "plus.circle.fill") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .help("Add placement") + } + + Toggle("Auto arrangement", isOn: $instrument.autoArrangementEnabled) + TextField("Role", text: optionalStringBinding(\.role)) + TextField("Playing style", text: optionalStringBinding(\.playingStyle)) + + if instrument.placements.isEmpty { + Text("No placements yet") + .foregroundStyle(.secondary) + } else { + ForEach(instrument.placements.indices, id: \.self) { placementIndex in + InstrumentPlacementEditor( + placement: $instrument.placements[placementIndex], + sections: sections, + removeAction: { + instrument.placements.remove(at: placementIndex) + } + ) + } + } + } + .padding(10) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + } + + private var instrumentDisplayName: String { + if let variant = instrument.variant, !variant.isEmpty { + return variant + } + + return instrument.instrumentId + } + + private func addPlacement() { + instrument.placements.append( + InstrumentPlacement(sectionId: sections.first?.id) + ) + } + + private func optionalStringBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding { + instrument[keyPath: keyPath] ?? "" + } set: { newValue in + let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + instrument[keyPath: keyPath] = trimmedValue.isEmpty ? nil : trimmedValue + } + } +} + +private struct InstrumentPlacementEditor: View { + @Binding var placement: InstrumentPlacement + let sections: [SongSection] + let removeAction: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Picker("Section", selection: sectionIDBinding) { + Text("No section").tag("") + ForEach(sections) { section in + Text(sectionLabel(for: section)).tag(section.id) + } + } + + Spacer() + + Button(action: removeAction) { + Label("Remove placement", systemImage: "trash") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .foregroundStyle(.red) + .help("Remove placement") + } + + TextField("Start seconds", text: optionalTimeBinding(\.startTime)) + TextField("End seconds", text: optionalTimeBinding(\.endTime)) + TextField("Direction", text: optionalStringBinding(\.direction), axis: .vertical) + .lineLimit(1...3) + } + .padding(8) + .background(Color(nsColor: .windowBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + } + + private var sectionIDBinding: Binding { + Binding { + placement.sectionId ?? "" + } set: { newValue in + placement.sectionId = newValue.isEmpty ? nil : newValue + } + } + + private func optionalTimeBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding { + guard let value = placement[keyPath: keyPath] else { return "" } + return String(format: "%.0f", value) + } set: { newValue in + let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + placement[keyPath: keyPath] = trimmedValue.isEmpty ? nil : TimeInterval(trimmedValue) + } + } + + private func optionalStringBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding { + placement[keyPath: keyPath] ?? "" + } set: { newValue in + let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + placement[keyPath: keyPath] = trimmedValue.isEmpty ? nil : trimmedValue + } + } + + private func sectionLabel(for section: SongSection) -> String { + "\(section.order + 1). \(section.title) (\(section.type.displayName))" + } +} + +#Preview { + ArrangementEditorPreview() +} + +private struct ArrangementEditorPreview: View { + @State private var project = SongProject( + title: "Arrangement Preview", + idea: "Place instruments", + sections: [ + SongSection(type: .intro, title: "Intro"), + SongSection(type: .verse, title: "Verse", order: 1) + ], + instruments: [ + InstrumentTrack( + instrumentId: "piano", + selected: true, + variant: "Piano", + role: "Harmony bed", + placements: [ + InstrumentPlacement(sectionId: nil, startTime: 0, endTime: 12, direction: "Soft opening") + ] + ) + ] + ) + + var body: some View { + Form { + ArrangementEditorView(project: $project) + } + .formStyle(.grouped) + .frame(width: 380) + } +} diff --git a/Sources/MusicAssistantApp/Presentation/ChatWorkspaceView.swift b/Sources/MusicAssistantApp/Presentation/ChatWorkspaceView.swift new file mode 100644 index 0000000..dde4050 --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/ChatWorkspaceView.swift @@ -0,0 +1,216 @@ +import MusicAssistantCore +import SwiftUI + +struct ChatWorkspaceView: View { + @State private var project: SongProject + let saveProject: (SongProject) async -> Bool + + @State private var messages: [ChatWorkspaceMessage] + @State private var draftMessage = "" + @FocusState private var isComposerFocused: Bool + + init(project: SongProject, saveProject: @escaping (SongProject) async -> Bool = { _ in true }) { + _project = State(initialValue: project) + self.saveProject = saveProject + _messages = State(initialValue: Self.initialMessages(for: project)) + } + + var body: some View { + HSplitView { + VStack(spacing: 0) { + ChatWorkspaceHeader(project: project) + + Divider() + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + ForEach(messages) { message in + ChatMessageBubble(message: message) + .id(message.id) + } + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(.background) + .onChange(of: messages.count) { _, _ in + guard let lastMessageID = messages.last?.id else { return } + withAnimation(.snappy) { + proxy.scrollTo(lastMessageID, anchor: .bottom) + } + } + } + + Divider() + + ChatComposer( + text: $draftMessage, + isFocused: $isComposerFocused, + sendAction: sendMessage + ) + .padding(16) + } + .frame(minWidth: 520, maxWidth: .infinity, maxHeight: .infinity) + + ProjectInspectorView(project: $project, saveProject: saveProject) + .frame(minWidth: 300, idealWidth: 340, maxWidth: 420, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + isComposerFocused = true + } + } + + private func sendMessage() { + let trimmedMessage = draftMessage.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedMessage.isEmpty else { return } + + messages.append(ChatWorkspaceMessage(role: .user, text: trimmedMessage)) + draftMessage = "" + } + + private static func initialMessages(for project: SongProject) -> [ChatWorkspaceMessage] { + let prompt: String + + if project.idea.isEmpty { + prompt = "What kind of song are we making?" + } else { + prompt = project.idea + } + + return [ + ChatWorkspaceMessage(role: .assistant, text: prompt) + ] + } +} + +private struct ChatWorkspaceHeader: View { + let project: SongProject + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text(project.title) + .font(.title.weight(.semibold)) + .lineLimit(1) + + Text("Song Project") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack(spacing: 10) { + ProjectMetricLabel(title: "Versions", value: project.versions.count, systemImage: "clock.arrow.circlepath") + ProjectMetricLabel(title: "Genres", value: project.genres.count, systemImage: "music.quarternote.3") + ProjectMetricLabel(title: "Instruments", value: project.selectedInstrumentTracks.count, systemImage: "pianokeys") + ProjectMetricLabel(title: "Sections", value: project.sections.count, systemImage: "text.line.first.and.arrowtriangle.forward") + ProjectMetricLabel(title: "Vocalists", value: project.vocalists.count, systemImage: "person.wave.2") + } + } + .padding(.horizontal, 24) + .padding(.vertical, 18) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct ProjectMetricLabel: View { + let title: String + let value: Int + let systemImage: String + + var body: some View { + Label { + Text("\(value) \(title)") + } icon: { + Image(systemName: systemImage) + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .labelStyle(.titleAndIcon) + } +} + +private struct ChatMessageBubble: View { + let message: ChatWorkspaceMessage + + var body: some View { + HStack { + if message.role == .user { + Spacer(minLength: 64) + } + + Text(message.text) + .font(.body) + .foregroundStyle(message.role == .user ? .white : .primary) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(message.role == .user ? Color.accentColor : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + .frame(maxWidth: 620, alignment: message.role == .user ? .trailing : .leading) + + if message.role == .assistant { + Spacer(minLength: 64) + } + } + .frame(maxWidth: .infinity, alignment: message.role == .user ? .trailing : .leading) + } +} + +private struct ChatComposer: View { + @Binding var text: String + var isFocused: FocusState.Binding + let sendAction: () -> Void + + var body: some View { + HStack(alignment: .bottom, spacing: 10) { + TextField("Describe the song idea, lyrics, mood, or arrangement", text: $text, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...5) + .focused(isFocused) + .onSubmit(sendAction) + + Button(action: sendAction) { + Label("Send", systemImage: "paperplane.fill") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderedProminent) + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .help("Send") + } + } +} + +private struct ChatWorkspaceMessage: Identifiable, Equatable { + enum Role: Equatable { + case assistant + case user + } + + let id: UUID + let role: Role + let text: String + + init(id: UUID = UUID(), role: Role, text: String) { + self.id = id + self.role = role + self.text = text + } +} + +#Preview { + ChatWorkspaceView( + project: SongProject( + title: "Untitled Song", + idea: "A cinematic Arabic pop song with oud, violin, and a hopeful chorus." + ) + ) +} diff --git a/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift b/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift new file mode 100644 index 0000000..39d01c5 --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/FinalReviewView.swift @@ -0,0 +1,190 @@ +import MusicAssistantCore +import SwiftUI + +struct FinalReviewView: View { + @Binding var project: SongProject + + @State private var newFieldKey = "" + @State private var newFieldValue = "" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + TextField("Approved lyrics", text: $project.lyrics.text, axis: .vertical) + .lineLimit(4...10) + + if !project.orderedSections.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Section lyrics") + .font(.headline) + + ForEach(project.orderedSections) { section in + TextField(section.title, text: sectionLyricsBinding(for: section.id), axis: .vertical) + .lineLimit(2...5) + } + } + } + + Divider() + + HStack { + Text("Suno fields") + .font(.headline) + + Spacer() + + Button { + ensureSunoOutput() + } label: { + Label("Prepare fields", systemImage: "wand.and.stars") + } + .disabled(project.sunoOutput != nil) + } + + TextField("Suno lyrics", text: sunoLyricsBinding, axis: .vertical) + .lineLimit(4...10) + TextField("Suno style prompt", text: sunoStylePromptBinding, axis: .vertical) + .lineLimit(3...8) + + if !additionalFieldKeys.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Additional fields") + .font(.headline) + + ForEach(additionalFieldKeys, id: \.self) { key in + HStack(alignment: .top, spacing: 8) { + TextField("Field", text: additionalFieldKeyBinding(for: key)) + TextField("Value", text: additionalFieldValueBinding(for: key), axis: .vertical) + .lineLimit(1...4) + + Button { + project.sunoOutput?.additionalFields.removeValue(forKey: key) + } label: { + Label("Remove field", systemImage: "minus.circle") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .help("Remove field") + } + } + } + } + + HStack { + TextField("Field name", text: $newFieldKey) + TextField("Value", text: $newFieldValue) + + Button(action: addAdditionalField) { + Label("Add field", systemImage: "plus.circle.fill") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .disabled(newFieldKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .help("Add field") + } + } + } + + private var additionalFieldKeys: [String] { + guard let additionalFields = project.sunoOutput?.additionalFields else { return [] } + return additionalFields.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + + private var sunoLyricsBinding: Binding { + Binding { + project.sunoOutput?.lyricsText ?? "" + } set: { newValue in + ensureSunoOutput() + project.sunoOutput?.lyricsText = newValue + } + } + + private var sunoStylePromptBinding: Binding { + Binding { + project.sunoOutput?.stylePrompt ?? "" + } set: { newValue in + ensureSunoOutput() + project.sunoOutput?.stylePrompt = newValue + } + } + + private func sectionLyricsBinding(for sectionID: String) -> Binding { + Binding { + project.lyrics.sectionTexts[sectionID] ?? "" + } set: { newValue in + if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + project.lyrics.sectionTexts.removeValue(forKey: sectionID) + } else { + project.lyrics.sectionTexts[sectionID] = newValue + } + } + } + + private func additionalFieldKeyBinding(for key: String) -> Binding { + Binding { + key + } set: { newKey in + let trimmedKey = newKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedKey.isEmpty, trimmedKey != key else { return } + ensureSunoOutput() + let value = project.sunoOutput?.additionalFields.removeValue(forKey: key) ?? "" + project.sunoOutput?.additionalFields[trimmedKey] = value + } + } + + private func additionalFieldValueBinding(for key: String) -> Binding { + Binding { + project.sunoOutput?.additionalFields[key] ?? "" + } set: { newValue in + ensureSunoOutput() + project.sunoOutput?.additionalFields[key] = newValue + } + } + + private func addAdditionalField() { + let key = newFieldKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + + ensureSunoOutput() + project.sunoOutput?.additionalFields[key] = newFieldValue + newFieldKey = "" + newFieldValue = "" + } + + private func ensureSunoOutput() { + guard project.sunoOutput == nil else { return } + project.sunoOutput = SunoOutput( + lyricsText: project.lyrics.text, + stylePrompt: "", + generatedAt: Date() + ) + } +} + +#Preview { + FinalReviewPreview() +} + +private struct FinalReviewPreview: View { + @State private var project = SongProject( + title: "Review", + idea: "Review generated fields", + sections: [ + SongSection(id: "verse", type: .verse, title: "Verse"), + SongSection(id: "chorus", type: .chorus, title: "Chorus", order: 1) + ], + lyrics: Lyrics(text: "Draft lyrics", sectionTexts: ["verse": "Verse text"]), + sunoOutput: SunoOutput( + lyricsText: "Draft lyrics", + stylePrompt: "Cinematic pop with oud", + additionalFields: ["mood": "Hopeful"] + ) + ) + + var body: some View { + Form { + FinalReviewView(project: $project) + } + .formStyle(.grouped) + .frame(width: 420) + } +} diff --git a/Sources/MusicAssistantApp/Presentation/MusicalParametersEditorView.swift b/Sources/MusicAssistantApp/Presentation/MusicalParametersEditorView.swift new file mode 100644 index 0000000..0dec298 --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/MusicalParametersEditorView.swift @@ -0,0 +1,156 @@ +import MusicAssistantCore +import SwiftUI + +struct MusicalParametersEditorView: View { + @Binding var project: SongProject + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ManualAutoIntegerRow( + title: "BPM", + value: manualAutoIntegerBinding(\.bpm) + ) + ManualAutoTextRow( + title: "Key", + value: manualAutoTextBinding(\.key), + placeholder: "D" + ) + ManualAutoTextRow( + title: "Scale", + value: manualAutoTextBinding(\.scale), + placeholder: "minor" + ) + ManualAutoTextRow( + title: "Maqam", + value: manualAutoTextBinding(\.maqam), + placeholder: "Hijaz" + ) + } + } + + private func manualAutoIntegerBinding( + _ keyPath: WritableKeyPath?> + ) -> Binding> { + Binding { + project[keyPath: keyPath] ?? ManualAutoValue(mode: .auto) + } set: { newValue in + project[keyPath: keyPath] = newValue + } + } + + private func manualAutoTextBinding( + _ keyPath: WritableKeyPath?> + ) -> Binding> { + Binding { + project[keyPath: keyPath] ?? ManualAutoValue(mode: .auto) + } set: { newValue in + project[keyPath: keyPath] = newValue + } + } +} + +private struct ManualAutoIntegerRow: View { + let title: String + @Binding var value: ManualAutoValue + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ManualAutoPicker(title: title, mode: modeBinding) + + if value.mode == .manual { + TextField(title, text: integerTextBinding) + } + } + } + + private var modeBinding: Binding { + Binding { + value.mode + } set: { newMode in + value.mode = newMode + if newMode == .auto { + value.value = nil + } + } + } + + private var integerTextBinding: Binding { + Binding { + value.value.map(String.init) ?? "" + } set: { newValue in + value.value = Int(newValue.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } +} + +private struct ManualAutoTextRow: View { + let title: String + @Binding var value: ManualAutoValue + let placeholder: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ManualAutoPicker(title: title, mode: modeBinding) + + if value.mode == .manual { + TextField(placeholder, text: textBinding) + } + } + } + + private var modeBinding: Binding { + Binding { + value.mode + } set: { newMode in + value.mode = newMode + if newMode == .auto { + value.value = nil + } + } + } + + private var textBinding: Binding { + Binding { + value.value ?? "" + } set: { newValue in + let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + value.value = trimmedValue.isEmpty ? nil : trimmedValue + } + } +} + +private struct ManualAutoPicker: View { + let title: String + @Binding var mode: ControlMode + + var body: some View { + Picker(title, selection: $mode) { + Text("Auto").tag(ControlMode.auto) + Text("Manual").tag(ControlMode.manual) + } + .pickerStyle(.segmented) + } +} + +#Preview { + MusicalParametersEditorPreview() +} + +private struct MusicalParametersEditorPreview: View { + @State private var project = SongProject( + title: "Parameters", + idea: "Preview", + bpm: ManualAutoValue(mode: .manual, value: 96), + key: ManualAutoValue(mode: .auto), + scale: ManualAutoValue(mode: .manual, value: "minor"), + maqam: ManualAutoValue(mode: .auto) + ) + + var body: some View { + Form { + MusicalParametersEditorView(project: $project) + } + .formStyle(.grouped) + .frame(width: 340) + } +} diff --git a/Sources/MusicAssistantApp/Presentation/ProjectBrowserView.swift b/Sources/MusicAssistantApp/Presentation/ProjectBrowserView.swift new file mode 100644 index 0000000..4bfff48 --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/ProjectBrowserView.swift @@ -0,0 +1,236 @@ +import Foundation +import MusicAssistantCore +import SwiftUI + +struct ProjectBrowserView: View { + @StateObject private var viewModel: ProjectBrowserViewModel + @State private var selectedProjectID: String? + + init(store: any ProjectStore = LocalSongProjectStore(directoryURL: ProjectBrowserStorage.projectsDirectoryURL)) { + _viewModel = StateObject(wrappedValue: ProjectBrowserViewModel(store: store)) + } + + var body: some View { + NavigationSplitView { + List(selection: $selectedProjectID) { + Section("Projects") { + ForEach(viewModel.projects) { project in + ProjectListRow(project: project) + .tag(project.id) + } + } + } + .navigationTitle("AI Music Studio") + .overlay { + if viewModel.isLoading { + ProgressView() + } else if viewModel.projects.isEmpty { + ContentUnavailableView( + "No Projects", + systemImage: "music.note.list", + description: Text("Create a song project to begin.") + ) + } + } + .toolbar { + ToolbarItem { + Button { + Task { + if let projectID = await viewModel.createProject() { + selectedProjectID = projectID + } + } + } label: { + Label("New Project", systemImage: "plus") + } + .disabled(viewModel.isCreatingProject) + } + } + } detail: { + ProjectBrowserDetailView( + project: viewModel.selectedProject, + errorMessage: viewModel.errorMessage, + isLoading: viewModel.isOpeningProject, + saveProject: viewModel.saveProject + ) + } + .task { + await viewModel.loadProjects() + selectedProjectID = selectedProjectID ?? viewModel.projects.first?.id + if let selectedProjectID { + await viewModel.openProject(id: selectedProjectID) + } + } + .onChange(of: selectedProjectID) { _, projectID in + guard let projectID else { + viewModel.clearSelection() + return + } + + Task { + await viewModel.openProject(id: projectID) + } + } + } +} + +private struct ProjectListRow: View { + let project: SongProjectListItem + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(project.title) + .font(.headline) + .lineLimit(1) + + Text(project.idea.isEmpty ? "No idea yet" : project.idea) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + + Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 4) + } +} + +private struct ProjectBrowserDetailView: View { + let project: SongProject? + let errorMessage: String? + let isLoading: Bool + let saveProject: (SongProject) async -> Bool + + var body: some View { + Group { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let project { + ChatWorkspaceView(project: project, saveProject: saveProject) + .id(project.id) + } else { + ContentUnavailableView( + "Select a Project", + systemImage: "music.note", + description: Text("Choose a saved song project from the browser.") + ) + } + } + .overlay(alignment: .bottom) { + if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.red, in: RoundedRectangle(cornerRadius: 8)) + .padding() + } + } + } +} + +@MainActor +final class ProjectBrowserViewModel: ObservableObject { + @Published private(set) var projects: [SongProjectListItem] = [] + @Published private(set) var selectedProject: SongProject? + @Published private(set) var isLoading = false + @Published private(set) var isOpeningProject = false + @Published private(set) var isCreatingProject = false + @Published private(set) var errorMessage: String? + + private let store: any ProjectStore + + init(store: any ProjectStore) { + self.store = store + } + + func loadProjects() async { + isLoading = true + errorMessage = nil + + do { + projects = try await store.loadProjectList() + } catch { + errorMessage = "Unable to load projects." + } + + isLoading = false + } + + func openProject(id projectID: String) async { + isOpeningProject = true + errorMessage = nil + + do { + selectedProject = try await store.open(id: projectID) + } catch { + selectedProject = nil + errorMessage = "Unable to open this project." + } + + isOpeningProject = false + } + + func createProject() async -> String? { + isCreatingProject = true + errorMessage = nil + + let now = Date() + let project = SongProject( + title: "Untitled Song", + idea: "", + createdAt: now, + updatedAt: now + ) + + do { + try await store.create(project) + projects = try await store.loadProjectList() + selectedProject = project + isCreatingProject = false + return project.id + } catch { + errorMessage = "Unable to create a new project." + isCreatingProject = false + return nil + } + } + + func saveProject(_ project: SongProject) async -> Bool { + var projectToSave = project + projectToSave.updatedAt = Date() + errorMessage = nil + + do { + try await store.save(projectToSave) + projects = try await store.loadProjectList() + selectedProject = projectToSave + return true + } catch { + errorMessage = "Unable to save this project." + return false + } + } + + func clearSelection() { + selectedProject = nil + } +} + +enum ProjectBrowserStorage { + static var projectsDirectoryURL: URL { + let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + + return baseURL + .appendingPathComponent("AI Music Studio", isDirectory: true) + .appendingPathComponent("Projects", isDirectory: true) + } +} + +#Preview { + ProjectBrowserView() +} diff --git a/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift b/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift new file mode 100644 index 0000000..8720c27 --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/ProjectInspectorView.swift @@ -0,0 +1,333 @@ +import MusicAssistantCore +import SwiftUI + +struct ProjectInspectorView: View { + @Binding var project: SongProject + let saveProject: (SongProject) async -> Bool + + @State private var newGenreName = "" + @State private var newInstrumentName = "" + @State private var newVocalistLabel = "" + @State private var newLanguageName = "" + @State private var isSaving = false + @State private var saveStatus: SaveStatus? + + var body: some View { + VStack(spacing: 0) { + HStack { + Label("Inspector", systemImage: "slider.horizontal.3") + .font(.headline) + + Spacer() + + Button { + Task { + await save() + } + } label: { + Label("Save", systemImage: "tray.and.arrow.down") + } + .disabled(isSaving) + } + .padding(16) + + Divider() + + Form { + Section("Overview") { + TextField("Title", text: $project.title) + TextField("Idea", text: $project.idea, axis: .vertical) + .lineLimit(2...4) + TextField("Duration seconds", text: durationSecondsBinding) + TextField("Duration note", text: durationDescriptionBinding) + } + + Section("Genres") { + EditableTokenList( + values: project.genres.map(\.name), + removeAction: { index in + project.deselectGenreStyle(id: project.genres[index].id) + } + ) + AddTextRow( + placeholder: "Add genre", + text: $newGenreName, + addAction: addGenre + ) + } + + Section("Instruments") { + ForEach($project.instruments, id: \.instrumentId) { $instrument in + Toggle(isOn: $instrument.selected) { + Text(instrument.variant?.isEmpty == false ? instrument.variant ?? instrument.instrumentId : instrument.instrumentId) + .lineLimit(1) + } + } + AddTextRow( + placeholder: "Add instrument", + text: $newInstrumentName, + addAction: addInstrument + ) + } + + Section("Vocalists") { + EditableTokenList( + values: project.vocalists.map(\.label), + removeAction: { index in + project.removeVocalist(id: project.vocalists[index].id) + } + ) + AddTextRow( + placeholder: "Add vocalist", + text: $newVocalistLabel, + addAction: addVocalist + ) + } + + Section("Languages") { + EditableTokenList( + values: project.languages.map(\.name), + removeAction: { index in + project.removeLanguage(id: project.languages[index].id) + } + ) + AddTextRow( + placeholder: "Add language", + text: $newLanguageName, + addAction: addLanguage + ) + } + + Section("Song Structure") { + SongStructureEditorView(project: $project) + } + + Section("Musical Parameters") { + MusicalParametersEditorView(project: $project) + } + + Section("Arrangement") { + ArrangementEditorView(project: $project) + } + + Section("Final Review") { + FinalReviewView(project: $project) + } + } + .formStyle(.grouped) + + if let saveStatus { + Divider() + Label(saveStatus.title, systemImage: saveStatus.systemImage) + .font(.caption) + .foregroundStyle(saveStatus.foregroundStyle) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .background(Color(nsColor: .windowBackgroundColor)) + } + + private var durationSecondsBinding: Binding { + Binding { + project.duration?.seconds.map(String.init) ?? "" + } set: { newValue in + var duration = project.duration ?? SongDuration() + duration.seconds = Int(newValue.trimmingCharacters(in: .whitespacesAndNewlines)) + project.duration = duration.isEmpty ? nil : duration + } + } + + private var durationDescriptionBinding: Binding { + Binding { + project.duration?.description ?? "" + } set: { newValue in + let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + var duration = project.duration ?? SongDuration() + duration.description = trimmedValue.isEmpty ? nil : trimmedValue + project.duration = duration.isEmpty ? nil : duration + } + } + + private func addGenre() { + let name = trimmed(newGenreName) + guard !name.isEmpty else { return } + project.selectGenreStyle(GenreStyle(id: stableID(from: name), name: name)) + newGenreName = "" + } + + private func addInstrument() { + let name = trimmed(newInstrumentName) + guard !name.isEmpty else { return } + project.selectInstrument(InstrumentTrack(instrumentId: stableID(from: name), variant: name)) + newInstrumentName = "" + } + + private func addVocalist() { + let label = trimmed(newVocalistLabel) + guard !label.isEmpty else { return } + project.upsertVocalist(Vocalist(id: stableID(from: label), label: label)) + newVocalistLabel = "" + } + + private func addLanguage() { + let name = trimmed(newLanguageName) + guard !name.isEmpty else { return } + project.upsertLanguage(LanguageSelection(id: stableID(from: name), name: name)) + newLanguageName = "" + } + + private func save() async { + isSaving = true + let didSave = await saveProject(project) + isSaving = false + saveStatus = didSave ? .saved : .failed + } + + private func trimmed(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func stableID(from value: String) -> String { + let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")) + let lowercasedValue = value.lowercased() + let scalars = lowercasedValue.unicodeScalars.map { scalar in + allowedCharacters.contains(scalar) ? String(scalar) : "-" + } + let collapsed = scalars.joined() + .split(separator: "-") + .joined(separator: "-") + return collapsed.isEmpty ? UUID().uuidString : collapsed + } +} + +private struct EditableTokenList: View { + let values: [String] + let removeAction: (Int) -> Void + + var body: some View { + if values.isEmpty { + Text("None yet") + .foregroundStyle(.secondary) + } else { + ForEach(Array(values.enumerated()), id: \.offset) { index, value in + HStack { + Text(value) + .lineLimit(1) + + Spacer() + + Button { + removeAction(index) + } label: { + Label("Remove", systemImage: "minus.circle") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .help("Remove") + } + } + } + } +} + +private struct AddTextRow: View { + let placeholder: String + @Binding var text: String + let addAction: () -> Void + + var body: some View { + HStack { + TextField(placeholder, text: $text) + .onSubmit(addAction) + + Button(action: addAction) { + Label("Add", systemImage: "plus.circle.fill") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .help("Add") + } + } +} + +private enum SaveStatus { + case saved + case failed + + var title: String { + switch self { + case .saved: + return "Saved" + case .failed: + return "Save failed" + } + } + + var systemImage: String { + switch self { + case .saved: + return "checkmark.circle.fill" + case .failed: + return "exclamationmark.triangle.fill" + } + } + + var foregroundStyle: Color { + switch self { + case .saved: + return .green + case .failed: + return .red + } + } +} + +private extension SongDuration { + var isEmpty: Bool { + seconds == nil && (description?.isEmpty ?? true) + } +} + +#Preview { + ProjectInspectorPreview() +} + +private struct ProjectInspectorPreview: View { + @State private var project = SongProject( + title: "Inspector Preview", + idea: "A compact editor for the current song project.", + duration: SongDuration(seconds: 180, description: "about three minutes"), + languages: [LanguageSelection(id: "english", name: "English")], + genres: [GenreStyle(id: "pop", name: "Pop")], + bpm: ManualAutoValue(mode: .manual, value: 96), + key: ManualAutoValue(mode: .auto), + scale: ManualAutoValue(mode: .manual, value: "minor"), + maqam: ManualAutoValue(mode: .auto), + sections: [ + SongSection(type: .intro, title: "Intro"), + SongSection(type: .verse, title: "Verse", order: 1) + ], + instruments: [ + InstrumentTrack( + instrumentId: "piano", + variant: "Piano", + placements: [ + InstrumentPlacement(sectionId: nil, startTime: 0, endTime: 12) + ] + ) + ], + vocalists: [Vocalist(id: "lead", label: "Lead vocalist")], + lyrics: Lyrics(text: "Draft lyric idea"), + sunoOutput: SunoOutput( + lyricsText: "Draft lyric idea", + stylePrompt: "Pop with piano" + ) + ) + + var body: some View { + ProjectInspectorView(project: $project, saveProject: { _ in true }) + } +} diff --git a/Sources/MusicAssistantApp/Presentation/RootView.swift b/Sources/MusicAssistantApp/Presentation/RootView.swift new file mode 100644 index 0000000..3f1ddf5 --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/RootView.swift @@ -0,0 +1,11 @@ +import SwiftUI + +struct RootView: View { + var body: some View { + ProjectBrowserView() + } +} + +#Preview { + RootView() +} diff --git a/Sources/MusicAssistantApp/Presentation/SongStructureEditorView.swift b/Sources/MusicAssistantApp/Presentation/SongStructureEditorView.swift new file mode 100644 index 0000000..e5a9e6f --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/SongStructureEditorView.swift @@ -0,0 +1,210 @@ +import MusicAssistantCore +import SwiftUI + +struct SongStructureEditorView: View { + @Binding var project: SongProject + + @State private var newSectionTitle = "" + @State private var newSectionType: SongSectionType = .verse + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if project.sections.isEmpty { + Text("No sections yet") + .foregroundStyle(.secondary) + } else { + ForEach(project.orderedSections) { section in + SongSectionEditorCard( + section: binding(for: section), + moveUpAction: { + moveSection(id: section.id, by: -1) + }, + moveDownAction: { + moveSection(id: section.id, by: 1) + }, + removeAction: { + removeSection(id: section.id) + } + ) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Picker("Type", selection: $newSectionType) { + ForEach(SongSectionType.editorCases, id: \.self) { type in + Text(type.displayName).tag(type) + } + } + + HStack { + TextField("New section title", text: $newSectionTitle) + .onSubmit(addSection) + + Button(action: addSection) { + Label("Add section", systemImage: "plus.circle.fill") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .disabled(newSectionTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .help("Add section") + } + } + } + } + + private func binding(for section: SongSection) -> Binding { + Binding { + project.sections.first { $0.id == section.id } ?? section + } set: { updatedSection in + guard let index = project.sections.firstIndex(where: { $0.id == section.id }) else { return } + project.sections[index] = updatedSection + project.normalizeSectionOrder() + } + } + + private func addSection() { + let trimmedTitle = newSectionTitle.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTitle.isEmpty else { return } + + project.appendSection( + SongSection( + type: newSectionType, + title: trimmedTitle + ) + ) + newSectionTitle = "" + } + + private func moveSection(id sectionID: String, by offset: Int) { + let orderedSections = project.orderedSections + guard let currentIndex = orderedSections.firstIndex(where: { $0.id == sectionID }) else { return } + project.moveSection(id: sectionID, toOrder: currentIndex + offset) + } + + private func removeSection(id sectionID: String) { + project.sections.removeAll { $0.id == sectionID } + project.normalizeSectionOrder() + } +} + +private struct SongSectionEditorCard: View { + @Binding var section: SongSection + let moveUpAction: () -> Void + let moveDownAction: () -> Void + let removeAction: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text(section.title.isEmpty ? "Untitled section" : section.title) + .font(.headline) + .lineLimit(1) + + Spacer() + + Button(action: moveUpAction) { + Label("Move up", systemImage: "chevron.up") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .help("Move up") + + Button(action: moveDownAction) { + Label("Move down", systemImage: "chevron.down") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .help("Move down") + + Button(action: removeAction) { + Label("Remove", systemImage: "trash") + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .foregroundStyle(.red) + .help("Remove") + } + + Picker("Type", selection: $section.type) { + ForEach(SongSectionType.editorCases, id: \.self) { type in + Text(type.displayName).tag(type) + } + } + + TextField("Title", text: $section.title) + TextField("Start seconds", text: optionalTimeBinding(\.startTime)) + TextField("End seconds", text: optionalTimeBinding(\.endTime)) + TextField("Lyrics or notes", text: $section.lyrics, axis: .vertical) + .lineLimit(2...4) + } + .padding(10) + .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8)) + } + + private func optionalTimeBinding(_ keyPath: WritableKeyPath) -> Binding { + Binding { + guard let value = section[keyPath: keyPath] else { return "" } + return String(format: "%.0f", value) + } set: { newValue in + let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines) + section[keyPath: keyPath] = trimmedValue.isEmpty ? nil : TimeInterval(trimmedValue) + } + } +} + +extension SongSectionType { + static let editorCases: [SongSectionType] = [ + .intro, + .verse, + .preChorus, + .chorus, + .bridge, + .outro, + .custom + ] + + var displayName: String { + switch self { + case .intro: + return "Intro" + case .verse: + return "Verse" + case .preChorus: + return "Pre-Chorus" + case .chorus: + return "Chorus" + case .bridge: + return "Bridge" + case .outro: + return "Outro" + case .custom: + return "Custom" + } + } +} + +#Preview { + SongStructureEditorPreview() +} + +private struct SongStructureEditorPreview: View { + @State private var project = SongProject( + title: "Structure Preview", + idea: "Edit sections", + sections: [ + SongSection(type: .intro, title: "Intro"), + SongSection(type: .verse, title: "Verse", order: 1, startTime: 12, endTime: 42), + SongSection(type: .chorus, title: "Chorus", order: 2) + ] + ) + + var body: some View { + Form { + SongStructureEditorView(project: $project) + } + .formStyle(.grouped) + .frame(width: 360) + } +} diff --git a/Sources/MusicAssistantCore/Configuration/AppConfiguration.swift b/Sources/MusicAssistantCore/Configuration/AppConfiguration.swift new file mode 100644 index 0000000..7389899 --- /dev/null +++ b/Sources/MusicAssistantCore/Configuration/AppConfiguration.swift @@ -0,0 +1,21 @@ +import Foundation + +public struct AppConfiguration: Equatable, Sendable { + public let openAIAPIKey: String? + public let privateApplicationRules: PrivateApplicationRules? + + public init( + openAIAPIKey: String?, + privateApplicationRules: PrivateApplicationRules? = nil + ) { + self.openAIAPIKey = openAIAPIKey + self.privateApplicationRules = privateApplicationRules + } + + public static func development(environment: [String: String] = ProcessInfo.processInfo.environment) -> AppConfiguration { + AppConfiguration( + openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"], + privateApplicationRules: PrivateApplicationRules(content: environment["MUSIC_ASSISTANT_PRIVATE_AI_RULES"]) + ) + } +} diff --git a/Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift b/Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift new file mode 100644 index 0000000..6cf9515 --- /dev/null +++ b/Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift @@ -0,0 +1,11 @@ +public struct ProductBoundarySummary: Equatable, Sendable { + public let description: String + + public init(description: String) { + self.description = description + } + + public static let initial = ProductBoundarySummary( + description: "Create and refine song project concepts, then prepare lyrics and style instructions for Suno handoff without starting generation." + ) +} diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift new file mode 100644 index 0000000..169ed81 --- /dev/null +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -0,0 +1,842 @@ +import Foundation + +public struct SongProject: Codable, Equatable, Identifiable, Sendable { + public let id: String + public var title: String + public var idea: String + public var duration: SongDuration? + public var conversationMode: ConversationMode + public var languages: [LanguageSelection] + public var dialects: [DialectSelection] + public var arabicPronunciationSettings: ArabicPronunciationSettings + public var genres: [GenreStyle] + public var moods: [MoodTag] + public var emotionalArc: [EmotionalArcPoint] + public var bpm: ManualAutoValue? + public var key: ManualAutoValue? + public var scale: ManualAutoValue? + public var maqam: ManualAutoValue? + public var sections: [SongSection] + public var instruments: [InstrumentTrack] + public var vocalists: [Vocalist] + public var lyrics: Lyrics + public var productionDirections: [ProductionDirection] + public var sunoOutput: SunoOutput? + public var versions: [SongVersion] + public let createdAt: Date + public var updatedAt: Date + + public init( + id: String = UUID().uuidString, + title: String, + idea: String, + duration: SongDuration? = nil, + conversationMode: ConversationMode = .auto, + languages: [LanguageSelection] = [], + dialects: [DialectSelection] = [], + arabicPronunciationSettings: ArabicPronunciationSettings = ArabicPronunciationSettings(), + genres: [GenreStyle] = [], + moods: [MoodTag] = [], + emotionalArc: [EmotionalArcPoint] = [], + bpm: ManualAutoValue? = nil, + key: ManualAutoValue? = nil, + scale: ManualAutoValue? = nil, + maqam: ManualAutoValue? = nil, + sections: [SongSection] = [], + instruments: [InstrumentTrack] = [], + vocalists: [Vocalist] = [], + lyrics: Lyrics = Lyrics(), + productionDirections: [ProductionDirection] = [], + sunoOutput: SunoOutput? = nil, + versions: [SongVersion] = [], + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.title = title + self.idea = idea + self.duration = duration + self.conversationMode = conversationMode + self.languages = languages + self.dialects = dialects + self.arabicPronunciationSettings = arabicPronunciationSettings + self.genres = genres + self.moods = moods + self.emotionalArc = emotionalArc + self.bpm = bpm + self.key = key + self.scale = scale + self.maqam = maqam + self.sections = sections + self.instruments = instruments + self.vocalists = vocalists + self.lyrics = lyrics + self.productionDirections = productionDirections + self.sunoOutput = sunoOutput + self.versions = versions + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public extension SongProject { + var orderedSections: [SongSection] { + sections + .enumerated() + .sorted { left, right in + if left.element.order == right.element.order { + return left.offset < right.offset + } + return left.element.order < right.element.order + } + .map(\.element) + } + + var selectedGenreStyleIDs: Set { + Set(genres.map(\.id)) + } + + func isGenreStyleSelected(_ genreStyle: GenreStyle) -> Bool { + selectedGenreStyleIDs.contains(genreStyle.id) + } + + mutating func selectGenreStyle(_ genreStyle: GenreStyle) { + guard !isGenreStyleSelected(genreStyle) else { return } + genres.append(genreStyle) + } + + mutating func deselectGenreStyle(id: String) { + genres.removeAll { $0.id == id } + } + + var languageIDs: Set { + Set(languages.map(\.id)) + } + + var dialectIDs: Set { + Set(dialects.map(\.id)) + } + + func language(for languageID: String) -> LanguageSelection? { + languages.first { $0.id == languageID } + } + + func dialect(for dialectID: String) -> DialectSelection? { + dialects.first { $0.id == dialectID } + } + + func dialects(forLanguageID languageID: String) -> [DialectSelection] { + dialects.filter { $0.languageID == languageID } + } + + mutating func upsertLanguage(_ language: LanguageSelection) { + if let index = languages.firstIndex(where: { $0.id == language.id }) { + languages[index] = language + } else { + languages.append(language) + } + } + + mutating func removeLanguage(id languageID: String) { + languages.removeAll { $0.id == languageID } + dialects.removeAll { $0.languageID == languageID } + } + + mutating func upsertDialect(_ dialect: DialectSelection) { + if let index = dialects.firstIndex(where: { $0.id == dialect.id }) { + dialects[index] = dialect + } else { + dialects.append(dialect) + } + } + + mutating func removeDialect(id dialectID: String) { + dialects.removeAll { $0.id == dialectID } + } + + mutating func setBPMManually(_ value: Int) { + bpm = ManualAutoValue(mode: .manual, value: value) + } + + mutating func setBPMAutomatic() { + bpm = ManualAutoValue(mode: .auto) + } + + mutating func setKeyManually(_ value: String) { + key = ManualAutoValue(mode: .manual, value: value) + } + + mutating func setKeyAutomatic() { + key = ManualAutoValue(mode: .auto) + } + + mutating func setScaleManually(_ value: String) { + scale = ManualAutoValue(mode: .manual, value: value) + } + + mutating func setScaleAutomatic() { + scale = ManualAutoValue(mode: .auto) + } + + mutating func setMaqamManually(_ value: String) { + maqam = ManualAutoValue(mode: .manual, value: value) + } + + mutating func setMaqamAutomatic() { + maqam = ManualAutoValue(mode: .auto) + } + + func emotionalArcPoint(for pointID: String) -> EmotionalArcPoint? { + emotionalArc.first { $0.id == pointID } + } + + func emotionalArcPoints(forSectionID sectionID: String) -> [EmotionalArcPoint] { + emotionalArc.filter { $0.sectionID == sectionID } + } + + mutating func upsertEmotionalArcPoint(_ point: EmotionalArcPoint) { + if let index = emotionalArc.firstIndex(where: { $0.id == point.id }) { + emotionalArc[index] = point + } else { + emotionalArc.append(point) + } + } + + mutating func removeEmotionalArcPoint(id pointID: String) { + emotionalArc.removeAll { $0.id == pointID } + } + + func productionDirection(for directionID: String) -> ProductionDirection? { + productionDirections.first { $0.id == directionID } + } + + func productionDirections(forSectionID sectionID: String) -> [ProductionDirection] { + productionDirections.filter { $0.sectionID == sectionID } + } + + mutating func upsertProductionDirection(_ direction: ProductionDirection) { + if let index = productionDirections.firstIndex(where: { $0.id == direction.id }) { + productionDirections[index] = direction + } else { + productionDirections.append(direction) + } + } + + mutating func removeProductionDirection(id directionID: String) { + productionDirections.removeAll { $0.id == directionID } + } + + var selectedInstrumentTracks: [InstrumentTrack] { + instruments.filter(\.selected) + } + + var selectedInstrumentIDs: Set { + Set(selectedInstrumentTracks.map(\.instrumentId)) + } + + func instrumentTrack(for instrumentId: String) -> InstrumentTrack? { + instruments.first { $0.instrumentId == instrumentId } + } + + func isInstrumentSelected(id instrumentId: String) -> Bool { + instrumentTrack(for: instrumentId)?.selected == true + } + + mutating func selectInstrument(_ instrument: InstrumentTrack) { + var selectedInstrument = instrument + selectedInstrument.selected = true + + if let index = instruments.firstIndex(where: { $0.instrumentId == instrument.instrumentId }) { + instruments[index] = selectedInstrument + } else { + instruments.append(selectedInstrument) + } + } + + mutating func deselectInstrument(id instrumentId: String) { + guard let index = instruments.firstIndex(where: { $0.instrumentId == instrumentId }) else { return } + instruments[index].selected = false + } + + mutating func configureInstrument( + id instrumentId: String, + variant: String?, + playingStyle: String?, + role: String?, + autoArrangementEnabled: Bool + ) { + guard let index = instruments.firstIndex(where: { $0.instrumentId == instrumentId }) else { return } + instruments[index].variant = variant + instruments[index].playingStyle = playingStyle + instruments[index].role = role + instruments[index].autoArrangementEnabled = autoArrangementEnabled + } + + mutating func addInstrumentPlacement(_ placement: InstrumentPlacement, to instrumentId: String) { + if let index = instruments.firstIndex(where: { $0.instrumentId == instrumentId }) { + instruments[index].placements.append(placement) + } else { + instruments.append( + InstrumentTrack( + instrumentId: instrumentId, + selected: true, + placements: [placement] + ) + ) + } + } + + var vocalistIDs: Set { + Set(vocalists.map(\.id)) + } + + func vocalist(for vocalistID: String) -> Vocalist? { + vocalists.first { $0.id == vocalistID } + } + + func vocalistsAssigned(toSectionID sectionID: String) -> [Vocalist] { + vocalists.filter { $0.assignedSections.contains(sectionID) } + } + + mutating func upsertVocalist(_ vocalist: Vocalist) { + var normalizedVocalist = vocalist + normalizedVocalist.assignedSections = Self.uniqueOrderedIDs(vocalist.assignedSections) + + if let index = vocalists.firstIndex(where: { $0.id == vocalist.id }) { + vocalists[index] = normalizedVocalist + } else { + vocalists.append(normalizedVocalist) + } + } + + mutating func removeVocalist(id vocalistID: String) { + vocalists.removeAll { $0.id == vocalistID } + } + + mutating func assignSection(_ sectionID: String, toVocalistID vocalistID: String) { + guard let index = vocalists.firstIndex(where: { $0.id == vocalistID }) else { return } + guard !vocalists[index].assignedSections.contains(sectionID) else { return } + vocalists[index].assignedSections.append(sectionID) + } + + mutating func unassignSection(_ sectionID: String, fromVocalistID vocalistID: String) { + guard let index = vocalists.firstIndex(where: { $0.id == vocalistID }) else { return } + vocalists[index].assignedSections.removeAll { $0 == sectionID } + } + + mutating func createVersion( + id: String = UUID().uuidString, + label: String, + createdAt: Date = Date() + ) -> SongVersion { + let version = SongVersion( + id: id, + label: label, + snapshot: SongProjectSnapshot(project: self), + createdAt: createdAt + ) + versions.append(version) + return version + } + + func version(id versionID: String) -> SongVersion? { + versions.first { $0.id == versionID } + } + + mutating func restoreVersion(id versionID: String, restoredAt: Date = Date()) -> Bool { + guard let version = version(id: versionID) else { return false } + restoreSnapshot(version.snapshot, updatedAt: restoredAt) + return true + } + + mutating func restoreSnapshot(_ snapshot: SongProjectSnapshot, updatedAt: Date = Date()) { + title = snapshot.title + idea = snapshot.idea + duration = snapshot.duration + conversationMode = snapshot.conversationMode + languages = snapshot.languages + dialects = snapshot.dialects + arabicPronunciationSettings = snapshot.arabicPronunciationSettings + genres = snapshot.genres + moods = snapshot.moods + emotionalArc = snapshot.emotionalArc + bpm = snapshot.bpm + key = snapshot.key + scale = snapshot.scale + maqam = snapshot.maqam + sections = snapshot.sections + instruments = snapshot.instruments + vocalists = snapshot.vocalists + lyrics = snapshot.lyrics + productionDirections = snapshot.productionDirections + sunoOutput = snapshot.sunoOutput + self.updatedAt = updatedAt + } + + mutating func appendSection(_ section: SongSection) { + var orderedSection = section + orderedSection.order = nextSectionOrder + sections.append(orderedSection) + } + + mutating func moveSection(id: String, toOrder targetOrder: Int) { + var ordered = orderedSections + guard let sourceIndex = ordered.firstIndex(where: { $0.id == id }) else { return } + + let section = ordered.remove(at: sourceIndex) + let boundedTargetOrder = min(max(targetOrder, 0), ordered.count) + ordered.insert(section, at: boundedTargetOrder) + sections = Self.sectionsWithNormalizedOrder(ordered) + } + + mutating func normalizeSectionOrder() { + sections = Self.sectionsWithNormalizedOrder(orderedSections) + } + + private var nextSectionOrder: Int { + (sections.map(\.order).max() ?? -1) + 1 + } + + private static func sectionsWithNormalizedOrder(_ sections: [SongSection]) -> [SongSection] { + sections.enumerated().map { index, section in + var orderedSection = section + orderedSection.order = index + return orderedSection + } + } + + private static func uniqueOrderedIDs(_ ids: [String]) -> [String] { + var seenIDs = Set() + return ids.filter { seenIDs.insert($0).inserted } + } +} + +public enum ConversationMode: String, Codable, Equatable, Sendable { + case auto + case discuss +} + +public enum ControlMode: String, Codable, Equatable, Sendable { + case manual + case auto +} + +public struct ManualAutoValue: Codable, Equatable, Sendable { + public var mode: ControlMode + public var value: Value? + + public init(mode: ControlMode, value: Value? = nil) { + self.mode = mode + self.value = value + } +} + +public struct SongDuration: Codable, Equatable, Sendable { + public var seconds: Int? + public var description: String? + + public init(seconds: Int? = nil, description: String? = nil) { + self.seconds = seconds + self.description = description + } +} + +public struct LanguageSelection: Codable, Equatable, Identifiable, Sendable { + public let id: String + public var name: String + + public init(id: String = UUID().uuidString, name: String) { + self.id = id + self.name = name + } +} + +public struct DialectSelection: Codable, Equatable, Identifiable, Sendable { + public let id: String + public var name: String + public var languageID: String? + + public init(id: String = UUID().uuidString, name: String, languageID: String? = nil) { + self.id = id + self.name = name + self.languageID = languageID + } +} + +public struct ArabicPronunciationSettings: Codable, Equatable, Sendable { + public var isEnabled: Bool + public var diacritizationPolicy: ArabicDiacritizationPolicy + public var tanweenPolicy: ArabicTanweenPolicy + public var userNotes: String? + public var preservedSpellings: [String] + + public init( + isEnabled: Bool = false, + diacritizationPolicy: ArabicDiacritizationPolicy = .unspecified, + tanweenPolicy: ArabicTanweenPolicy = .unspecified, + userNotes: String? = nil, + preservedSpellings: [String] = [] + ) { + self.isEnabled = isEnabled + self.diacritizationPolicy = diacritizationPolicy + self.tanweenPolicy = tanweenPolicy + self.userNotes = userNotes + self.preservedSpellings = preservedSpellings + } +} + +public enum ArabicDiacritizationPolicy: String, Codable, Equatable, Sendable { + case unspecified + case pronunciationTargeted + case fullTashkeel +} + +public enum ArabicTanweenPolicy: String, Codable, Equatable, Sendable { + case unspecified + case preserveUserInput + case addWhenPronunciationRequires + case removeWhenUnwanted +} + +public struct GenreStyle: Codable, Equatable, Identifiable, Sendable { + public let id: String + public var name: String + public var aliases: [String] + + public init(id: String = UUID().uuidString, name: String, aliases: [String] = []) { + self.id = id + self.name = name + self.aliases = aliases + } +} + +public struct MoodTag: Codable, Equatable, Identifiable, Sendable { + public let id: String + public var name: String + + public init(id: String = UUID().uuidString, name: String) { + self.id = id + self.name = name + } +} + +public struct EmotionalArcPoint: Codable, Equatable, Identifiable, Sendable { + public let id: String + public var sectionID: String? + public var emotion: String + public var energy: Int? + public var direction: String? + + public init( + id: String = UUID().uuidString, + sectionID: String? = nil, + emotion: String, + energy: Int? = nil, + direction: String? = nil + ) { + self.id = id + self.sectionID = sectionID + self.emotion = emotion + self.energy = energy + self.direction = direction + } +} + +public struct Lyrics: Codable, Equatable, Sendable { + public var text: String + public var sectionTexts: [String: String] + + public init(text: String = "", sectionTexts: [String: String] = [:]) { + self.text = text + self.sectionTexts = sectionTexts + } +} + +public struct ProductionDirection: Codable, Equatable, Identifiable, Sendable { + public let id: String + public var sectionID: String? + public var text: String + + public init(id: String = UUID().uuidString, sectionID: String? = nil, text: String) { + self.id = id + self.sectionID = sectionID + self.text = text + } +} + +public struct InstrumentCatalogItem: Codable, Equatable, Identifiable, Sendable { + public let id: String + public var name: String + public var familyCategory: String + public var regionOrigin: String? + public var aliases: [String] + public var searchTerms: [String] + + public init( + id: String, + name: String, + familyCategory: String, + regionOrigin: String? = nil, + aliases: [String] = [], + searchTerms: [String] = [] + ) { + self.id = id + self.name = name + self.familyCategory = familyCategory + self.regionOrigin = regionOrigin + self.aliases = aliases + self.searchTerms = searchTerms + } +} + +public struct SongSection: Codable, Equatable, Identifiable, Sendable { + public let id: String + public var type: SongSectionType + public var title: String + public var order: Int + public var startTime: TimeInterval? + public var endTime: TimeInterval? + public var lyrics: String + public var emotion: String? + public var energy: Int? + public var vocalDirection: String? + public var productionDirection: String? + + public init( + id: String = UUID().uuidString, + type: SongSectionType, + title: String, + order: Int = 0, + startTime: TimeInterval? = nil, + endTime: TimeInterval? = nil, + lyrics: String = "", + emotion: String? = nil, + energy: Int? = nil, + vocalDirection: String? = nil, + productionDirection: String? = nil + ) { + self.id = id + self.type = type + self.title = title + self.order = order + self.startTime = startTime + self.endTime = endTime + self.lyrics = lyrics + self.emotion = emotion + self.energy = energy + self.vocalDirection = vocalDirection + self.productionDirection = productionDirection + } +} + +public enum SongSectionType: String, Codable, Equatable, Sendable { + case intro + case verse + case preChorus + case chorus + case bridge + case outro + case custom +} + +public struct InstrumentTrack: Codable, Equatable, Sendable { + public var instrumentId: String + public var selected: Bool + public var variant: String? + public var playingStyle: String? + public var role: String? + public var autoArrangementEnabled: Bool + public var placements: [InstrumentPlacement] + + public init( + instrumentId: String, + selected: Bool = true, + variant: String? = nil, + playingStyle: String? = nil, + role: String? = nil, + autoArrangementEnabled: Bool = true, + placements: [InstrumentPlacement] = [] + ) { + self.instrumentId = instrumentId + self.selected = selected + self.variant = variant + self.playingStyle = playingStyle + self.role = role + self.autoArrangementEnabled = autoArrangementEnabled + self.placements = placements + } +} + +public struct InstrumentPlacement: Codable, Equatable, Sendable { + public var sectionId: String? + public var startTime: TimeInterval? + public var endTime: TimeInterval? + public var direction: String? + + public init( + sectionId: String? = nil, + startTime: TimeInterval? = nil, + endTime: TimeInterval? = nil, + direction: String? = nil + ) { + self.sectionId = sectionId + self.startTime = startTime + self.endTime = endTime + self.direction = direction + } +} + +public struct Vocalist: Codable, Equatable, Identifiable, Sendable { + public let id: String + public var label: String + public var voiceType: String? + public var genderSelection: String? + public var performanceStyle: String? + public var assignedSections: [String] + + public init( + id: String = UUID().uuidString, + label: String, + voiceType: String? = nil, + genderSelection: String? = nil, + performanceStyle: String? = nil, + assignedSections: [String] = [] + ) { + self.id = id + self.label = label + self.voiceType = voiceType + self.genderSelection = genderSelection + self.performanceStyle = performanceStyle + self.assignedSections = assignedSections + } +} + +public struct SunoOutput: Codable, Equatable, Sendable { + public var lyricsText: String + public var stylePrompt: String + public var additionalFields: [String: String] + public var generatedAt: Date + + public init( + lyricsText: String, + stylePrompt: String, + additionalFields: [String: String] = [:], + generatedAt: Date = Date() + ) { + self.lyricsText = lyricsText + self.stylePrompt = stylePrompt + self.additionalFields = additionalFields + self.generatedAt = generatedAt + } +} + +public struct SongVersion: Codable, Equatable, Identifiable, Sendable { + public let id: String + public var label: String + public var snapshot: SongProjectSnapshot + public let createdAt: Date + + public init( + id: String = UUID().uuidString, + label: String, + snapshot: SongProjectSnapshot, + createdAt: Date = Date() + ) { + self.id = id + self.label = label + self.snapshot = snapshot + self.createdAt = createdAt + } +} + +public struct SongProjectSnapshot: Codable, Equatable, Sendable { + public var title: String + public var idea: String + public var duration: SongDuration? + public var conversationMode: ConversationMode + public var languages: [LanguageSelection] + public var dialects: [DialectSelection] + public var arabicPronunciationSettings: ArabicPronunciationSettings + public var genres: [GenreStyle] + public var moods: [MoodTag] + public var emotionalArc: [EmotionalArcPoint] + public var bpm: ManualAutoValue? + public var key: ManualAutoValue? + public var scale: ManualAutoValue? + public var maqam: ManualAutoValue? + public var sections: [SongSection] + public var instruments: [InstrumentTrack] + public var vocalists: [Vocalist] + public var lyrics: Lyrics + public var productionDirections: [ProductionDirection] + public var sunoOutput: SunoOutput? + + public init( + title: String, + idea: String, + duration: SongDuration? = nil, + conversationMode: ConversationMode = .auto, + languages: [LanguageSelection] = [], + dialects: [DialectSelection] = [], + arabicPronunciationSettings: ArabicPronunciationSettings = ArabicPronunciationSettings(), + genres: [GenreStyle] = [], + moods: [MoodTag] = [], + emotionalArc: [EmotionalArcPoint] = [], + bpm: ManualAutoValue? = nil, + key: ManualAutoValue? = nil, + scale: ManualAutoValue? = nil, + maqam: ManualAutoValue? = nil, + sections: [SongSection] = [], + instruments: [InstrumentTrack] = [], + vocalists: [Vocalist] = [], + lyrics: Lyrics = Lyrics(), + productionDirections: [ProductionDirection] = [], + sunoOutput: SunoOutput? = nil + ) { + self.title = title + self.idea = idea + self.duration = duration + self.conversationMode = conversationMode + self.languages = languages + self.dialects = dialects + self.arabicPronunciationSettings = arabicPronunciationSettings + self.genres = genres + self.moods = moods + self.emotionalArc = emotionalArc + self.bpm = bpm + self.key = key + self.scale = scale + self.maqam = maqam + self.sections = sections + self.instruments = instruments + self.vocalists = vocalists + self.lyrics = lyrics + self.productionDirections = productionDirections + self.sunoOutput = sunoOutput + } + + public init(project: SongProject) { + self.init( + title: project.title, + idea: project.idea, + duration: project.duration, + conversationMode: project.conversationMode, + languages: project.languages, + dialects: project.dialects, + arabicPronunciationSettings: project.arabicPronunciationSettings, + genres: project.genres, + moods: project.moods, + emotionalArc: project.emotionalArc, + bpm: project.bpm, + key: project.key, + scale: project.scale, + maqam: project.maqam, + sections: project.sections, + instruments: project.instruments, + vocalists: project.vocalists, + lyrics: project.lyrics, + productionDirections: project.productionDirections, + sunoOutput: project.sunoOutput + ) + } +} diff --git a/Sources/MusicAssistantCore/Integrations/OpenAI/OpenAIClient.swift b/Sources/MusicAssistantCore/Integrations/OpenAI/OpenAIClient.swift new file mode 100644 index 0000000..756a462 --- /dev/null +++ b/Sources/MusicAssistantCore/Integrations/OpenAI/OpenAIClient.swift @@ -0,0 +1,150 @@ +import Foundation + +public struct OpenAIClientConfiguration: Equatable, Sendable { + public var apiKey: String + public var endpointURL: URL + public var model: String? + public var organizationID: String? + public var projectID: String? + + public init( + apiKey: String, + endpointURL: URL, + model: String? = nil, + organizationID: String? = nil, + projectID: String? = nil + ) { + self.apiKey = apiKey + self.endpointURL = endpointURL + self.model = model + self.organizationID = organizationID + self.projectID = projectID + } +} + +public struct OpenAIClientRequest: Equatable, Sendable { + public var method: String + public var url: URL? + public var body: Data + public var additionalHeaders: [String: String] + + public init( + method: String = "POST", + url: URL? = nil, + body: Data, + additionalHeaders: [String: String] = [:] + ) { + self.method = method + self.url = url + self.body = body + self.additionalHeaders = additionalHeaders + } +} + +public protocol OpenAIClientAdapter: Sendable { + func makeProjectGenerationRequest( + _ request: SongProjectGenerationRequest, + configuration: OpenAIClientConfiguration + ) throws -> OpenAIClientRequest + + func decodeProjectGenerationResult(from data: Data) throws -> SongProjectGenerationResult + + func makeLyricsRevisionRequest( + _ request: LyricsRevisionRequest, + configuration: OpenAIClientConfiguration + ) throws -> OpenAIClientRequest + + func decodeLyricsRevisionResult(from data: Data) throws -> LyricsRevisionResult + + func makeProjectUpdateRequest( + _ request: SongProjectUpdateRequest, + configuration: OpenAIClientConfiguration + ) throws -> OpenAIClientRequest + + func decodeProjectUpdateResult(from data: Data) throws -> SongProjectUpdateResult +} + +public protocol OpenAIHTTPTransport: Sendable { + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) +} + +extension URLSession: OpenAIHTTPTransport { + public func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await data(for: request, delegate: nil) + guard let httpResponse = response as? HTTPURLResponse else { + throw OpenAIClientError.invalidResponse + } + return (data, httpResponse) + } +} + +public final class OpenAIClient: AIService, Sendable { + private let configuration: OpenAIClientConfiguration + private let adapter: any OpenAIClientAdapter + private let transport: any OpenAIHTTPTransport + + public init( + configuration: OpenAIClientConfiguration, + adapter: any OpenAIClientAdapter, + transport: any OpenAIHTTPTransport = URLSession.shared + ) { + self.configuration = configuration + self.adapter = adapter + self.transport = transport + } + + public func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult { + let clientRequest = try adapter.makeProjectGenerationRequest(request, configuration: configuration) + let data = try await perform(clientRequest) + return try adapter.decodeProjectGenerationResult(from: data) + } + + public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult { + let clientRequest = try adapter.makeLyricsRevisionRequest(request, configuration: configuration) + let data = try await perform(clientRequest) + return try adapter.decodeLyricsRevisionResult(from: data) + } + + public func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult { + let clientRequest = try adapter.makeProjectUpdateRequest(request, configuration: configuration) + let data = try await perform(clientRequest) + return try adapter.decodeProjectUpdateResult(from: data) + } + + private func perform(_ clientRequest: OpenAIClientRequest) async throws -> Data { + guard !configuration.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw OpenAIClientError.missingAPIKey + } + + var request = URLRequest(url: clientRequest.url ?? configuration.endpointURL) + request.httpMethod = clientRequest.method + request.httpBody = clientRequest.body + request.setValue("Bearer \(configuration.apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let organizationID = configuration.organizationID { + request.setValue(organizationID, forHTTPHeaderField: "OpenAI-Organization") + } + + if let projectID = configuration.projectID { + request.setValue(projectID, forHTTPHeaderField: "OpenAI-Project") + } + + for (header, value) in clientRequest.additionalHeaders { + request.setValue(value, forHTTPHeaderField: header) + } + + let (data, response) = try await transport.data(for: request) + guard (200..<300).contains(response.statusCode) else { + throw OpenAIClientError.unacceptableStatusCode(response.statusCode) + } + + return data + } +} + +public enum OpenAIClientError: Error, Equatable, Sendable { + case missingAPIKey + case invalidResponse + case unacceptableStatusCode(Int) +} diff --git a/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift b/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift new file mode 100644 index 0000000..ce52025 --- /dev/null +++ b/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift @@ -0,0 +1,3 @@ +public protocol SunoHandoffService: Sendable { + func handOff(_ output: CompiledSunoOutput) async throws +} diff --git a/Sources/MusicAssistantCore/Persistence/ProjectStore.swift b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift new file mode 100644 index 0000000..be8efc9 --- /dev/null +++ b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift @@ -0,0 +1,124 @@ +import Foundation + +public protocol ProjectStore: Sendable { + func create(_ project: SongProject) async throws + func save(_ project: SongProject) async throws + func open(id: String) async throws -> SongProject + func loadProjectIDs() async throws -> [String] + func loadProjectList() async throws -> [SongProjectListItem] +} + +public struct SongProjectListItem: Codable, Equatable, Identifiable, Sendable { + public let id: String + public var title: String + public var idea: String + public var createdAt: Date + public var updatedAt: Date + + public init(id: String, title: String, idea: String, createdAt: Date, updatedAt: Date) { + self.id = id + self.title = title + self.idea = idea + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + public init(project: SongProject) { + self.init( + id: project.id, + title: project.title, + idea: project.idea, + createdAt: project.createdAt, + updatedAt: project.updatedAt + ) + } +} + +public actor LocalSongProjectStore: ProjectStore { + private let directoryURL: URL + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + public init(directoryURL: URL) { + self.directoryURL = directoryURL + self.encoder = JSONEncoder() + self.decoder = JSONDecoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + + public func create(_ project: SongProject) async throws { + try ensureDirectoryExists() + let url = projectURL(for: project.id) + guard !FileManager.default.fileExists(atPath: url.path) else { + throw LocalSongProjectStoreError.projectAlreadyExists(project.id) + } + try saveEncoded(project, to: url) + } + + public func save(_ project: SongProject) async throws { + try ensureDirectoryExists() + try saveEncoded(project, to: projectURL(for: project.id)) + } + + public func open(id: String) async throws -> SongProject { + let url = projectURL(for: id) + guard FileManager.default.fileExists(atPath: url.path) else { + throw LocalSongProjectStoreError.projectNotFound(id) + } + let data = try Data(contentsOf: url) + return try decoder.decode(SongProject.self, from: data) + } + + public func loadProjectIDs() async throws -> [String] { + try await loadProjectList().map(\.id) + } + + public func loadProjectList() async throws -> [SongProjectListItem] { + try ensureDirectoryExists() + return try projectFileURLs() + .map { url in + let data = try Data(contentsOf: url) + let project = try decoder.decode(SongProject.self, from: data) + return SongProjectListItem(project: project) + } + .sorted { left, right in + if left.updatedAt == right.updatedAt { + return left.title.localizedStandardCompare(right.title) == .orderedAscending + } + return left.updatedAt > right.updatedAt + } + } + + private func ensureDirectoryExists() throws { + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + } + + private func saveEncoded(_ project: SongProject, to url: URL) throws { + let data = try encoder.encode(project) + try data.write(to: url, options: [.atomic]) + } + + private func projectFileURLs() throws -> [URL] { + try FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil + ) + .filter { $0.pathExtension == Self.fileExtension } + } + + private func projectURL(for id: String) -> URL { + directoryURL + .appendingPathComponent(id) + .appendingPathExtension(Self.fileExtension) + } + + private static let fileExtension = "json" +} + +public enum LocalSongProjectStoreError: Error, Equatable, Sendable { + case projectAlreadyExists(String) + case projectNotFound(String) +} diff --git a/Sources/MusicAssistantCore/Services/AI/AIService.swift b/Sources/MusicAssistantCore/Services/AI/AIService.swift new file mode 100644 index 0000000..c57af24 --- /dev/null +++ b/Sources/MusicAssistantCore/Services/AI/AIService.swift @@ -0,0 +1,170 @@ +import Foundation + +public protocol AIService: Sendable { + func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult + func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult + func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult +} + +public struct AIConversationMessage: Equatable, Identifiable, Sendable { + public let id: String + public var role: AIConversationRole + public var content: String + + public init( + id: String = UUID().uuidString, + role: AIConversationRole, + content: String + ) { + self.id = id + self.role = role + self.content = content + } +} + +public enum AIConversationRole: String, Equatable, Sendable { + case user + case assistant + case system +} + +public struct AIRequestContext: Equatable, Sendable { + public var userInstruction: String + public var conversation: [AIConversationMessage] + public var localeIdentifier: String? + internal var privateApplicationRules: PrivateApplicationRules? + + public init( + userInstruction: String, + conversation: [AIConversationMessage] = [], + localeIdentifier: String? = nil + ) { + self.userInstruction = userInstruction + self.conversation = conversation + self.localeIdentifier = localeIdentifier + self.privateApplicationRules = nil + } + + internal mutating func injectPrivateApplicationRules(_ rules: PrivateApplicationRules?) { + privateApplicationRules = rules + } +} + +public struct SongProjectGenerationRequest: Equatable, Sendable { + public var context: AIRequestContext + public var seedProject: SongProject? + public var discussionMode: ConversationMode + + public init( + context: AIRequestContext, + seedProject: SongProject? = nil, + discussionMode: ConversationMode = .auto + ) { + self.context = context + self.seedProject = seedProject + self.discussionMode = discussionMode + } +} + +public struct SongProjectGenerationResult: Equatable, Sendable { + public var project: SongProject + public var notes: [String] + public var followUpQuestions: [String] + + public init( + project: SongProject, + notes: [String] = [], + followUpQuestions: [String] = [] + ) { + self.project = project + self.notes = notes + self.followUpQuestions = followUpQuestions + } +} + +public struct LyricsRevisionRequest: Equatable, Sendable { + public var context: AIRequestContext + public var project: SongProject + public var sourceLyrics: Lyrics + public var mode: LyricsRevisionMode + + public init( + context: AIRequestContext, + project: SongProject, + sourceLyrics: Lyrics, + mode: LyricsRevisionMode + ) { + self.context = context + self.project = project + self.sourceLyrics = sourceLyrics + self.mode = mode + } +} + +public enum LyricsRevisionMode: String, Equatable, Sendable { + case writeFromIdea + case correct + case improve + case restructure + case complete +} + +public struct LyricsRevisionResult: Equatable, Sendable { + public var lyrics: Lyrics + public var notes: [String] + + public init(lyrics: Lyrics, notes: [String] = []) { + self.lyrics = lyrics + self.notes = notes + } +} + +public struct SongProjectUpdateRequest: Equatable, Sendable { + public var context: AIRequestContext + public var project: SongProject + public var allowedScopes: [SongProjectUpdateScope] + + public init( + context: AIRequestContext, + project: SongProject, + allowedScopes: [SongProjectUpdateScope] + ) { + self.context = context + self.project = project + self.allowedScopes = allowedScopes + } +} + +public enum SongProjectUpdateScope: String, Codable, Equatable, Sendable { + case duration + case genres + case instruments + case vocals + case languages + case lyrics + case structure + case arrangement + case musicalParameters + case emotionalArc + case productionDirections + case sunoOutput +} + +public struct SongProjectUpdateResult: Equatable, Sendable { + public var project: SongProject + public var changedScopes: [SongProjectUpdateScope] + public var notes: [String] + public var followUpQuestions: [String] + + public init( + project: SongProject, + changedScopes: [SongProjectUpdateScope] = [], + notes: [String] = [], + followUpQuestions: [String] = [] + ) { + self.project = project + self.changedScopes = changedScopes + self.notes = notes + self.followUpQuestions = followUpQuestions + } +} diff --git a/Sources/MusicAssistantCore/Services/AI/ApplicationRules.swift b/Sources/MusicAssistantCore/Services/AI/ApplicationRules.swift new file mode 100644 index 0000000..bf232a8 --- /dev/null +++ b/Sources/MusicAssistantCore/Services/AI/ApplicationRules.swift @@ -0,0 +1,73 @@ +import Foundation + +public struct PrivateApplicationRules: Equatable, Sendable { + public let content: String + + public init?(content: String?) { + guard let content else { return nil } + + let trimmedContent = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedContent.isEmpty else { return nil } + + self.content = trimmedContent + } +} + +public protocol ApplicationRuleProviding: Sendable { + func privateApplicationRules() throws -> PrivateApplicationRules? +} + +public struct StaticApplicationRuleProvider: ApplicationRuleProviding { + private let rules: PrivateApplicationRules? + + public init(rules: PrivateApplicationRules?) { + self.rules = rules + } + + public func privateApplicationRules() throws -> PrivateApplicationRules? { + rules + } +} + +public final class ApplicationRuleInjectingAIService: AIService, Sendable { + private let baseService: any AIService + private let ruleProvider: any ApplicationRuleProviding + + public init( + baseService: any AIService, + ruleProvider: any ApplicationRuleProviding + ) { + self.baseService = baseService + self.ruleProvider = ruleProvider + } + + public func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult { + try await baseService.generateSongProject(from: requestWithInjectedRules(request)) + } + + public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult { + try await baseService.reviseLyrics(from: requestWithInjectedRules(request)) + } + + public func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult { + try await baseService.proposeProjectUpdate(from: requestWithInjectedRules(request)) + } + + private func requestWithInjectedRules(_ request: SongProjectGenerationRequest) throws -> SongProjectGenerationRequest { + var request = request + request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules()) + return request + } + + private func requestWithInjectedRules(_ request: LyricsRevisionRequest) throws -> LyricsRevisionRequest { + var request = request + request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules()) + return request + } + + private func requestWithInjectedRules(_ request: SongProjectUpdateRequest) throws -> SongProjectUpdateRequest { + var request = request + request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules()) + return request + } +} diff --git a/Sources/MusicAssistantCore/Services/AI/StructuredAIResponseSchema.swift b/Sources/MusicAssistantCore/Services/AI/StructuredAIResponseSchema.swift new file mode 100644 index 0000000..d6d590e --- /dev/null +++ b/Sources/MusicAssistantCore/Services/AI/StructuredAIResponseSchema.swift @@ -0,0 +1,37 @@ +import Foundation + +public struct StructuredSongProjectUpdateResponse: Codable, Equatable, Sendable { + public static let schemaName = "song_project_update_response" + + public var project: SongProjectSnapshot + public var changedScopes: [SongProjectUpdateScope] + public var notes: [String] + public var followUpQuestions: [String] + + public init( + project: SongProjectSnapshot, + changedScopes: [SongProjectUpdateScope] = [], + notes: [String] = [], + followUpQuestions: [String] = [] + ) { + self.project = project + self.changedScopes = changedScopes + self.notes = notes + self.followUpQuestions = followUpQuestions + } + + public func makeUpdateResult( + updating existingProject: SongProject, + updatedAt: Date = Date() + ) -> SongProjectUpdateResult { + var updatedProject = existingProject + updatedProject.restoreSnapshot(project, updatedAt: updatedAt) + + return SongProjectUpdateResult( + project: updatedProject, + changedScopes: changedScopes, + notes: notes, + followUpQuestions: followUpQuestions + ) + } +} diff --git a/Sources/MusicAssistantCore/Services/ProjectVersioning/ProjectAutosaveService.swift b/Sources/MusicAssistantCore/Services/ProjectVersioning/ProjectAutosaveService.swift new file mode 100644 index 0000000..f1a6305 --- /dev/null +++ b/Sources/MusicAssistantCore/Services/ProjectVersioning/ProjectAutosaveService.swift @@ -0,0 +1,65 @@ +import Foundation + +public struct ProjectAutosavePolicy: Equatable, Sendable { + public static let `default` = ProjectAutosavePolicy() + + public var minimumVersionInterval: TimeInterval + public var versionLabel: String + + public init( + minimumVersionInterval: TimeInterval = 300, + versionLabel: String = "Autosave checkpoint" + ) { + self.minimumVersionInterval = minimumVersionInterval + self.versionLabel = versionLabel + } +} + +public struct ProjectAutosaveResult: Equatable, Sendable { + public var project: SongProject + public var didCreateVersion: Bool + + public init(project: SongProject, didCreateVersion: Bool) { + self.project = project + self.didCreateVersion = didCreateVersion + } +} + +public actor ProjectAutosaveService { + private let store: any ProjectStore + private let policy: ProjectAutosavePolicy + + public init(store: any ProjectStore, policy: ProjectAutosavePolicy = .default) { + self.store = store + self.policy = policy + } + + public func autosave(_ project: SongProject, at savedAt: Date = Date()) async throws -> ProjectAutosaveResult { + var autosavedProject = project + var didCreateVersion = false + + if shouldCreateAutosaveVersion(for: autosavedProject, at: savedAt) { + _ = autosavedProject.createVersion(label: policy.versionLabel, createdAt: savedAt) + didCreateVersion = true + } + + autosavedProject.updatedAt = savedAt + try await store.save(autosavedProject) + + return ProjectAutosaveResult(project: autosavedProject, didCreateVersion: didCreateVersion) + } + + private func shouldCreateAutosaveVersion(for project: SongProject, at savedAt: Date) -> Bool { + let currentSnapshot = SongProjectSnapshot(project: project) + + guard let latestVersion = project.versions.last else { + return true + } + + guard latestVersion.snapshot != currentSnapshot else { + return false + } + + return savedAt.timeIntervalSince(latestVersion.createdAt) >= policy.minimumVersionInterval + } +} diff --git a/Sources/MusicAssistantCore/Services/PromptCompiler/PromptCompiling.swift b/Sources/MusicAssistantCore/Services/PromptCompiler/PromptCompiling.swift new file mode 100644 index 0000000..60cc6f7 --- /dev/null +++ b/Sources/MusicAssistantCore/Services/PromptCompiler/PromptCompiling.swift @@ -0,0 +1,15 @@ +public protocol PromptCompiling: Sendable { + associatedtype Project + + func compile(project: Project) throws -> CompiledSunoOutput +} + +public struct CompiledSunoOutput: Equatable, Sendable { + public let lyricsText: String + public let stylePrompt: String + + public init(lyricsText: String, stylePrompt: String) { + self.lyricsText = lyricsText + self.stylePrompt = stylePrompt + } +} diff --git a/Tests/MusicAssistantCoreTests/AIServiceTests.swift b/Tests/MusicAssistantCoreTests/AIServiceTests.swift new file mode 100644 index 0000000..0e32b7b --- /dev/null +++ b/Tests/MusicAssistantCoreTests/AIServiceTests.swift @@ -0,0 +1,96 @@ +import MusicAssistantCore +import XCTest + +final class AIServiceTests: XCTestCase { + func testProviderIndependentServiceGeneratesStructuredProjectResult() async throws { + let service = MockAIService() + let request = SongProjectGenerationRequest( + context: AIRequestContext( + userInstruction: "Write a hopeful cinematic pop song.", + conversation: [ + AIConversationMessage(role: .user, content: "Use piano and violin.") + ], + localeIdentifier: "en_US" + ), + discussionMode: .discuss + ) + + let result = try await service.generateSongProject(from: request) + + XCTAssertEqual(result.project.title, "Generated Song") + XCTAssertEqual(result.project.idea, request.context.userInstruction) + XCTAssertEqual(result.project.conversationMode, .discuss) + XCTAssertEqual(result.notes, ["Generated by a provider-independent mock."]) + XCTAssertEqual(result.followUpQuestions, ["Should the chorus be bigger?"]) + } + + func testProviderIndependentServiceRevisesLyricsWithStructuredResult() async throws { + let service = MockAIService() + let project = SongProject(title: "Lyric Project", idea: "Improve words") + let sourceLyrics = Lyrics(text: "rough draft") + let request = LyricsRevisionRequest( + context: AIRequestContext(userInstruction: "Make it stronger."), + project: project, + sourceLyrics: sourceLyrics, + mode: .improve + ) + + let result = try await service.reviseLyrics(from: request) + + XCTAssertEqual(result.lyrics.text, "rough draft\nMake it stronger.") + XCTAssertEqual(result.notes, ["improve"]) + } + + func testProviderIndependentServiceProposesScopedProjectUpdates() async throws { + let service = MockAIService() + let project = SongProject(title: "Scoped Project", idea: "Add tempo") + let request = SongProjectUpdateRequest( + context: AIRequestContext(userInstruction: "Set tempo manually."), + project: project, + allowedScopes: [.musicalParameters] + ) + + let result = try await service.proposeProjectUpdate(from: request) + + XCTAssertEqual(result.project.bpm, ManualAutoValue(mode: .manual, value: 96)) + XCTAssertEqual(result.changedScopes, [.musicalParameters]) + XCTAssertEqual(result.notes, ["Updated only requested scopes."]) + } +} + +private struct MockAIService: AIService { + func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult { + SongProjectGenerationResult( + project: SongProject( + title: "Generated Song", + idea: request.context.userInstruction, + conversationMode: request.discussionMode + ), + notes: ["Generated by a provider-independent mock."], + followUpQuestions: ["Should the chorus be bigger?"] + ) + } + + func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult { + LyricsRevisionResult( + lyrics: Lyrics(text: "\(request.sourceLyrics.text)\n\(request.context.userInstruction)"), + notes: [request.mode.rawValue] + ) + } + + func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult { + var project = request.project + var changedScopes: [SongProjectUpdateScope] = [] + + if request.allowedScopes.contains(.musicalParameters) { + project.setBPMManually(96) + changedScopes.append(.musicalParameters) + } + + return SongProjectUpdateResult( + project: project, + changedScopes: changedScopes, + notes: ["Updated only requested scopes."] + ) + } +} diff --git a/Tests/MusicAssistantCoreTests/AppConfigurationTests.swift b/Tests/MusicAssistantCoreTests/AppConfigurationTests.swift new file mode 100644 index 0000000..77b8025 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/AppConfigurationTests.swift @@ -0,0 +1,21 @@ +import MusicAssistantCore +import XCTest + +final class AppConfigurationTests: XCTestCase { + func testDevelopmentConfigurationReadsKnownEnvironmentKeys() { + let configuration = AppConfiguration.development(environment: [ + "MUSIC_ASSISTANT_OPENAI_API_KEY": "openai-test-key", + "MUSIC_ASSISTANT_PRIVATE_AI_RULES": " private product rules " + ]) + + XCTAssertEqual(configuration.openAIAPIKey, "openai-test-key") + XCTAssertEqual(configuration.privateApplicationRules?.content, "private product rules") + } + + func testDevelopmentConfigurationAllowsMissingSecrets() { + let configuration = AppConfiguration.development(environment: [:]) + + XCTAssertNil(configuration.openAIAPIKey) + XCTAssertNil(configuration.privateApplicationRules) + } +} diff --git a/Tests/MusicAssistantCoreTests/ApplicationRuleInjectionTests.swift b/Tests/MusicAssistantCoreTests/ApplicationRuleInjectionTests.swift new file mode 100644 index 0000000..22ca901 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/ApplicationRuleInjectionTests.swift @@ -0,0 +1,102 @@ +@testable import MusicAssistantCore +import XCTest + +final class ApplicationRuleInjectionTests: XCTestCase { + func testInjectingServiceAddsPrivateRulesToEveryAIRequestType() async throws { + let baseService = RecordingAIService() + let service = ApplicationRuleInjectingAIService( + baseService: baseService, + ruleProvider: StaticApplicationRuleProvider( + rules: PrivateApplicationRules(content: "Use private product rules.") + ) + ) + let project = SongProject(title: "Rule Test", idea: "Test rules") + + _ = try await service.generateSongProject( + from: SongProjectGenerationRequest( + context: AIRequestContext(userInstruction: "Generate.") + ) + ) + _ = try await service.reviseLyrics( + from: LyricsRevisionRequest( + context: AIRequestContext(userInstruction: "Improve."), + project: project, + sourceLyrics: Lyrics(text: "Draft"), + mode: .improve + ) + ) + _ = try await service.proposeProjectUpdate( + from: SongProjectUpdateRequest( + context: AIRequestContext(userInstruction: "Update."), + project: project, + allowedScopes: [.lyrics] + ) + ) + + let recordedRuleContents = await baseService.recordedRuleContents() + XCTAssertEqual( + recordedRuleContents, + [ + "Use private product rules.", + "Use private product rules.", + "Use private product rules." + ] + ) + } + + func testInjectingServiceDoesNotAddPrivateRulesToVisibleConversation() async throws { + let baseService = RecordingAIService() + let service = ApplicationRuleInjectingAIService( + baseService: baseService, + ruleProvider: StaticApplicationRuleProvider( + rules: PrivateApplicationRules(content: "Hidden owner rules") + ) + ) + let visibleMessage = AIConversationMessage(role: .user, content: "Visible user text.") + + _ = try await service.generateSongProject( + from: SongProjectGenerationRequest( + context: AIRequestContext( + userInstruction: "Generate.", + conversation: [visibleMessage] + ) + ) + ) + + let recordedConversation = await baseService.recordedConversations().first + XCTAssertEqual(recordedConversation, [visibleMessage]) + } + + func testBlankPrivateRulesAreIgnored() { + XCTAssertNil(PrivateApplicationRules(content: " \n ")) + } +} + +private actor RecordingAIService: AIService { + private var contexts: [AIRequestContext] = [] + + func recordedRuleContents() -> [String?] { + contexts.map { $0.privateApplicationRules?.content } + } + + func recordedConversations() -> [[AIConversationMessage]] { + contexts.map(\.conversation) + } + + func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult { + contexts.append(request.context) + return SongProjectGenerationResult( + project: SongProject(title: "Generated", idea: request.context.userInstruction) + ) + } + + func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult { + contexts.append(request.context) + return LyricsRevisionResult(lyrics: request.sourceLyrics) + } + + func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult { + contexts.append(request.context) + return SongProjectUpdateResult(project: request.project) + } +} diff --git a/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift b/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift new file mode 100644 index 0000000..1819403 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift @@ -0,0 +1,112 @@ +import Foundation +import MusicAssistantCore +import XCTest + +final class LocalSongProjectStoreTests: XCTestCase { + private var temporaryDirectoryURL: URL! + + override func setUpWithError() throws { + temporaryDirectoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("music-assistant-store-tests") + .appendingPathComponent(UUID().uuidString) + } + + override func tearDownWithError() throws { + if let temporaryDirectoryURL { + try? FileManager.default.removeItem(at: temporaryDirectoryURL) + } + temporaryDirectoryURL = nil + } + + func testCreatesOpensSavesAndListsSongProjectsLocally() async throws { + let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + let createdAt = Date(timeIntervalSince1970: 100) + let updatedAt = Date(timeIntervalSince1970: 200) + var project = SongProject( + id: "local-project", + title: "Local Project", + idea: "Persist this song locally", + genres: [GenreStyle(id: "pop", name: "Pop")], + createdAt: createdAt, + updatedAt: updatedAt + ) + + try await store.create(project) + + let createdProjectIDs = try await store.loadProjectIDs() + let openedProject = try await store.open(id: project.id) + + XCTAssertEqual(createdProjectIDs, ["local-project"]) + XCTAssertEqual(openedProject, project) + + project.title = "Updated Local Project" + project.setBPMManually(100) + try await store.save(project) + + let updatedProject = try await store.open(id: project.id) + + XCTAssertEqual(updatedProject, project) + } + + func testLoadsProjectListSortedByMostRecentUpdate() async throws { + let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + let older = SongProject( + id: "older-project", + title: "Older", + idea: "Created first", + createdAt: Date(timeIntervalSince1970: 100), + updatedAt: Date(timeIntervalSince1970: 150) + ) + let newer = SongProject( + id: "newer-project", + title: "Newer", + idea: "Updated later", + createdAt: Date(timeIntervalSince1970: 200), + updatedAt: Date(timeIntervalSince1970: 300) + ) + + try await store.create(older) + try await store.create(newer) + + let projectList = try await store.loadProjectList() + let projectIDs = try await store.loadProjectIDs() + + XCTAssertEqual( + projectList, + [ + SongProjectListItem(project: newer), + SongProjectListItem(project: older) + ] + ) + XCTAssertEqual(projectIDs, ["newer-project", "older-project"]) + } + + func testCreateDoesNotOverwriteExistingProject() async throws { + let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + let project = SongProject( + id: "duplicate-project", + title: "Duplicate", + idea: "Do not overwrite" + ) + + try await store.create(project) + + do { + try await store.create(project) + XCTFail("Expected duplicate project creation to fail.") + } catch let error as LocalSongProjectStoreError { + XCTAssertEqual(error, .projectAlreadyExists(project.id)) + } + } + + func testOpenMissingProjectThrowsNotFound() async throws { + let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + + do { + _ = try await store.open(id: "missing") + XCTFail("Expected missing project open to fail.") + } catch let error as LocalSongProjectStoreError { + XCTAssertEqual(error, .projectNotFound("missing")) + } + } +} diff --git a/Tests/MusicAssistantCoreTests/OpenAIClientTests.swift b/Tests/MusicAssistantCoreTests/OpenAIClientTests.swift new file mode 100644 index 0000000..fdf238a --- /dev/null +++ b/Tests/MusicAssistantCoreTests/OpenAIClientTests.swift @@ -0,0 +1,213 @@ +import Foundation +import MusicAssistantCore +import XCTest + +final class OpenAIClientTests: XCTestCase { + func testGenerateSongProjectUsesConfiguredEndpointAndHeaders() async throws { + let transport = RecordingOpenAITransport( + statusCode: 200, + responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Generated")) + ) + let configuration = OpenAIClientConfiguration( + apiKey: "test-api-key", + endpointURL: URL(string: "https://api.example.test/v1/configured")!, + model: "configured-model", + organizationID: "org-test", + projectID: "project-test" + ) + let client = OpenAIClient( + configuration: configuration, + adapter: MockOpenAIClientAdapter(), + transport: transport + ) + + let result = try await client.generateSongProject( + from: SongProjectGenerationRequest( + context: AIRequestContext(userInstruction: "Generate this."), + discussionMode: .auto + ) + ) + let request = await transport.recordedRequests.first + + XCTAssertEqual(result.project.title, "Generated") + XCTAssertEqual(request?.url, configuration.endpointURL) + XCTAssertEqual(request?.httpMethod, "POST") + XCTAssertEqual(request?.value(forHTTPHeaderField: "Authorization"), "Bearer test-api-key") + XCTAssertEqual(request?.value(forHTTPHeaderField: "OpenAI-Organization"), "org-test") + XCTAssertEqual(request?.value(forHTTPHeaderField: "OpenAI-Project"), "project-test") + XCTAssertEqual(request?.value(forHTTPHeaderField: "X-Adapter"), "mock") + XCTAssertEqual(request?.value(forHTTPHeaderField: "Content-Type"), "application/json") + let requestBodyString = await transport.requestBodyString + XCTAssertEqual(requestBodyString, "generate|configured-model|Generate this.") + } + + func testClientUsesAdapterURLOverrideWhenProvided() async throws { + let overrideURL = URL(string: "https://api.example.test/v1/override")! + let transport = RecordingOpenAITransport( + statusCode: 200, + responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Updated")) + ) + let client = OpenAIClient( + configuration: OpenAIClientConfiguration( + apiKey: "test-api-key", + endpointURL: URL(string: "https://api.example.test/v1/configured")! + ), + adapter: MockOpenAIClientAdapter(overrideURL: overrideURL), + transport: transport + ) + + _ = try await client.proposeProjectUpdate( + from: SongProjectUpdateRequest( + context: AIRequestContext(userInstruction: "Update it."), + project: SongProject(title: "Original", idea: "Original idea"), + allowedScopes: [.lyrics] + ) + ) + + let recordedURL = await transport.recordedRequests.first?.url + XCTAssertEqual(recordedURL, overrideURL) + } + + func testClientRejectsMissingAPIKeyBeforeSendingRequest() async throws { + let transport = RecordingOpenAITransport( + statusCode: 200, + responseData: try JSONEncoder().encode(MockOpenAIResponse(text: "Never sent")) + ) + let client = OpenAIClient( + configuration: OpenAIClientConfiguration( + apiKey: " ", + endpointURL: URL(string: "https://api.example.test/v1/configured")! + ), + adapter: MockOpenAIClientAdapter(), + transport: transport + ) + + do { + _ = try await client.reviseLyrics( + from: LyricsRevisionRequest( + context: AIRequestContext(userInstruction: "Improve."), + project: SongProject(title: "Song", idea: "Idea"), + sourceLyrics: Lyrics(text: "Draft"), + mode: .improve + ) + ) + XCTFail("Expected missing API key to throw.") + } catch let error as OpenAIClientError { + XCTAssertEqual(error, .missingAPIKey) + } + + let recordedRequestCount = await transport.recordedRequests.count + XCTAssertEqual(recordedRequestCount, 0) + } + + func testClientReportsUnacceptableStatusWithoutLeakingResponseBody() async throws { + let transport = RecordingOpenAITransport( + statusCode: 429, + responseData: Data("secret server detail".utf8) + ) + let client = OpenAIClient( + configuration: OpenAIClientConfiguration( + apiKey: "test-api-key", + endpointURL: URL(string: "https://api.example.test/v1/configured")! + ), + adapter: MockOpenAIClientAdapter(), + transport: transport + ) + + do { + _ = try await client.generateSongProject( + from: SongProjectGenerationRequest( + context: AIRequestContext(userInstruction: "Generate.") + ) + ) + XCTFail("Expected status error to throw.") + } catch let error as OpenAIClientError { + XCTAssertEqual(error, .unacceptableStatusCode(429)) + } + } +} + +private actor RecordingOpenAITransport: OpenAIHTTPTransport { + private(set) var recordedRequests: [URLRequest] = [] + private let statusCode: Int + private let responseData: Data + + init(statusCode: Int, responseData: Data) { + self.statusCode = statusCode + self.responseData = responseData + } + + var requestBodyString: String? { + guard let body = recordedRequests.first?.httpBody else { return nil } + return String(data: body, encoding: .utf8) + } + + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + recordedRequests.append(request) + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + return (responseData, response) + } +} + +private struct MockOpenAIClientAdapter: OpenAIClientAdapter { + var overrideURL: URL? + + func makeProjectGenerationRequest( + _ request: SongProjectGenerationRequest, + configuration: OpenAIClientConfiguration + ) throws -> OpenAIClientRequest { + OpenAIClientRequest( + url: overrideURL, + body: Data("generate|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8), + additionalHeaders: ["X-Adapter": "mock"] + ) + } + + func decodeProjectGenerationResult(from data: Data) throws -> SongProjectGenerationResult { + let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data) + return SongProjectGenerationResult( + project: SongProject(title: response.text, idea: response.text) + ) + } + + func makeLyricsRevisionRequest( + _ request: LyricsRevisionRequest, + configuration: OpenAIClientConfiguration + ) throws -> OpenAIClientRequest { + OpenAIClientRequest( + url: overrideURL, + body: Data("lyrics|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8) + ) + } + + func decodeLyricsRevisionResult(from data: Data) throws -> LyricsRevisionResult { + let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data) + return LyricsRevisionResult(lyrics: Lyrics(text: response.text)) + } + + func makeProjectUpdateRequest( + _ request: SongProjectUpdateRequest, + configuration: OpenAIClientConfiguration + ) throws -> OpenAIClientRequest { + OpenAIClientRequest( + url: overrideURL, + body: Data("update|\(configuration.model ?? "")|\(request.context.userInstruction)".utf8) + ) + } + + func decodeProjectUpdateResult(from data: Data) throws -> SongProjectUpdateResult { + let response = try JSONDecoder().decode(MockOpenAIResponse.self, from: data) + return SongProjectUpdateResult( + project: SongProject(title: response.text, idea: response.text) + ) + } +} + +private struct MockOpenAIResponse: Codable { + var text: String +} diff --git a/Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift b/Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift new file mode 100644 index 0000000..c7e2468 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift @@ -0,0 +1,11 @@ +import MusicAssistantCore +import XCTest + +final class ProductBoundarySummaryTests: XCTestCase { + func testInitialBoundaryKeepsSunoGenerationOutsideTheApp() { + let summary = ProductBoundarySummary.initial.description + + XCTAssertTrue(summary.contains("Suno")) + XCTAssertTrue(summary.contains("without starting generation")) + } +} diff --git a/Tests/MusicAssistantCoreTests/ProjectAutosaveServiceTests.swift b/Tests/MusicAssistantCoreTests/ProjectAutosaveServiceTests.swift new file mode 100644 index 0000000..b7ab5b6 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/ProjectAutosaveServiceTests.swift @@ -0,0 +1,104 @@ +import Foundation +import MusicAssistantCore +import XCTest + +final class ProjectAutosaveServiceTests: XCTestCase { + private var temporaryDirectoryURL: URL! + + override func setUpWithError() throws { + temporaryDirectoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("music-assistant-autosave-tests") + .appendingPathComponent(UUID().uuidString) + } + + override func tearDownWithError() throws { + if let temporaryDirectoryURL { + try? FileManager.default.removeItem(at: temporaryDirectoryURL) + } + temporaryDirectoryURL = nil + } + + func testAutosavePersistsCurrentProjectWithoutCreatingExcessiveVersions() async throws { + let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + let autosaver = ProjectAutosaveService( + store: store, + policy: ProjectAutosavePolicy(minimumVersionInterval: 300) + ) + var project = SongProject( + id: "autosaved-project", + title: "Draft", + idea: "Initial idea", + updatedAt: Date(timeIntervalSince1970: 50) + ) + + try await store.create(project) + + let firstAutosave = try await autosaver.autosave( + project, + at: Date(timeIntervalSince1970: 100) + ) + + XCTAssertTrue(firstAutosave.didCreateVersion) + XCTAssertEqual(firstAutosave.project.versions.count, 1) + XCTAssertEqual(firstAutosave.project.versions.first?.snapshot.title, "Draft") + XCTAssertEqual(firstAutosave.project.updatedAt, Date(timeIntervalSince1970: 100)) + + project = firstAutosave.project + project.title = "Draft with small edit" + + let rapidAutosave = try await autosaver.autosave( + project, + at: Date(timeIntervalSince1970: 120) + ) + + XCTAssertFalse(rapidAutosave.didCreateVersion) + XCTAssertEqual(rapidAutosave.project.versions.count, 1) + XCTAssertEqual(rapidAutosave.project.title, "Draft with small edit") + XCTAssertEqual(rapidAutosave.project.updatedAt, Date(timeIntervalSince1970: 120)) + + let storedRapidAutosave = try await store.open(id: project.id) + XCTAssertEqual(storedRapidAutosave, rapidAutosave.project) + + project = rapidAutosave.project + project.idea = "Later autosaved idea" + + let laterAutosave = try await autosaver.autosave( + project, + at: Date(timeIntervalSince1970: 420) + ) + + XCTAssertTrue(laterAutosave.didCreateVersion) + XCTAssertEqual(laterAutosave.project.versions.count, 2) + XCTAssertEqual(laterAutosave.project.versions.last?.snapshot.idea, "Later autosaved idea") + XCTAssertEqual(laterAutosave.project.updatedAt, Date(timeIntervalSince1970: 420)) + } + + func testAutosaveSkipsVersionWhenCurrentSnapshotMatchesLatestVersion() async throws { + let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + let autosaver = ProjectAutosaveService( + store: store, + policy: ProjectAutosavePolicy(minimumVersionInterval: 300) + ) + var project = SongProject( + id: "unchanged-autosave-project", + title: "Stable", + idea: "No meaningful changes" + ) + _ = project.createVersion( + id: "version-1", + label: "Stable checkpoint", + createdAt: Date(timeIntervalSince1970: 100) + ) + + try await store.create(project) + + let autosave = try await autosaver.autosave( + project, + at: Date(timeIntervalSince1970: 1_000) + ) + + XCTAssertFalse(autosave.didCreateVersion) + XCTAssertEqual(autosave.project.versions.count, 1) + XCTAssertEqual(autosave.project.updatedAt, Date(timeIntervalSince1970: 1_000)) + } +} diff --git a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift new file mode 100644 index 0000000..fe9d478 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift @@ -0,0 +1,561 @@ +import Foundation +import MusicAssistantCore +import XCTest + +final class SongProjectModelTests: XCTestCase { + func testSongProjectStoresCoreDomainCollections() { + let section = SongSection( + id: "section-verse-1", + type: .verse, + title: "Verse 1", + startTime: 12, + endTime: 42, + lyrics: "Draft verse", + emotion: "hopeful", + energy: 6, + vocalDirection: "soft lead", + productionDirection: "light percussion" + ) + let placement = InstrumentPlacement( + sectionId: section.id, + startTime: section.startTime, + endTime: section.endTime, + direction: "enter after the first line" + ) + let instrument = InstrumentTrack( + instrumentId: "oud", + selected: true, + variant: "Arabic oud", + playingStyle: "picked", + role: "main melodic hook", + autoArrangementEnabled: false, + placements: [placement] + ) + let vocalist = Vocalist( + id: "lead", + label: "Lead vocalist", + voiceType: "warm tenor", + genderSelection: "male", + performanceStyle: "sung", + assignedSections: [section.id] + ) + let project = SongProject( + id: "project-1", + title: "Desert Lights", + idea: "A hopeful Arabic pop song", + duration: SongDuration(seconds: 180), + conversationMode: .discuss, + languages: [LanguageSelection(id: "arabic", name: "Arabic")], + dialects: [DialectSelection(id: "levantine", name: "Levantine", languageID: "arabic")], + arabicPronunciationSettings: ArabicPronunciationSettings(isEnabled: true), + genres: [GenreStyle(id: "arabic-pop", name: "Arabic Pop")], + moods: [MoodTag(id: "hopeful", name: "Hopeful")], + emotionalArc: [EmotionalArcPoint(id: "arc-1", sectionID: section.id, emotion: "hopeful")], + bpm: ManualAutoValue(mode: .manual, value: 96), + key: ManualAutoValue(mode: .auto), + scale: ManualAutoValue(mode: .auto), + maqam: ManualAutoValue(mode: .manual, value: "Hijaz"), + sections: [section], + instruments: [instrument], + vocalists: [vocalist], + lyrics: Lyrics(text: "Draft lyrics", sectionTexts: [section.id: "Draft verse"]), + productionDirections: [ProductionDirection(id: "prod-1", sectionID: section.id, text: "add lift")], + sunoOutput: SunoOutput(lyricsText: "Draft lyrics", stylePrompt: "Arabic pop with oud") + ) + + XCTAssertEqual(project.sections.first?.type, .verse) + XCTAssertEqual(project.instruments.first?.instrumentId, "oud") + XCTAssertEqual(project.instruments.first?.placements.first?.sectionId, section.id) + XCTAssertEqual(project.vocalists.first?.assignedSections, [section.id]) + XCTAssertEqual(project.bpm?.mode, .manual) + XCTAssertEqual(project.maqam?.value, "Hijaz") + } + + func testSongProjectSnapshotCopiesVersionableState() { + let project = SongProject( + id: "project-2", + title: "Night Drive", + idea: "Electronic track", + genres: [GenreStyle(id: "synthwave", name: "Synthwave")], + instruments: [InstrumentTrack(instrumentId: "synthesizer")] + ) + + let snapshot = SongProjectSnapshot(project: project) + let version = SongVersion(label: "Initial direction", snapshot: snapshot) + + XCTAssertEqual(version.snapshot.title, project.title) + XCTAssertEqual(version.snapshot.idea, project.idea) + XCTAssertEqual(version.snapshot.genres, project.genres) + XCTAssertEqual(version.snapshot.instruments, project.instruments) + } + + func testVersionHistoryStoresIndependentSnapshots() { + var project = SongProject( + id: "versioned-project", + title: "First Title", + idea: "Original idea", + genres: [GenreStyle(id: "folk", name: "Folk")] + ) + let createdAt = Date(timeIntervalSince1970: 500) + + let version = project.createVersion( + id: "version-1", + label: "Initial snapshot", + createdAt: createdAt + ) + + project.title = "Changed Title" + project.idea = "Changed idea" + project.selectGenreStyle(GenreStyle(id: "electronic", name: "Electronic")) + + XCTAssertEqual(project.versions.count, 1) + XCTAssertEqual(project.version(id: "version-1"), version) + XCTAssertEqual(project.versions.first?.label, "Initial snapshot") + XCTAssertEqual(project.versions.first?.createdAt, createdAt) + XCTAssertEqual(project.versions.first?.snapshot.title, "First Title") + XCTAssertEqual(project.versions.first?.snapshot.idea, "Original idea") + XCTAssertEqual(project.versions.first?.snapshot.genres, [GenreStyle(id: "folk", name: "Folk")]) + XCTAssertEqual(project.title, "Changed Title") + XCTAssertEqual(project.genres.map(\.id), ["folk", "electronic"]) + } + + func testRestoresPreviousVersionWithoutDestroyingLaterVersions() { + let createdAt = Date(timeIntervalSince1970: 100) + let restoredAt = Date(timeIntervalSince1970: 900) + var project = SongProject( + id: "restorable-project", + title: "First Title", + idea: "Original idea", + duration: SongDuration(seconds: 180), + genres: [GenreStyle(id: "folk", name: "Folk")], + instruments: [InstrumentTrack(instrumentId: "piano")], + lyrics: Lyrics(text: "Original lyrics"), + createdAt: createdAt, + updatedAt: Date(timeIntervalSince1970: 200) + ) + + _ = project.createVersion( + id: "version-1", + label: "Original direction", + createdAt: Date(timeIntervalSince1970: 300) + ) + + project.title = "Second Title" + project.idea = "Second idea" + project.duration = SongDuration(seconds: 210) + project.selectGenreStyle(GenreStyle(id: "electronic", name: "Electronic")) + project.selectInstrument(InstrumentTrack(instrumentId: "oud")) + project.lyrics = Lyrics(text: "Second lyrics") + _ = project.createVersion( + id: "version-2", + label: "Later direction", + createdAt: Date(timeIntervalSince1970: 400) + ) + + project.title = "Current Title" + project.idea = "Current idea" + project.duration = SongDuration(seconds: 240) + project.deselectGenreStyle(id: "folk") + project.lyrics = Lyrics(text: "Current lyrics") + let versionsBeforeRestore = project.versions + + XCTAssertTrue(project.restoreVersion(id: "version-1", restoredAt: restoredAt)) + + XCTAssertEqual(project.title, "First Title") + XCTAssertEqual(project.idea, "Original idea") + XCTAssertEqual(project.duration, SongDuration(seconds: 180)) + XCTAssertEqual(project.genres, [GenreStyle(id: "folk", name: "Folk")]) + XCTAssertEqual(project.instruments, [InstrumentTrack(instrumentId: "piano")]) + XCTAssertEqual(project.lyrics, Lyrics(text: "Original lyrics")) + XCTAssertEqual(project.createdAt, createdAt) + XCTAssertEqual(project.updatedAt, restoredAt) + XCTAssertEqual(project.versions, versionsBeforeRestore) + XCTAssertEqual(project.version(id: "version-2")?.snapshot.title, "Second Title") + + let restoredProject = project + XCTAssertFalse(project.restoreVersion(id: "missing-version", restoredAt: Date(timeIntervalSince1970: 1_000))) + XCTAssertEqual(project, restoredProject) + } + + func testGenreStylesAreModeledAsMultiSelect() { + let arabicPop = GenreStyle(id: "arabic-pop", name: "Arabic Pop") + let synthwave = GenreStyle(id: "synthwave", name: "Synthwave") + var project = SongProject( + id: "project-3", + title: "Hybrid", + idea: "Blend traditional melody with electronic production" + ) + + project.selectGenreStyle(arabicPop) + project.selectGenreStyle(synthwave) + project.selectGenreStyle(arabicPop) + + XCTAssertEqual(project.genres, [arabicPop, synthwave]) + XCTAssertTrue(project.isGenreStyleSelected(arabicPop)) + XCTAssertEqual(project.selectedGenreStyleIDs, ["arabic-pop", "synthwave"]) + + project.deselectGenreStyle(id: arabicPop.id) + + XCTAssertEqual(project.genres, [synthwave]) + XCTAssertFalse(project.isGenreStyleSelected(arabicPop)) + } + + func testSongSectionsKeepExplicitOrderAndOptionalTimestamps() { + var project = SongProject( + id: "project-4", + title: "Section Flow", + idea: "Build a classic song structure" + ) + let intro = SongSection(id: "intro", type: .intro, title: "Intro") + let verse = SongSection( + id: "verse", + type: .verse, + title: "Verse", + startTime: 15, + endTime: 45 + ) + let chorus = SongSection(id: "chorus", type: .chorus, title: "Chorus") + + project.appendSection(verse) + project.appendSection(chorus) + project.appendSection(intro) + project.moveSection(id: intro.id, toOrder: 0) + + XCTAssertEqual(project.orderedSections.map(\.id), ["intro", "verse", "chorus"]) + XCTAssertNil(project.orderedSections.first?.startTime) + XCTAssertNil(project.orderedSections.first?.endTime) + XCTAssertEqual(project.orderedSections[1].startTime, 15) + XCTAssertEqual(project.orderedSections[1].endTime, 45) + XCTAssertEqual(project.orderedSections.map(\.order), [0, 1, 2]) + } + + func testInstrumentsTrackVariantsRolesSelectionAndPlacements() { + var project = SongProject( + id: "project-5", + title: "Instrument Plan", + idea: "Feature oud and violin with clear entries" + ) + let oud = InstrumentTrack( + instrumentId: "oud", + selected: true, + variant: "Arabic oud", + playingStyle: "tremolo", + role: "main hook", + autoArrangementEnabled: false + ) + let introPlacement = InstrumentPlacement( + sectionId: "intro", + startTime: 0, + endTime: 12, + direction: "solo intro" + ) + let chorusPlacement = InstrumentPlacement( + sectionId: "chorus", + direction: "double the vocal melody" + ) + + project.selectInstrument(oud) + project.addInstrumentPlacement(introPlacement, to: oud.instrumentId) + project.addInstrumentPlacement(chorusPlacement, to: "violin") + project.configureInstrument( + id: "violin", + variant: "solo violin", + playingStyle: "legato", + role: "countermelody", + autoArrangementEnabled: true + ) + + XCTAssertEqual(project.selectedInstrumentIDs, ["oud", "violin"]) + XCTAssertEqual(project.instrumentTrack(for: "oud")?.variant, "Arabic oud") + XCTAssertEqual(project.instrumentTrack(for: "oud")?.role, "main hook") + XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [introPlacement]) + XCTAssertEqual(project.instrumentTrack(for: "violin")?.variant, "solo violin") + XCTAssertEqual(project.instrumentTrack(for: "violin")?.playingStyle, "legato") + XCTAssertEqual(project.instrumentTrack(for: "violin")?.role, "countermelody") + XCTAssertEqual(project.instrumentTrack(for: "violin")?.placements, [chorusPlacement]) + + project.deselectInstrument(id: "oud") + + XCTAssertFalse(project.isInstrumentSelected(id: "oud")) + XCTAssertEqual(project.selectedInstrumentIDs, ["violin"]) + XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [introPlacement]) + } + + func testMultipleVocalistsCanBeAssignedToSections() { + var project = SongProject( + id: "project-6", + title: "Vocal Arrangement", + idea: "Lead and harmony share selected sections" + ) + let lead = Vocalist( + id: "lead", + label: "Lead", + voiceType: "tenor", + genderSelection: "male", + performanceStyle: "sung", + assignedSections: ["verse", "verse"] + ) + let harmony = Vocalist( + id: "harmony", + label: "Harmony", + voiceType: "alto", + genderSelection: "female", + performanceStyle: "harmony" + ) + + project.upsertVocalist(lead) + project.upsertVocalist(harmony) + project.assignSection("chorus", toVocalistID: "lead") + project.assignSection("chorus", toVocalistID: "lead") + project.assignSection("chorus", toVocalistID: "harmony") + project.assignSection("bridge", toVocalistID: "harmony") + + XCTAssertEqual(project.vocalistIDs, ["lead", "harmony"]) + XCTAssertEqual(project.vocalist(for: "lead")?.assignedSections, ["verse", "chorus"]) + XCTAssertEqual(project.vocalist(for: "harmony")?.assignedSections, ["chorus", "bridge"]) + XCTAssertEqual(project.vocalistsAssigned(toSectionID: "chorus").map(\.id), ["lead", "harmony"]) + + project.unassignSection("chorus", fromVocalistID: "lead") + + XCTAssertEqual(project.vocalist(for: "lead")?.assignedSections, ["verse"]) + XCTAssertEqual(project.vocalistsAssigned(toSectionID: "chorus").map(\.id), ["harmony"]) + + project.removeVocalist(id: "harmony") + + XCTAssertNil(project.vocalist(for: "harmony")) + } + + func testLanguagesDialectsAndArabicPronunciationSettingsAreModeled() { + var project = SongProject( + id: "project-7", + title: "Multilingual Song", + idea: "Arabic and English hook" + ) + let arabic = LanguageSelection(id: "arabic", name: "Arabic") + let english = LanguageSelection(id: "english", name: "English") + let levantine = DialectSelection(id: "levantine", name: "Levantine", languageID: arabic.id) + let gulf = DialectSelection(id: "gulf", name: "Gulf", languageID: arabic.id) + + project.upsertLanguage(arabic) + project.upsertLanguage(english) + project.upsertLanguage(LanguageSelection(id: "english", name: "English")) + project.upsertDialect(levantine) + project.upsertDialect(gulf) + project.arabicPronunciationSettings = ArabicPronunciationSettings( + isEnabled: true, + diacritizationPolicy: .pronunciationTargeted, + tanweenPolicy: .preserveUserInput, + userNotes: "Preserve dialect phrasing", + preservedSpellings: ["ya habibi"] + ) + + XCTAssertEqual(project.languageIDs, ["arabic", "english"]) + XCTAssertEqual(project.dialectIDs, ["levantine", "gulf"]) + XCTAssertEqual(project.language(for: arabic.id), arabic) + XCTAssertEqual(project.dialects(forLanguageID: arabic.id), [levantine, gulf]) + XCTAssertTrue(project.arabicPronunciationSettings.isEnabled) + XCTAssertEqual(project.arabicPronunciationSettings.diacritizationPolicy, .pronunciationTargeted) + XCTAssertEqual(project.arabicPronunciationSettings.tanweenPolicy, .preserveUserInput) + + project.removeDialect(id: gulf.id) + + XCTAssertNil(project.dialect(for: gulf.id)) + XCTAssertEqual(project.dialects(forLanguageID: arabic.id), [levantine]) + + project.removeLanguage(id: arabic.id) + + XCTAssertNil(project.language(for: arabic.id)) + XCTAssertEqual(project.dialects(forLanguageID: arabic.id), []) + XCTAssertEqual(project.languageIDs, ["english"]) + } + + func testMusicalParametersSupportManualAndAutoModes() { + var project = SongProject( + id: "project-8", + title: "Musical Parameters", + idea: "Control tempo and harmony" + ) + + project.setBPMManually(104) + project.setKeyManually("D") + project.setScaleAutomatic() + project.setMaqamManually("Bayati") + + XCTAssertEqual(project.bpm, ManualAutoValue(mode: .manual, value: 104)) + XCTAssertEqual(project.key, ManualAutoValue(mode: .manual, value: "D")) + XCTAssertEqual(project.scale, ManualAutoValue(mode: .auto)) + XCTAssertEqual(project.maqam, ManualAutoValue(mode: .manual, value: "Bayati")) + + project.setBPMAutomatic() + project.setKeyAutomatic() + project.setScaleManually("minor") + project.setMaqamAutomatic() + + XCTAssertEqual(project.bpm, ManualAutoValue(mode: .auto)) + XCTAssertEqual(project.key, ManualAutoValue(mode: .auto)) + XCTAssertEqual(project.scale, ManualAutoValue(mode: .manual, value: "minor")) + XCTAssertEqual(project.maqam, ManualAutoValue(mode: .auto)) + } + + func testEmotionalArcAndProductionDirectionsAreModeled() { + var project = SongProject( + id: "project-9", + title: "Arc", + idea: "Build from intimate to explosive" + ) + let verseArc = EmotionalArcPoint( + id: "arc-verse", + sectionID: "verse", + emotion: "intimate", + energy: 3, + direction: "restrained" + ) + let chorusArc = EmotionalArcPoint( + id: "arc-chorus", + sectionID: "chorus", + emotion: "euphoric", + energy: 9, + direction: "wide and bright" + ) + let verseProduction = ProductionDirection( + id: "prod-verse", + sectionID: "verse", + text: "keep drums minimal" + ) + let chorusProduction = ProductionDirection( + id: "prod-chorus", + sectionID: "chorus", + text: "add layered harmonies and bigger percussion" + ) + + project.upsertEmotionalArcPoint(verseArc) + project.upsertEmotionalArcPoint(chorusArc) + project.upsertEmotionalArcPoint( + EmotionalArcPoint( + id: "arc-verse", + sectionID: "verse", + emotion: "focused", + energy: 4, + direction: "slightly warmer" + ) + ) + project.upsertProductionDirection(verseProduction) + project.upsertProductionDirection(chorusProduction) + + XCTAssertEqual(project.emotionalArc.count, 2) + XCTAssertEqual(project.emotionalArcPoint(for: "arc-verse")?.emotion, "focused") + XCTAssertEqual(project.emotionalArcPoints(forSectionID: "chorus"), [chorusArc]) + XCTAssertEqual(project.productionDirections(forSectionID: "verse"), [verseProduction]) + XCTAssertEqual(project.productionDirection(for: "prod-chorus"), chorusProduction) + + project.removeEmotionalArcPoint(id: "arc-chorus") + project.removeProductionDirection(id: "prod-verse") + + XCTAssertNil(project.emotionalArcPoint(for: "arc-chorus")) + XCTAssertNil(project.productionDirection(for: "prod-verse")) + } + + func testSongProjectRoundTripsThroughJSONSerialization() throws { + let createdAt = Date(timeIntervalSince1970: 1_700_000_000) + let updatedAt = Date(timeIntervalSince1970: 1_700_000_120) + let generatedAt = Date(timeIntervalSince1970: 1_700_000_240) + let section = SongSection( + id: "verse", + type: .verse, + title: "Verse", + order: 0, + startTime: 8, + endTime: 38, + lyrics: "Draft verse", + emotion: "reflective", + energy: 4, + vocalDirection: "close vocal", + productionDirection: "subtle pad" + ) + let instrument = InstrumentTrack( + instrumentId: "oud", + selected: true, + variant: "Arabic oud", + playingStyle: "picked", + role: "lead motif", + autoArrangementEnabled: false, + placements: [ + InstrumentPlacement( + sectionId: section.id, + startTime: section.startTime, + endTime: section.endTime, + direction: "answer the vocal" + ) + ] + ) + var project = SongProject( + id: "serialized-project", + title: "Serializable Song", + idea: "A multilingual cinematic song", + duration: SongDuration(seconds: 210, description: "about three and a half minutes"), + conversationMode: .discuss, + languages: [LanguageSelection(id: "arabic", name: "Arabic")], + dialects: [DialectSelection(id: "levantine", name: "Levantine", languageID: "arabic")], + arabicPronunciationSettings: ArabicPronunciationSettings( + isEnabled: true, + diacritizationPolicy: .pronunciationTargeted, + tanweenPolicy: .preserveUserInput, + userNotes: "Keep dialect phrasing natural", + preservedSpellings: ["habibi"] + ), + genres: [GenreStyle(id: "cinematic-pop", name: "Cinematic Pop", aliases: ["orchestral pop"])], + moods: [MoodTag(id: "yearning", name: "Yearning")], + emotionalArc: [ + EmotionalArcPoint( + id: "arc-verse", + sectionID: section.id, + emotion: "reflective", + energy: 4, + direction: "slowly opening" + ) + ], + bpm: ManualAutoValue(mode: .manual, value: 92), + key: ManualAutoValue(mode: .manual, value: "D"), + scale: ManualAutoValue(mode: .auto), + maqam: ManualAutoValue(mode: .manual, value: "Hijaz"), + sections: [section], + instruments: [instrument], + vocalists: [ + Vocalist( + id: "lead", + label: "Lead", + voiceType: "tenor", + genderSelection: "male", + performanceStyle: "sung", + assignedSections: [section.id] + ) + ], + lyrics: Lyrics(text: "Full lyric draft", sectionTexts: [section.id: "Draft verse"]), + productionDirections: [ + ProductionDirection(id: "prod-verse", sectionID: section.id, text: "keep percussion sparse") + ], + sunoOutput: SunoOutput( + lyricsText: "Full lyric draft", + stylePrompt: "Cinematic pop with oud", + additionalFields: ["style": "cinematic"], + generatedAt: generatedAt + ), + createdAt: createdAt, + updatedAt: updatedAt + ) + project.versions = [ + SongVersion( + id: "version-1", + label: "Initial approved direction", + snapshot: SongProjectSnapshot(project: project), + createdAt: updatedAt + ) + ] + + let encoder = JSONEncoder() + let data = try encoder.encode(project) + let decodedProject = try JSONDecoder().decode(SongProject.self, from: data) + + XCTAssertEqual(decodedProject, project) + } +} diff --git a/Tests/MusicAssistantCoreTests/StructuredAIResponseSchemaTests.swift b/Tests/MusicAssistantCoreTests/StructuredAIResponseSchemaTests.swift new file mode 100644 index 0000000..5e999dc --- /dev/null +++ b/Tests/MusicAssistantCoreTests/StructuredAIResponseSchemaTests.swift @@ -0,0 +1,114 @@ +import MusicAssistantCore +import XCTest + +final class StructuredAIResponseSchemaTests: XCTestCase { + func testStructuredSongProjectUpdateResponseDecodesFromJSON() throws { + let json = """ + { + "project": { + "title": "Updated Song", + "idea": "A brighter chorus", + "conversationMode": "auto", + "languages": [{"id": "english", "name": "English"}], + "dialects": [], + "arabicPronunciationSettings": { + "isEnabled": false, + "diacritizationPolicy": "unspecified", + "tanweenPolicy": "unspecified", + "preservedSpellings": [] + }, + "genres": [{"id": "pop", "name": "Pop", "aliases": []}], + "moods": [{"id": "hopeful", "name": "Hopeful"}], + "emotionalArc": [], + "sections": [ + { + "id": "chorus", + "type": "chorus", + "title": "Chorus", + "order": 0, + "lyrics": "Lift the hook higher" + } + ], + "instruments": [ + { + "instrumentId": "piano", + "selected": true, + "autoArrangementEnabled": true, + "placements": [] + } + ], + "vocalists": [], + "lyrics": { + "text": "Lift the hook higher", + "sectionTexts": {"chorus": "Lift the hook higher"} + }, + "productionDirections": [], + "sunoOutput": { + "lyricsText": "Lift the hook higher", + "stylePrompt": "Hopeful pop with piano", + "additionalFields": {}, + "generatedAt": "2026-09-13T18:00:00Z" + } + }, + "changedScopes": ["lyrics", "structure", "sunoOutput"], + "notes": ["Updated requested creative fields."], + "followUpQuestions": ["Should the final chorus repeat?"] + } + """.data(using: .utf8)! + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let response = try decoder.decode(StructuredSongProjectUpdateResponse.self, from: json) + + XCTAssertEqual(StructuredSongProjectUpdateResponse.schemaName, "song_project_update_response") + XCTAssertEqual(response.project.title, "Updated Song") + XCTAssertEqual(response.project.sections.first?.type, .chorus) + XCTAssertEqual(response.project.instruments.first?.instrumentId, "piano") + XCTAssertEqual(response.changedScopes, [.lyrics, .structure, .sunoOutput]) + XCTAssertEqual(response.notes, ["Updated requested creative fields."]) + XCTAssertEqual(response.followUpQuestions, ["Should the final chorus repeat?"]) + } + + func testStructuredSongProjectUpdateResponseCreatesUpdateResultWhilePreservingProjectIdentityAndVersions() { + let createdAt = Date(timeIntervalSince1970: 100) + let updatedAt = Date(timeIntervalSince1970: 200) + var existingProject = SongProject( + id: "project-id", + title: "Original", + idea: "Original idea", + createdAt: createdAt, + updatedAt: createdAt + ) + let version = existingProject.createVersion( + id: "version-id", + label: "Before AI update", + createdAt: createdAt + ) + let response = StructuredSongProjectUpdateResponse( + project: SongProjectSnapshot( + title: "Updated", + idea: "Updated idea", + genres: [GenreStyle(id: "cinematic", name: "Cinematic")], + lyrics: Lyrics(text: "Updated lyrics") + ), + changedScopes: [.genres, .lyrics], + notes: ["Proposed focused update."], + followUpQuestions: ["Keep it minimal?"] + ) + + let result = response.makeUpdateResult( + updating: existingProject, + updatedAt: updatedAt + ) + + XCTAssertEqual(result.project.id, "project-id") + XCTAssertEqual(result.project.createdAt, createdAt) + XCTAssertEqual(result.project.updatedAt, updatedAt) + XCTAssertEqual(result.project.versions, [version]) + XCTAssertEqual(result.project.title, "Updated") + XCTAssertEqual(result.project.lyrics.text, "Updated lyrics") + XCTAssertEqual(result.changedScopes, [.genres, .lyrics]) + XCTAssertEqual(result.notes, ["Proposed focused update."]) + XCTAssertEqual(result.followUpQuestions, ["Keep it minimal?"]) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..84556ec --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,85 @@ +# Architecture --- Initial Direction + +## Principle + +Keep UI, domain data, AI orchestration, external integrations and +persistence separated. Do not hard-code provider behavior into SwiftUI +views. + +## Suggested Layers + +``` text +macOS App +├── Presentation (SwiftUI) +├── Domain +│ ├── SongProject +│ ├── SongVersion +│ ├── Lyrics +│ ├── SongStructure +│ ├── Genre/Style +│ ├── Instrument +│ ├── Vocal +│ ├── Arrangement +│ └── MusicalParameters +├── Application Services +│ ├── AI Director +│ ├── Prompt Compiler +│ ├── Instrument Catalog +│ ├── Arabic Pronunciation Processor +│ ├── Project Versioning +│ └── Validation +├── Integrations +│ ├── OpenAI +│ └── Suno Field-Fill Integration +└── Persistence + └── Local project/version storage +``` + +## AI Director + +OpenAI receives the user's intent plus private application rules and +structured project state. AI output should be requested as structured +data wherever possible, not treated as an unstructured chat transcript. + +## Prompt Compiler + +A deterministic layer converts the approved SongProject into the final +Suno-facing lyrics/style content. User choices override AI suggestions. + +## Security + +- Never commit API keys to source control. +- Keep secrets outside source code. +- Design provider clients behind protocols/interfaces so credentials + and providers can be changed later. +- Do not log secrets or full authorization headers. + +## Suno Boundary + +The initial requirement is browser/site handoff that fills fields but +does not press Generate. Treat this integration as replaceable because +website UI/behavior can change. + +## Instrument Selector Boundary + +Instrument catalog data is structured names and metadata, not SwiftUI +view code. The Presentation layer may search, filter and toggle +selections, but catalog loading, search normalization and selected +instrument state should live in Domain/Application Services and the +SongProject model. + +Catalog entries should include stable ids, display names, +family/category, optional region/origin and optional aliases/search +terms. The catalog should support worldwide coverage across Western, +Middle Eastern, African, South Asian, East Asian, Southeast Asian, Latin +American, traditional, folk, orchestral, electronic and modern +instruments. + +Selected instruments are part of the SongProject and must be available +to the AI Director and Prompt Compiler so they can influence +arrangement, roles, entry/exit timing, relevant structure decisions and +the Suno Style Prompt. + +The current architecture does not include instrument images, audio +previews, sound samples, playback, Freesound, an Instrument API or +external API calls for instrument data. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 0000000..31bf731 --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,87 @@ +# Song Project --- Draft Data Model + +This schema is intentionally provider-neutral and should evolve before +implementation is locked. + +``` text +SongProject +- id +- title +- idea +- duration +- conversationMode: auto | discuss +- languages[] +- dialects[] +- arabicPronunciationSettings +- genres[] +- moods[] +- emotionalArc[] +- bpm +- key +- scale +- maqam +- sections[] +- instruments[] +- vocalists[] +- lyrics +- productionDirections[] +- sunoOutput +- versions[] +- createdAt +- updatedAt + +InstrumentCatalogItem +- id +- name +- familyCategory +- regionOrigin (optional) +- aliases[] +- searchTerms[] + +SongSection +- id +- type (intro, verse, preChorus, chorus, bridge, outro, custom) +- title +- startTime +- endTime +- lyrics +- emotion +- energy +- vocalDirection +- productionDirection + +InstrumentTrack +- instrumentId +- selected +- variant +- playingStyle +- role +- autoArrangementEnabled +- placements[] + +InstrumentPlacement +- sectionId (optional) +- startTime (optional) +- endTime (optional) +- direction + +Vocalist +- id +- label +- voiceType +- genderSelection +- performanceStyle +- assignedSections[] + +SunoOutput +- lyricsText +- stylePrompt +- additionalFields +- generatedAt +``` + +## Override Rule + +Explicit user values are authoritative. AI may fill missing values or +propose changes, but must not silently replace locked/manual user +choices. diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md new file mode 100644 index 0000000..71873d2 --- /dev/null +++ b/docs/OPEN_QUESTIONS.md @@ -0,0 +1,24 @@ +# Open Questions + +These items must be resolved before their dependent implementation is +finalized. + +- Resolved: minimum macOS deployment target is macOS 14.0. +- Exact Swift/SwiftUI architecture conventions for the repository. +- OpenAI model(s) and API endpoint strategy. +- Exact private AI rules/system prompt. +- Complete genre/style taxonomy and whether it is curated locally or + AI-assisted. +- Complete worldwide instrument catalog taxonomy, family/category + groups, region/origin coverage, aliases/search terms and maintenance + strategy. +- Exact list of supported languages/dialects for MVP. +- Arabic diacritization policy: full tashkeel vs + pronunciation-targeted tashkeel. +- Exact Suno fields to populate and supported handoff mechanism. +- Whether Suno integration is permitted/reliable under the intended + account/workflow and current terms. +- 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. +- Authentication/subscription/usage-limit requirements, if any. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md new file mode 100644 index 0000000..86f010b --- /dev/null +++ b/docs/PRODUCT.md @@ -0,0 +1,96 @@ +# Product Definition --- AI Music Studio for macOS + +## Vision + +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. + +## Core Flow + +1. User starts a new song project in a natural chat. +2. User describes the song idea and optionally selects duration, + genres/styles, instruments, vocals, language/dialect, structure and + musical parameters. +3. OpenAI applies product rules and completes missing creative details. + An optional discussion mode lets the AI ask the user before making + decisions. +4. User reviews and manually edits every generated component. +5. Project versions are saved. +6. User presses **Send to Suno**. +7. The app opens Suno and fills the appropriate fields. It does not + trigger Generate. + +## Song Controls + +- Multiple genres/styles can be blended. +- Instruments can be selected from a large worldwide instrument + catalog. +- Instruments can be searched by name, browsed by family/category and + browsed by region/origin where useful. +- Multiple instruments can be selected and deselected using + checkboxes. +- Selected instruments belong to the current Song Project. +- Each instrument can have a role and entry/exit timing. +- Instrument timing supports both timestamps and song sections. +- Arrangement supports Manual and Auto modes. +- Song structure supports Manual and Auto modes. +- BPM, key/scale and maqam support Manual and Auto modes. +- Multiple vocalists/voices can exist in one song. +- User controls vocal gender/type, rap/singing mode, delivery and + section-specific performance. +- Multiple languages and dialects can be used in one song. +- Arabic receives dedicated diacritics/harakat/tanween handling for + pronunciation. +- Energy and emotional progression can change throughout the song. +- Effects, transitions, build-ups, drops, bass intensity and vocal + processing can be manually specified or AI-assisted. + +## Lyrics + +The user can either: - provide only an idea and have AI write the +lyrics; or - provide existing lyrics and ask AI to correct, improve, +restructure or complete them. + +## Instrument Selector + +The application provides a names-and-metadata Instrument Selector. The +catalog should cover as many musical instruments as reasonably possible, +including Western, Middle Eastern, African, South Asian, East Asian, +Southeast Asian, Latin American, traditional, folk, orchestral, +electronic and modern instruments. + +Instrument catalog data must not be hardcoded directly inside SwiftUI +views. Each catalog entry should support structured metadata: + +- id +- name +- family/category +- optional region/origin +- optional aliases/search terms + +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. + +Instrument images, audio previews, sound samples, audio playback, +Freesound, an Instrument API and external API calls for instrument data +are not part of the current product requirement. + +## Project Management + +Every song is stored as a project. Important changes can create versions +so previous states remain recoverable. + +## AI Rules + +The product owner has private system rules that govern OpenAI behavior. +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 prepares the project and fills Suno fields only. Generated songs do +not need to return to the app in the initial version. diff --git a/docs/TASKS.md b/docs/TASKS.md new file mode 100644 index 0000000..16481a9 --- /dev/null +++ b/docs/TASKS.md @@ -0,0 +1,135 @@ +# Agent Task Plan --- AI Music Studio macOS + +## Operating Rule + +Work incrementally. Do not invent product requirements. When a +requirement is missing and blocks implementation, record it in +`OPEN_QUESTIONS.md` instead of silently deciding it. + +## Phase 0 --- Repository Foundation + +- [x] Inspect existing repository and document current state. +- [x] Create/confirm native macOS project structure. +- [x] Establish clear Presentation / Domain / Services / Integrations + / Persistence boundaries. +- [x] Add configuration strategy for development secrets without + committing keys. +- [x] Add basic unit-test target. +- [x] Ensure project builds cleanly. + +## Phase 1 --- Domain Model + +- [x] Implement `SongProject` and supporting models from + `DATA_MODEL.md`. +- [x] Model genres/styles as multi-select. +- [x] Model song sections with order and optional timestamps. +- [x] Model instruments, variants, roles and placements. +- [x] Model multiple vocalists and section assignments. +- [x] Model language/dialect and Arabic pronunciation settings. +- [x] Model BPM, key, scale and maqam with Manual/Auto state. +- [x] Model emotional arc and production directions. +- [x] Add serialization tests. + +## Phase 2 --- Project Persistence & Versions + +- [x] Create new/open/save song projects locally. +- [x] Implement project list. +- [x] Implement immutable or snapshot-based version history. +- [x] Restore a previous version without destroying later versions. +- [x] Add autosave strategy that does not create excessive versions. + +## Phase 3 --- Core macOS UI + +- [x] Build project browser. +- [x] Build natural chat workspace as the primary entry point. +- [x] Build editable project inspector for duration, genres, + instruments, vocals and language. +- [x] Build song structure editor. +- [x] Build arrangement editor supporting section-based and + timestamp-based placement. +- [x] Add Manual/Auto toggles for supported controls. +- [x] Build final review screen where every generated field can be + edited. + +## Phase 4 --- OpenAI Integration + +- [x] Create provider-independent `AIService` interface. +- [x] Implement OpenAI client. +- [x] Define private application-rule injection mechanism. +- [x] Define structured AI response schema for SongProject updates. +- [ ] Implement idea → complete project generation. +- [ ] Implement existing lyrics → correction/improvement flow. +- [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam + and production decisions. +- [ ] Implement optional Discuss mode. +- [ ] Enforce user-lock/manual-value precedence over AI output. +- [ ] Add error, retry, cancellation and rate-limit handling. + +## Phase 5 --- Arabic Lyrics Processing + +- [ ] Add Arabic-specific settings UI. +- [ ] Support diacritics/harakat/tanween processing. +- [ ] Preserve intentional spelling/dialect choices where possible. +- [ ] Allow user to compare/edit processed Arabic before Suno handoff. +- [ ] Add Arabic test fixtures covering multiple dialects. + +## Phase 6 --- Instrument Selector & Catalog + +- [ ] Create structured catalog models for instrument id, name, + family/category, optional region/origin and aliases/search terms. +- [ ] Provide large worldwide catalog data covering Western, Middle + Eastern, African, South Asian, East Asian, Southeast Asian, Latin + American, traditional, folk, orchestral, electronic and modern + instruments. +- [ ] Keep catalog data out of SwiftUI views. +- [ ] Build searchable instrument browser. +- [ ] Add browsing/filtering by family/category. +- [ ] Add browsing/filtering by region/origin where useful. +- [ ] Add checkbox-based multi-select and deselect behavior. +- [ ] Persist selected instruments on the current Song Project. +- [ ] Make selected instruments available to OpenAI/song-generation + logic for arrangement, roles, entry/exit timing, relevant structure + decisions and Suno Style Prompt generation. +- [ ] Preserve existing Manual/Auto arrangement behavior. +- [ ] Add tests for catalog search, category/region filtering, + selection persistence and SongProject serialization. + +## Phase 7 --- Prompt Compiler + +- [ ] Create deterministic compiler from approved SongProject → Suno + output. +- [ ] Generate lyrics text with section/performance directives where + appropriate. +- [ ] Generate style prompt from genre blend, instrumentation, vocals, + tempo, harmony, emotion and production instructions. +- [ ] Validate output before handoff. +- [ ] Keep compiler independent from UI and OpenAI client. + +## Phase 8 --- Suno Handoff + +- [ ] Implement explicit `Send to Suno` action. +- [ ] Open the appropriate Suno creation surface. +- [ ] Fill supported fields with approved project output. +- [ ] Never trigger Generate automatically. +- [ ] Detect/report when fields cannot be filled rather than silently + failing. +- [ ] Keep integration isolated because Suno UI can change. + +## Phase 9 --- Quality + +- [ ] Add validation for contradictory/invalid project settings. +- [ ] Add loading, offline and provider-error states. +- [ ] Add accessibility labels and keyboard navigation. +- [ ] Test project/version recovery. +- [ ] 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. diff --git a/music-assistant-build-files.md b/music-assistant-build-files.md new file mode 100644 index 0000000..2fc2677 --- /dev/null +++ b/music-assistant-build-files.md @@ -0,0 +1,325 @@ +# Build Files Template for music-assistant + +Use this document as the prompt/instruction file for an agent when you create the +`music-assistant` Swift macOS app project. + +The target project does not need to match the Servery path. These files should be +created in the root of the future `music-assistant` project, next to +`Package.swift`. + +## Agent Request + +Copy this request into the agent working inside the new project: + +```text +Create the same build-command structure for this Swift macOS app. + +App name: +music-assistant + +Product name: +music-assistant + +Bundle identifier: +com.music-assistant.app + +Requirements: +1. Create `Makefile` with `build`, `dev`, and `test` commands. +2. Create `clean_rebuild.sh` for a clean release build, packaging, signing, + installing into `/Applications`, and launching the app. +3. Create `dev_rebuild.sh` for a fast incremental debug build, packaging, + installing into `/Applications`, and launching the app. +4. Assume these files live in the project root next to `Package.swift`. +5. Use the app icon at `Resources/music-assistant.icns`. +6. If the app has additional resources, copy them into + `Contents/Resources` during packaging. +7. Make both shell scripts executable. +8. Keep the implementation small and local. Do not refactor unrelated project + files. +9. After creating the files, verify that the scripts reference the correct app + name, product name, bundle identifier, icon path, and install path. +``` + +## Makefile + +Create this file as `Makefile`: + +```makefile +.PHONY: build dev test + +build: + ./clean_rebuild.sh + +dev: + ./dev_rebuild.sh + +test: + swift test +``` + +## dev_rebuild.sh + +Create this file as `dev_rebuild.sh`: + +```zsh +#!/bin/zsh +# Fast development rebuild: incremental DEBUG build, repackage, relaunch. +# +# This skips `swift package clean` and release-mode optimizations, so it reuses +# the incremental build cache and finishes quickly. Use it while iterating. + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)" +APP_NAME="music-assistant" +PRODUCT_NAME="music-assistant" +APP_BUNDLE_NAME="${APP_NAME}.app" +BUILD_DIR="${PROJECT_ROOT}/.build" +APP_BUNDLE="${BUILD_DIR}/${APP_BUNDLE_NAME}" +INSTALL_PATH="/Applications/${APP_BUNDLE_NAME}" +CONTENTS_DIR="${APP_BUNDLE}/Contents" +MACOS_DIR="${CONTENTS_DIR}/MacOS" +RESOURCES_DIR="${CONTENTS_DIR}/Resources" +APP_ICON="${PROJECT_ROOT}/Resources/music-assistant.icns" + +fail() { + print -u2 -- "Error: $1" + exit 1 +} + +step() { + print -- "==> $1" +} + +[[ -f "${PROJECT_ROOT}/Package.swift" ]] || fail "Package.swift was not found in ${PROJECT_ROOT}." +[[ -f "${APP_ICON}" ]] || fail "App icon was not found at ${APP_ICON}." +command -v swift >/dev/null 2>&1 || fail "Swift is required. Install Xcode or the Xcode Command Line Tools." +command -v codesign >/dev/null 2>&1 || fail "codesign is required and should be available with Xcode." + +cd "${PROJECT_ROOT}" + +step "Stopping any running ${APP_NAME} application" +if pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1; then + pkill -x "${PRODUCT_NAME}" || fail "Could not stop the running ${APP_NAME} application." + for _ in {1..20}; do + pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 || break + sleep 0.1 + done + pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 && fail "${APP_NAME} is still running. Quit it and retry." +fi + +step "Building ${PRODUCT_NAME} in debug mode (incremental)" +swift build || fail "Debug build failed. Review the Swift compiler diagnostics above." +BIN_PATH="$(swift build --show-bin-path 2>/dev/null | tail -n1)" +SOURCE_EXECUTABLE="${BIN_PATH}/${PRODUCT_NAME}" +[[ -x "${SOURCE_EXECUTABLE}" ]] || fail "Built executable was not found at ${SOURCE_EXECUTABLE}." + +step "Packaging ${APP_BUNDLE_NAME}" +rm -rf "${APP_BUNDLE}" +mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}" +cp "${SOURCE_EXECUTABLE}" "${MACOS_DIR}/${PRODUCT_NAME}" +cp "${APP_ICON}" "${RESOURCES_DIR}/music-assistant.icns" + +cat > "${CONTENTS_DIR}/Info.plist" <<'PLIST' + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + music-assistant + CFBundleExecutable + music-assistant + CFBundleIdentifier + com.music-assistant.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleIconFile + music-assistant + CFBundleName + music-assistant + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +PLIST + +plutil -lint "${CONTENTS_DIR}/Info.plist" >/dev/null || fail "Generated Info.plist is invalid." +codesign --force --sign - --timestamp=none "${APP_BUNDLE}" || fail "Ad-hoc signing of ${APP_BUNDLE_NAME} failed." +[[ -x "${MACOS_DIR}/${PRODUCT_NAME}" ]] || fail "Packaged executable is missing." + +step "Installing ${APP_BUNDLE_NAME} in /Applications" +rm -rf "${INSTALL_PATH}" +ditto "${APP_BUNDLE}" "${INSTALL_PATH}" || fail "Could not install ${APP_BUNDLE_NAME} in /Applications. Check permissions and retry." + +step "Launching ${APP_NAME}" +open -n "${INSTALL_PATH}" || fail "${APP_NAME} was installed but could not be opened." + +print -- "==> ${APP_NAME} was rebuilt (debug), installed, and launched successfully." +``` + +## clean_rebuild.sh + +Create this file as `clean_rebuild.sh`: + +```zsh +#!/bin/zsh +# Build, package, install, and launch the music-assistant macOS application. + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)" +APP_NAME="music-assistant" +PRODUCT_NAME="music-assistant" +APP_BUNDLE_NAME="${APP_NAME}.app" +BUILD_DIR="${PROJECT_ROOT}/.build" +APP_BUNDLE="${BUILD_DIR}/${APP_BUNDLE_NAME}" +INSTALL_PATH="/Applications/${APP_BUNDLE_NAME}" +CONTENTS_DIR="${APP_BUNDLE}/Contents" +MACOS_DIR="${CONTENTS_DIR}/MacOS" +RESOURCES_DIR="${CONTENTS_DIR}/Resources" +APP_ICON="${PROJECT_ROOT}/Resources/music-assistant.icns" + +fail() { + print -u2 -- "Error: $1" + exit 1 +} + +step() { + print -- "==> $1" +} + +[[ -f "${PROJECT_ROOT}/Package.swift" ]] || fail "Package.swift was not found in ${PROJECT_ROOT}." +[[ -f "${APP_ICON}" ]] || fail "App icon was not found at ${APP_ICON}." +command -v swift >/dev/null 2>&1 || fail "Swift is required. Install Xcode or the Xcode Command Line Tools." +command -v codesign >/dev/null 2>&1 || fail "codesign is required and should be available with Xcode." + +cd "${PROJECT_ROOT}" + +step "Stopping any running ${APP_NAME} application" +if pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1; then + pkill -x "${PRODUCT_NAME}" || fail "Could not stop the running ${APP_NAME} application." + for _ in {1..20}; do + pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 || break + sleep 0.1 + done + pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 && fail "${APP_NAME} is still running. Quit it and retry." +fi + +step "Removing stale installed copy" +if [[ -e "${INSTALL_PATH}" ]]; then + rm -rf "${INSTALL_PATH}" || fail "Could not remove ${INSTALL_PATH}. Check permissions and retry." +fi + +step "Cleaning local Swift build artifacts" +swift package clean || fail "Swift Package Manager could not clean the local build artifacts." + +step "Building ${PRODUCT_NAME} in release mode" +BIN_PATH="$(swift build -c release --show-bin-path 2>/dev/null | tail -n1)" +SOURCE_EXECUTABLE="${BIN_PATH}/${PRODUCT_NAME}" + +built=0 +for attempt in 1 2 3; do + swift build -c release || fail "Release build failed. Review the Swift compiler diagnostics above." + if [[ -x "${SOURCE_EXECUTABLE}" ]]; then built=1; break; fi + print -- "==> Executable not present after build (attempt ${attempt}); retrying" + sleep 1 +done +[[ "${built}" -eq 1 ]] || fail "Built executable was not found at ${SOURCE_EXECUTABLE}." + +step "Packaging ${APP_BUNDLE_NAME}" +rm -rf "${APP_BUNDLE}" +mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}" +cp "${SOURCE_EXECUTABLE}" "${MACOS_DIR}/${PRODUCT_NAME}" +cp "${APP_ICON}" "${RESOURCES_DIR}/music-assistant.icns" + +cat > "${CONTENTS_DIR}/Info.plist" <<'PLIST' + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + music-assistant + CFBundleExecutable + music-assistant + CFBundleIdentifier + com.music-assistant.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleIconFile + music-assistant + CFBundleName + music-assistant + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +PLIST + +plutil -lint "${CONTENTS_DIR}/Info.plist" >/dev/null || fail "Generated Info.plist is invalid." +codesign --force --sign - --timestamp=none "${APP_BUNDLE}" || fail "Ad-hoc signing of ${APP_BUNDLE_NAME} failed." +codesign --verify --deep --strict "${APP_BUNDLE}" || fail "Packaged ${APP_BUNDLE_NAME} failed code-signature verification." +[[ -x "${MACOS_DIR}/${PRODUCT_NAME}" ]] || fail "Packaged executable is missing." + +step "Installing ${APP_BUNDLE_NAME} in /Applications" +ditto "${APP_BUNDLE}" "${INSTALL_PATH}" || fail "Could not install ${APP_BUNDLE_NAME} in /Applications. Check permissions and retry." + +INSTALLED_EXECUTABLE="${INSTALL_PATH}/Contents/MacOS/${PRODUCT_NAME}" +[[ -x "${INSTALLED_EXECUTABLE}" ]] || fail "Installed executable is missing at ${INSTALLED_EXECUTABLE}." +SOURCE_HASH="$(shasum -a 256 "${MACOS_DIR}/${PRODUCT_NAME}" | awk '{print $1}')" +INSTALLED_HASH="$(shasum -a 256 "${INSTALLED_EXECUTABLE}" | awk '{print $1}')" +[[ "${SOURCE_HASH}" == "${INSTALLED_HASH}" ]] || fail "Installed executable does not match the newly built executable." + +step "Launching ${APP_NAME}" +open -n "${INSTALL_PATH}" || fail "${APP_NAME} was installed but could not be opened." + +print -- "==> ${APP_NAME} was rebuilt, installed, and launched successfully." +``` + +## Required Terminal Step + +After the files are created in the new project, run: + +```bash +chmod +x clean_rebuild.sh dev_rebuild.sh +``` + +Then use: + +```bash +make dev +make build +make test +``` + +## Notes + +- The future project must have `Package.swift`. +- The future project must have an icon at `Resources/music-assistant.icns`. +- If the executable product name in `Package.swift` is different, change + `PRODUCT_NAME` in both scripts. +- If the app needs permissions, add the matching usage-description keys to the + generated `Info.plist`. diff --git a/scripts/build-install-launch.zsh b/scripts/build-install-launch.zsh new file mode 100755 index 0000000..b30d0b1 --- /dev/null +++ b/scripts/build-install-launch.zsh @@ -0,0 +1,156 @@ +#!/bin/zsh +# Build, package, install, and launch the AI Music Studio macOS application. + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APP_DISPLAY_NAME="${APP_DISPLAY_NAME:-AI Music Studio}" +EXECUTABLE_NAME="${EXECUTABLE_NAME:-music-assistant}" +BUNDLE_IDENTIFIER="${BUNDLE_IDENTIFIER:-com.musicassistant.app}" +APP_BUNDLE_NAME="${APP_DISPLAY_NAME}.app" +BUILD_DIR="${PROJECT_ROOT}/.build/app" +APP_BUNDLE="${BUILD_DIR}/${APP_BUNDLE_NAME}" +INSTALL_DIR="${INSTALL_DIR:-/Applications}" +INSTALL_PATH="${INSTALL_DIR}/${APP_BUNDLE_NAME}" +CONTENTS_DIR="${APP_BUNDLE}/Contents" +MACOS_DIR="${CONTENTS_DIR}/MacOS" +RESOURCES_DIR="${CONTENTS_DIR}/Resources" + +fail() { + print -u2 -- "Error: $1" + exit 1 +} + +step() { + print -- "==> $1" +} + +find_app_icon() { + if [[ -n "${APP_ICON:-}" ]]; then + [[ -f "${APP_ICON}" ]] || fail "App icon was not found at ${APP_ICON}." + print -- "${APP_ICON}" + return + fi + + local candidate + for candidate in \ + "${PROJECT_ROOT}/Resources/AI Music Studio.icns" \ + "${PROJECT_ROOT}/Resources/AppIcon.icns" \ + "${PROJECT_ROOT}/Resources/MusicAssistant.icns"; do + if [[ -f "${candidate}" ]]; then + print -- "${candidate}" + return + fi + done +} + +[[ -f "${PROJECT_ROOT}/Package.swift" ]] || fail "Package.swift was not found in ${PROJECT_ROOT}." +command -v swift >/dev/null 2>&1 || fail "Swift is required. Install Xcode or the Xcode Command Line Tools." +command -v codesign >/dev/null 2>&1 || fail "codesign is required and should be available with Xcode." +command -v ditto >/dev/null 2>&1 || fail "ditto is required and should be available on macOS." +command -v open >/dev/null 2>&1 || fail "open is required and should be available on macOS." + +cd "${PROJECT_ROOT}" + +step "Stopping any running ${APP_DISPLAY_NAME} application" +if pgrep -x "${EXECUTABLE_NAME}" >/dev/null 2>&1; then + pkill -x "${EXECUTABLE_NAME}" || fail "Could not stop the running ${APP_DISPLAY_NAME} application." + for _ in {1..20}; do + pgrep -x "${EXECUTABLE_NAME}" >/dev/null 2>&1 || break + sleep 0.1 + done + pgrep -x "${EXECUTABLE_NAME}" >/dev/null 2>&1 && fail "${APP_DISPLAY_NAME} is still running. Quit it and retry." +fi + +step "Removing stale installed copy" +if [[ -e "${INSTALL_PATH}" ]]; then + rm -rf "${INSTALL_PATH}" || fail "Could not remove ${INSTALL_PATH}. Check permissions and retry." +fi + +step "Cleaning local Swift build artifacts" +swift package clean || fail "Swift Package Manager could not clean the local build artifacts." + +step "Building ${EXECUTABLE_NAME} in release mode" +BIN_PATH="$(swift build -c release --show-bin-path 2>/dev/null | tail -n1)" +SOURCE_EXECUTABLE="${BIN_PATH}/${EXECUTABLE_NAME}" + +built=0 +for attempt in 1 2 3; do + swift build -c release || fail "Release build failed. Review the Swift compiler diagnostics above." + if [[ -x "${SOURCE_EXECUTABLE}" ]]; then + built=1 + break + fi + print -- "==> Executable not present after build (attempt ${attempt}); retrying" + sleep 1 +done +[[ "${built}" -eq 1 ]] || fail "Built executable was not found at ${SOURCE_EXECUTABLE}." + +step "Packaging ${APP_BUNDLE_NAME}" +rm -rf "${APP_BUNDLE}" +mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}" +cp "${SOURCE_EXECUTABLE}" "${MACOS_DIR}/${EXECUTABLE_NAME}" + +ICON_PATH="$(find_app_icon)" +PLIST_ICON_ENTRY="" +if [[ -n "${ICON_PATH}" ]]; then + ICON_FILE_NAME="$(basename "${ICON_PATH}")" + ICON_FILE_BASE="${ICON_FILE_NAME:r}" + cp "${ICON_PATH}" "${RESOURCES_DIR}/${ICON_FILE_NAME}" + PLIST_ICON_ENTRY=$' CFBundleIconFile\n '"${ICON_FILE_BASE}"$'' +else + step "No app icon found; packaging without a custom icon" +fi + +cat > "${CONTENTS_DIR}/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${APP_DISPLAY_NAME} + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIdentifier + ${BUNDLE_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 +${PLIST_ICON_ENTRY} + CFBundleName + ${APP_DISPLAY_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +PLIST + +plutil -lint "${CONTENTS_DIR}/Info.plist" >/dev/null || fail "Generated Info.plist is invalid." +codesign --force --sign - --timestamp=none "${APP_BUNDLE}" || fail "Ad-hoc signing of ${APP_BUNDLE_NAME} failed." +codesign --verify --deep --strict "${APP_BUNDLE}" || fail "Packaged ${APP_BUNDLE_NAME} failed code-signature verification." +[[ -x "${MACOS_DIR}/${EXECUTABLE_NAME}" ]] || fail "Packaged executable is missing." + +step "Installing ${APP_BUNDLE_NAME} in ${INSTALL_DIR}" +ditto "${APP_BUNDLE}" "${INSTALL_PATH}" || fail "Could not install ${APP_BUNDLE_NAME} in ${INSTALL_DIR}. Check permissions and retry." + +INSTALLED_EXECUTABLE="${INSTALL_PATH}/Contents/MacOS/${EXECUTABLE_NAME}" +[[ -x "${INSTALLED_EXECUTABLE}" ]] || fail "Installed executable is missing at ${INSTALLED_EXECUTABLE}." +SOURCE_HASH="$(shasum -a 256 "${MACOS_DIR}/${EXECUTABLE_NAME}" | awk '{print $1}')" +INSTALLED_HASH="$(shasum -a 256 "${INSTALLED_EXECUTABLE}" | awk '{print $1}')" +[[ "${SOURCE_HASH}" == "${INSTALLED_HASH}" ]] || fail "Installed executable does not match the newly built executable." + +step "Launching ${APP_DISPLAY_NAME}" +open -n "${INSTALL_PATH}" || fail "${APP_DISPLAY_NAME} was installed but could not be opened." + +print -- "==> ${APP_DISPLAY_NAME} was rebuilt, installed, and launched successfully."