Compare commits

..
10 Commits
Author SHA1 Message Date
diyaa bbf5f191f2 Merge master into main 2026-09-13 20:23:35 +02:00
diyaa 18d7d56345 Initial commit 2026-09-13 20:21:10 +02:00
diya dce0880576 Add macOS app packaging script 2026-09-13 14:40:09 +02:00
Codex 8bdebc5c24 Implement snapshot version history 2026-09-13 14:28:43 +02:00
Codex e28d45ae18 Implement song project list 2026-09-13 14:17:38 +02:00
Codex 60b4f1b916 Implement local song project store 2026-09-13 13:56:59 +02:00
Codex 55235ba90d Add song project serialization tests 2026-09-13 13:50:13 +02:00
Codex ece652b2e4 Model emotional arc and production directions 2026-09-13 13:48:05 +02:00
Codex 20c541421f Model manual auto musical parameters 2026-09-13 13:33:30 +02:00
Codex c3ebf95e53 Model language and pronunciation settings 2026-09-13 13:29:15 +02:00
28 changed files with 3646 additions and 12 deletions
+7
View File
@@ -14,3 +14,10 @@ Current environment keys:
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.
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build dev test
.PHONY: build dev test install-app
build:
swift build
@@ -8,3 +8,6 @@ dev:
test:
swift test
install-app:
scripts/build-install-launch.zsh
@@ -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 })
}
}
@@ -1,3 +1,10 @@
<<<<<<< HEAD
import SwiftUI
struct RootView: View {
var body: some View {
ProjectBrowserView()
=======
import MusicAssistantCore
import SwiftUI
@@ -37,6 +44,7 @@ struct RootView: View {
.padding(32)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
>>>>>>> origin/master
}
}
@@ -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)
}
}
@@ -2,14 +2,30 @@ import Foundation
public struct AppConfiguration: Equatable, Sendable {
public let openAIAPIKey: String?
<<<<<<< HEAD
public let privateApplicationRules: PrivateApplicationRules?
public init(
openAIAPIKey: String?,
privateApplicationRules: PrivateApplicationRules? = nil
) {
self.openAIAPIKey = openAIAPIKey
self.privateApplicationRules = privateApplicationRules
=======
public init(openAIAPIKey: String?) {
self.openAIAPIKey = openAIAPIKey
>>>>>>> origin/master
}
public static func development(environment: [String: String] = ProcessInfo.processInfo.environment) -> AppConfiguration {
AppConfiguration(
<<<<<<< HEAD
openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"],
privateApplicationRules: PrivateApplicationRules(content: environment["MUSIC_ASSISTANT_PRIVATE_AI_RULES"])
=======
openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"]
>>>>>>> origin/master
)
}
}
@@ -109,6 +109,123 @@ public extension SongProject {
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)
}
@@ -207,6 +324,58 @@ public extension SongProject {
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 }
}
<<<<<<< HEAD
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
}
=======
>>>>>>> origin/master
mutating func appendSection(_ section: SongSection) {
var orderedSection = section
orderedSection.order = nextSectionOrder
@@ -299,16 +468,39 @@ public struct DialectSelection: Codable, Equatable, Identifiable, Sendable {
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, userNotes: String? = nil, 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
@@ -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)
}
@@ -1,6 +1,124 @@
public protocol ProjectStore: Sendable {
associatedtype Project
import Foundation
func save(_ project: Project) async throws
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)
}
@@ -1,3 +1,173 @@
<<<<<<< HEAD
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
=======
public protocol AIService: Sendable {
func planSongProject(from request: SongPlanningRequest) async throws -> SongPlanningDraft
}
@@ -15,5 +185,6 @@ public struct SongPlanningDraft: Equatable, Sendable {
public init(summary: String) {
self.summary = summary
>>>>>>> origin/master
}
}
@@ -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,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."]
)
}
}
@@ -4,15 +4,28 @@ import XCTest
final class AppConfigurationTests: XCTestCase {
func testDevelopmentConfigurationReadsKnownEnvironmentKeys() {
let configuration = AppConfiguration.development(environment: [
<<<<<<< HEAD
"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")
=======
"MUSIC_ASSISTANT_OPENAI_API_KEY": "openai-test-key"
])
XCTAssertEqual(configuration.openAIAPIKey, "openai-test-key")
>>>>>>> origin/master
}
func testDevelopmentConfigurationAllowsMissingSecrets() {
let configuration = AppConfiguration.development(environment: [:])
XCTAssertNil(configuration.openAIAPIKey)
<<<<<<< HEAD
XCTAssertNil(configuration.privateApplicationRules)
=======
>>>>>>> origin/master
}
}
@@ -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,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))
}
}
@@ -1,3 +1,4 @@
import Foundation
import MusicAssistantCore
import XCTest
@@ -88,6 +89,97 @@ final class SongProjectModelTests: XCTestCase {
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"])
}
<<<<<<< HEAD
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)
}
=======
>>>>>>> origin/master
func testGenreStylesAreModeledAsMultiSelect() {
let arabicPop = GenreStyle(id: "arabic-pop", name: "Arabic Pop")
let synthwave = GenreStyle(id: "synthwave", name: "Synthwave")
@@ -235,4 +327,238 @@ final class SongProjectModelTests: XCTestCase {
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?"])
}
}
+31 -7
View File
@@ -25,16 +25,32 @@ requirement is missing and blocks implementation, record it in
- [x] Model song sections with order and optional timestamps.
- [x] Model instruments, variants, roles and placements.
- [x] Model multiple vocalists and section assignments.
- [ ] Model language/dialect and Arabic pronunciation settings.
- [ ] Model BPM, key, scale and maqam with Manual/Auto state.
- [ ] Model emotional arc and production directions.
- [ ] Add serialization tests.
- [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
- [ ] Create new/open/save song projects locally.
- [ ] Implement project list.
- [ ] Implement immutable or snapshot-based version history.
- [x] Create new/open/save song projects locally.
- [x] Implement project list.
- [x] Implement immutable or snapshot-based version history.
<<<<<<< HEAD
- [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
=======
- [ ] Restore a previous version without destroying later versions.
- [ ] Add autosave strategy that does not create excessive versions.
@@ -49,14 +65,22 @@ requirement is missing and blocks implementation, record it in
timestamp-based placement.
- [ ] Add Manual/Auto toggles for supported controls.
- [ ] Build final review screen where every generated field can be
>>>>>>> origin/master
edited.
## Phase 4 --- OpenAI Integration
<<<<<<< HEAD
- [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.
=======
- [ ] Create provider-independent `AIService` interface.
- [ ] Implement OpenAI client.
- [ ] Define private application-rule injection mechanism.
- [ ] Define structured AI response schema for SongProject updates.
>>>>>>> origin/master
- [ ] Implement idea → complete project generation.
- [ ] Implement existing lyrics → correction/improvement flow.
- [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam
+156
View File
@@ -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."