Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Local development only. Do not commit real values.
|
||||
MUSIC_ASSISTANT_OPENAI_API_KEY=
|
||||
@@ -0,0 +1,7 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
DerivedData/
|
||||
.DS_Store
|
||||
.env
|
||||
*.xcuserdata
|
||||
*.xcuserstate
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MusicAssistantApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
}
|
||||
.windowStyle(.titleBar)
|
||||
}
|
||||
}
|
||||
@@ -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<InstrumentTrack, String?>) -> Binding<String> {
|
||||
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<String> {
|
||||
Binding {
|
||||
placement.sectionId ?? ""
|
||||
} set: { newValue in
|
||||
placement.sectionId = newValue.isEmpty ? nil : newValue
|
||||
}
|
||||
}
|
||||
|
||||
private func optionalTimeBinding(_ keyPath: WritableKeyPath<InstrumentPlacement, TimeInterval?>) -> Binding<String> {
|
||||
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<InstrumentPlacement, String?>) -> Binding<String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<Bool>.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."
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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<String> {
|
||||
Binding {
|
||||
project.sunoOutput?.lyricsText ?? ""
|
||||
} set: { newValue in
|
||||
ensureSunoOutput()
|
||||
project.sunoOutput?.lyricsText = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private var sunoStylePromptBinding: Binding<String> {
|
||||
Binding {
|
||||
project.sunoOutput?.stylePrompt ?? ""
|
||||
} set: { newValue in
|
||||
ensureSunoOutput()
|
||||
project.sunoOutput?.stylePrompt = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionLyricsBinding(for sectionID: String) -> Binding<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<SongProject, ManualAutoValue<Int>?>
|
||||
) -> Binding<ManualAutoValue<Int>> {
|
||||
Binding {
|
||||
project[keyPath: keyPath] ?? ManualAutoValue(mode: .auto)
|
||||
} set: { newValue in
|
||||
project[keyPath: keyPath] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private func manualAutoTextBinding(
|
||||
_ keyPath: WritableKeyPath<SongProject, ManualAutoValue<String>?>
|
||||
) -> Binding<ManualAutoValue<String>> {
|
||||
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<Int>
|
||||
|
||||
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<ControlMode> {
|
||||
Binding {
|
||||
value.mode
|
||||
} set: { newMode in
|
||||
value.mode = newMode
|
||||
if newMode == .auto {
|
||||
value.value = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var integerTextBinding: Binding<String> {
|
||||
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<String>
|
||||
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<ControlMode> {
|
||||
Binding {
|
||||
value.mode
|
||||
} set: { newMode in
|
||||
value.mode = newMode
|
||||
if newMode == .auto {
|
||||
value.value = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var textBinding: Binding<String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
var body: some View {
|
||||
ProjectBrowserView()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
RootView()
|
||||
}
|
||||
@@ -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<SongSection> {
|
||||
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<SongSection, TimeInterval?>) -> Binding<String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
)
|
||||
}
|
||||
@@ -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<Int>?
|
||||
public var key: ManualAutoValue<String>?
|
||||
public var scale: ManualAutoValue<String>?
|
||||
public var maqam: ManualAutoValue<String>?
|
||||
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<Int>? = nil,
|
||||
key: ManualAutoValue<String>? = nil,
|
||||
scale: ManualAutoValue<String>? = nil,
|
||||
maqam: ManualAutoValue<String>? = 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<String> {
|
||||
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<String> {
|
||||
Set(languages.map(\.id))
|
||||
}
|
||||
|
||||
var dialectIDs: Set<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String>()
|
||||
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<Value: Codable & Equatable & Sendable>: 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<Int>?
|
||||
public var key: ManualAutoValue<String>?
|
||||
public var scale: ManualAutoValue<String>?
|
||||
public var maqam: ManualAutoValue<String>?
|
||||
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<Int>? = nil,
|
||||
key: ManualAutoValue<String>? = nil,
|
||||
scale: ManualAutoValue<String>? = nil,
|
||||
maqam: ManualAutoValue<String>? = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
public protocol SunoHandoffService: Sendable {
|
||||
func handOff(_ output: CompiledSunoOutput) async throws
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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."]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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?"])
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+135
@@ -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.
|
||||
@@ -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'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>music-assistant</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>music-assistant</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.music-assistant.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>music-assistant</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>music-assistant</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
</plist>
|
||||
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'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>music-assistant</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>music-assistant</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.music-assistant.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>music-assistant</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>music-assistant</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
</plist>
|
||||
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`.
|
||||
Executable
+156
@@ -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=$' <key>CFBundleIconFile</key>\n <string>'"${ICON_FILE_BASE}"$'</string>'
|
||||
else
|
||||
step "No app icon found; packaging without a custom icon"
|
||||
fi
|
||||
|
||||
cat > "${CONTENTS_DIR}/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${APP_DISPLAY_NAME}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>${EXECUTABLE_NAME}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>${BUNDLE_IDENTIFIER}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
${PLIST_ICON_ENTRY}
|
||||
<key>CFBundleName</key>
|
||||
<string>${APP_DISPLAY_NAME}</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
</plist>
|
||||
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."
|
||||
Reference in New Issue
Block a user