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