Initial commit

This commit is contained in:
diyaa
2026-09-13 20:21:10 +02:00
commit 18d7d56345
42 changed files with 5459 additions and 0 deletions
@@ -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)
}
}