Initial commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MusicAssistantApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
}
|
||||
.windowStyle(.titleBar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import MusicAssistantCore
|
||||
import SwiftUI
|
||||
|
||||
struct ArrangementEditorView: View {
|
||||
@Binding var project: SongProject
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
let selectedInstrumentIndices = project.instruments.indices.filter { project.instruments[$0].selected }
|
||||
|
||||
if selectedInstrumentIndices.isEmpty {
|
||||
Text("Select instruments before arranging entries.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(selectedInstrumentIndices, id: \.self) { index in
|
||||
InstrumentArrangementCard(
|
||||
instrument: $project.instruments[index],
|
||||
sections: project.orderedSections
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct InstrumentArrangementCard: View {
|
||||
@Binding var instrument: InstrumentTrack
|
||||
let sections: [SongSection]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text(instrumentDisplayName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: addPlacement) {
|
||||
Label("Add placement", systemImage: "plus.circle.fill")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Add placement")
|
||||
}
|
||||
|
||||
Toggle("Auto arrangement", isOn: $instrument.autoArrangementEnabled)
|
||||
TextField("Role", text: optionalStringBinding(\.role))
|
||||
TextField("Playing style", text: optionalStringBinding(\.playingStyle))
|
||||
|
||||
if instrument.placements.isEmpty {
|
||||
Text("No placements yet")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(instrument.placements.indices, id: \.self) { placementIndex in
|
||||
InstrumentPlacementEditor(
|
||||
placement: $instrument.placements[placementIndex],
|
||||
sections: sections,
|
||||
removeAction: {
|
||||
instrument.placements.remove(at: placementIndex)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var instrumentDisplayName: String {
|
||||
if let variant = instrument.variant, !variant.isEmpty {
|
||||
return variant
|
||||
}
|
||||
|
||||
return instrument.instrumentId
|
||||
}
|
||||
|
||||
private func addPlacement() {
|
||||
instrument.placements.append(
|
||||
InstrumentPlacement(sectionId: sections.first?.id)
|
||||
)
|
||||
}
|
||||
|
||||
private func optionalStringBinding(_ keyPath: WritableKeyPath<InstrumentTrack, String?>) -> Binding<String> {
|
||||
Binding {
|
||||
instrument[keyPath: keyPath] ?? ""
|
||||
} set: { newValue in
|
||||
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
instrument[keyPath: keyPath] = trimmedValue.isEmpty ? nil : trimmedValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct InstrumentPlacementEditor: View {
|
||||
@Binding var placement: InstrumentPlacement
|
||||
let sections: [SongSection]
|
||||
let removeAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Picker("Section", selection: sectionIDBinding) {
|
||||
Text("No section").tag("")
|
||||
ForEach(sections) { section in
|
||||
Text(sectionLabel(for: section)).tag(section.id)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: removeAction) {
|
||||
Label("Remove placement", systemImage: "trash")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(.red)
|
||||
.help("Remove placement")
|
||||
}
|
||||
|
||||
TextField("Start seconds", text: optionalTimeBinding(\.startTime))
|
||||
TextField("End seconds", text: optionalTimeBinding(\.endTime))
|
||||
TextField("Direction", text: optionalStringBinding(\.direction), axis: .vertical)
|
||||
.lineLimit(1...3)
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color(nsColor: .windowBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var sectionIDBinding: Binding<String> {
|
||||
Binding {
|
||||
placement.sectionId ?? ""
|
||||
} set: { newValue in
|
||||
placement.sectionId = newValue.isEmpty ? nil : newValue
|
||||
}
|
||||
}
|
||||
|
||||
private func optionalTimeBinding(_ keyPath: WritableKeyPath<InstrumentPlacement, TimeInterval?>) -> Binding<String> {
|
||||
Binding {
|
||||
guard let value = placement[keyPath: keyPath] else { return "" }
|
||||
return String(format: "%.0f", value)
|
||||
} set: { newValue in
|
||||
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
placement[keyPath: keyPath] = trimmedValue.isEmpty ? nil : TimeInterval(trimmedValue)
|
||||
}
|
||||
}
|
||||
|
||||
private func optionalStringBinding(_ keyPath: WritableKeyPath<InstrumentPlacement, String?>) -> Binding<String> {
|
||||
Binding {
|
||||
placement[keyPath: keyPath] ?? ""
|
||||
} set: { newValue in
|
||||
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
placement[keyPath: keyPath] = trimmedValue.isEmpty ? nil : trimmedValue
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionLabel(for section: SongSection) -> String {
|
||||
"\(section.order + 1). \(section.title) (\(section.type.displayName))"
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ArrangementEditorPreview()
|
||||
}
|
||||
|
||||
private struct ArrangementEditorPreview: View {
|
||||
@State private var project = SongProject(
|
||||
title: "Arrangement Preview",
|
||||
idea: "Place instruments",
|
||||
sections: [
|
||||
SongSection(type: .intro, title: "Intro"),
|
||||
SongSection(type: .verse, title: "Verse", order: 1)
|
||||
],
|
||||
instruments: [
|
||||
InstrumentTrack(
|
||||
instrumentId: "piano",
|
||||
selected: true,
|
||||
variant: "Piano",
|
||||
role: "Harmony bed",
|
||||
placements: [
|
||||
InstrumentPlacement(sectionId: nil, startTime: 0, endTime: 12, direction: "Soft opening")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
ArrangementEditorView(project: $project)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 380)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import MusicAssistantCore
|
||||
import SwiftUI
|
||||
|
||||
struct ChatWorkspaceView: View {
|
||||
@State private var project: SongProject
|
||||
let saveProject: (SongProject) async -> Bool
|
||||
|
||||
@State private var messages: [ChatWorkspaceMessage]
|
||||
@State private var draftMessage = ""
|
||||
@FocusState private var isComposerFocused: Bool
|
||||
|
||||
init(project: SongProject, saveProject: @escaping (SongProject) async -> Bool = { _ in true }) {
|
||||
_project = State(initialValue: project)
|
||||
self.saveProject = saveProject
|
||||
_messages = State(initialValue: Self.initialMessages(for: project))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
VStack(spacing: 0) {
|
||||
ChatWorkspaceHeader(project: project)
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 16) {
|
||||
ForEach(messages) { message in
|
||||
ChatMessageBubble(message: message)
|
||||
.id(message.id)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(.background)
|
||||
.onChange(of: messages.count) { _, _ in
|
||||
guard let lastMessageID = messages.last?.id else { return }
|
||||
withAnimation(.snappy) {
|
||||
proxy.scrollTo(lastMessageID, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ChatComposer(
|
||||
text: $draftMessage,
|
||||
isFocused: $isComposerFocused,
|
||||
sendAction: sendMessage
|
||||
)
|
||||
.padding(16)
|
||||
}
|
||||
.frame(minWidth: 520, maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
ProjectInspectorView(project: $project, saveProject: saveProject)
|
||||
.frame(minWidth: 300, idealWidth: 340, maxWidth: 420, maxHeight: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear {
|
||||
isComposerFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
private func sendMessage() {
|
||||
let trimmedMessage = draftMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedMessage.isEmpty else { return }
|
||||
|
||||
messages.append(ChatWorkspaceMessage(role: .user, text: trimmedMessage))
|
||||
draftMessage = ""
|
||||
}
|
||||
|
||||
private static func initialMessages(for project: SongProject) -> [ChatWorkspaceMessage] {
|
||||
let prompt: String
|
||||
|
||||
if project.idea.isEmpty {
|
||||
prompt = "What kind of song are we making?"
|
||||
} else {
|
||||
prompt = project.idea
|
||||
}
|
||||
|
||||
return [
|
||||
ChatWorkspaceMessage(role: .assistant, text: prompt)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatWorkspaceHeader: View {
|
||||
let project: SongProject
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(project.title)
|
||||
.font(.title.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
|
||||
Text("Song Project")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
ProjectMetricLabel(title: "Versions", value: project.versions.count, systemImage: "clock.arrow.circlepath")
|
||||
ProjectMetricLabel(title: "Genres", value: project.genres.count, systemImage: "music.quarternote.3")
|
||||
ProjectMetricLabel(title: "Instruments", value: project.selectedInstrumentTracks.count, systemImage: "pianokeys")
|
||||
ProjectMetricLabel(title: "Sections", value: project.sections.count, systemImage: "text.line.first.and.arrowtriangle.forward")
|
||||
ProjectMetricLabel(title: "Vocalists", value: project.vocalists.count, systemImage: "person.wave.2")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProjectMetricLabel: View {
|
||||
let title: String
|
||||
let value: Int
|
||||
let systemImage: String
|
||||
|
||||
var body: some View {
|
||||
Label {
|
||||
Text("\(value) \(title)")
|
||||
} icon: {
|
||||
Image(systemName: systemImage)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatMessageBubble: View {
|
||||
let message: ChatWorkspaceMessage
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if message.role == .user {
|
||||
Spacer(minLength: 64)
|
||||
}
|
||||
|
||||
Text(message.text)
|
||||
.font(.body)
|
||||
.foregroundStyle(message.role == .user ? .white : .primary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(message.role == .user ? Color.accentColor : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||
.frame(maxWidth: 620, alignment: message.role == .user ? .trailing : .leading)
|
||||
|
||||
if message.role == .assistant {
|
||||
Spacer(minLength: 64)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: message.role == .user ? .trailing : .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatComposer: View {
|
||||
@Binding var text: String
|
||||
var isFocused: FocusState<Bool>.Binding
|
||||
let sendAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
TextField("Describe the song idea, lyrics, mood, or arrangement", text: $text, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...5)
|
||||
.focused(isFocused)
|
||||
.onSubmit(sendAction)
|
||||
|
||||
Button(action: sendAction) {
|
||||
Label("Send", systemImage: "paperplane.fill")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.help("Send")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatWorkspaceMessage: Identifiable, Equatable {
|
||||
enum Role: Equatable {
|
||||
case assistant
|
||||
case user
|
||||
}
|
||||
|
||||
let id: UUID
|
||||
let role: Role
|
||||
let text: String
|
||||
|
||||
init(id: UUID = UUID(), role: Role, text: String) {
|
||||
self.id = id
|
||||
self.role = role
|
||||
self.text = text
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ChatWorkspaceView(
|
||||
project: SongProject(
|
||||
title: "Untitled Song",
|
||||
idea: "A cinematic Arabic pop song with oud, violin, and a hopeful chorus."
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import MusicAssistantCore
|
||||
import SwiftUI
|
||||
|
||||
struct FinalReviewView: View {
|
||||
@Binding var project: SongProject
|
||||
|
||||
@State private var newFieldKey = ""
|
||||
@State private var newFieldValue = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
TextField("Approved lyrics", text: $project.lyrics.text, axis: .vertical)
|
||||
.lineLimit(4...10)
|
||||
|
||||
if !project.orderedSections.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Section lyrics")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(project.orderedSections) { section in
|
||||
TextField(section.title, text: sectionLyricsBinding(for: section.id), axis: .vertical)
|
||||
.lineLimit(2...5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Text("Suno fields")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
ensureSunoOutput()
|
||||
} label: {
|
||||
Label("Prepare fields", systemImage: "wand.and.stars")
|
||||
}
|
||||
.disabled(project.sunoOutput != nil)
|
||||
}
|
||||
|
||||
TextField("Suno lyrics", text: sunoLyricsBinding, axis: .vertical)
|
||||
.lineLimit(4...10)
|
||||
TextField("Suno style prompt", text: sunoStylePromptBinding, axis: .vertical)
|
||||
.lineLimit(3...8)
|
||||
|
||||
if !additionalFieldKeys.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Additional fields")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(additionalFieldKeys, id: \.self) { key in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
TextField("Field", text: additionalFieldKeyBinding(for: key))
|
||||
TextField("Value", text: additionalFieldValueBinding(for: key), axis: .vertical)
|
||||
.lineLimit(1...4)
|
||||
|
||||
Button {
|
||||
project.sunoOutput?.additionalFields.removeValue(forKey: key)
|
||||
} label: {
|
||||
Label("Remove field", systemImage: "minus.circle")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove field")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
TextField("Field name", text: $newFieldKey)
|
||||
TextField("Value", text: $newFieldValue)
|
||||
|
||||
Button(action: addAdditionalField) {
|
||||
Label("Add field", systemImage: "plus.circle.fill")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(newFieldKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.help("Add field")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var additionalFieldKeys: [String] {
|
||||
guard let additionalFields = project.sunoOutput?.additionalFields else { return [] }
|
||||
return additionalFields.keys.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
private var sunoLyricsBinding: Binding<String> {
|
||||
Binding {
|
||||
project.sunoOutput?.lyricsText ?? ""
|
||||
} set: { newValue in
|
||||
ensureSunoOutput()
|
||||
project.sunoOutput?.lyricsText = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private var sunoStylePromptBinding: Binding<String> {
|
||||
Binding {
|
||||
project.sunoOutput?.stylePrompt ?? ""
|
||||
} set: { newValue in
|
||||
ensureSunoOutput()
|
||||
project.sunoOutput?.stylePrompt = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionLyricsBinding(for sectionID: String) -> Binding<String> {
|
||||
Binding {
|
||||
project.lyrics.sectionTexts[sectionID] ?? ""
|
||||
} set: { newValue in
|
||||
if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
project.lyrics.sectionTexts.removeValue(forKey: sectionID)
|
||||
} else {
|
||||
project.lyrics.sectionTexts[sectionID] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func additionalFieldKeyBinding(for key: String) -> Binding<String> {
|
||||
Binding {
|
||||
key
|
||||
} set: { newKey in
|
||||
let trimmedKey = newKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedKey.isEmpty, trimmedKey != key else { return }
|
||||
ensureSunoOutput()
|
||||
let value = project.sunoOutput?.additionalFields.removeValue(forKey: key) ?? ""
|
||||
project.sunoOutput?.additionalFields[trimmedKey] = value
|
||||
}
|
||||
}
|
||||
|
||||
private func additionalFieldValueBinding(for key: String) -> Binding<String> {
|
||||
Binding {
|
||||
project.sunoOutput?.additionalFields[key] ?? ""
|
||||
} set: { newValue in
|
||||
ensureSunoOutput()
|
||||
project.sunoOutput?.additionalFields[key] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private func addAdditionalField() {
|
||||
let key = newFieldKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return }
|
||||
|
||||
ensureSunoOutput()
|
||||
project.sunoOutput?.additionalFields[key] = newFieldValue
|
||||
newFieldKey = ""
|
||||
newFieldValue = ""
|
||||
}
|
||||
|
||||
private func ensureSunoOutput() {
|
||||
guard project.sunoOutput == nil else { return }
|
||||
project.sunoOutput = SunoOutput(
|
||||
lyricsText: project.lyrics.text,
|
||||
stylePrompt: "",
|
||||
generatedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
FinalReviewPreview()
|
||||
}
|
||||
|
||||
private struct FinalReviewPreview: View {
|
||||
@State private var project = SongProject(
|
||||
title: "Review",
|
||||
idea: "Review generated fields",
|
||||
sections: [
|
||||
SongSection(id: "verse", type: .verse, title: "Verse"),
|
||||
SongSection(id: "chorus", type: .chorus, title: "Chorus", order: 1)
|
||||
],
|
||||
lyrics: Lyrics(text: "Draft lyrics", sectionTexts: ["verse": "Verse text"]),
|
||||
sunoOutput: SunoOutput(
|
||||
lyricsText: "Draft lyrics",
|
||||
stylePrompt: "Cinematic pop with oud",
|
||||
additionalFields: ["mood": "Hopeful"]
|
||||
)
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
FinalReviewView(project: $project)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 420)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import MusicAssistantCore
|
||||
import SwiftUI
|
||||
|
||||
struct MusicalParametersEditorView: View {
|
||||
@Binding var project: SongProject
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ManualAutoIntegerRow(
|
||||
title: "BPM",
|
||||
value: manualAutoIntegerBinding(\.bpm)
|
||||
)
|
||||
ManualAutoTextRow(
|
||||
title: "Key",
|
||||
value: manualAutoTextBinding(\.key),
|
||||
placeholder: "D"
|
||||
)
|
||||
ManualAutoTextRow(
|
||||
title: "Scale",
|
||||
value: manualAutoTextBinding(\.scale),
|
||||
placeholder: "minor"
|
||||
)
|
||||
ManualAutoTextRow(
|
||||
title: "Maqam",
|
||||
value: manualAutoTextBinding(\.maqam),
|
||||
placeholder: "Hijaz"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func manualAutoIntegerBinding(
|
||||
_ keyPath: WritableKeyPath<SongProject, ManualAutoValue<Int>?>
|
||||
) -> Binding<ManualAutoValue<Int>> {
|
||||
Binding {
|
||||
project[keyPath: keyPath] ?? ManualAutoValue(mode: .auto)
|
||||
} set: { newValue in
|
||||
project[keyPath: keyPath] = newValue
|
||||
}
|
||||
}
|
||||
|
||||
private func manualAutoTextBinding(
|
||||
_ keyPath: WritableKeyPath<SongProject, ManualAutoValue<String>?>
|
||||
) -> Binding<ManualAutoValue<String>> {
|
||||
Binding {
|
||||
project[keyPath: keyPath] ?? ManualAutoValue(mode: .auto)
|
||||
} set: { newValue in
|
||||
project[keyPath: keyPath] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ManualAutoIntegerRow: View {
|
||||
let title: String
|
||||
@Binding var value: ManualAutoValue<Int>
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ManualAutoPicker(title: title, mode: modeBinding)
|
||||
|
||||
if value.mode == .manual {
|
||||
TextField(title, text: integerTextBinding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var modeBinding: Binding<ControlMode> {
|
||||
Binding {
|
||||
value.mode
|
||||
} set: { newMode in
|
||||
value.mode = newMode
|
||||
if newMode == .auto {
|
||||
value.value = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var integerTextBinding: Binding<String> {
|
||||
Binding {
|
||||
value.value.map(String.init) ?? ""
|
||||
} set: { newValue in
|
||||
value.value = Int(newValue.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ManualAutoTextRow: View {
|
||||
let title: String
|
||||
@Binding var value: ManualAutoValue<String>
|
||||
let placeholder: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ManualAutoPicker(title: title, mode: modeBinding)
|
||||
|
||||
if value.mode == .manual {
|
||||
TextField(placeholder, text: textBinding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var modeBinding: Binding<ControlMode> {
|
||||
Binding {
|
||||
value.mode
|
||||
} set: { newMode in
|
||||
value.mode = newMode
|
||||
if newMode == .auto {
|
||||
value.value = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var textBinding: Binding<String> {
|
||||
Binding {
|
||||
value.value ?? ""
|
||||
} set: { newValue in
|
||||
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
value.value = trimmedValue.isEmpty ? nil : trimmedValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ManualAutoPicker: View {
|
||||
let title: String
|
||||
@Binding var mode: ControlMode
|
||||
|
||||
var body: some View {
|
||||
Picker(title, selection: $mode) {
|
||||
Text("Auto").tag(ControlMode.auto)
|
||||
Text("Manual").tag(ControlMode.manual)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MusicalParametersEditorPreview()
|
||||
}
|
||||
|
||||
private struct MusicalParametersEditorPreview: View {
|
||||
@State private var project = SongProject(
|
||||
title: "Parameters",
|
||||
idea: "Preview",
|
||||
bpm: ManualAutoValue(mode: .manual, value: 96),
|
||||
key: ManualAutoValue(mode: .auto),
|
||||
scale: ManualAutoValue(mode: .manual, value: "minor"),
|
||||
maqam: ManualAutoValue(mode: .auto)
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
MusicalParametersEditorView(project: $project)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 340)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import Foundation
|
||||
import MusicAssistantCore
|
||||
import SwiftUI
|
||||
|
||||
struct ProjectBrowserView: View {
|
||||
@StateObject private var viewModel: ProjectBrowserViewModel
|
||||
@State private var selectedProjectID: String?
|
||||
|
||||
init(store: any ProjectStore = LocalSongProjectStore(directoryURL: ProjectBrowserStorage.projectsDirectoryURL)) {
|
||||
_viewModel = StateObject(wrappedValue: ProjectBrowserViewModel(store: store))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $selectedProjectID) {
|
||||
Section("Projects") {
|
||||
ForEach(viewModel.projects) { project in
|
||||
ProjectListRow(project: project)
|
||||
.tag(project.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("AI Music Studio")
|
||||
.overlay {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else if viewModel.projects.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Projects",
|
||||
systemImage: "music.note.list",
|
||||
description: Text("Create a song project to begin.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
Task {
|
||||
if let projectID = await viewModel.createProject() {
|
||||
selectedProjectID = projectID
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("New Project", systemImage: "plus")
|
||||
}
|
||||
.disabled(viewModel.isCreatingProject)
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
ProjectBrowserDetailView(
|
||||
project: viewModel.selectedProject,
|
||||
errorMessage: viewModel.errorMessage,
|
||||
isLoading: viewModel.isOpeningProject,
|
||||
saveProject: viewModel.saveProject
|
||||
)
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadProjects()
|
||||
selectedProjectID = selectedProjectID ?? viewModel.projects.first?.id
|
||||
if let selectedProjectID {
|
||||
await viewModel.openProject(id: selectedProjectID)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedProjectID) { _, projectID in
|
||||
guard let projectID else {
|
||||
viewModel.clearSelection()
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await viewModel.openProject(id: projectID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProjectListRow: View {
|
||||
let project: SongProjectListItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(project.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(project.idea.isEmpty ? "No idea yet" : project.idea)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProjectBrowserDetailView: View {
|
||||
let project: SongProject?
|
||||
let errorMessage: String?
|
||||
let isLoading: Bool
|
||||
let saveProject: (SongProject) async -> Bool
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let project {
|
||||
ChatWorkspaceView(project: project, saveProject: saveProject)
|
||||
.id(project.id)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Select a Project",
|
||||
systemImage: "music.note",
|
||||
description: Text("Choose a saved song project from the browser.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.red, in: RoundedRectangle(cornerRadius: 8))
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ProjectBrowserViewModel: ObservableObject {
|
||||
@Published private(set) var projects: [SongProjectListItem] = []
|
||||
@Published private(set) var selectedProject: SongProject?
|
||||
@Published private(set) var isLoading = false
|
||||
@Published private(set) var isOpeningProject = false
|
||||
@Published private(set) var isCreatingProject = false
|
||||
@Published private(set) var errorMessage: String?
|
||||
|
||||
private let store: any ProjectStore
|
||||
|
||||
init(store: any ProjectStore) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
func loadProjects() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
projects = try await store.loadProjectList()
|
||||
} catch {
|
||||
errorMessage = "Unable to load projects."
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func openProject(id projectID: String) async {
|
||||
isOpeningProject = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
selectedProject = try await store.open(id: projectID)
|
||||
} catch {
|
||||
selectedProject = nil
|
||||
errorMessage = "Unable to open this project."
|
||||
}
|
||||
|
||||
isOpeningProject = false
|
||||
}
|
||||
|
||||
func createProject() async -> String? {
|
||||
isCreatingProject = true
|
||||
errorMessage = nil
|
||||
|
||||
let now = Date()
|
||||
let project = SongProject(
|
||||
title: "Untitled Song",
|
||||
idea: "",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
)
|
||||
|
||||
do {
|
||||
try await store.create(project)
|
||||
projects = try await store.loadProjectList()
|
||||
selectedProject = project
|
||||
isCreatingProject = false
|
||||
return project.id
|
||||
} catch {
|
||||
errorMessage = "Unable to create a new project."
|
||||
isCreatingProject = false
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func saveProject(_ project: SongProject) async -> Bool {
|
||||
var projectToSave = project
|
||||
projectToSave.updatedAt = Date()
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
try await store.save(projectToSave)
|
||||
projects = try await store.loadProjectList()
|
||||
selectedProject = projectToSave
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = "Unable to save this project."
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func clearSelection() {
|
||||
selectedProject = nil
|
||||
}
|
||||
}
|
||||
|
||||
enum ProjectBrowserStorage {
|
||||
static var projectsDirectoryURL: URL {
|
||||
let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
|
||||
return baseURL
|
||||
.appendingPathComponent("AI Music Studio", isDirectory: true)
|
||||
.appendingPathComponent("Projects", isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ProjectBrowserView()
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import MusicAssistantCore
|
||||
import SwiftUI
|
||||
|
||||
struct ProjectInspectorView: View {
|
||||
@Binding var project: SongProject
|
||||
let saveProject: (SongProject) async -> Bool
|
||||
|
||||
@State private var newGenreName = ""
|
||||
@State private var newInstrumentName = ""
|
||||
@State private var newVocalistLabel = ""
|
||||
@State private var newLanguageName = ""
|
||||
@State private var isSaving = false
|
||||
@State private var saveStatus: SaveStatus?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Label("Inspector", systemImage: "slider.horizontal.3")
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await save()
|
||||
}
|
||||
} label: {
|
||||
Label("Save", systemImage: "tray.and.arrow.down")
|
||||
}
|
||||
.disabled(isSaving)
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
Divider()
|
||||
|
||||
Form {
|
||||
Section("Overview") {
|
||||
TextField("Title", text: $project.title)
|
||||
TextField("Idea", text: $project.idea, axis: .vertical)
|
||||
.lineLimit(2...4)
|
||||
TextField("Duration seconds", text: durationSecondsBinding)
|
||||
TextField("Duration note", text: durationDescriptionBinding)
|
||||
}
|
||||
|
||||
Section("Genres") {
|
||||
EditableTokenList(
|
||||
values: project.genres.map(\.name),
|
||||
removeAction: { index in
|
||||
project.deselectGenreStyle(id: project.genres[index].id)
|
||||
}
|
||||
)
|
||||
AddTextRow(
|
||||
placeholder: "Add genre",
|
||||
text: $newGenreName,
|
||||
addAction: addGenre
|
||||
)
|
||||
}
|
||||
|
||||
Section("Instruments") {
|
||||
ForEach($project.instruments, id: \.instrumentId) { $instrument in
|
||||
Toggle(isOn: $instrument.selected) {
|
||||
Text(instrument.variant?.isEmpty == false ? instrument.variant ?? instrument.instrumentId : instrument.instrumentId)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
AddTextRow(
|
||||
placeholder: "Add instrument",
|
||||
text: $newInstrumentName,
|
||||
addAction: addInstrument
|
||||
)
|
||||
}
|
||||
|
||||
Section("Vocalists") {
|
||||
EditableTokenList(
|
||||
values: project.vocalists.map(\.label),
|
||||
removeAction: { index in
|
||||
project.removeVocalist(id: project.vocalists[index].id)
|
||||
}
|
||||
)
|
||||
AddTextRow(
|
||||
placeholder: "Add vocalist",
|
||||
text: $newVocalistLabel,
|
||||
addAction: addVocalist
|
||||
)
|
||||
}
|
||||
|
||||
Section("Languages") {
|
||||
EditableTokenList(
|
||||
values: project.languages.map(\.name),
|
||||
removeAction: { index in
|
||||
project.removeLanguage(id: project.languages[index].id)
|
||||
}
|
||||
)
|
||||
AddTextRow(
|
||||
placeholder: "Add language",
|
||||
text: $newLanguageName,
|
||||
addAction: addLanguage
|
||||
)
|
||||
}
|
||||
|
||||
Section("Song Structure") {
|
||||
SongStructureEditorView(project: $project)
|
||||
}
|
||||
|
||||
Section("Musical Parameters") {
|
||||
MusicalParametersEditorView(project: $project)
|
||||
}
|
||||
|
||||
Section("Arrangement") {
|
||||
ArrangementEditorView(project: $project)
|
||||
}
|
||||
|
||||
Section("Final Review") {
|
||||
FinalReviewView(project: $project)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
if let saveStatus {
|
||||
Divider()
|
||||
Label(saveStatus.title, systemImage: saveStatus.systemImage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(saveStatus.foregroundStyle)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
}
|
||||
|
||||
private var durationSecondsBinding: Binding<String> {
|
||||
Binding {
|
||||
project.duration?.seconds.map(String.init) ?? ""
|
||||
} set: { newValue in
|
||||
var duration = project.duration ?? SongDuration()
|
||||
duration.seconds = Int(newValue.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
project.duration = duration.isEmpty ? nil : duration
|
||||
}
|
||||
}
|
||||
|
||||
private var durationDescriptionBinding: Binding<String> {
|
||||
Binding {
|
||||
project.duration?.description ?? ""
|
||||
} set: { newValue in
|
||||
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var duration = project.duration ?? SongDuration()
|
||||
duration.description = trimmedValue.isEmpty ? nil : trimmedValue
|
||||
project.duration = duration.isEmpty ? nil : duration
|
||||
}
|
||||
}
|
||||
|
||||
private func addGenre() {
|
||||
let name = trimmed(newGenreName)
|
||||
guard !name.isEmpty else { return }
|
||||
project.selectGenreStyle(GenreStyle(id: stableID(from: name), name: name))
|
||||
newGenreName = ""
|
||||
}
|
||||
|
||||
private func addInstrument() {
|
||||
let name = trimmed(newInstrumentName)
|
||||
guard !name.isEmpty else { return }
|
||||
project.selectInstrument(InstrumentTrack(instrumentId: stableID(from: name), variant: name))
|
||||
newInstrumentName = ""
|
||||
}
|
||||
|
||||
private func addVocalist() {
|
||||
let label = trimmed(newVocalistLabel)
|
||||
guard !label.isEmpty else { return }
|
||||
project.upsertVocalist(Vocalist(id: stableID(from: label), label: label))
|
||||
newVocalistLabel = ""
|
||||
}
|
||||
|
||||
private func addLanguage() {
|
||||
let name = trimmed(newLanguageName)
|
||||
guard !name.isEmpty else { return }
|
||||
project.upsertLanguage(LanguageSelection(id: stableID(from: name), name: name))
|
||||
newLanguageName = ""
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
isSaving = true
|
||||
let didSave = await saveProject(project)
|
||||
isSaving = false
|
||||
saveStatus = didSave ? .saved : .failed
|
||||
}
|
||||
|
||||
private func trimmed(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func stableID(from value: String) -> String {
|
||||
let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-"))
|
||||
let lowercasedValue = value.lowercased()
|
||||
let scalars = lowercasedValue.unicodeScalars.map { scalar in
|
||||
allowedCharacters.contains(scalar) ? String(scalar) : "-"
|
||||
}
|
||||
let collapsed = scalars.joined()
|
||||
.split(separator: "-")
|
||||
.joined(separator: "-")
|
||||
return collapsed.isEmpty ? UUID().uuidString : collapsed
|
||||
}
|
||||
}
|
||||
|
||||
private struct EditableTokenList: View {
|
||||
let values: [String]
|
||||
let removeAction: (Int) -> Void
|
||||
|
||||
var body: some View {
|
||||
if values.isEmpty {
|
||||
Text("None yet")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(Array(values.enumerated()), id: \.offset) { index, value in
|
||||
HStack {
|
||||
Text(value)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
removeAction(index)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "minus.circle")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AddTextRow: View {
|
||||
let placeholder: String
|
||||
@Binding var text: String
|
||||
let addAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
TextField(placeholder, text: $text)
|
||||
.onSubmit(addAction)
|
||||
|
||||
Button(action: addAction) {
|
||||
Label("Add", systemImage: "plus.circle.fill")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.help("Add")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum SaveStatus {
|
||||
case saved
|
||||
case failed
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .saved:
|
||||
return "Saved"
|
||||
case .failed:
|
||||
return "Save failed"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .saved:
|
||||
return "checkmark.circle.fill"
|
||||
case .failed:
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var foregroundStyle: Color {
|
||||
switch self {
|
||||
case .saved:
|
||||
return .green
|
||||
case .failed:
|
||||
return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension SongDuration {
|
||||
var isEmpty: Bool {
|
||||
seconds == nil && (description?.isEmpty ?? true)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ProjectInspectorPreview()
|
||||
}
|
||||
|
||||
private struct ProjectInspectorPreview: View {
|
||||
@State private var project = SongProject(
|
||||
title: "Inspector Preview",
|
||||
idea: "A compact editor for the current song project.",
|
||||
duration: SongDuration(seconds: 180, description: "about three minutes"),
|
||||
languages: [LanguageSelection(id: "english", name: "English")],
|
||||
genres: [GenreStyle(id: "pop", name: "Pop")],
|
||||
bpm: ManualAutoValue(mode: .manual, value: 96),
|
||||
key: ManualAutoValue(mode: .auto),
|
||||
scale: ManualAutoValue(mode: .manual, value: "minor"),
|
||||
maqam: ManualAutoValue(mode: .auto),
|
||||
sections: [
|
||||
SongSection(type: .intro, title: "Intro"),
|
||||
SongSection(type: .verse, title: "Verse", order: 1)
|
||||
],
|
||||
instruments: [
|
||||
InstrumentTrack(
|
||||
instrumentId: "piano",
|
||||
variant: "Piano",
|
||||
placements: [
|
||||
InstrumentPlacement(sectionId: nil, startTime: 0, endTime: 12)
|
||||
]
|
||||
)
|
||||
],
|
||||
vocalists: [Vocalist(id: "lead", label: "Lead vocalist")],
|
||||
lyrics: Lyrics(text: "Draft lyric idea"),
|
||||
sunoOutput: SunoOutput(
|
||||
lyricsText: "Draft lyric idea",
|
||||
stylePrompt: "Pop with piano"
|
||||
)
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
ProjectInspectorView(project: $project, saveProject: { _ in true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
var body: some View {
|
||||
ProjectBrowserView()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
RootView()
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import MusicAssistantCore
|
||||
import SwiftUI
|
||||
|
||||
struct SongStructureEditorView: View {
|
||||
@Binding var project: SongProject
|
||||
|
||||
@State private var newSectionTitle = ""
|
||||
@State private var newSectionType: SongSectionType = .verse
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if project.sections.isEmpty {
|
||||
Text("No sections yet")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(project.orderedSections) { section in
|
||||
SongSectionEditorCard(
|
||||
section: binding(for: section),
|
||||
moveUpAction: {
|
||||
moveSection(id: section.id, by: -1)
|
||||
},
|
||||
moveDownAction: {
|
||||
moveSection(id: section.id, by: 1)
|
||||
},
|
||||
removeAction: {
|
||||
removeSection(id: section.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Picker("Type", selection: $newSectionType) {
|
||||
ForEach(SongSectionType.editorCases, id: \.self) { type in
|
||||
Text(type.displayName).tag(type)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
TextField("New section title", text: $newSectionTitle)
|
||||
.onSubmit(addSection)
|
||||
|
||||
Button(action: addSection) {
|
||||
Label("Add section", systemImage: "plus.circle.fill")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(newSectionTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
.help("Add section")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func binding(for section: SongSection) -> Binding<SongSection> {
|
||||
Binding {
|
||||
project.sections.first { $0.id == section.id } ?? section
|
||||
} set: { updatedSection in
|
||||
guard let index = project.sections.firstIndex(where: { $0.id == section.id }) else { return }
|
||||
project.sections[index] = updatedSection
|
||||
project.normalizeSectionOrder()
|
||||
}
|
||||
}
|
||||
|
||||
private func addSection() {
|
||||
let trimmedTitle = newSectionTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedTitle.isEmpty else { return }
|
||||
|
||||
project.appendSection(
|
||||
SongSection(
|
||||
type: newSectionType,
|
||||
title: trimmedTitle
|
||||
)
|
||||
)
|
||||
newSectionTitle = ""
|
||||
}
|
||||
|
||||
private func moveSection(id sectionID: String, by offset: Int) {
|
||||
let orderedSections = project.orderedSections
|
||||
guard let currentIndex = orderedSections.firstIndex(where: { $0.id == sectionID }) else { return }
|
||||
project.moveSection(id: sectionID, toOrder: currentIndex + offset)
|
||||
}
|
||||
|
||||
private func removeSection(id sectionID: String) {
|
||||
project.sections.removeAll { $0.id == sectionID }
|
||||
project.normalizeSectionOrder()
|
||||
}
|
||||
}
|
||||
|
||||
private struct SongSectionEditorCard: View {
|
||||
@Binding var section: SongSection
|
||||
let moveUpAction: () -> Void
|
||||
let moveDownAction: () -> Void
|
||||
let removeAction: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Text(section.title.isEmpty ? "Untitled section" : section.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: moveUpAction) {
|
||||
Label("Move up", systemImage: "chevron.up")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Move up")
|
||||
|
||||
Button(action: moveDownAction) {
|
||||
Label("Move down", systemImage: "chevron.down")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Move down")
|
||||
|
||||
Button(action: removeAction) {
|
||||
Label("Remove", systemImage: "trash")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(.red)
|
||||
.help("Remove")
|
||||
}
|
||||
|
||||
Picker("Type", selection: $section.type) {
|
||||
ForEach(SongSectionType.editorCases, id: \.self) { type in
|
||||
Text(type.displayName).tag(type)
|
||||
}
|
||||
}
|
||||
|
||||
TextField("Title", text: $section.title)
|
||||
TextField("Start seconds", text: optionalTimeBinding(\.startTime))
|
||||
TextField("End seconds", text: optionalTimeBinding(\.endTime))
|
||||
TextField("Lyrics or notes", text: $section.lyrics, axis: .vertical)
|
||||
.lineLimit(2...4)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func optionalTimeBinding(_ keyPath: WritableKeyPath<SongSection, TimeInterval?>) -> Binding<String> {
|
||||
Binding {
|
||||
guard let value = section[keyPath: keyPath] else { return "" }
|
||||
return String(format: "%.0f", value)
|
||||
} set: { newValue in
|
||||
let trimmedValue = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
section[keyPath: keyPath] = trimmedValue.isEmpty ? nil : TimeInterval(trimmedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SongSectionType {
|
||||
static let editorCases: [SongSectionType] = [
|
||||
.intro,
|
||||
.verse,
|
||||
.preChorus,
|
||||
.chorus,
|
||||
.bridge,
|
||||
.outro,
|
||||
.custom
|
||||
]
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .intro:
|
||||
return "Intro"
|
||||
case .verse:
|
||||
return "Verse"
|
||||
case .preChorus:
|
||||
return "Pre-Chorus"
|
||||
case .chorus:
|
||||
return "Chorus"
|
||||
case .bridge:
|
||||
return "Bridge"
|
||||
case .outro:
|
||||
return "Outro"
|
||||
case .custom:
|
||||
return "Custom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SongStructureEditorPreview()
|
||||
}
|
||||
|
||||
private struct SongStructureEditorPreview: View {
|
||||
@State private var project = SongProject(
|
||||
title: "Structure Preview",
|
||||
idea: "Edit sections",
|
||||
sections: [
|
||||
SongSection(type: .intro, title: "Intro"),
|
||||
SongSection(type: .verse, title: "Verse", order: 1, startTime: 12, endTime: 42),
|
||||
SongSection(type: .chorus, title: "Chorus", order: 2)
|
||||
]
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
SongStructureEditorView(project: $project)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 360)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
public struct AppConfiguration: Equatable, Sendable {
|
||||
public let openAIAPIKey: String?
|
||||
public let privateApplicationRules: PrivateApplicationRules?
|
||||
|
||||
public init(
|
||||
openAIAPIKey: String?,
|
||||
privateApplicationRules: PrivateApplicationRules? = nil
|
||||
) {
|
||||
self.openAIAPIKey = openAIAPIKey
|
||||
self.privateApplicationRules = privateApplicationRules
|
||||
}
|
||||
|
||||
public static func development(environment: [String: String] = ProcessInfo.processInfo.environment) -> AppConfiguration {
|
||||
AppConfiguration(
|
||||
openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"],
|
||||
privateApplicationRules: PrivateApplicationRules(content: environment["MUSIC_ASSISTANT_PRIVATE_AI_RULES"])
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
public struct ProductBoundarySummary: Equatable, Sendable {
|
||||
public let description: String
|
||||
|
||||
public init(description: String) {
|
||||
self.description = description
|
||||
}
|
||||
|
||||
public static let initial = ProductBoundarySummary(
|
||||
description: "Create and refine song project concepts, then prepare lyrics and style instructions for Suno handoff without starting generation."
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
import Foundation
|
||||
|
||||
public struct SongProject: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var title: String
|
||||
public var idea: String
|
||||
public var duration: SongDuration?
|
||||
public var conversationMode: ConversationMode
|
||||
public var languages: [LanguageSelection]
|
||||
public var dialects: [DialectSelection]
|
||||
public var arabicPronunciationSettings: ArabicPronunciationSettings
|
||||
public var genres: [GenreStyle]
|
||||
public var moods: [MoodTag]
|
||||
public var emotionalArc: [EmotionalArcPoint]
|
||||
public var bpm: ManualAutoValue<Int>?
|
||||
public var key: ManualAutoValue<String>?
|
||||
public var scale: ManualAutoValue<String>?
|
||||
public var maqam: ManualAutoValue<String>?
|
||||
public var sections: [SongSection]
|
||||
public var instruments: [InstrumentTrack]
|
||||
public var vocalists: [Vocalist]
|
||||
public var lyrics: Lyrics
|
||||
public var productionDirections: [ProductionDirection]
|
||||
public var sunoOutput: SunoOutput?
|
||||
public var versions: [SongVersion]
|
||||
public let createdAt: Date
|
||||
public var updatedAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
title: String,
|
||||
idea: String,
|
||||
duration: SongDuration? = nil,
|
||||
conversationMode: ConversationMode = .auto,
|
||||
languages: [LanguageSelection] = [],
|
||||
dialects: [DialectSelection] = [],
|
||||
arabicPronunciationSettings: ArabicPronunciationSettings = ArabicPronunciationSettings(),
|
||||
genres: [GenreStyle] = [],
|
||||
moods: [MoodTag] = [],
|
||||
emotionalArc: [EmotionalArcPoint] = [],
|
||||
bpm: ManualAutoValue<Int>? = nil,
|
||||
key: ManualAutoValue<String>? = nil,
|
||||
scale: ManualAutoValue<String>? = nil,
|
||||
maqam: ManualAutoValue<String>? = nil,
|
||||
sections: [SongSection] = [],
|
||||
instruments: [InstrumentTrack] = [],
|
||||
vocalists: [Vocalist] = [],
|
||||
lyrics: Lyrics = Lyrics(),
|
||||
productionDirections: [ProductionDirection] = [],
|
||||
sunoOutput: SunoOutput? = nil,
|
||||
versions: [SongVersion] = [],
|
||||
createdAt: Date = Date(),
|
||||
updatedAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.idea = idea
|
||||
self.duration = duration
|
||||
self.conversationMode = conversationMode
|
||||
self.languages = languages
|
||||
self.dialects = dialects
|
||||
self.arabicPronunciationSettings = arabicPronunciationSettings
|
||||
self.genres = genres
|
||||
self.moods = moods
|
||||
self.emotionalArc = emotionalArc
|
||||
self.bpm = bpm
|
||||
self.key = key
|
||||
self.scale = scale
|
||||
self.maqam = maqam
|
||||
self.sections = sections
|
||||
self.instruments = instruments
|
||||
self.vocalists = vocalists
|
||||
self.lyrics = lyrics
|
||||
self.productionDirections = productionDirections
|
||||
self.sunoOutput = sunoOutput
|
||||
self.versions = versions
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
public extension SongProject {
|
||||
var orderedSections: [SongSection] {
|
||||
sections
|
||||
.enumerated()
|
||||
.sorted { left, right in
|
||||
if left.element.order == right.element.order {
|
||||
return left.offset < right.offset
|
||||
}
|
||||
return left.element.order < right.element.order
|
||||
}
|
||||
.map(\.element)
|
||||
}
|
||||
|
||||
var selectedGenreStyleIDs: Set<String> {
|
||||
Set(genres.map(\.id))
|
||||
}
|
||||
|
||||
func isGenreStyleSelected(_ genreStyle: GenreStyle) -> Bool {
|
||||
selectedGenreStyleIDs.contains(genreStyle.id)
|
||||
}
|
||||
|
||||
mutating func selectGenreStyle(_ genreStyle: GenreStyle) {
|
||||
guard !isGenreStyleSelected(genreStyle) else { return }
|
||||
genres.append(genreStyle)
|
||||
}
|
||||
|
||||
mutating func deselectGenreStyle(id: String) {
|
||||
genres.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
var languageIDs: Set<String> {
|
||||
Set(languages.map(\.id))
|
||||
}
|
||||
|
||||
var dialectIDs: Set<String> {
|
||||
Set(dialects.map(\.id))
|
||||
}
|
||||
|
||||
func language(for languageID: String) -> LanguageSelection? {
|
||||
languages.first { $0.id == languageID }
|
||||
}
|
||||
|
||||
func dialect(for dialectID: String) -> DialectSelection? {
|
||||
dialects.first { $0.id == dialectID }
|
||||
}
|
||||
|
||||
func dialects(forLanguageID languageID: String) -> [DialectSelection] {
|
||||
dialects.filter { $0.languageID == languageID }
|
||||
}
|
||||
|
||||
mutating func upsertLanguage(_ language: LanguageSelection) {
|
||||
if let index = languages.firstIndex(where: { $0.id == language.id }) {
|
||||
languages[index] = language
|
||||
} else {
|
||||
languages.append(language)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func removeLanguage(id languageID: String) {
|
||||
languages.removeAll { $0.id == languageID }
|
||||
dialects.removeAll { $0.languageID == languageID }
|
||||
}
|
||||
|
||||
mutating func upsertDialect(_ dialect: DialectSelection) {
|
||||
if let index = dialects.firstIndex(where: { $0.id == dialect.id }) {
|
||||
dialects[index] = dialect
|
||||
} else {
|
||||
dialects.append(dialect)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func removeDialect(id dialectID: String) {
|
||||
dialects.removeAll { $0.id == dialectID }
|
||||
}
|
||||
|
||||
mutating func setBPMManually(_ value: Int) {
|
||||
bpm = ManualAutoValue(mode: .manual, value: value)
|
||||
}
|
||||
|
||||
mutating func setBPMAutomatic() {
|
||||
bpm = ManualAutoValue(mode: .auto)
|
||||
}
|
||||
|
||||
mutating func setKeyManually(_ value: String) {
|
||||
key = ManualAutoValue(mode: .manual, value: value)
|
||||
}
|
||||
|
||||
mutating func setKeyAutomatic() {
|
||||
key = ManualAutoValue(mode: .auto)
|
||||
}
|
||||
|
||||
mutating func setScaleManually(_ value: String) {
|
||||
scale = ManualAutoValue(mode: .manual, value: value)
|
||||
}
|
||||
|
||||
mutating func setScaleAutomatic() {
|
||||
scale = ManualAutoValue(mode: .auto)
|
||||
}
|
||||
|
||||
mutating func setMaqamManually(_ value: String) {
|
||||
maqam = ManualAutoValue(mode: .manual, value: value)
|
||||
}
|
||||
|
||||
mutating func setMaqamAutomatic() {
|
||||
maqam = ManualAutoValue(mode: .auto)
|
||||
}
|
||||
|
||||
func emotionalArcPoint(for pointID: String) -> EmotionalArcPoint? {
|
||||
emotionalArc.first { $0.id == pointID }
|
||||
}
|
||||
|
||||
func emotionalArcPoints(forSectionID sectionID: String) -> [EmotionalArcPoint] {
|
||||
emotionalArc.filter { $0.sectionID == sectionID }
|
||||
}
|
||||
|
||||
mutating func upsertEmotionalArcPoint(_ point: EmotionalArcPoint) {
|
||||
if let index = emotionalArc.firstIndex(where: { $0.id == point.id }) {
|
||||
emotionalArc[index] = point
|
||||
} else {
|
||||
emotionalArc.append(point)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func removeEmotionalArcPoint(id pointID: String) {
|
||||
emotionalArc.removeAll { $0.id == pointID }
|
||||
}
|
||||
|
||||
func productionDirection(for directionID: String) -> ProductionDirection? {
|
||||
productionDirections.first { $0.id == directionID }
|
||||
}
|
||||
|
||||
func productionDirections(forSectionID sectionID: String) -> [ProductionDirection] {
|
||||
productionDirections.filter { $0.sectionID == sectionID }
|
||||
}
|
||||
|
||||
mutating func upsertProductionDirection(_ direction: ProductionDirection) {
|
||||
if let index = productionDirections.firstIndex(where: { $0.id == direction.id }) {
|
||||
productionDirections[index] = direction
|
||||
} else {
|
||||
productionDirections.append(direction)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func removeProductionDirection(id directionID: String) {
|
||||
productionDirections.removeAll { $0.id == directionID }
|
||||
}
|
||||
|
||||
var selectedInstrumentTracks: [InstrumentTrack] {
|
||||
instruments.filter(\.selected)
|
||||
}
|
||||
|
||||
var selectedInstrumentIDs: Set<String> {
|
||||
Set(selectedInstrumentTracks.map(\.instrumentId))
|
||||
}
|
||||
|
||||
func instrumentTrack(for instrumentId: String) -> InstrumentTrack? {
|
||||
instruments.first { $0.instrumentId == instrumentId }
|
||||
}
|
||||
|
||||
func isInstrumentSelected(id instrumentId: String) -> Bool {
|
||||
instrumentTrack(for: instrumentId)?.selected == true
|
||||
}
|
||||
|
||||
mutating func selectInstrument(_ instrument: InstrumentTrack) {
|
||||
var selectedInstrument = instrument
|
||||
selectedInstrument.selected = true
|
||||
|
||||
if let index = instruments.firstIndex(where: { $0.instrumentId == instrument.instrumentId }) {
|
||||
instruments[index] = selectedInstrument
|
||||
} else {
|
||||
instruments.append(selectedInstrument)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func deselectInstrument(id instrumentId: String) {
|
||||
guard let index = instruments.firstIndex(where: { $0.instrumentId == instrumentId }) else { return }
|
||||
instruments[index].selected = false
|
||||
}
|
||||
|
||||
mutating func configureInstrument(
|
||||
id instrumentId: String,
|
||||
variant: String?,
|
||||
playingStyle: String?,
|
||||
role: String?,
|
||||
autoArrangementEnabled: Bool
|
||||
) {
|
||||
guard let index = instruments.firstIndex(where: { $0.instrumentId == instrumentId }) else { return }
|
||||
instruments[index].variant = variant
|
||||
instruments[index].playingStyle = playingStyle
|
||||
instruments[index].role = role
|
||||
instruments[index].autoArrangementEnabled = autoArrangementEnabled
|
||||
}
|
||||
|
||||
mutating func addInstrumentPlacement(_ placement: InstrumentPlacement, to instrumentId: String) {
|
||||
if let index = instruments.firstIndex(where: { $0.instrumentId == instrumentId }) {
|
||||
instruments[index].placements.append(placement)
|
||||
} else {
|
||||
instruments.append(
|
||||
InstrumentTrack(
|
||||
instrumentId: instrumentId,
|
||||
selected: true,
|
||||
placements: [placement]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var vocalistIDs: Set<String> {
|
||||
Set(vocalists.map(\.id))
|
||||
}
|
||||
|
||||
func vocalist(for vocalistID: String) -> Vocalist? {
|
||||
vocalists.first { $0.id == vocalistID }
|
||||
}
|
||||
|
||||
func vocalistsAssigned(toSectionID sectionID: String) -> [Vocalist] {
|
||||
vocalists.filter { $0.assignedSections.contains(sectionID) }
|
||||
}
|
||||
|
||||
mutating func upsertVocalist(_ vocalist: Vocalist) {
|
||||
var normalizedVocalist = vocalist
|
||||
normalizedVocalist.assignedSections = Self.uniqueOrderedIDs(vocalist.assignedSections)
|
||||
|
||||
if let index = vocalists.firstIndex(where: { $0.id == vocalist.id }) {
|
||||
vocalists[index] = normalizedVocalist
|
||||
} else {
|
||||
vocalists.append(normalizedVocalist)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func removeVocalist(id vocalistID: String) {
|
||||
vocalists.removeAll { $0.id == vocalistID }
|
||||
}
|
||||
|
||||
mutating func assignSection(_ sectionID: String, toVocalistID vocalistID: String) {
|
||||
guard let index = vocalists.firstIndex(where: { $0.id == vocalistID }) else { return }
|
||||
guard !vocalists[index].assignedSections.contains(sectionID) else { return }
|
||||
vocalists[index].assignedSections.append(sectionID)
|
||||
}
|
||||
|
||||
mutating func unassignSection(_ sectionID: String, fromVocalistID vocalistID: String) {
|
||||
guard let index = vocalists.firstIndex(where: { $0.id == vocalistID }) else { return }
|
||||
vocalists[index].assignedSections.removeAll { $0 == sectionID }
|
||||
}
|
||||
|
||||
mutating func createVersion(
|
||||
id: String = UUID().uuidString,
|
||||
label: String,
|
||||
createdAt: Date = Date()
|
||||
) -> SongVersion {
|
||||
let version = SongVersion(
|
||||
id: id,
|
||||
label: label,
|
||||
snapshot: SongProjectSnapshot(project: self),
|
||||
createdAt: createdAt
|
||||
)
|
||||
versions.append(version)
|
||||
return version
|
||||
}
|
||||
|
||||
func version(id versionID: String) -> SongVersion? {
|
||||
versions.first { $0.id == versionID }
|
||||
}
|
||||
|
||||
mutating func restoreVersion(id versionID: String, restoredAt: Date = Date()) -> Bool {
|
||||
guard let version = version(id: versionID) else { return false }
|
||||
restoreSnapshot(version.snapshot, updatedAt: restoredAt)
|
||||
return true
|
||||
}
|
||||
|
||||
mutating func restoreSnapshot(_ snapshot: SongProjectSnapshot, updatedAt: Date = Date()) {
|
||||
title = snapshot.title
|
||||
idea = snapshot.idea
|
||||
duration = snapshot.duration
|
||||
conversationMode = snapshot.conversationMode
|
||||
languages = snapshot.languages
|
||||
dialects = snapshot.dialects
|
||||
arabicPronunciationSettings = snapshot.arabicPronunciationSettings
|
||||
genres = snapshot.genres
|
||||
moods = snapshot.moods
|
||||
emotionalArc = snapshot.emotionalArc
|
||||
bpm = snapshot.bpm
|
||||
key = snapshot.key
|
||||
scale = snapshot.scale
|
||||
maqam = snapshot.maqam
|
||||
sections = snapshot.sections
|
||||
instruments = snapshot.instruments
|
||||
vocalists = snapshot.vocalists
|
||||
lyrics = snapshot.lyrics
|
||||
productionDirections = snapshot.productionDirections
|
||||
sunoOutput = snapshot.sunoOutput
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
mutating func appendSection(_ section: SongSection) {
|
||||
var orderedSection = section
|
||||
orderedSection.order = nextSectionOrder
|
||||
sections.append(orderedSection)
|
||||
}
|
||||
|
||||
mutating func moveSection(id: String, toOrder targetOrder: Int) {
|
||||
var ordered = orderedSections
|
||||
guard let sourceIndex = ordered.firstIndex(where: { $0.id == id }) else { return }
|
||||
|
||||
let section = ordered.remove(at: sourceIndex)
|
||||
let boundedTargetOrder = min(max(targetOrder, 0), ordered.count)
|
||||
ordered.insert(section, at: boundedTargetOrder)
|
||||
sections = Self.sectionsWithNormalizedOrder(ordered)
|
||||
}
|
||||
|
||||
mutating func normalizeSectionOrder() {
|
||||
sections = Self.sectionsWithNormalizedOrder(orderedSections)
|
||||
}
|
||||
|
||||
private var nextSectionOrder: Int {
|
||||
(sections.map(\.order).max() ?? -1) + 1
|
||||
}
|
||||
|
||||
private static func sectionsWithNormalizedOrder(_ sections: [SongSection]) -> [SongSection] {
|
||||
sections.enumerated().map { index, section in
|
||||
var orderedSection = section
|
||||
orderedSection.order = index
|
||||
return orderedSection
|
||||
}
|
||||
}
|
||||
|
||||
private static func uniqueOrderedIDs(_ ids: [String]) -> [String] {
|
||||
var seenIDs = Set<String>()
|
||||
return ids.filter { seenIDs.insert($0).inserted }
|
||||
}
|
||||
}
|
||||
|
||||
public enum ConversationMode: String, Codable, Equatable, Sendable {
|
||||
case auto
|
||||
case discuss
|
||||
}
|
||||
|
||||
public enum ControlMode: String, Codable, Equatable, Sendable {
|
||||
case manual
|
||||
case auto
|
||||
}
|
||||
|
||||
public struct ManualAutoValue<Value: Codable & Equatable & Sendable>: Codable, Equatable, Sendable {
|
||||
public var mode: ControlMode
|
||||
public var value: Value?
|
||||
|
||||
public init(mode: ControlMode, value: Value? = nil) {
|
||||
self.mode = mode
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
public struct SongDuration: Codable, Equatable, Sendable {
|
||||
public var seconds: Int?
|
||||
public var description: String?
|
||||
|
||||
public init(seconds: Int? = nil, description: String? = nil) {
|
||||
self.seconds = seconds
|
||||
self.description = description
|
||||
}
|
||||
}
|
||||
|
||||
public struct LanguageSelection: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var name: String
|
||||
|
||||
public init(id: String = UUID().uuidString, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
public struct DialectSelection: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var name: String
|
||||
public var languageID: String?
|
||||
|
||||
public init(id: String = UUID().uuidString, name: String, languageID: String? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.languageID = languageID
|
||||
}
|
||||
}
|
||||
|
||||
public struct ArabicPronunciationSettings: Codable, Equatable, Sendable {
|
||||
public var isEnabled: Bool
|
||||
public var diacritizationPolicy: ArabicDiacritizationPolicy
|
||||
public var tanweenPolicy: ArabicTanweenPolicy
|
||||
public var userNotes: String?
|
||||
public var preservedSpellings: [String]
|
||||
|
||||
public init(
|
||||
isEnabled: Bool = false,
|
||||
diacritizationPolicy: ArabicDiacritizationPolicy = .unspecified,
|
||||
tanweenPolicy: ArabicTanweenPolicy = .unspecified,
|
||||
userNotes: String? = nil,
|
||||
preservedSpellings: [String] = []
|
||||
) {
|
||||
self.isEnabled = isEnabled
|
||||
self.diacritizationPolicy = diacritizationPolicy
|
||||
self.tanweenPolicy = tanweenPolicy
|
||||
self.userNotes = userNotes
|
||||
self.preservedSpellings = preservedSpellings
|
||||
}
|
||||
}
|
||||
|
||||
public enum ArabicDiacritizationPolicy: String, Codable, Equatable, Sendable {
|
||||
case unspecified
|
||||
case pronunciationTargeted
|
||||
case fullTashkeel
|
||||
}
|
||||
|
||||
public enum ArabicTanweenPolicy: String, Codable, Equatable, Sendable {
|
||||
case unspecified
|
||||
case preserveUserInput
|
||||
case addWhenPronunciationRequires
|
||||
case removeWhenUnwanted
|
||||
}
|
||||
|
||||
public struct GenreStyle: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var name: String
|
||||
public var aliases: [String]
|
||||
|
||||
public init(id: String = UUID().uuidString, name: String, aliases: [String] = []) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.aliases = aliases
|
||||
}
|
||||
}
|
||||
|
||||
public struct MoodTag: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var name: String
|
||||
|
||||
public init(id: String = UUID().uuidString, name: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
|
||||
public struct EmotionalArcPoint: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var sectionID: String?
|
||||
public var emotion: String
|
||||
public var energy: Int?
|
||||
public var direction: String?
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
sectionID: String? = nil,
|
||||
emotion: String,
|
||||
energy: Int? = nil,
|
||||
direction: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.sectionID = sectionID
|
||||
self.emotion = emotion
|
||||
self.energy = energy
|
||||
self.direction = direction
|
||||
}
|
||||
}
|
||||
|
||||
public struct Lyrics: Codable, Equatable, Sendable {
|
||||
public var text: String
|
||||
public var sectionTexts: [String: String]
|
||||
|
||||
public init(text: String = "", sectionTexts: [String: String] = [:]) {
|
||||
self.text = text
|
||||
self.sectionTexts = sectionTexts
|
||||
}
|
||||
}
|
||||
|
||||
public struct ProductionDirection: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var sectionID: String?
|
||||
public var text: String
|
||||
|
||||
public init(id: String = UUID().uuidString, sectionID: String? = nil, text: String) {
|
||||
self.id = id
|
||||
self.sectionID = sectionID
|
||||
self.text = text
|
||||
}
|
||||
}
|
||||
|
||||
public struct InstrumentCatalogItem: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var name: String
|
||||
public var familyCategory: String
|
||||
public var regionOrigin: String?
|
||||
public var aliases: [String]
|
||||
public var searchTerms: [String]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String,
|
||||
familyCategory: String,
|
||||
regionOrigin: String? = nil,
|
||||
aliases: [String] = [],
|
||||
searchTerms: [String] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.familyCategory = familyCategory
|
||||
self.regionOrigin = regionOrigin
|
||||
self.aliases = aliases
|
||||
self.searchTerms = searchTerms
|
||||
}
|
||||
}
|
||||
|
||||
public struct SongSection: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var type: SongSectionType
|
||||
public var title: String
|
||||
public var order: Int
|
||||
public var startTime: TimeInterval?
|
||||
public var endTime: TimeInterval?
|
||||
public var lyrics: String
|
||||
public var emotion: String?
|
||||
public var energy: Int?
|
||||
public var vocalDirection: String?
|
||||
public var productionDirection: String?
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
type: SongSectionType,
|
||||
title: String,
|
||||
order: Int = 0,
|
||||
startTime: TimeInterval? = nil,
|
||||
endTime: TimeInterval? = nil,
|
||||
lyrics: String = "",
|
||||
emotion: String? = nil,
|
||||
energy: Int? = nil,
|
||||
vocalDirection: String? = nil,
|
||||
productionDirection: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.title = title
|
||||
self.order = order
|
||||
self.startTime = startTime
|
||||
self.endTime = endTime
|
||||
self.lyrics = lyrics
|
||||
self.emotion = emotion
|
||||
self.energy = energy
|
||||
self.vocalDirection = vocalDirection
|
||||
self.productionDirection = productionDirection
|
||||
}
|
||||
}
|
||||
|
||||
public enum SongSectionType: String, Codable, Equatable, Sendable {
|
||||
case intro
|
||||
case verse
|
||||
case preChorus
|
||||
case chorus
|
||||
case bridge
|
||||
case outro
|
||||
case custom
|
||||
}
|
||||
|
||||
public struct InstrumentTrack: Codable, Equatable, Sendable {
|
||||
public var instrumentId: String
|
||||
public var selected: Bool
|
||||
public var variant: String?
|
||||
public var playingStyle: String?
|
||||
public var role: String?
|
||||
public var autoArrangementEnabled: Bool
|
||||
public var placements: [InstrumentPlacement]
|
||||
|
||||
public init(
|
||||
instrumentId: String,
|
||||
selected: Bool = true,
|
||||
variant: String? = nil,
|
||||
playingStyle: String? = nil,
|
||||
role: String? = nil,
|
||||
autoArrangementEnabled: Bool = true,
|
||||
placements: [InstrumentPlacement] = []
|
||||
) {
|
||||
self.instrumentId = instrumentId
|
||||
self.selected = selected
|
||||
self.variant = variant
|
||||
self.playingStyle = playingStyle
|
||||
self.role = role
|
||||
self.autoArrangementEnabled = autoArrangementEnabled
|
||||
self.placements = placements
|
||||
}
|
||||
}
|
||||
|
||||
public struct InstrumentPlacement: Codable, Equatable, Sendable {
|
||||
public var sectionId: String?
|
||||
public var startTime: TimeInterval?
|
||||
public var endTime: TimeInterval?
|
||||
public var direction: String?
|
||||
|
||||
public init(
|
||||
sectionId: String? = nil,
|
||||
startTime: TimeInterval? = nil,
|
||||
endTime: TimeInterval? = nil,
|
||||
direction: String? = nil
|
||||
) {
|
||||
self.sectionId = sectionId
|
||||
self.startTime = startTime
|
||||
self.endTime = endTime
|
||||
self.direction = direction
|
||||
}
|
||||
}
|
||||
|
||||
public struct Vocalist: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var label: String
|
||||
public var voiceType: String?
|
||||
public var genderSelection: String?
|
||||
public var performanceStyle: String?
|
||||
public var assignedSections: [String]
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
label: String,
|
||||
voiceType: String? = nil,
|
||||
genderSelection: String? = nil,
|
||||
performanceStyle: String? = nil,
|
||||
assignedSections: [String] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.label = label
|
||||
self.voiceType = voiceType
|
||||
self.genderSelection = genderSelection
|
||||
self.performanceStyle = performanceStyle
|
||||
self.assignedSections = assignedSections
|
||||
}
|
||||
}
|
||||
|
||||
public struct SunoOutput: Codable, Equatable, Sendable {
|
||||
public var lyricsText: String
|
||||
public var stylePrompt: String
|
||||
public var additionalFields: [String: String]
|
||||
public var generatedAt: Date
|
||||
|
||||
public init(
|
||||
lyricsText: String,
|
||||
stylePrompt: String,
|
||||
additionalFields: [String: String] = [:],
|
||||
generatedAt: Date = Date()
|
||||
) {
|
||||
self.lyricsText = lyricsText
|
||||
self.stylePrompt = stylePrompt
|
||||
self.additionalFields = additionalFields
|
||||
self.generatedAt = generatedAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct SongVersion: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var label: String
|
||||
public var snapshot: SongProjectSnapshot
|
||||
public let createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
label: String,
|
||||
snapshot: SongProjectSnapshot,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.label = label
|
||||
self.snapshot = snapshot
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct SongProjectSnapshot: Codable, Equatable, Sendable {
|
||||
public var title: String
|
||||
public var idea: String
|
||||
public var duration: SongDuration?
|
||||
public var conversationMode: ConversationMode
|
||||
public var languages: [LanguageSelection]
|
||||
public var dialects: [DialectSelection]
|
||||
public var arabicPronunciationSettings: ArabicPronunciationSettings
|
||||
public var genres: [GenreStyle]
|
||||
public var moods: [MoodTag]
|
||||
public var emotionalArc: [EmotionalArcPoint]
|
||||
public var bpm: ManualAutoValue<Int>?
|
||||
public var key: ManualAutoValue<String>?
|
||||
public var scale: ManualAutoValue<String>?
|
||||
public var maqam: ManualAutoValue<String>?
|
||||
public var sections: [SongSection]
|
||||
public var instruments: [InstrumentTrack]
|
||||
public var vocalists: [Vocalist]
|
||||
public var lyrics: Lyrics
|
||||
public var productionDirections: [ProductionDirection]
|
||||
public var sunoOutput: SunoOutput?
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
idea: String,
|
||||
duration: SongDuration? = nil,
|
||||
conversationMode: ConversationMode = .auto,
|
||||
languages: [LanguageSelection] = [],
|
||||
dialects: [DialectSelection] = [],
|
||||
arabicPronunciationSettings: ArabicPronunciationSettings = ArabicPronunciationSettings(),
|
||||
genres: [GenreStyle] = [],
|
||||
moods: [MoodTag] = [],
|
||||
emotionalArc: [EmotionalArcPoint] = [],
|
||||
bpm: ManualAutoValue<Int>? = nil,
|
||||
key: ManualAutoValue<String>? = nil,
|
||||
scale: ManualAutoValue<String>? = nil,
|
||||
maqam: ManualAutoValue<String>? = nil,
|
||||
sections: [SongSection] = [],
|
||||
instruments: [InstrumentTrack] = [],
|
||||
vocalists: [Vocalist] = [],
|
||||
lyrics: Lyrics = Lyrics(),
|
||||
productionDirections: [ProductionDirection] = [],
|
||||
sunoOutput: SunoOutput? = nil
|
||||
) {
|
||||
self.title = title
|
||||
self.idea = idea
|
||||
self.duration = duration
|
||||
self.conversationMode = conversationMode
|
||||
self.languages = languages
|
||||
self.dialects = dialects
|
||||
self.arabicPronunciationSettings = arabicPronunciationSettings
|
||||
self.genres = genres
|
||||
self.moods = moods
|
||||
self.emotionalArc = emotionalArc
|
||||
self.bpm = bpm
|
||||
self.key = key
|
||||
self.scale = scale
|
||||
self.maqam = maqam
|
||||
self.sections = sections
|
||||
self.instruments = instruments
|
||||
self.vocalists = vocalists
|
||||
self.lyrics = lyrics
|
||||
self.productionDirections = productionDirections
|
||||
self.sunoOutput = sunoOutput
|
||||
}
|
||||
|
||||
public init(project: SongProject) {
|
||||
self.init(
|
||||
title: project.title,
|
||||
idea: project.idea,
|
||||
duration: project.duration,
|
||||
conversationMode: project.conversationMode,
|
||||
languages: project.languages,
|
||||
dialects: project.dialects,
|
||||
arabicPronunciationSettings: project.arabicPronunciationSettings,
|
||||
genres: project.genres,
|
||||
moods: project.moods,
|
||||
emotionalArc: project.emotionalArc,
|
||||
bpm: project.bpm,
|
||||
key: project.key,
|
||||
scale: project.scale,
|
||||
maqam: project.maqam,
|
||||
sections: project.sections,
|
||||
instruments: project.instruments,
|
||||
vocalists: project.vocalists,
|
||||
lyrics: project.lyrics,
|
||||
productionDirections: project.productionDirections,
|
||||
sunoOutput: project.sunoOutput
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import Foundation
|
||||
|
||||
public struct OpenAIClientConfiguration: Equatable, Sendable {
|
||||
public var apiKey: String
|
||||
public var endpointURL: URL
|
||||
public var model: String?
|
||||
public var organizationID: String?
|
||||
public var projectID: String?
|
||||
|
||||
public init(
|
||||
apiKey: String,
|
||||
endpointURL: URL,
|
||||
model: String? = nil,
|
||||
organizationID: String? = nil,
|
||||
projectID: String? = nil
|
||||
) {
|
||||
self.apiKey = apiKey
|
||||
self.endpointURL = endpointURL
|
||||
self.model = model
|
||||
self.organizationID = organizationID
|
||||
self.projectID = projectID
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenAIClientRequest: Equatable, Sendable {
|
||||
public var method: String
|
||||
public var url: URL?
|
||||
public var body: Data
|
||||
public var additionalHeaders: [String: String]
|
||||
|
||||
public init(
|
||||
method: String = "POST",
|
||||
url: URL? = nil,
|
||||
body: Data,
|
||||
additionalHeaders: [String: String] = [:]
|
||||
) {
|
||||
self.method = method
|
||||
self.url = url
|
||||
self.body = body
|
||||
self.additionalHeaders = additionalHeaders
|
||||
}
|
||||
}
|
||||
|
||||
public protocol OpenAIClientAdapter: Sendable {
|
||||
func makeProjectGenerationRequest(
|
||||
_ request: SongProjectGenerationRequest,
|
||||
configuration: OpenAIClientConfiguration
|
||||
) throws -> OpenAIClientRequest
|
||||
|
||||
func decodeProjectGenerationResult(from data: Data) throws -> SongProjectGenerationResult
|
||||
|
||||
func makeLyricsRevisionRequest(
|
||||
_ request: LyricsRevisionRequest,
|
||||
configuration: OpenAIClientConfiguration
|
||||
) throws -> OpenAIClientRequest
|
||||
|
||||
func decodeLyricsRevisionResult(from data: Data) throws -> LyricsRevisionResult
|
||||
|
||||
func makeProjectUpdateRequest(
|
||||
_ request: SongProjectUpdateRequest,
|
||||
configuration: OpenAIClientConfiguration
|
||||
) throws -> OpenAIClientRequest
|
||||
|
||||
func decodeProjectUpdateResult(from data: Data) throws -> SongProjectUpdateResult
|
||||
}
|
||||
|
||||
public protocol OpenAIHTTPTransport: Sendable {
|
||||
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
}
|
||||
|
||||
extension URLSession: OpenAIHTTPTransport {
|
||||
public func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
let (data, response) = try await data(for: request, delegate: nil)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw OpenAIClientError.invalidResponse
|
||||
}
|
||||
return (data, httpResponse)
|
||||
}
|
||||
}
|
||||
|
||||
public final class OpenAIClient: AIService, Sendable {
|
||||
private let configuration: OpenAIClientConfiguration
|
||||
private let adapter: any OpenAIClientAdapter
|
||||
private let transport: any OpenAIHTTPTransport
|
||||
|
||||
public init(
|
||||
configuration: OpenAIClientConfiguration,
|
||||
adapter: any OpenAIClientAdapter,
|
||||
transport: any OpenAIHTTPTransport = URLSession.shared
|
||||
) {
|
||||
self.configuration = configuration
|
||||
self.adapter = adapter
|
||||
self.transport = transport
|
||||
}
|
||||
|
||||
public func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
|
||||
let clientRequest = try adapter.makeProjectGenerationRequest(request, configuration: configuration)
|
||||
let data = try await perform(clientRequest)
|
||||
return try adapter.decodeProjectGenerationResult(from: data)
|
||||
}
|
||||
|
||||
public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||
let clientRequest = try adapter.makeLyricsRevisionRequest(request, configuration: configuration)
|
||||
let data = try await perform(clientRequest)
|
||||
return try adapter.decodeLyricsRevisionResult(from: data)
|
||||
}
|
||||
|
||||
public func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
|
||||
let clientRequest = try adapter.makeProjectUpdateRequest(request, configuration: configuration)
|
||||
let data = try await perform(clientRequest)
|
||||
return try adapter.decodeProjectUpdateResult(from: data)
|
||||
}
|
||||
|
||||
private func perform(_ clientRequest: OpenAIClientRequest) async throws -> Data {
|
||||
guard !configuration.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw OpenAIClientError.missingAPIKey
|
||||
}
|
||||
|
||||
var request = URLRequest(url: clientRequest.url ?? configuration.endpointURL)
|
||||
request.httpMethod = clientRequest.method
|
||||
request.httpBody = clientRequest.body
|
||||
request.setValue("Bearer \(configuration.apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
if let organizationID = configuration.organizationID {
|
||||
request.setValue(organizationID, forHTTPHeaderField: "OpenAI-Organization")
|
||||
}
|
||||
|
||||
if let projectID = configuration.projectID {
|
||||
request.setValue(projectID, forHTTPHeaderField: "OpenAI-Project")
|
||||
}
|
||||
|
||||
for (header, value) in clientRequest.additionalHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: header)
|
||||
}
|
||||
|
||||
let (data, response) = try await transport.data(for: request)
|
||||
guard (200..<300).contains(response.statusCode) else {
|
||||
throw OpenAIClientError.unacceptableStatusCode(response.statusCode)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
public enum OpenAIClientError: Error, Equatable, Sendable {
|
||||
case missingAPIKey
|
||||
case invalidResponse
|
||||
case unacceptableStatusCode(Int)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
public protocol SunoHandoffService: Sendable {
|
||||
func handOff(_ output: CompiledSunoOutput) async throws
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import Foundation
|
||||
|
||||
public protocol ProjectStore: Sendable {
|
||||
func create(_ project: SongProject) async throws
|
||||
func save(_ project: SongProject) async throws
|
||||
func open(id: String) async throws -> SongProject
|
||||
func loadProjectIDs() async throws -> [String]
|
||||
func loadProjectList() async throws -> [SongProjectListItem]
|
||||
}
|
||||
|
||||
public struct SongProjectListItem: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var title: String
|
||||
public var idea: String
|
||||
public var createdAt: Date
|
||||
public var updatedAt: Date
|
||||
|
||||
public init(id: String, title: String, idea: String, createdAt: Date, updatedAt: Date) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.idea = idea
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
public init(project: SongProject) {
|
||||
self.init(
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
idea: project.idea,
|
||||
createdAt: project.createdAt,
|
||||
updatedAt: project.updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public actor LocalSongProjectStore: ProjectStore {
|
||||
private let directoryURL: URL
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
public init(directoryURL: URL) {
|
||||
self.directoryURL = directoryURL
|
||||
self.encoder = JSONEncoder()
|
||||
self.decoder = JSONDecoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
}
|
||||
|
||||
public func create(_ project: SongProject) async throws {
|
||||
try ensureDirectoryExists()
|
||||
let url = projectURL(for: project.id)
|
||||
guard !FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw LocalSongProjectStoreError.projectAlreadyExists(project.id)
|
||||
}
|
||||
try saveEncoded(project, to: url)
|
||||
}
|
||||
|
||||
public func save(_ project: SongProject) async throws {
|
||||
try ensureDirectoryExists()
|
||||
try saveEncoded(project, to: projectURL(for: project.id))
|
||||
}
|
||||
|
||||
public func open(id: String) async throws -> SongProject {
|
||||
let url = projectURL(for: id)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw LocalSongProjectStoreError.projectNotFound(id)
|
||||
}
|
||||
let data = try Data(contentsOf: url)
|
||||
return try decoder.decode(SongProject.self, from: data)
|
||||
}
|
||||
|
||||
public func loadProjectIDs() async throws -> [String] {
|
||||
try await loadProjectList().map(\.id)
|
||||
}
|
||||
|
||||
public func loadProjectList() async throws -> [SongProjectListItem] {
|
||||
try ensureDirectoryExists()
|
||||
return try projectFileURLs()
|
||||
.map { url in
|
||||
let data = try Data(contentsOf: url)
|
||||
let project = try decoder.decode(SongProject.self, from: data)
|
||||
return SongProjectListItem(project: project)
|
||||
}
|
||||
.sorted { left, right in
|
||||
if left.updatedAt == right.updatedAt {
|
||||
return left.title.localizedStandardCompare(right.title) == .orderedAscending
|
||||
}
|
||||
return left.updatedAt > right.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureDirectoryExists() throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: directoryURL,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
}
|
||||
|
||||
private func saveEncoded(_ project: SongProject, to url: URL) throws {
|
||||
let data = try encoder.encode(project)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
}
|
||||
|
||||
private func projectFileURLs() throws -> [URL] {
|
||||
try FileManager.default.contentsOfDirectory(
|
||||
at: directoryURL,
|
||||
includingPropertiesForKeys: nil
|
||||
)
|
||||
.filter { $0.pathExtension == Self.fileExtension }
|
||||
}
|
||||
|
||||
private func projectURL(for id: String) -> URL {
|
||||
directoryURL
|
||||
.appendingPathComponent(id)
|
||||
.appendingPathExtension(Self.fileExtension)
|
||||
}
|
||||
|
||||
private static let fileExtension = "json"
|
||||
}
|
||||
|
||||
public enum LocalSongProjectStoreError: Error, Equatable, Sendable {
|
||||
case projectAlreadyExists(String)
|
||||
case projectNotFound(String)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import Foundation
|
||||
|
||||
public protocol AIService: Sendable {
|
||||
func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult
|
||||
func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult
|
||||
func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult
|
||||
}
|
||||
|
||||
public struct AIConversationMessage: Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var role: AIConversationRole
|
||||
public var content: String
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
role: AIConversationRole,
|
||||
content: String
|
||||
) {
|
||||
self.id = id
|
||||
self.role = role
|
||||
self.content = content
|
||||
}
|
||||
}
|
||||
|
||||
public enum AIConversationRole: String, Equatable, Sendable {
|
||||
case user
|
||||
case assistant
|
||||
case system
|
||||
}
|
||||
|
||||
public struct AIRequestContext: Equatable, Sendable {
|
||||
public var userInstruction: String
|
||||
public var conversation: [AIConversationMessage]
|
||||
public var localeIdentifier: String?
|
||||
internal var privateApplicationRules: PrivateApplicationRules?
|
||||
|
||||
public init(
|
||||
userInstruction: String,
|
||||
conversation: [AIConversationMessage] = [],
|
||||
localeIdentifier: String? = nil
|
||||
) {
|
||||
self.userInstruction = userInstruction
|
||||
self.conversation = conversation
|
||||
self.localeIdentifier = localeIdentifier
|
||||
self.privateApplicationRules = nil
|
||||
}
|
||||
|
||||
internal mutating func injectPrivateApplicationRules(_ rules: PrivateApplicationRules?) {
|
||||
privateApplicationRules = rules
|
||||
}
|
||||
}
|
||||
|
||||
public struct SongProjectGenerationRequest: Equatable, Sendable {
|
||||
public var context: AIRequestContext
|
||||
public var seedProject: SongProject?
|
||||
public var discussionMode: ConversationMode
|
||||
|
||||
public init(
|
||||
context: AIRequestContext,
|
||||
seedProject: SongProject? = nil,
|
||||
discussionMode: ConversationMode = .auto
|
||||
) {
|
||||
self.context = context
|
||||
self.seedProject = seedProject
|
||||
self.discussionMode = discussionMode
|
||||
}
|
||||
}
|
||||
|
||||
public struct SongProjectGenerationResult: Equatable, Sendable {
|
||||
public var project: SongProject
|
||||
public var notes: [String]
|
||||
public var followUpQuestions: [String]
|
||||
|
||||
public init(
|
||||
project: SongProject,
|
||||
notes: [String] = [],
|
||||
followUpQuestions: [String] = []
|
||||
) {
|
||||
self.project = project
|
||||
self.notes = notes
|
||||
self.followUpQuestions = followUpQuestions
|
||||
}
|
||||
}
|
||||
|
||||
public struct LyricsRevisionRequest: Equatable, Sendable {
|
||||
public var context: AIRequestContext
|
||||
public var project: SongProject
|
||||
public var sourceLyrics: Lyrics
|
||||
public var mode: LyricsRevisionMode
|
||||
|
||||
public init(
|
||||
context: AIRequestContext,
|
||||
project: SongProject,
|
||||
sourceLyrics: Lyrics,
|
||||
mode: LyricsRevisionMode
|
||||
) {
|
||||
self.context = context
|
||||
self.project = project
|
||||
self.sourceLyrics = sourceLyrics
|
||||
self.mode = mode
|
||||
}
|
||||
}
|
||||
|
||||
public enum LyricsRevisionMode: String, Equatable, Sendable {
|
||||
case writeFromIdea
|
||||
case correct
|
||||
case improve
|
||||
case restructure
|
||||
case complete
|
||||
}
|
||||
|
||||
public struct LyricsRevisionResult: Equatable, Sendable {
|
||||
public var lyrics: Lyrics
|
||||
public var notes: [String]
|
||||
|
||||
public init(lyrics: Lyrics, notes: [String] = []) {
|
||||
self.lyrics = lyrics
|
||||
self.notes = notes
|
||||
}
|
||||
}
|
||||
|
||||
public struct SongProjectUpdateRequest: Equatable, Sendable {
|
||||
public var context: AIRequestContext
|
||||
public var project: SongProject
|
||||
public var allowedScopes: [SongProjectUpdateScope]
|
||||
|
||||
public init(
|
||||
context: AIRequestContext,
|
||||
project: SongProject,
|
||||
allowedScopes: [SongProjectUpdateScope]
|
||||
) {
|
||||
self.context = context
|
||||
self.project = project
|
||||
self.allowedScopes = allowedScopes
|
||||
}
|
||||
}
|
||||
|
||||
public enum SongProjectUpdateScope: String, Codable, Equatable, Sendable {
|
||||
case duration
|
||||
case genres
|
||||
case instruments
|
||||
case vocals
|
||||
case languages
|
||||
case lyrics
|
||||
case structure
|
||||
case arrangement
|
||||
case musicalParameters
|
||||
case emotionalArc
|
||||
case productionDirections
|
||||
case sunoOutput
|
||||
}
|
||||
|
||||
public struct SongProjectUpdateResult: Equatable, Sendable {
|
||||
public var project: SongProject
|
||||
public var changedScopes: [SongProjectUpdateScope]
|
||||
public var notes: [String]
|
||||
public var followUpQuestions: [String]
|
||||
|
||||
public init(
|
||||
project: SongProject,
|
||||
changedScopes: [SongProjectUpdateScope] = [],
|
||||
notes: [String] = [],
|
||||
followUpQuestions: [String] = []
|
||||
) {
|
||||
self.project = project
|
||||
self.changedScopes = changedScopes
|
||||
self.notes = notes
|
||||
self.followUpQuestions = followUpQuestions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
public struct PrivateApplicationRules: Equatable, Sendable {
|
||||
public let content: String
|
||||
|
||||
public init?(content: String?) {
|
||||
guard let content else { return nil }
|
||||
|
||||
let trimmedContent = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedContent.isEmpty else { return nil }
|
||||
|
||||
self.content = trimmedContent
|
||||
}
|
||||
}
|
||||
|
||||
public protocol ApplicationRuleProviding: Sendable {
|
||||
func privateApplicationRules() throws -> PrivateApplicationRules?
|
||||
}
|
||||
|
||||
public struct StaticApplicationRuleProvider: ApplicationRuleProviding {
|
||||
private let rules: PrivateApplicationRules?
|
||||
|
||||
public init(rules: PrivateApplicationRules?) {
|
||||
self.rules = rules
|
||||
}
|
||||
|
||||
public func privateApplicationRules() throws -> PrivateApplicationRules? {
|
||||
rules
|
||||
}
|
||||
}
|
||||
|
||||
public final class ApplicationRuleInjectingAIService: AIService, Sendable {
|
||||
private let baseService: any AIService
|
||||
private let ruleProvider: any ApplicationRuleProviding
|
||||
|
||||
public init(
|
||||
baseService: any AIService,
|
||||
ruleProvider: any ApplicationRuleProviding
|
||||
) {
|
||||
self.baseService = baseService
|
||||
self.ruleProvider = ruleProvider
|
||||
}
|
||||
|
||||
public func generateSongProject(from request: SongProjectGenerationRequest) async throws -> SongProjectGenerationResult {
|
||||
try await baseService.generateSongProject(from: requestWithInjectedRules(request))
|
||||
}
|
||||
|
||||
public func reviseLyrics(from request: LyricsRevisionRequest) async throws -> LyricsRevisionResult {
|
||||
try await baseService.reviseLyrics(from: requestWithInjectedRules(request))
|
||||
}
|
||||
|
||||
public func proposeProjectUpdate(from request: SongProjectUpdateRequest) async throws -> SongProjectUpdateResult {
|
||||
try await baseService.proposeProjectUpdate(from: requestWithInjectedRules(request))
|
||||
}
|
||||
|
||||
private func requestWithInjectedRules(_ request: SongProjectGenerationRequest) throws -> SongProjectGenerationRequest {
|
||||
var request = request
|
||||
request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules())
|
||||
return request
|
||||
}
|
||||
|
||||
private func requestWithInjectedRules(_ request: LyricsRevisionRequest) throws -> LyricsRevisionRequest {
|
||||
var request = request
|
||||
request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules())
|
||||
return request
|
||||
}
|
||||
|
||||
private func requestWithInjectedRules(_ request: SongProjectUpdateRequest) throws -> SongProjectUpdateRequest {
|
||||
var request = request
|
||||
request.context.injectPrivateApplicationRules(try ruleProvider.privateApplicationRules())
|
||||
return request
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
public struct StructuredSongProjectUpdateResponse: Codable, Equatable, Sendable {
|
||||
public static let schemaName = "song_project_update_response"
|
||||
|
||||
public var project: SongProjectSnapshot
|
||||
public var changedScopes: [SongProjectUpdateScope]
|
||||
public var notes: [String]
|
||||
public var followUpQuestions: [String]
|
||||
|
||||
public init(
|
||||
project: SongProjectSnapshot,
|
||||
changedScopes: [SongProjectUpdateScope] = [],
|
||||
notes: [String] = [],
|
||||
followUpQuestions: [String] = []
|
||||
) {
|
||||
self.project = project
|
||||
self.changedScopes = changedScopes
|
||||
self.notes = notes
|
||||
self.followUpQuestions = followUpQuestions
|
||||
}
|
||||
|
||||
public func makeUpdateResult(
|
||||
updating existingProject: SongProject,
|
||||
updatedAt: Date = Date()
|
||||
) -> SongProjectUpdateResult {
|
||||
var updatedProject = existingProject
|
||||
updatedProject.restoreSnapshot(project, updatedAt: updatedAt)
|
||||
|
||||
return SongProjectUpdateResult(
|
||||
project: updatedProject,
|
||||
changedScopes: changedScopes,
|
||||
notes: notes,
|
||||
followUpQuestions: followUpQuestions
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
public struct ProjectAutosavePolicy: Equatable, Sendable {
|
||||
public static let `default` = ProjectAutosavePolicy()
|
||||
|
||||
public var minimumVersionInterval: TimeInterval
|
||||
public var versionLabel: String
|
||||
|
||||
public init(
|
||||
minimumVersionInterval: TimeInterval = 300,
|
||||
versionLabel: String = "Autosave checkpoint"
|
||||
) {
|
||||
self.minimumVersionInterval = minimumVersionInterval
|
||||
self.versionLabel = versionLabel
|
||||
}
|
||||
}
|
||||
|
||||
public struct ProjectAutosaveResult: Equatable, Sendable {
|
||||
public var project: SongProject
|
||||
public var didCreateVersion: Bool
|
||||
|
||||
public init(project: SongProject, didCreateVersion: Bool) {
|
||||
self.project = project
|
||||
self.didCreateVersion = didCreateVersion
|
||||
}
|
||||
}
|
||||
|
||||
public actor ProjectAutosaveService {
|
||||
private let store: any ProjectStore
|
||||
private let policy: ProjectAutosavePolicy
|
||||
|
||||
public init(store: any ProjectStore, policy: ProjectAutosavePolicy = .default) {
|
||||
self.store = store
|
||||
self.policy = policy
|
||||
}
|
||||
|
||||
public func autosave(_ project: SongProject, at savedAt: Date = Date()) async throws -> ProjectAutosaveResult {
|
||||
var autosavedProject = project
|
||||
var didCreateVersion = false
|
||||
|
||||
if shouldCreateAutosaveVersion(for: autosavedProject, at: savedAt) {
|
||||
_ = autosavedProject.createVersion(label: policy.versionLabel, createdAt: savedAt)
|
||||
didCreateVersion = true
|
||||
}
|
||||
|
||||
autosavedProject.updatedAt = savedAt
|
||||
try await store.save(autosavedProject)
|
||||
|
||||
return ProjectAutosaveResult(project: autosavedProject, didCreateVersion: didCreateVersion)
|
||||
}
|
||||
|
||||
private func shouldCreateAutosaveVersion(for project: SongProject, at savedAt: Date) -> Bool {
|
||||
let currentSnapshot = SongProjectSnapshot(project: project)
|
||||
|
||||
guard let latestVersion = project.versions.last else {
|
||||
return true
|
||||
}
|
||||
|
||||
guard latestVersion.snapshot != currentSnapshot else {
|
||||
return false
|
||||
}
|
||||
|
||||
return savedAt.timeIntervalSince(latestVersion.createdAt) >= policy.minimumVersionInterval
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
public protocol PromptCompiling: Sendable {
|
||||
associatedtype Project
|
||||
|
||||
func compile(project: Project) throws -> CompiledSunoOutput
|
||||
}
|
||||
|
||||
public struct CompiledSunoOutput: Equatable, Sendable {
|
||||
public let lyricsText: String
|
||||
public let stylePrompt: String
|
||||
|
||||
public init(lyricsText: String, stylePrompt: String) {
|
||||
self.lyricsText = lyricsText
|
||||
self.stylePrompt = stylePrompt
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user