From 1b523a924d52c323a65e5e9bd56e9982f7cea4eb Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 13 Sep 2026 13:19:33 +0200 Subject: [PATCH] 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.