Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbf5f191f2 | ||
|
|
18d7d56345 | ||
|
|
dce0880576 | ||
|
|
8bdebc5c24 | ||
|
|
e28d45ae18 | ||
|
|
60b4f1b916 | ||
|
|
55235ba90d | ||
|
|
ece652b2e4 | ||
|
|
20c541421f | ||
|
|
c3ebf95e53 |
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
#!/bin/zsh
|
||||
# Build, package, install, and launch the AI Music Studio macOS application.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP_DISPLAY_NAME="${APP_DISPLAY_NAME:-AI Music Studio}"
|
||||
EXECUTABLE_NAME="${EXECUTABLE_NAME:-music-assistant}"
|
||||
BUNDLE_IDENTIFIER="${BUNDLE_IDENTIFIER:-com.musicassistant.app}"
|
||||
APP_BUNDLE_NAME="${APP_DISPLAY_NAME}.app"
|
||||
BUILD_DIR="${PROJECT_ROOT}/.build/app"
|
||||
APP_BUNDLE="${BUILD_DIR}/${APP_BUNDLE_NAME}"
|
||||
INSTALL_DIR="${INSTALL_DIR:-/Applications}"
|
||||
INSTALL_PATH="${INSTALL_DIR}/${APP_BUNDLE_NAME}"
|
||||
CONTENTS_DIR="${APP_BUNDLE}/Contents"
|
||||
MACOS_DIR="${CONTENTS_DIR}/MacOS"
|
||||
RESOURCES_DIR="${CONTENTS_DIR}/Resources"
|
||||
|
||||
fail() {
|
||||
print -u2 -- "Error: $1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
step() {
|
||||
print -- "==> $1"
|
||||
}
|
||||
|
||||
find_app_icon() {
|
||||
if [[ -n "${APP_ICON:-}" ]]; then
|
||||
[[ -f "${APP_ICON}" ]] || fail "App icon was not found at ${APP_ICON}."
|
||||
print -- "${APP_ICON}"
|
||||
return
|
||||
fi
|
||||
|
||||
local candidate
|
||||
for candidate in \
|
||||
"${PROJECT_ROOT}/Resources/AI Music Studio.icns" \
|
||||
"${PROJECT_ROOT}/Resources/AppIcon.icns" \
|
||||
"${PROJECT_ROOT}/Resources/MusicAssistant.icns"; do
|
||||
if [[ -f "${candidate}" ]]; then
|
||||
print -- "${candidate}"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
[[ -f "${PROJECT_ROOT}/Package.swift" ]] || fail "Package.swift was not found in ${PROJECT_ROOT}."
|
||||
command -v swift >/dev/null 2>&1 || fail "Swift is required. Install Xcode or the Xcode Command Line Tools."
|
||||
command -v codesign >/dev/null 2>&1 || fail "codesign is required and should be available with Xcode."
|
||||
command -v ditto >/dev/null 2>&1 || fail "ditto is required and should be available on macOS."
|
||||
command -v open >/dev/null 2>&1 || fail "open is required and should be available on macOS."
|
||||
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
step "Stopping any running ${APP_DISPLAY_NAME} application"
|
||||
if pgrep -x "${EXECUTABLE_NAME}" >/dev/null 2>&1; then
|
||||
pkill -x "${EXECUTABLE_NAME}" || fail "Could not stop the running ${APP_DISPLAY_NAME} application."
|
||||
for _ in {1..20}; do
|
||||
pgrep -x "${EXECUTABLE_NAME}" >/dev/null 2>&1 || break
|
||||
sleep 0.1
|
||||
done
|
||||
pgrep -x "${EXECUTABLE_NAME}" >/dev/null 2>&1 && fail "${APP_DISPLAY_NAME} is still running. Quit it and retry."
|
||||
fi
|
||||
|
||||
step "Removing stale installed copy"
|
||||
if [[ -e "${INSTALL_PATH}" ]]; then
|
||||
rm -rf "${INSTALL_PATH}" || fail "Could not remove ${INSTALL_PATH}. Check permissions and retry."
|
||||
fi
|
||||
|
||||
step "Cleaning local Swift build artifacts"
|
||||
swift package clean || fail "Swift Package Manager could not clean the local build artifacts."
|
||||
|
||||
step "Building ${EXECUTABLE_NAME} in release mode"
|
||||
BIN_PATH="$(swift build -c release --show-bin-path 2>/dev/null | tail -n1)"
|
||||
SOURCE_EXECUTABLE="${BIN_PATH}/${EXECUTABLE_NAME}"
|
||||
|
||||
built=0
|
||||
for attempt in 1 2 3; do
|
||||
swift build -c release || fail "Release build failed. Review the Swift compiler diagnostics above."
|
||||
if [[ -x "${SOURCE_EXECUTABLE}" ]]; then
|
||||
built=1
|
||||
break
|
||||
fi
|
||||
print -- "==> Executable not present after build (attempt ${attempt}); retrying"
|
||||
sleep 1
|
||||
done
|
||||
[[ "${built}" -eq 1 ]] || fail "Built executable was not found at ${SOURCE_EXECUTABLE}."
|
||||
|
||||
step "Packaging ${APP_BUNDLE_NAME}"
|
||||
rm -rf "${APP_BUNDLE}"
|
||||
mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}"
|
||||
cp "${SOURCE_EXECUTABLE}" "${MACOS_DIR}/${EXECUTABLE_NAME}"
|
||||
|
||||
ICON_PATH="$(find_app_icon)"
|
||||
PLIST_ICON_ENTRY=""
|
||||
if [[ -n "${ICON_PATH}" ]]; then
|
||||
ICON_FILE_NAME="$(basename "${ICON_PATH}")"
|
||||
ICON_FILE_BASE="${ICON_FILE_NAME:r}"
|
||||
cp "${ICON_PATH}" "${RESOURCES_DIR}/${ICON_FILE_NAME}"
|
||||
PLIST_ICON_ENTRY=$' <key>CFBundleIconFile</key>\n <string>'"${ICON_FILE_BASE}"$'</string>'
|
||||
else
|
||||
step "No app icon found; packaging without a custom icon"
|
||||
fi
|
||||
|
||||
cat > "${CONTENTS_DIR}/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${APP_DISPLAY_NAME}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>${EXECUTABLE_NAME}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>${BUNDLE_IDENTIFIER}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
${PLIST_ICON_ENTRY}
|
||||
<key>CFBundleName</key>
|
||||
<string>${APP_DISPLAY_NAME}</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
plutil -lint "${CONTENTS_DIR}/Info.plist" >/dev/null || fail "Generated Info.plist is invalid."
|
||||
codesign --force --sign - --timestamp=none "${APP_BUNDLE}" || fail "Ad-hoc signing of ${APP_BUNDLE_NAME} failed."
|
||||
codesign --verify --deep --strict "${APP_BUNDLE}" || fail "Packaged ${APP_BUNDLE_NAME} failed code-signature verification."
|
||||
[[ -x "${MACOS_DIR}/${EXECUTABLE_NAME}" ]] || fail "Packaged executable is missing."
|
||||
|
||||
step "Installing ${APP_BUNDLE_NAME} in ${INSTALL_DIR}"
|
||||
ditto "${APP_BUNDLE}" "${INSTALL_PATH}" || fail "Could not install ${APP_BUNDLE_NAME} in ${INSTALL_DIR}. Check permissions and retry."
|
||||
|
||||
INSTALLED_EXECUTABLE="${INSTALL_PATH}/Contents/MacOS/${EXECUTABLE_NAME}"
|
||||
[[ -x "${INSTALLED_EXECUTABLE}" ]] || fail "Installed executable is missing at ${INSTALLED_EXECUTABLE}."
|
||||
SOURCE_HASH="$(shasum -a 256 "${MACOS_DIR}/${EXECUTABLE_NAME}" | awk '{print $1}')"
|
||||
INSTALLED_HASH="$(shasum -a 256 "${INSTALLED_EXECUTABLE}" | awk '{print $1}')"
|
||||
[[ "${SOURCE_HASH}" == "${INSTALLED_HASH}" ]] || fail "Installed executable does not match the newly built executable."
|
||||
|
||||
step "Launching ${APP_DISPLAY_NAME}"
|
||||
open -n "${INSTALL_PATH}" || fail "${APP_DISPLAY_NAME} was installed but could not be opened."
|
||||
|
||||
print -- "==> ${APP_DISPLAY_NAME} was rebuilt, installed, and launched successfully."
|
||||
Reference in New Issue
Block a user