From 57867bb3cccf9aeeebd53c194d66c1bf3d4b5ede Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 02:41:58 +0200 Subject: [PATCH 01/11] Document instrument selector requirement --- AGENTS.md | 68 +++++++++++++++++++++ docs/ARCHITECTURE.md | 85 ++++++++++++++++++++++++++ docs/DATA_MODEL.md | 87 ++++++++++++++++++++++++++ docs/OPEN_QUESTIONS.md | 24 ++++++++ docs/PRODUCT.md | 96 +++++++++++++++++++++++++++++ docs/TASKS.md | 135 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 495 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DATA_MODEL.md create mode 100644 docs/OPEN_QUESTIONS.md create mode 100644 docs/PRODUCT.md create mode 100644 docs/TASKS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d5f34e7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,68 @@ +# Agent Guide + +This file is the permanent operating guide for any coding agent working on this +project. + +## Project Scope + +- This project is a native macOS application. +- The minimum deployment target is macOS 14.0. +- Use Swift and SwiftUI according to the architecture documented for this + project. +- Do not invent undocumented features or requirements. +- Any substantial change to the architecture or product scope must be documented + before implementation. + +## Required Reading + +Before making significant changes, read: + +- `docs/PRODUCT.md` +- `docs/ARCHITECTURE.md` +- `docs/DATA_MODEL.md` +- `docs/TASKS.md` +- `docs/OPEN_QUESTIONS.md` + +Use these documents as the project references: + +- `docs/PRODUCT.md` is the source of truth for product requirements. +- `docs/ARCHITECTURE.md` is the reference for architecture decisions. +- `docs/DATA_MODEL.md` is the reference for data models. +- `docs/TASKS.md` is the reference for implementation phases and tasks. +- `docs/OPEN_QUESTIONS.md` contains decisions that are not resolved yet. + +## Decision Rules + +- If documents conflict, do not guess. State the conflict before making a + decision. +- Do not decide anything listed in `docs/OPEN_QUESTIONS.md` on your own. +- Do not automatically move to a new phase in `docs/TASKS.md` unless explicitly + asked. +- Update `docs/TASKS.md` when tasks are completed. +- Preserve version history and the Song Project concept as documented. + +## Code Standards + +- Write organized code that is maintainable and extensible. +- Keep clear separation between UI, Models, Services, Persistence, and + Integrations. +- Keep OpenAI, instrument catalog/selector, and Suno integrations separated from + UI logic as much as possible. +- In the current scope, Suno handoff fills the fields only and does not press + Generate. +- Do not modify files outside the task scope without a clear reason. +- Do not perform a broad refactor while implementing a small task unless it is + explicitly required. + +## Secrets And Configuration + +- Do not place API keys, tokens, or secrets in source code or Git. +- Read secrets only from secure environment or configuration mechanisms. +- Do not log secrets. + +## Verification + +- Run a build after code changes. +- Run relevant tests when they are available. +- Do not consider a task complete if the project fails to build because of new + changes. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..84556ec --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,85 @@ +# Architecture --- Initial Direction + +## Principle + +Keep UI, domain data, AI orchestration, external integrations and +persistence separated. Do not hard-code provider behavior into SwiftUI +views. + +## Suggested Layers + +``` text +macOS App +├── Presentation (SwiftUI) +├── Domain +│ ├── SongProject +│ ├── SongVersion +│ ├── Lyrics +│ ├── SongStructure +│ ├── Genre/Style +│ ├── Instrument +│ ├── Vocal +│ ├── Arrangement +│ └── MusicalParameters +├── Application Services +│ ├── AI Director +│ ├── Prompt Compiler +│ ├── Instrument Catalog +│ ├── Arabic Pronunciation Processor +│ ├── Project Versioning +│ └── Validation +├── Integrations +│ ├── OpenAI +│ └── Suno Field-Fill Integration +└── Persistence + └── Local project/version storage +``` + +## AI Director + +OpenAI receives the user's intent plus private application rules and +structured project state. AI output should be requested as structured +data wherever possible, not treated as an unstructured chat transcript. + +## Prompt Compiler + +A deterministic layer converts the approved SongProject into the final +Suno-facing lyrics/style content. User choices override AI suggestions. + +## Security + +- Never commit API keys to source control. +- Keep secrets outside source code. +- Design provider clients behind protocols/interfaces so credentials + and providers can be changed later. +- Do not log secrets or full authorization headers. + +## Suno Boundary + +The initial requirement is browser/site handoff that fills fields but +does not press Generate. Treat this integration as replaceable because +website UI/behavior can change. + +## Instrument Selector Boundary + +Instrument catalog data is structured names and metadata, not SwiftUI +view code. The Presentation layer may search, filter and toggle +selections, but catalog loading, search normalization and selected +instrument state should live in Domain/Application Services and the +SongProject model. + +Catalog entries should include stable ids, display names, +family/category, optional region/origin and optional aliases/search +terms. The catalog should support worldwide coverage across Western, +Middle Eastern, African, South Asian, East Asian, Southeast Asian, Latin +American, traditional, folk, orchestral, electronic and modern +instruments. + +Selected instruments are part of the SongProject and must be available +to the AI Director and Prompt Compiler so they can influence +arrangement, roles, entry/exit timing, relevant structure decisions and +the Suno Style Prompt. + +The current architecture does not include instrument images, audio +previews, sound samples, playback, Freesound, an Instrument API or +external API calls for instrument data. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 0000000..31bf731 --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,87 @@ +# Song Project --- Draft Data Model + +This schema is intentionally provider-neutral and should evolve before +implementation is locked. + +``` text +SongProject +- id +- title +- idea +- duration +- conversationMode: auto | discuss +- languages[] +- dialects[] +- arabicPronunciationSettings +- genres[] +- moods[] +- emotionalArc[] +- bpm +- key +- scale +- maqam +- sections[] +- instruments[] +- vocalists[] +- lyrics +- productionDirections[] +- sunoOutput +- versions[] +- createdAt +- updatedAt + +InstrumentCatalogItem +- id +- name +- familyCategory +- regionOrigin (optional) +- aliases[] +- searchTerms[] + +SongSection +- id +- type (intro, verse, preChorus, chorus, bridge, outro, custom) +- title +- startTime +- endTime +- lyrics +- emotion +- energy +- vocalDirection +- productionDirection + +InstrumentTrack +- instrumentId +- selected +- variant +- playingStyle +- role +- autoArrangementEnabled +- placements[] + +InstrumentPlacement +- sectionId (optional) +- startTime (optional) +- endTime (optional) +- direction + +Vocalist +- id +- label +- voiceType +- genderSelection +- performanceStyle +- assignedSections[] + +SunoOutput +- lyricsText +- stylePrompt +- additionalFields +- generatedAt +``` + +## Override Rule + +Explicit user values are authoritative. AI may fill missing values or +propose changes, but must not silently replace locked/manual user +choices. diff --git a/docs/OPEN_QUESTIONS.md b/docs/OPEN_QUESTIONS.md new file mode 100644 index 0000000..71873d2 --- /dev/null +++ b/docs/OPEN_QUESTIONS.md @@ -0,0 +1,24 @@ +# Open Questions + +These items must be resolved before their dependent implementation is +finalized. + +- Resolved: minimum macOS deployment target is macOS 14.0. +- Exact Swift/SwiftUI architecture conventions for the repository. +- OpenAI model(s) and API endpoint strategy. +- Exact private AI rules/system prompt. +- Complete genre/style taxonomy and whether it is curated locally or + AI-assisted. +- Complete worldwide instrument catalog taxonomy, family/category + groups, region/origin coverage, aliases/search terms and maintenance + strategy. +- Exact list of supported languages/dialects for MVP. +- Arabic diacritization policy: full tashkeel vs + pronunciation-targeted tashkeel. +- Exact Suno fields to populate and supported handoff mechanism. +- Whether Suno integration is permitted/reliable under the intended + account/workflow and current terms. +- Project storage technology and whether cloud sync is required later. +- Whether users supply their own OpenAI/API credentials or the product + owner supplies service credentials. +- Authentication/subscription/usage-limit requirements, if any. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md new file mode 100644 index 0000000..86f010b --- /dev/null +++ b/docs/PRODUCT.md @@ -0,0 +1,96 @@ +# Product Definition --- AI Music Studio for macOS + +## Vision + +A native macOS application that helps users design a complete song +concept through a natural chat interface, using OpenAI as the planning +and writing engine, then prepares the final lyrics and style +instructions for Suno. + +## Core Flow + +1. User starts a new song project in a natural chat. +2. User describes the song idea and optionally selects duration, + genres/styles, instruments, vocals, language/dialect, structure and + musical parameters. +3. OpenAI applies product rules and completes missing creative details. + An optional discussion mode lets the AI ask the user before making + decisions. +4. User reviews and manually edits every generated component. +5. Project versions are saved. +6. User presses **Send to Suno**. +7. The app opens Suno and fills the appropriate fields. It does not + trigger Generate. + +## Song Controls + +- Multiple genres/styles can be blended. +- Instruments can be selected from a large worldwide instrument + catalog. +- Instruments can be searched by name, browsed by family/category and + browsed by region/origin where useful. +- Multiple instruments can be selected and deselected using + checkboxes. +- Selected instruments belong to the current Song Project. +- Each instrument can have a role and entry/exit timing. +- Instrument timing supports both timestamps and song sections. +- Arrangement supports Manual and Auto modes. +- Song structure supports Manual and Auto modes. +- BPM, key/scale and maqam support Manual and Auto modes. +- Multiple vocalists/voices can exist in one song. +- User controls vocal gender/type, rap/singing mode, delivery and + section-specific performance. +- Multiple languages and dialects can be used in one song. +- Arabic receives dedicated diacritics/harakat/tanween handling for + pronunciation. +- Energy and emotional progression can change throughout the song. +- Effects, transitions, build-ups, drops, bass intensity and vocal + processing can be manually specified or AI-assisted. + +## Lyrics + +The user can either: - provide only an idea and have AI write the +lyrics; or - provide existing lyrics and ask AI to correct, improve, +restructure or complete them. + +## Instrument Selector + +The application provides a names-and-metadata Instrument Selector. The +catalog should cover as many musical instruments as reasonably possible, +including Western, Middle Eastern, African, South Asian, East Asian, +Southeast Asian, Latin American, traditional, folk, orchestral, +electronic and modern instruments. + +Instrument catalog data must not be hardcoded directly inside SwiftUI +views. Each catalog entry should support structured metadata: + +- id +- name +- family/category +- optional region/origin +- optional aliases/search terms + +Selected instruments must be available as part of the Song Project and +later available to the OpenAI/song-generation layer so they can +influence arrangement, instrument roles, entry/exit timing, song +structure where relevant and the Suno Style Prompt. + +Instrument images, audio previews, sound samples, audio playback, +Freesound, an Instrument API and external API calls for instrument data +are not part of the current product requirement. + +## Project Management + +Every song is stored as a project. Important changes can create versions +so previous states remain recoverable. + +## AI Rules + +The product owner has private system rules that govern OpenAI behavior. +Normal users cannot view or modify these rules in the initial version. + +## Initial Boundary + +Suno remains responsible for music generation and playback. The macOS +app prepares the project and fills Suno fields only. Generated songs do +not need to return to the app in the initial version. diff --git a/docs/TASKS.md b/docs/TASKS.md new file mode 100644 index 0000000..f253bc6 --- /dev/null +++ b/docs/TASKS.md @@ -0,0 +1,135 @@ +# Agent Task Plan --- AI Music Studio macOS + +## Operating Rule + +Work incrementally. Do not invent product requirements. When a +requirement is missing and blocks implementation, record it in +`OPEN_QUESTIONS.md` instead of silently deciding it. + +## Phase 0 --- Repository Foundation + +- [x] Inspect existing repository and document current state. +- [x] Create/confirm native macOS project structure. +- [x] Establish clear Presentation / Domain / Services / Integrations + / Persistence boundaries. +- [x] Add configuration strategy for development secrets without + committing keys. +- [x] Add basic unit-test target. +- [x] Ensure project builds cleanly. + +## Phase 1 --- Domain Model + +- [ ] Implement `SongProject` and supporting models from + `DATA_MODEL.md`. +- [ ] Model genres/styles as multi-select. +- [ ] Model song sections with order and optional timestamps. +- [ ] Model instruments, variants, roles and placements. +- [ ] Model multiple vocalists and section assignments. +- [ ] Model language/dialect and Arabic pronunciation settings. +- [ ] Model BPM, key, scale and maqam with Manual/Auto state. +- [ ] Model emotional arc and production directions. +- [ ] Add serialization tests. + +## Phase 2 --- Project Persistence & Versions + +- [ ] Create new/open/save song projects locally. +- [ ] Implement project list. +- [ ] Implement immutable or snapshot-based version history. +- [ ] Restore a previous version without destroying later versions. +- [ ] Add autosave strategy that does not create excessive versions. + +## Phase 3 --- Core macOS UI + +- [ ] Build project browser. +- [ ] Build natural chat workspace as the primary entry point. +- [ ] Build editable project inspector for duration, genres, + instruments, vocals and language. +- [ ] Build song structure editor. +- [ ] Build arrangement editor supporting section-based and + timestamp-based placement. +- [ ] Add Manual/Auto toggles for supported controls. +- [ ] Build final review screen where every generated field can be + edited. + +## Phase 4 --- OpenAI Integration + +- [ ] Create provider-independent `AIService` interface. +- [ ] Implement OpenAI client. +- [ ] Define private application-rule injection mechanism. +- [ ] Define structured AI response schema for SongProject updates. +- [ ] Implement idea → complete project generation. +- [ ] Implement existing lyrics → correction/improvement flow. +- [ ] Implement Auto mode for structure, arrangement, BPM/key/maqam + and production decisions. +- [ ] Implement optional Discuss mode. +- [ ] Enforce user-lock/manual-value precedence over AI output. +- [ ] Add error, retry, cancellation and rate-limit handling. + +## Phase 5 --- Arabic Lyrics Processing + +- [ ] Add Arabic-specific settings UI. +- [ ] Support diacritics/harakat/tanween processing. +- [ ] Preserve intentional spelling/dialect choices where possible. +- [ ] Allow user to compare/edit processed Arabic before Suno handoff. +- [ ] Add Arabic test fixtures covering multiple dialects. + +## Phase 6 --- Instrument Selector & Catalog + +- [ ] Create structured catalog models for instrument id, name, + family/category, optional region/origin and aliases/search terms. +- [ ] Provide large worldwide catalog data covering Western, Middle + Eastern, African, South Asian, East Asian, Southeast Asian, Latin + American, traditional, folk, orchestral, electronic and modern + instruments. +- [ ] Keep catalog data out of SwiftUI views. +- [ ] Build searchable instrument browser. +- [ ] Add browsing/filtering by family/category. +- [ ] Add browsing/filtering by region/origin where useful. +- [ ] Add checkbox-based multi-select and deselect behavior. +- [ ] Persist selected instruments on the current Song Project. +- [ ] Make selected instruments available to OpenAI/song-generation + logic for arrangement, roles, entry/exit timing, relevant structure + decisions and Suno Style Prompt generation. +- [ ] Preserve existing Manual/Auto arrangement behavior. +- [ ] Add tests for catalog search, category/region filtering, + selection persistence and SongProject serialization. + +## Phase 7 --- Prompt Compiler + +- [ ] Create deterministic compiler from approved SongProject → Suno + output. +- [ ] Generate lyrics text with section/performance directives where + appropriate. +- [ ] Generate style prompt from genre blend, instrumentation, vocals, + tempo, harmony, emotion and production instructions. +- [ ] Validate output before handoff. +- [ ] Keep compiler independent from UI and OpenAI client. + +## Phase 8 --- Suno Handoff + +- [ ] Implement explicit `Send to Suno` action. +- [ ] Open the appropriate Suno creation surface. +- [ ] Fill supported fields with approved project output. +- [ ] Never trigger Generate automatically. +- [ ] Detect/report when fields cannot be filled rather than silently + failing. +- [ ] Keep integration isolated because Suno UI can change. + +## Phase 9 --- Quality + +- [ ] Add validation for contradictory/invalid project settings. +- [ ] Add loading, offline and provider-error states. +- [ ] Add accessibility labels and keyboard navigation. +- [ ] Test project/version recovery. +- [ ] Test AI output against locked user choices. +- [ ] Test selected instruments influence compiled song-generation + context. +- [ ] Test Suno handoff without generation. + +## Definition of MVP Done + +A user can create a macOS song project through chat, manually or +automatically configure the agreed song parameters, generate/edit lyrics +and song planning through OpenAI, select instruments, review +the complete project, save versions, and send the approved lyrics/style +data to Suno where the app fills fields without initiating generation. From 1993800f277fa274dee881cee64e7510a6f58a95 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 13:18:08 +0200 Subject: [PATCH 02/11] Implement initial song project domain models --- .env.example | 2 + .gitignore | 7 + DEVELOPMENT.md | 16 + Makefile | 10 + Package.swift | 37 ++ .../MusicAssistantApp/MusicAssistantApp.swift | 11 + .../Presentation/RootView.swift | 45 ++ .../Configuration/AppConfiguration.swift | 15 + .../Domain/ProductBoundarySummary.swift | 11 + .../Domain/SongProject.swift | 610 ++++++++++++++++++ .../Suno/SunoHandoffService.swift | 3 + .../Persistence/ProjectStore.swift | 6 + .../Services/AI/AIService.swift | 19 + .../PromptCompiler/PromptCompiling.swift | 15 + .../AppConfigurationTests.swift | 18 + .../ProductBoundarySummaryTests.swift | 11 + .../SongProjectModelTests.swift | 194 ++++++ docs/TASKS.md | 8 +- music-assistant-build-files.md | 325 ++++++++++ 19 files changed, 1359 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DEVELOPMENT.md create mode 100644 Makefile create mode 100644 Package.swift create mode 100644 Sources/MusicAssistantApp/MusicAssistantApp.swift create mode 100644 Sources/MusicAssistantApp/Presentation/RootView.swift create mode 100644 Sources/MusicAssistantCore/Configuration/AppConfiguration.swift create mode 100644 Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift create mode 100644 Sources/MusicAssistantCore/Domain/SongProject.swift create mode 100644 Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift create mode 100644 Sources/MusicAssistantCore/Persistence/ProjectStore.swift create mode 100644 Sources/MusicAssistantCore/Services/AI/AIService.swift create mode 100644 Sources/MusicAssistantCore/Services/PromptCompiler/PromptCompiling.swift create mode 100644 Tests/MusicAssistantCoreTests/AppConfigurationTests.swift create mode 100644 Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift create mode 100644 Tests/MusicAssistantCoreTests/SongProjectModelTests.swift create mode 100644 music-assistant-build-files.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2b046ac --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Local development only. Do not commit real values. +MUSIC_ASSISTANT_OPENAI_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b0e8d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.build/ +.swiftpm/ +DerivedData/ +.DS_Store +.env +*.xcuserdata +*.xcuserstate diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..0b2c848 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,16 @@ +# Development Setup + +## Secrets + +Runtime secrets are read from the local process environment. + +Copy `.env.example` to `.env` for local notes if needed, but do not commit +real credentials. The application must not hard-code API keys or authorization +headers in source files. + +Current environment keys: + +- `MUSIC_ASSISTANT_OPENAI_API_KEY` + +Provider integrations are intentionally protocol-only in the repository +foundation phase. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a6e0f26 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: build dev test + +build: + swift build + +dev: + swift run music-assistant + +test: + swift test diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..b2c40a8 --- /dev/null +++ b/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "music-assistant", + defaultLocalization: "en", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable( + name: "music-assistant", + targets: ["MusicAssistantApp"] + ), + .library( + name: "MusicAssistantCore", + targets: ["MusicAssistantCore"] + ) + ], + targets: [ + .executableTarget( + name: "MusicAssistantApp", + dependencies: ["MusicAssistantCore"], + path: "Sources/MusicAssistantApp" + ), + .target( + name: "MusicAssistantCore", + path: "Sources/MusicAssistantCore" + ), + .testTarget( + name: "MusicAssistantCoreTests", + dependencies: ["MusicAssistantCore"], + path: "Tests/MusicAssistantCoreTests" + ) + ] +) diff --git a/Sources/MusicAssistantApp/MusicAssistantApp.swift b/Sources/MusicAssistantApp/MusicAssistantApp.swift new file mode 100644 index 0000000..039bb7d --- /dev/null +++ b/Sources/MusicAssistantApp/MusicAssistantApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct MusicAssistantApp: App { + var body: some Scene { + WindowGroup { + RootView() + } + .windowStyle(.titleBar) + } +} diff --git a/Sources/MusicAssistantApp/Presentation/RootView.swift b/Sources/MusicAssistantApp/Presentation/RootView.swift new file mode 100644 index 0000000..b2605e5 --- /dev/null +++ b/Sources/MusicAssistantApp/Presentation/RootView.swift @@ -0,0 +1,45 @@ +import MusicAssistantCore +import SwiftUI + +struct RootView: View { + private let appName = "AI Music Studio" + private let boundarySummary = ProductBoundarySummary.initial + + var body: some View { + NavigationSplitView { + List { + Label("Projects", systemImage: "music.note.list") + Label("Settings", systemImage: "gearshape") + } + .navigationTitle(appName) + } detail: { + VStack(alignment: .leading, spacing: 16) { + Text("New Song Project") + .font(.largeTitle.weight(.semibold)) + + Text(boundarySummary.description) + .font(.body) + .foregroundStyle(.secondary) + .frame(maxWidth: 560, alignment: .leading) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Foundation status") + .font(.headline) + + Label("Presentation, Domain, Services, Integrations, and Persistence boundaries are separated.", systemImage: "checkmark.circle") + Label("External providers are represented by protocols only.", systemImage: "lock.shield") + Label("Secrets are expected from local environment configuration.", systemImage: "key") + } + .labelStyle(.titleAndIcon) + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } +} + +#Preview { + RootView() +} diff --git a/Sources/MusicAssistantCore/Configuration/AppConfiguration.swift b/Sources/MusicAssistantCore/Configuration/AppConfiguration.swift new file mode 100644 index 0000000..9317a60 --- /dev/null +++ b/Sources/MusicAssistantCore/Configuration/AppConfiguration.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct AppConfiguration: Equatable, Sendable { + public let openAIAPIKey: String? + + public init(openAIAPIKey: String?) { + self.openAIAPIKey = openAIAPIKey + } + + public static func development(environment: [String: String] = ProcessInfo.processInfo.environment) -> AppConfiguration { + AppConfiguration( + openAIAPIKey: environment["MUSIC_ASSISTANT_OPENAI_API_KEY"] + ) + } +} diff --git a/Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift b/Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift new file mode 100644 index 0000000..6cf9515 --- /dev/null +++ b/Sources/MusicAssistantCore/Domain/ProductBoundarySummary.swift @@ -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." + ) +} diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift new file mode 100644 index 0000000..aab5e60 --- /dev/null +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -0,0 +1,610 @@ +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? + public var key: ManualAutoValue? + public var scale: ManualAutoValue? + public var maqam: ManualAutoValue? + 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? = nil, + key: ManualAutoValue? = nil, + scale: ManualAutoValue? = nil, + maqam: ManualAutoValue? = 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 { + 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 selectedInstrumentTracks: [InstrumentTrack] { + instruments.filter(\.selected) + } + + var selectedInstrumentIDs: Set { + 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] + ) + ) + } + } + + 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 + } + } +} + +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: 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 userNotes: String? + public var preservedSpellings: [String] + + public init(isEnabled: Bool = false, userNotes: String? = nil, preservedSpellings: [String] = []) { + self.isEnabled = isEnabled + self.userNotes = userNotes + self.preservedSpellings = preservedSpellings + } +} + +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? + public var key: ManualAutoValue? + public var scale: ManualAutoValue? + public var maqam: ManualAutoValue? + 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? = nil, + key: ManualAutoValue? = nil, + scale: ManualAutoValue? = nil, + maqam: ManualAutoValue? = 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 + ) + } +} diff --git a/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift b/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift new file mode 100644 index 0000000..ce52025 --- /dev/null +++ b/Sources/MusicAssistantCore/Integrations/Suno/SunoHandoffService.swift @@ -0,0 +1,3 @@ +public protocol SunoHandoffService: Sendable { + func handOff(_ output: CompiledSunoOutput) async throws +} diff --git a/Sources/MusicAssistantCore/Persistence/ProjectStore.swift b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift new file mode 100644 index 0000000..2b4715f --- /dev/null +++ b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift @@ -0,0 +1,6 @@ +public protocol ProjectStore: Sendable { + associatedtype Project + + func save(_ project: Project) async throws + func loadProjectIDs() async throws -> [String] +} diff --git a/Sources/MusicAssistantCore/Services/AI/AIService.swift b/Sources/MusicAssistantCore/Services/AI/AIService.swift new file mode 100644 index 0000000..1aae9e6 --- /dev/null +++ b/Sources/MusicAssistantCore/Services/AI/AIService.swift @@ -0,0 +1,19 @@ +public protocol AIService: Sendable { + func planSongProject(from request: SongPlanningRequest) async throws -> SongPlanningDraft +} + +public struct SongPlanningRequest: Equatable, Sendable { + public let userIntent: String + + public init(userIntent: String) { + self.userIntent = userIntent + } +} + +public struct SongPlanningDraft: Equatable, Sendable { + public let summary: String + + public init(summary: String) { + self.summary = summary + } +} diff --git a/Sources/MusicAssistantCore/Services/PromptCompiler/PromptCompiling.swift b/Sources/MusicAssistantCore/Services/PromptCompiler/PromptCompiling.swift new file mode 100644 index 0000000..60cc6f7 --- /dev/null +++ b/Sources/MusicAssistantCore/Services/PromptCompiler/PromptCompiling.swift @@ -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 + } +} diff --git a/Tests/MusicAssistantCoreTests/AppConfigurationTests.swift b/Tests/MusicAssistantCoreTests/AppConfigurationTests.swift new file mode 100644 index 0000000..4566867 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/AppConfigurationTests.swift @@ -0,0 +1,18 @@ +import MusicAssistantCore +import XCTest + +final class AppConfigurationTests: XCTestCase { + func testDevelopmentConfigurationReadsKnownEnvironmentKeys() { + let configuration = AppConfiguration.development(environment: [ + "MUSIC_ASSISTANT_OPENAI_API_KEY": "openai-test-key" + ]) + + XCTAssertEqual(configuration.openAIAPIKey, "openai-test-key") + } + + func testDevelopmentConfigurationAllowsMissingSecrets() { + let configuration = AppConfiguration.development(environment: [:]) + + XCTAssertNil(configuration.openAIAPIKey) + } +} diff --git a/Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift b/Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift new file mode 100644 index 0000000..c7e2468 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/ProductBoundarySummaryTests.swift @@ -0,0 +1,11 @@ +import MusicAssistantCore +import XCTest + +final class ProductBoundarySummaryTests: XCTestCase { + func testInitialBoundaryKeepsSunoGenerationOutsideTheApp() { + let summary = ProductBoundarySummary.initial.description + + XCTAssertTrue(summary.contains("Suno")) + XCTAssertTrue(summary.contains("without starting generation")) + } +} diff --git a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift new file mode 100644 index 0000000..896e4a5 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift @@ -0,0 +1,194 @@ +import MusicAssistantCore +import XCTest + +final class SongProjectModelTests: XCTestCase { + func testSongProjectStoresCoreDomainCollections() { + let section = SongSection( + id: "section-verse-1", + type: .verse, + title: "Verse 1", + startTime: 12, + endTime: 42, + lyrics: "Draft verse", + emotion: "hopeful", + energy: 6, + vocalDirection: "soft lead", + productionDirection: "light percussion" + ) + let placement = InstrumentPlacement( + sectionId: section.id, + startTime: section.startTime, + endTime: section.endTime, + direction: "enter after the first line" + ) + let instrument = InstrumentTrack( + instrumentId: "oud", + selected: true, + variant: "Arabic oud", + playingStyle: "picked", + role: "main melodic hook", + autoArrangementEnabled: false, + placements: [placement] + ) + let vocalist = Vocalist( + id: "lead", + label: "Lead vocalist", + voiceType: "warm tenor", + genderSelection: "male", + performanceStyle: "sung", + assignedSections: [section.id] + ) + let project = SongProject( + id: "project-1", + title: "Desert Lights", + idea: "A hopeful Arabic pop song", + duration: SongDuration(seconds: 180), + conversationMode: .discuss, + languages: [LanguageSelection(id: "arabic", name: "Arabic")], + dialects: [DialectSelection(id: "levantine", name: "Levantine", languageID: "arabic")], + arabicPronunciationSettings: ArabicPronunciationSettings(isEnabled: true), + genres: [GenreStyle(id: "arabic-pop", name: "Arabic Pop")], + moods: [MoodTag(id: "hopeful", name: "Hopeful")], + emotionalArc: [EmotionalArcPoint(id: "arc-1", sectionID: section.id, emotion: "hopeful")], + bpm: ManualAutoValue(mode: .manual, value: 96), + key: ManualAutoValue(mode: .auto), + scale: ManualAutoValue(mode: .auto), + maqam: ManualAutoValue(mode: .manual, value: "Hijaz"), + sections: [section], + instruments: [instrument], + vocalists: [vocalist], + lyrics: Lyrics(text: "Draft lyrics", sectionTexts: [section.id: "Draft verse"]), + productionDirections: [ProductionDirection(id: "prod-1", sectionID: section.id, text: "add lift")], + sunoOutput: SunoOutput(lyricsText: "Draft lyrics", stylePrompt: "Arabic pop with oud") + ) + + XCTAssertEqual(project.sections.first?.type, .verse) + XCTAssertEqual(project.instruments.first?.instrumentId, "oud") + XCTAssertEqual(project.instruments.first?.placements.first?.sectionId, section.id) + XCTAssertEqual(project.vocalists.first?.assignedSections, [section.id]) + XCTAssertEqual(project.bpm?.mode, .manual) + XCTAssertEqual(project.maqam?.value, "Hijaz") + } + + func testSongProjectSnapshotCopiesVersionableState() { + let project = SongProject( + id: "project-2", + title: "Night Drive", + idea: "Electronic track", + genres: [GenreStyle(id: "synthwave", name: "Synthwave")], + instruments: [InstrumentTrack(instrumentId: "synthesizer")] + ) + + let snapshot = SongProjectSnapshot(project: project) + let version = SongVersion(label: "Initial direction", snapshot: snapshot) + + XCTAssertEqual(version.snapshot.title, project.title) + XCTAssertEqual(version.snapshot.idea, project.idea) + XCTAssertEqual(version.snapshot.genres, project.genres) + XCTAssertEqual(version.snapshot.instruments, project.instruments) + } + + func testGenreStylesAreModeledAsMultiSelect() { + let arabicPop = GenreStyle(id: "arabic-pop", name: "Arabic Pop") + let synthwave = GenreStyle(id: "synthwave", name: "Synthwave") + var project = SongProject( + id: "project-3", + title: "Hybrid", + idea: "Blend traditional melody with electronic production" + ) + + project.selectGenreStyle(arabicPop) + project.selectGenreStyle(synthwave) + project.selectGenreStyle(arabicPop) + + XCTAssertEqual(project.genres, [arabicPop, synthwave]) + XCTAssertTrue(project.isGenreStyleSelected(arabicPop)) + XCTAssertEqual(project.selectedGenreStyleIDs, ["arabic-pop", "synthwave"]) + + project.deselectGenreStyle(id: arabicPop.id) + + XCTAssertEqual(project.genres, [synthwave]) + XCTAssertFalse(project.isGenreStyleSelected(arabicPop)) + } + + func testSongSectionsKeepExplicitOrderAndOptionalTimestamps() { + var project = SongProject( + id: "project-4", + title: "Section Flow", + idea: "Build a classic song structure" + ) + let intro = SongSection(id: "intro", type: .intro, title: "Intro") + let verse = SongSection( + id: "verse", + type: .verse, + title: "Verse", + startTime: 15, + endTime: 45 + ) + let chorus = SongSection(id: "chorus", type: .chorus, title: "Chorus") + + project.appendSection(verse) + project.appendSection(chorus) + project.appendSection(intro) + project.moveSection(id: intro.id, toOrder: 0) + + XCTAssertEqual(project.orderedSections.map(\.id), ["intro", "verse", "chorus"]) + XCTAssertNil(project.orderedSections.first?.startTime) + XCTAssertNil(project.orderedSections.first?.endTime) + XCTAssertEqual(project.orderedSections[1].startTime, 15) + XCTAssertEqual(project.orderedSections[1].endTime, 45) + XCTAssertEqual(project.orderedSections.map(\.order), [0, 1, 2]) + } + + func testInstrumentsTrackVariantsRolesSelectionAndPlacements() { + var project = SongProject( + id: "project-5", + title: "Instrument Plan", + idea: "Feature oud and violin with clear entries" + ) + let oud = InstrumentTrack( + instrumentId: "oud", + selected: true, + variant: "Arabic oud", + playingStyle: "tremolo", + role: "main hook", + autoArrangementEnabled: false + ) + let introPlacement = InstrumentPlacement( + sectionId: "intro", + startTime: 0, + endTime: 12, + direction: "solo intro" + ) + let chorusPlacement = InstrumentPlacement( + sectionId: "chorus", + direction: "double the vocal melody" + ) + + project.selectInstrument(oud) + project.addInstrumentPlacement(introPlacement, to: oud.instrumentId) + project.addInstrumentPlacement(chorusPlacement, to: "violin") + project.configureInstrument( + id: "violin", + variant: "solo violin", + playingStyle: "legato", + role: "countermelody", + autoArrangementEnabled: true + ) + + XCTAssertEqual(project.selectedInstrumentIDs, ["oud", "violin"]) + XCTAssertEqual(project.instrumentTrack(for: "oud")?.variant, "Arabic oud") + XCTAssertEqual(project.instrumentTrack(for: "oud")?.role, "main hook") + XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [introPlacement]) + XCTAssertEqual(project.instrumentTrack(for: "violin")?.variant, "solo violin") + XCTAssertEqual(project.instrumentTrack(for: "violin")?.playingStyle, "legato") + XCTAssertEqual(project.instrumentTrack(for: "violin")?.role, "countermelody") + XCTAssertEqual(project.instrumentTrack(for: "violin")?.placements, [chorusPlacement]) + + project.deselectInstrument(id: "oud") + + XCTAssertFalse(project.isInstrumentSelected(id: "oud")) + XCTAssertEqual(project.selectedInstrumentIDs, ["violin"]) + XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [introPlacement]) + } +} diff --git a/docs/TASKS.md b/docs/TASKS.md index f253bc6..d921947 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -19,11 +19,11 @@ requirement is missing and blocks implementation, record it in ## Phase 1 --- Domain Model -- [ ] Implement `SongProject` and supporting models from +- [x] Implement `SongProject` and supporting models from `DATA_MODEL.md`. -- [ ] Model genres/styles as multi-select. -- [ ] Model song sections with order and optional timestamps. -- [ ] Model instruments, variants, roles and placements. +- [x] Model genres/styles as multi-select. +- [x] Model song sections with order and optional timestamps. +- [x] Model instruments, variants, roles and placements. - [ ] Model multiple vocalists and section assignments. - [ ] Model language/dialect and Arabic pronunciation settings. - [ ] Model BPM, key, scale and maqam with Manual/Auto state. diff --git a/music-assistant-build-files.md b/music-assistant-build-files.md new file mode 100644 index 0000000..2fc2677 --- /dev/null +++ b/music-assistant-build-files.md @@ -0,0 +1,325 @@ +# Build Files Template for music-assistant + +Use this document as the prompt/instruction file for an agent when you create the +`music-assistant` Swift macOS app project. + +The target project does not need to match the Servery path. These files should be +created in the root of the future `music-assistant` project, next to +`Package.swift`. + +## Agent Request + +Copy this request into the agent working inside the new project: + +```text +Create the same build-command structure for this Swift macOS app. + +App name: +music-assistant + +Product name: +music-assistant + +Bundle identifier: +com.music-assistant.app + +Requirements: +1. Create `Makefile` with `build`, `dev`, and `test` commands. +2. Create `clean_rebuild.sh` for a clean release build, packaging, signing, + installing into `/Applications`, and launching the app. +3. Create `dev_rebuild.sh` for a fast incremental debug build, packaging, + installing into `/Applications`, and launching the app. +4. Assume these files live in the project root next to `Package.swift`. +5. Use the app icon at `Resources/music-assistant.icns`. +6. If the app has additional resources, copy them into + `Contents/Resources` during packaging. +7. Make both shell scripts executable. +8. Keep the implementation small and local. Do not refactor unrelated project + files. +9. After creating the files, verify that the scripts reference the correct app + name, product name, bundle identifier, icon path, and install path. +``` + +## Makefile + +Create this file as `Makefile`: + +```makefile +.PHONY: build dev test + +build: + ./clean_rebuild.sh + +dev: + ./dev_rebuild.sh + +test: + swift test +``` + +## dev_rebuild.sh + +Create this file as `dev_rebuild.sh`: + +```zsh +#!/bin/zsh +# Fast development rebuild: incremental DEBUG build, repackage, relaunch. +# +# This skips `swift package clean` and release-mode optimizations, so it reuses +# the incremental build cache and finishes quickly. Use it while iterating. + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)" +APP_NAME="music-assistant" +PRODUCT_NAME="music-assistant" +APP_BUNDLE_NAME="${APP_NAME}.app" +BUILD_DIR="${PROJECT_ROOT}/.build" +APP_BUNDLE="${BUILD_DIR}/${APP_BUNDLE_NAME}" +INSTALL_PATH="/Applications/${APP_BUNDLE_NAME}" +CONTENTS_DIR="${APP_BUNDLE}/Contents" +MACOS_DIR="${CONTENTS_DIR}/MacOS" +RESOURCES_DIR="${CONTENTS_DIR}/Resources" +APP_ICON="${PROJECT_ROOT}/Resources/music-assistant.icns" + +fail() { + print -u2 -- "Error: $1" + exit 1 +} + +step() { + print -- "==> $1" +} + +[[ -f "${PROJECT_ROOT}/Package.swift" ]] || fail "Package.swift was not found in ${PROJECT_ROOT}." +[[ -f "${APP_ICON}" ]] || fail "App icon was not found at ${APP_ICON}." +command -v swift >/dev/null 2>&1 || fail "Swift is required. Install Xcode or the Xcode Command Line Tools." +command -v codesign >/dev/null 2>&1 || fail "codesign is required and should be available with Xcode." + +cd "${PROJECT_ROOT}" + +step "Stopping any running ${APP_NAME} application" +if pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1; then + pkill -x "${PRODUCT_NAME}" || fail "Could not stop the running ${APP_NAME} application." + for _ in {1..20}; do + pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 || break + sleep 0.1 + done + pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 && fail "${APP_NAME} is still running. Quit it and retry." +fi + +step "Building ${PRODUCT_NAME} in debug mode (incremental)" +swift build || fail "Debug build failed. Review the Swift compiler diagnostics above." +BIN_PATH="$(swift build --show-bin-path 2>/dev/null | tail -n1)" +SOURCE_EXECUTABLE="${BIN_PATH}/${PRODUCT_NAME}" +[[ -x "${SOURCE_EXECUTABLE}" ]] || fail "Built executable was not found at ${SOURCE_EXECUTABLE}." + +step "Packaging ${APP_BUNDLE_NAME}" +rm -rf "${APP_BUNDLE}" +mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}" +cp "${SOURCE_EXECUTABLE}" "${MACOS_DIR}/${PRODUCT_NAME}" +cp "${APP_ICON}" "${RESOURCES_DIR}/music-assistant.icns" + +cat > "${CONTENTS_DIR}/Info.plist" <<'PLIST' + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + music-assistant + CFBundleExecutable + music-assistant + CFBundleIdentifier + com.music-assistant.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleIconFile + music-assistant + CFBundleName + music-assistant + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +PLIST + +plutil -lint "${CONTENTS_DIR}/Info.plist" >/dev/null || fail "Generated Info.plist is invalid." +codesign --force --sign - --timestamp=none "${APP_BUNDLE}" || fail "Ad-hoc signing of ${APP_BUNDLE_NAME} failed." +[[ -x "${MACOS_DIR}/${PRODUCT_NAME}" ]] || fail "Packaged executable is missing." + +step "Installing ${APP_BUNDLE_NAME} in /Applications" +rm -rf "${INSTALL_PATH}" +ditto "${APP_BUNDLE}" "${INSTALL_PATH}" || fail "Could not install ${APP_BUNDLE_NAME} in /Applications. Check permissions and retry." + +step "Launching ${APP_NAME}" +open -n "${INSTALL_PATH}" || fail "${APP_NAME} was installed but could not be opened." + +print -- "==> ${APP_NAME} was rebuilt (debug), installed, and launched successfully." +``` + +## clean_rebuild.sh + +Create this file as `clean_rebuild.sh`: + +```zsh +#!/bin/zsh +# Build, package, install, and launch the music-assistant macOS application. + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")" && pwd)" +APP_NAME="music-assistant" +PRODUCT_NAME="music-assistant" +APP_BUNDLE_NAME="${APP_NAME}.app" +BUILD_DIR="${PROJECT_ROOT}/.build" +APP_BUNDLE="${BUILD_DIR}/${APP_BUNDLE_NAME}" +INSTALL_PATH="/Applications/${APP_BUNDLE_NAME}" +CONTENTS_DIR="${APP_BUNDLE}/Contents" +MACOS_DIR="${CONTENTS_DIR}/MacOS" +RESOURCES_DIR="${CONTENTS_DIR}/Resources" +APP_ICON="${PROJECT_ROOT}/Resources/music-assistant.icns" + +fail() { + print -u2 -- "Error: $1" + exit 1 +} + +step() { + print -- "==> $1" +} + +[[ -f "${PROJECT_ROOT}/Package.swift" ]] || fail "Package.swift was not found in ${PROJECT_ROOT}." +[[ -f "${APP_ICON}" ]] || fail "App icon was not found at ${APP_ICON}." +command -v swift >/dev/null 2>&1 || fail "Swift is required. Install Xcode or the Xcode Command Line Tools." +command -v codesign >/dev/null 2>&1 || fail "codesign is required and should be available with Xcode." + +cd "${PROJECT_ROOT}" + +step "Stopping any running ${APP_NAME} application" +if pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1; then + pkill -x "${PRODUCT_NAME}" || fail "Could not stop the running ${APP_NAME} application." + for _ in {1..20}; do + pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 || break + sleep 0.1 + done + pgrep -x "${PRODUCT_NAME}" >/dev/null 2>&1 && fail "${APP_NAME} is still running. Quit it and retry." +fi + +step "Removing stale installed copy" +if [[ -e "${INSTALL_PATH}" ]]; then + rm -rf "${INSTALL_PATH}" || fail "Could not remove ${INSTALL_PATH}. Check permissions and retry." +fi + +step "Cleaning local Swift build artifacts" +swift package clean || fail "Swift Package Manager could not clean the local build artifacts." + +step "Building ${PRODUCT_NAME} in release mode" +BIN_PATH="$(swift build -c release --show-bin-path 2>/dev/null | tail -n1)" +SOURCE_EXECUTABLE="${BIN_PATH}/${PRODUCT_NAME}" + +built=0 +for attempt in 1 2 3; do + swift build -c release || fail "Release build failed. Review the Swift compiler diagnostics above." + if [[ -x "${SOURCE_EXECUTABLE}" ]]; then built=1; break; fi + print -- "==> Executable not present after build (attempt ${attempt}); retrying" + sleep 1 +done +[[ "${built}" -eq 1 ]] || fail "Built executable was not found at ${SOURCE_EXECUTABLE}." + +step "Packaging ${APP_BUNDLE_NAME}" +rm -rf "${APP_BUNDLE}" +mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}" +cp "${SOURCE_EXECUTABLE}" "${MACOS_DIR}/${PRODUCT_NAME}" +cp "${APP_ICON}" "${RESOURCES_DIR}/music-assistant.icns" + +cat > "${CONTENTS_DIR}/Info.plist" <<'PLIST' + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + music-assistant + CFBundleExecutable + music-assistant + CFBundleIdentifier + com.music-assistant.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleIconFile + music-assistant + CFBundleName + music-assistant + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +PLIST + +plutil -lint "${CONTENTS_DIR}/Info.plist" >/dev/null || fail "Generated Info.plist is invalid." +codesign --force --sign - --timestamp=none "${APP_BUNDLE}" || fail "Ad-hoc signing of ${APP_BUNDLE_NAME} failed." +codesign --verify --deep --strict "${APP_BUNDLE}" || fail "Packaged ${APP_BUNDLE_NAME} failed code-signature verification." +[[ -x "${MACOS_DIR}/${PRODUCT_NAME}" ]] || fail "Packaged executable is missing." + +step "Installing ${APP_BUNDLE_NAME} in /Applications" +ditto "${APP_BUNDLE}" "${INSTALL_PATH}" || fail "Could not install ${APP_BUNDLE_NAME} in /Applications. Check permissions and retry." + +INSTALLED_EXECUTABLE="${INSTALL_PATH}/Contents/MacOS/${PRODUCT_NAME}" +[[ -x "${INSTALLED_EXECUTABLE}" ]] || fail "Installed executable is missing at ${INSTALLED_EXECUTABLE}." +SOURCE_HASH="$(shasum -a 256 "${MACOS_DIR}/${PRODUCT_NAME}" | awk '{print $1}')" +INSTALLED_HASH="$(shasum -a 256 "${INSTALLED_EXECUTABLE}" | awk '{print $1}')" +[[ "${SOURCE_HASH}" == "${INSTALLED_HASH}" ]] || fail "Installed executable does not match the newly built executable." + +step "Launching ${APP_NAME}" +open -n "${INSTALL_PATH}" || fail "${APP_NAME} was installed but could not be opened." + +print -- "==> ${APP_NAME} was rebuilt, installed, and launched successfully." +``` + +## Required Terminal Step + +After the files are created in the new project, run: + +```bash +chmod +x clean_rebuild.sh dev_rebuild.sh +``` + +Then use: + +```bash +make dev +make build +make test +``` + +## Notes + +- The future project must have `Package.swift`. +- The future project must have an icon at `Resources/music-assistant.icns`. +- If the executable product name in `Package.swift` is different, change + `PRODUCT_NAME` in both scripts. +- If the app needs permissions, add the matching usage-description keys to the + generated `Info.plist`. From 1b523a924d52c323a65e5e9bd56e9982f7cea4eb Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 13:19:33 +0200 Subject: [PATCH 03/11] Model vocalist section assignments --- .../Domain/SongProject.swift | 43 ++++++++++++++++++ .../SongProjectModelTests.swift | 44 +++++++++++++++++++ docs/TASKS.md | 2 +- 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift index aab5e60..c6435a5 100644 --- a/Sources/MusicAssistantCore/Domain/SongProject.swift +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -169,6 +169,44 @@ public extension SongProject { } } + var vocalistIDs: Set { + 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 appendSection(_ section: SongSection) { var orderedSection = section orderedSection.order = nextSectionOrder @@ -200,6 +238,11 @@ public extension SongProject { return orderedSection } } + + private static func uniqueOrderedIDs(_ ids: [String]) -> [String] { + var seenIDs = Set() + return ids.filter { seenIDs.insert($0).inserted } + } } public enum ConversationMode: String, Codable, Equatable, Sendable { diff --git a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift index 896e4a5..e86d8e1 100644 --- a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift +++ b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift @@ -191,4 +191,48 @@ final class SongProjectModelTests: XCTestCase { XCTAssertEqual(project.selectedInstrumentIDs, ["violin"]) XCTAssertEqual(project.instrumentTrack(for: "oud")?.placements, [introPlacement]) } + + func testMultipleVocalistsCanBeAssignedToSections() { + var project = SongProject( + id: "project-6", + title: "Vocal Arrangement", + idea: "Lead and harmony share selected sections" + ) + let lead = Vocalist( + id: "lead", + label: "Lead", + voiceType: "tenor", + genderSelection: "male", + performanceStyle: "sung", + assignedSections: ["verse", "verse"] + ) + let harmony = Vocalist( + id: "harmony", + label: "Harmony", + voiceType: "alto", + genderSelection: "female", + performanceStyle: "harmony" + ) + + project.upsertVocalist(lead) + project.upsertVocalist(harmony) + project.assignSection("chorus", toVocalistID: "lead") + project.assignSection("chorus", toVocalistID: "lead") + project.assignSection("chorus", toVocalistID: "harmony") + project.assignSection("bridge", toVocalistID: "harmony") + + XCTAssertEqual(project.vocalistIDs, ["lead", "harmony"]) + XCTAssertEqual(project.vocalist(for: "lead")?.assignedSections, ["verse", "chorus"]) + XCTAssertEqual(project.vocalist(for: "harmony")?.assignedSections, ["chorus", "bridge"]) + XCTAssertEqual(project.vocalistsAssigned(toSectionID: "chorus").map(\.id), ["lead", "harmony"]) + + project.unassignSection("chorus", fromVocalistID: "lead") + + XCTAssertEqual(project.vocalist(for: "lead")?.assignedSections, ["verse"]) + XCTAssertEqual(project.vocalistsAssigned(toSectionID: "chorus").map(\.id), ["harmony"]) + + project.removeVocalist(id: "harmony") + + XCTAssertNil(project.vocalist(for: "harmony")) + } } diff --git a/docs/TASKS.md b/docs/TASKS.md index d921947..6cd85bf 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -24,7 +24,7 @@ requirement is missing and blocks implementation, record it in - [x] Model genres/styles as multi-select. - [x] Model song sections with order and optional timestamps. - [x] Model instruments, variants, roles and placements. -- [ ] Model multiple vocalists and section assignments. +- [x] Model multiple vocalists and section assignments. - [ ] Model language/dialect and Arabic pronunciation settings. - [ ] Model BPM, key, scale and maqam with Manual/Auto state. - [ ] Model emotional arc and production directions. From c3ebf95e537738d6d772a8d1377bd2cf0d13c7f4 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 13:29:15 +0200 Subject: [PATCH 04/11] Model language and pronunciation settings --- .../Domain/SongProject.swift | 70 ++++++++++++++++++- .../SongProjectModelTests.swift | 44 ++++++++++++ docs/TASKS.md | 2 +- 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift index c6435a5..41de062 100644 --- a/Sources/MusicAssistantCore/Domain/SongProject.swift +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -109,6 +109,51 @@ public extension SongProject { genres.removeAll { $0.id == id } } + var languageIDs: Set { + Set(languages.map(\.id)) + } + + var dialectIDs: Set { + 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 } + } + var selectedInstrumentTracks: [InstrumentTrack] { instruments.filter(\.selected) } @@ -299,16 +344,39 @@ public struct DialectSelection: Codable, Equatable, Identifiable, Sendable { public struct ArabicPronunciationSettings: Codable, Equatable, Sendable { public var isEnabled: Bool + public var diacritizationPolicy: ArabicDiacritizationPolicy + public var tanweenPolicy: ArabicTanweenPolicy public var userNotes: String? public var preservedSpellings: [String] - public init(isEnabled: Bool = false, userNotes: String? = nil, preservedSpellings: [String] = []) { + public init( + isEnabled: Bool = false, + diacritizationPolicy: ArabicDiacritizationPolicy = .unspecified, + tanweenPolicy: ArabicTanweenPolicy = .unspecified, + userNotes: String? = nil, + preservedSpellings: [String] = [] + ) { self.isEnabled = isEnabled + self.diacritizationPolicy = diacritizationPolicy + self.tanweenPolicy = tanweenPolicy self.userNotes = userNotes self.preservedSpellings = preservedSpellings } } +public enum ArabicDiacritizationPolicy: String, Codable, Equatable, Sendable { + case unspecified + case pronunciationTargeted + case fullTashkeel +} + +public enum ArabicTanweenPolicy: String, Codable, Equatable, Sendable { + case unspecified + case preserveUserInput + case addWhenPronunciationRequires + case removeWhenUnwanted +} + public struct GenreStyle: Codable, Equatable, Identifiable, Sendable { public let id: String public var name: String diff --git a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift index e86d8e1..787b920 100644 --- a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift +++ b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift @@ -235,4 +235,48 @@ final class SongProjectModelTests: XCTestCase { XCTAssertNil(project.vocalist(for: "harmony")) } + + func testLanguagesDialectsAndArabicPronunciationSettingsAreModeled() { + var project = SongProject( + id: "project-7", + title: "Multilingual Song", + idea: "Arabic and English hook" + ) + let arabic = LanguageSelection(id: "arabic", name: "Arabic") + let english = LanguageSelection(id: "english", name: "English") + let levantine = DialectSelection(id: "levantine", name: "Levantine", languageID: arabic.id) + let gulf = DialectSelection(id: "gulf", name: "Gulf", languageID: arabic.id) + + project.upsertLanguage(arabic) + project.upsertLanguage(english) + project.upsertLanguage(LanguageSelection(id: "english", name: "English")) + project.upsertDialect(levantine) + project.upsertDialect(gulf) + project.arabicPronunciationSettings = ArabicPronunciationSettings( + isEnabled: true, + diacritizationPolicy: .pronunciationTargeted, + tanweenPolicy: .preserveUserInput, + userNotes: "Preserve dialect phrasing", + preservedSpellings: ["ya habibi"] + ) + + XCTAssertEqual(project.languageIDs, ["arabic", "english"]) + XCTAssertEqual(project.dialectIDs, ["levantine", "gulf"]) + XCTAssertEqual(project.language(for: arabic.id), arabic) + XCTAssertEqual(project.dialects(forLanguageID: arabic.id), [levantine, gulf]) + XCTAssertTrue(project.arabicPronunciationSettings.isEnabled) + XCTAssertEqual(project.arabicPronunciationSettings.diacritizationPolicy, .pronunciationTargeted) + XCTAssertEqual(project.arabicPronunciationSettings.tanweenPolicy, .preserveUserInput) + + project.removeDialect(id: gulf.id) + + XCTAssertNil(project.dialect(for: gulf.id)) + XCTAssertEqual(project.dialects(forLanguageID: arabic.id), [levantine]) + + project.removeLanguage(id: arabic.id) + + XCTAssertNil(project.language(for: arabic.id)) + XCTAssertEqual(project.dialects(forLanguageID: arabic.id), []) + XCTAssertEqual(project.languageIDs, ["english"]) + } } diff --git a/docs/TASKS.md b/docs/TASKS.md index 6cd85bf..51d64f3 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -25,7 +25,7 @@ requirement is missing and blocks implementation, record it in - [x] Model song sections with order and optional timestamps. - [x] Model instruments, variants, roles and placements. - [x] Model multiple vocalists and section assignments. -- [ ] Model language/dialect and Arabic pronunciation settings. +- [x] Model language/dialect and Arabic pronunciation settings. - [ ] Model BPM, key, scale and maqam with Manual/Auto state. - [ ] Model emotional arc and production directions. - [ ] Add serialization tests. From 20c541421fe34161cedcff446475307a92ceee12 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 13:33:30 +0200 Subject: [PATCH 05/11] Model manual auto musical parameters --- .../Domain/SongProject.swift | 32 +++++++++++++++++++ .../SongProjectModelTests.swift | 28 ++++++++++++++++ docs/TASKS.md | 2 +- 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift index 41de062..ae86459 100644 --- a/Sources/MusicAssistantCore/Domain/SongProject.swift +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -154,6 +154,38 @@ public extension SongProject { 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) + } + var selectedInstrumentTracks: [InstrumentTrack] { instruments.filter(\.selected) } diff --git a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift index 787b920..e96570a 100644 --- a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift +++ b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift @@ -279,4 +279,32 @@ final class SongProjectModelTests: XCTestCase { XCTAssertEqual(project.dialects(forLanguageID: arabic.id), []) XCTAssertEqual(project.languageIDs, ["english"]) } + + func testMusicalParametersSupportManualAndAutoModes() { + var project = SongProject( + id: "project-8", + title: "Musical Parameters", + idea: "Control tempo and harmony" + ) + + project.setBPMManually(104) + project.setKeyManually("D") + project.setScaleAutomatic() + project.setMaqamManually("Bayati") + + XCTAssertEqual(project.bpm, ManualAutoValue(mode: .manual, value: 104)) + XCTAssertEqual(project.key, ManualAutoValue(mode: .manual, value: "D")) + XCTAssertEqual(project.scale, ManualAutoValue(mode: .auto)) + XCTAssertEqual(project.maqam, ManualAutoValue(mode: .manual, value: "Bayati")) + + project.setBPMAutomatic() + project.setKeyAutomatic() + project.setScaleManually("minor") + project.setMaqamAutomatic() + + XCTAssertEqual(project.bpm, ManualAutoValue(mode: .auto)) + XCTAssertEqual(project.key, ManualAutoValue(mode: .auto)) + XCTAssertEqual(project.scale, ManualAutoValue(mode: .manual, value: "minor")) + XCTAssertEqual(project.maqam, ManualAutoValue(mode: .auto)) + } } diff --git a/docs/TASKS.md b/docs/TASKS.md index 51d64f3..d47972c 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -26,7 +26,7 @@ requirement is missing and blocks implementation, record it in - [x] Model instruments, variants, roles and placements. - [x] Model multiple vocalists and section assignments. - [x] Model language/dialect and Arabic pronunciation settings. -- [ ] Model BPM, key, scale and maqam with Manual/Auto state. +- [x] Model BPM, key, scale and maqam with Manual/Auto state. - [ ] Model emotional arc and production directions. - [ ] Add serialization tests. From ece652b2e453b69e5e6e70e66387cc48cd4c657a Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 13:48:05 +0200 Subject: [PATCH 06/11] Model emotional arc and production directions --- .../Domain/SongProject.swift | 40 +++++++++++++ .../SongProjectModelTests.swift | 58 +++++++++++++++++++ docs/TASKS.md | 2 +- 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift index ae86459..fffb11e 100644 --- a/Sources/MusicAssistantCore/Domain/SongProject.swift +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -186,6 +186,46 @@ public extension SongProject { 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) } diff --git a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift index e96570a..fa2be4e 100644 --- a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift +++ b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift @@ -307,4 +307,62 @@ final class SongProjectModelTests: XCTestCase { XCTAssertEqual(project.scale, ManualAutoValue(mode: .manual, value: "minor")) XCTAssertEqual(project.maqam, ManualAutoValue(mode: .auto)) } + + func testEmotionalArcAndProductionDirectionsAreModeled() { + var project = SongProject( + id: "project-9", + title: "Arc", + idea: "Build from intimate to explosive" + ) + let verseArc = EmotionalArcPoint( + id: "arc-verse", + sectionID: "verse", + emotion: "intimate", + energy: 3, + direction: "restrained" + ) + let chorusArc = EmotionalArcPoint( + id: "arc-chorus", + sectionID: "chorus", + emotion: "euphoric", + energy: 9, + direction: "wide and bright" + ) + let verseProduction = ProductionDirection( + id: "prod-verse", + sectionID: "verse", + text: "keep drums minimal" + ) + let chorusProduction = ProductionDirection( + id: "prod-chorus", + sectionID: "chorus", + text: "add layered harmonies and bigger percussion" + ) + + project.upsertEmotionalArcPoint(verseArc) + project.upsertEmotionalArcPoint(chorusArc) + project.upsertEmotionalArcPoint( + EmotionalArcPoint( + id: "arc-verse", + sectionID: "verse", + emotion: "focused", + energy: 4, + direction: "slightly warmer" + ) + ) + project.upsertProductionDirection(verseProduction) + project.upsertProductionDirection(chorusProduction) + + XCTAssertEqual(project.emotionalArc.count, 2) + XCTAssertEqual(project.emotionalArcPoint(for: "arc-verse")?.emotion, "focused") + XCTAssertEqual(project.emotionalArcPoints(forSectionID: "chorus"), [chorusArc]) + XCTAssertEqual(project.productionDirections(forSectionID: "verse"), [verseProduction]) + XCTAssertEqual(project.productionDirection(for: "prod-chorus"), chorusProduction) + + project.removeEmotionalArcPoint(id: "arc-chorus") + project.removeProductionDirection(id: "prod-verse") + + XCTAssertNil(project.emotionalArcPoint(for: "arc-chorus")) + XCTAssertNil(project.productionDirection(for: "prod-verse")) + } } diff --git a/docs/TASKS.md b/docs/TASKS.md index d47972c..92fa2c9 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -27,7 +27,7 @@ requirement is missing and blocks implementation, record it in - [x] Model multiple vocalists and section assignments. - [x] Model language/dialect and Arabic pronunciation settings. - [x] Model BPM, key, scale and maqam with Manual/Auto state. -- [ ] Model emotional arc and production directions. +- [x] Model emotional arc and production directions. - [ ] Add serialization tests. ## Phase 2 --- Project Persistence & Versions From 55235ba90d26196daadc873fd60f50a00f443f17 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 13:50:13 +0200 Subject: [PATCH 07/11] Add song project serialization tests --- .../SongProjectModelTests.swift | 105 ++++++++++++++++++ docs/TASKS.md | 2 +- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift index fa2be4e..5dd15c8 100644 --- a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift +++ b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift @@ -1,3 +1,4 @@ +import Foundation import MusicAssistantCore import XCTest @@ -365,4 +366,108 @@ final class SongProjectModelTests: XCTestCase { XCTAssertNil(project.emotionalArcPoint(for: "arc-chorus")) XCTAssertNil(project.productionDirection(for: "prod-verse")) } + + func testSongProjectRoundTripsThroughJSONSerialization() throws { + let createdAt = Date(timeIntervalSince1970: 1_700_000_000) + let updatedAt = Date(timeIntervalSince1970: 1_700_000_120) + let generatedAt = Date(timeIntervalSince1970: 1_700_000_240) + let section = SongSection( + id: "verse", + type: .verse, + title: "Verse", + order: 0, + startTime: 8, + endTime: 38, + lyrics: "Draft verse", + emotion: "reflective", + energy: 4, + vocalDirection: "close vocal", + productionDirection: "subtle pad" + ) + let instrument = InstrumentTrack( + instrumentId: "oud", + selected: true, + variant: "Arabic oud", + playingStyle: "picked", + role: "lead motif", + autoArrangementEnabled: false, + placements: [ + InstrumentPlacement( + sectionId: section.id, + startTime: section.startTime, + endTime: section.endTime, + direction: "answer the vocal" + ) + ] + ) + var project = SongProject( + id: "serialized-project", + title: "Serializable Song", + idea: "A multilingual cinematic song", + duration: SongDuration(seconds: 210, description: "about three and a half minutes"), + conversationMode: .discuss, + languages: [LanguageSelection(id: "arabic", name: "Arabic")], + dialects: [DialectSelection(id: "levantine", name: "Levantine", languageID: "arabic")], + arabicPronunciationSettings: ArabicPronunciationSettings( + isEnabled: true, + diacritizationPolicy: .pronunciationTargeted, + tanweenPolicy: .preserveUserInput, + userNotes: "Keep dialect phrasing natural", + preservedSpellings: ["habibi"] + ), + genres: [GenreStyle(id: "cinematic-pop", name: "Cinematic Pop", aliases: ["orchestral pop"])], + moods: [MoodTag(id: "yearning", name: "Yearning")], + emotionalArc: [ + EmotionalArcPoint( + id: "arc-verse", + sectionID: section.id, + emotion: "reflective", + energy: 4, + direction: "slowly opening" + ) + ], + bpm: ManualAutoValue(mode: .manual, value: 92), + key: ManualAutoValue(mode: .manual, value: "D"), + scale: ManualAutoValue(mode: .auto), + maqam: ManualAutoValue(mode: .manual, value: "Hijaz"), + sections: [section], + instruments: [instrument], + vocalists: [ + Vocalist( + id: "lead", + label: "Lead", + voiceType: "tenor", + genderSelection: "male", + performanceStyle: "sung", + assignedSections: [section.id] + ) + ], + lyrics: Lyrics(text: "Full lyric draft", sectionTexts: [section.id: "Draft verse"]), + productionDirections: [ + ProductionDirection(id: "prod-verse", sectionID: section.id, text: "keep percussion sparse") + ], + sunoOutput: SunoOutput( + lyricsText: "Full lyric draft", + stylePrompt: "Cinematic pop with oud", + additionalFields: ["style": "cinematic"], + generatedAt: generatedAt + ), + createdAt: createdAt, + updatedAt: updatedAt + ) + project.versions = [ + SongVersion( + id: "version-1", + label: "Initial approved direction", + snapshot: SongProjectSnapshot(project: project), + createdAt: updatedAt + ) + ] + + let encoder = JSONEncoder() + let data = try encoder.encode(project) + let decodedProject = try JSONDecoder().decode(SongProject.self, from: data) + + XCTAssertEqual(decodedProject, project) + } } diff --git a/docs/TASKS.md b/docs/TASKS.md index 92fa2c9..0af7183 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -28,7 +28,7 @@ requirement is missing and blocks implementation, record it in - [x] Model language/dialect and Arabic pronunciation settings. - [x] Model BPM, key, scale and maqam with Manual/Auto state. - [x] Model emotional arc and production directions. -- [ ] Add serialization tests. +- [x] Add serialization tests. ## Phase 2 --- Project Persistence & Versions From 60b4f1b916e152c312392a77af4f4002fd5f77dd Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 13:56:59 +0200 Subject: [PATCH 08/11] Implement local song project store --- .../Persistence/ProjectStore.swift | 81 ++++++++++++++++++- .../LocalSongProjectStoreTests.swift | 75 +++++++++++++++++ docs/TASKS.md | 2 +- 3 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift diff --git a/Sources/MusicAssistantCore/Persistence/ProjectStore.swift b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift index 2b4715f..9434341 100644 --- a/Sources/MusicAssistantCore/Persistence/ProjectStore.swift +++ b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift @@ -1,6 +1,81 @@ -public protocol ProjectStore: Sendable { - associatedtype Project +import Foundation - func save(_ project: Project) async throws +public protocol ProjectStore: Sendable { + func create(_ project: SongProject) async throws + func save(_ project: SongProject) async throws + func open(id: String) async throws -> SongProject func loadProjectIDs() async throws -> [String] } + +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 ensureDirectoryExists() + let fileURLs = try FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil + ) + return fileURLs + .filter { $0.pathExtension == Self.fileExtension } + .map { $0.deletingPathExtension().lastPathComponent } + .sorted() + } + + 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 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) +} diff --git a/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift b/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift new file mode 100644 index 0000000..f31cf27 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift @@ -0,0 +1,75 @@ +import Foundation +import MusicAssistantCore +import XCTest + +final class LocalSongProjectStoreTests: XCTestCase { + private var temporaryDirectoryURL: URL! + + override func setUpWithError() throws { + temporaryDirectoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("music-assistant-store-tests") + .appendingPathComponent(UUID().uuidString) + } + + override func tearDownWithError() throws { + if let temporaryDirectoryURL { + try? FileManager.default.removeItem(at: temporaryDirectoryURL) + } + temporaryDirectoryURL = nil + } + + func testCreatesOpensSavesAndListsSongProjectsLocally() async throws { + let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + var project = SongProject( + id: "local-project", + title: "Local Project", + idea: "Persist this song locally", + genres: [GenreStyle(id: "pop", name: "Pop")] + ) + + try await store.create(project) + + let createdProjectIDs = try await store.loadProjectIDs() + let openedProject = try await store.open(id: project.id) + + XCTAssertEqual(createdProjectIDs, ["local-project"]) + XCTAssertEqual(openedProject, project) + + project.title = "Updated Local Project" + project.setBPMManually(100) + try await store.save(project) + + let updatedProject = try await store.open(id: project.id) + + XCTAssertEqual(updatedProject, project) + } + + func testCreateDoesNotOverwriteExistingProject() async throws { + let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + let project = SongProject( + id: "duplicate-project", + title: "Duplicate", + idea: "Do not overwrite" + ) + + try await store.create(project) + + do { + try await store.create(project) + XCTFail("Expected duplicate project creation to fail.") + } catch let error as LocalSongProjectStoreError { + XCTAssertEqual(error, .projectAlreadyExists(project.id)) + } + } + + func testOpenMissingProjectThrowsNotFound() async throws { + let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + + do { + _ = try await store.open(id: "missing") + XCTFail("Expected missing project open to fail.") + } catch let error as LocalSongProjectStoreError { + XCTAssertEqual(error, .projectNotFound("missing")) + } + } +} diff --git a/docs/TASKS.md b/docs/TASKS.md index 0af7183..903cf01 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -32,7 +32,7 @@ requirement is missing and blocks implementation, record it in ## Phase 2 --- Project Persistence & Versions -- [ ] Create new/open/save song projects locally. +- [x] Create new/open/save song projects locally. - [ ] Implement project list. - [ ] Implement immutable or snapshot-based version history. - [ ] Restore a previous version without destroying later versions. From e28d45ae184ee38744ffad71ce0bef4deaa9e848 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 14:17:38 +0200 Subject: [PATCH 09/11] Implement song project list --- .../Persistence/ProjectStore.swift | 59 ++++++++++++++++--- .../LocalSongProjectStoreTests.swift | 39 +++++++++++- docs/TASKS.md | 2 +- 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/Sources/MusicAssistantCore/Persistence/ProjectStore.swift b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift index 9434341..be8efc9 100644 --- a/Sources/MusicAssistantCore/Persistence/ProjectStore.swift +++ b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift @@ -5,6 +5,33 @@ public protocol ProjectStore: Sendable { 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 { @@ -43,15 +70,23 @@ public actor LocalSongProjectStore: ProjectStore { } public func loadProjectIDs() async throws -> [String] { + try await loadProjectList().map(\.id) + } + + public func loadProjectList() async throws -> [SongProjectListItem] { try ensureDirectoryExists() - let fileURLs = try FileManager.default.contentsOfDirectory( - at: directoryURL, - includingPropertiesForKeys: nil - ) - return fileURLs - .filter { $0.pathExtension == Self.fileExtension } - .map { $0.deletingPathExtension().lastPathComponent } - .sorted() + 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 { @@ -66,6 +101,14 @@ public actor LocalSongProjectStore: ProjectStore { 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) diff --git a/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift b/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift index f31cf27..1819403 100644 --- a/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift +++ b/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift @@ -20,11 +20,15 @@ final class LocalSongProjectStoreTests: XCTestCase { func testCreatesOpensSavesAndListsSongProjectsLocally() async throws { let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + let createdAt = Date(timeIntervalSince1970: 100) + let updatedAt = Date(timeIntervalSince1970: 200) var project = SongProject( id: "local-project", title: "Local Project", idea: "Persist this song locally", - genres: [GenreStyle(id: "pop", name: "Pop")] + genres: [GenreStyle(id: "pop", name: "Pop")], + createdAt: createdAt, + updatedAt: updatedAt ) try await store.create(project) @@ -44,6 +48,39 @@ final class LocalSongProjectStoreTests: XCTestCase { XCTAssertEqual(updatedProject, project) } + func testLoadsProjectListSortedByMostRecentUpdate() async throws { + let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + let older = SongProject( + id: "older-project", + title: "Older", + idea: "Created first", + createdAt: Date(timeIntervalSince1970: 100), + updatedAt: Date(timeIntervalSince1970: 150) + ) + let newer = SongProject( + id: "newer-project", + title: "Newer", + idea: "Updated later", + createdAt: Date(timeIntervalSince1970: 200), + updatedAt: Date(timeIntervalSince1970: 300) + ) + + try await store.create(older) + try await store.create(newer) + + let projectList = try await store.loadProjectList() + let projectIDs = try await store.loadProjectIDs() + + XCTAssertEqual( + projectList, + [ + SongProjectListItem(project: newer), + SongProjectListItem(project: older) + ] + ) + XCTAssertEqual(projectIDs, ["newer-project", "older-project"]) + } + func testCreateDoesNotOverwriteExistingProject() async throws { let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) let project = SongProject( diff --git a/docs/TASKS.md b/docs/TASKS.md index 903cf01..2e2a60d 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -33,7 +33,7 @@ requirement is missing and blocks implementation, record it in ## Phase 2 --- Project Persistence & Versions - [x] Create new/open/save song projects locally. -- [ ] Implement project list. +- [x] Implement project list. - [ ] Implement immutable or snapshot-based version history. - [ ] Restore a previous version without destroying later versions. - [ ] Add autosave strategy that does not create excessive versions. From 8bdebc5c24fdb87a28d8b757e3fabeb57d1fe94f Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 14:28:43 +0200 Subject: [PATCH 10/11] Implement snapshot version history --- .../Domain/SongProject.swift | 19 ++++++++++++ .../SongProjectModelTests.swift | 30 +++++++++++++++++++ docs/TASKS.md | 2 +- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/Sources/MusicAssistantCore/Domain/SongProject.swift b/Sources/MusicAssistantCore/Domain/SongProject.swift index fffb11e..28b1da6 100644 --- a/Sources/MusicAssistantCore/Domain/SongProject.swift +++ b/Sources/MusicAssistantCore/Domain/SongProject.swift @@ -324,6 +324,25 @@ public extension SongProject { vocalists[index].assignedSections.removeAll { $0 == sectionID } } + mutating func createVersion( + id: String = UUID().uuidString, + label: String, + createdAt: Date = Date() + ) -> SongVersion { + let version = SongVersion( + id: id, + label: label, + snapshot: SongProjectSnapshot(project: self), + createdAt: createdAt + ) + versions.append(version) + return version + } + + func version(id versionID: String) -> SongVersion? { + versions.first { $0.id == versionID } + } + mutating func appendSection(_ section: SongSection) { var orderedSection = section orderedSection.order = nextSectionOrder diff --git a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift index 5dd15c8..d9effaa 100644 --- a/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift +++ b/Tests/MusicAssistantCoreTests/SongProjectModelTests.swift @@ -89,6 +89,36 @@ final class SongProjectModelTests: XCTestCase { XCTAssertEqual(version.snapshot.instruments, project.instruments) } + func testVersionHistoryStoresIndependentSnapshots() { + var project = SongProject( + id: "versioned-project", + title: "First Title", + idea: "Original idea", + genres: [GenreStyle(id: "folk", name: "Folk")] + ) + let createdAt = Date(timeIntervalSince1970: 500) + + let version = project.createVersion( + id: "version-1", + label: "Initial snapshot", + createdAt: createdAt + ) + + project.title = "Changed Title" + project.idea = "Changed idea" + project.selectGenreStyle(GenreStyle(id: "electronic", name: "Electronic")) + + XCTAssertEqual(project.versions.count, 1) + XCTAssertEqual(project.version(id: "version-1"), version) + XCTAssertEqual(project.versions.first?.label, "Initial snapshot") + XCTAssertEqual(project.versions.first?.createdAt, createdAt) + XCTAssertEqual(project.versions.first?.snapshot.title, "First Title") + XCTAssertEqual(project.versions.first?.snapshot.idea, "Original idea") + XCTAssertEqual(project.versions.first?.snapshot.genres, [GenreStyle(id: "folk", name: "Folk")]) + XCTAssertEqual(project.title, "Changed Title") + XCTAssertEqual(project.genres.map(\.id), ["folk", "electronic"]) + } + func testGenreStylesAreModeledAsMultiSelect() { let arabicPop = GenreStyle(id: "arabic-pop", name: "Arabic Pop") let synthwave = GenreStyle(id: "synthwave", name: "Synthwave") diff --git a/docs/TASKS.md b/docs/TASKS.md index 2e2a60d..7db8dab 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -34,7 +34,7 @@ requirement is missing and blocks implementation, record it in - [x] Create new/open/save song projects locally. - [x] Implement project list. -- [ ] Implement immutable or snapshot-based version history. +- [x] Implement immutable or snapshot-based version history. - [ ] Restore a previous version without destroying later versions. - [ ] Add autosave strategy that does not create excessive versions. From dce0880576274b21533991bf2297660d6e320a69 Mon Sep 17 00:00:00 2001 From: diya Date: Sun, 13 Sep 2026 14:40:09 +0200 Subject: [PATCH 11/11] Add macOS app packaging script --- DEVELOPMENT.md | 7 ++ Makefile | 5 +- scripts/build-install-launch.zsh | 156 +++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 1 deletion(-) create mode 100755 scripts/build-install-launch.zsh diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0b2c848..544bdb3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -14,3 +14,10 @@ Current environment keys: Provider integrations are intentionally protocol-only in the repository foundation phase. + +## Packaging + +Run `make install-app` to build the release executable, package it as +`AI Music Studio.app`, install it in `/Applications`, and launch it. + +Set `APP_ICON=/path/to/icon.icns` when packaging with a custom icon. diff --git a/Makefile b/Makefile index a6e0f26..19ea76c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build dev test +.PHONY: build dev test install-app build: swift build @@ -8,3 +8,6 @@ dev: test: swift test + +install-app: + scripts/build-install-launch.zsh diff --git a/scripts/build-install-launch.zsh b/scripts/build-install-launch.zsh new file mode 100755 index 0000000..b30d0b1 --- /dev/null +++ b/scripts/build-install-launch.zsh @@ -0,0 +1,156 @@ +#!/bin/zsh +# Build, package, install, and launch the AI Music Studio macOS application. + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APP_DISPLAY_NAME="${APP_DISPLAY_NAME:-AI Music Studio}" +EXECUTABLE_NAME="${EXECUTABLE_NAME:-music-assistant}" +BUNDLE_IDENTIFIER="${BUNDLE_IDENTIFIER:-com.musicassistant.app}" +APP_BUNDLE_NAME="${APP_DISPLAY_NAME}.app" +BUILD_DIR="${PROJECT_ROOT}/.build/app" +APP_BUNDLE="${BUILD_DIR}/${APP_BUNDLE_NAME}" +INSTALL_DIR="${INSTALL_DIR:-/Applications}" +INSTALL_PATH="${INSTALL_DIR}/${APP_BUNDLE_NAME}" +CONTENTS_DIR="${APP_BUNDLE}/Contents" +MACOS_DIR="${CONTENTS_DIR}/MacOS" +RESOURCES_DIR="${CONTENTS_DIR}/Resources" + +fail() { + print -u2 -- "Error: $1" + exit 1 +} + +step() { + print -- "==> $1" +} + +find_app_icon() { + if [[ -n "${APP_ICON:-}" ]]; then + [[ -f "${APP_ICON}" ]] || fail "App icon was not found at ${APP_ICON}." + print -- "${APP_ICON}" + return + fi + + local candidate + for candidate in \ + "${PROJECT_ROOT}/Resources/AI Music Studio.icns" \ + "${PROJECT_ROOT}/Resources/AppIcon.icns" \ + "${PROJECT_ROOT}/Resources/MusicAssistant.icns"; do + if [[ -f "${candidate}" ]]; then + print -- "${candidate}" + return + fi + done +} + +[[ -f "${PROJECT_ROOT}/Package.swift" ]] || fail "Package.swift was not found in ${PROJECT_ROOT}." +command -v swift >/dev/null 2>&1 || fail "Swift is required. Install Xcode or the Xcode Command Line Tools." +command -v codesign >/dev/null 2>&1 || fail "codesign is required and should be available with Xcode." +command -v ditto >/dev/null 2>&1 || fail "ditto is required and should be available on macOS." +command -v open >/dev/null 2>&1 || fail "open is required and should be available on macOS." + +cd "${PROJECT_ROOT}" + +step "Stopping any running ${APP_DISPLAY_NAME} application" +if pgrep -x "${EXECUTABLE_NAME}" >/dev/null 2>&1; then + pkill -x "${EXECUTABLE_NAME}" || fail "Could not stop the running ${APP_DISPLAY_NAME} application." + for _ in {1..20}; do + pgrep -x "${EXECUTABLE_NAME}" >/dev/null 2>&1 || break + sleep 0.1 + done + pgrep -x "${EXECUTABLE_NAME}" >/dev/null 2>&1 && fail "${APP_DISPLAY_NAME} is still running. Quit it and retry." +fi + +step "Removing stale installed copy" +if [[ -e "${INSTALL_PATH}" ]]; then + rm -rf "${INSTALL_PATH}" || fail "Could not remove ${INSTALL_PATH}. Check permissions and retry." +fi + +step "Cleaning local Swift build artifacts" +swift package clean || fail "Swift Package Manager could not clean the local build artifacts." + +step "Building ${EXECUTABLE_NAME} in release mode" +BIN_PATH="$(swift build -c release --show-bin-path 2>/dev/null | tail -n1)" +SOURCE_EXECUTABLE="${BIN_PATH}/${EXECUTABLE_NAME}" + +built=0 +for attempt in 1 2 3; do + swift build -c release || fail "Release build failed. Review the Swift compiler diagnostics above." + if [[ -x "${SOURCE_EXECUTABLE}" ]]; then + built=1 + break + fi + print -- "==> Executable not present after build (attempt ${attempt}); retrying" + sleep 1 +done +[[ "${built}" -eq 1 ]] || fail "Built executable was not found at ${SOURCE_EXECUTABLE}." + +step "Packaging ${APP_BUNDLE_NAME}" +rm -rf "${APP_BUNDLE}" +mkdir -p "${MACOS_DIR}" "${RESOURCES_DIR}" +cp "${SOURCE_EXECUTABLE}" "${MACOS_DIR}/${EXECUTABLE_NAME}" + +ICON_PATH="$(find_app_icon)" +PLIST_ICON_ENTRY="" +if [[ -n "${ICON_PATH}" ]]; then + ICON_FILE_NAME="$(basename "${ICON_PATH}")" + ICON_FILE_BASE="${ICON_FILE_NAME:r}" + cp "${ICON_PATH}" "${RESOURCES_DIR}/${ICON_FILE_NAME}" + PLIST_ICON_ENTRY=$' CFBundleIconFile\n '"${ICON_FILE_BASE}"$'' +else + step "No app icon found; packaging without a custom icon" +fi + +cat > "${CONTENTS_DIR}/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${APP_DISPLAY_NAME} + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIdentifier + ${BUNDLE_IDENTIFIER} + CFBundleInfoDictionaryVersion + 6.0 +${PLIST_ICON_ENTRY} + CFBundleName + ${APP_DISPLAY_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +PLIST + +plutil -lint "${CONTENTS_DIR}/Info.plist" >/dev/null || fail "Generated Info.plist is invalid." +codesign --force --sign - --timestamp=none "${APP_BUNDLE}" || fail "Ad-hoc signing of ${APP_BUNDLE_NAME} failed." +codesign --verify --deep --strict "${APP_BUNDLE}" || fail "Packaged ${APP_BUNDLE_NAME} failed code-signature verification." +[[ -x "${MACOS_DIR}/${EXECUTABLE_NAME}" ]] || fail "Packaged executable is missing." + +step "Installing ${APP_BUNDLE_NAME} in ${INSTALL_DIR}" +ditto "${APP_BUNDLE}" "${INSTALL_PATH}" || fail "Could not install ${APP_BUNDLE_NAME} in ${INSTALL_DIR}. Check permissions and retry." + +INSTALLED_EXECUTABLE="${INSTALL_PATH}/Contents/MacOS/${EXECUTABLE_NAME}" +[[ -x "${INSTALLED_EXECUTABLE}" ]] || fail "Installed executable is missing at ${INSTALLED_EXECUTABLE}." +SOURCE_HASH="$(shasum -a 256 "${MACOS_DIR}/${EXECUTABLE_NAME}" | awk '{print $1}')" +INSTALLED_HASH="$(shasum -a 256 "${INSTALLED_EXECUTABLE}" | awk '{print $1}')" +[[ "${SOURCE_HASH}" == "${INSTALLED_HASH}" ]] || fail "Installed executable does not match the newly built executable." + +step "Launching ${APP_DISPLAY_NAME}" +open -n "${INSTALL_PATH}" || fail "${APP_DISPLAY_NAME} was installed but could not be opened." + +print -- "==> ${APP_DISPLAY_NAME} was rebuilt, installed, and launched successfully."