From fd87beb419f1891bc0dd39deda2ec82f7e5ab9da Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Sat, 11 Jul 2026 09:42:21 +0300 Subject: [PATCH 01/12] feat: add note entry --- CHANGELOG.md | 2 + JammLab.xcodeproj/project.pbxproj | 8 + JammLab/ContentView.swift | 21 +- JammLab/Models/JammLabProject.swift | 2 +- JammLab/Models/NotationScoreModels.swift | 169 ++++++ JammLab/Models/ProjectStateNormalizer.swift | 2 + JammLab/Services/NotationExportService.swift | 27 +- JammLab/Services/NotationNoteAuditioner.swift | 149 +++++ .../NotationNotePlacementResolver.swift | 346 +++++++++++ JammLab/Services/NotationSMuFL.swift | 105 +++- .../Services/NotationViewportFactory.swift | 1 + JammLab/Utilities/AppHotkey.swift | 27 + .../AudioPlayerViewModel+Notation.swift | 297 +++++++++- .../AudioPlayerViewModel+Project.swift | 1 + JammLab/ViewModels/AudioPlayerViewModel.swift | 4 + JammLab/Views/MainWorkspacePanels.swift | 8 +- JammLab/Views/NotationTrackView.swift | 313 +++++++++- JammLab/Views/NotationWindowView.swift | 44 +- JammLab/Views/WaveformTimelineView.swift | 27 +- .../AppHotkeyNotationEventFilterTests.swift | 69 +++ .../AppHotkeyNotationMappingTests.swift | 67 +++ .../NotationMusicXMLRestExportTests.swift | 42 ++ JammLabTests/NotationPrimitivesTests.swift | 490 ++++++++++++++++ .../ProjectStateNormalizerTests.swift | 28 + .../StemWorkflowPersistenceTests.swift | 46 +- JammLabTests/TestSupport.swift | 15 + .../ViewModelNotationClipboardTests.swift | 52 ++ ...wModelNotationDurationSelectionTests.swift | 554 ++++++++++++++++++ .../ViewModelNotationPasteTests.swift | 80 +++ .../ViewModelNotationSelectionTests.swift | 89 ++- 30 files changed, 3045 insertions(+), 40 deletions(-) create mode 100644 JammLab/Services/NotationNoteAuditioner.swift create mode 100644 JammLab/Services/NotationNotePlacementResolver.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 764e480..d7d4e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased +- Added Notation note-entry mode for placing pitched notes with Leland-rendered note glyphs, note selection preview, cross-rest insertion, rest recomposition, and MusicXML export. +- Added Notation note editing for dragging note pitch, arrow-key pitch changes, and deleting selected notes back to rests. - Added clearer hover tooltips and numpad shortcuts for Notation duration buttons. - Added a bar/beat position readout to the transport time display. diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index f64d292..f387103 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -51,6 +51,8 @@ 9FEA01032D70000100112233 /* NotationViewportFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00032D70000100112233 /* NotationViewportFactory.swift */; }; 9FEA01042D70000100112233 /* NotationTrackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00042D70000100112233 /* NotationTrackView.swift */; }; 9FCB01062D80000100112233 /* NotationMeasureLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB00062D80000100112233 /* NotationMeasureLayout.swift */; }; + 9FAE01012F10000100112233 /* NotationNotePlacementResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012F10000100112233 /* NotationNotePlacementResolver.swift */; }; + 9FAE01022F10000100112233 /* NotationNoteAuditioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022F10000100112233 /* NotationNoteAuditioner.swift */; }; 9FEA01052D70000100112233 /* NotationVisibleMeasureFitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */; }; 9FEA01062D70000100112233 /* ProjectKeySelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00062D70000100112233 /* ProjectKeySelection.swift */; }; 9FEE01012D90000100112233 /* NotationExportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE00012D90000100112233 /* NotationExportService.swift */; }; @@ -315,6 +317,8 @@ 9FEA00032D70000100112233 /* NotationViewportFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationViewportFactory.swift; sourceTree = ""; }; 9FEA00042D70000100112233 /* NotationTrackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationTrackView.swift; sourceTree = ""; }; 9FCB00062D80000100112233 /* NotationMeasureLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureLayout.swift; sourceTree = ""; }; + 9FAE00012F10000100112233 /* NotationNotePlacementResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationNotePlacementResolver.swift; sourceTree = ""; }; + 9FAE00022F10000100112233 /* NotationNoteAuditioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationNoteAuditioner.swift; sourceTree = ""; }; 9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationVisibleMeasureFitter.swift; sourceTree = ""; }; 9FEA00062D70000100112233 /* ProjectKeySelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectKeySelection.swift; sourceTree = ""; }; 9FEE00012D90000100112233 /* NotationExportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationExportService.swift; sourceTree = ""; }; @@ -597,6 +601,8 @@ 9F8A00182C00000100112233 /* MetronomeClickScheduler.swift */, 9FCB00062D80000100112233 /* NotationMeasureLayout.swift */, 9FEE00012D90000100112233 /* NotationExportService.swift */, + 9FAE00022F10000100112233 /* NotationNoteAuditioner.swift */, + 9FAE00012F10000100112233 /* NotationNotePlacementResolver.swift */, 9F2700012EA0000100112233 /* NotationSMuFL.swift */, 9FEA00032D70000100112233 /* NotationViewportFactory.swift */, 9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */, @@ -1113,6 +1119,8 @@ 9F8A010D2C00000100112233 /* AppHotkey.swift in Sources */, 9FCB01062D80000100112233 /* NotationMeasureLayout.swift in Sources */, 9FEE01012D90000100112233 /* NotationExportService.swift in Sources */, + 9FAE01022F10000100112233 /* NotationNoteAuditioner.swift in Sources */, + 9FAE01012F10000100112233 /* NotationNotePlacementResolver.swift in Sources */, 9FEA01032D70000100112233 /* NotationViewportFactory.swift in Sources */, 9FEA01052D70000100112233 /* NotationVisibleMeasureFitter.swift in Sources */, 9FEA01022D70000100112233 /* NotationViewportState.swift in Sources */, diff --git a/JammLab/ContentView.swift b/JammLab/ContentView.swift index 439f7c3..a6e48ed 100644 --- a/JammLab/ContentView.swift +++ b/JammLab/ContentView.swift @@ -181,7 +181,7 @@ struct ContentView: View { if !viewModel.canPasteNotationMeasureClipboard { hotkeys.remove(.pasteMeasure) } - if !viewModel.hasSelectedNotationMeasures { + if !viewModel.hasSelectedNotationMeasures && !viewModel.isNotationNoteEntryModeEnabled { hotkeys.remove(.clearNotationMeasureSelection) } if !viewModel.canEditSelectedNotationItem { @@ -190,6 +190,12 @@ struct ContentView: View { if !viewModel.canChangeNotationDuration { hotkeys.subtract(AppHotkey.notationDurationHotkeys) } + if !viewModel.canChangeSelectedNotationNotePitch(byStaffPositionDelta: -1) { + hotkeys.remove(.moveSelectedNotationNotePitchUp) + } + if !viewModel.canChangeSelectedNotationNotePitch(byStaffPositionDelta: 1) { + hotkeys.remove(.moveSelectedNotationNotePitchDown) + } return hotkeys } @@ -237,10 +243,21 @@ struct ContentView: View { case .pasteMeasure: return viewModel.pasteNotationMeasureClipboard() case .clearNotationMeasureSelection: - viewModel.clearNotationMeasureSelection() + if viewModel.isNotationNoteEntryModeEnabled { + viewModel.clearNotationNoteEntryMode() + } else { + viewModel.clearNotationMeasureSelection() + } return true case .editHarmonyAtSelectedNotationItem: return viewModel.requestEditSelectedNotationItem() + case .toggleNotationNoteEntryMode: + viewModel.toggleNotationNoteEntryMode() + return true + case .moveSelectedNotationNotePitchUp: + return viewModel.changeSelectedNotationNotePitch(byStaffPositionDelta: -1) + case .moveSelectedNotationNotePitchDown: + return viewModel.changeSelectedNotationNotePitch(byStaffPositionDelta: 1) case .setNotationDurationEighth, .setNotationDurationQuarter, .setNotationDurationHalf, diff --git a/JammLab/Models/JammLabProject.swift b/JammLab/Models/JammLabProject.swift index e5c9ed4..4447827 100644 --- a/JammLab/Models/JammLabProject.swift +++ b/JammLab/Models/JammLabProject.swift @@ -44,7 +44,7 @@ struct JammLabProject: Codable { var isNotationTrackCollapsed: Bool? init( - formatVersion: Int = 10, + formatVersion: Int = 11, audioBookmarkData: Data, artifactRootBookmarkData: Data? = nil, audioDisplayName: String, diff --git a/JammLab/Models/NotationScoreModels.swift b/JammLab/Models/NotationScoreModels.swift index eec2f91..0dcdbbb 100644 --- a/JammLab/Models/NotationScoreModels.swift +++ b/JammLab/Models/NotationScoreModels.swift @@ -131,6 +131,8 @@ struct NotationMeasureClipboardItem: Equatable { } struct NotationMeasureClipboardNotationItem: Equatable { + var kind: NotationMeasureItem.Kind = .rest + var pitch: NotationPitch? = nil var offsetInQuarterNotes: Double var durationInQuarterNotes: Double var displayDuration: NotationDuration @@ -254,10 +256,12 @@ struct NotationDuration: Codable, Equatable, Identifiable { struct NotationMeasureItem: Identifiable, Codable, Equatable { enum Kind: String, Codable, Equatable { case rest + case note } var id: String var kind: Kind + var pitch: NotationPitch? var measureNumber: Int var measureStartTime: TimeInterval var offsetInQuarterNotes: Double @@ -268,6 +272,7 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { init( id: String = UUID().uuidString, kind: Kind = .rest, + pitch: NotationPitch? = nil, measureNumber: Int, measureStartTime: TimeInterval, offsetInQuarterNotes: Double, @@ -277,6 +282,7 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { ) { self.id = id self.kind = kind + self.pitch = kind == .note ? pitch : nil self.measureNumber = measureNumber self.measureStartTime = measureStartTime self.offsetInQuarterNotes = offsetInQuarterNotes @@ -284,6 +290,154 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { self.displayDuration = displayDuration self.isSynthesized = isSynthesized } + + var isNote: Bool { + kind == .note + } + + var isRest: Bool { + kind == .rest + } + + func persistedCopy() -> NotationMeasureItem { + NotationMeasureItem( + id: isSynthesized ? UUID().uuidString : id, + kind: kind, + pitch: pitch, + measureNumber: measureNumber, + measureStartTime: measureStartTime, + offsetInQuarterNotes: offsetInQuarterNotes, + durationInQuarterNotes: durationInQuarterNotes, + displayDuration: displayDuration + ) + } + + private enum CodingKeys: String, CodingKey { + case id + case kind + case pitch + case measureNumber + case measureStartTime + case offsetInQuarterNotes + case durationInQuarterNotes + case displayDuration + case isSynthesized + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString + kind = try container.decodeIfPresent(Kind.self, forKey: .kind) ?? .rest + let decodedPitch = try container.decodeIfPresent(NotationPitch.self, forKey: .pitch) + pitch = kind == .note ? decodedPitch : nil + measureNumber = try container.decode(Int.self, forKey: .measureNumber) + measureStartTime = try container.decode(TimeInterval.self, forKey: .measureStartTime) + offsetInQuarterNotes = try container.decode(Double.self, forKey: .offsetInQuarterNotes) + durationInQuarterNotes = try container.decode(Double.self, forKey: .durationInQuarterNotes) + displayDuration = try container.decode(NotationDuration.self, forKey: .displayDuration) + isSynthesized = try container.decodeIfPresent(Bool.self, forKey: .isSynthesized) ?? false + } +} + +enum NotationPitchStep: String, Codable, CaseIterable, Equatable { + case c = "C" + case d = "D" + case e = "E" + case f = "F" + case g = "G" + case a = "A" + case b = "B" +} + +struct NotationPitch: Codable, Equatable { + var step: NotationPitchStep + var octave: Int + var alter: Int + + init(step: NotationPitchStep, octave: Int, alter: Int = 0) { + self.step = step + self.octave = octave + self.alter = min(1, max(-1, alter)) + } + + var midiNoteNumber: Int { + let semitone: Int + switch step { + case .c: + semitone = 0 + case .d: + semitone = 2 + case .e: + semitone = 4 + case .f: + semitone = 5 + case .g: + semitone = 7 + case .a: + semitone = 9 + case .b: + semitone = 11 + } + + return min(127, max(0, (octave + 1) * 12 + semitone + alter)) + } +} + +enum NotationPitchMapper { + private static let staffPitches: [NotationPitch] = [ + NotationPitch(step: .f, octave: 5), + NotationPitch(step: .e, octave: 5), + NotationPitch(step: .d, octave: 5), + NotationPitch(step: .c, octave: 5), + NotationPitch(step: .b, octave: 4), + NotationPitch(step: .a, octave: 4), + NotationPitch(step: .g, octave: 4), + NotationPitch(step: .f, octave: 4), + NotationPitch(step: .e, octave: 4) + ] + + static var minimumStaffPosition: Int { 0 } + static var maximumStaffPosition: Int { staffPitches.count - 1 } + + static func pitch( + forStaffPosition staffPosition: Int, + keySignature: KeySignature + ) -> NotationPitch { + let index = min(maximumStaffPosition, max(minimumStaffPosition, staffPosition)) + var pitch = staffPitches[index] + pitch.alter = keySignature.defaultAlter(for: pitch.step) + return pitch + } + + static func staffPosition(for pitch: NotationPitch) -> Int? { + staffPitches.firstIndex { + $0.step == pitch.step && $0.octave == pitch.octave + } + } + + static func adjacentPitch( + from pitch: NotationPitch, + staffPositionDelta: Int, + keySignature: KeySignature + ) -> NotationPitch? { + guard staffPositionDelta != 0, + let currentPosition = staffPosition(for: pitch) + else { + return nil + } + + let targetPosition = currentPosition + staffPositionDelta + guard targetPosition >= minimumStaffPosition, + targetPosition <= maximumStaffPosition + else { + return nil + } + + return self.pitch( + forStaffPosition: targetPosition, + keySignature: keySignature + ) + } } enum NotationRestItemFactory { @@ -441,6 +595,18 @@ struct KeySignature: Equatable { } } + func defaultAlter(for step: NotationPitchStep) -> Int { + if fifths > 0 { + return Self.sharpSteps.prefix(accidentalCount).contains(step) ? 1 : 0 + } + + if fifths < 0 { + return Self.flatSteps.prefix(accidentalCount).contains(step) ? -1 : 0 + } + + return 0 + } + static func normalized(from keyName: String?) -> KeySignature { guard let keyName else { return .cMajor } @@ -524,6 +690,9 @@ struct KeySignature: Equatable { "E": 1, "B": 2, "F#": 3, "C#": 4, "G#": 5, "D#": 6, "A#": 7 ] + private static let sharpSteps: [NotationPitchStep] = [.f, .c, .g, .d, .a, .e, .b] + private static let flatSteps: [NotationPitchStep] = [.b, .e, .a, .d, .g, .c, .f] + private static let trebleSharpAccidentals: [KeySignatureAccidental] = [ KeySignatureAccidental(symbol: "♯", staffPositionFromTopLine: 0), KeySignatureAccidental(symbol: "♯", staffPositionFromTopLine: 3), diff --git a/JammLab/Models/ProjectStateNormalizer.swift b/JammLab/Models/ProjectStateNormalizer.swift index 518cada..321ce78 100644 --- a/JammLab/Models/ProjectStateNormalizer.swift +++ b/JammLab/Models/ProjectStateNormalizer.swift @@ -126,6 +126,7 @@ struct ProjectStateNormalizer { return items .filter { item in !item.isSynthesized + && (item.kind == .rest || item.pitch != nil) && item.measureStartTime.isFinite && item.measureStartTime >= 0 && item.measureStartTime <= duration @@ -137,6 +138,7 @@ struct ProjectStateNormalizer { NotationMeasureItem( id: item.id, kind: item.kind, + pitch: item.kind == .note ? item.pitch : nil, measureNumber: max(1, item.measureNumber), measureStartTime: min(max(0, finiteTime(item.measureStartTime)), duration), offsetInQuarterNotes: max(0, finiteTime(item.offsetInQuarterNotes)), diff --git a/JammLab/Services/NotationExportService.swift b/JammLab/Services/NotationExportService.swift index a301305..84de7f2 100644 --- a/JammLab/Services/NotationExportService.swift +++ b/JammLab/Services/NotationExportService.swift @@ -214,7 +214,7 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { includeBoundaryHarmony: false, harmonyIndex: &harmonyIndex ) - measureElement.addChild(restNote(for: item, isOnlyItem: sortedItems.count == 1)) + measureElement.addChild(notationNote(for: item, isOnlyItem: sortedItems.count == 1)) notationCursorOffsetInQuarterNotes = restEndOffsetInQuarterNotes } @@ -383,6 +383,15 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { return pitchElement } + private func notationNote(for item: NotationMeasureItem, isOnlyItem: Bool) -> XMLElement { + switch item.kind { + case .rest: + return restNote(for: item, isOnlyItem: isOnlyItem) + case .note: + return pitchNote(for: item) + } + } + private func restNote(for item: NotationMeasureItem, isOnlyItem: Bool) -> XMLElement { let note = element("note") let isMeasureRest = isOnlyItem && item.isSynthesized && item.offsetInQuarterNotes == 0 @@ -393,6 +402,22 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { return note } + private func pitchNote(for item: NotationMeasureItem) -> XMLElement { + let note = element("note") + let pitch = item.pitch ?? NotationPitch(step: .c, octave: 4) + let pitchElement = element("pitch") + pitchElement.addChild(element("step", stringValue: pitch.step.rawValue)) + if pitch.alter != 0 { + pitchElement.addChild(element("alter", stringValue: "\(pitch.alter)")) + } + pitchElement.addChild(element("octave", stringValue: "\(pitch.octave)")) + note.addChild(pitchElement) + note.addChild(element("duration", stringValue: "\(durationValue(forQuarterOffset: item.durationInQuarterNotes))")) + note.addChild(element("voice", stringValue: "1")) + note.addChild(element("type", stringValue: item.displayDuration.displayName)) + return note + } + private func durationValue(forQuarterOffset offset: Double) -> Int { max(0, Int((offset * Double(divisions)).rounded())) } diff --git a/JammLab/Services/NotationNoteAuditioner.swift b/JammLab/Services/NotationNoteAuditioner.swift new file mode 100644 index 0000000..b9bf523 --- /dev/null +++ b/JammLab/Services/NotationNoteAuditioner.swift @@ -0,0 +1,149 @@ +@preconcurrency import AVFoundation +import AudioToolbox +import Foundation + +@MainActor +protocol NotationNoteAuditioning: AnyObject { + func audition(pitch: NotationPitch) throws +} + +@MainActor +final class NoopNotationNoteAuditioner: NotationNoteAuditioning { + func audition(pitch: NotationPitch) throws {} +} + +enum NotationNoteAuditionerError: LocalizedError { + case soundBankUnavailable + case initializationFailed + + var errorDescription: String? { + switch self { + case .soundBankUnavailable: + return "The system General MIDI sound bank is unavailable." + case .initializationFailed: + return "Notation note preview could not be initialized." + } + } +} + +private enum NotationNoteAuditionDefaults { + static let previewVelocity: UInt8 = 88 + static let previewDurationNanoseconds: UInt64 = 450_000_000 + static let midiChannel: UInt8 = 0 +} + +@MainActor +final class SamplerNotationNoteAuditioner: NotationNoteAuditioning { + private var engine: AVAudioEngine? + private var sampler: AVAudioUnitSampler? + private var isPrepared = false + private var didFailPreparation = false + private var currentPreviewNote: UInt8? + private var noteOffTask: Task? + + deinit { + noteOffTask?.cancel() + if let currentPreviewNote, let sampler { + sampler.stopNote(currentPreviewNote, onChannel: NotationNoteAuditionDefaults.midiChannel) + } + engine?.stop() + } + + func audition(pitch: NotationPitch) throws { + guard !didFailPreparation else { + throw NotationNoteAuditionerError.initializationFailed + } + + do { + try prepareIfNeeded() + try startEngineIfNeeded() + } catch { + didFailPreparation = true + stopEngine() + throw error + } + + guard let sampler else { + didFailPreparation = true + throw NotationNoteAuditionerError.initializationFailed + } + + let midiNote = UInt8(pitch.midiNoteNumber) + stopCurrentPreviewNote() + sampler.startNote( + midiNote, + withVelocity: NotationNoteAuditionDefaults.previewVelocity, + onChannel: NotationNoteAuditionDefaults.midiChannel + ) + currentPreviewNote = midiNote + noteOffTask = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: NotationNoteAuditionDefaults.previewDurationNanoseconds) + } catch { + return + } + await MainActor.run { + self?.stopPreviewNote(midiNote) + } + } + } + + private func prepareIfNeeded() throws { + guard !isPrepared else { return } + guard let soundBankURL = Self.defaultSoundBankURL() else { + throw NotationNoteAuditionerError.soundBankUnavailable + } + + let engine = AVAudioEngine() + let sampler = AVAudioUnitSampler() + engine.attach(sampler) + engine.connect(sampler, to: engine.mainMixerNode, format: nil) + try sampler.loadSoundBankInstrument( + at: soundBankURL, + program: 0, + bankMSB: UInt8(kAUSampler_DefaultMelodicBankMSB), + bankLSB: UInt8(kAUSampler_DefaultBankLSB) + ) + + self.engine = engine + self.sampler = sampler + isPrepared = true + } + + private func startEngineIfNeeded() throws { + guard let engine, !engine.isRunning else { return } + try engine.start() + } + + private func stopCurrentPreviewNote() { + noteOffTask?.cancel() + noteOffTask = nil + if let currentPreviewNote, let sampler { + sampler.stopNote(currentPreviewNote, onChannel: NotationNoteAuditionDefaults.midiChannel) + } + currentPreviewNote = nil + } + + private func stopPreviewNote(_ note: UInt8) { + guard currentPreviewNote == note else { return } + sampler?.stopNote(note, onChannel: NotationNoteAuditionDefaults.midiChannel) + currentPreviewNote = nil + noteOffTask = nil + } + + private func stopEngine() { + noteOffTask?.cancel() + noteOffTask = nil + currentPreviewNote = nil + sampler = nil + engine?.stop() + engine = nil + isPrepared = false + } + + private static func defaultSoundBankURL() -> URL? { + let url = URL(fileURLWithPath: "/System/Library/Components/CoreAudio.component/Contents/Resources/gs_instruments.dls") + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return url + } +} diff --git a/JammLab/Services/NotationNotePlacementResolver.swift b/JammLab/Services/NotationNotePlacementResolver.swift new file mode 100644 index 0000000..902ddc7 --- /dev/null +++ b/JammLab/Services/NotationNotePlacementResolver.swift @@ -0,0 +1,346 @@ +import CoreGraphics +import Foundation + +struct NotationNotePlacement: Equatable { + var measure: ScoreMeasure + var targetRestID: String + var offsetInQuarterNotes: Double + var durationInQuarterNotes: Double + var displayDuration: NotationDuration + var pitch: NotationPitch + + var x: CGFloat + var y: CGFloat +} + +struct NotationRestSpan: Equatable { + var rests: [NotationMeasureItem] + var startOffsetInQuarterNotes: Double + var endOffsetInQuarterNotes: Double + + var availableDurationInQuarterNotes: Double { + max(0, endOffsetInQuarterNotes - startOffsetInQuarterNotes) + } + + func contains(_ item: NotationMeasureItem) -> Bool { + rests.contains { + $0.id == item.id + && abs($0.offsetInQuarterNotes - item.offsetInQuarterNotes) < NotationMeasureTiming.timelineTolerance + } + } +} + +enum NotationNotePlacementResolver { + static func placement( + in measure: ScoreMeasure, + geometry: NotationMeasureCanvasGeometry, + point: CGPoint, + staffTop: CGFloat, + selectedDuration: NotationDuration, + lineSpacing: CGFloat = AppTheme.Timeline.notationStaffLineSpacing + ) -> NotationNotePlacement? { + guard lineSpacing > 0, + let pitchPosition = staffPosition(forY: point.y, staffTop: staffTop, lineSpacing: lineSpacing) + else { + return nil + } + + let selectedLength = selectedDuration.durationInQuarterNotes + guard selectedLength > NotationMeasureTiming.timelineTolerance else { return nil } + + let targetRest: NotationMeasureItem + if let fullMeasureWholeRest = singleFullMeasureWholeRest(in: measure), + restSpan( + in: measure, + from: fullMeasureWholeRest, + requiredDurationInQuarterNotes: selectedLength + ) != nil { + targetRest = fullMeasureWholeRest + } else { + let rawProgress = NotationMeasureLayout.notationAnchorProgress( + atX: point.x, + geometry: geometry + ) + let rawOffset = rawProgress * NotationMeasureTiming.quarterLength(for: measure.attributes.timeSignature) + let eligibleRests = measure.notationItems + .filter { + $0.kind == .rest + && restSpan( + in: measure, + from: $0, + requiredDurationInQuarterNotes: selectedLength + ) != nil + } + guard !eligibleRests.isEmpty else { return nil } + + if let containingRest = eligibleRests.first(where: { + restContains(rawOffset, in: $0) + }) { + targetRest = containingRest + } else { + targetRest = eligibleRests.min { + let lhsDistance = abs($0.offsetInQuarterNotes - rawOffset) + let rhsDistance = abs($1.offsetInQuarterNotes - rawOffset) + if abs(lhsDistance - rhsDistance) > NotationMeasureTiming.timelineTolerance { + return lhsDistance < rhsDistance + } + + return $0.offsetInQuarterNotes < $1.offsetInQuarterNotes + } ?? eligibleRests[0] + } + } + + let pitch = NotationPitchMapper.pitch( + forStaffPosition: pitchPosition, + keySignature: measure.attributes.keySignature + ) + let x = NotationMeasureLayout.harmonyX( + geometry: geometry, + offsetInQuarterNotes: targetRest.offsetInQuarterNotes, + timeSignature: measure.attributes.timeSignature + ) + let y = yPosition(forStaffPosition: pitchPosition, staffTop: staffTop, lineSpacing: lineSpacing) + + return NotationNotePlacement( + measure: measure, + targetRestID: targetRest.id, + offsetInQuarterNotes: targetRest.offsetInQuarterNotes, + durationInQuarterNotes: selectedLength, + displayDuration: selectedDuration, + pitch: pitch, + x: x, + y: y + ) + } + + static func singleFullMeasureWholeRest(in measure: ScoreMeasure) -> NotationMeasureItem? { + guard measure.notationItems.count == 1, + let item = measure.notationItems.first, + item.kind == .rest, + item.displayDuration.denominator == NotationDuration.defaultDenominator, + abs(item.offsetInQuarterNotes) <= NotationMeasureTiming.timelineTolerance + else { + return nil + } + + let measureLength = NotationMeasureTiming.quarterLength(for: measure.attributes.timeSignature) + guard abs(item.durationInQuarterNotes - measureLength) <= NotationMeasureTiming.timelineTolerance else { + return nil + } + return item + } + + static func restSpan( + in measure: ScoreMeasure, + from targetRest: NotationMeasureItem, + requiredDurationInQuarterNotes requiredDuration: Double + ) -> NotationRestSpan? { + guard targetRest.kind == .rest, + requiredDuration > NotationMeasureTiming.timelineTolerance + else { + return nil + } + + let orderedItems = measure.notationItems.sorted(by: notationItemSort) + guard let targetIndex = orderedItems.firstIndex(where: { + $0.kind == .rest + && $0.id == targetRest.id + && abs($0.offsetInQuarterNotes - targetRest.offsetInQuarterNotes) < NotationMeasureTiming.timelineTolerance + }) ?? orderedItems.firstIndex(where: { + $0.kind == .rest + && abs($0.offsetInQuarterNotes - targetRest.offsetInQuarterNotes) < NotationMeasureTiming.timelineTolerance + }) else { + return nil + } + + var rests: [NotationMeasureItem] = [] + let startOffset = orderedItems[targetIndex].offsetInQuarterNotes + let requiredEndOffset = startOffset + requiredDuration + var cursor = startOffset + + for item in orderedItems[targetIndex...] { + guard item.kind == .rest else { return nil } + guard abs(item.offsetInQuarterNotes - cursor) <= NotationMeasureTiming.timelineTolerance else { + return nil + } + + rests.append(item) + cursor = item.offsetInQuarterNotes + item.durationInQuarterNotes + if cursor >= requiredEndOffset - NotationMeasureTiming.timelineTolerance { + return NotationRestSpan( + rests: rests, + startOffsetInQuarterNotes: startOffset, + endOffsetInQuarterNotes: cursor + ) + } + } + + return nil + } + + static func restSpan( + in measure: ScoreMeasure, + matching placement: NotationNotePlacement + ) -> NotationRestSpan? { + guard let targetRest = measure.notationItems.first(where: { + $0.kind == .rest + && $0.id == placement.targetRestID + && abs($0.offsetInQuarterNotes - placement.offsetInQuarterNotes) < NotationMeasureTiming.timelineTolerance + }) ?? measure.notationItems.first(where: { + $0.kind == .rest + && abs($0.offsetInQuarterNotes - placement.offsetInQuarterNotes) < NotationMeasureTiming.timelineTolerance + }) else { + return nil + } + + return restSpan( + in: measure, + from: targetRest, + requiredDurationInQuarterNotes: placement.durationInQuarterNotes + ) + } + + private static func restContains( + _ offset: Double, + in rest: NotationMeasureItem + ) -> Bool { + let start = rest.offsetInQuarterNotes + let end = rest.offsetInQuarterNotes + rest.durationInQuarterNotes + return offset >= start - NotationMeasureTiming.timelineTolerance + && offset < end - NotationMeasureTiming.timelineTolerance + } + + private static func notationItemSort(_ lhs: NotationMeasureItem, _ rhs: NotationMeasureItem) -> Bool { + if abs(lhs.offsetInQuarterNotes - rhs.offsetInQuarterNotes) > NotationMeasureTiming.timelineTolerance { + return lhs.offsetInQuarterNotes < rhs.offsetInQuarterNotes + } + + return lhs.id < rhs.id + } + + static func staffPosition( + forY y: CGFloat, + staffTop: CGFloat, + lineSpacing: CGFloat = AppTheme.Timeline.notationStaffLineSpacing + ) -> Int? { + guard lineSpacing > 0 else { return nil } + let halfSpacing = lineSpacing / 2 + let minimumY = staffTop - halfSpacing + let maximumY = staffTop + lineSpacing * 4 + halfSpacing + guard y >= minimumY, y <= maximumY else { return nil } + + let rawPosition = ((y - staffTop) / halfSpacing).rounded() + return min( + NotationPitchMapper.maximumStaffPosition, + max(NotationPitchMapper.minimumStaffPosition, Int(rawPosition)) + ) + } + + static func clampedStaffPosition( + forY y: CGFloat, + staffTop: CGFloat, + lineSpacing: CGFloat = AppTheme.Timeline.notationStaffLineSpacing + ) -> Int? { + guard lineSpacing > 0 else { return nil } + let halfSpacing = lineSpacing / 2 + let rawPosition = ((y - staffTop) / halfSpacing).rounded() + return min( + NotationPitchMapper.maximumStaffPosition, + max(NotationPitchMapper.minimumStaffPosition, Int(rawPosition)) + ) + } + + static func yPosition( + forStaffPosition staffPosition: Int, + staffTop: CGFloat, + lineSpacing: CGFloat = AppTheme.Timeline.notationStaffLineSpacing + ) -> CGFloat { + staffTop + CGFloat(staffPosition) * lineSpacing / 2 + } +} + +enum NotationNoteEntryRecomposer { + static func recomposedItems( + in measure: ScoreMeasure, + replacing restSpan: NotationRestSpan, + with noteItem: NotationMeasureItem + ) -> [NotationMeasureItem] { + let orderedItems = measure.notationItems.sorted(by: notationItemSort) + + var output: [NotationMeasureItem] = [] + var didInsertNote = false + for item in orderedItems { + if restSpan.contains(item) { + if !didInsertNote { + output.append(noteItem) + let noteEnd = noteItem.offsetInQuarterNotes + noteItem.durationInQuarterNotes + output.append(contentsOf: metricAwareFillerNotationItems( + measure: measure, + startOffset: noteEnd, + remaining: restSpan.endOffsetInQuarterNotes - noteEnd + )) + didInsertNote = true + } + continue + } + + output.append(item.persistedCopy()) + } + + return output + } + + private static func metricAwareFillerNotationItems( + measure: ScoreMeasure, + startOffset: Double, + remaining: Double + ) -> [NotationMeasureItem] { + guard remaining > NotationMeasureTiming.timelineTolerance else { return [] } + + var output: [NotationMeasureItem] = [] + var cursor = startOffset + var rest = remaining + let nextQuarterBoundary = floor(cursor + NotationMeasureTiming.timelineTolerance) + 1 + let distanceToQuarterBoundary = nextQuarterBoundary - cursor + let isOnQuarterBoundary = abs(cursor.rounded() - cursor) <= NotationMeasureTiming.timelineTolerance + + if !isOnQuarterBoundary, + distanceToQuarterBoundary > NotationMeasureTiming.timelineTolerance, + distanceToQuarterBoundary < rest - NotationMeasureTiming.timelineTolerance, + let duration = exactNotationDuration(for: distanceToQuarterBoundary) { + output.append(NotationRestItemFactory.restItem( + measureNumber: measure.number, + measureStartTime: measure.startTime, + offsetInQuarterNotes: cursor, + durationInQuarterNotes: distanceToQuarterBoundary, + displayDuration: duration + )) + cursor += distanceToQuarterBoundary + rest -= distanceToQuarterBoundary + } + + output.append(contentsOf: NotationRestItemFactory.restItems( + measureNumber: measure.number, + measureStartTime: measure.startTime, + startOffset: cursor, + remaining: rest + )) + return output + } + + private static func exactNotationDuration(for length: Double) -> NotationDuration? { + NotationDuration.allowedDenominators + .map(NotationDuration.init(denominator:)) + .first { + abs($0.durationInQuarterNotes - length) <= NotationMeasureTiming.timelineTolerance + } + } + + private static func notationItemSort(_ lhs: NotationMeasureItem, _ rhs: NotationMeasureItem) -> Bool { + if abs(lhs.offsetInQuarterNotes - rhs.offsetInQuarterNotes) > NotationMeasureTiming.timelineTolerance { + return lhs.offsetInQuarterNotes < rhs.offsetInQuarterNotes + } + + return lhs.id < rhs.id + } +} diff --git a/JammLab/Services/NotationSMuFL.swift b/JammLab/Services/NotationSMuFL.swift index 7b28180..f573cba 100644 --- a/JammLab/Services/NotationSMuFL.swift +++ b/JammLab/Services/NotationSMuFL.swift @@ -82,8 +82,63 @@ enum NotationDurationControlSymbol: Equatable { } } +enum NotationStaffNoteSymbol: Equatable { + case whole + case half + case quarter + case eighth + + init?(duration: NotationDuration) { + switch duration.denominator { + case 1: + self = .whole + case 2: + self = .half + case 4: + self = .quarter + case 8: + self = .eighth + default: + return nil + } + } + + var durationControlSymbol: NotationDurationControlSymbol { + switch self { + case .whole: + return .whole + case .half: + return .half + case .quarter: + return .quarter + case .eighth: + return .eighth + } + } + + var codepoint: UInt32 { + durationControlSymbol.codepoint + } + + fileprivate var anchorReferenceCodepoint: UInt32? { + switch self { + case .whole: + return nil + case .half: + return 0xE0A3 + case .quarter, .eighth: + return 0xE0A4 + } + } + + var glyph: String { + durationControlSymbol.glyph + } +} + enum NotationMusicFontRegistry { static let fallbackFontName = "Leland" + private static let glyphPathCache = NSCache() static var fontName: String { registeredFontName ?? fallbackFontName @@ -115,7 +170,33 @@ enum NotationMusicFontRegistry { glyphPath(forCodepoint: symbol.codepoint, fontSize: fontSize) } + static func glyphPath( + for symbol: NotationStaffNoteSymbol, + fontSize: CGFloat + ) -> NotationSMuFLGlyphPath? { + glyphPath(forCodepoint: symbol.codepoint, fontSize: fontSize) + } + + static func noteheadAnchor( + for symbol: NotationStaffNoteSymbol, + fontSize: CGFloat + ) -> CGPoint? { + let glyphPath: NotationSMuFLGlyphPath? + if let anchorCodepoint = symbol.anchorReferenceCodepoint { + glyphPath = self.glyphPath(forCodepoint: anchorCodepoint, fontSize: fontSize) + } else { + glyphPath = self.glyphPath(for: symbol, fontSize: fontSize) + } + + return glyphPath.map { CGPoint(x: $0.bounds.midX, y: $0.bounds.midY) } + } + private static func glyphPath(forCodepoint codepoint: UInt32, fontSize: CGFloat) -> NotationSMuFLGlyphPath? { + let cacheKey = NSString(string: "\(codepoint)-\(fontSize)") + if let cached = glyphPathCache.object(forKey: cacheKey) { + return cached.value + } + guard let character = UniChar(exactly: codepoint) else { return nil } let font = CTFontCreateWithName(fontName as CFString, fontSize, nil) @@ -130,7 +211,17 @@ enum NotationMusicFontRegistry { let bounds = CTFontGetBoundingRectsForGlyphs(font, .default, &glyph, nil, 1) guard !bounds.isEmpty else { return nil } - return NotationSMuFLGlyphPath(path: path, bounds: bounds) + let glyphPath = NotationSMuFLGlyphPath(path: path, bounds: bounds) + glyphPathCache.setObject(NotationSMuFLGlyphPathBox(glyphPath), forKey: cacheKey) + return glyphPath + } +} + +private final class NotationSMuFLGlyphPathBox { + let value: NotationSMuFLGlyphPath + + init(_ value: NotationSMuFLGlyphPath) { + self.value = value } } @@ -149,4 +240,16 @@ struct NotationSMuFLGlyphPath { ty: size.height / 2 + bounds.midY ) } + + func anchoredTransform(anchor: CGPoint, target: CGPoint) -> CGAffineTransform { + // CoreText glyph paths use y-up coordinates; Canvas uses y-down. + CGAffineTransform( + a: 1, + b: 0, + c: 0, + d: -1, + tx: target.x - anchor.x, + ty: target.y + anchor.y + ) + } } diff --git a/JammLab/Services/NotationViewportFactory.swift b/JammLab/Services/NotationViewportFactory.swift index 6b116e7..2aac62a 100644 --- a/JammLab/Services/NotationViewportFactory.swift +++ b/JammLab/Services/NotationViewportFactory.swift @@ -526,6 +526,7 @@ struct NotationViewportFactory { .filter { item in item.measureNumber == measure.number && abs(item.measureStartTime - measure.startTime) < timelineTolerance + && (item.kind == .rest || item.pitch != nil) } .sorted(by: itemSort) guard !explicitItems.isEmpty else { diff --git a/JammLab/Utilities/AppHotkey.swift b/JammLab/Utilities/AppHotkey.swift index d4eebaa..81f4eab 100644 --- a/JammLab/Utilities/AppHotkey.swift +++ b/JammLab/Utilities/AppHotkey.swift @@ -17,6 +17,9 @@ enum AppHotkey: CaseIterable, Hashable { case pasteMeasure case clearNotationMeasureSelection case editHarmonyAtSelectedNotationItem + case toggleNotationNoteEntryMode + case moveSelectedNotationNotePitchUp + case moveSelectedNotationNotePitchDown case setNotationDurationEighth case setNotationDurationQuarter case setNotationDurationHalf @@ -73,6 +76,12 @@ enum AppHotkey: CaseIterable, Hashable { self = .setLoopEnd case 46: self = .addNote + case 45: + self = .toggleNotationNoteEntryMode + case 126: + self = .moveSelectedNotationNotePitchUp + case 125: + self = .moveSelectedNotationNotePitchDown case 11: self = .setBeatOne case 8: @@ -124,6 +133,12 @@ enum AppHotkey: CaseIterable, Hashable { return "Esc" case .editHarmonyAtSelectedNotationItem: return "Cmd+K" + case .toggleNotationNoteEntryMode: + return "N" + case .moveSelectedNotationNotePitchUp: + return "Arrow Up" + case .moveSelectedNotationNotePitchDown: + return "Arrow Down" case .setNotationDurationEighth: return "4" case .setNotationDurationQuarter: @@ -167,6 +182,12 @@ enum AppHotkey: CaseIterable, Hashable { return "Clear Measure Selection" case .editHarmonyAtSelectedNotationItem: return "Edit Harmony" + case .toggleNotationNoteEntryMode: + return "Notation Note Entry" + case .moveSelectedNotationNotePitchUp: + return "Move Notation Note Up" + case .moveSelectedNotationNotePitchDown: + return "Move Notation Note Down" case .setNotationDurationEighth: return "Set Eighth Note Duration" case .setNotationDurationQuarter: @@ -210,6 +231,12 @@ enum AppHotkey: CaseIterable, Hashable { return "Clear the selected notation measure or measure range." case .editHarmonyAtSelectedNotationItem: return "Open harmony entry for the selected notation item." + case .toggleNotationNoteEntryMode: + return "Enable or disable adding notes to Notation." + case .moveSelectedNotationNotePitchUp: + return "Move the selected notation note to the next higher staff position." + case .moveSelectedNotationNotePitchDown: + return "Move the selected notation note to the next lower staff position." case .setNotationDurationEighth: return "Set notation duration to eighth notes for the selected notation item." case .setNotationDurationQuarter: diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift index 5b19371..b6f6ffb 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift @@ -6,14 +6,42 @@ extension AudioPlayerViewModel { } var canChangeNotationDuration: Bool { - canEditSelectedNotationItem + duration > 0 && (isNotationNoteEntryModeEnabled || canEditSelectedNotationItem) + } + + var canChangeSelectedNotationNotePitch: Bool { + canChangeSelectedNotationNotePitch(byStaffPositionDelta: -1) + || canChangeSelectedNotationNotePitch(byStaffPositionDelta: 1) } func setNotationDurationDenominator(_ denominator: Int) { notationDurationDenominator = NotationDuration.normalizedDenominator(denominator) + guard !isNotationNoteEntryModeEnabled else { return } changeSelectedNotationItemDuration(to: NotationDuration(denominator: notationDurationDenominator)) } + func toggleNotationNoteEntryMode() { + setNotationNoteEntryModeEnabled(!isNotationNoteEntryModeEnabled) + } + + func setNotationNoteEntryModeEnabled(_ isEnabled: Bool) { + let resolvedIsEnabled = isEnabled && duration > 0 + guard isNotationNoteEntryModeEnabled != resolvedIsEnabled else { return } + + isNotationNoteEntryModeEnabled = resolvedIsEnabled + if resolvedIsEnabled { + selectedNotationMeasures = [] + notationMeasureSelectionAnchor = nil + selectedHarmonySymbolID = nil + selectedNotationItem = nil + pendingHarmonyEditorRequest = nil + } + } + + func clearNotationNoteEntryMode() { + setNotationNoteEntryModeEnabled(false) + } + @discardableResult func requestEditSelectedNotationItem() -> Bool { guard duration > 0, @@ -34,20 +62,36 @@ extension AudioPlayerViewModel { selectedHarmonySymbolID = availableHarmonySymbolID(id) } - func selectNotationItem(_ selection: NotationItemSelection?) { + func selectNotationItem( + _ selection: NotationItemSelection?, + shouldAudition: Bool = false + ) { guard let selection else { clearNotationItemSelection() return } - selectedNotationItem = selection + guard let match = notationItemMatch(for: selection) else { + clearNotationItemSelection() + return + } + + let canonicalSelection = NotationItemSelection(measure: match.measure, item: match.item) + selectedNotationItem = canonicalSelection selectedNotationMeasures = [] notationMeasureSelectionAnchor = nil - if let placement = harmonyPlacement(for: selection) { + isNotationNoteEntryModeEnabled = false + if let placement = harmonyPlacement(for: canonicalSelection) { selectedHarmonySymbolID = harmonySymbolID(at: placement.harmonyPlacement.time) } else { selectedHarmonySymbolID = nil } + + if shouldAudition, + match.item.kind == .note, + let pitch = match.item.pitch { + try? notationNoteAuditioner.audition(pitch: pitch) + } } func clearNotationItemSelection() { @@ -93,6 +137,7 @@ extension AudioPlayerViewModel { } selectedHarmonySymbolID = nil selectedNotationItem = nil + isNotationNoteEntryModeEnabled = false locatePlaybackMarkerAtFirstSelectedNotationMeasure() } @@ -103,6 +148,151 @@ extension AudioPlayerViewModel { selectedNotationItem = nil } + @discardableResult + func insertNotationNote(_ placement: NotationNotePlacement) -> Bool { + guard duration > 0, + let measure = currentNotationScoreMeasures().first(where: { + $0.number == placement.measure.number + && abs($0.startTime - placement.measure.startTime) < NotationMeasureTiming.timelineTolerance + && abs($0.endTime - placement.measure.endTime) < NotationMeasureTiming.timelineTolerance + }), + let restSpan = NotationNotePlacementResolver.restSpan(in: measure, matching: placement) + else { + return false + } + + let noteItem = NotationMeasureItem( + kind: .note, + pitch: placement.pitch, + measureNumber: measure.number, + measureStartTime: measure.startTime, + offsetInQuarterNotes: restSpan.startOffsetInQuarterNotes, + durationInQuarterNotes: placement.durationInQuarterNotes, + displayDuration: placement.displayDuration + ) + let recomposedItems = NotationNoteEntryRecomposer.recomposedItems( + in: measure, + replacing: restSpan, + with: noteItem + ) + + performUndoableEdit("Add Notation Note") { + replaceNotationMeasureItems(in: measure, with: recomposedItems) + selectedNotationMeasures = [] + notationMeasureSelectionAnchor = nil + selectedHarmonySymbolID = nil + + if let updatedMeasure = currentNotationScoreMeasures().first(where: { + $0.number == measure.number + && abs($0.startTime - measure.startTime) < NotationMeasureTiming.timelineTolerance + }), let selected = updatedMeasure.notationItems.first(where: { $0.id == noteItem.id }) { + selectedNotationItem = NotationItemSelection(measure: updatedMeasure, item: selected) + } else { + selectedNotationItem = nil + } + } + + try? notationNoteAuditioner.audition(pitch: placement.pitch) + return true + } + + @discardableResult + func changeSelectedNotationNotePitch( + to pitch: NotationPitch, + shouldAudition: Bool = true + ) -> Bool { + guard let selection = selectedNotationItem, + let match = notationItemMatch(for: selection), + match.item.kind == .note, + match.item.pitch != nil, + match.item.pitch != pitch + else { + return false + } + + let measure = match.measure + let item = match.item + let updatedItem = NotationMeasureItem( + id: item.id, + kind: .note, + pitch: pitch, + measureNumber: item.measureNumber, + measureStartTime: item.measureStartTime, + offsetInQuarterNotes: item.offsetInQuarterNotes, + durationInQuarterNotes: item.durationInQuarterNotes, + displayDuration: item.displayDuration + ) + + performUndoableEdit("Change Notation Note Pitch") { + replaceNotationItem( + in: measure, + matching: item, + with: updatedItem + ) + reselectNotationItem( + inMeasureNumber: measure.number, + measureStartTime: measure.startTime, + itemID: updatedItem.id + ) + } + + if shouldAudition { + try? notationNoteAuditioner.audition(pitch: pitch) + } + return true + } + + func auditionNotationNotePitch(_ pitch: NotationPitch) { + try? notationNoteAuditioner.audition(pitch: pitch) + } + + func canChangeSelectedNotationNotePitch(byStaffPositionDelta staffPositionDelta: Int) -> Bool { + selectedNotationNotePitchChange(byStaffPositionDelta: staffPositionDelta) != nil + } + + @discardableResult + func changeSelectedNotationNotePitch(byStaffPositionDelta staffPositionDelta: Int) -> Bool { + guard let pitch = selectedNotationNotePitchChange(byStaffPositionDelta: staffPositionDelta) else { + return false + } + + return changeSelectedNotationNotePitch(to: pitch) + } + + @discardableResult + func deleteSelectedNotationNote() -> Bool { + guard let selection = selectedNotationItem, + let match = notationItemMatch(for: selection), + match.item.kind == .note + else { + return false + } + + let measure = match.measure + let item = match.item + let replacementRest = NotationMeasureItem( + id: item.id, + kind: .rest, + pitch: nil, + measureNumber: item.measureNumber, + measureStartTime: item.measureStartTime, + offsetInQuarterNotes: item.offsetInQuarterNotes, + durationInQuarterNotes: item.durationInQuarterNotes, + displayDuration: item.displayDuration + ) + + performUndoableEdit("Delete Notation Note") { + replaceNotationItem( + in: measure, + matching: item, + with: replacementRest + ) + selectedNotationItem = nil + } + + return true + } + func clearNotationMeasureSelectionAndClipboard() { clearNotationMeasureSelection() notationMeasureClipboard = nil @@ -148,6 +338,7 @@ extension AudioPlayerViewModel { $0.offsetInQuarterNotes, in: targetMeasure.attributes.timeSignature ) + && ($0.kind == .rest || $0.pitch != nil) } .sorted(by: notationClipboardNotationItemSort) } @@ -191,6 +382,8 @@ extension AudioPlayerViewModel { }) notationItems.append(contentsOf: pastedNotationItems.map { item in NotationMeasureItem( + kind: item.kind, + pitch: item.kind == .note ? item.pitch : nil, measureNumber: targetMeasure.number, measureStartTime: targetMeasure.startTime, offsetInQuarterNotes: item.offsetInQuarterNotes, @@ -345,17 +538,12 @@ extension AudioPlayerViewModel { let suffix = notationDurationSuffix( measure: measure, + selectedItem: match.item, startOffset: startOffset, remaining: remaining, selectedDuration: selectedDuration ) - notationItems.removeAll { - $0.measureNumber == measure.number - && abs($0.measureStartTime - measure.startTime) < NotationMeasureTiming.timelineTolerance - } - notationItems.append(contentsOf: prefix) - notationItems.append(contentsOf: suffix) - notationItems = ProjectStateNormalizer.normalizedNotationItems(notationItems, duration: duration) + replaceNotationMeasureItems(in: measure, with: prefix + suffix) if let selected = suffix.first { selectedNotationItem = NotationItemSelection(measure: measure, item: selected) @@ -365,6 +553,7 @@ extension AudioPlayerViewModel { private func notationDurationSuffix( measure: ScoreMeasure, + selectedItem: NotationMeasureItem, startOffset: Double, remaining: Double, selectedDuration: NotationDuration @@ -373,6 +562,35 @@ extension AudioPlayerViewModel { var cursor = startOffset var rest = remaining let selectedLength = selectedDuration.durationInQuarterNotes + + if selectedItem.kind == .note { + guard selectedLength <= rest + NotationMeasureTiming.timelineTolerance, + let pitch = selectedItem.pitch + else { + return [selectedItem.persistedCopy()] + } + + let duration = min(selectedLength, rest) + items.append(NotationMeasureItem( + id: selectedItem.id, + kind: .note, + pitch: pitch, + measureNumber: measure.number, + measureStartTime: measure.startTime, + offsetInQuarterNotes: cursor, + durationInQuarterNotes: duration, + displayDuration: selectedDuration + )) + cursor += duration + rest -= duration + items.append(contentsOf: fillerNotationItems( + measure: measure, + startOffset: cursor, + remaining: rest + )) + return items + } + let selectedCount = min(2, Int(floor((rest + NotationMeasureTiming.timelineTolerance) / selectedLength))) if selectedCount > 0 { @@ -485,6 +703,8 @@ extension AudioPlayerViewModel { .filter { !$0.isSynthesized } .map { NotationMeasureClipboardNotationItem( + kind: $0.kind, + pitch: $0.pitch, offsetInQuarterNotes: $0.offsetInQuarterNotes, durationInQuarterNotes: $0.durationInQuarterNotes, displayDuration: $0.displayDuration @@ -565,6 +785,61 @@ extension AudioPlayerViewModel { ).measures } + private func replaceNotationItem( + in measure: ScoreMeasure, + matching targetItem: NotationMeasureItem, + with replacementItem: NotationMeasureItem + ) { + let updatedItems = measure.notationItems.map { item in + item.id == targetItem.id ? replacementItem : item.persistedCopy() + } + replaceNotationMeasureItems(in: measure, with: updatedItems) + } + + private func replaceNotationMeasureItems( + in measure: ScoreMeasure, + with replacementItems: [NotationMeasureItem] + ) { + notationItems.removeAll { + $0.measureNumber == measure.number + && abs($0.measureStartTime - measure.startTime) < NotationMeasureTiming.timelineTolerance + } + notationItems.append(contentsOf: replacementItems) + notationItems = ProjectStateNormalizer.normalizedNotationItems(notationItems, duration: duration) + } + + private func selectedNotationNotePitchChange(byStaffPositionDelta staffPositionDelta: Int) -> NotationPitch? { + guard let selection = selectedNotationItem, + let match = notationItemMatch(for: selection), + match.item.kind == .note, + let pitch = match.item.pitch + else { + return nil + } + + return NotationPitchMapper.adjacentPitch( + from: pitch, + staffPositionDelta: staffPositionDelta, + keySignature: match.measure.attributes.keySignature + ) + } + + private func reselectNotationItem( + inMeasureNumber measureNumber: Int, + measureStartTime: TimeInterval, + itemID: String + ) { + guard let updatedMeasure = currentNotationScoreMeasures().first(where: { + $0.number == measureNumber + && abs($0.startTime - measureStartTime) < NotationMeasureTiming.timelineTolerance + }), let selected = updatedMeasure.notationItems.first(where: { $0.id == itemID }) else { + selectedNotationItem = nil + return + } + + selectedNotationItem = NotationItemSelection(measure: updatedMeasure, item: selected) + } + private func locatePlaybackMarkerAtFirstSelectedNotationMeasure() { guard let firstSelectedMeasure = currentSelectedNotationMeasures().first else { return } locatePlaybackMarkerExactly(to: firstSelectedMeasure.startTime) diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift index 15c12a1..0dd028a 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift @@ -304,6 +304,7 @@ extension AudioPlayerViewModel { clearNotationMeasureSelectionAndClipboard() pendingHarmonyEditorRequest = nil notationDurationDenominator = NotationDuration.defaultDenominator + isNotationNoteEntryModeEnabled = false activeLoopRegionID = nil } diff --git a/JammLab/ViewModels/AudioPlayerViewModel.swift b/JammLab/ViewModels/AudioPlayerViewModel.swift index 7fb95d4..f1b5b5c 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel.swift @@ -46,6 +46,7 @@ final class AudioPlayerViewModel: ObservableObject { @Published var notationMeasureClipboard: NotationMeasureClipboard? @Published var notationDurationDenominator = NotationDuration.defaultDenominator @Published var notationItems: [NotationMeasureItem] = [] + @Published var isNotationNoteEntryModeEnabled = false @Published var pendingHarmonyEditorRequest: HarmonyEditorRequest? @Published var activeLoopRegionID: TimecodedNote.ID? @Published var loopRegion: LoopRegion = .empty @@ -89,6 +90,7 @@ final class AudioPlayerViewModel: ObservableObject { let projectPersistenceCoordinator: ProjectPersistenceCoordinator let notationExportService: NotationExportService let notationExportDocumentService: NotationExportDocumentService + let notationNoteAuditioner: NotationNoteAuditioning let recentProjectsStore: RecentProjectsStore let isSandboxed: () -> Bool var clockTask: Task? @@ -176,6 +178,7 @@ final class AudioPlayerViewModel: ObservableObject { projectPersistenceCoordinator: ProjectPersistenceCoordinator? = nil, notationExportService: NotationExportService = NotationExportService(), notationExportDocumentService: NotationExportDocumentService = NotationExportDocumentService(), + notationNoteAuditioner: NotationNoteAuditioning? = nil, recentProjectsStore: RecentProjectsStore? = nil, isSandboxed: @escaping () -> Bool = AudioPlayerViewModel.defaultSandboxDetection ) { @@ -198,6 +201,7 @@ final class AudioPlayerViewModel: ObservableObject { ) self.notationExportService = notationExportService self.notationExportDocumentService = notationExportDocumentService + self.notationNoteAuditioner = notationNoteAuditioner ?? SamplerNotationNoteAuditioner() self.recentProjectsStore = recentProjectsStore ?? .shared self.isSandboxed = isSandboxed self.playbackEngine.setClickVolume(clickVolume) diff --git a/JammLab/Views/MainWorkspacePanels.swift b/JammLab/Views/MainWorkspacePanels.swift index 1c66bd9..1f9557d 100644 --- a/JammLab/Views/MainWorkspacePanels.swift +++ b/JammLab/Views/MainWorkspacePanels.swift @@ -147,6 +147,7 @@ extension ContentView { : notationViewportState(availableWidth: notationTrackContentWidth), notationDurationDenominator: viewModel.notationDurationDenominator, canChangeNotationDuration: viewModel.canChangeNotationDuration, + isNotationNoteEntryModeEnabled: viewModel.isNotationNoteEntryModeEnabled, isNotationTrackCollapsed: viewModel.isNotationTrackCollapsed, isLoadingPeakform: viewModel.isBuildingWaveform, mainTrackVolume: viewModel.mainTrackVolume, @@ -200,7 +201,7 @@ extension ContentView { addNote: { viewModel.addNote(at: $0) }, selectHarmony: { viewModel.selectHarmonySymbol(id: $0) }, selectNotationMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1) }, - selectNotationItem: { viewModel.selectNotationItem($0) }, + selectNotationItem: { viewModel.selectNotationItem($0, shouldAudition: $1) }, saveHarmony: { viewModel.saveHarmonySymbol($0) }, deleteHarmony: { viewModel.deleteHarmonySymbol(id: $0) }, adjacentHarmonyPlacement: { viewModel.adjacentHarmonyPlacement(from: $0, direction: $1) }, @@ -222,6 +223,11 @@ extension ContentView { mainTrackVolumeChanged: { viewModel.setMainTrackVolume($0) }, notationTrackCollapsedChanged: { viewModel.setNotationTrackCollapsed($0) }, notationDurationChanged: { viewModel.setNotationDurationDenominator($0) }, + notationNoteEntryModeToggled: { viewModel.toggleNotationNoteEntryMode() }, + insertNotationNote: { viewModel.insertNotationNote($0) }, + changeSelectedNotePitch: { viewModel.changeSelectedNotationNotePitch(to: $0, shouldAudition: $1) }, + auditionNotePitch: { viewModel.auditionNotationNotePitch($0) }, + deleteSelectedNotationNote: { viewModel.deleteSelectedNotationNote() }, showNotationWindow: { openWindow(id: AppWindowID.notation) } ) } diff --git a/JammLab/Views/NotationTrackView.swift b/JammLab/Views/NotationTrackView.swift index 2c746a1..25fd0cd 100644 --- a/JammLab/Views/NotationTrackView.swift +++ b/JammLab/Views/NotationTrackView.swift @@ -1,10 +1,16 @@ import AppKit import SwiftUI +private let notationTrackCoordinateSpaceName = "NotationTrackCoordinateSpace" + struct NotationTrackActions { var selectHarmony: (HarmonySymbol.ID?) -> Void var selectMeasure: (ScoreMeasure?, Bool) -> Void - var selectItem: (NotationItemSelection?) -> Void + var selectItem: (NotationItemSelection?, Bool) -> Void + var insertNotationNote: (NotationNotePlacement) -> Bool + var changeSelectedNotePitch: (NotationPitch, Bool) -> Bool + var auditionNotePitch: (NotationPitch) -> Void + var deleteSelectedNotationNote: () -> Bool var locatePlaybackMarkerExactly: (TimeInterval) -> Void var saveHarmony: (HarmonySymbol) -> Void var deleteHarmony: (HarmonySymbol.ID) -> Void @@ -17,6 +23,8 @@ struct NotationTrackView: View { let selectedHarmonySymbolID: HarmonySymbol.ID? let selectedMeasures: [NotationMeasureSelection] let selectedItem: NotationItemSelection? + let selectedDuration: NotationDuration + let isNoteEntryModeEnabled: Bool let pendingEditorRequest: HarmonyEditorRequest? let actions: NotationTrackActions let cornerRadius: CGFloat @@ -24,6 +32,8 @@ struct NotationTrackView: View { @Environment(\.appColors) private var appColors @FocusState private var isTrackFocused: Bool @State private var editingDraft: HarmonyEditorDraft? + @State private var hoveredNotePlacement: NotationNotePlacement? + @State private var draggedNotePitchPreview: NotationDraggedNotePitchPreview? init( state: NotationViewportState, @@ -31,6 +41,8 @@ struct NotationTrackView: View { selectedHarmonySymbolID: HarmonySymbol.ID? = nil, selectedMeasures: [NotationMeasureSelection] = [], selectedItem: NotationItemSelection? = nil, + selectedDuration: NotationDuration = NotationDuration(), + isNoteEntryModeEnabled: Bool = false, pendingEditorRequest: HarmonyEditorRequest? = nil, actions: NotationTrackActions = .noop, cornerRadius: CGFloat = AppTheme.Radius.small @@ -40,6 +52,8 @@ struct NotationTrackView: View { self.selectedHarmonySymbolID = selectedHarmonySymbolID self.selectedMeasures = selectedMeasures self.selectedItem = selectedItem + self.selectedDuration = selectedDuration + self.isNoteEntryModeEnabled = isNoteEntryModeEnabled self.pendingEditorRequest = pendingEditorRequest self.actions = actions self.cornerRadius = cornerRadius @@ -105,7 +119,13 @@ struct NotationTrackView: View { height: proxy.size.height, attributeDisplays: attributeDisplays ) + noteEntryLayer( + width: contentWidth, + height: proxy.size.height, + attributeDisplays: attributeDisplays + ) } + .coordinateSpace(name: notationTrackCoordinateSpaceName) .frame(width: contentWidth, height: proxy.size.height) .id(scrollResetIdentity) .contentShape(Rectangle()) @@ -117,11 +137,23 @@ struct NotationTrackView: View { .focused($isTrackFocused) .focusEffectDisabled(true) .onDeleteCommand { - deleteSelectedHarmony() + deleteSelectedNotationItemOrHarmony() } .onChange(of: pendingEditorRequest?.id) { _, _ in handlePendingEditorRequest() } + .onChange(of: isNoteEntryModeEnabled) { _, isEnabled in + draggedNotePitchPreview = nil + if !isEnabled { + hoveredNotePlacement = nil + } + } + .onChange(of: selectedItem) { _, newSelection in + if let draggedNotePitchPreview, + newSelection != draggedNotePitchPreview.selection { + self.draggedNotePitchPreview = nil + } + } .accessibilityElement(children: .contain) .accessibilityLabel("Notation Track") .accessibilityValue(accessibilityValue) @@ -179,7 +211,7 @@ struct NotationTrackView: View { staffTop: staffTop, in: &context ) - drawNotationRests( + drawNotationItems( geometries: geometries, staffTop: staffTop, in: &context @@ -243,7 +275,7 @@ struct NotationTrackView: View { } } - private func drawNotationRests( + private func drawNotationItems( geometries: [NotationMeasureCanvasGeometry], staffTop: CGFloat, in context: inout GraphicsContext @@ -255,19 +287,80 @@ struct NotationTrackView: View { ) == true ? appColors.accent : appColors.notationSymbolsAndLines - guard let symbol = NotationSMuFLSymbol(duration: item.notationItem.displayDuration) else { - continue + switch item.notationItem.kind { + case .rest: + guard let symbol = NotationSMuFLSymbol(duration: item.notationItem.displayDuration) else { + continue + } + drawRestGlyph( + symbol: symbol, + x: item.x, + staffTop: staffTop, + color: color, + in: &context + ) + case .note: + let pitch = draggedNotePitchPreview?.matches(item.selection) == true + ? draggedNotePitchPreview?.pitch + : item.notationItem.pitch + guard let pitch, + let staffPosition = NotationPitchMapper.staffPosition(for: pitch) + else { + continue + } + drawNoteGlyph( + duration: item.notationItem.displayDuration, + x: item.x, + y: NotationNotePlacementResolver.yPosition( + forStaffPosition: staffPosition, + staffTop: staffTop + ), + color: color, + opacity: 1, + in: &context + ) } - drawRestGlyph( - symbol: symbol, - x: item.x, - staffTop: staffTop, - color: color, + } + + if let hoveredNotePlacement { + drawNoteGlyph( + duration: hoveredNotePlacement.displayDuration, + x: hoveredNotePlacement.x, + y: hoveredNotePlacement.y, + color: appColors.accent, + opacity: 0.56, in: &context ) } } + private func drawNoteGlyph( + duration: NotationDuration, + x: CGFloat, + y: CGFloat, + color: Color, + opacity: Double, + in context: inout GraphicsContext + ) { + let spacing = AppTheme.Timeline.notationStaffLineSpacing + let fontSize = spacing * 3.25 + guard let symbol = NotationStaffNoteSymbol(duration: duration), + let glyphPath = NotationMusicFontRegistry.glyphPath(for: symbol, fontSize: fontSize), + let anchor = NotationMusicFontRegistry.noteheadAnchor(for: symbol, fontSize: fontSize) + else { + return + } + + let transform = glyphPath.anchoredTransform( + anchor: anchor, + target: CGPoint(x: x, y: y) + ) + context.fill( + Path(glyphPath.path).applying(transform), + with: .color(color.opacity(opacity)) + ) + } + private func drawRestGlyph( symbol: NotationSMuFLSymbol, x: CGFloat, @@ -480,9 +573,13 @@ struct NotationTrackView: View { .onTapGesture { isTrackFocused = true editingDraft = nil - actions.selectItem(item.selection) + actions.selectItem(item.selection, true) } - .accessibilityLabel("\(item.notationItem.displayDuration.displayName.capitalized) rest in measure \(item.selection.measureNumber)") + .simultaneousGesture(notePitchDragGesture( + item: item, + staffTop: staffTop + )) + .accessibilityLabel(notationItemAccessibilityLabel(item)) .accessibilityValue( selectedItem?.matches( item.measure, @@ -493,6 +590,123 @@ struct NotationTrackView: View { } } + @ViewBuilder + private func noteEntryLayer( + width: CGFloat, + height: CGFloat, + attributeDisplays: [NotationAttributeDisplay] + ) -> some View { + if isNoteEntryModeEnabled { + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + .frame(width: width, height: height) + .onContinuousHover { phase in + switch phase { + case .active(let point): + hoveredNotePlacement = notePlacement( + at: point, + width: width, + height: height, + attributeDisplays: attributeDisplays + ) + case .ended: + hoveredNotePlacement = nil + } + } + .gesture( + DragGesture(minimumDistance: 0) + .onEnded { value in + isTrackFocused = true + editingDraft = nil + guard let placement = notePlacement( + at: value.location, + width: width, + height: height, + attributeDisplays: attributeDisplays + ) else { return } + if actions.insertNotationNote(placement) { + hoveredNotePlacement = placement + } + } + ) + .cursor(.crosshair) + .help("Add notation note") + .accessibilityLabel("Notation note entry area") + } + } + + private func notePitchDragGesture( + item: NotationItemLayoutItem, + staffTop: CGFloat + ) -> some Gesture { + DragGesture(minimumDistance: 3, coordinateSpace: .named(notationTrackCoordinateSpaceName)) + .onChanged { value in + guard !isNoteEntryModeEnabled, + item.notationItem.kind == .note, + let pitch = notePitch( + forDragY: value.location.y, + measure: item.measure, + staffTop: staffTop + ) + else { + return + } + + isTrackFocused = true + editingDraft = nil + if draggedNotePitchPreview?.matches(item.selection) != true { + actions.selectItem(item.selection, false) + } + + let didAudition: Bool + if draggedNotePitchPreview?.matches(item.selection) == true, + draggedNotePitchPreview?.pitch == pitch { + didAudition = draggedNotePitchPreview?.didAudition == true + } else if item.notationItem.pitch != pitch { + actions.auditionNotePitch(pitch) + didAudition = true + } else { + didAudition = false + } + + draggedNotePitchPreview = NotationDraggedNotePitchPreview( + selection: item.selection, + pitch: pitch, + didAudition: didAudition + ) + } + .onEnded { _ in + guard let preview = draggedNotePitchPreview, + preview.matches(item.selection) + else { + draggedNotePitchPreview = nil + return + } + + _ = actions.changeSelectedNotePitch(preview.pitch, !preview.didAudition) + draggedNotePitchPreview = nil + } + } + + private func notePitch( + forDragY y: CGFloat, + measure: ScoreMeasure, + staffTop: CGFloat + ) -> NotationPitch? { + guard let staffPosition = NotationNotePlacementResolver.clampedStaffPosition( + forY: y, + staffTop: staffTop + ) else { + return nil + } + + return NotationPitchMapper.pitch( + forStaffPosition: staffPosition, + keySignature: measure.attributes.keySignature + ) + } + private func barlineHitLayer( width: CGFloat, height: CGFloat, @@ -662,14 +876,14 @@ struct NotationTrackView: View { .contentShape(Rectangle()) .onTapGesture { isTrackFocused = true - actions.selectItem(itemSelection(for: symbol)) + actions.selectItem(itemSelection(for: symbol), false) if !isShiftClickActive { actions.selectHarmony(symbol.id) } } .onTapGesture(count: 2) { isTrackFocused = true - actions.selectItem(itemSelection(for: symbol)) + actions.selectItem(itemSelection(for: symbol), false) beginEditingHarmony(symbol) } .accessibilityLabel("Harmony \(symbol.rawText)") @@ -1009,6 +1223,16 @@ struct NotationTrackView: View { editingDraft = nil } + private func deleteSelectedNotationItemOrHarmony() { + if actions.deleteSelectedNotationNote() { + editingDraft = nil + draggedNotePitchPreview = nil + return + } + + deleteSelectedHarmony() + } + private func deleteSelectedHarmony() { guard let selectedHarmonySymbolID else { return } editingDraft = nil @@ -1109,6 +1333,37 @@ struct NotationTrackView: View { ) } + private func notePlacement( + at point: CGPoint, + width: CGFloat, + height: CGFloat, + attributeDisplays: [NotationAttributeDisplay] + ) -> NotationNotePlacement? { + guard renderedMeasureCount > 0, !state.visibleMeasures.isEmpty else { return nil } + let geometries = measureCanvasGeometries( + measureCount: renderedMeasureCount, + width: width, + attributeDisplays: attributeDisplays + ) + guard let geometryIndex = NotationMeasureLayout.measureIndex( + atX: point.x, + in: geometries + ) else { + return nil + } + + let measureIndex = min(max(0, geometryIndex), state.visibleMeasures.count - 1) + guard geometries.indices.contains(measureIndex) else { return nil } + + return NotationNotePlacementResolver.placement( + in: state.visibleMeasures[measureIndex], + geometry: geometries[measureIndex], + point: point, + staffTop: staffTop(in: height), + selectedDuration: selectedDuration + ) + } + private var accessibilityValue: String { guard let first = state.visibleMeasures.first, let last = state.visibleMeasures.last else { return "Pending tempo" @@ -1140,6 +1395,18 @@ struct NotationTrackView: View { } } + private func notationItemAccessibilityLabel(_ item: NotationItemLayoutItem) -> String { + switch item.notationItem.kind { + case .rest: + return "\(item.notationItem.displayDuration.displayName.capitalized) rest in measure \(item.selection.measureNumber)" + case .note: + let pitchText = item.notationItem.pitch.map { + "\($0.step.rawValue)\($0.alter == 1 ? " sharp" : $0.alter == -1 ? " flat" : "")\($0.octave)" + } ?? "note" + return "\(item.notationItem.displayDuration.displayName.capitalized) \(pitchText) in measure \(item.selection.measureNumber)" + } + } + private var isShiftClickActive: Bool { NSApp.currentEvent?.modifierFlags.contains(.shift) == true } @@ -1210,11 +1477,25 @@ private struct NotationHarmonyPlacement: Equatable { } } +private struct NotationDraggedNotePitchPreview: Equatable { + var selection: NotationItemSelection + var pitch: NotationPitch + var didAudition: Bool + + func matches(_ selection: NotationItemSelection) -> Bool { + self.selection == selection + } +} + private extension NotationTrackActions { static let noop = NotationTrackActions( selectHarmony: { _ in }, selectMeasure: { _, _ in }, - selectItem: { _ in }, + selectItem: { _, _ in }, + insertNotationNote: { _ in false }, + changeSelectedNotePitch: { _, _ in false }, + auditionNotePitch: { _ in }, + deleteSelectedNotationNote: { false }, locatePlaybackMarkerExactly: { _ in }, saveHarmony: { _ in }, deleteHarmony: { _ in }, diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index fdc3d77..9e7c7de 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -63,6 +63,18 @@ struct NotationWindowView: View { isEnabled: viewModel.canChangeNotationDuration ) + AppControlButton( + title: "Note Entry", + systemImage: "music.note", + isActive: viewModel.isNotationNoteEntryModeEnabled + ) { + viewModel.toggleNotationNoteEntryMode() + } + .disabled(viewModel.duration <= 0) + .help("Add notes to Notation (N)") + .accessibilityLabel("Notation Note Entry") + .accessibilityValue(viewModel.isNotationNoteEntryModeEnabled ? "Enabled" : "Disabled") + Spacer(minLength: AppTheme.Spacing.md) AppControlButton( @@ -97,6 +109,8 @@ struct NotationWindowView: View { selectedHarmonySymbolID: viewModel.selectedHarmonySymbolID, selectedMeasures: viewModel.selectedNotationMeasures, selectedItem: viewModel.selectedNotationItem, + selectedDuration: NotationDuration(denominator: viewModel.notationDurationDenominator), + isNoteEntryModeEnabled: viewModel.isNotationNoteEntryModeEnabled, pendingEditorRequest: viewModel.pendingHarmonyEditorRequest, actions: notationActions, cornerRadius: AppTheme.Spacing.none @@ -161,7 +175,11 @@ struct NotationWindowView: View { NotationTrackActions( selectHarmony: { viewModel.selectHarmonySymbol(id: $0) }, selectMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1) }, - selectItem: { viewModel.selectNotationItem($0) }, + selectItem: { viewModel.selectNotationItem($0, shouldAudition: $1) }, + insertNotationNote: { viewModel.insertNotationNote($0) }, + changeSelectedNotePitch: { viewModel.changeSelectedNotationNotePitch(to: $0, shouldAudition: $1) }, + auditionNotePitch: { viewModel.auditionNotationNotePitch($0) }, + deleteSelectedNotationNote: { viewModel.deleteSelectedNotationNote() }, locatePlaybackMarkerExactly: { viewModel.locatePlaybackMarkerExactly(to: $0) }, saveHarmony: { viewModel.saveHarmonySymbol($0) }, deleteHarmony: { viewModel.deleteHarmonySymbol(id: $0) }, @@ -177,7 +195,7 @@ struct NotationWindowView: View { if viewModel.canPasteNotationMeasureClipboard { hotkeys.insert(.pasteMeasure) } - if viewModel.hasSelectedNotationMeasures { + if viewModel.hasSelectedNotationMeasures || viewModel.isNotationNoteEntryModeEnabled { hotkeys.insert(.clearNotationMeasureSelection) } if viewModel.canEditSelectedNotationItem { @@ -186,6 +204,15 @@ struct NotationWindowView: View { if viewModel.canChangeNotationDuration { hotkeys.formUnion(AppHotkey.notationDurationHotkeys) } + if viewModel.duration > 0 { + hotkeys.insert(.toggleNotationNoteEntryMode) + } + if viewModel.canChangeSelectedNotationNotePitch(byStaffPositionDelta: -1) { + hotkeys.insert(.moveSelectedNotationNotePitchUp) + } + if viewModel.canChangeSelectedNotationNotePitch(byStaffPositionDelta: 1) { + hotkeys.insert(.moveSelectedNotationNotePitchDown) + } return hotkeys } @@ -200,10 +227,21 @@ struct NotationWindowView: View { case .pasteMeasure: return viewModel.pasteNotationMeasureClipboard() case .clearNotationMeasureSelection: - viewModel.clearNotationMeasureSelection() + if viewModel.isNotationNoteEntryModeEnabled { + viewModel.clearNotationNoteEntryMode() + } else { + viewModel.clearNotationMeasureSelection() + } return true case .editHarmonyAtSelectedNotationItem: return viewModel.requestEditSelectedNotationItem() + case .toggleNotationNoteEntryMode: + viewModel.toggleNotationNoteEntryMode() + return true + case .moveSelectedNotationNotePitchUp: + return viewModel.changeSelectedNotationNotePitch(byStaffPositionDelta: -1) + case .moveSelectedNotationNotePitchDown: + return viewModel.changeSelectedNotationNotePitch(byStaffPositionDelta: 1) case .setNotationDurationEighth, .setNotationDurationQuarter, .setNotationDurationHalf, diff --git a/JammLab/Views/WaveformTimelineView.swift b/JammLab/Views/WaveformTimelineView.swift index 1551e58..7de9c27 100644 --- a/JammLab/Views/WaveformTimelineView.swift +++ b/JammLab/Views/WaveformTimelineView.swift @@ -34,6 +34,7 @@ struct TimelineViewState: Equatable { var notationViewport: NotationViewportState var notationDurationDenominator: Int var canChangeNotationDuration: Bool + var isNotationNoteEntryModeEnabled: Bool var isNotationTrackCollapsed: Bool var isLoadingPeakform: Bool var mainTrackVolume: Float @@ -50,7 +51,7 @@ struct TimelineViewActions { var addNote: (TimeInterval) -> Void var selectHarmony: (HarmonySymbol.ID?) -> Void var selectNotationMeasure: (ScoreMeasure?, Bool) -> Void - var selectNotationItem: (NotationItemSelection?) -> Void + var selectNotationItem: (NotationItemSelection?, Bool) -> Void var saveHarmony: (HarmonySymbol) -> Void var deleteHarmony: (HarmonySymbol.ID) -> Void var adjacentHarmonyPlacement: (TimeInterval, HarmonyNavigationDirection) -> HarmonyPlacement? @@ -72,6 +73,11 @@ struct TimelineViewActions { var mainTrackVolumeChanged: (Float) -> Void var notationTrackCollapsedChanged: (Bool) -> Void var notationDurationChanged: (Int) -> Void + var notationNoteEntryModeToggled: () -> Void + var insertNotationNote: (NotationNotePlacement) -> Bool + var changeSelectedNotePitch: (NotationPitch, Bool) -> Bool + var auditionNotePitch: (NotationPitch) -> Void + var deleteSelectedNotationNote: () -> Bool var showNotationWindow: () -> Void } @@ -269,11 +275,17 @@ struct WaveformTimelineView: View { selectedHarmonySymbolID: state.selectedHarmonySymbolID, selectedMeasures: state.selectedNotationMeasures, selectedItem: state.selectedNotationItem, + selectedDuration: NotationDuration(denominator: state.notationDurationDenominator), + isNoteEntryModeEnabled: state.isNotationNoteEntryModeEnabled, pendingEditorRequest: state.pendingHarmonyEditorRequest, actions: NotationTrackActions( selectHarmony: actions.selectHarmony, selectMeasure: actions.selectNotationMeasure, selectItem: actions.selectNotationItem, + insertNotationNote: actions.insertNotationNote, + changeSelectedNotePitch: actions.changeSelectedNotePitch, + auditionNotePitch: actions.auditionNotePitch, + deleteSelectedNotationNote: actions.deleteSelectedNotationNote, locatePlaybackMarkerExactly: actions.locatePlaybackMarkerExactly, saveHarmony: actions.saveHarmony, deleteHarmony: actions.deleteHarmony, @@ -309,6 +321,19 @@ struct WaveformTimelineView: View { ), isEnabled: state.canChangeNotationDuration ) + + AppLetterToggleButton( + title: "N", + isActive: state.isNotationNoteEntryModeEnabled, + activeFillColor: appColors.accent, + inactiveTextColor: appColors.secondaryText + ) { + actions.notationNoteEntryModeToggled() + } + .disabled(state.duration <= 0) + .help("Add notes to Notation (N)") + .accessibilityLabel("Notation Note Entry") + .accessibilityValue(state.isNotationNoteEntryModeEnabled ? "Enabled" : "Disabled") } } .padding(.horizontal, AppTheme.Spacing.md) diff --git a/JammLabTests/AppHotkeyNotationEventFilterTests.swift b/JammLabTests/AppHotkeyNotationEventFilterTests.swift index 47dc752..7f4a42c 100644 --- a/JammLabTests/AppHotkeyNotationEventFilterTests.swift +++ b/JammLabTests/AppHotkeyNotationEventFilterTests.swift @@ -101,6 +101,75 @@ final class AppHotkeyNotationEventFilterTests: XCTestCase { ) } + func testAppHotkeyEventFilterDoesNotStealNotationPitchArrowsFromTextRespondersOrUnavailableScopes() throws { + let arrowEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "\u{F700}", + charactersIgnoringModifiers: "\u{F700}", + isARepeat: false, + keyCode: 126 + )) + let repeatArrowEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "\u{F700}", + charactersIgnoringModifiers: "\u{F700}", + isARepeat: true, + keyCode: 126 + )) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: arrowEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.moveSelectedNotationNotePitchUp] + ), + .moveSelectedNotationNotePitchUp + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: arrowEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: arrowEvent, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.moveSelectedNotationNotePitchUp] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: arrowEvent, + attachedWindowNumber: 42, + firstResponder: AbletonNumberFieldNSView(), + allowedHotkeys: [.moveSelectedNotationNotePitchUp] + ) + ) + XCTAssertNil( + AppHotkeyEventFilter.hotkey( + for: repeatArrowEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.moveSelectedNotationNotePitchUp] + ) + ) + } + func testAppHotkeyEventFilterDoesNotStealEditHarmonyFromTextRespondersOrUnavailableScopes() throws { let editHarmonyEvent = try XCTUnwrap(NSEvent.keyEvent( with: .keyDown, diff --git a/JammLabTests/AppHotkeyNotationMappingTests.swift b/JammLabTests/AppHotkeyNotationMappingTests.swift index 0654c44..cf2a199 100644 --- a/JammLabTests/AppHotkeyNotationMappingTests.swift +++ b/JammLabTests/AppHotkeyNotationMappingTests.swift @@ -49,6 +49,73 @@ final class AppHotkeyNotationMappingTests: XCTestCase { XCTAssertTrue(AppHotkey.allCases.contains(.setNotationDurationWhole)) } + func testAppHotkeyRecognizesNotationNoteEntryModeToggle() throws { + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "n", + charactersIgnoringModifiers: "n", + isARepeat: false, + keyCode: 45 + )) + + XCTAssertEqual(AppHotkey(event: event), .toggleNotationNoteEntryMode) + XCTAssertEqual(AppHotkey.toggleNotationNoteEntryMode.key, "N") + XCTAssertEqual(AppHotkey.toggleNotationNoteEntryMode.title, "Notation Note Entry") + XCTAssertTrue(AppHotkey.allCases.contains(.toggleNotationNoteEntryMode)) + } + + func testAppHotkeyRecognizesNotationNotePitchArrowKeys() throws { + let upEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "\u{F700}", + charactersIgnoringModifiers: "\u{F700}", + isARepeat: false, + keyCode: 126 + )) + let downEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "\u{F701}", + charactersIgnoringModifiers: "\u{F701}", + isARepeat: false, + keyCode: 125 + )) + let shiftUpEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.shift], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "\u{F700}", + charactersIgnoringModifiers: "\u{F700}", + isARepeat: false, + keyCode: 126 + )) + + XCTAssertEqual(AppHotkey(event: upEvent), .moveSelectedNotationNotePitchUp) + XCTAssertEqual(AppHotkey(event: downEvent), .moveSelectedNotationNotePitchDown) + XCTAssertNil(AppHotkey(event: shiftUpEvent)) + XCTAssertEqual(AppHotkey.moveSelectedNotationNotePitchUp.key, "Arrow Up") + XCTAssertEqual(AppHotkey.moveSelectedNotationNotePitchDown.key, "Arrow Down") + XCTAssertEqual(AppHotkey.moveSelectedNotationNotePitchUp.title, "Move Notation Note Up") + XCTAssertTrue(AppHotkey.allCases.contains(.moveSelectedNotationNotePitchDown)) + } + func testAppHotkeyRecognizesNotationDurationNumpadKeys() throws { let expectations: [(key: String, keyCode: UInt16, hotkey: AppHotkey, denominator: Int)] = [ ("4", 86, .setNotationDurationEighth, 8), diff --git a/JammLabTests/NotationMusicXMLRestExportTests.swift b/JammLabTests/NotationMusicXMLRestExportTests.swift index 11d7435..0147d59 100644 --- a/JammLabTests/NotationMusicXMLRestExportTests.swift +++ b/JammLabTests/NotationMusicXMLRestExportTests.swift @@ -60,6 +60,48 @@ final class NotationMusicXMLRestExportTests: XCTestCase { try assertXMLChild(harmony, precedes: thirdRest, in: firstMeasure) } + func testMusicXMLExportIncludesPitchedNotationNoteWithoutBeam() throws { + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 4), + duration: 4, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "G major", + notationItems: [ + NotationMeasureItem( + kind: .note, + pitch: NotationPitch(step: .f, octave: 5, alter: 1), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ), + NotationMeasureItem( + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0.5, + durationInQuarterNotes: 3.5, + displayDuration: NotationDuration(denominator: 2) + ) + ] + ) + + let document = try exportedMusicXMLDocument(for: state) + let part = try XCTUnwrap(document.rootElement()?.elements(forName: "part").first) + let firstMeasure = try XCTUnwrap(part.elements(forName: "measure").first) + let note = try XCTUnwrap(firstMeasure.elements(forName: "note").first) + let pitch = try firstXMLChild(named: "pitch", in: note) + + XCTAssertEqual(try firstXMLChild(named: "step", in: pitch).stringValue, "F") + XCTAssertEqual(try firstXMLChild(named: "alter", in: pitch).stringValue, "1") + XCTAssertEqual(try firstXMLChild(named: "octave", in: pitch).stringValue, "5") + XCTAssertEqual(try firstXMLChild(named: "duration", in: note).stringValue, "240") + XCTAssertEqual(try firstXMLChild(named: "type", in: note).stringValue, "eighth") + XCTAssertTrue(note.elements(forName: "beam").isEmpty) + } + private func exportedMusicXMLDocument(for state: NotationScoreState) throws -> XMLDocument { let data = try NotationExportService().export( NotationExportRequest(displayName: "Song", score: state), diff --git a/JammLabTests/NotationPrimitivesTests.swift b/JammLabTests/NotationPrimitivesTests.swift index 2c68b2e..ac11b84 100644 --- a/JammLabTests/NotationPrimitivesTests.swift +++ b/JammLabTests/NotationPrimitivesTests.swift @@ -1,3 +1,4 @@ +import CoreGraphics import XCTest @testable import JammLab @@ -36,6 +37,18 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertEqual(eighth.glyph.unicodeScalars.first?.value, 0xECA7) } + func testNotationStaffNoteSymbolsReuseLelandMetNoteCodepoints() throws { + let whole = try XCTUnwrap(NotationStaffNoteSymbol(duration: NotationDuration(denominator: 1))) + let half = try XCTUnwrap(NotationStaffNoteSymbol(duration: NotationDuration(denominator: 2))) + let quarter = try XCTUnwrap(NotationStaffNoteSymbol(duration: NotationDuration(denominator: 4))) + let eighth = try XCTUnwrap(NotationStaffNoteSymbol(duration: NotationDuration(denominator: 8))) + + XCTAssertEqual(whole.codepoint, 0xECA2) + XCTAssertEqual(half.codepoint, 0xECA3) + XCTAssertEqual(quarter.codepoint, 0xECA5) + XCTAssertEqual(eighth.codepoint, 0xECA7) + } + func testNotationRestItemFactoryUsesGreedyAllowedDurationDecomposition() { let segments = NotationRestItemFactory.greedySegments(startOffset: 0, remaining: 3.5) @@ -109,6 +122,438 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertTrue(item.isSynthesized) } + func testNotationMeasureItemDecodesLegacyRestWithoutPitchOrSynthesizedFlag() throws { + let json = """ + { + "id": "legacy-rest", + "kind": "rest", + "measureNumber": 1, + "measureStartTime": 0, + "offsetInQuarterNotes": 0, + "durationInQuarterNotes": 4, + "displayDuration": { "denominator": 1 } + } + """ + + let item = try JSONDecoder().decode(NotationMeasureItem.self, from: Data(json.utf8)) + + XCTAssertEqual(item.id, "legacy-rest") + XCTAssertEqual(item.kind, .rest) + XCTAssertNil(item.pitch) + XCTAssertFalse(item.isSynthesized) + } + + func testNotationMeasureItemPersistedCopyClearsSynthesizedStateAndNormalizesPitch() throws { + let persistedNote = NotationMeasureItem( + id: "note", + kind: .note, + pitch: NotationPitch(step: .g, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let synthesizedRest = NotationMeasureItem( + id: "default-rest", + kind: .rest, + pitch: NotationPitch(step: .c, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2), + isSynthesized: true + ) + + let noteCopy = persistedNote.persistedCopy() + let restCopy = synthesizedRest.persistedCopy() + + XCTAssertEqual(noteCopy.id, "note") + XCTAssertEqual(noteCopy.kind, .note) + XCTAssertEqual(noteCopy.pitch, NotationPitch(step: .g, octave: 4)) + XCTAssertFalse(noteCopy.isSynthesized) + XCTAssertNotEqual(restCopy.id, "default-rest") + XCTAssertEqual(restCopy.kind, .rest) + XCTAssertNil(restCopy.pitch) + XCTAssertFalse(restCopy.isSynthesized) + } + + func testNotationPitchMapperAppliesKeySignatureDefaultAlter() { + let fSharp = NotationPitchMapper.pitch( + forStaffPosition: 0, + keySignature: KeySignature.normalized(from: "G major") + ) + let bFlat = NotationPitchMapper.pitch( + forStaffPosition: 4, + keySignature: KeySignature.normalized(from: "F major") + ) + + XCTAssertEqual(fSharp.step, .f) + XCTAssertEqual(fSharp.octave, 5) + XCTAssertEqual(fSharp.alter, 1) + XCTAssertEqual(bFlat.step, .b) + XCTAssertEqual(bFlat.octave, 4) + XCTAssertEqual(bFlat.alter, -1) + } + + func testNotationPitchMapperAdjacentPitchRespectsBoundsAndKeySignature() throws { + let cMajor = KeySignature.normalized(from: "C major") + let fMajor = KeySignature.normalized(from: "F major") + + XCTAssertEqual( + NotationPitchMapper.adjacentPitch( + from: NotationPitch(step: .c, octave: 5), + staffPositionDelta: 1, + keySignature: cMajor + ), + NotationPitch(step: .b, octave: 4) + ) + XCTAssertEqual( + NotationPitchMapper.adjacentPitch( + from: NotationPitch(step: .c, octave: 5), + staffPositionDelta: -1, + keySignature: cMajor + ), + NotationPitch(step: .d, octave: 5) + ) + XCTAssertEqual( + NotationPitchMapper.adjacentPitch( + from: NotationPitch(step: .a, octave: 4), + staffPositionDelta: -1, + keySignature: fMajor + ), + NotationPitch(step: .b, octave: 4, alter: -1) + ) + XCTAssertNil(NotationPitchMapper.adjacentPitch( + from: NotationPitch(step: .f, octave: 5), + staffPositionDelta: -1, + keySignature: cMajor + )) + XCTAssertNil(NotationPitchMapper.adjacentPitch( + from: NotationPitch(step: .c, octave: 3), + staffPositionDelta: 1, + keySignature: cMajor + )) + } + + func testNotationPitchMapsToMIDINoteNumbers() { + XCTAssertEqual(NotationPitch(step: .c, octave: 4).midiNoteNumber, 60) + XCTAssertEqual(NotationPitch(step: .f, octave: 5, alter: 1).midiNoteNumber, 78) + XCTAssertEqual(NotationPitch(step: .b, octave: 4, alter: -1).midiNoteNumber, 70) + XCTAssertEqual(NotationPitch(step: .c, octave: -2).midiNoteNumber, 0) + XCTAssertEqual(NotationPitch(step: .b, octave: 10, alter: 1).midiNoteNumber, 127) + } + + func testNotationNotePlacementResolverForcesFullMeasureWholeRestToBeatOne() throws { + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble, + notationItems: [ + NotationMeasureItem( + id: "explicit-whole-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 4, + displayDuration: NotationDuration(denominator: 1) + ) + ] + ) + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 200, + contentStartX: 20, + contentEndX: 180, + staffStartX: 0, + staffEndX: 200 + ) + + let placement = try XCTUnwrap(NotationNotePlacementResolver.placement( + in: measure, + geometry: geometry, + point: CGPoint(x: 160, y: 72), + staffTop: 40, + selectedDuration: NotationDuration(denominator: 4) + )) + + XCTAssertEqual(placement.offsetInQuarterNotes, 0, accuracy: 0.0001) + XCTAssertEqual(placement.targetRestID, "explicit-whole-rest") + XCTAssertEqual(placement.pitch.step, .e) + XCTAssertEqual(placement.pitch.octave, 4) + } + + func testNotationNotePlacementResolverTargetsContainingShortRestWithFollowingCapacity() throws { + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble, + notationItems: [ + NotationMeasureItem( + id: "first-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "first-eighth-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ), + NotationMeasureItem( + id: "second-eighth-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1.5, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ), + NotationMeasureItem( + id: "half-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + ] + ) + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 200, + contentStartX: 20, + contentEndX: 180, + staffStartX: 0, + staffEndX: 200 + ) + let xInsideSecondEighth = NotationMeasureLayout.notationAnchorX( + geometry: geometry, + offsetInQuarterNotes: 1.9, + timeSignature: measure.attributes.timeSignature + ) + + let placement = try XCTUnwrap(NotationNotePlacementResolver.placement( + in: measure, + geometry: geometry, + point: CGPoint(x: xInsideSecondEighth, y: 72), + staffTop: 40, + selectedDuration: NotationDuration(denominator: 4) + )) + + XCTAssertEqual(placement.targetRestID, "second-eighth-rest") + XCTAssertEqual(placement.offsetInQuarterNotes, 1.5, accuracy: 0.0001) + XCTAssertEqual(placement.durationInQuarterNotes, 1, accuracy: 0.0001) + } + + func testNotationNotePlacementResolverRejectsRestWithoutContiguousCapacity() { + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble, + notationItems: [ + NotationMeasureItem( + id: "short-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ), + NotationMeasureItem( + id: "next-note", + kind: .note, + pitch: NotationPitch(step: .e, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1.5, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + ) + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 200, + contentStartX: 20, + contentEndX: 180, + staffStartX: 0, + staffEndX: 200 + ) + let xInsideShortRest = NotationMeasureLayout.notationAnchorX( + geometry: geometry, + offsetInQuarterNotes: 1.25, + timeSignature: measure.attributes.timeSignature + ) + + XCTAssertNil(NotationNotePlacementResolver.placement( + in: measure, + geometry: geometry, + point: CGPoint(x: xInsideShortRest, y: 72), + staffTop: 40, + selectedDuration: NotationDuration(denominator: 4) + )) + } + + func testNotationNoteEntryRecomposerPreservesPersistedItemsAndCopiesSynthesizedItems() throws { + let note = NotationMeasureItem( + id: "existing-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let targetRest = NotationMeasureItem( + id: "target-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + isSynthesized: true + ) + let trailingRest = NotationMeasureItem( + id: "trailing-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2), + isSynthesized: true + ) + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble, + notationItems: [note, targetRest, trailingRest] + ) + let span = try XCTUnwrap(NotationNotePlacementResolver.restSpan( + in: measure, + from: targetRest, + requiredDurationInQuarterNotes: 1 + )) + let inserted = NotationMeasureItem( + id: "inserted-note", + kind: .note, + pitch: NotationPitch(step: .e, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + + let recomposed = NotationNoteEntryRecomposer.recomposedItems( + in: measure, + replacing: span, + with: inserted + ) + + XCTAssertEqual(recomposed[0].id, "existing-note") + XCTAssertEqual(recomposed[1].id, "inserted-note") + XCTAssertNotEqual(recomposed[2].id, "trailing-rest") + XCTAssertEqual(recomposed.map(\.kind), [.note, .note, .rest]) + XCTAssertEqual(recomposed[0].pitch, NotationPitch(step: .c, octave: 5)) + XCTAssertFalse(recomposed[0].isSynthesized) + XCTAssertFalse(recomposed[2].isSynthesized) + } + + func testNotationNoteEntryRecomposerSplitsTailToNextQuarterBoundary() throws { + let targetRest = NotationMeasureItem( + id: "second-eighth-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1.5, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ) + let followingRest = NotationMeasureItem( + id: "half-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble, + notationItems: [targetRest, followingRest] + ) + let span = try XCTUnwrap(NotationNotePlacementResolver.restSpan( + in: measure, + from: targetRest, + requiredDurationInQuarterNotes: 1 + )) + let inserted = NotationMeasureItem( + id: "inserted-note", + kind: .note, + pitch: NotationPitch(step: .e, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1.5, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + + let recomposed = NotationNoteEntryRecomposer.recomposedItems( + in: measure, + replacing: span, + with: inserted + ) + + XCTAssertEqual(recomposed.map(\.kind), [.note, .rest, .rest]) + XCTAssertEqual(recomposed.map(\.offsetInQuarterNotes), [1.5, 2.5, 3]) + XCTAssertEqual(recomposed.map(\.durationInQuarterNotes), [1, 0.5, 1]) + XCTAssertEqual(recomposed.map(\.displayDuration.denominator), [4, 8, 4]) + } + + func testNotationNotePlacementResolverClampsStaffPositionForPitchDrag() throws { + let staffTop: CGFloat = 40 + let spacing = AppTheme.Timeline.notationStaffLineSpacing + + XCTAssertEqual( + NotationNotePlacementResolver.clampedStaffPosition( + forY: staffTop - spacing * 4, + staffTop: staffTop + ), + NotationPitchMapper.minimumStaffPosition + ) + XCTAssertEqual( + NotationNotePlacementResolver.clampedStaffPosition( + forY: staffTop + spacing * 8, + staffTop: staffTop + ), + NotationPitchMapper.maximumStaffPosition + ) + XCTAssertEqual( + NotationNotePlacementResolver.clampedStaffPosition( + forY: staffTop + spacing, + staffTop: staffTop + ), + 2 + ) + } + func testLelandFontResourceIsBundledAndRegistered() throws { let url = try XCTUnwrap(Bundle.main.url(forResource: "Leland", withExtension: "otf")) @@ -145,6 +590,51 @@ final class NotationPrimitivesTests: XCTestCase { } } + func testLelandStaffNoteGlyphPathsHaveBounds() throws { + for symbol in [ + NotationStaffNoteSymbol.whole, + .half, + .quarter, + .eighth + ] { + let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( + for: symbol, + fontSize: 32.5 + )) + + XCTAssertFalse(glyphPath.path.isEmpty) + XCTAssertGreaterThan(glyphPath.bounds.width, 0) + XCTAssertGreaterThan(glyphPath.bounds.height, 0) + } + } + + func testLelandStaffNoteGlyphTransformAnchorsNoteheadAtTargetPoint() throws { + let target = CGPoint(x: 42, y: 73) + + for symbol in [ + NotationStaffNoteSymbol.whole, + .half, + .quarter, + .eighth + ] { + let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( + for: symbol, + fontSize: 32.5 + )) + let anchor = try XCTUnwrap(NotationMusicFontRegistry.noteheadAnchor( + for: symbol, + fontSize: 32.5 + )) + let transformedAnchor = anchor.applying(glyphPath.anchoredTransform( + anchor: anchor, + target: target + )) + + XCTAssertEqual(transformedAnchor.x, target.x, accuracy: 0.0001) + XCTAssertEqual(transformedAnchor.y, target.y, accuracy: 0.0001) + } + } + func testLelandDurationControlGlyphPathsCenterInsideButtonFrame() throws { let buttonSize = CGSize( width: AppTheme.ControlSize.notationDurationButtonWidth, diff --git a/JammLabTests/ProjectStateNormalizerTests.swift b/JammLabTests/ProjectStateNormalizerTests.swift index 1e7bd25..7624b11 100644 --- a/JammLabTests/ProjectStateNormalizerTests.swift +++ b/JammLabTests/ProjectStateNormalizerTests.swift @@ -51,6 +51,34 @@ final class ProjectStateNormalizerTests: XCTestCase { XCTAssertEqual(symbols[1].rawText, "G7 alt") } + func testProjectStateNormalizerPreservesNotationNotePitchAndDropsInvalidNotes() { + let note = NotationMeasureItem( + id: "note", + kind: .note, + pitch: NotationPitch(step: .f, octave: 5, alter: 1), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let invalidNote = NotationMeasureItem( + id: "invalid", + kind: .note, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + + let items = ProjectStateNormalizer.normalizedNotationItems([invalidNote, note], duration: 4) + + XCTAssertEqual(items.map(\.id), ["note"]) + XCTAssertEqual(items.first?.kind, .note) + XCTAssertEqual(items.first?.pitch, NotationPitch(step: .f, octave: 5, alter: 1)) + } + func testProjectStateNormalizerUsesSliderDefaultsForPlaybackControls() { XCTAssertEqual( ProjectStateNormalizer.normalizedPlaybackRate(0), diff --git a/JammLabTests/StemWorkflowPersistenceTests.swift b/JammLabTests/StemWorkflowPersistenceTests.swift index 79cea73..1b4d143 100644 --- a/JammLabTests/StemWorkflowPersistenceTests.swift +++ b/JammLabTests/StemWorkflowPersistenceTests.swift @@ -43,7 +43,7 @@ final class StemWorkflowPersistenceTests: XCTestCase { let decoded = try JSONDecoder().decode(JammLabProject.self, from: JSONEncoder().encode(project)) - XCTAssertEqual(decoded.formatVersion, 10) + XCTAssertEqual(decoded.formatVersion, 11) XCTAssertEqual(decoded.artifactRootBookmarkData, artifactRootBookmarkData) XCTAssertEqual(decoded.mediaKind, .video) XCTAssertEqual(decoded.isLoopEnabled, true) @@ -62,6 +62,50 @@ final class StemWorkflowPersistenceTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(decoded.stemState?.mixState.effectiveVolume(for: .drums)), 0.8, accuracy: 0.0001) } + func testProjectVersionElevenPersistsPitchedNotationItems() throws { + let pitch = NotationPitch(step: .f, octave: 4, alter: 1) + let project = JammLabProject( + audioBookmarkData: Data("bookmark".utf8), + audioDisplayName: "lesson.mp3", + audioDuration: 8, + notes: [], + notationItems: [ + NotationMeasureItem( + id: "note", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + ], + loopStart: 0, + loopEnd: 8, + playbackRate: 1, + pitchShiftSemitones: 0 + ) + + let decoded = try JSONDecoder().decode(JammLabProject.self, from: JSONEncoder().encode(project)) + + XCTAssertEqual(decoded.formatVersion, 11) + XCTAssertEqual(decoded.notationItems.map(\.id), ["note", "rest"]) + XCTAssertEqual(decoded.notationItems.map(\.kind), [.note, .rest]) + XCTAssertEqual(decoded.notationItems.map(\.pitch), [pitch, nil]) + XCTAssertEqual(decoded.notationItems.map(\.offsetInQuarterNotes), [0, 1]) + XCTAssertEqual(decoded.notationItems.map(\.durationInQuarterNotes), [1, 2]) + XCTAssertEqual(decoded.notationItems.map(\.displayDuration.denominator), [4, 2]) + } + func testLegacyProjectWithoutStemStateStillDecodes() throws { let json = """ { diff --git a/JammLabTests/TestSupport.swift b/JammLabTests/TestSupport.swift index 379fe51..2294866 100644 --- a/JammLabTests/TestSupport.swift +++ b/JammLabTests/TestSupport.swift @@ -102,6 +102,21 @@ final class MockAnalyzer: AudioAnalyzing { } } +@MainActor +final class MockNotationNoteAuditioner: NotationNoteAuditioning { + var errorToThrow: Error? + private(set) var attemptedPitches: [NotationPitch] = [] + private(set) var auditionedPitches: [NotationPitch] = [] + + func audition(pitch: NotationPitch) throws { + attemptedPitches.append(pitch) + if let errorToThrow { + throw errorToThrow + } + auditionedPitches.append(pitch) + } +} + struct MockPeakformProvider: PeakformProvider { var samplesPerPeakLevels = PeakformData.defaultSamplesPerPeakLevels var peakform = PeakformData( diff --git a/JammLabTests/ViewModelNotationClipboardTests.swift b/JammLabTests/ViewModelNotationClipboardTests.swift index 3c58bcd..0cad8b0 100644 --- a/JammLabTests/ViewModelNotationClipboardTests.swift +++ b/JammLabTests/ViewModelNotationClipboardTests.swift @@ -43,6 +43,58 @@ final class ViewModelNotationClipboardTests: XCTestCase { ]) } + @MainActor + func testCopyNotationMeasurePreservesNotationNotesAndRests() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + let pitch = NotationPitch(step: .f, octave: 4, alter: 1) + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "D") + ] + viewModel.notationItems = [ + NotationMeasureItem( + id: "note", + kind: .note, + pitch: pitch, + measureNumber: measure.number, + measureStartTime: measure.startTime, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "rest", + measureNumber: measure.number, + measureStartTime: measure.startTime, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + ] + + viewModel.selectNotationMeasure(try notationMeasure(1, in: viewModel)) + + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + let copiedMeasure = try XCTUnwrap(viewModel.notationMeasureClipboard?.measures.first) + XCTAssertEqual(copiedMeasure.items, [ + NotationMeasureClipboardItem(offsetInQuarterNotes: 0, rawText: "D") + ]) + XCTAssertEqual(copiedMeasure.notationItems, [ + NotationMeasureClipboardNotationItem( + kind: .note, + pitch: pitch, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureClipboardNotationItem( + offsetInQuarterNotes: 1, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + ]) + } + @MainActor func testCopyRejectsPartialStaleNotationMeasureSelection() throws { let viewModel = try loadedNotationViewModel(duration: 8) diff --git a/JammLabTests/ViewModelNotationDurationSelectionTests.swift b/JammLabTests/ViewModelNotationDurationSelectionTests.swift index c053b2e..6b34518 100644 --- a/JammLabTests/ViewModelNotationDurationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationDurationSelectionTests.swift @@ -37,4 +37,558 @@ final class ViewModelNotationDurationSelectionTests: XCTestCase { XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [2, 1]) XCTAssertTrue(viewModel.isProjectModified) } + + @MainActor + func testAddingNotationNoteRecomposesRemainingRestsAndCanUndo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let measure = try notationMeasure(1, in: viewModel) + let rest = try XCTUnwrap(measure.notationItems.first) + let placement = NotationNotePlacement( + measure: measure, + targetRestID: rest.id, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + pitch: NotationPitch(step: .e, octave: 4), + x: 0, + y: 0 + ) + viewModel.markProjectClean() + + XCTAssertTrue(viewModel.insertNotationNote(placement)) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(updatedMeasure.notationItems.map(\.kind), [.note, .rest, .rest]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 1, 3]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [1, 2, 1]) + XCTAssertEqual(updatedMeasure.notationItems.first?.pitch, NotationPitch(step: .e, octave: 4)) + XCTAssertEqual(viewModel.selectedNotationItem?.offsetInQuarterNotes, 0) + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.undoLastEdit() + + let undoneMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(undoneMeasure.notationItems.count, 1) + XCTAssertEqual(undoneMeasure.notationItems.first?.kind, .rest) + } + + @MainActor + func testAddingNotationNoteCanConsumeFollowingRestsFromShortTargetRest() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.notationItems = [ + NotationMeasureItem( + id: "first-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "first-eighth-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ), + NotationMeasureItem( + id: "second-eighth-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1.5, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ), + NotationMeasureItem( + id: "half-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + ] + viewModel.markProjectClean() + + let measure = try notationMeasure(1, in: viewModel) + let targetRest = try XCTUnwrap(measure.notationItems.first { $0.id == "second-eighth-rest" }) + let pitch = NotationPitch(step: .e, octave: 4) + let placement = NotationNotePlacement( + measure: measure, + targetRestID: targetRest.id, + offsetInQuarterNotes: targetRest.offsetInQuarterNotes, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + pitch: pitch, + x: 0, + y: 0 + ) + + XCTAssertTrue(viewModel.insertNotationNote(placement)) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(updatedMeasure.notationItems.map(\.kind), [.note, .rest, .note, .rest, .rest]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 1, 1.5, 2.5, 3]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [1, 0.5, 1, 0.5, 1]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [4, 8, 4, 8, 4]) + XCTAssertEqual(updatedMeasure.notationItems[2].pitch, pitch) + XCTAssertEqual(viewModel.selectedNotationItem?.offsetInQuarterNotes, 1.5) + + viewModel.undoLastEdit() + + let undoneMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(undoneMeasure.notationItems.map(\.id), [ + "first-note", + "first-eighth-rest", + "second-eighth-rest", + "half-rest" + ]) + } + + @MainActor + func testAddingNotationNoteAuditionsInsertedPitch() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let measure = try notationMeasure(1, in: viewModel) + let rest = try XCTUnwrap(measure.notationItems.first) + let pitch = NotationPitch(step: .g, octave: 4) + let placement = NotationNotePlacement( + measure: measure, + targetRestID: rest.id, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + pitch: pitch, + x: 0, + y: 0 + ) + + XCTAssertTrue(viewModel.insertNotationNote(placement)) + + XCTAssertEqual(auditioner.auditionedPitches, [pitch]) + } + + @MainActor + func testRejectedNotationNoteDoesNotAuditionPitch() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let measure = try notationMeasure(1, in: viewModel) + let rest = try XCTUnwrap(measure.notationItems.first) + let placement = NotationNotePlacement( + measure: measure, + targetRestID: rest.id, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 8, + displayDuration: NotationDuration(denominator: 1), + pitch: NotationPitch(step: .g, octave: 4), + x: 0, + y: 0 + ) + + XCTAssertFalse(viewModel.insertNotationNote(placement)) + + XCTAssertTrue(auditioner.attemptedPitches.isEmpty) + XCTAssertTrue(auditioner.auditionedPitches.isEmpty) + } + + @MainActor + func testRejectedNotationNoteDoesNotConsumeAcrossExistingNoteOrAudition() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + viewModel.notationItems = [ + NotationMeasureItem( + id: "short-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ), + NotationMeasureItem( + id: "blocking-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1.5, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + viewModel.markProjectClean() + let measure = try notationMeasure(1, in: viewModel) + let rest = try XCTUnwrap(measure.notationItems.first { $0.id == "short-rest" }) + let placement = NotationNotePlacement( + measure: measure, + targetRestID: rest.id, + offsetInQuarterNotes: rest.offsetInQuarterNotes, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + pitch: NotationPitch(step: .g, octave: 4), + x: 0, + y: 0 + ) + + XCTAssertFalse(viewModel.insertNotationNote(placement)) + XCTAssertTrue(auditioner.attemptedPitches.isEmpty) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testNotationNoteInsertionSucceedsWhenAuditionFails() throws { + let auditioner = MockNotationNoteAuditioner() + auditioner.errorToThrow = NotationNoteAuditionerError.initializationFailed + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let measure = try notationMeasure(1, in: viewModel) + let rest = try XCTUnwrap(measure.notationItems.first) + let pitch = NotationPitch(step: .a, octave: 4) + let placement = NotationNotePlacement( + measure: measure, + targetRestID: rest.id, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + pitch: pitch, + x: 0, + y: 0 + ) + + XCTAssertTrue(viewModel.insertNotationNote(placement)) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(updatedMeasure.notationItems.first?.kind, .note) + XCTAssertEqual(auditioner.attemptedPitches, [pitch]) + XCTAssertTrue(auditioner.auditionedPitches.isEmpty) + } + + @MainActor + func testChangingSelectedNotationNotePitchPreservesRhythmAndCanUndoRedo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let insertedPitch = NotationPitch(step: .e, octave: 4) + let updatedPitch = NotationPitch(step: .g, octave: 4) + let insertedNote = try insertQuarterNote( + pitch: insertedPitch, + inMeasure: 1, + viewModel: viewModel + ) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.markProjectClean() + + XCTAssertTrue(viewModel.changeSelectedNotationNotePitch(to: updatedPitch)) + + let changedNote = try XCTUnwrap(try notationMeasure(1, in: viewModel).notationItems.first) + XCTAssertEqual(changedNote.id, insertedNote.id) + XCTAssertEqual(changedNote.kind, .note) + XCTAssertEqual(changedNote.pitch, updatedPitch) + XCTAssertEqual(changedNote.offsetInQuarterNotes, insertedNote.offsetInQuarterNotes) + XCTAssertEqual(changedNote.durationInQuarterNotes, insertedNote.durationInQuarterNotes) + XCTAssertEqual(changedNote.displayDuration, insertedNote.displayDuration) + XCTAssertEqual(viewModel.selectedNotationItem?.itemID, insertedNote.id) + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.undoLastEdit() + + let undoneNote = try XCTUnwrap(try notationMeasure(1, in: viewModel).notationItems.first) + XCTAssertEqual(undoneNote.id, insertedNote.id) + XCTAssertEqual(undoneNote.pitch, insertedPitch) + + viewModel.redoLastEdit() + + let redoneNote = try XCTUnwrap(try notationMeasure(1, in: viewModel).notationItems.first) + XCTAssertEqual(redoneNote.id, insertedNote.id) + XCTAssertEqual(redoneNote.pitch, updatedPitch) + } + + @MainActor + func testChangingSelectedNotationNotePitchAuditionsDirectCommitOnly() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let insertedPitch = NotationPitch(step: .e, octave: 4) + let updatedPitch = NotationPitch(step: .a, octave: 4) + _ = try insertQuarterNote( + pitch: insertedPitch, + inMeasure: 1, + viewModel: viewModel + ) + + XCTAssertTrue(viewModel.changeSelectedNotationNotePitch(to: updatedPitch)) + + XCTAssertEqual(auditioner.auditionedPitches, [insertedPitch, updatedPitch]) + } + + @MainActor + func testChangingSelectedNotationNoteDurationPreservesPitchAndCanUndoRedo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let insertedPitch = NotationPitch(step: .e, octave: 4) + let insertedNote = try insertQuarterNote( + pitch: insertedPitch, + inMeasure: 1, + viewModel: viewModel + ) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.markProjectClean() + + viewModel.setNotationDurationDenominator(2) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + let updatedNote = try XCTUnwrap(updatedMeasure.notationItems.first) + XCTAssertEqual(updatedMeasure.notationItems.map(\.kind), [.note, .rest]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 2]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [2, 2]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [2, 2]) + XCTAssertEqual(updatedNote.id, insertedNote.id) + XCTAssertEqual(updatedNote.pitch, insertedPitch) + XCTAssertEqual(viewModel.selectedNotationItem?.itemID, insertedNote.id) + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.undoLastEdit() + + let undoneNote = try XCTUnwrap(try notationMeasure(1, in: viewModel).notationItems.first) + XCTAssertEqual(undoneNote.id, insertedNote.id) + XCTAssertEqual(undoneNote.pitch, insertedPitch) + XCTAssertEqual(undoneNote.durationInQuarterNotes, insertedNote.durationInQuarterNotes) + + viewModel.redoLastEdit() + + let redoneNote = try XCTUnwrap(try notationMeasure(1, in: viewModel).notationItems.first) + XCTAssertEqual(redoneNote.id, insertedNote.id) + XCTAssertEqual(redoneNote.pitch, insertedPitch) + XCTAssertEqual(redoneNote.durationInQuarterNotes, 2) + } + + @MainActor + func testChangingSelectedNotationNotePitchByStaffStepPreservesRhythmAndCanUndoRedo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let insertedPitch = NotationPitch(step: .c, octave: 5) + let updatedPitch = NotationPitch(step: .b, octave: 4) + let insertedNote = try insertQuarterNote( + pitch: insertedPitch, + inMeasure: 1, + viewModel: viewModel + ) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.markProjectClean() + + XCTAssertTrue(viewModel.canChangeSelectedNotationNotePitch(byStaffPositionDelta: 1)) + XCTAssertTrue(viewModel.changeSelectedNotationNotePitch(byStaffPositionDelta: 1)) + + let changedNote = try XCTUnwrap(try notationMeasure(1, in: viewModel).notationItems.first) + XCTAssertEqual(changedNote.id, insertedNote.id) + XCTAssertEqual(changedNote.pitch, updatedPitch) + XCTAssertEqual(changedNote.offsetInQuarterNotes, insertedNote.offsetInQuarterNotes) + XCTAssertEqual(changedNote.durationInQuarterNotes, insertedNote.durationInQuarterNotes) + + viewModel.undoLastEdit() + + let undoneNote = try XCTUnwrap(try notationMeasure(1, in: viewModel).notationItems.first) + XCTAssertEqual(undoneNote.id, insertedNote.id) + XCTAssertEqual(undoneNote.pitch, insertedPitch) + + viewModel.redoLastEdit() + + let redoneNote = try XCTUnwrap(try notationMeasure(1, in: viewModel).notationItems.first) + XCTAssertEqual(redoneNote.id, insertedNote.id) + XCTAssertEqual(redoneNote.pitch, updatedPitch) + } + + @MainActor + func testChangingSelectedNotationNotePitchByStaffStepUpAuditionsAdjacentPitch() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let insertedPitch = NotationPitch(step: .c, octave: 5) + let updatedPitch = NotationPitch(step: .d, octave: 5) + _ = try insertQuarterNote( + pitch: insertedPitch, + inMeasure: 1, + viewModel: viewModel + ) + + XCTAssertTrue(viewModel.changeSelectedNotationNotePitch(byStaffPositionDelta: -1)) + + let changedNote = try XCTUnwrap(try notationMeasure(1, in: viewModel).notationItems.first) + XCTAssertEqual(changedNote.pitch, updatedPitch) + XCTAssertEqual(auditioner.auditionedPitches, [insertedPitch, updatedPitch]) + } + + @MainActor + func testChangingSelectedNotationNotePitchNoOpsForRestAndSamePitch() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let note = try insertQuarterNote( + pitch: NotationPitch(step: .e, octave: 4), + inMeasure: 1, + viewModel: viewModel + ) + viewModel.markProjectClean() + + XCTAssertFalse(viewModel.changeSelectedNotationNotePitch(to: try XCTUnwrap(note.pitch))) + XCTAssertFalse(viewModel.isProjectModified) + + let measure = try notationMeasure(1, in: viewModel) + let rest = try XCTUnwrap(measure.notationItems.first { $0.kind == .rest }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: rest)) + + XCTAssertFalse(viewModel.changeSelectedNotationNotePitch(to: NotationPitch(step: .g, octave: 4))) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testChangingSelectedNotationNotePitchByStaffStepNoOpsForRestBoundsAndUnmappedPitch() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let note = try insertQuarterNote( + pitch: NotationPitch(step: .f, octave: 5), + inMeasure: 1, + viewModel: viewModel + ) + viewModel.markProjectClean() + + XCTAssertFalse(viewModel.canChangeSelectedNotationNotePitch(byStaffPositionDelta: -1)) + XCTAssertFalse(viewModel.changeSelectedNotationNotePitch(byStaffPositionDelta: -1)) + XCTAssertFalse(viewModel.isProjectModified) + + let measure = try notationMeasure(1, in: viewModel) + let rest = try XCTUnwrap(measure.notationItems.first { $0.id != note.id }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: rest)) + + XCTAssertFalse(viewModel.canChangeSelectedNotationNotePitch(byStaffPositionDelta: 1)) + XCTAssertFalse(viewModel.changeSelectedNotationNotePitch(byStaffPositionDelta: 1)) + XCTAssertFalse(viewModel.isProjectModified) + + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: note)) + XCTAssertTrue(viewModel.changeSelectedNotationNotePitch(to: NotationPitch(step: .c, octave: 3))) + viewModel.markProjectClean() + + XCTAssertFalse(viewModel.canChangeSelectedNotationNotePitch(byStaffPositionDelta: 1)) + XCTAssertFalse(viewModel.changeSelectedNotationNotePitch(byStaffPositionDelta: 1)) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testDeletingSelectedNotationNoteReplacesItWithCorrespondingRestAndCanUndoRedo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let note = try insertQuarterNote( + pitch: NotationPitch(step: .e, octave: 4), + inMeasure: 1, + viewModel: viewModel + ) + let harmony = HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") + viewModel.harmonySymbols = [harmony] + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.markProjectClean() + + XCTAssertTrue(viewModel.deleteSelectedNotationNote()) + + let deletedMeasure = try notationMeasure(1, in: viewModel) + let replacement = try XCTUnwrap(deletedMeasure.notationItems.first) + XCTAssertEqual(replacement.id, note.id) + XCTAssertEqual(replacement.kind, .rest) + XCTAssertNil(replacement.pitch) + XCTAssertEqual(replacement.offsetInQuarterNotes, note.offsetInQuarterNotes) + XCTAssertEqual(replacement.durationInQuarterNotes, note.durationInQuarterNotes) + XCTAssertEqual(replacement.displayDuration, note.displayDuration) + XCTAssertEqual(deletedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 1, 3]) + XCTAssertEqual(viewModel.harmonySymbols, [harmony]) + XCTAssertNil(viewModel.selectedNotationItem) + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.undoLastEdit() + + let undoneNote = try XCTUnwrap(try notationMeasure(1, in: viewModel).notationItems.first) + XCTAssertEqual(undoneNote.id, note.id) + XCTAssertEqual(undoneNote.kind, .note) + XCTAssertEqual(undoneNote.pitch, note.pitch) + + viewModel.redoLastEdit() + + let redoneRest = try XCTUnwrap(try notationMeasure(1, in: viewModel).notationItems.first) + XCTAssertEqual(redoneRest.id, note.id) + XCTAssertEqual(redoneRest.kind, .rest) + } + + @MainActor + func testDeletingSelectedNotationRestIsNoOp() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let note = try insertQuarterNote( + pitch: NotationPitch(step: .e, octave: 4), + inMeasure: 1, + viewModel: viewModel + ) + let measure = try notationMeasure(1, in: viewModel) + let rest = try XCTUnwrap(measure.notationItems.first { $0.id != note.id }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: rest)) + viewModel.markProjectClean() + + XCTAssertFalse(viewModel.deleteSelectedNotationNote()) + + let unchangedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(unchangedMeasure.notationItems.first(where: { $0.id == rest.id })?.kind, .rest) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testNoteEntryDurationChangeDoesNotEditSelectedItem() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + let item = try XCTUnwrap(measure.notationItems.first) + + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: item)) + viewModel.setNotationNoteEntryModeEnabled(true) + viewModel.setNotationDurationDenominator(4) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(viewModel.notationDurationDenominator, 4) + XCTAssertNil(viewModel.selectedNotationItem) + XCTAssertEqual(updatedMeasure.notationItems.count, 1) + XCTAssertEqual(updatedMeasure.notationItems.first?.displayDuration.denominator, 1) + } + + @MainActor + private func insertQuarterNote( + pitch: NotationPitch, + inMeasure measureNumber: Int, + viewModel: AudioPlayerViewModel + ) throws -> NotationMeasureItem { + let measure = try notationMeasure(measureNumber, in: viewModel) + let rest = try XCTUnwrap(measure.notationItems.first) + let placement = NotationNotePlacement( + measure: measure, + targetRestID: rest.id, + offsetInQuarterNotes: rest.offsetInQuarterNotes, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + pitch: pitch, + x: 0, + y: 0 + ) + XCTAssertTrue(viewModel.insertNotationNote(placement)) + return try XCTUnwrap(try notationMeasure(measureNumber, in: viewModel).notationItems.first) + } } diff --git a/JammLabTests/ViewModelNotationPasteTests.swift b/JammLabTests/ViewModelNotationPasteTests.swift index 3ad99d6..cb061c5 100644 --- a/JammLabTests/ViewModelNotationPasteTests.swift +++ b/JammLabTests/ViewModelNotationPasteTests.swift @@ -69,6 +69,86 @@ final class ViewModelNotationPasteTests: XCTestCase { }) } + @MainActor + func testPasteNotationMeasureReplacesTargetNotationItemsAndSupportsUndoRedo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let sourceMeasure = try notationMeasure(1, in: viewModel) + let targetMeasure = try notationMeasure(2, in: viewModel) + let pitch = NotationPitch(step: .e, octave: 4) + viewModel.notationItems = [ + NotationMeasureItem( + id: "source-note", + kind: .note, + pitch: pitch, + measureNumber: sourceMeasure.number, + measureStartTime: sourceMeasure.startTime, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "source-rest", + measureNumber: sourceMeasure.number, + measureStartTime: sourceMeasure.startTime, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ), + NotationMeasureItem( + id: "target-rest", + measureNumber: targetMeasure.number, + measureStartTime: targetMeasure.startTime, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 4, + displayDuration: NotationDuration(denominator: 1) + ) + ] + viewModel.markProjectClean() + + viewModel.selectNotationMeasure(try notationMeasure(1, in: viewModel)) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(try notationMeasure(2, in: viewModel)) + let beforePaste = viewModel.notationItems + viewModel.markProjectClean() + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let pastedMeasure = try notationMeasure(2, in: viewModel) + XCTAssertEqual(pastedMeasure.notationItems.map(\.kind), [.note, .rest, .rest]) + XCTAssertEqual(pastedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 1, 3]) + XCTAssertEqual(pastedMeasure.notationItems.map(\.durationInQuarterNotes), [1, 2, 1]) + XCTAssertEqual(pastedMeasure.notationItems.map(\.displayDuration.denominator), [4, 2, 4]) + XCTAssertEqual(pastedMeasure.notationItems.first?.pitch, pitch) + XCTAssertEqual(pastedMeasure.notationItems.map(\.measureNumber), [ + targetMeasure.number, + targetMeasure.number, + targetMeasure.number + ]) + XCTAssertEqual(pastedMeasure.notationItems.map(\.measureStartTime), [ + targetMeasure.startTime, + targetMeasure.startTime, + targetMeasure.startTime + ]) + XCTAssertFalse(pastedMeasure.notationItems.map(\.id).contains("source-note")) + XCTAssertFalse(pastedMeasure.notationItems.map(\.id).contains("source-rest")) + XCTAssertFalse(pastedMeasure.notationItems.map(\.id).contains("target-rest")) + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.undoLastEdit() + + XCTAssertEqual(viewModel.notationItems, beforePaste) + XCTAssertFalse(viewModel.isProjectModified) + + viewModel.redoLastEdit() + + let redoneMeasure = try notationMeasure(2, in: viewModel) + XCTAssertEqual(redoneMeasure.notationItems.map(\.kind), [.note, .rest, .rest]) + XCTAssertEqual(redoneMeasure.notationItems.first?.pitch, pitch) + XCTAssertTrue(viewModel.isProjectModified) + } + @MainActor func testPasteNotationMeasureSkipsOffsetsOutsideTargetTimeSignature() throws { let viewModel = try loadedNotationViewModel(duration: 8) diff --git a/JammLabTests/ViewModelNotationSelectionTests.swift b/JammLabTests/ViewModelNotationSelectionTests.swift index fcb2284..9b70816 100644 --- a/JammLabTests/ViewModelNotationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationSelectionTests.swift @@ -47,6 +47,89 @@ final class ViewModelNotationSelectionTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } + @MainActor + func testSelectingNotationNoteAuditionsWhenRequested() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let pitch = NotationPitch(step: .e, octave: 4) + viewModel.notationItems = [ + NotationMeasureItem( + id: "selected-note", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + viewModel.markProjectClean() + let measure = try notationMeasure(1, in: viewModel) + let note = try XCTUnwrap(measure.notationItems.first { $0.id == "selected-note" }) + let rest = try XCTUnwrap(measure.notationItems.first { $0.id == "rest" }) + + viewModel.selectNotationItem( + NotationItemSelection(measure: measure, item: note), + shouldAudition: true + ) + viewModel.selectNotationItem( + NotationItemSelection(measure: measure, item: note), + shouldAudition: true + ) + viewModel.selectNotationItem( + NotationItemSelection(measure: measure, item: rest), + shouldAudition: true + ) + viewModel.clearNotationItemSelection() + + XCTAssertEqual(auditioner.auditionedPitches, [pitch, pitch]) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testSelectingNotationNoteDoesNotAuditionWhenSuppressed() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + viewModel.notationItems = [ + NotationMeasureItem( + id: "selected-note", + kind: .note, + pitch: NotationPitch(step: .e, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + viewModel.markProjectClean() + let measure = try notationMeasure(1, in: viewModel) + let note = try XCTUnwrap(measure.notationItems.first { $0.id == "selected-note" }) + + viewModel.selectNotationItem( + NotationItemSelection(measure: measure, item: note), + shouldAudition: false + ) + + XCTAssertTrue(auditioner.auditionedPitches.isEmpty) + XCTAssertFalse(viewModel.isProjectModified) + } + @MainActor func testRequestEditSelectedNotationItemUsesExactItemOffsetAndExistingHarmony() throws { let viewModel = try loadedNotationViewModel(duration: 8) @@ -184,14 +267,16 @@ extension XCTestCase { @MainActor func loadedNotationViewModel( duration: TimeInterval, - playbackEngine: MockPlaybackEngine? = nil + playbackEngine: MockPlaybackEngine? = nil, + notationNoteAuditioner: NotationNoteAuditioning? = nil ) throws -> AudioPlayerViewModel { let audioURL = try temporaryAudioFile(duration: duration) let playbackEngine = playbackEngine ?? MockPlaybackEngine() let viewModel = AudioPlayerViewModel( analyzer: MockAnalyzer(), peakformProvider: MockPeakformProvider(), - playbackEngine: playbackEngine + playbackEngine: playbackEngine, + notationNoteAuditioner: notationNoteAuditioner ?? NoopNotationNoteAuditioner() ) let media = ImportedAudioFile(url: audioURL, displayName: "notation.wav", duration: duration) try viewModel.loadImportedAudio(media) From b516d03acf9e5a917220e7a2078e79b2f1aeb682 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Sat, 11 Jul 2026 16:45:43 +0300 Subject: [PATCH 02/12] feat(notation): add ledger note range and rest entry mode --- AGENTS.md | 45 +++ CHANGELOG.md | 1 + JammLab/ContentView.swift | 6 +- JammLab/Models/NotationScoreModels.swift | 21 +- .../NotationNotePlacementResolver.swift | 233 ++++++++++++---- .../AudioPlayerViewModel+Notation.swift | 117 ++++++-- .../AudioPlayerViewModel+Project.swift | 2 +- JammLab/ViewModels/AudioPlayerViewModel.swift | 7 +- JammLab/Views/Components/AppControls.swift | 83 ++++++ JammLab/Views/MainWorkspacePanels.swift | 4 +- JammLab/Views/NotationTrackView.swift | 258 +++++++++++++++--- JammLab/Views/NotationWindowView.swift | 37 ++- JammLab/Views/WaveformTimelineView.swift | 50 +++- JammLabTests/NotationPrimitivesTests.swift | 237 +++++++++++++++- ...wModelNotationDurationSelectionTests.swift | 238 +++++++++++++++- .../ViewModelNotationSelectionTests.swift | 48 ++++ 16 files changed, 1226 insertions(+), 161 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 70cb36f..295e20c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,3 +116,48 @@ Do not use vague names such as `ai-work`, `codex-fix`, `changes`, `update`, or ` - Audio access uses security-scoped bookmarks. - Recent projects are stored in user defaults and capped to 10 entries. - Project load must normalize/clamp duration-dependent state: beat grid, loop range, notes, regions, playback rate, pitch shift, and tempo. + +## Documentation lookup + +When working with Swift, SwiftUI, AppKit, AVFoundation, AVAudioEngine, CoreAudio, macOS sandboxing, GitHub Actions, or packaging, use Context7 to check current documentation before proposing or implementing changes. + +Prefer Context7 documentation lookup for: +- Swift language APIs +- SwiftUI macOS components +- AppKit interoperability +- AVFoundation and AVAudioEngine +- macOS sandboxing and entitlements +- GitHub Actions and release packaging + +When documentation is relevant, mention briefly which API or library was checked. + +## Test review workflow + +For implementation tasks that create, modify, rename, or delete source files, test files, project files, or behavior-affecting configuration: + +1. After the implementation is complete and before the final report, spawn the `test_reviewer` subagent. +2. The `test_reviewer` subagent must be read-only and must not edit files. +3. Ask `test_reviewer` to review: + - missing tests; + - weak or misleading tests; + - likely regressions not covered by tests; + - flaky or timing-sensitive tests; + - async/concurrency test risks; + - whether existing tests need updates. +4. Wait for the review result. +5. Address valid test reviewer findings before the final report when the fix is in scope. +6. If a reviewer finding is rejected or deferred, explain why briefly in the final report. + +Use `test_reviewer` especially for changes involving: +- Swift model or business logic; +- SwiftUI state behavior; +- AVAudioEngine, AVFoundation, CoreAudio, playback, timing, waveform rendering, BPM/key detection, or metronome logic; +- MusicXML, notation, harmony, markers, regions, beats, measures, or time-signature logic; +- persistence, project loading/saving, import/export, or migrations; +- bug fixes; +- refactoring that could change behavior. + +Do not spawn `test_reviewer` for pure analysis-only tasks, DISCUSS mode, documentation-only edits, comment-only edits, or formatting-only changes unless tests are explicitly part of the request. + +The main agent owns the final decision. +The main agent must not silently ignore `test_reviewer` findings. diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d4e48..879fcee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased - Added Notation note-entry mode for placing pitched notes with Leland-rendered note glyphs, note selection preview, cross-rest insertion, rest recomposition, and MusicXML export. +- Expanded Notation entry to ledger-line pitches from G3 through D6 and added rest-entry controls for inserting selected rest durations. - Added Notation note editing for dragging note pitch, arrow-key pitch changes, and deleting selected notes back to rests. - Added clearer hover tooltips and numpad shortcuts for Notation duration buttons. - Added a bar/beat position readout to the transport time display. diff --git a/JammLab/ContentView.swift b/JammLab/ContentView.swift index a6e48ed..7221f0c 100644 --- a/JammLab/ContentView.swift +++ b/JammLab/ContentView.swift @@ -181,7 +181,7 @@ struct ContentView: View { if !viewModel.canPasteNotationMeasureClipboard { hotkeys.remove(.pasteMeasure) } - if !viewModel.hasSelectedNotationMeasures && !viewModel.isNotationNoteEntryModeEnabled { + if !viewModel.hasSelectedNotationMeasures && !viewModel.isNotationEntryModeEnabled { hotkeys.remove(.clearNotationMeasureSelection) } if !viewModel.canEditSelectedNotationItem { @@ -243,8 +243,8 @@ struct ContentView: View { case .pasteMeasure: return viewModel.pasteNotationMeasureClipboard() case .clearNotationMeasureSelection: - if viewModel.isNotationNoteEntryModeEnabled { - viewModel.clearNotationNoteEntryMode() + if viewModel.isNotationEntryModeEnabled { + viewModel.clearNotationEntryMode() } else { viewModel.clearNotationMeasureSelection() } diff --git a/JammLab/Models/NotationScoreModels.swift b/JammLab/Models/NotationScoreModels.swift index 0dcdbbb..97cc57d 100644 --- a/JammLab/Models/NotationScoreModels.swift +++ b/JammLab/Models/NotationScoreModels.swift @@ -385,6 +385,11 @@ struct NotationPitch: Codable, Equatable { enum NotationPitchMapper { private static let staffPitches: [NotationPitch] = [ + NotationPitch(step: .d, octave: 6), + NotationPitch(step: .c, octave: 6), + NotationPitch(step: .b, octave: 5), + NotationPitch(step: .a, octave: 5), + NotationPitch(step: .g, octave: 5), NotationPitch(step: .f, octave: 5), NotationPitch(step: .e, octave: 5), NotationPitch(step: .d, octave: 5), @@ -393,17 +398,23 @@ enum NotationPitchMapper { NotationPitch(step: .a, octave: 4), NotationPitch(step: .g, octave: 4), NotationPitch(step: .f, octave: 4), - NotationPitch(step: .e, octave: 4) + NotationPitch(step: .e, octave: 4), + NotationPitch(step: .d, octave: 4), + NotationPitch(step: .c, octave: 4), + NotationPitch(step: .b, octave: 3), + NotationPitch(step: .a, octave: 3), + NotationPitch(step: .g, octave: 3) ] - static var minimumStaffPosition: Int { 0 } - static var maximumStaffPosition: Int { staffPitches.count - 1 } + static let minimumStaffPosition = -5 + static let maximumStaffPosition = 13 static func pitch( forStaffPosition staffPosition: Int, keySignature: KeySignature ) -> NotationPitch { - let index = min(maximumStaffPosition, max(minimumStaffPosition, staffPosition)) + let clampedPosition = min(maximumStaffPosition, max(minimumStaffPosition, staffPosition)) + let index = clampedPosition - minimumStaffPosition var pitch = staffPitches[index] pitch.alter = keySignature.defaultAlter(for: pitch.step) return pitch @@ -412,6 +423,8 @@ enum NotationPitchMapper { static func staffPosition(for pitch: NotationPitch) -> Int? { staffPitches.firstIndex { $0.step == pitch.step && $0.octave == pitch.octave + }.map { + $0 + minimumStaffPosition } } diff --git a/JammLab/Services/NotationNotePlacementResolver.swift b/JammLab/Services/NotationNotePlacementResolver.swift index 902ddc7..f94a4fb 100644 --- a/JammLab/Services/NotationNotePlacementResolver.swift +++ b/JammLab/Services/NotationNotePlacementResolver.swift @@ -13,6 +13,16 @@ struct NotationNotePlacement: Equatable { var y: CGFloat } +struct NotationRestPlacement: Equatable { + var measure: ScoreMeasure + var targetRestID: String + var offsetInQuarterNotes: Double + var durationInQuarterNotes: Double + var displayDuration: NotationDuration + + var x: CGFloat +} + struct NotationRestSpan: Equatable { var rests: [NotationMeasureItem] var startOffsetInQuarterNotes: Double @@ -48,47 +58,12 @@ enum NotationNotePlacementResolver { let selectedLength = selectedDuration.durationInQuarterNotes guard selectedLength > NotationMeasureTiming.timelineTolerance else { return nil } - let targetRest: NotationMeasureItem - if let fullMeasureWholeRest = singleFullMeasureWholeRest(in: measure), - restSpan( - in: measure, - from: fullMeasureWholeRest, - requiredDurationInQuarterNotes: selectedLength - ) != nil { - targetRest = fullMeasureWholeRest - } else { - let rawProgress = NotationMeasureLayout.notationAnchorProgress( - atX: point.x, - geometry: geometry - ) - let rawOffset = rawProgress * NotationMeasureTiming.quarterLength(for: measure.attributes.timeSignature) - let eligibleRests = measure.notationItems - .filter { - $0.kind == .rest - && restSpan( - in: measure, - from: $0, - requiredDurationInQuarterNotes: selectedLength - ) != nil - } - guard !eligibleRests.isEmpty else { return nil } - - if let containingRest = eligibleRests.first(where: { - restContains(rawOffset, in: $0) - }) { - targetRest = containingRest - } else { - targetRest = eligibleRests.min { - let lhsDistance = abs($0.offsetInQuarterNotes - rawOffset) - let rhsDistance = abs($1.offsetInQuarterNotes - rawOffset) - if abs(lhsDistance - rhsDistance) > NotationMeasureTiming.timelineTolerance { - return lhsDistance < rhsDistance - } - - return $0.offsetInQuarterNotes < $1.offsetInQuarterNotes - } ?? eligibleRests[0] - } - } + guard let targetRest = targetRest( + in: measure, + geometry: geometry, + x: point.x, + requiredDurationInQuarterNotes: selectedLength + ) else { return nil } let pitch = NotationPitchMapper.pitch( forStaffPosition: pitchPosition, @@ -113,6 +88,43 @@ enum NotationNotePlacementResolver { ) } + static func restPlacement( + in measure: ScoreMeasure, + geometry: NotationMeasureCanvasGeometry, + point: CGPoint, + staffTop: CGFloat, + selectedDuration: NotationDuration, + lineSpacing: CGFloat = AppTheme.Timeline.notationStaffLineSpacing + ) -> NotationRestPlacement? { + guard isWithinEntryYRange(point.y, staffTop: staffTop, lineSpacing: lineSpacing) else { + return nil + } + + let selectedLength = selectedDuration.durationInQuarterNotes + guard selectedLength > NotationMeasureTiming.timelineTolerance else { return nil } + guard let targetRest = targetRest( + in: measure, + geometry: geometry, + x: point.x, + requiredDurationInQuarterNotes: selectedLength + ) else { return nil } + + let x = NotationMeasureLayout.harmonyX( + geometry: geometry, + offsetInQuarterNotes: targetRest.offsetInQuarterNotes, + timeSignature: measure.attributes.timeSignature + ) + + return NotationRestPlacement( + measure: measure, + targetRestID: targetRest.id, + offsetInQuarterNotes: targetRest.offsetInQuarterNotes, + durationInQuarterNotes: selectedLength, + displayDuration: selectedDuration, + x: x + ) + } + static func singleFullMeasureWholeRest(in measure: ScoreMeasure) -> NotationMeasureItem? { guard measure.notationItems.count == 1, let item = measure.notationItems.first, @@ -181,14 +193,40 @@ enum NotationNotePlacementResolver { static func restSpan( in measure: ScoreMeasure, matching placement: NotationNotePlacement + ) -> NotationRestSpan? { + restSpan( + in: measure, + targetRestID: placement.targetRestID, + offsetInQuarterNotes: placement.offsetInQuarterNotes, + requiredDurationInQuarterNotes: placement.durationInQuarterNotes + ) + } + + static func restSpan( + in measure: ScoreMeasure, + matching placement: NotationRestPlacement + ) -> NotationRestSpan? { + restSpan( + in: measure, + targetRestID: placement.targetRestID, + offsetInQuarterNotes: placement.offsetInQuarterNotes, + requiredDurationInQuarterNotes: placement.durationInQuarterNotes + ) + } + + private static func restSpan( + in measure: ScoreMeasure, + targetRestID: String, + offsetInQuarterNotes: Double, + requiredDurationInQuarterNotes requiredDuration: Double ) -> NotationRestSpan? { guard let targetRest = measure.notationItems.first(where: { $0.kind == .rest - && $0.id == placement.targetRestID - && abs($0.offsetInQuarterNotes - placement.offsetInQuarterNotes) < NotationMeasureTiming.timelineTolerance + && $0.id == targetRestID + && abs($0.offsetInQuarterNotes - offsetInQuarterNotes) < NotationMeasureTiming.timelineTolerance }) ?? measure.notationItems.first(where: { $0.kind == .rest - && abs($0.offsetInQuarterNotes - placement.offsetInQuarterNotes) < NotationMeasureTiming.timelineTolerance + && abs($0.offsetInQuarterNotes - offsetInQuarterNotes) < NotationMeasureTiming.timelineTolerance }) else { return nil } @@ -196,8 +234,68 @@ enum NotationNotePlacementResolver { return restSpan( in: measure, from: targetRest, - requiredDurationInQuarterNotes: placement.durationInQuarterNotes + requiredDurationInQuarterNotes: requiredDuration + ) + } + + static func ledgerLineStaffPositions(forStaffPosition staffPosition: Int) -> [Int] { + if staffPosition < 0 { + return stride(from: -2, through: staffPosition, by: -2).map { $0 } + } + + if staffPosition > 8 { + return stride(from: 10, through: staffPosition, by: 2).map { $0 } + } + + return [] + } + + private static func targetRest( + in measure: ScoreMeasure, + geometry: NotationMeasureCanvasGeometry, + x: CGFloat, + requiredDurationInQuarterNotes requiredDuration: Double + ) -> NotationMeasureItem? { + if let fullMeasureWholeRest = singleFullMeasureWholeRest(in: measure), + restSpan( + in: measure, + from: fullMeasureWholeRest, + requiredDurationInQuarterNotes: requiredDuration + ) != nil { + return fullMeasureWholeRest + } + + let rawProgress = NotationMeasureLayout.notationAnchorProgress( + atX: x, + geometry: geometry ) + let rawOffset = rawProgress * NotationMeasureTiming.quarterLength(for: measure.attributes.timeSignature) + let eligibleRests = measure.notationItems + .filter { + $0.kind == .rest + && restSpan( + in: measure, + from: $0, + requiredDurationInQuarterNotes: requiredDuration + ) != nil + } + guard !eligibleRests.isEmpty else { return nil } + + if let containingRest = eligibleRests.first(where: { + restContains(rawOffset, in: $0) + }) { + return containingRest + } + + return eligibleRests.min { + let lhsDistance = abs($0.offsetInQuarterNotes - rawOffset) + let rhsDistance = abs($1.offsetInQuarterNotes - rawOffset) + if abs(lhsDistance - rhsDistance) > NotationMeasureTiming.timelineTolerance { + return lhsDistance < rhsDistance + } + + return $0.offsetInQuarterNotes < $1.offsetInQuarterNotes + } ?? eligibleRests[0] } private static func restContains( @@ -223,12 +321,9 @@ enum NotationNotePlacementResolver { staffTop: CGFloat, lineSpacing: CGFloat = AppTheme.Timeline.notationStaffLineSpacing ) -> Int? { - guard lineSpacing > 0 else { return nil } - let halfSpacing = lineSpacing / 2 - let minimumY = staffTop - halfSpacing - let maximumY = staffTop + lineSpacing * 4 + halfSpacing - guard y >= minimumY, y <= maximumY else { return nil } + guard isWithinEntryYRange(y, staffTop: staffTop, lineSpacing: lineSpacing) else { return nil } + let halfSpacing = lineSpacing / 2 let rawPosition = ((y - staffTop) / halfSpacing).rounded() return min( NotationPitchMapper.maximumStaffPosition, @@ -257,29 +352,49 @@ enum NotationNotePlacementResolver { ) -> CGFloat { staffTop + CGFloat(staffPosition) * lineSpacing / 2 } + + private static func isWithinEntryYRange( + _ y: CGFloat, + staffTop: CGFloat, + lineSpacing: CGFloat + ) -> Bool { + guard lineSpacing > 0 else { return false } + let halfSpacing = lineSpacing / 2 + let minimumY = yPosition( + forStaffPosition: NotationPitchMapper.minimumStaffPosition, + staffTop: staffTop, + lineSpacing: lineSpacing + ) - halfSpacing + let maximumY = yPosition( + forStaffPosition: NotationPitchMapper.maximumStaffPosition, + staffTop: staffTop, + lineSpacing: lineSpacing + ) + halfSpacing + return y >= minimumY && y <= maximumY + } } -enum NotationNoteEntryRecomposer { +enum NotationEntryRecomposer { static func recomposedItems( in measure: ScoreMeasure, replacing restSpan: NotationRestSpan, - with noteItem: NotationMeasureItem + with insertedItem: NotationMeasureItem ) -> [NotationMeasureItem] { let orderedItems = measure.notationItems.sorted(by: notationItemSort) var output: [NotationMeasureItem] = [] - var didInsertNote = false + var didInsertItem = false for item in orderedItems { if restSpan.contains(item) { - if !didInsertNote { - output.append(noteItem) - let noteEnd = noteItem.offsetInQuarterNotes + noteItem.durationInQuarterNotes + if !didInsertItem { + output.append(insertedItem) + let insertedItemEnd = insertedItem.offsetInQuarterNotes + insertedItem.durationInQuarterNotes output.append(contentsOf: metricAwareFillerNotationItems( measure: measure, - startOffset: noteEnd, - remaining: restSpan.endOffsetInQuarterNotes - noteEnd + startOffset: insertedItemEnd, + remaining: restSpan.endOffsetInQuarterNotes - insertedItemEnd )) - didInsertNote = true + didInsertItem = true } continue } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift index b6f6ffb..56077e2 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift @@ -6,7 +6,7 @@ extension AudioPlayerViewModel { } var canChangeNotationDuration: Bool { - duration > 0 && (isNotationNoteEntryModeEnabled || canEditSelectedNotationItem) + duration > 0 && (isNotationEntryModeEnabled || canEditSelectedNotationItem) } var canChangeSelectedNotationNotePitch: Bool { @@ -16,20 +16,48 @@ extension AudioPlayerViewModel { func setNotationDurationDenominator(_ denominator: Int) { notationDurationDenominator = NotationDuration.normalizedDenominator(denominator) - guard !isNotationNoteEntryModeEnabled else { return } + guard !isNotationEntryModeEnabled else { return } changeSelectedNotationItemDuration(to: NotationDuration(denominator: notationDurationDenominator)) } + var isNotationEntryModeEnabled: Bool { + notationEntryMode != nil + } + + var isNotationNoteEntryModeEnabled: Bool { + notationEntryMode == .note + } + + var isNotationRestEntryModeEnabled: Bool { + notationEntryMode == .rest + } + func toggleNotationNoteEntryMode() { setNotationNoteEntryModeEnabled(!isNotationNoteEntryModeEnabled) } func setNotationNoteEntryModeEnabled(_ isEnabled: Bool) { - let resolvedIsEnabled = isEnabled && duration > 0 - guard isNotationNoteEntryModeEnabled != resolvedIsEnabled else { return } + setNotationEntryMode(isEnabled ? .note : nil) + } + + func toggleNotationRestEntryMode() { + setNotationEntryMode(isNotationRestEntryModeEnabled ? nil : .rest) + } + + func setNotationRestEntryModeEnabled(_ isEnabled: Bool) { + setNotationEntryMode(isEnabled ? .rest : nil) + } + + func clearNotationEntryMode() { + setNotationEntryMode(nil) + } + + private func setNotationEntryMode(_ mode: NotationEntryMode?) { + let resolvedMode = duration > 0 ? mode : nil + guard notationEntryMode != resolvedMode else { return } - isNotationNoteEntryModeEnabled = resolvedIsEnabled - if resolvedIsEnabled { + notationEntryMode = resolvedMode + if resolvedMode != nil { selectedNotationMeasures = [] notationMeasureSelectionAnchor = nil selectedHarmonySymbolID = nil @@ -39,7 +67,7 @@ extension AudioPlayerViewModel { } func clearNotationNoteEntryMode() { - setNotationNoteEntryModeEnabled(false) + clearNotationEntryMode() } @discardableResult @@ -80,7 +108,7 @@ extension AudioPlayerViewModel { selectedNotationItem = canonicalSelection selectedNotationMeasures = [] notationMeasureSelectionAnchor = nil - isNotationNoteEntryModeEnabled = false + notationEntryMode = nil if let placement = harmonyPlacement(for: canonicalSelection) { selectedHarmonySymbolID = harmonySymbolID(at: placement.harmonyPlacement.time) } else { @@ -137,7 +165,7 @@ extension AudioPlayerViewModel { } selectedHarmonySymbolID = nil selectedNotationItem = nil - isNotationNoteEntryModeEnabled = false + notationEntryMode = nil locatePlaybackMarkerAtFirstSelectedNotationMeasure() } @@ -170,7 +198,7 @@ extension AudioPlayerViewModel { durationInQuarterNotes: placement.durationInQuarterNotes, displayDuration: placement.displayDuration ) - let recomposedItems = NotationNoteEntryRecomposer.recomposedItems( + let recomposedItems = NotationEntryRecomposer.recomposedItems( in: measure, replacing: restSpan, with: noteItem @@ -181,21 +209,72 @@ extension AudioPlayerViewModel { selectedNotationMeasures = [] notationMeasureSelectionAnchor = nil selectedHarmonySymbolID = nil - - if let updatedMeasure = currentNotationScoreMeasures().first(where: { - $0.number == measure.number - && abs($0.startTime - measure.startTime) < NotationMeasureTiming.timelineTolerance - }), let selected = updatedMeasure.notationItems.first(where: { $0.id == noteItem.id }) { - selectedNotationItem = NotationItemSelection(measure: updatedMeasure, item: selected) - } else { - selectedNotationItem = nil - } + reselectNotationItem( + inMeasureNumber: measure.number, + measureStartTime: measure.startTime, + itemID: noteItem.id + ) } try? notationNoteAuditioner.audition(pitch: placement.pitch) return true } + @discardableResult + func insertNotationRest(_ placement: NotationRestPlacement) -> Bool { + guard duration > 0, + let measure = currentNotationScoreMeasures().first(where: { + $0.number == placement.measure.number + && abs($0.startTime - placement.measure.startTime) < NotationMeasureTiming.timelineTolerance + && abs($0.endTime - placement.measure.endTime) < NotationMeasureTiming.timelineTolerance + }), + let restSpan = NotationNotePlacementResolver.restSpan(in: measure, matching: placement), + let targetRest = restSpan.rests.first + else { + return false + } + + let restItem = NotationMeasureItem( + kind: .rest, + measureNumber: measure.number, + measureStartTime: measure.startTime, + offsetInQuarterNotes: restSpan.startOffsetInQuarterNotes, + durationInQuarterNotes: placement.durationInQuarterNotes, + displayDuration: placement.displayDuration + ) + + if !targetRest.isSynthesized, + abs(targetRest.offsetInQuarterNotes - restItem.offsetInQuarterNotes) < NotationMeasureTiming.timelineTolerance, + abs(targetRest.durationInQuarterNotes - restItem.durationInQuarterNotes) < NotationMeasureTiming.timelineTolerance, + targetRest.displayDuration == restItem.displayDuration { + selectedNotationMeasures = [] + notationMeasureSelectionAnchor = nil + selectedHarmonySymbolID = nil + selectedNotationItem = NotationItemSelection(measure: measure, item: targetRest) + return true + } + + let recomposedItems = NotationEntryRecomposer.recomposedItems( + in: measure, + replacing: restSpan, + with: restItem + ) + + performUndoableEdit("Add Notation Rest") { + replaceNotationMeasureItems(in: measure, with: recomposedItems) + selectedNotationMeasures = [] + notationMeasureSelectionAnchor = nil + selectedHarmonySymbolID = nil + reselectNotationItem( + inMeasureNumber: measure.number, + measureStartTime: measure.startTime, + itemID: restItem.id + ) + } + + return true + } + @discardableResult func changeSelectedNotationNotePitch( to pitch: NotationPitch, diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift index 0dd028a..e19ce28 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift @@ -304,7 +304,7 @@ extension AudioPlayerViewModel { clearNotationMeasureSelectionAndClipboard() pendingHarmonyEditorRequest = nil notationDurationDenominator = NotationDuration.defaultDenominator - isNotationNoteEntryModeEnabled = false + notationEntryMode = nil activeLoopRegionID = nil } diff --git a/JammLab/ViewModels/AudioPlayerViewModel.swift b/JammLab/ViewModels/AudioPlayerViewModel.swift index f1b5b5c..9935caa 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel.swift @@ -22,6 +22,11 @@ enum PlaybackState: Equatable { } } +enum NotationEntryMode: Equatable { + case note + case rest +} + @MainActor final class AudioPlayerViewModel: ObservableObject { @Published var importedFile: ImportedAudioFile? @@ -46,7 +51,7 @@ final class AudioPlayerViewModel: ObservableObject { @Published var notationMeasureClipboard: NotationMeasureClipboard? @Published var notationDurationDenominator = NotationDuration.defaultDenominator @Published var notationItems: [NotationMeasureItem] = [] - @Published var isNotationNoteEntryModeEnabled = false + @Published var notationEntryMode: NotationEntryMode? @Published var pendingHarmonyEditorRequest: HarmonyEditorRequest? @Published var activeLoopRegionID: TimecodedNote.ID? @Published var loopRegion: LoopRegion = .empty diff --git a/JammLab/Views/Components/AppControls.swift b/JammLab/Views/Components/AppControls.swift index 48a84ce..b80402d 100644 --- a/JammLab/Views/Components/AppControls.swift +++ b/JammLab/Views/Components/AppControls.swift @@ -83,6 +83,61 @@ struct NotationDurationControl: View { } } +struct NotationEntryModeButton: View { + let mode: NotationEntryMode + let selectedDuration: NotationDuration + let isActive: Bool + let action: () -> Void + @Environment(\.isEnabled) private var isEnabled + @Environment(\.appColors) private var appColors + + var body: some View { + Button(action: action) { + ZStack { + switch mode { + case .note: + Image(systemName: "pencil") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(iconColor) + case .rest: + if let symbol = NotationSMuFLSymbol(duration: selectedDuration) { + NotationRestControlGlyphView( + symbol: symbol, + color: iconColor + ) + } + } + } + .frame( + width: AppTheme.ControlSize.notationDurationButtonWidth, + height: AppTheme.ControlSize.notationDurationControlHeight + ) + .background(backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.Radius.small, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: AppTheme.Radius.small, style: .continuous) + .stroke(borderColor, lineWidth: AppTheme.Stroke.thin) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(isEnabled ? 1 : 0.45) + } + + private var iconColor: Color { + guard isEnabled else { return appColors.disabledText } + return isActive ? appColors.primaryText : appColors.secondaryText + } + + private var backgroundColor: Color { + isActive ? appColors.accent : appColors.statusButtonFill + } + + private var borderColor: Color { + isActive ? appColors.accent : appColors.border + } +} + private struct NotationDurationGlyphView: View { let symbol: NotationDurationControlSymbol let color: Color @@ -111,6 +166,34 @@ private struct NotationDurationGlyphView: View { } } +private struct NotationRestControlGlyphView: View { + let symbol: NotationSMuFLSymbol + let color: Color + + var body: some View { + ZStack { + if let glyphPath = NotationMusicFontRegistry.glyphPath( + for: symbol, + fontSize: AppTheme.ControlSize.notationDurationGlyphSize + ) { + Canvas { context, size in + context.fill( + Path(glyphPath.path).applying(glyphPath.centeredTransform(in: size)), + with: .color(color) + ) + } + } else { + Text(symbol.glyph) + .font(.custom( + NotationMusicFontRegistry.fontName, + size: AppTheme.ControlSize.notationDurationGlyphSize + )) + .foregroundStyle(color) + } + } + } +} + private struct NotationDurationOption: Identifiable { let denominator: Int let duration: NotationDuration diff --git a/JammLab/Views/MainWorkspacePanels.swift b/JammLab/Views/MainWorkspacePanels.swift index 1f9557d..9f342f5 100644 --- a/JammLab/Views/MainWorkspacePanels.swift +++ b/JammLab/Views/MainWorkspacePanels.swift @@ -147,7 +147,7 @@ extension ContentView { : notationViewportState(availableWidth: notationTrackContentWidth), notationDurationDenominator: viewModel.notationDurationDenominator, canChangeNotationDuration: viewModel.canChangeNotationDuration, - isNotationNoteEntryModeEnabled: viewModel.isNotationNoteEntryModeEnabled, + notationEntryMode: viewModel.notationEntryMode, isNotationTrackCollapsed: viewModel.isNotationTrackCollapsed, isLoadingPeakform: viewModel.isBuildingWaveform, mainTrackVolume: viewModel.mainTrackVolume, @@ -224,7 +224,9 @@ extension ContentView { notationTrackCollapsedChanged: { viewModel.setNotationTrackCollapsed($0) }, notationDurationChanged: { viewModel.setNotationDurationDenominator($0) }, notationNoteEntryModeToggled: { viewModel.toggleNotationNoteEntryMode() }, + notationRestEntryModeToggled: { viewModel.toggleNotationRestEntryMode() }, insertNotationNote: { viewModel.insertNotationNote($0) }, + insertNotationRest: { viewModel.insertNotationRest($0) }, changeSelectedNotePitch: { viewModel.changeSelectedNotationNotePitch(to: $0, shouldAudition: $1) }, auditionNotePitch: { viewModel.auditionNotationNotePitch($0) }, deleteSelectedNotationNote: { viewModel.deleteSelectedNotationNote() }, diff --git a/JammLab/Views/NotationTrackView.swift b/JammLab/Views/NotationTrackView.swift index 25fd0cd..042ae18 100644 --- a/JammLab/Views/NotationTrackView.swift +++ b/JammLab/Views/NotationTrackView.swift @@ -8,6 +8,7 @@ struct NotationTrackActions { var selectMeasure: (ScoreMeasure?, Bool) -> Void var selectItem: (NotationItemSelection?, Bool) -> Void var insertNotationNote: (NotationNotePlacement) -> Bool + var insertNotationRest: (NotationRestPlacement) -> Bool var changeSelectedNotePitch: (NotationPitch, Bool) -> Bool var auditionNotePitch: (NotationPitch) -> Void var deleteSelectedNotationNote: () -> Bool @@ -24,7 +25,7 @@ struct NotationTrackView: View { let selectedMeasures: [NotationMeasureSelection] let selectedItem: NotationItemSelection? let selectedDuration: NotationDuration - let isNoteEntryModeEnabled: Bool + let entryMode: NotationEntryMode? let pendingEditorRequest: HarmonyEditorRequest? let actions: NotationTrackActions let cornerRadius: CGFloat @@ -33,6 +34,7 @@ struct NotationTrackView: View { @FocusState private var isTrackFocused: Bool @State private var editingDraft: HarmonyEditorDraft? @State private var hoveredNotePlacement: NotationNotePlacement? + @State private var hoveredRestPlacement: NotationRestPlacement? @State private var draggedNotePitchPreview: NotationDraggedNotePitchPreview? init( @@ -42,7 +44,7 @@ struct NotationTrackView: View { selectedMeasures: [NotationMeasureSelection] = [], selectedItem: NotationItemSelection? = nil, selectedDuration: NotationDuration = NotationDuration(), - isNoteEntryModeEnabled: Bool = false, + entryMode: NotationEntryMode? = nil, pendingEditorRequest: HarmonyEditorRequest? = nil, actions: NotationTrackActions = .noop, cornerRadius: CGFloat = AppTheme.Radius.small @@ -53,7 +55,7 @@ struct NotationTrackView: View { self.selectedMeasures = selectedMeasures self.selectedItem = selectedItem self.selectedDuration = selectedDuration - self.isNoteEntryModeEnabled = isNoteEntryModeEnabled + self.entryMode = entryMode self.pendingEditorRequest = pendingEditorRequest self.actions = actions self.cornerRadius = cornerRadius @@ -119,7 +121,7 @@ struct NotationTrackView: View { height: proxy.size.height, attributeDisplays: attributeDisplays ) - noteEntryLayer( + notationEntryLayer( width: contentWidth, height: proxy.size.height, attributeDisplays: attributeDisplays @@ -142,9 +144,14 @@ struct NotationTrackView: View { .onChange(of: pendingEditorRequest?.id) { _, _ in handlePendingEditorRequest() } - .onChange(of: isNoteEntryModeEnabled) { _, isEnabled in + .onChange(of: entryMode) { _, mode in draggedNotePitchPreview = nil - if !isEnabled { + if mode == nil { + hoveredNotePlacement = nil + hoveredRestPlacement = nil + } else if mode == .note { + hoveredRestPlacement = nil + } else { hoveredNotePlacement = nil } } @@ -164,6 +171,10 @@ struct NotationTrackView: View { max(1, state.visibleMeasures.isEmpty ? state.visibleMeasureCount : state.visibleMeasures.count) } + private var isEntryModeEnabled: Bool { + entryMode != nil + } + private var visibleAttributeDisplays: [NotationAttributeDisplay] { state.visibleMeasures.indices.map { index in let previousAttributes = index > 0 ? state.visibleMeasures[index - 1].attributes : nil @@ -308,13 +319,11 @@ struct NotationTrackView: View { else { continue } - drawNoteGlyph( + drawNoteGlyphWithLedgerLines( duration: item.notationItem.displayDuration, x: item.x, - y: NotationNotePlacementResolver.yPosition( - forStaffPosition: staffPosition, - staffTop: staffTop - ), + staffPosition: staffPosition, + staffTop: staffTop, color: color, opacity: 1, in: &context @@ -323,17 +332,85 @@ struct NotationTrackView: View { } if let hoveredNotePlacement { - drawNoteGlyph( - duration: hoveredNotePlacement.displayDuration, - x: hoveredNotePlacement.x, - y: hoveredNotePlacement.y, - color: appColors.accent, - opacity: 0.56, + if let staffPosition = NotationPitchMapper.staffPosition(for: hoveredNotePlacement.pitch) { + drawNoteGlyphWithLedgerLines( + duration: hoveredNotePlacement.displayDuration, + x: hoveredNotePlacement.x, + staffPosition: staffPosition, + staffTop: staffTop, + color: appColors.accent, + opacity: 0.56, + in: &context + ) + } + } else if let hoveredRestPlacement, + let symbol = NotationSMuFLSymbol(duration: hoveredRestPlacement.displayDuration) { + drawRestGlyph( + symbol: symbol, + x: hoveredRestPlacement.x, + staffTop: staffTop, + color: appColors.accent.opacity(0.56), in: &context ) } } + private func drawNoteGlyphWithLedgerLines( + duration: NotationDuration, + x: CGFloat, + staffPosition: Int, + staffTop: CGFloat, + color: Color, + opacity: Double, + in context: inout GraphicsContext + ) { + drawLedgerLines( + staffPosition: staffPosition, + x: x, + staffTop: staffTop, + color: color, + opacity: opacity, + in: &context + ) + drawNoteGlyph( + duration: duration, + x: x, + y: NotationNotePlacementResolver.yPosition( + forStaffPosition: staffPosition, + staffTop: staffTop + ), + color: color, + opacity: opacity, + in: &context + ) + } + + private func drawLedgerLines( + staffPosition: Int, + x: CGFloat, + staffTop: CGFloat, + color: Color, + opacity: Double, + in context: inout GraphicsContext + ) { + let spacing = AppTheme.Timeline.notationStaffLineSpacing + let halfWidth = spacing * 1.28 + for ledgerPosition in NotationNotePlacementResolver.ledgerLineStaffPositions(forStaffPosition: staffPosition) { + let y = NotationNotePlacementResolver.yPosition( + forStaffPosition: ledgerPosition, + staffTop: staffTop + ) + var path = Path() + path.move(to: CGPoint(x: x - halfWidth, y: y)) + path.addLine(to: CGPoint(x: x + halfWidth, y: y)) + context.stroke( + path, + with: .color(color.opacity(opacity)), + lineWidth: AppTheme.Stroke.thin + ) + } + } + private func drawNoteGlyph( duration: NotationDuration, x: CGFloat, @@ -553,7 +630,6 @@ struct NotationTrackView: View { attributeDisplays: attributeDisplays ) let staffTop = staffTop(in: height) - let hitY = max(0, staffTop - AppTheme.Spacing.sm) let hitHeight = AppTheme.Timeline.notationStaffLineSpacing * 4 + AppTheme.Spacing.md let hitWidth = max( AppTheme.ControlSize.abletonNumberFieldHeight, @@ -562,13 +638,14 @@ struct NotationTrackView: View { return ZStack(alignment: .topLeading) { ForEach(notationItemLayoutItems(geometries: geometries), id: \.id) { item in + let hitCenterY = notationItemHitCenterY(item, staffTop: staffTop) Rectangle() .fill(Color.clear) .contentShape(Rectangle()) .frame(width: hitWidth, height: hitHeight) .offset( x: item.x - hitWidth / 2, - y: hitY + y: max(0, hitCenterY - hitHeight / 2) ) .onTapGesture { isTrackFocused = true @@ -590,13 +667,32 @@ struct NotationTrackView: View { } } + private func notationItemHitCenterY( + _ item: NotationItemLayoutItem, + staffTop: CGFloat + ) -> CGFloat { + if item.notationItem.kind == .note, + let pitch = item.notationItem.pitch, + let staffPosition = NotationPitchMapper.staffPosition(for: pitch) { + return NotationNotePlacementResolver.yPosition( + forStaffPosition: staffPosition, + staffTop: staffTop + ) + } + + return restGlyphY( + symbol: NotationSMuFLSymbol(duration: item.notationItem.displayDuration) ?? .restQuarter, + staffTop: staffTop + ) + } + @ViewBuilder - private func noteEntryLayer( + private func notationEntryLayer( width: CGFloat, height: CGFloat, attributeDisplays: [NotationAttributeDisplay] ) -> some View { - if isNoteEntryModeEnabled { + if let entryMode { Rectangle() .fill(Color.clear) .contentShape(Rectangle()) @@ -604,14 +700,16 @@ struct NotationTrackView: View { .onContinuousHover { phase in switch phase { case .active(let point): - hoveredNotePlacement = notePlacement( - at: point, + updateHoveredEntryPlacement( + mode: entryMode, + point: point, width: width, height: height, attributeDisplays: attributeDisplays ) case .ended: hoveredNotePlacement = nil + hoveredRestPlacement = nil } } .gesture( @@ -619,20 +717,62 @@ struct NotationTrackView: View { .onEnded { value in isTrackFocused = true editingDraft = nil - guard let placement = notePlacement( - at: value.location, - width: width, - height: height, - attributeDisplays: attributeDisplays - ) else { return } - if actions.insertNotationNote(placement) { - hoveredNotePlacement = placement + switch entryMode { + case .note: + guard let placement = notePlacement( + at: value.location, + width: width, + height: height, + attributeDisplays: attributeDisplays + ) else { return } + if actions.insertNotationNote(placement) { + hoveredNotePlacement = placement + hoveredRestPlacement = nil + } + case .rest: + guard let placement = restPlacement( + at: value.location, + width: width, + height: height, + attributeDisplays: attributeDisplays + ) else { return } + if actions.insertNotationRest(placement) { + hoveredNotePlacement = nil + hoveredRestPlacement = placement + } } } ) .cursor(.crosshair) - .help("Add notation note") - .accessibilityLabel("Notation note entry area") + .help(entryMode == .note ? "Add notation note" : "Add notation rest") + .accessibilityLabel(entryMode == .note ? "Notation note entry area" : "Notation rest entry area") + } + } + + private func updateHoveredEntryPlacement( + mode: NotationEntryMode, + point: CGPoint, + width: CGFloat, + height: CGFloat, + attributeDisplays: [NotationAttributeDisplay] + ) { + switch mode { + case .note: + hoveredNotePlacement = notePlacement( + at: point, + width: width, + height: height, + attributeDisplays: attributeDisplays + ) + hoveredRestPlacement = nil + case .rest: + hoveredNotePlacement = nil + hoveredRestPlacement = restPlacement( + at: point, + width: width, + height: height, + attributeDisplays: attributeDisplays + ) } } @@ -642,7 +782,7 @@ struct NotationTrackView: View { ) -> some Gesture { DragGesture(minimumDistance: 3, coordinateSpace: .named(notationTrackCoordinateSpaceName)) .onChanged { value in - guard !isNoteEntryModeEnabled, + guard !isEntryModeEnabled, item.notationItem.kind == .note, let pitch = notePitch( forDragY: value.location.y, @@ -1339,6 +1479,47 @@ struct NotationTrackView: View { height: CGFloat, attributeDisplays: [NotationAttributeDisplay] ) -> NotationNotePlacement? { + guard let target = notationEntryTarget( + at: point, + width: width, + attributeDisplays: attributeDisplays + ) else { return nil } + + return NotationNotePlacementResolver.placement( + in: target.measure, + geometry: target.geometry, + point: point, + staffTop: staffTop(in: height), + selectedDuration: selectedDuration + ) + } + + private func restPlacement( + at point: CGPoint, + width: CGFloat, + height: CGFloat, + attributeDisplays: [NotationAttributeDisplay] + ) -> NotationRestPlacement? { + guard let target = notationEntryTarget( + at: point, + width: width, + attributeDisplays: attributeDisplays + ) else { return nil } + + return NotationNotePlacementResolver.restPlacement( + in: target.measure, + geometry: target.geometry, + point: point, + staffTop: staffTop(in: height), + selectedDuration: selectedDuration + ) + } + + private func notationEntryTarget( + at point: CGPoint, + width: CGFloat, + attributeDisplays: [NotationAttributeDisplay] + ) -> (measure: ScoreMeasure, geometry: NotationMeasureCanvasGeometry)? { guard renderedMeasureCount > 0, !state.visibleMeasures.isEmpty else { return nil } let geometries = measureCanvasGeometries( measureCount: renderedMeasureCount, @@ -1355,13 +1536,7 @@ struct NotationTrackView: View { let measureIndex = min(max(0, geometryIndex), state.visibleMeasures.count - 1) guard geometries.indices.contains(measureIndex) else { return nil } - return NotationNotePlacementResolver.placement( - in: state.visibleMeasures[measureIndex], - geometry: geometries[measureIndex], - point: point, - staffTop: staffTop(in: height), - selectedDuration: selectedDuration - ) + return (measure: state.visibleMeasures[measureIndex], geometry: geometries[measureIndex]) } private var accessibilityValue: String { @@ -1493,6 +1668,7 @@ private extension NotationTrackActions { selectMeasure: { _, _ in }, selectItem: { _, _ in }, insertNotationNote: { _ in false }, + insertNotationRest: { _ in false }, changeSelectedNotePitch: { _, _ in false }, auditionNotePitch: { _ in }, deleteSelectedNotationNote: { false }, diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index 9e7c7de..0c69a06 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -55,6 +55,18 @@ struct NotationWindowView: View { private var header: some View { HStack(spacing: AppTheme.Spacing.md) { + NotationEntryModeButton( + mode: .note, + selectedDuration: NotationDuration(denominator: viewModel.notationDurationDenominator), + isActive: viewModel.isNotationNoteEntryModeEnabled + ) { + viewModel.toggleNotationNoteEntryMode() + } + .disabled(viewModel.duration <= 0) + .help("Add notes to Notation (N)") + .accessibilityLabel("Notation Note Entry") + .accessibilityValue(viewModel.isNotationNoteEntryModeEnabled ? "Enabled" : "Disabled") + NotationDurationControl( denominator: Binding( get: { viewModel.notationDurationDenominator }, @@ -63,17 +75,17 @@ struct NotationWindowView: View { isEnabled: viewModel.canChangeNotationDuration ) - AppControlButton( - title: "Note Entry", - systemImage: "music.note", - isActive: viewModel.isNotationNoteEntryModeEnabled + NotationEntryModeButton( + mode: .rest, + selectedDuration: NotationDuration(denominator: viewModel.notationDurationDenominator), + isActive: viewModel.isNotationRestEntryModeEnabled ) { - viewModel.toggleNotationNoteEntryMode() + viewModel.toggleNotationRestEntryMode() } .disabled(viewModel.duration <= 0) - .help("Add notes to Notation (N)") - .accessibilityLabel("Notation Note Entry") - .accessibilityValue(viewModel.isNotationNoteEntryModeEnabled ? "Enabled" : "Disabled") + .help("Add rests to Notation") + .accessibilityLabel("Notation Rest Entry") + .accessibilityValue(viewModel.isNotationRestEntryModeEnabled ? "Enabled" : "Disabled") Spacer(minLength: AppTheme.Spacing.md) @@ -110,7 +122,7 @@ struct NotationWindowView: View { selectedMeasures: viewModel.selectedNotationMeasures, selectedItem: viewModel.selectedNotationItem, selectedDuration: NotationDuration(denominator: viewModel.notationDurationDenominator), - isNoteEntryModeEnabled: viewModel.isNotationNoteEntryModeEnabled, + entryMode: viewModel.notationEntryMode, pendingEditorRequest: viewModel.pendingHarmonyEditorRequest, actions: notationActions, cornerRadius: AppTheme.Spacing.none @@ -177,6 +189,7 @@ struct NotationWindowView: View { selectMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1) }, selectItem: { viewModel.selectNotationItem($0, shouldAudition: $1) }, insertNotationNote: { viewModel.insertNotationNote($0) }, + insertNotationRest: { viewModel.insertNotationRest($0) }, changeSelectedNotePitch: { viewModel.changeSelectedNotationNotePitch(to: $0, shouldAudition: $1) }, auditionNotePitch: { viewModel.auditionNotationNotePitch($0) }, deleteSelectedNotationNote: { viewModel.deleteSelectedNotationNote() }, @@ -195,7 +208,7 @@ struct NotationWindowView: View { if viewModel.canPasteNotationMeasureClipboard { hotkeys.insert(.pasteMeasure) } - if viewModel.hasSelectedNotationMeasures || viewModel.isNotationNoteEntryModeEnabled { + if viewModel.hasSelectedNotationMeasures || viewModel.isNotationEntryModeEnabled { hotkeys.insert(.clearNotationMeasureSelection) } if viewModel.canEditSelectedNotationItem { @@ -227,8 +240,8 @@ struct NotationWindowView: View { case .pasteMeasure: return viewModel.pasteNotationMeasureClipboard() case .clearNotationMeasureSelection: - if viewModel.isNotationNoteEntryModeEnabled { - viewModel.clearNotationNoteEntryMode() + if viewModel.isNotationEntryModeEnabled { + viewModel.clearNotationEntryMode() } else { viewModel.clearNotationMeasureSelection() } diff --git a/JammLab/Views/WaveformTimelineView.swift b/JammLab/Views/WaveformTimelineView.swift index 7de9c27..9a6b233 100644 --- a/JammLab/Views/WaveformTimelineView.swift +++ b/JammLab/Views/WaveformTimelineView.swift @@ -34,7 +34,7 @@ struct TimelineViewState: Equatable { var notationViewport: NotationViewportState var notationDurationDenominator: Int var canChangeNotationDuration: Bool - var isNotationNoteEntryModeEnabled: Bool + var notationEntryMode: NotationEntryMode? var isNotationTrackCollapsed: Bool var isLoadingPeakform: Bool var mainTrackVolume: Float @@ -74,7 +74,9 @@ struct TimelineViewActions { var notationTrackCollapsedChanged: (Bool) -> Void var notationDurationChanged: (Int) -> Void var notationNoteEntryModeToggled: () -> Void + var notationRestEntryModeToggled: () -> Void var insertNotationNote: (NotationNotePlacement) -> Bool + var insertNotationRest: (NotationRestPlacement) -> Bool var changeSelectedNotePitch: (NotationPitch, Bool) -> Bool var auditionNotePitch: (NotationPitch) -> Void var deleteSelectedNotationNote: () -> Bool @@ -155,6 +157,14 @@ struct WaveformTimelineView: View { ) } + private var isNotationNoteEntryModeEnabled: Bool { + state.notationEntryMode == .note + } + + private var isNotationRestEntryModeEnabled: Bool { + state.notationEntryMode == .rest + } + private var upperTrackStack: some View { VStack(spacing: AppTheme.Spacing.none) { timelineTrackRow(height: AppTheme.Timeline.regionTrackHeight) { @@ -276,13 +286,14 @@ struct WaveformTimelineView: View { selectedMeasures: state.selectedNotationMeasures, selectedItem: state.selectedNotationItem, selectedDuration: NotationDuration(denominator: state.notationDurationDenominator), - isNoteEntryModeEnabled: state.isNotationNoteEntryModeEnabled, + entryMode: state.notationEntryMode, pendingEditorRequest: state.pendingHarmonyEditorRequest, actions: NotationTrackActions( selectHarmony: actions.selectHarmony, selectMeasure: actions.selectNotationMeasure, selectItem: actions.selectNotationItem, insertNotationNote: actions.insertNotationNote, + insertNotationRest: actions.insertNotationRest, changeSelectedNotePitch: actions.changeSelectedNotePitch, auditionNotePitch: actions.auditionNotePitch, deleteSelectedNotationNote: actions.deleteSelectedNotationNote, @@ -322,18 +333,31 @@ struct WaveformTimelineView: View { isEnabled: state.canChangeNotationDuration ) - AppLetterToggleButton( - title: "N", - isActive: state.isNotationNoteEntryModeEnabled, - activeFillColor: appColors.accent, - inactiveTextColor: appColors.secondaryText - ) { - actions.notationNoteEntryModeToggled() + HStack(spacing: AppTheme.Spacing.xs) { + NotationEntryModeButton( + mode: .note, + selectedDuration: NotationDuration(denominator: state.notationDurationDenominator), + isActive: isNotationNoteEntryModeEnabled + ) { + actions.notationNoteEntryModeToggled() + } + .disabled(state.duration <= 0) + .help("Add notes to Notation (N)") + .accessibilityLabel("Notation Note Entry") + .accessibilityValue(isNotationNoteEntryModeEnabled ? "Enabled" : "Disabled") + + NotationEntryModeButton( + mode: .rest, + selectedDuration: NotationDuration(denominator: state.notationDurationDenominator), + isActive: isNotationRestEntryModeEnabled + ) { + actions.notationRestEntryModeToggled() + } + .disabled(state.duration <= 0) + .help("Add rests to Notation") + .accessibilityLabel("Notation Rest Entry") + .accessibilityValue(isNotationRestEntryModeEnabled ? "Enabled" : "Disabled") } - .disabled(state.duration <= 0) - .help("Add notes to Notation (N)") - .accessibilityLabel("Notation Note Entry") - .accessibilityValue(state.isNotationNoteEntryModeEnabled ? "Enabled" : "Disabled") } } .padding(.horizontal, AppTheme.Spacing.md) diff --git a/JammLabTests/NotationPrimitivesTests.swift b/JammLabTests/NotationPrimitivesTests.swift index ac11b84..28e1d3c 100644 --- a/JammLabTests/NotationPrimitivesTests.swift +++ b/JammLabTests/NotationPrimitivesTests.swift @@ -197,6 +197,41 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertEqual(bFlat.alter, -1) } + func testNotationPitchMapperSupportsLedgerRange() { + let cMajor = KeySignature.normalized(from: "C major") + + XCTAssertEqual( + NotationPitchMapper.pitch( + forStaffPosition: NotationPitchMapper.minimumStaffPosition, + keySignature: cMajor + ), + NotationPitch(step: .d, octave: 6) + ) + XCTAssertEqual( + NotationPitchMapper.pitch( + forStaffPosition: NotationPitchMapper.maximumStaffPosition, + keySignature: cMajor + ), + NotationPitch(step: .g, octave: 3) + ) + XCTAssertEqual( + NotationPitchMapper.pitch(forStaffPosition: -100, keySignature: cMajor), + NotationPitch(step: .d, octave: 6) + ) + XCTAssertEqual( + NotationPitchMapper.pitch(forStaffPosition: 100, keySignature: cMajor), + NotationPitch(step: .g, octave: 3) + ) + XCTAssertEqual( + NotationPitchMapper.staffPosition(for: NotationPitch(step: .d, octave: 6)), + NotationPitchMapper.minimumStaffPosition + ) + XCTAssertEqual( + NotationPitchMapper.staffPosition(for: NotationPitch(step: .g, octave: 3)), + NotationPitchMapper.maximumStaffPosition + ) + } + func testNotationPitchMapperAdjacentPitchRespectsBoundsAndKeySignature() throws { let cMajor = KeySignature.normalized(from: "C major") let fMajor = KeySignature.normalized(from: "F major") @@ -225,13 +260,21 @@ final class NotationPrimitivesTests: XCTestCase { ), NotationPitch(step: .b, octave: 4, alter: -1) ) + XCTAssertEqual( + NotationPitchMapper.adjacentPitch( + from: NotationPitch(step: .f, octave: 5), + staffPositionDelta: -1, + keySignature: cMajor + ), + NotationPitch(step: .g, octave: 5) + ) XCTAssertNil(NotationPitchMapper.adjacentPitch( - from: NotationPitch(step: .f, octave: 5), + from: NotationPitch(step: .d, octave: 6), staffPositionDelta: -1, keySignature: cMajor )) XCTAssertNil(NotationPitchMapper.adjacentPitch( - from: NotationPitch(step: .c, octave: 3), + from: NotationPitch(step: .g, octave: 3), staffPositionDelta: 1, keySignature: cMajor )) @@ -357,6 +400,78 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertEqual(placement.durationInQuarterNotes, 1, accuracy: 0.0001) } + func testNotationRestPlacementResolverTargetsContainingShortRestWithFollowingCapacity() throws { + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble, + notationItems: [ + NotationMeasureItem( + id: "first-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "first-eighth-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ), + NotationMeasureItem( + id: "second-eighth-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1.5, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ), + NotationMeasureItem( + id: "half-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + ] + ) + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 200, + contentStartX: 20, + contentEndX: 180, + staffStartX: 0, + staffEndX: 200 + ) + let xInsideSecondEighth = NotationMeasureLayout.notationAnchorX( + geometry: geometry, + offsetInQuarterNotes: 1.9, + timeSignature: measure.attributes.timeSignature + ) + + let placement = try XCTUnwrap(NotationNotePlacementResolver.restPlacement( + in: measure, + geometry: geometry, + point: CGPoint(x: xInsideSecondEighth, y: 72), + staffTop: 40, + selectedDuration: NotationDuration(denominator: 4) + )) + + XCTAssertEqual(placement.targetRestID, "second-eighth-rest") + XCTAssertEqual(placement.offsetInQuarterNotes, 1.5, accuracy: 0.0001) + XCTAssertEqual(placement.durationInQuarterNotes, 1, accuracy: 0.0001) + XCTAssertEqual(placement.displayDuration.denominator, 4) + } + func testNotationNotePlacementResolverRejectsRestWithoutContiguousCapacity() { let measure = ScoreMeasure( number: 1, @@ -408,7 +523,58 @@ final class NotationPrimitivesTests: XCTestCase { )) } - func testNotationNoteEntryRecomposerPreservesPersistedItemsAndCopiesSynthesizedItems() throws { + func testNotationRestPlacementResolverRejectsRestWithoutContiguousCapacity() { + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble, + notationItems: [ + NotationMeasureItem( + id: "short-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ), + NotationMeasureItem( + id: "next-note", + kind: .note, + pitch: NotationPitch(step: .e, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1.5, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + ) + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 200, + contentStartX: 20, + contentEndX: 180, + staffStartX: 0, + staffEndX: 200 + ) + let xInsideShortRest = NotationMeasureLayout.notationAnchorX( + geometry: geometry, + offsetInQuarterNotes: 1.25, + timeSignature: measure.attributes.timeSignature + ) + + XCTAssertNil(NotationNotePlacementResolver.restPlacement( + in: measure, + geometry: geometry, + point: CGPoint(x: xInsideShortRest, y: 72), + staffTop: 40, + selectedDuration: NotationDuration(denominator: 4) + )) + } + + func testNotationEntryRecomposerPreservesPersistedItemsAndCopiesSynthesizedItems() throws { let note = NotationMeasureItem( id: "existing-note", kind: .note, @@ -460,7 +626,7 @@ final class NotationPrimitivesTests: XCTestCase { displayDuration: NotationDuration(denominator: 4) ) - let recomposed = NotationNoteEntryRecomposer.recomposedItems( + let recomposed = NotationEntryRecomposer.recomposedItems( in: measure, replacing: span, with: inserted @@ -475,7 +641,7 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertFalse(recomposed[2].isSynthesized) } - func testNotationNoteEntryRecomposerSplitsTailToNextQuarterBoundary() throws { + func testNotationEntryRecomposerSplitsTailToNextQuarterBoundary() throws { let targetRest = NotationMeasureItem( id: "second-eighth-rest", measureNumber: 1, @@ -515,7 +681,7 @@ final class NotationPrimitivesTests: XCTestCase { displayDuration: NotationDuration(denominator: 4) ) - let recomposed = NotationNoteEntryRecomposer.recomposedItems( + let recomposed = NotationEntryRecomposer.recomposedItems( in: measure, replacing: span, with: inserted @@ -554,6 +720,65 @@ final class NotationPrimitivesTests: XCTestCase { ) } + func testNotationNotePlacementResolverAllowsExtendedEntryRange() { + let staffTop: CGFloat = 40 + let spacing = AppTheme.Timeline.notationStaffLineSpacing + + XCTAssertEqual( + NotationNotePlacementResolver.staffPosition( + forY: NotationNotePlacementResolver.yPosition( + forStaffPosition: NotationPitchMapper.minimumStaffPosition, + staffTop: staffTop, + lineSpacing: spacing + ), + staffTop: staffTop, + lineSpacing: spacing + ), + NotationPitchMapper.minimumStaffPosition + ) + XCTAssertEqual( + NotationNotePlacementResolver.staffPosition( + forY: NotationNotePlacementResolver.yPosition( + forStaffPosition: NotationPitchMapper.maximumStaffPosition, + staffTop: staffTop, + lineSpacing: spacing + ), + staffTop: staffTop, + lineSpacing: spacing + ), + NotationPitchMapper.maximumStaffPosition + ) + XCTAssertNil(NotationNotePlacementResolver.staffPosition( + forY: staffTop - spacing * 4, + staffTop: staffTop, + lineSpacing: spacing + )) + XCTAssertNil(NotationNotePlacementResolver.staffPosition( + forY: staffTop + spacing * 8, + staffTop: staffTop, + lineSpacing: spacing + )) + } + + func testNotationNotePlacementResolverLedgerLinePositions() { + XCTAssertEqual( + NotationNotePlacementResolver.ledgerLineStaffPositions(forStaffPosition: -5), + [-2, -4] + ) + XCTAssertEqual( + NotationNotePlacementResolver.ledgerLineStaffPositions(forStaffPosition: -1), + [] + ) + XCTAssertEqual( + NotationNotePlacementResolver.ledgerLineStaffPositions(forStaffPosition: 9), + [] + ) + XCTAssertEqual( + NotationNotePlacementResolver.ledgerLineStaffPositions(forStaffPosition: 13), + [10, 12] + ) + } + func testLelandFontResourceIsBundledAndRegistered() throws { let url = try XCTUnwrap(Bundle.main.url(forResource: "Leland", withExtension: "otf")) diff --git a/JammLabTests/ViewModelNotationDurationSelectionTests.swift b/JammLabTests/ViewModelNotationDurationSelectionTests.swift index 6b34518..559d2d4 100644 --- a/JammLabTests/ViewModelNotationDurationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationDurationSelectionTests.swift @@ -1,3 +1,4 @@ +import CoreGraphics import XCTest @testable import JammLab @@ -152,6 +153,224 @@ final class ViewModelNotationDurationSelectionTests: XCTestCase { ]) } + @MainActor + func testAddingNotationRestCanConsumeFollowingRestsFromShortTargetRest() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.notationItems = [ + NotationMeasureItem( + id: "first-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "first-eighth-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ), + NotationMeasureItem( + id: "second-eighth-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1.5, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ), + NotationMeasureItem( + id: "half-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + ] + viewModel.markProjectClean() + + let measure = try notationMeasure(1, in: viewModel) + let targetRest = try XCTUnwrap(measure.notationItems.first { $0.id == "second-eighth-rest" }) + let placement = NotationRestPlacement( + measure: measure, + targetRestID: targetRest.id, + offsetInQuarterNotes: targetRest.offsetInQuarterNotes, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + x: 0 + ) + + XCTAssertTrue(viewModel.insertNotationRest(placement)) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(updatedMeasure.notationItems.map(\.kind), [.note, .rest, .rest, .rest, .rest]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 1, 1.5, 2.5, 3]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [1, 0.5, 1, 0.5, 1]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [4, 8, 4, 8, 4]) + XCTAssertEqual(viewModel.selectedNotationItem?.offsetInQuarterNotes, 1.5) + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.undoLastEdit() + + let undoneMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(undoneMeasure.notationItems.map(\.id), [ + "first-note", + "first-eighth-rest", + "second-eighth-rest", + "half-rest" + ]) + } + + @MainActor + func testRejectedNotationRestDoesNotConsumeAcrossExistingNoteOrMutateSelection() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.notationItems = [ + NotationMeasureItem( + id: "short-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ), + NotationMeasureItem( + id: "blocking-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1.5, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + let rest = try XCTUnwrap(measure.notationItems.first { $0.id == "short-rest" }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: rest)) + viewModel.markProjectClean() + let originalItems = viewModel.notationItems + let originalSelection = viewModel.selectedNotationItem + let placement = NotationRestPlacement( + measure: measure, + targetRestID: rest.id, + offsetInQuarterNotes: rest.offsetInQuarterNotes, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + x: 0 + ) + + XCTAssertFalse(viewModel.insertNotationRest(placement)) + + XCTAssertEqual(viewModel.notationItems, originalItems) + XCTAssertEqual(viewModel.selectedNotationItem, originalSelection) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testAddingExactPersistedNotationRestSelectsWithoutDirtyingProject() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.notationItems = [ + NotationMeasureItem( + id: "explicit-whole-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 4, + displayDuration: NotationDuration(denominator: 1) + ) + ] + viewModel.markProjectClean() + let measure = try notationMeasure(1, in: viewModel) + let rest = try XCTUnwrap(measure.notationItems.first) + let placement = NotationRestPlacement( + measure: measure, + targetRestID: rest.id, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 4, + displayDuration: NotationDuration(denominator: 1), + x: 0 + ) + + XCTAssertTrue(viewModel.insertNotationRest(placement)) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(updatedMeasure.notationItems.map(\.id), ["explicit-whole-rest"]) + XCTAssertEqual(viewModel.selectedNotationItem?.itemID, "explicit-whole-rest") + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testAddingNotationNotesAtLedgerExtremesUsesResolverPlacementAndRespectsPitchBounds() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 200, + contentStartX: 20, + contentEndX: 180, + staffStartX: 0, + staffEndX: 200 + ) + let staffTop: CGFloat = 40 + let selectedDuration = NotationDuration(denominator: 4) + let highY = NotationNotePlacementResolver.yPosition( + forStaffPosition: NotationPitchMapper.minimumStaffPosition, + staffTop: staffTop + ) + let lowY = NotationNotePlacementResolver.yPosition( + forStaffPosition: NotationPitchMapper.maximumStaffPosition, + staffTop: staffTop + ) + + let initialMeasure = try notationMeasure(1, in: viewModel) + let highPlacement = try XCTUnwrap(NotationNotePlacementResolver.placement( + in: initialMeasure, + geometry: geometry, + point: CGPoint(x: 160, y: highY), + staffTop: staffTop, + selectedDuration: selectedDuration + )) + XCTAssertEqual(highPlacement.pitch, NotationPitch(step: .d, octave: 6)) + XCTAssertTrue(viewModel.insertNotationNote(highPlacement)) + + let measureAfterHighNote = try notationMeasure(1, in: viewModel) + let highNote = try XCTUnwrap(measureAfterHighNote.notationItems.first { + $0.pitch == NotationPitch(step: .d, octave: 6) + }) + viewModel.selectNotationItem(NotationItemSelection(measure: measureAfterHighNote, item: highNote)) + XCTAssertFalse(viewModel.canChangeSelectedNotationNotePitch(byStaffPositionDelta: -1)) + XCTAssertTrue(viewModel.canChangeSelectedNotationNotePitch(byStaffPositionDelta: 1)) + + let lowPlacement = try XCTUnwrap(NotationNotePlacementResolver.placement( + in: measureAfterHighNote, + geometry: geometry, + point: CGPoint(x: 70, y: lowY), + staffTop: staffTop, + selectedDuration: selectedDuration + )) + XCTAssertEqual(lowPlacement.pitch, NotationPitch(step: .g, octave: 3)) + XCTAssertTrue(viewModel.insertNotationNote(lowPlacement)) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + let lowNote = try XCTUnwrap(updatedMeasure.notationItems.first { + $0.pitch == NotationPitch(step: .g, octave: 3) + }) + XCTAssertEqual(viewModel.selectedNotationItem?.itemID, lowNote.id) + XCTAssertEqual(updatedMeasure.notationItems.filter { $0.kind == .note }.compactMap(\.pitch), [ + NotationPitch(step: .d, octave: 6), + NotationPitch(step: .g, octave: 3) + ]) + XCTAssertFalse(viewModel.canChangeSelectedNotationNotePitch(byStaffPositionDelta: 1)) + XCTAssertTrue(viewModel.canChangeSelectedNotationNotePitch(byStaffPositionDelta: -1)) + } + @MainActor func testAddingNotationNoteAuditionsInsertedPitch() throws { let auditioner = MockNotationNoteAuditioner() @@ -463,7 +682,7 @@ final class ViewModelNotationDurationSelectionTests: XCTestCase { func testChangingSelectedNotationNotePitchByStaffStepNoOpsForRestBoundsAndUnmappedPitch() throws { let viewModel = try loadedNotationViewModel(duration: 8) let note = try insertQuarterNote( - pitch: NotationPitch(step: .f, octave: 5), + pitch: NotationPitch(step: .d, octave: 6), inMeasure: 1, viewModel: viewModel ) @@ -570,6 +789,23 @@ final class ViewModelNotationDurationSelectionTests: XCTestCase { XCTAssertEqual(updatedMeasure.notationItems.first?.displayDuration.denominator, 1) } + @MainActor + func testRestEntryDurationChangeDoesNotEditSelectedItem() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + let item = try XCTUnwrap(measure.notationItems.first) + + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: item)) + viewModel.setNotationRestEntryModeEnabled(true) + viewModel.setNotationDurationDenominator(4) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(viewModel.notationDurationDenominator, 4) + XCTAssertNil(viewModel.selectedNotationItem) + XCTAssertEqual(updatedMeasure.notationItems.count, 1) + XCTAssertEqual(updatedMeasure.notationItems.first?.displayDuration.denominator, 1) + } + @MainActor private func insertQuarterNote( pitch: NotationPitch, diff --git a/JammLabTests/ViewModelNotationSelectionTests.swift b/JammLabTests/ViewModelNotationSelectionTests.swift index 9b70816..5cdf087 100644 --- a/JammLabTests/ViewModelNotationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationSelectionTests.swift @@ -130,6 +130,54 @@ final class ViewModelNotationSelectionTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } + @MainActor + func testNotationEntryModesAreMutuallyExclusiveAndClearEditingState() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + let item = try XCTUnwrap(measure.notationItems.first) + let harmony = HarmonySymbol( + time: measure.startTime, + measureNumber: measure.number, + offsetInQuarterNotes: 0, + rawText: "C" + ) + viewModel.harmonySymbols = [harmony] + viewModel.selectedNotationMeasures = [NotationMeasureSelection(measure: measure)] + viewModel.notationMeasureSelectionAnchor = NotationMeasureSelection(measure: measure) + viewModel.selectedNotationItem = NotationItemSelection(measure: measure, item: item) + viewModel.selectedHarmonySymbolID = harmony.id + viewModel.pendingHarmonyEditorRequest = HarmonyEditorRequest(time: measure.startTime) + + viewModel.setNotationNoteEntryModeEnabled(true) + + XCTAssertEqual(viewModel.notationEntryMode, .note) + XCTAssertTrue(viewModel.isNotationNoteEntryModeEnabled) + XCTAssertFalse(viewModel.isNotationRestEntryModeEnabled) + XCTAssertTrue(viewModel.selectedNotationMeasures.isEmpty) + XCTAssertNil(viewModel.notationMeasureSelectionAnchor) + XCTAssertNil(viewModel.selectedNotationItem) + XCTAssertNil(viewModel.selectedHarmonySymbolID) + XCTAssertNil(viewModel.pendingHarmonyEditorRequest) + + viewModel.setNotationRestEntryModeEnabled(true) + + XCTAssertEqual(viewModel.notationEntryMode, .rest) + XCTAssertFalse(viewModel.isNotationNoteEntryModeEnabled) + XCTAssertTrue(viewModel.isNotationRestEntryModeEnabled) + + viewModel.toggleNotationRestEntryMode() + + XCTAssertNil(viewModel.notationEntryMode) + XCTAssertFalse(viewModel.isNotationEntryModeEnabled) + + viewModel.setNotationNoteEntryModeEnabled(true) + viewModel.clearNotationEntryMode() + + XCTAssertNil(viewModel.notationEntryMode) + XCTAssertFalse(viewModel.isNotationNoteEntryModeEnabled) + XCTAssertFalse(viewModel.isNotationRestEntryModeEnabled) + } + @MainActor func testRequestEditSelectedNotationItemUsesExactItemOffsetAndExistingHarmony() throws { let viewModel = try loadedNotationViewModel(duration: 8) From 4b4d2a9940aa92d74ccdb1a2dff809c5869d15ab Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Mon, 13 Jul 2026 17:13:06 +0300 Subject: [PATCH 03/12] feat(notation): add stem parts and multi-part MusicXML export --- CHANGELOG.md | 2 + JammLab.xcodeproj/project.pbxproj | 4 + JammLab/ContentView.swift | 2 +- JammLab/DesignSystem/AppTheme.swift | 47 +++- JammLab/Models/JammLabProject.swift | 14 +- JammLab/Models/NotationScoreModels.swift | 65 +++++- JammLab/Models/NotationViewportState.swift | 52 +++-- JammLab/Models/ProjectEditableState.swift | 4 + JammLab/Models/ProjectStateNormalizer.swift | 18 ++ JammLab/Services/NotationExportService.swift | 64 +++-- .../NotationNotePlacementResolver.swift | 52 ++++- .../Services/NotationViewportFactory.swift | 28 ++- .../ProjectPersistenceCoordinator.swift | 6 +- .../AudioPlayerViewModel+Notation.swift | 191 +++++++++++---- .../AudioPlayerViewModel+NotationExport.swift | 23 ++ .../AudioPlayerViewModel+Project.swift | 10 +- .../AudioPlayerViewModel+UndoDirty.swift | 12 + JammLab/ViewModels/AudioPlayerViewModel.swift | 2 + JammLab/Views/MainWorkspacePanels.swift | 45 +++- JammLab/Views/NotationTrackLayoutItems.swift | 52 +++++ JammLab/Views/NotationTrackView.swift | 77 +++--- JammLab/Views/NotationWindowScoreLayout.swift | 167 +++++++++++++ JammLab/Views/NotationWindowView.swift | 221 ++++++++++++------ JammLab/Views/WaveformTimelineView.swift | 212 ++++++++++++++--- JammLabTests/NotationMusicXMLTests.swift | 100 ++++++++ .../NotationTrackLayoutItemsTests.swift | 177 ++++++++++++++ JammLabTests/NotationViewportTests.swift | 119 ++++++++++ .../NotationVisibleMeasureFitterTests.swift | 109 +++++++++ JammLabTests/StemWorkflowLogicTests.swift | 71 ++++++ JammLabTests/TimelineProjectLogicTests.swift | 62 +++++ ...wModelNotationDurationSelectionTests.swift | 38 +++ .../ViewModelNotationExportTests.swift | 34 +++ .../ViewModelNotationSelectionTests.swift | 61 ++++- .../ViewModelNotationTrackCollapseTests.swift | 56 +++++ 34 files changed, 1953 insertions(+), 244 deletions(-) create mode 100644 JammLab/Views/NotationWindowScoreLayout.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 879fcee..a98f5f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,10 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. - Added Notation note-entry mode for placing pitched notes with Leland-rendered note glyphs, note selection preview, cross-rest insertion, rest recomposition, and MusicXML export. - Expanded Notation entry to ledger-line pitches from G3 through D6 and added rest-entry controls for inserting selected rest durations. - Added Notation note editing for dragging note pitch, arrow-key pitch changes, and deleting selected notes back to rests. +- Added per-stem Notation parts with collapsible stem notation tracks, score-aligned multi-part Notation systems, shared Region labels, part visibility controls, and multi-part MusicXML export. - Added clearer hover tooltips and numpad shortcuts for Notation duration buttons. - Added a bar/beat position readout to the transport time display. +- Fixed multi-part Notation accessibility selection announcements and prevented harmony editing from clearing a selected stem note. ## 1.1.0 diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index f387103..09f4ff2 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -60,6 +60,7 @@ 9FD100012E00000100112233 /* NotationTrackLayoutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FD100002E00000100112233 /* NotationTrackLayoutItems.swift */; }; 9FCB01072D80000100112233 /* NotationHarmonyInlineTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB00072D80000100112233 /* NotationHarmonyInlineTextField.swift */; }; 9FEC01012D71000100112233 /* NotationWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEC00012D71000100112233 /* NotationWindowView.swift */; }; + 9FEC01022D71000100112233 /* NotationWindowScoreLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEC00022D71000100112233 /* NotationWindowScoreLayout.swift */; }; 9F8B01062C10000100112233 /* TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8B00072C10000100112233 /* TestSupport.swift */; }; 9FCD01012D50000100112233 /* PerformanceBenchmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCD00012D50000100112233 /* PerformanceBenchmarkTests.swift */; }; 9FCD01022D50000100112233 /* TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8B00072C10000100112233 /* TestSupport.swift */; }; @@ -326,6 +327,7 @@ 9FD100002E00000100112233 /* NotationTrackLayoutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationTrackLayoutItems.swift; sourceTree = ""; }; 9FCB00072D80000100112233 /* NotationHarmonyInlineTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationHarmonyInlineTextField.swift; sourceTree = ""; }; 9FEC00012D71000100112233 /* NotationWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationWindowView.swift; sourceTree = ""; }; + 9FEC00022D71000100112233 /* NotationWindowScoreLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationWindowScoreLayout.swift; sourceTree = ""; }; 9F8B00062C10000100112233 /* JammLabTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JammLabTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9F8B00072C10000100112233 /* TestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSupport.swift; sourceTree = ""; }; 9FCD00012D50000100112233 /* PerformanceBenchmarkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceBenchmarkTests.swift; sourceTree = ""; }; @@ -676,6 +678,7 @@ 9FD100002E00000100112233 /* NotationTrackLayoutItems.swift */, 9FEA00042D70000100112233 /* NotationTrackView.swift */, 9FEC00012D71000100112233 /* NotationWindowView.swift */, + 9FEC00022D71000100112233 /* NotationWindowScoreLayout.swift */, 9F8B00032C10000100112233 /* PeakformTimelineView.swift */, 9FDD00032D60000100112233 /* RegionInteractionOverlay.swift */, 9FDD00062D60000100112233 /* SettingsContentViews.swift */, @@ -1116,6 +1119,7 @@ 9FCB01072D80000100112233 /* NotationHarmonyInlineTextField.swift in Sources */, 9FD100012E00000100112233 /* NotationTrackLayoutItems.swift in Sources */, 9FEC01012D71000100112233 /* NotationWindowView.swift in Sources */, + 9FEC01022D71000100112233 /* NotationWindowScoreLayout.swift in Sources */, 9F8A010D2C00000100112233 /* AppHotkey.swift in Sources */, 9FCB01062D80000100112233 /* NotationMeasureLayout.swift in Sources */, 9FEE01012D90000100112233 /* NotationExportService.swift in Sources */, diff --git a/JammLab/ContentView.swift b/JammLab/ContentView.swift index 7221f0c..a54371c 100644 --- a/JammLab/ContentView.swift +++ b/JammLab/ContentView.swift @@ -184,7 +184,7 @@ struct ContentView: View { if !viewModel.hasSelectedNotationMeasures && !viewModel.isNotationEntryModeEnabled { hotkeys.remove(.clearNotationMeasureSelection) } - if !viewModel.canEditSelectedNotationItem { + if !viewModel.canEditHarmonyAtSelectedNotationItem { hotkeys.remove(.editHarmonyAtSelectedNotationItem) } if !viewModel.canChangeNotationDuration { diff --git a/JammLab/DesignSystem/AppTheme.swift b/JammLab/DesignSystem/AppTheme.swift index 28ab5ad..b49d81c 100644 --- a/JammLab/DesignSystem/AppTheme.swift +++ b/JammLab/DesignSystem/AppTheme.swift @@ -333,18 +333,34 @@ enum AppTheme { static var upperTrackStackHeight: CGFloat { upperTrackStackHeight(isNotationTrackCollapsed: false) } - static func stemTracksHeight(rowCount: Int) -> CGFloat { + static func stemRowHeight(isNotationExpanded: Bool) -> CGFloat { + stemTrackHeight + + (isNotationExpanded ? trackSpacing + notationTrackHeight : 0) + } + static func stemTracksHeight( + rowCount: Int, + expandedStemNotationCount: Int = 0 + ) -> CGFloat { let visibleRows = max(defaultVisibleStemRows, rowCount) + let expandedRows = min(max(0, expandedStemNotationCount), max(0, rowCount)) return CGFloat(visibleRows) * stemTrackHeight + CGFloat(max(0, visibleRows - 1)) * AppTheme.Spacing.md + + CGFloat(expandedRows) * (trackSpacing + notationTrackHeight) } static var stemTracksHeight: CGFloat { stemTracksHeight(rowCount: defaultVisibleStemRows) } - static func tracksMinimumHeight(stemRowCount: Int, isNotationTrackCollapsed: Bool = false) -> CGFloat { + static func tracksMinimumHeight( + stemRowCount: Int, + isNotationTrackCollapsed: Bool = false, + expandedStemNotationCount: Int = 0 + ) -> CGFloat { upperTrackStackHeight(isNotationTrackCollapsed: isNotationTrackCollapsed) + trackSpacing - + stemTracksHeight(rowCount: stemRowCount) + + stemTracksHeight( + rowCount: stemRowCount, + expandedStemNotationCount: expandedStemNotationCount + ) } static var tracksMinimumHeight: CGFloat { tracksMinimumHeight(stemRowCount: defaultVisibleStemRows) @@ -355,11 +371,13 @@ enum AppTheme { } static func timelineBlockMinimumHeight( stemRowCount: Int, - isNotationTrackCollapsed: Bool = false + isNotationTrackCollapsed: Bool = false, + expandedStemNotationCount: Int = 0 ) -> CGFloat { tracksMinimumHeight( stemRowCount: stemRowCount, - isNotationTrackCollapsed: isNotationTrackCollapsed + isNotationTrackCollapsed: isNotationTrackCollapsed, + expandedStemNotationCount: expandedStemNotationCount ) + viewportFooterGap + viewportControlBarHeight } static var timelineBlockMinimumHeight: CGFloat { @@ -367,11 +385,13 @@ enum AppTheme { } static func minimumContentHeight( stemRowCount: Int, - isNotationTrackCollapsed: Bool = false + isNotationTrackCollapsed: Bool = false, + expandedStemNotationCount: Int = 0 ) -> CGFloat { timelineBlockMinimumHeight( stemRowCount: stemRowCount, - isNotationTrackCollapsed: isNotationTrackCollapsed + isNotationTrackCollapsed: isNotationTrackCollapsed, + expandedStemNotationCount: expandedStemNotationCount ) } static var minimumContentHeight: CGFloat { @@ -441,7 +461,18 @@ enum AppTheme { enum NotationWindow { static let maximumMeasuresPerSystem = 4 static let systemHeight: CGFloat = 124 - static let systemSpacing: CGFloat = AppTheme.Spacing.none + static let staffSpacing: CGFloat = AppTheme.Spacing.none + static let systemSpacing: CGFloat = AppTheme.Spacing.xxl static let pagePadding: CGFloat = 28 + static let partLabelWidth: CGFloat = 104 + static let partGutterSpacing: CGFloat = AppTheme.Spacing.xl + static let systemConnectorWidth: CGFloat = AppTheme.Spacing.sm + static let systemConnectorTopInset: CGFloat = max( + AppTheme.Spacing.xxl, + (systemHeight - AppTheme.Timeline.notationStaffLineSpacing * 4) / 2 + AppTheme.Spacing.xs + ) + static let systemConnectorBottomInset: CGFloat = systemHeight + - systemConnectorTopInset + - AppTheme.Timeline.notationStaffLineSpacing * 4 } } diff --git a/JammLab/Models/JammLabProject.swift b/JammLab/Models/JammLabProject.swift index 4447827..9f26f64 100644 --- a/JammLab/Models/JammLabProject.swift +++ b/JammLab/Models/JammLabProject.swift @@ -42,6 +42,8 @@ struct JammLabProject: Codable { var stemState: StemProjectState? var isVideoWindowOpen: Bool? var isNotationTrackCollapsed: Bool? + var stemNotationTrackCollapsed: [StemType: Bool] + var visibleNotationPartIDs: Set init( formatVersion: Int = 11, @@ -70,7 +72,9 @@ struct JammLabProject: Codable { timelineVisibleRange: ProjectTimelineVisibleRange? = nil, stemState: StemProjectState? = nil, isVideoWindowOpen: Bool? = nil, - isNotationTrackCollapsed: Bool? = nil + isNotationTrackCollapsed: Bool? = nil, + stemNotationTrackCollapsed: [StemType: Bool] = [:], + visibleNotationPartIDs: Set = [.main] ) { self.formatVersion = formatVersion self.audioBookmarkData = audioBookmarkData @@ -99,6 +103,8 @@ struct JammLabProject: Codable { self.stemState = stemState self.isVideoWindowOpen = isVideoWindowOpen self.isNotationTrackCollapsed = isNotationTrackCollapsed + self.stemNotationTrackCollapsed = stemNotationTrackCollapsed + self.visibleNotationPartIDs = visibleNotationPartIDs } private enum CodingKeys: String, CodingKey { @@ -129,6 +135,8 @@ struct JammLabProject: Codable { case stemState case isVideoWindowOpen case isNotationTrackCollapsed + case stemNotationTrackCollapsed + case visibleNotationPartIDs } init(from decoder: Decoder) throws { @@ -160,6 +168,8 @@ struct JammLabProject: Codable { stemState = try container.decodeIfPresent(StemProjectState.self, forKey: .stemState) isVideoWindowOpen = try container.decodeIfPresent(Bool.self, forKey: .isVideoWindowOpen) isNotationTrackCollapsed = try container.decodeIfPresent(Bool.self, forKey: .isNotationTrackCollapsed) + stemNotationTrackCollapsed = try container.decodeIfPresent([StemType: Bool].self, forKey: .stemNotationTrackCollapsed) ?? [:] + visibleNotationPartIDs = try container.decodeIfPresent(Set.self, forKey: .visibleNotationPartIDs) ?? [.main] } func encode(to encoder: Encoder) throws { @@ -191,6 +201,8 @@ struct JammLabProject: Codable { try container.encodeIfPresent(stemState, forKey: .stemState) try container.encodeIfPresent(isVideoWindowOpen, forKey: .isVideoWindowOpen) try container.encodeIfPresent(isNotationTrackCollapsed, forKey: .isNotationTrackCollapsed) + try container.encode(stemNotationTrackCollapsed, forKey: .stemNotationTrackCollapsed) + try container.encode(visibleNotationPartIDs, forKey: .visibleNotationPartIDs) } func resolvedAudioURL() throws -> URL { diff --git a/JammLab/Models/NotationScoreModels.swift b/JammLab/Models/NotationScoreModels.swift index 97cc57d..10f4451 100644 --- a/JammLab/Models/NotationScoreModels.swift +++ b/JammLab/Models/NotationScoreModels.swift @@ -59,12 +59,14 @@ struct NotationRegionLabel: Identifiable, Equatable { } struct NotationMeasureSelection: Equatable, Identifiable { + var partID: NotationPartID var number: Int var startTime: TimeInterval var endTime: TimeInterval var attributes: MeasureAttributes - init(measure: ScoreMeasure) { + init(measure: ScoreMeasure, partID: NotationPartID = .main) { + self.partID = partID self.number = measure.number self.startTime = measure.startTime self.endTime = measure.endTime @@ -72,17 +74,23 @@ struct NotationMeasureSelection: Equatable, Identifiable { } var id: String { - "\(number)-\(startTime)-\(endTime)" + "\(partID.rawValue)-\(number)-\(startTime)-\(endTime)" } func matches(_ measure: ScoreMeasure) -> Bool { - number == measure.number + matches(measure, partID: partID) + } + + func matches(_ measure: ScoreMeasure, partID: NotationPartID) -> Bool { + self.partID == partID + && number == measure.number && abs(startTime - measure.startTime) < NotationMeasureTiming.timelineTolerance && abs(endTime - measure.endTime) < NotationMeasureTiming.timelineTolerance } } struct NotationItemSelection: Equatable, Identifiable { + var partID: NotationPartID var measureNumber: Int var measureStartTime: TimeInterval var measureEndTime: TimeInterval @@ -91,7 +99,8 @@ struct NotationItemSelection: Equatable, Identifiable { var offsetInQuarterNotes: Double var durationInQuarterNotes: Double - init(measure: ScoreMeasure, item: NotationMeasureItem) { + init(measure: ScoreMeasure, item: NotationMeasureItem, partID: NotationPartID? = nil) { + self.partID = partID ?? item.partID self.measureNumber = measure.number self.measureStartTime = measure.startTime self.measureEndTime = measure.endTime @@ -102,11 +111,12 @@ struct NotationItemSelection: Equatable, Identifiable { } var id: String { - "\(measureNumber)-\(measureStartTime)-\(measureEndTime)-\(itemID)-\(offsetInQuarterNotes)-\(durationInQuarterNotes)" + "\(partID.rawValue)-\(measureNumber)-\(measureStartTime)-\(measureEndTime)-\(itemID)-\(offsetInQuarterNotes)-\(durationInQuarterNotes)" } func matches(_ measure: ScoreMeasure, item: NotationMeasureItem) -> Bool { - measureNumber == measure.number + item.partID == partID + && measureNumber == measure.number && abs(measureStartTime - measure.startTime) < NotationMeasureTiming.timelineTolerance && abs(measureEndTime - measure.endTime) < NotationMeasureTiming.timelineTolerance && attributes == measure.attributes @@ -138,6 +148,38 @@ struct NotationMeasureClipboardNotationItem: Equatable { var displayDuration: NotationDuration } +struct NotationPartID: Codable, Hashable, Identifiable, Equatable { + var rawValue: String + + var id: String { rawValue } + + static let main = NotationPartID(rawValue: "main") + + static func stem(_ type: StemType) -> NotationPartID { + NotationPartID(rawValue: "stem:\(type.rawValue)") + } + + var stemType: StemType? { + guard rawValue.hasPrefix("stem:") else { return nil } + return StemType(rawValue: String(rawValue.dropFirst("stem:".count))) + } + + var isMain: Bool { + self == .main + } +} + +struct NotationPartDescriptor: Codable, Equatable, Identifiable { + var id: NotationPartID + var title: String + + static let main = NotationPartDescriptor(id: .main, title: "Main") + + static func stem(_ type: StemType) -> NotationPartDescriptor { + NotationPartDescriptor(id: .stem(type), title: type.title) + } +} + enum NotationMeasureTiming { static let timelineTolerance: TimeInterval = 0.000_001 @@ -260,6 +302,7 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { } var id: String + var partID: NotationPartID var kind: Kind var pitch: NotationPitch? var measureNumber: Int @@ -271,6 +314,7 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { init( id: String = UUID().uuidString, + partID: NotationPartID = .main, kind: Kind = .rest, pitch: NotationPitch? = nil, measureNumber: Int, @@ -281,6 +325,7 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { isSynthesized: Bool = false ) { self.id = id + self.partID = partID self.kind = kind self.pitch = kind == .note ? pitch : nil self.measureNumber = measureNumber @@ -302,6 +347,7 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { func persistedCopy() -> NotationMeasureItem { NotationMeasureItem( id: isSynthesized ? UUID().uuidString : id, + partID: partID, kind: kind, pitch: pitch, measureNumber: measureNumber, @@ -314,6 +360,7 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { private enum CodingKeys: String, CodingKey { case id + case partID case kind case pitch case measureNumber @@ -327,6 +374,7 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decodeIfPresent(String.self, forKey: .id) ?? UUID().uuidString + partID = try container.decodeIfPresent(NotationPartID.self, forKey: .partID) ?? .main kind = try container.decodeIfPresent(Kind.self, forKey: .kind) ?? .rest let decodedPitch = try container.decodeIfPresent(NotationPitch.self, forKey: .pitch) pitch = kind == .note ? decodedPitch : nil @@ -503,6 +551,7 @@ enum NotationRestItemFactory { measureStartTime: TimeInterval, startOffset: Double, remaining: Double, + partID: NotationPartID = .main, isSynthesized: Bool = false, includeTail: Bool = false, id: (Segment) -> String? = { _ in nil } @@ -511,6 +560,7 @@ enum NotationRestItemFactory { .map { segment in restItem( id: id(segment), + partID: partID, measureNumber: measureNumber, measureStartTime: measureStartTime, offsetInQuarterNotes: segment.offsetInQuarterNotes, @@ -523,6 +573,7 @@ enum NotationRestItemFactory { static func restItem( id: String? = nil, + partID: NotationPartID = .main, measureNumber: Int, measureStartTime: TimeInterval, offsetInQuarterNotes: Double, @@ -533,6 +584,7 @@ enum NotationRestItemFactory { if let id { return NotationMeasureItem( id: id, + partID: partID, measureNumber: measureNumber, measureStartTime: measureStartTime, offsetInQuarterNotes: offsetInQuarterNotes, @@ -543,6 +595,7 @@ enum NotationRestItemFactory { } return NotationMeasureItem( + partID: partID, measureNumber: measureNumber, measureStartTime: measureStartTime, offsetInQuarterNotes: offsetInQuarterNotes, diff --git a/JammLab/Models/NotationViewportState.swift b/JammLab/Models/NotationViewportState.swift index 470a596..a4e83fe 100644 --- a/JammLab/Models/NotationViewportState.swift +++ b/JammLab/Models/NotationViewportState.swift @@ -128,46 +128,68 @@ struct NotationScoreContent: Equatable { } final class NotationProjectionCache { - private var cachedInputs: Inputs? - private var cachedContent: NotationScoreContent? + private var cachedEntries: [Scope: Entry] = [:] + private(set) var cacheMissCount = 0 + + var cachedScopeCount: Int { + cachedEntries.count + } func content( tempoMap: TempoMap, duration: TimeInterval, keyName: String?, + partID: NotationPartID = .main, + includesHarmonies: Bool = true, notationItems: [NotationMeasureItem], harmonySymbols: [HarmonySymbol], notes: [TimecodedNote] ) -> NotationScoreContent { + let scopedNotationItems = notationItems.filter { $0.partID == partID } + let scopedHarmonySymbols = includesHarmonies ? harmonySymbols : [] + let regionNotes = notes.filter(\.isRegion) let inputs = Inputs( tempoMap: tempoMap, duration: duration, keyName: keyName, - notationItems: notationItems, - harmonySymbols: harmonySymbols, - notes: notes + notationItems: scopedNotationItems, + harmonySymbols: scopedHarmonySymbols, + regionNotes: regionNotes ) + let scope = Scope(partID: partID, includesHarmonies: includesHarmonies) - if inputs == cachedInputs, let cachedContent { - return cachedContent + if let entry = cachedEntries[scope], entry.inputs == inputs { + return entry.content } + cacheMissCount += 1 let content = NotationViewportFactory().scoreContent( tempoMap: tempoMap, duration: duration, keyName: keyName, - notationItems: notationItems, - harmonySymbols: harmonySymbols, - notes: notes + partID: partID, + includesHarmonies: includesHarmonies, + notationItems: scopedNotationItems, + harmonySymbols: scopedHarmonySymbols, + notes: regionNotes ) - cachedInputs = inputs - cachedContent = content + cachedEntries[scope] = Entry(inputs: inputs, content: content) return content } func invalidate() { - cachedInputs = nil - cachedContent = nil + cachedEntries.removeAll() + cacheMissCount = 0 + } + + private struct Scope: Hashable { + var partID: NotationPartID + var includesHarmonies: Bool + } + + private struct Entry { + var inputs: Inputs + var content: NotationScoreContent } private struct Inputs: Equatable { @@ -176,7 +198,7 @@ final class NotationProjectionCache { var keyName: String? var notationItems: [NotationMeasureItem] var harmonySymbols: [HarmonySymbol] - var notes: [TimecodedNote] + var regionNotes: [TimecodedNote] } } diff --git a/JammLab/Models/ProjectEditableState.swift b/JammLab/Models/ProjectEditableState.swift index 5a15fe2..a5c9241 100644 --- a/JammLab/Models/ProjectEditableState.swift +++ b/JammLab/Models/ProjectEditableState.swift @@ -4,6 +4,8 @@ struct ProjectEditableState: Equatable { var notes: [TimecodedNote] var harmonySymbols: [HarmonySymbol] = [] var notationItems: [NotationMeasureItem] = [] + var stemNotationTrackCollapsed: [StemType: Bool] = [:] + var visibleNotationPartIDs: Set = [.main] var projectKeySelection: ProjectKeySelection? = nil var selectedRegionID: TimecodedNote.ID? var selectedHarmonySymbolID: HarmonySymbol.ID? @@ -26,6 +28,8 @@ struct ProjectPersistedEditableState: Equatable { var notes: [TimecodedNote] var harmonySymbols: [HarmonySymbol] = [] var notationItems: [NotationMeasureItem] = [] + var stemNotationTrackCollapsed: [StemType: Bool] = [:] + var visibleNotationPartIDs: Set = [.main] var projectKeySelection: ProjectKeySelection? = nil var loopRegion: LoopRegion var isLooping: Bool diff --git a/JammLab/Models/ProjectStateNormalizer.swift b/JammLab/Models/ProjectStateNormalizer.swift index 321ce78..8f2cd56 100644 --- a/JammLab/Models/ProjectStateNormalizer.swift +++ b/JammLab/Models/ProjectStateNormalizer.swift @@ -137,6 +137,7 @@ struct ProjectStateNormalizer { .map { item in NotationMeasureItem( id: item.id, + partID: item.partID, kind: item.kind, pitch: item.kind == .note ? item.pitch : nil, measureNumber: max(1, item.measureNumber), @@ -148,6 +149,10 @@ struct ProjectStateNormalizer { ) } .sorted { + if $0.partID.rawValue != $1.partID.rawValue { + return notationPartSortKey($0.partID) < notationPartSortKey($1.partID) + } + if $0.measureNumber != $1.measureNumber { return $0.measureNumber < $1.measureNumber } @@ -164,6 +169,19 @@ struct ProjectStateNormalizer { } } + private static func notationPartSortKey(_ partID: NotationPartID) -> String { + if partID == .main { + return "0-main" + } + + if let stemType = partID.stemType, + let index = StemType.allCases.firstIndex(of: stemType) { + return "1-\(String(format: "%02d", index))-\(stemType.rawValue)" + } + + return "9-\(partID.rawValue)" + } + static func normalizedNote(_ note: TimecodedNote, duration: TimeInterval) -> TimecodedNote { let title = normalizedTitle(note.title, fallback: note.isRegion ? "Region" : "Marker") diff --git a/JammLab/Services/NotationExportService.swift b/JammLab/Services/NotationExportService.swift index 84de7f2..808f60d 100644 --- a/JammLab/Services/NotationExportService.swift +++ b/JammLab/Services/NotationExportService.swift @@ -23,7 +23,21 @@ enum NotationExportFormat { struct NotationExportRequest { var displayName: String var score: NotationScoreState + var parts: [NotationExportPart] = [] var tempoBPM: Double? = nil + + var exportParts: [NotationExportPart] { + let readyParts = parts.filter { $0.score.isReady && !$0.score.measures.isEmpty } + guard !readyParts.isEmpty else { + return [NotationExportPart(descriptor: .main, score: score)] + } + return readyParts + } +} + +struct NotationExportPart: Equatable { + var descriptor: NotationPartDescriptor + var score: NotationScoreState } protocol NotationExportRenderer { @@ -56,7 +70,7 @@ final class NotationExportService { } func export(_ request: NotationExportRequest, format: NotationExportFormat) throws -> Data { - guard request.score.isReady, !request.score.measures.isEmpty else { + guard request.exportParts.contains(where: { $0.score.isReady && !$0.score.measures.isEmpty }) else { throw NotationExportError.emptyScore } @@ -98,7 +112,6 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { let format: NotationExportFormat = .musicXML private let divisions = 480 - private let partID = "P1" private let appVersionProvider: () -> String? init(appVersionProvider: @escaping () -> String? = MusicXMLNotationExportRenderer.bundledAppVersion) { @@ -106,15 +119,23 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { } func render(_ request: NotationExportRequest) throws -> Data { - guard request.score.isReady, !request.score.measures.isEmpty else { + let exportParts = request.exportParts + guard !exportParts.isEmpty else { throw NotationExportError.emptyScore } let root = element("score-partwise", attributes: ["version": "4.0"]) root.addChild(identification()) root.addChild(titleCredit(title: request.displayName)) - root.addChild(partList(title: request.displayName)) - root.addChild(try part(measures: request.score.measures, tempoBPM: request.tempoBPM)) + root.addChild(partList(parts: exportParts, title: request.displayName)) + for (index, exportPart) in exportParts.enumerated() { + root.addChild(try part( + id: musicXMLPartID(for: index), + measures: exportPart.score.measures, + tempoBPM: index == 0 ? request.tempoBPM : nil, + includesAnnotations: exportPart.descriptor.id.isMain + )) + } let document = XMLDocument(rootElement: root) document.version = "1.0" @@ -154,14 +175,22 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { return trimmedVersion.isEmpty ? nil : trimmedVersion } - private func partList(title: String) -> XMLElement { + private func partList(parts: [NotationExportPart], title: String) -> XMLElement { let partList = element("part-list") - let scorePart = element("score-part", attributes: ["id": partID]) - scorePart.addChild(element("part-name", stringValue: title, attributes: ["print-object": "no"])) - partList.addChild(scorePart) + for (index, part) in parts.enumerated() { + let scorePart = element("score-part", attributes: ["id": musicXMLPartID(for: index)]) + let partName = part.descriptor.id.isMain ? title : part.descriptor.title + let attributes = part.descriptor.id.isMain ? ["print-object": "no"] : [:] + scorePart.addChild(element("part-name", stringValue: partName, attributes: attributes)) + partList.addChild(scorePart) + } return partList } + private func musicXMLPartID(for index: Int) -> String { + "P\(index + 1)" + } + private func titleCredit(title: String) -> XMLElement { let credit = element("credit", attributes: ["page": "1"]) credit.addChild(element("credit-type", stringValue: "title")) @@ -179,8 +208,13 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { return credit } - private func part(measures: [ScoreMeasure], tempoBPM: Double?) throws -> XMLElement { - let part = element("part", attributes: ["id": partID]) + private func part( + id: String, + measures: [ScoreMeasure], + tempoBPM: Double?, + includesAnnotations: Bool + ) throws -> XMLElement { + let part = element("part", attributes: ["id": id]) var previousAttributes: MeasureAttributes? for (measureIndex, measure) in measures.enumerated() { @@ -193,11 +227,13 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { measureElement.addChild(metronomeDirection) } - for regionLabel in measure.regionLabels { - measureElement.addChild(regionDirection(for: regionLabel)) + if includesAnnotations { + for regionLabel in measure.regionLabels { + measureElement.addChild(regionDirection(for: regionLabel)) + } } - let sortedHarmonies = measure.harmonies.sorted(by: isHarmonyOrderedByNotationPosition) + let sortedHarmonies = includesAnnotations ? measure.harmonies.sorted(by: isHarmonyOrderedByNotationPosition) : [] var harmonyIndex = sortedHarmonies.startIndex var notationCursorOffsetInQuarterNotes = 0.0 let sortedItems = measure.notationItems.sorted(by: isNotationItemOrderedByNotationPosition) diff --git a/JammLab/Services/NotationNotePlacementResolver.swift b/JammLab/Services/NotationNotePlacementResolver.swift index f94a4fb..b7cae92 100644 --- a/JammLab/Services/NotationNotePlacementResolver.swift +++ b/JammLab/Services/NotationNotePlacementResolver.swift @@ -3,6 +3,7 @@ import Foundation struct NotationNotePlacement: Equatable { var measure: ScoreMeasure + var partID: NotationPartID var targetRestID: String var offsetInQuarterNotes: Double var durationInQuarterNotes: Double @@ -11,16 +12,57 @@ struct NotationNotePlacement: Equatable { var x: CGFloat var y: CGFloat + + init( + measure: ScoreMeasure, + partID: NotationPartID = .main, + targetRestID: String, + offsetInQuarterNotes: Double, + durationInQuarterNotes: Double, + displayDuration: NotationDuration, + pitch: NotationPitch, + x: CGFloat, + y: CGFloat + ) { + self.measure = measure + self.partID = partID + self.targetRestID = targetRestID + self.offsetInQuarterNotes = offsetInQuarterNotes + self.durationInQuarterNotes = durationInQuarterNotes + self.displayDuration = displayDuration + self.pitch = pitch + self.x = x + self.y = y + } } struct NotationRestPlacement: Equatable { var measure: ScoreMeasure + var partID: NotationPartID var targetRestID: String var offsetInQuarterNotes: Double var durationInQuarterNotes: Double var displayDuration: NotationDuration var x: CGFloat + + init( + measure: ScoreMeasure, + partID: NotationPartID = .main, + targetRestID: String, + offsetInQuarterNotes: Double, + durationInQuarterNotes: Double, + displayDuration: NotationDuration, + x: CGFloat + ) { + self.measure = measure + self.partID = partID + self.targetRestID = targetRestID + self.offsetInQuarterNotes = offsetInQuarterNotes + self.durationInQuarterNotes = durationInQuarterNotes + self.displayDuration = displayDuration + self.x = x + } } struct NotationRestSpan: Equatable { @@ -47,6 +89,7 @@ enum NotationNotePlacementResolver { point: CGPoint, staffTop: CGFloat, selectedDuration: NotationDuration, + partID: NotationPartID = .main, lineSpacing: CGFloat = AppTheme.Timeline.notationStaffLineSpacing ) -> NotationNotePlacement? { guard lineSpacing > 0, @@ -78,6 +121,7 @@ enum NotationNotePlacementResolver { return NotationNotePlacement( measure: measure, + partID: partID, targetRestID: targetRest.id, offsetInQuarterNotes: targetRest.offsetInQuarterNotes, durationInQuarterNotes: selectedLength, @@ -94,6 +138,7 @@ enum NotationNotePlacementResolver { point: CGPoint, staffTop: CGFloat, selectedDuration: NotationDuration, + partID: NotationPartID = .main, lineSpacing: CGFloat = AppTheme.Timeline.notationStaffLineSpacing ) -> NotationRestPlacement? { guard isWithinEntryYRange(point.y, staffTop: staffTop, lineSpacing: lineSpacing) else { @@ -117,6 +162,7 @@ enum NotationNotePlacementResolver { return NotationRestPlacement( measure: measure, + partID: partID, targetRestID: targetRest.id, offsetInQuarterNotes: targetRest.offsetInQuarterNotes, durationInQuarterNotes: selectedLength, @@ -391,6 +437,7 @@ enum NotationEntryRecomposer { let insertedItemEnd = insertedItem.offsetInQuarterNotes + insertedItem.durationInQuarterNotes output.append(contentsOf: metricAwareFillerNotationItems( measure: measure, + partID: insertedItem.partID, startOffset: insertedItemEnd, remaining: restSpan.endOffsetInQuarterNotes - insertedItemEnd )) @@ -407,6 +454,7 @@ enum NotationEntryRecomposer { private static func metricAwareFillerNotationItems( measure: ScoreMeasure, + partID: NotationPartID, startOffset: Double, remaining: Double ) -> [NotationMeasureItem] { @@ -424,6 +472,7 @@ enum NotationEntryRecomposer { distanceToQuarterBoundary < rest - NotationMeasureTiming.timelineTolerance, let duration = exactNotationDuration(for: distanceToQuarterBoundary) { output.append(NotationRestItemFactory.restItem( + partID: partID, measureNumber: measure.number, measureStartTime: measure.startTime, offsetInQuarterNotes: cursor, @@ -438,7 +487,8 @@ enum NotationEntryRecomposer { measureNumber: measure.number, measureStartTime: measure.startTime, startOffset: cursor, - remaining: rest + remaining: rest, + partID: partID )) return output } diff --git a/JammLab/Services/NotationViewportFactory.swift b/JammLab/Services/NotationViewportFactory.swift index 2aac62a..75559a0 100644 --- a/JammLab/Services/NotationViewportFactory.swift +++ b/JammLab/Services/NotationViewportFactory.swift @@ -8,6 +8,8 @@ struct NotationViewportFactory { playbackMarkerTime: TimeInterval, isPlaying: Bool, keyName: String?, + partID: NotationPartID = .main, + includesHarmonies: Bool = true, notationItems: [NotationMeasureItem] = [], harmonySymbols: [HarmonySymbol] = [], notes: [TimecodedNote] = [] @@ -16,6 +18,8 @@ struct NotationViewportFactory { tempoMap: tempoMap, duration: duration, keyName: keyName, + partID: partID, + includesHarmonies: includesHarmonies, notationItems: notationItems, harmonySymbols: harmonySymbols, notes: notes @@ -34,6 +38,8 @@ struct NotationViewportFactory { tempoMap: TempoMap, duration: TimeInterval, keyName: String?, + partID: NotationPartID = .main, + includesHarmonies: Bool = true, notationItems: [NotationMeasureItem] = [], harmonySymbols: [HarmonySymbol] = [], notes: [TimecodedNote] = [] @@ -54,6 +60,8 @@ struct NotationViewportFactory { measures.append(decoratedMeasure( cursor, keySignature: keySignature, + partID: partID, + includesHarmonies: includesHarmonies, notationItems: notationItems, harmonySymbols: harmonySymbols, regionNotes: regionNotes @@ -120,6 +128,8 @@ struct NotationViewportFactory { isPlaying: Bool, keyName: String?, visibleMeasureCount: Int, + partID: NotationPartID = .main, + includesHarmonies: Bool = true, notationItems: [NotationMeasureItem] = [], harmonySymbols: [HarmonySymbol] = [], notes: [TimecodedNote] = [] @@ -128,6 +138,8 @@ struct NotationViewportFactory { tempoMap: tempoMap, duration: duration, keyName: keyName, + partID: partID, + includesHarmonies: includesHarmonies, notationItems: notationItems, harmonySymbols: harmonySymbols, notes: notes @@ -195,14 +207,16 @@ struct NotationViewportFactory { private func decoratedMeasure( _ measure: ScoreMeasure, keySignature: KeySignature, + partID: NotationPartID, + includesHarmonies: Bool, notationItems: [NotationMeasureItem], harmonySymbols: [HarmonySymbol], regionNotes: [TimecodedNote] ) -> ScoreMeasure { let keyedMeasure = measure.withKeySignature(keySignature) return keyedMeasure - .withNotationItems(Self.notationItems(for: keyedMeasure, from: notationItems)) - .withHarmonies(harmonies(for: keyedMeasure, from: harmonySymbols)) + .withNotationItems(Self.notationItems(for: keyedMeasure, from: notationItems, partID: partID)) + .withHarmonies(includesHarmonies ? harmonies(for: keyedMeasure, from: harmonySymbols) : []) .withRegionLabels(regionLabels(for: keyedMeasure, from: regionNotes)) } @@ -517,7 +531,8 @@ struct NotationViewportFactory { static func notationItems( for measure: ScoreMeasure, - from notationItems: [NotationMeasureItem] + from notationItems: [NotationMeasureItem], + partID: NotationPartID = .main ) -> [NotationMeasureItem] { let measureLength = NotationMeasureTiming.quarterLength(for: measure.attributes.timeSignature) guard measureLength > 0 else { return [] } @@ -525,6 +540,7 @@ struct NotationViewportFactory { let explicitItems = notationItems .filter { item in item.measureNumber == measure.number + && item.partID == partID && abs(item.measureStartTime - measure.startTime) < timelineTolerance && (item.kind == .rest || item.pitch != nil) } @@ -533,6 +549,7 @@ struct NotationViewportFactory { return [ NotationRestItemFactory.restItem( id: "default-rest-\(measure.number)-\(measure.startTime)-\(measure.endTime)", + partID: partID, measureNumber: measure.number, measureStartTime: measure.startTime, offsetInQuarterNotes: 0, @@ -550,6 +567,7 @@ struct NotationViewportFactory { if offset > cursor + timelineTolerance { normalized.append(contentsOf: fillerItems( measure: measure, + partID: partID, startOffset: cursor, remaining: offset - cursor )) @@ -561,6 +579,7 @@ struct NotationViewportFactory { guard duration > timelineTolerance else { continue } var copy = item + copy.partID = partID copy.measureNumber = measure.number copy.measureStartTime = measure.startTime copy.offsetInQuarterNotes = offset @@ -573,6 +592,7 @@ struct NotationViewportFactory { if cursor < measureLength - timelineTolerance { normalized.append(contentsOf: fillerItems( measure: measure, + partID: partID, startOffset: cursor, remaining: measureLength - cursor )) @@ -583,6 +603,7 @@ struct NotationViewportFactory { static func fillerItems( measure: ScoreMeasure, + partID: NotationPartID = .main, startOffset: Double, remaining: Double ) -> [NotationMeasureItem] { @@ -591,6 +612,7 @@ struct NotationViewportFactory { measureStartTime: measure.startTime, startOffset: startOffset, remaining: remaining, + partID: partID, isSynthesized: true, includeTail: true ) { segment in diff --git a/JammLab/Services/ProjectPersistenceCoordinator.swift b/JammLab/Services/ProjectPersistenceCoordinator.swift index a887245..aaa0df1 100644 --- a/JammLab/Services/ProjectPersistenceCoordinator.swift +++ b/JammLab/Services/ProjectPersistenceCoordinator.swift @@ -50,6 +50,8 @@ struct ProjectDocumentSnapshot { let stemState: StemProjectState? let isVideoWindowOpen: Bool let isNotationTrackCollapsed: Bool + let stemNotationTrackCollapsed: [StemType: Bool] + let visibleNotationPartIDs: Set } struct ProjectPersistenceCoordinator { @@ -231,7 +233,9 @@ struct ProjectPersistenceCoordinator { ), stemState: snapshot.stemState, isVideoWindowOpen: snapshot.importedFile.mediaKind == .video ? snapshot.isVideoWindowOpen : nil, - isNotationTrackCollapsed: snapshot.isNotationTrackCollapsed + isNotationTrackCollapsed: snapshot.isNotationTrackCollapsed, + stemNotationTrackCollapsed: snapshot.stemNotationTrackCollapsed, + visibleNotationPartIDs: snapshot.visibleNotationPartIDs ) } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift index 56077e2..23b940f 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift @@ -5,6 +5,55 @@ extension AudioPlayerViewModel { duration > 0 } + var availableNotationParts: [NotationPartDescriptor] { + [.main] + knownStemNotationPartTypes().map(NotationPartDescriptor.stem) + } + + var visibleNotationParts: [NotationPartDescriptor] { + let visibleIDs = normalizedVisibleNotationPartIDs() + return availableNotationParts.filter { visibleIDs.contains($0.id) } + } + + func isStemNotationTrackCollapsed(_ stemType: StemType) -> Bool { + stemNotationTrackCollapsed[stemType] ?? true + } + + func toggleNotationWindowPartVisibility(_ partID: NotationPartID) { + var next = normalizedVisibleNotationPartIDs() + if next.contains(partID) { + next.remove(partID) + } else { + next.insert(partID) + } + + let normalized = normalizedVisibleNotationPartIDs(from: next) + guard normalized != visibleNotationPartIDs else { return } + visibleNotationPartIDs = normalized + refreshProjectModifiedState() + } + + func normalizedVisibleNotationPartIDs(from rawPartIDs: Set? = nil) -> Set { + let allowedPartIDs = Set(availableNotationParts.map(\.id)) + var normalized = (rawPartIDs ?? visibleNotationPartIDs).intersection(allowedPartIDs) + if normalized.isEmpty { + normalized = allowedPartIDs.contains(.main) ? [.main] : Set(allowedPartIDs.prefix(1)) + } + return normalized + } + + private func knownStemNotationPartTypes() -> [StemType] { + let stemTypes = Set(stemFiles.map(\.type)) + let notationStemTypes = Set(notationItems.compactMap(\.partID.stemType)) + let collapsedStemTypes = Set(stemNotationTrackCollapsed.keys) + let visibleStemTypes = Set(visibleNotationPartIDs.compactMap(\.stemType)) + let knownTypes = stemTypes + .union(notationStemTypes) + .union(collapsedStemTypes) + .union(visibleStemTypes) + + return StemType.allCases.filter { knownTypes.contains($0) } + } + var canChangeNotationDuration: Bool { duration > 0 && (isNotationEntryModeEnabled || canEditSelectedNotationItem) } @@ -73,14 +122,18 @@ extension AudioPlayerViewModel { @discardableResult func requestEditSelectedNotationItem() -> Bool { guard duration > 0, - let selection = selectedNotationItem, - let placement = harmonyPlacement(for: selection) + let selection = selectedNotationItem else { clearNotationItemSelection() return false } + guard selection.partID.isMain else { return false } + guard let placement = harmonyPlacement(for: selection) else { + clearNotationItemSelection() + return false + } - selectedNotationItem = NotationItemSelection(measure: placement.measure, item: placement.item) + selectedNotationItem = NotationItemSelection(measure: placement.measure, item: placement.item, partID: selection.partID) selectedHarmonySymbolID = harmonySymbolID(at: placement.harmonyPlacement.time) pendingHarmonyEditorRequest = HarmonyEditorRequest(time: placement.harmonyPlacement.time) return true @@ -104,7 +157,7 @@ extension AudioPlayerViewModel { return } - let canonicalSelection = NotationItemSelection(measure: match.measure, item: match.item) + let canonicalSelection = NotationItemSelection(measure: match.measure, item: match.item, partID: selection.partID) selectedNotationItem = canonicalSelection selectedNotationMeasures = [] notationMeasureSelectionAnchor = nil @@ -143,25 +196,39 @@ extension AudioPlayerViewModel { duration > 0 && selectedNotationItem != nil } - func selectNotationMeasure(_ measure: ScoreMeasure?, extendingSelection: Bool = false) { + var canEditHarmonyAtSelectedNotationItem: Bool { + guard duration > 0, + let selection = selectedNotationItem, + selection.partID.isMain + else { return false } + + return harmonyPlacement(for: selection) != nil + } + + func selectNotationMeasure( + _ measure: ScoreMeasure?, + extendingSelection: Bool = false, + partID: NotationPartID = .main + ) { guard let measure else { clearNotationMeasureSelection() return } if extendingSelection, let anchor = notationMeasureSelectionAnchor { - let scoreMeasures = currentNotationScoreMeasures() - if let anchorIndex = scoreMeasures.firstIndex(where: anchor.matches), - let measureIndex = scoreMeasures.firstIndex(where: { NotationMeasureSelection(measure: measure).matches($0) }) { + let scoreMeasures = currentNotationScoreMeasures(partID: partID) + if anchor.partID == partID, + let anchorIndex = scoreMeasures.firstIndex(where: { anchor.matches($0, partID: partID) }), + let measureIndex = scoreMeasures.firstIndex(where: { NotationMeasureSelection(measure: measure, partID: partID).matches($0, partID: partID) }) { let range = min(anchorIndex, measureIndex)...max(anchorIndex, measureIndex) - selectedNotationMeasures = range.map { NotationMeasureSelection(measure: scoreMeasures[$0]) } + selectedNotationMeasures = range.map { NotationMeasureSelection(measure: scoreMeasures[$0], partID: partID) } } else { - selectedNotationMeasures = [NotationMeasureSelection(measure: measure)] - notationMeasureSelectionAnchor = NotationMeasureSelection(measure: measure) + selectedNotationMeasures = [NotationMeasureSelection(measure: measure, partID: partID)] + notationMeasureSelectionAnchor = NotationMeasureSelection(measure: measure, partID: partID) } } else { - selectedNotationMeasures = [NotationMeasureSelection(measure: measure)] - notationMeasureSelectionAnchor = NotationMeasureSelection(measure: measure) + selectedNotationMeasures = [NotationMeasureSelection(measure: measure, partID: partID)] + notationMeasureSelectionAnchor = NotationMeasureSelection(measure: measure, partID: partID) } selectedHarmonySymbolID = nil selectedNotationItem = nil @@ -179,7 +246,7 @@ extension AudioPlayerViewModel { @discardableResult func insertNotationNote(_ placement: NotationNotePlacement) -> Bool { guard duration > 0, - let measure = currentNotationScoreMeasures().first(where: { + let measure = currentNotationScoreMeasures(partID: placement.partID).first(where: { $0.number == placement.measure.number && abs($0.startTime - placement.measure.startTime) < NotationMeasureTiming.timelineTolerance && abs($0.endTime - placement.measure.endTime) < NotationMeasureTiming.timelineTolerance @@ -190,6 +257,7 @@ extension AudioPlayerViewModel { } let noteItem = NotationMeasureItem( + partID: placement.partID, kind: .note, pitch: placement.pitch, measureNumber: measure.number, @@ -212,7 +280,8 @@ extension AudioPlayerViewModel { reselectNotationItem( inMeasureNumber: measure.number, measureStartTime: measure.startTime, - itemID: noteItem.id + itemID: noteItem.id, + partID: placement.partID ) } @@ -223,7 +292,7 @@ extension AudioPlayerViewModel { @discardableResult func insertNotationRest(_ placement: NotationRestPlacement) -> Bool { guard duration > 0, - let measure = currentNotationScoreMeasures().first(where: { + let measure = currentNotationScoreMeasures(partID: placement.partID).first(where: { $0.number == placement.measure.number && abs($0.startTime - placement.measure.startTime) < NotationMeasureTiming.timelineTolerance && abs($0.endTime - placement.measure.endTime) < NotationMeasureTiming.timelineTolerance @@ -235,6 +304,7 @@ extension AudioPlayerViewModel { } let restItem = NotationMeasureItem( + partID: placement.partID, kind: .rest, measureNumber: measure.number, measureStartTime: measure.startTime, @@ -250,7 +320,7 @@ extension AudioPlayerViewModel { selectedNotationMeasures = [] notationMeasureSelectionAnchor = nil selectedHarmonySymbolID = nil - selectedNotationItem = NotationItemSelection(measure: measure, item: targetRest) + selectedNotationItem = NotationItemSelection(measure: measure, item: targetRest, partID: placement.partID) return true } @@ -268,7 +338,8 @@ extension AudioPlayerViewModel { reselectNotationItem( inMeasureNumber: measure.number, measureStartTime: measure.startTime, - itemID: restItem.id + itemID: restItem.id, + partID: placement.partID ) } @@ -293,6 +364,7 @@ extension AudioPlayerViewModel { let item = match.item let updatedItem = NotationMeasureItem( id: item.id, + partID: item.partID, kind: .note, pitch: pitch, measureNumber: item.measureNumber, @@ -311,7 +383,8 @@ extension AudioPlayerViewModel { reselectNotationItem( inMeasureNumber: measure.number, measureStartTime: measure.startTime, - itemID: updatedItem.id + itemID: updatedItem.id, + partID: item.partID ) } @@ -351,6 +424,7 @@ extension AudioPlayerViewModel { let item = match.item let replacementRest = NotationMeasureItem( id: item.id, + partID: item.partID, kind: .rest, pitch: nil, measureNumber: item.measureNumber, @@ -399,9 +473,11 @@ extension AudioPlayerViewModel { else { return false } + let targetPartID = selectedNotationMeasures.first?.partID ?? .main - let pastedItemsByMeasure = zip(targetMeasures, clipboard.measures).map { targetMeasure, sourceMeasure in - sourceMeasure.items + let pastedItemsByMeasure: [[NotationMeasureClipboardItem]] = zip(targetMeasures, clipboard.measures).map { targetMeasure, sourceMeasure in + guard targetPartID == .main else { return [] as [NotationMeasureClipboardItem] } + return sourceMeasure.items .filter { NotationMeasureTiming.isValidHarmonyOffset( $0.offsetInQuarterNotes, @@ -429,20 +505,22 @@ extension AudioPlayerViewModel { else { selectedHarmonySymbolID = nil selectedNotationItem = nil - selectedNotationMeasures = targetMeasures.map(NotationMeasureSelection.init) + selectedNotationMeasures = targetMeasures.map { NotationMeasureSelection(measure: $0, partID: targetPartID) } notationMeasureSelectionAnchor = selectedNotationMeasures.first return true } performUndoableEdit(targetMeasures.count == 1 ? "Paste Measure" : "Paste Measures") { harmonySymbols.removeAll { symbol in - targetMeasures.contains { targetMeasure in + guard targetPartID == .main else { return false } + return targetMeasures.contains { targetMeasure in NotationMeasureTiming.containsEventTime(symbol.time, in: targetMeasure) } } notationItems.removeAll { item in targetMeasures.contains { targetMeasure in item.measureNumber == targetMeasure.number + && item.partID == targetPartID && abs(item.measureStartTime - targetMeasure.startTime) < NotationMeasureTiming.timelineTolerance } } @@ -461,6 +539,7 @@ extension AudioPlayerViewModel { }) notationItems.append(contentsOf: pastedNotationItems.map { item in NotationMeasureItem( + partID: targetPartID, kind: item.kind, pitch: item.kind == .note ? item.pitch : nil, measureNumber: targetMeasure.number, @@ -476,7 +555,7 @@ extension AudioPlayerViewModel { notationItems = ProjectStateNormalizer.normalizedNotationItems(notationItems, duration: duration) selectedHarmonySymbolID = nil selectedNotationItem = nil - selectedNotationMeasures = targetMeasures.map(NotationMeasureSelection.init) + selectedNotationMeasures = targetMeasures.map { NotationMeasureSelection(measure: $0, partID: targetPartID) } notationMeasureSelectionAnchor = selectedNotationMeasures.first } @@ -562,7 +641,9 @@ extension AudioPlayerViewModel { private func harmonyPlacement( for selection: NotationItemSelection ) -> (measure: ScoreMeasure, item: NotationMeasureItem, harmonyPlacement: HarmonyPlacement)? { - guard let match = notationItemMatch(for: selection) else { return nil } + guard selection.partID == .main, + let match = notationItemMatch(for: selection) + else { return nil } guard NotationMeasureTiming.isValidHarmonyOffset( selection.offsetInQuarterNotes, in: match.measure.attributes.timeSignature @@ -585,13 +666,14 @@ extension AudioPlayerViewModel { private func notationItemMatch( for selection: NotationItemSelection ) -> (measure: ScoreMeasure, item: NotationMeasureItem)? { - for measure in currentNotationScoreMeasures() { + for measure in currentNotationScoreMeasures(partID: selection.partID) { guard measure.number == selection.measureNumber else { continue } if let exact = measure.notationItems.first(where: { selection.matches(measure, item: $0) }) { return (measure, exact) } if let byOffset = measure.notationItems.first(where: { - abs($0.offsetInQuarterNotes - selection.offsetInQuarterNotes) < NotationMeasureTiming.timelineTolerance + $0.partID == selection.partID + && abs($0.offsetInQuarterNotes - selection.offsetInQuarterNotes) < NotationMeasureTiming.timelineTolerance }) { return (measure, byOffset) } @@ -652,6 +734,7 @@ extension AudioPlayerViewModel { let duration = min(selectedLength, rest) items.append(NotationMeasureItem( id: selectedItem.id, + partID: selectedItem.partID, kind: .note, pitch: pitch, measureNumber: measure.number, @@ -664,6 +747,7 @@ extension AudioPlayerViewModel { rest -= duration items.append(contentsOf: fillerNotationItems( measure: measure, + partID: selectedItem.partID, startOffset: cursor, remaining: rest )) @@ -676,6 +760,7 @@ extension AudioPlayerViewModel { for _ in 0.. [NotationMeasureItem] { @@ -715,7 +803,8 @@ extension AudioPlayerViewModel { measureNumber: measure.number, measureStartTime: measure.startTime, startOffset: startOffset, - remaining: remaining + remaining: remaining, + partID: partID ) } @@ -754,7 +843,9 @@ extension AudioPlayerViewModel { } private func notationClipboardItems(in measure: ScoreMeasure) -> [NotationMeasureClipboardItem] { - harmonySymbols + guard measure.notationItems.allSatisfy({ $0.partID == .main }) else { return [] } + + return harmonySymbols .compactMap { symbol -> (HarmonySymbol, Double)? in guard NotationMeasureTiming.containsEventTime(symbol.time, in: measure) else { return nil @@ -799,9 +890,11 @@ extension AudioPlayerViewModel { return nil } - selectedNotationMeasures = measures.map(NotationMeasureSelection.init) + let partID = selectedNotationMeasures.first?.partID ?? .main + selectedNotationMeasures = measures.map { NotationMeasureSelection(measure: $0, partID: partID) } if let anchor = notationMeasureSelectionAnchor, - measures.contains(where: anchor.matches) { + anchor.partID == partID, + measures.contains(where: { anchor.matches($0, partID: partID) }) { notationMeasureSelectionAnchor = anchor } else { notationMeasureSelectionAnchor = selectedNotationMeasures.first @@ -815,22 +908,25 @@ extension AudioPlayerViewModel { return nil } - selectedNotationMeasures = targetMeasures.map(NotationMeasureSelection.init) + let partID = selectedNotationMeasures.first?.partID ?? .main + selectedNotationMeasures = targetMeasures.map { NotationMeasureSelection(measure: $0, partID: partID) } notationMeasureSelectionAnchor = selectedNotationMeasures.first return targetMeasures } private func currentSelectedNotationMeasures() -> [ScoreMeasure] { guard !selectedNotationMeasures.isEmpty else { return [] } + let partID = selectedNotationMeasures[0].partID + guard selectedNotationMeasures.allSatisfy({ $0.partID == partID }) else { return [] } - let scoreMeasures = currentNotationScoreMeasures() - guard let firstIndex = scoreMeasures.firstIndex(where: selectedNotationMeasures[0].matches) else { return [] } + let scoreMeasures = currentNotationScoreMeasures(partID: partID) + guard let firstIndex = scoreMeasures.firstIndex(where: { selectedNotationMeasures[0].matches($0, partID: partID) }) else { return [] } let expectedRange = firstIndex..<(firstIndex + selectedNotationMeasures.count) guard expectedRange.upperBound <= scoreMeasures.endIndex else { return [] } let expectedMeasures = expectedRange.map { scoreMeasures[$0] } return zip(expectedMeasures, selectedNotationMeasures).allSatisfy { measure, selection in - selection.matches(measure) + selection.matches(measure, partID: partID) } ? expectedMeasures : [] } @@ -839,9 +935,10 @@ extension AudioPlayerViewModel { let selectedMeasures = currentSelectedNotationMeasures() guard let firstSelectedMeasure = selectedMeasures.first else { return nil } - let scoreMeasures = currentNotationScoreMeasures() - let firstSelection = NotationMeasureSelection(measure: firstSelectedMeasure) - guard let startIndex = scoreMeasures.firstIndex(where: { firstSelection.matches($0) }) else { + let partID = selectedNotationMeasures.first?.partID ?? .main + let scoreMeasures = currentNotationScoreMeasures(partID: partID) + let firstSelection = NotationMeasureSelection(measure: firstSelectedMeasure, partID: partID) + guard let startIndex = scoreMeasures.firstIndex(where: { firstSelection.matches($0, partID: partID) }) else { return nil } @@ -850,7 +947,9 @@ extension AudioPlayerViewModel { return (startIndex..<(startIndex + targetCount)).map { scoreMeasures[$0] } } - private func currentNotationScoreMeasures() -> [ScoreMeasure] { + private func currentNotationScoreMeasures( + partID: NotationPartID = .main + ) -> [ScoreMeasure] { NotationViewportFactory().scoreState( tempoMap: tempoMap, duration: duration, @@ -858,6 +957,8 @@ extension AudioPlayerViewModel { playbackMarkerTime: playbackMarkerTime, isPlaying: playbackState == .playing, keyName: effectiveKeyName, + partID: partID, + includesHarmonies: partID.isMain, notationItems: notationItems, harmonySymbols: harmonySymbols, notes: notes @@ -870,7 +971,7 @@ extension AudioPlayerViewModel { with replacementItem: NotationMeasureItem ) { let updatedItems = measure.notationItems.map { item in - item.id == targetItem.id ? replacementItem : item.persistedCopy() + item.id == targetItem.id && item.partID == targetItem.partID ? replacementItem : item.persistedCopy() } replaceNotationMeasureItems(in: measure, with: updatedItems) } @@ -881,6 +982,7 @@ extension AudioPlayerViewModel { ) { notationItems.removeAll { $0.measureNumber == measure.number + && $0.partID == (replacementItems.first?.partID ?? measure.notationItems.first?.partID ?? .main) && abs($0.measureStartTime - measure.startTime) < NotationMeasureTiming.timelineTolerance } notationItems.append(contentsOf: replacementItems) @@ -906,17 +1008,18 @@ extension AudioPlayerViewModel { private func reselectNotationItem( inMeasureNumber measureNumber: Int, measureStartTime: TimeInterval, - itemID: String + itemID: String, + partID: NotationPartID ) { - guard let updatedMeasure = currentNotationScoreMeasures().first(where: { + guard let updatedMeasure = currentNotationScoreMeasures(partID: partID).first(where: { $0.number == measureNumber && abs($0.startTime - measureStartTime) < NotationMeasureTiming.timelineTolerance - }), let selected = updatedMeasure.notationItems.first(where: { $0.id == itemID }) else { + }), let selected = updatedMeasure.notationItems.first(where: { $0.id == itemID && $0.partID == partID }) else { selectedNotationItem = nil return } - selectedNotationItem = NotationItemSelection(measure: updatedMeasure, item: selected) + selectedNotationItem = NotationItemSelection(measure: updatedMeasure, item: selected, partID: partID) } private func locatePlaybackMarkerAtFirstSelectedNotationMeasure() { diff --git a/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift b/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift index 997918b..06810b9 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift @@ -67,6 +67,7 @@ extension AudioPlayerViewModel { return NotationExportRequest( displayName: notationExportDisplayName, score: score, + parts: currentNotationExportParts(mainScore: score), tempoBPM: beatGridSettings.bpm ) } @@ -85,6 +86,28 @@ extension AudioPlayerViewModel { ) } + private func currentNotationExportParts(mainScore: NotationScoreState) -> [NotationExportPart] { + availableNotationParts.compactMap { part in + let score = part.id.isMain + ? mainScore + : NotationViewportFactory().scoreState( + tempoMap: tempoMap, + duration: duration, + currentTime: currentTime, + playbackMarkerTime: playbackMarkerTime, + isPlaying: playbackState == .playing, + keyName: effectiveKeyName, + partID: part.id, + includesHarmonies: false, + notationItems: notationItems, + harmonySymbols: harmonySymbols, + notes: notes + ) + guard score.isReady, !score.measures.isEmpty else { return nil } + return NotationExportPart(descriptor: part, score: score) + } + } + private var notationExportDisplayName: String { (importedFile?.displayName as NSString?)?.deletingPathExtension ?? "Notation" } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift index e19ce28..29d18c6 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift @@ -115,6 +115,8 @@ extension AudioPlayerViewModel { notes = [] harmonySymbols = [] notationItems = [] + stemNotationTrackCollapsed = [:] + visibleNotationPartIDs = [.main] projectKeySelection = nil clearTransientEditingState() loopRegion = .empty @@ -173,6 +175,8 @@ extension AudioPlayerViewModel { notes = [] harmonySymbols = [] notationItems = [] + stemNotationTrackCollapsed = [:] + visibleNotationPartIDs = [.main] projectKeySelection = nil clearTransientEditingState() loopRegion = LoopRegion(start: 0, end: file.duration).clamped(to: file.duration) @@ -272,6 +276,8 @@ extension AudioPlayerViewModel { setPlaybackMarkerExactly(to: restoredPlaybackMarkerTime) restoreVideoWindowOpenState(file.mediaKind == .video && project.isVideoWindowOpen == true) isNotationTrackCollapsed = project.isNotationTrackCollapsed ?? true + stemNotationTrackCollapsed = project.stemNotationTrackCollapsed + visibleNotationPartIDs = normalizedVisibleNotationPartIDs(from: project.visibleNotationPartIDs) isImporting = false clearUndoHistory() markProjectClean() @@ -404,7 +410,9 @@ extension AudioPlayerViewModel { timelineVisibleRange: userTimelineVisibleRange, stemState: makeStemProjectState(), isVideoWindowOpen: isVideoWindowOpen, - isNotationTrackCollapsed: isNotationTrackCollapsed + isNotationTrackCollapsed: isNotationTrackCollapsed, + stemNotationTrackCollapsed: stemNotationTrackCollapsed, + visibleNotationPartIDs: normalizedVisibleNotationPartIDs() ) } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift b/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift index 5ea4c19..0c2e057 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift @@ -6,6 +6,8 @@ extension AudioPlayerViewModel { notes: notes, harmonySymbols: harmonySymbols, notationItems: notationItems, + stemNotationTrackCollapsed: stemNotationTrackCollapsed, + visibleNotationPartIDs: normalizedVisibleNotationPartIDs(), projectKeySelection: projectKeySelection, selectedRegionID: selectedRegionID, selectedHarmonySymbolID: selectedHarmonySymbolID, @@ -34,6 +36,8 @@ extension AudioPlayerViewModel { notes: ProjectStateNormalizer.normalizedNotes(notes, duration: duration), harmonySymbols: ProjectStateNormalizer.normalizedHarmonySymbols(harmonySymbols, duration: duration), notationItems: ProjectStateNormalizer.normalizedNotationItems(notationItems, duration: duration), + stemNotationTrackCollapsed: stemNotationTrackCollapsed, + visibleNotationPartIDs: normalizedVisibleNotationPartIDs(), projectKeySelection: projectKeySelection, loopRegion: clampedLoop, isLooping: isLooping, @@ -77,6 +81,8 @@ extension AudioPlayerViewModel { notes = ProjectStateNormalizer.normalizedNotes(state.notes, duration: duration) harmonySymbols = ProjectStateNormalizer.normalizedHarmonySymbols(state.harmonySymbols, duration: duration) notationItems = ProjectStateNormalizer.normalizedNotationItems(state.notationItems, duration: duration) + stemNotationTrackCollapsed = state.stemNotationTrackCollapsed + visibleNotationPartIDs = normalizedVisibleNotationPartIDs(from: state.visibleNotationPartIDs) projectKeySelection = state.projectKeySelection selectedRegionID = availableRegionID(state.selectedRegionID) selectedHarmonySymbolID = availableHarmonySymbolID(state.selectedHarmonySymbolID) @@ -131,6 +137,12 @@ extension AudioPlayerViewModel { refreshProjectModifiedState() } + func setStemNotationTrackCollapsed(_ stemType: StemType, isCollapsed: Bool) { + guard stemNotationTrackCollapsed[stemType] != isCollapsed else { return } + stemNotationTrackCollapsed[stemType] = isCollapsed + refreshProjectModifiedState() + } + func registerUndoState(_ state: ProjectEditableState, actionName: String) { guard !isRestoringUndoState, let undoManager else { return } diff --git a/JammLab/ViewModels/AudioPlayerViewModel.swift b/JammLab/ViewModels/AudioPlayerViewModel.swift index 9935caa..38157c2 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel.swift @@ -71,6 +71,8 @@ final class AudioPlayerViewModel: ObservableObject { @Published var isSnapEnabled = false @Published var isVideoWindowOpen = false @Published var isNotationTrackCollapsed = true + @Published var stemNotationTrackCollapsed: [StemType: Bool] = [:] + @Published var visibleNotationPartIDs: Set = [.main] @Published var mainTrackVolume: Float = AppSliderDefaults.mainTrackVolume @Published var clickVolume: Float = AudioPlayerViewModel.restoredClickVolume() @Published var undoStateRevision = 0 diff --git a/JammLab/Views/MainWorkspacePanels.swift b/JammLab/Views/MainWorkspacePanels.swift index 9f342f5..93a1cc7 100644 --- a/JammLab/Views/MainWorkspacePanels.swift +++ b/JammLab/Views/MainWorkspacePanels.swift @@ -109,12 +109,23 @@ extension ContentView { var timelineTracksHeight: CGFloat { AppTheme.Timeline.tracksMinimumHeight( stemRowCount: timelineStemRowCount, - isNotationTrackCollapsed: viewModel.isNotationTrackCollapsed + isNotationTrackCollapsed: viewModel.isNotationTrackCollapsed, + expandedStemNotationCount: expandedStemNotationRowCount ) } + var expandedStemNotationRowCount: Int { + viewModel.stemFiles.filter { + !viewModel.isStemNotationTrackCollapsed($0.type) + }.count + } + var timelineMinimumContentHeight: CGFloat { - AppTheme.Timeline.minimumContentHeight(stemRowCount: timelineStemRowCount) + AppTheme.Timeline.minimumContentHeight( + stemRowCount: timelineStemRowCount, + isNotationTrackCollapsed: viewModel.isNotationTrackCollapsed, + expandedStemNotationCount: expandedStemNotationRowCount + ) } var timelineColumnMinimumHeight: CGFloat { @@ -144,11 +155,13 @@ extension ContentView { visibleMeasureCount: 1, keySignature: KeySignature.normalized(from: viewModel.effectiveKeyName) ) - : notationViewportState(availableWidth: notationTrackContentWidth), + : notationViewportState(availableWidth: notationTrackContentWidth, partID: .main), + stemNotationViewports: stemNotationViewports(availableWidth: notationTrackContentWidth), notationDurationDenominator: viewModel.notationDurationDenominator, canChangeNotationDuration: viewModel.canChangeNotationDuration, notationEntryMode: viewModel.notationEntryMode, isNotationTrackCollapsed: viewModel.isNotationTrackCollapsed, + stemNotationTrackCollapsed: viewModel.stemNotationTrackCollapsed, isLoadingPeakform: viewModel.isBuildingWaveform, mainTrackVolume: viewModel.mainTrackVolume, playbackMode: viewModel.playbackMode, @@ -159,12 +172,17 @@ extension ContentView { ) } - func notationViewportState(availableWidth: CGFloat) -> NotationViewportState { + func notationViewportState( + availableWidth: CGFloat, + partID: NotationPartID + ) -> NotationViewportState { let factory = NotationViewportFactory() let content = notationProjectionCache.content( tempoMap: viewModel.tempoMap, duration: viewModel.duration, keyName: viewModel.effectiveKeyName, + partID: partID, + includesHarmonies: partID.isMain, notationItems: viewModel.notationItems, harmonySymbols: viewModel.harmonySymbols, notes: viewModel.notes @@ -194,13 +212,29 @@ extension ContentView { ) } + func stemNotationViewports(availableWidth: CGFloat) -> [StemType: NotationViewportState] { + Dictionary(uniqueKeysWithValues: viewModel.stemFiles.map { stemFile in + let isCollapsed = viewModel.isStemNotationTrackCollapsed(stemFile.type) + let viewport: NotationViewportState = isCollapsed + ? .pending( + visibleMeasureCount: 1, + keySignature: KeySignature.normalized(from: viewModel.effectiveKeyName) + ) + : notationViewportState( + availableWidth: availableWidth, + partID: .stem(stemFile.type) + ) + return (stemFile.type, viewport) + }) + } + var timelineViewActions: TimelineViewActions { TimelineViewActions( locatePlaybackMarker: { viewModel.locatePlaybackMarker(to: $0) }, locatePlaybackMarkerExactly: { viewModel.locatePlaybackMarkerExactly(to: $0) }, addNote: { viewModel.addNote(at: $0) }, selectHarmony: { viewModel.selectHarmonySymbol(id: $0) }, - selectNotationMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1) }, + selectNotationMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1, partID: $2) }, selectNotationItem: { viewModel.selectNotationItem($0, shouldAudition: $1) }, saveHarmony: { viewModel.saveHarmonySymbol($0) }, deleteHarmony: { viewModel.deleteHarmonySymbol(id: $0) }, @@ -222,6 +256,7 @@ extension ContentView { timelineScroll: { viewModel.handleTimelineScroll(deltaX: $0, deltaY: $1, anchorTime: $2) }, mainTrackVolumeChanged: { viewModel.setMainTrackVolume($0) }, notationTrackCollapsedChanged: { viewModel.setNotationTrackCollapsed($0) }, + stemNotationTrackCollapsedChanged: { viewModel.setStemNotationTrackCollapsed($0, isCollapsed: $1) }, notationDurationChanged: { viewModel.setNotationDurationDenominator($0) }, notationNoteEntryModeToggled: { viewModel.toggleNotationNoteEntryMode() }, notationRestEntryModeToggled: { viewModel.toggleNotationRestEntryMode() }, diff --git a/JammLab/Views/NotationTrackLayoutItems.swift b/JammLab/Views/NotationTrackLayoutItems.swift index 153e0be..1101522 100644 --- a/JammLab/Views/NotationTrackLayoutItems.swift +++ b/JammLab/Views/NotationTrackLayoutItems.swift @@ -23,6 +23,26 @@ struct NotationItemLayoutItem: Equatable, Identifiable { } enum NotationTrackLayoutItems { + static func selectedMeasures( + _ selectedMeasures: [NotationMeasureSelection], + for partID: NotationPartID + ) -> [NotationMeasureSelection] { + selectedMeasures.filter { $0.partID == partID } + } + + static func selectedMeasureIndices( + visibleMeasures: [ScoreMeasure], + selectedMeasures: [NotationMeasureSelection], + partID: NotationPartID + ) -> [Int] { + let partSelections = Self.selectedMeasures(selectedMeasures, for: partID) + return visibleMeasures.indices.filter { index in + partSelections.contains(where: { + $0.matches(visibleMeasures[index], partID: partID) + }) + } + } + static func regionLabels( visibleMeasures: [ScoreMeasure], geometries: [NotationMeasureCanvasGeometry] @@ -114,6 +134,38 @@ enum NotationTrackLayoutItems { } } +enum NotationTrackAccessibility { + static func value( + visibleMeasures: [ScoreMeasure], + keySignature: KeySignature, + timeSignature: TimeSignature, + selectedMeasures: [NotationMeasureSelection], + partID: NotationPartID + ) -> String { + guard let first = visibleMeasures.first, let last = visibleMeasures.last else { + return "Pending tempo" + } + + let partSelections = NotationTrackLayoutItems.selectedMeasures( + selectedMeasures, + for: partID + ) + let selectedMeasureText: String + if partSelections.isEmpty { + selectedMeasureText = "" + } else if partSelections.count == 1, let selectedMeasure = partSelections.first { + selectedMeasureText = ", selected measure \(selectedMeasure.number)" + } else if let firstSelectedMeasure = partSelections.first, + let lastSelectedMeasure = partSelections.last { + selectedMeasureText = ", selected measures \(firstSelectedMeasure.number) through \(lastSelectedMeasure.number)" + } else { + selectedMeasureText = "" + } + + return "Measures \(first.number) through \(last.number), \(keySignature.displayName), \(timeSignature.displayText)\(selectedMeasureText)" + } +} + private struct RegionLabelLayoutCandidate: Equatable { var label: NotationRegionLabel var x: CGFloat diff --git a/JammLab/Views/NotationTrackView.swift b/JammLab/Views/NotationTrackView.swift index 042ae18..b24de51 100644 --- a/JammLab/Views/NotationTrackView.swift +++ b/JammLab/Views/NotationTrackView.swift @@ -5,7 +5,7 @@ private let notationTrackCoordinateSpaceName = "NotationTrackCoordinateSpace" struct NotationTrackActions { var selectHarmony: (HarmonySymbol.ID?) -> Void - var selectMeasure: (ScoreMeasure?, Bool) -> Void + var selectMeasure: (ScoreMeasure?, Bool, NotationPartID) -> Void var selectItem: (NotationItemSelection?, Bool) -> Void var insertNotationNote: (NotationNotePlacement) -> Bool var insertNotationRest: (NotationRestPlacement) -> Bool @@ -20,6 +20,7 @@ struct NotationTrackActions { struct NotationTrackView: View { let state: NotationViewportState + let partID: NotationPartID let playbackDisplayState: PlaybackDisplayState? let selectedHarmonySymbolID: HarmonySymbol.ID? let selectedMeasures: [NotationMeasureSelection] @@ -27,6 +28,7 @@ struct NotationTrackView: View { let selectedDuration: NotationDuration let entryMode: NotationEntryMode? let pendingEditorRequest: HarmonyEditorRequest? + let showsRegionLabels: Bool let actions: NotationTrackActions let cornerRadius: CGFloat @@ -39,6 +41,7 @@ struct NotationTrackView: View { init( state: NotationViewportState, + partID: NotationPartID = .main, playbackDisplayState: PlaybackDisplayState? = nil, selectedHarmonySymbolID: HarmonySymbol.ID? = nil, selectedMeasures: [NotationMeasureSelection] = [], @@ -46,10 +49,12 @@ struct NotationTrackView: View { selectedDuration: NotationDuration = NotationDuration(), entryMode: NotationEntryMode? = nil, pendingEditorRequest: HarmonyEditorRequest? = nil, + showsRegionLabels: Bool = true, actions: NotationTrackActions = .noop, cornerRadius: CGFloat = AppTheme.Radius.small ) { self.state = state + self.partID = partID self.playbackDisplayState = playbackDisplayState self.selectedHarmonySymbolID = selectedHarmonySymbolID self.selectedMeasures = selectedMeasures @@ -57,6 +62,7 @@ struct NotationTrackView: View { self.selectedDuration = selectedDuration self.entryMode = entryMode self.pendingEditorRequest = pendingEditorRequest + self.showsRegionLabels = showsRegionLabels self.actions = actions self.cornerRadius = cornerRadius } @@ -96,11 +102,13 @@ struct NotationTrackView: View { height: proxy.size.height, attributeDisplays: attributeDisplays ) - regionLabelsLayer( - width: contentWidth, - height: proxy.size.height, - attributeDisplays: attributeDisplays - ) + if showsRegionLabels { + regionLabelsLayer( + width: contentWidth, + height: proxy.size.height, + attributeDisplays: attributeDisplays + ) + } harmonySymbolsLayer( width: contentWidth, height: proxy.size.height, @@ -116,11 +124,13 @@ struct NotationTrackView: View { height: proxy.size.height, attributeDisplays: attributeDisplays ) - harmonyEditorLayer( - width: contentWidth, - height: proxy.size.height, - attributeDisplays: attributeDisplays - ) + if partID.isMain { + harmonyEditorLayer( + width: contentWidth, + height: proxy.size.height, + attributeDisplays: attributeDisplays + ) + } notationEntryLayer( width: contentWidth, height: proxy.size.height, @@ -142,7 +152,9 @@ struct NotationTrackView: View { deleteSelectedNotationItemOrHarmony() } .onChange(of: pendingEditorRequest?.id) { _, _ in - handlePendingEditorRequest() + if partID.isMain { + handlePendingEditorRequest() + } } .onChange(of: entryMode) { _, mode in draggedNotePitchPreview = nil @@ -558,9 +570,11 @@ struct NotationTrackView: View { + AppTheme.Timeline.notationStaffLineSpacing * 4 + AppTheme.Spacing.lg let overlayHeight = max(1, overlayBottom - overlayY) - let selectedMeasureIndices = state.visibleMeasures.indices.filter { index in - selectedMeasures.contains(where: { $0.matches(state.visibleMeasures[index]) }) - } + let selectedMeasureIndices = NotationTrackLayoutItems.selectedMeasureIndices( + visibleMeasures: state.visibleMeasures, + selectedMeasures: selectedMeasures, + partID: partID + ) let selectionRuns = NotationMeasureLayout.selectionOverlayRuns( selectedMeasureIndices: selectedMeasureIndices, geometries: geometries @@ -611,7 +625,7 @@ struct NotationTrackView: View { .onTapGesture { isTrackFocused = true editingDraft = nil - actions.selectMeasure(state.visibleMeasures[index], isShiftClickActive) + actions.selectMeasure(state.visibleMeasures[index], isShiftClickActive, partID) } .accessibilityHidden(true) } @@ -1490,7 +1504,8 @@ struct NotationTrackView: View { geometry: target.geometry, point: point, staffTop: staffTop(in: height), - selectedDuration: selectedDuration + selectedDuration: selectedDuration, + partID: partID ) } @@ -1511,7 +1526,8 @@ struct NotationTrackView: View { geometry: target.geometry, point: point, staffTop: staffTop(in: height), - selectedDuration: selectedDuration + selectedDuration: selectedDuration, + partID: partID ) } @@ -1540,22 +1556,13 @@ struct NotationTrackView: View { } private var accessibilityValue: String { - guard let first = state.visibleMeasures.first, let last = state.visibleMeasures.last else { - return "Pending tempo" - } - - let selectedMeasureText: String - if selectedMeasures.isEmpty { - selectedMeasureText = "" - } else if selectedMeasures.count == 1, let selectedMeasure = selectedMeasures.first { - selectedMeasureText = ", selected measure \(selectedMeasure.number)" - } else if let firstSelectedMeasure = selectedMeasures.first, - let lastSelectedMeasure = selectedMeasures.last { - selectedMeasureText = ", selected measures \(firstSelectedMeasure.number) through \(lastSelectedMeasure.number)" - } else { - selectedMeasureText = "" - } - return "Measures \(first.number) through \(last.number), \(state.keySignature.displayName), \(state.timeSignature.displayText)\(selectedMeasureText)" + NotationTrackAccessibility.value( + visibleMeasures: state.visibleMeasures, + keySignature: state.keySignature, + timeSignature: state.timeSignature, + selectedMeasures: selectedMeasures, + partID: partID + ) } private func barlineAccessibilityLabel(for target: NotationBarlineHitTarget) -> String { @@ -1665,7 +1672,7 @@ private struct NotationDraggedNotePitchPreview: Equatable { private extension NotationTrackActions { static let noop = NotationTrackActions( selectHarmony: { _ in }, - selectMeasure: { _, _ in }, + selectMeasure: { _, _, _ in }, selectItem: { _, _ in }, insertNotationNote: { _ in false }, insertNotationRest: { _ in false }, diff --git a/JammLab/Views/NotationWindowScoreLayout.swift b/JammLab/Views/NotationWindowScoreLayout.swift new file mode 100644 index 0000000..4e25d64 --- /dev/null +++ b/JammLab/Views/NotationWindowScoreLayout.swift @@ -0,0 +1,167 @@ +import CoreGraphics +import Foundation + +struct NotationWindowPartRenderState: Equatable { + var part: NotationPartDescriptor + var scoreState: NotationScoreState +} + +struct NotationWindowStaffRenderState: Equatable, Identifiable { + var part: NotationPartDescriptor + var system: NotationSystemState + var showsRegionLabels: Bool + + var id: NotationPartID { + part.id + } +} + +struct NotationWindowScoreSystem: Equatable, Identifiable { + var id: String + var staves: [NotationWindowStaffRenderState] + + var connectorHeight: CGFloat { + let rowCount = CGFloat(staves.count) + let totalHeight = rowCount * AppTheme.NotationWindow.systemHeight + + CGFloat(max(0, staves.count - 1)) * AppTheme.NotationWindow.staffSpacing + return max( + 0, + totalHeight + - AppTheme.NotationWindow.systemConnectorTopInset + - AppTheme.NotationWindow.systemConnectorBottomInset + ) + } + + func contains(anchorTime: TimeInterval) -> Bool { + guard let measures = staves.first?.system.viewportState.visibleMeasures, + let firstMeasure = measures.first, + let lastMeasure = measures.last + else { return false } + + return anchorTime >= firstMeasure.startTime - NotationMeasureTiming.timelineTolerance + && anchorTime <= lastMeasure.endTime + NotationMeasureTiming.timelineTolerance + } +} + +struct NotationWindowLayoutSignature: Equatable { + var partIDs: [NotationPartID] + var systemIDs: [String] +} + +struct NotationWindowScoreLayout: Equatable { + var systems: [NotationWindowScoreSystem] + var anchorTime: TimeInterval + var measuresPerSystem: Int + var usesPartGutter: Bool + var signature: NotationWindowLayoutSignature + + var activeSystemID: String? { + systems.first(where: { $0.contains(anchorTime: anchorTime) })?.id + } + + static func make( + partStates: [NotationWindowPartRenderState], + contentWidth: CGFloat + ) -> NotationWindowScoreLayout { + let readyPartStates = partStates.filter { $0.scoreState.isReady && !$0.scoreState.measures.isEmpty } + let partIDs = readyPartStates.map(\.part.id) + let usesPartGutter = readyPartStates.count > 1 + let reservedWidth = usesPartGutter + ? AppTheme.NotationWindow.partLabelWidth + AppTheme.NotationWindow.partGutterSpacing + : 0 + let staffWidth = max(1, contentWidth - reservedWidth) + let measuresPerSystem = fittedMeasuresPerSystem( + width: staffWidth, + partStates: readyPartStates + ) + let systemsByPart = readyPartStates.map { partState in + (partState, partState.scoreState.systems(measuresPerSystem: measuresPerSystem)) + } + let referenceSystems = systemsByPart.first?.1 ?? [] + let systems = referenceSystems.compactMap { referenceSystem -> NotationWindowScoreSystem? in + let staves = systemsByPart.enumerated().compactMap { partIndex, entry -> NotationWindowStaffRenderState? in + guard entry.1.indices.contains(referenceSystem.index) else { + assertionFailure("Notation part is missing score system \(referenceSystem.index)") + return nil + } + + let candidate = entry.1[referenceSystem.index] + guard hasMatchingMeasureRange(referenceSystem, candidate) else { + assertionFailure("Notation parts have mismatched score system ranges") + return nil + } + + return NotationWindowStaffRenderState( + part: entry.0.part, + system: candidate, + showsRegionLabels: partIndex == 0 + ) + } + + guard !staves.isEmpty else { return nil } + return NotationWindowScoreSystem( + id: systemID(for: referenceSystem), + staves: staves + ) + } + + return NotationWindowScoreLayout( + systems: systems, + anchorTime: readyPartStates.first?.scoreState.anchorTime ?? 0, + measuresPerSystem: measuresPerSystem, + usesPartGutter: usesPartGutter, + signature: NotationWindowLayoutSignature( + partIDs: partIDs, + systemIDs: systems.map(\.id) + ) + ) + } + + private static func fittedMeasuresPerSystem( + width: CGFloat, + partStates: [NotationWindowPartRenderState] + ) -> Int { + guard !partStates.isEmpty else { return 1 } + + for count in stride( + from: AppTheme.NotationWindow.maximumMeasuresPerSystem, + through: 1, + by: -1 + ) { + let requiredWidth = partStates + .flatMap { $0.scoreState.systems(measuresPerSystem: count) } + .map { NotationVisibleMeasureFitter.minimumRequiredWidth(for: $0.viewportState) } + .max() ?? 0 + if requiredWidth <= width + NotationVisibleMeasureFitter.widthTolerance { + return count + } + } + + return 1 + } + + private static func hasMatchingMeasureRange( + _ lhs: NotationSystemState, + _ rhs: NotationSystemState + ) -> Bool { + let lhsMeasures = lhs.viewportState.visibleMeasures + let rhsMeasures = rhs.viewportState.visibleMeasures + guard lhsMeasures.count == rhsMeasures.count else { return false } + + return zip(lhsMeasures, rhsMeasures).allSatisfy { lhsMeasure, rhsMeasure in + lhsMeasure.number == rhsMeasure.number + && abs(lhsMeasure.startTime - rhsMeasure.startTime) < NotationMeasureTiming.timelineTolerance + && abs(lhsMeasure.endTime - rhsMeasure.endTime) < NotationMeasureTiming.timelineTolerance + } + } + + private static func systemID(for system: NotationSystemState) -> String { + guard let first = system.viewportState.visibleMeasures.first, + let last = system.viewportState.visibleMeasures.last + else { + return "score-system-\(system.index)-empty" + } + + return "score-system-\(first.number)-\(first.startTime)-\(last.number)-\(last.endTime)" + } +} diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index 0c69a06..a4675c6 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -17,18 +17,14 @@ struct NotationWindowView: View { 1, proxy.size.width - AppTheme.NotationWindow.pagePadding * 2 ) - let scoreState = notationScoreState - let systems = fittedSystems( - width: contentWidth, - scoreState: scoreState - ) + let scoreLayout = notationScoreLayout(contentWidth: contentWidth) VStack(spacing: AppTheme.Spacing.none) { header Divider() - scoreBody(scoreState: scoreState, systems: systems) + scoreBody(layout: scoreLayout) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(appColors.appBackground) @@ -87,6 +83,8 @@ struct NotationWindowView: View { .accessibilityLabel("Notation Rest Entry") .accessibilityValue(viewModel.isNotationRestEntryModeEnabled ? "Enabled" : "Disabled") + partVisibilityMenu + Spacer(minLength: AppTheme.Spacing.md) AppControlButton( @@ -107,28 +105,15 @@ struct NotationWindowView: View { @ViewBuilder private func scoreBody( - scoreState: NotationScoreState, - systems: [NotationSystemState] + layout: NotationWindowScoreLayout ) -> some View { - if scoreState.isReady, !systems.isEmpty { + if !layout.systems.isEmpty { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: AppTheme.NotationWindow.systemSpacing) { - ForEach(systems) { system in - NotationTrackView( - state: system.viewportState, - playbackDisplayState: viewModel.playbackDisplayState, - selectedHarmonySymbolID: viewModel.selectedHarmonySymbolID, - selectedMeasures: viewModel.selectedNotationMeasures, - selectedItem: viewModel.selectedNotationItem, - selectedDuration: NotationDuration(denominator: viewModel.notationDurationDenominator), - entryMode: viewModel.notationEntryMode, - pendingEditorRequest: viewModel.pendingHarmonyEditorRequest, - actions: notationActions, - cornerRadius: AppTheme.Spacing.none - ) - .frame(height: AppTheme.NotationWindow.systemHeight) - .id(system.id) + ForEach(layout.systems) { system in + notationSystem(system, usesPartGutter: layout.usesPartGutter) + .id(system.id) } } .padding(AppTheme.NotationWindow.pagePadding) @@ -137,20 +122,26 @@ struct NotationWindowView: View { .simultaneousGesture(userNavigationGesture) .onAppear { scrollToActiveSystem( - in: systems, - anchorTime: scoreState.anchorTime, + in: layout, reader: proxy, animated: false ) } - .onChange(of: scoreState.anchorTime) { _, _ in + .onChange(of: layout.anchorTime) { _, _ in scrollToActiveSystem( - in: systems, - anchorTime: scoreState.anchorTime, + in: layout, reader: proxy, animated: true ) } + .onChange(of: layout.signature) { _, _ in + lastAutoScrolledSystemID = nil + scrollToActiveSystem( + in: layout, + reader: proxy, + animated: false + ) + } } } else { VStack { @@ -165,11 +156,108 @@ struct NotationWindowView: View { } } - private var notationScoreState: NotationScoreState { + private func notationSystem( + _ system: NotationWindowScoreSystem, + usesPartGutter: Bool + ) -> some View { + ZStack(alignment: .topLeading) { + VStack(spacing: AppTheme.NotationWindow.staffSpacing) { + ForEach(system.staves) { staff in + HStack(spacing: AppTheme.Spacing.none) { + if usesPartGutter { + Text(staff.part.title) + .font(AppTheme.Typography.noteTitle) + .foregroundStyle(appColors.secondaryText) + .lineLimit(2) + .minimumScaleFactor(0.75) + .multilineTextAlignment(.trailing) + .frame( + width: AppTheme.NotationWindow.partLabelWidth, + alignment: .trailing + ) + + Color.clear + .frame(width: AppTheme.NotationWindow.partGutterSpacing) + } + + NotationTrackView( + state: staff.system.viewportState, + partID: staff.part.id, + playbackDisplayState: viewModel.playbackDisplayState, + selectedHarmonySymbolID: staff.part.id.isMain ? viewModel.selectedHarmonySymbolID : nil, + selectedMeasures: viewModel.selectedNotationMeasures, + selectedItem: viewModel.selectedNotationItem, + selectedDuration: NotationDuration(denominator: viewModel.notationDurationDenominator), + entryMode: viewModel.notationEntryMode, + pendingEditorRequest: staff.part.id.isMain ? viewModel.pendingHarmonyEditorRequest : nil, + showsRegionLabels: staff.showsRegionLabels, + actions: notationActions, + cornerRadius: AppTheme.Spacing.none + ) + } + .frame(height: AppTheme.NotationWindow.systemHeight) + } + } + + if usesPartGutter, system.staves.count > 1 { + NotationWindowSystemConnector(color: appColors.notationSymbolsAndLines) + .frame( + width: AppTheme.NotationWindow.systemConnectorWidth, + height: system.connectorHeight + ) + .offset( + x: AppTheme.NotationWindow.partLabelWidth, + y: AppTheme.NotationWindow.systemConnectorTopInset + ) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var partVisibilityMenu: some View { + Menu { + ForEach(viewModel.availableNotationParts) { part in + Button { + viewModel.toggleNotationWindowPartVisibility(part.id) + } label: { + if viewModel.normalizedVisibleNotationPartIDs().contains(part.id) { + Label(part.title, systemImage: "checkmark") + } else { + Text(part.title) + } + } + } + } label: { + Label("Parts", systemImage: "rectangle.stack") + } + .disabled(viewModel.availableNotationParts.count <= 1) + .help("Choose visible Notation parts") + .accessibilityLabel("Visible Notation Parts") + } + + private func notationScoreLayout(contentWidth: CGFloat) -> NotationWindowScoreLayout { + let partStates = viewModel.visibleNotationParts.map { part in + let scoreState = notationScoreState(partID: part.id) + return NotationWindowPartRenderState( + part: part, + scoreState: scoreState + ) + } + return NotationWindowScoreLayout.make( + partStates: partStates, + contentWidth: contentWidth + ) + } + + private func notationScoreState(partID: NotationPartID) -> NotationScoreState { let content = notationProjectionCache.content( tempoMap: viewModel.tempoMap, duration: viewModel.duration, keyName: viewModel.effectiveKeyName, + partID: partID, + includesHarmonies: partID.isMain, notationItems: viewModel.notationItems, harmonySymbols: viewModel.harmonySymbols, notes: viewModel.notes @@ -186,7 +274,7 @@ struct NotationWindowView: View { private var notationActions: NotationTrackActions { NotationTrackActions( selectHarmony: { viewModel.selectHarmonySymbol(id: $0) }, - selectMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1) }, + selectMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1, partID: $2) }, selectItem: { viewModel.selectNotationItem($0, shouldAudition: $1) }, insertNotationNote: { viewModel.insertNotationNote($0) }, insertNotationRest: { viewModel.insertNotationRest($0) }, @@ -211,7 +299,7 @@ struct NotationWindowView: View { if viewModel.hasSelectedNotationMeasures || viewModel.isNotationEntryModeEnabled { hotkeys.insert(.clearNotationMeasureSelection) } - if viewModel.canEditSelectedNotationItem { + if viewModel.canEditHarmonyAtSelectedNotationItem { hotkeys.insert(.editHarmonyAtSelectedNotationItem) } if viewModel.canChangeNotationDuration { @@ -274,40 +362,8 @@ struct NotationWindowView: View { } } - private func fittedSystems( - width: CGFloat, - scoreState: NotationScoreState - ) -> [NotationSystemState] { - let measuresPerSystem = fittedMeasuresPerSystem( - width: width, - scoreState: scoreState - ) - return scoreState.systems(measuresPerSystem: measuresPerSystem) - } - - private func fittedMeasuresPerSystem( - width: CGFloat, - scoreState: NotationScoreState - ) -> Int { - guard scoreState.isReady else { return 1 } - - let maximum = AppTheme.NotationWindow.maximumMeasuresPerSystem - for count in stride(from: maximum, through: 1, by: -1) { - let systems = scoreState.systems(measuresPerSystem: count) - let requiredWidth = systems - .map { NotationVisibleMeasureFitter.minimumRequiredWidth(for: $0.viewportState) } - .max() ?? 0 - if requiredWidth <= width + NotationVisibleMeasureFitter.widthTolerance { - return count - } - } - - return 1 - } - private func scrollToActiveSystem( - in systems: [NotationSystemState], - anchorTime: TimeInterval, + in layout: NotationWindowScoreLayout, reader: ScrollViewProxy, animated: Bool ) { @@ -315,20 +371,12 @@ struct NotationWindowView: View { viewModel.pendingHarmonyEditorRequest == nil else { return } - guard let activeSystem = systems.first(where: { system in - system.viewportState.visibleMeasures.contains { measure in - anchorTime >= measure.startTime - && ( - anchorTime < measure.endTime - || abs(anchorTime - measure.endTime) < 0.000_001 - ) - } - }) else { return } - guard activeSystem.id != lastAutoScrolledSystemID else { return } + guard let targetID = layout.activeSystemID else { return } + guard targetID != lastAutoScrolledSystemID else { return } let action = { - lastAutoScrolledSystemID = activeSystem.id - reader.scrollTo(activeSystem.id, anchor: .center) + lastAutoScrolledSystemID = targetID + reader.scrollTo(targetID, anchor: .center) } if animated { @@ -353,6 +401,25 @@ struct NotationWindowView: View { } } +private struct NotationWindowSystemConnector: View { + var color: Color + + var body: some View { + GeometryReader { proxy in + Path { path in + let x = proxy.size.width - AppTheme.Stroke.thin / 2 + path.move(to: CGPoint(x: x, y: 0)) + path.addLine(to: CGPoint(x: x, y: proxy.size.height)) + path.move(to: CGPoint(x: 0, y: 0)) + path.addLine(to: CGPoint(x: x, y: 0)) + path.move(to: CGPoint(x: 0, y: proxy.size.height)) + path.addLine(to: CGPoint(x: x, y: proxy.size.height)) + } + .stroke(color, lineWidth: AppTheme.Stroke.thin) + } + } +} + #Preview { NotationWindowView(viewModel: AudioPlayerViewModel()) .environment(\.appColors, AppThemeColors.default) diff --git a/JammLab/Views/WaveformTimelineView.swift b/JammLab/Views/WaveformTimelineView.swift index 9a6b233..e6e5b5f 100644 --- a/JammLab/Views/WaveformTimelineView.swift +++ b/JammLab/Views/WaveformTimelineView.swift @@ -32,10 +32,12 @@ struct TimelineViewState: Equatable { var selectedRegionID: TimecodedNote.ID? var beatGrid: BeatGridConfiguration var notationViewport: NotationViewportState + var stemNotationViewports: [StemType: NotationViewportState] var notationDurationDenominator: Int var canChangeNotationDuration: Bool var notationEntryMode: NotationEntryMode? var isNotationTrackCollapsed: Bool + var stemNotationTrackCollapsed: [StemType: Bool] var isLoadingPeakform: Bool var mainTrackVolume: Float var playbackMode: PlaybackMode @@ -50,7 +52,7 @@ struct TimelineViewActions { var locatePlaybackMarkerExactly: (TimeInterval) -> Void var addNote: (TimeInterval) -> Void var selectHarmony: (HarmonySymbol.ID?) -> Void - var selectNotationMeasure: (ScoreMeasure?, Bool) -> Void + var selectNotationMeasure: (ScoreMeasure?, Bool, NotationPartID) -> Void var selectNotationItem: (NotationItemSelection?, Bool) -> Void var saveHarmony: (HarmonySymbol) -> Void var deleteHarmony: (HarmonySymbol.ID) -> Void @@ -72,6 +74,7 @@ struct TimelineViewActions { var timelineScroll: (Double, Double, TimeInterval?) -> Void var mainTrackVolumeChanged: (Float) -> Void var notationTrackCollapsedChanged: (Bool) -> Void + var stemNotationTrackCollapsedChanged: (StemType, Bool) -> Void var notationDurationChanged: (Int) -> Void var notationNoteEntryModeToggled: () -> Void var notationRestEntryModeToggled: () -> Void @@ -83,6 +86,38 @@ struct TimelineViewActions { var showNotationWindow: () -> Void } +extension TimelineViewActions { + func notationTrackActions(allowsHarmony: Bool) -> NotationTrackActions { + let selectHarmonyAction: (HarmonySymbol.ID?) -> Void = allowsHarmony + ? selectHarmony + : { _ in } + let saveHarmonyAction: (HarmonySymbol) -> Void = allowsHarmony + ? saveHarmony + : { _ in } + let deleteHarmonyAction: (HarmonySymbol.ID) -> Void = allowsHarmony + ? deleteHarmony + : { _ in } + let adjacentHarmonyAction: (TimeInterval, HarmonyNavigationDirection) -> HarmonyPlacement? = allowsHarmony + ? adjacentHarmonyPlacement + : { _, _ in nil } + + return NotationTrackActions( + selectHarmony: selectHarmonyAction, + selectMeasure: selectNotationMeasure, + selectItem: selectNotationItem, + insertNotationNote: insertNotationNote, + insertNotationRest: insertNotationRest, + changeSelectedNotePitch: changeSelectedNotePitch, + auditionNotePitch: auditionNotePitch, + deleteSelectedNotationNote: deleteSelectedNotationNote, + locatePlaybackMarkerExactly: locatePlaybackMarkerExactly, + saveHarmony: saveHarmonyAction, + deleteHarmony: deleteHarmonyAction, + adjacentHarmonyPlacement: adjacentHarmonyAction + ) + } +} + struct StemTrackActions { var volumeChanged: (StemType, Float) -> Void var muteToggled: (StemType) -> Void @@ -125,6 +160,15 @@ struct WaveformTimelineView: View { duration: state.duration, viewport: viewport, trackControlWidth: trackControlWidth, + selectedMeasures: state.selectedNotationMeasures, + selectedItem: state.selectedNotationItem, + selectedDuration: NotationDuration(denominator: state.notationDurationDenominator), + entryMode: state.notationEntryMode, + canChangeNotationDuration: state.canChangeNotationDuration, + notationDurationDenominator: state.notationDurationDenominator, + notationViewports: state.stemNotationViewports, + notationCollapsed: state.stemNotationTrackCollapsed, + notationActions: actions, actions: stemActions ) .frame(height: stemTracksHeight, alignment: .top) @@ -135,13 +179,23 @@ struct WaveformTimelineView: View { } private var stemTracksHeight: CGFloat { - AppTheme.Timeline.stemTracksHeight(rowCount: visibleStemRowCount) + AppTheme.Timeline.stemTracksHeight( + rowCount: visibleStemRowCount, + expandedStemNotationCount: expandedStemNotationRowCount + ) + } + + private var expandedStemNotationRowCount: Int { + state.stemFiles.filter { + state.stemNotationTrackCollapsed[$0.type] == false + }.count } private var tracksHeight: CGFloat { AppTheme.Timeline.tracksMinimumHeight( stemRowCount: visibleStemRowCount, - isNotationTrackCollapsed: state.isNotationTrackCollapsed + isNotationTrackCollapsed: state.isNotationTrackCollapsed, + expandedStemNotationCount: expandedStemNotationRowCount ) } @@ -281,6 +335,7 @@ struct WaveformTimelineView: View { if !state.isNotationTrackCollapsed { NotationTrackView( state: state.notationViewport, + partID: .main, playbackDisplayState: state.playbackDisplayState, selectedHarmonySymbolID: state.selectedHarmonySymbolID, selectedMeasures: state.selectedNotationMeasures, @@ -288,20 +343,7 @@ struct WaveformTimelineView: View { selectedDuration: NotationDuration(denominator: state.notationDurationDenominator), entryMode: state.notationEntryMode, pendingEditorRequest: state.pendingHarmonyEditorRequest, - actions: NotationTrackActions( - selectHarmony: actions.selectHarmony, - selectMeasure: actions.selectNotationMeasure, - selectItem: actions.selectNotationItem, - insertNotationNote: actions.insertNotationNote, - insertNotationRest: actions.insertNotationRest, - changeSelectedNotePitch: actions.changeSelectedNotePitch, - auditionNotePitch: actions.auditionNotePitch, - deleteSelectedNotationNote: actions.deleteSelectedNotationNote, - locatePlaybackMarkerExactly: actions.locatePlaybackMarkerExactly, - saveHarmony: actions.saveHarmony, - deleteHarmony: actions.deleteHarmony, - adjacentHarmonyPlacement: actions.adjacentHarmonyPlacement - ) + actions: actions.notationTrackActions(allowsHarmony: true) ) .frame(height: AppTheme.Timeline.notationTrackHeight) .overlay { @@ -494,6 +536,15 @@ private struct StemTracksSection: View { let duration: TimeInterval let viewport: TimelineViewport let trackControlWidth: CGFloat + let selectedMeasures: [NotationMeasureSelection] + let selectedItem: NotationItemSelection? + let selectedDuration: NotationDuration + let entryMode: NotationEntryMode? + let canChangeNotationDuration: Bool + let notationDurationDenominator: Int + let notationViewports: [StemType: NotationViewportState] + let notationCollapsed: [StemType: Bool] + let notationActions: TimelineViewActions let actions: StemTrackActions @Environment(\.appColors) private var appColors @@ -514,23 +565,34 @@ private struct StemTracksSection: View { let isRowEnabled = item.isAvailable && areStemTracksActive let isLaneActive = isRowEnabled && mixState.isAudible(type) - return HStack(spacing: AppTheme.Spacing.md) { - controls(type: type, item: item) - .frame(width: trackControlWidth) + return VStack(spacing: AppTheme.Timeline.trackSpacing) { + HStack(spacing: AppTheme.Spacing.md) { + controls(type: type, item: item) + .frame(width: trackControlWidth) + .frame(height: AppTheme.Timeline.stemTrackHeight) + + StemPeakformLaneView( + peakformData: stemPeakforms[type], + duration: duration, + viewport: viewport, + isLoading: isLoadingStemPeakforms, + isAvailable: item.isAvailable, + isActive: isLaneActive + ) .frame(height: AppTheme.Timeline.stemTrackHeight) - - StemPeakformLaneView( - peakformData: stemPeakforms[type], - duration: duration, - viewport: viewport, - isLoading: isLoadingStemPeakforms, - isAvailable: item.isAvailable, - isActive: isLaneActive - ) + .opacity(isRowEnabled ? 1 : 0.45) + } .frame(height: AppTheme.Timeline.stemTrackHeight) - .opacity(isRowEnabled ? 1 : 0.45) + + if item.isAvailable && !isNotationCollapsed(type) { + stemNotationRow(type) + } } - .frame(height: AppTheme.Timeline.stemTrackHeight) + .frame( + height: AppTheme.Timeline.stemRowHeight( + isNotationExpanded: item.isAvailable && !isNotationCollapsed(type) + ) + ) .controlSize(.small) } @@ -562,6 +624,16 @@ private struct StemTracksSection: View { .disabled(!isRowEnabled) .help(ControlHelpText.muteTrack(type.title)) .accessibilityLabel("Mute \(type.title)") + + TimelineIconButton( + systemName: isNotationCollapsed(type) ? "music.note.list" : "music.note", + helpText: notationToggleHelpText(type), + accessibilityLabel: notationToggleHelpText(type), + accessibilityValue: isNotationCollapsed(type) ? "Collapsed" : "Expanded" + ) { + notationActions.stemNotationTrackCollapsedChanged(type, !isNotationCollapsed(type)) + } + .disabled(!item.isAvailable) } HStack(spacing: AppTheme.Spacing.md) { @@ -608,6 +680,84 @@ private struct StemTracksSection: View { private var areStemTracksActive: Bool { playbackMode == .stems } + + private func stemNotationRow(_ type: StemType) -> some View { + HStack(alignment: .top, spacing: AppTheme.Spacing.md) { + stemNotationControls(type) + .frame(width: trackControlWidth) + .frame(height: AppTheme.Timeline.notationTrackHeight, alignment: .topLeading) + + NotationTrackView( + state: notationViewports[type] ?? .pending( + visibleMeasureCount: 1, + keySignature: .cMajor + ), + partID: .stem(type), + playbackDisplayState: nil, + selectedHarmonySymbolID: nil, + selectedMeasures: selectedMeasures, + selectedItem: selectedItem, + selectedDuration: selectedDuration, + entryMode: entryMode, + pendingEditorRequest: nil, + actions: notationActions.notationTrackActions(allowsHarmony: false) + ) + .frame(height: AppTheme.Timeline.notationTrackHeight) + } + } + + private func stemNotationControls(_ type: StemType) -> some View { + VStack(alignment: .leading, spacing: AppTheme.Spacing.sm) { + Text("Notation") + .font(AppTheme.Typography.noteTitle) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + NotationDurationControl( + denominator: Binding( + get: { notationDurationDenominator }, + set: { notationActions.notationDurationChanged($0) } + ), + isEnabled: canChangeNotationDuration + ) + + HStack(spacing: AppTheme.Spacing.xs) { + NotationEntryModeButton( + mode: .note, + selectedDuration: selectedDuration, + isActive: entryMode == .note + ) { + notationActions.notationNoteEntryModeToggled() + } + .disabled(duration <= 0) + .help("Add notes to \(type.title) Notation (N)") + .accessibilityLabel("\(type.title) Notation Note Entry") + + NotationEntryModeButton( + mode: .rest, + selectedDuration: selectedDuration, + isActive: entryMode == .rest + ) { + notationActions.notationRestEntryModeToggled() + } + .disabled(duration <= 0) + .help("Add rests to \(type.title) Notation") + .accessibilityLabel("\(type.title) Notation Rest Entry") + } + } + .padding(.horizontal, AppTheme.Spacing.md) + .padding(.vertical, AppTheme.Spacing.sm) + } + + private func isNotationCollapsed(_ type: StemType) -> Bool { + notationCollapsed[type] ?? true + } + + private func notationToggleHelpText(_ type: StemType) -> String { + isNotationCollapsed(type) + ? "Expand \(type.title) Notation" + : "Collapse \(type.title) Notation" + } } private struct StemPeakformLaneView: View { diff --git a/JammLabTests/NotationMusicXMLTests.swift b/JammLabTests/NotationMusicXMLTests.swift index 622973d..10f61b3 100644 --- a/JammLabTests/NotationMusicXMLTests.swift +++ b/JammLabTests/NotationMusicXMLTests.swift @@ -149,10 +149,110 @@ final class NotationMusicXMLTests: XCTestCase { XCTAssertEqual(try firstXMLChild(named: "type", in: firstMeasureRest).stringValue, "whole") } + func testMusicXMLExportIncludesStemNotationAsSeparatePart() throws { + let tempoMap = fourFourTempoMap(duration: 4) + let mainNote = NotationMeasureItem( + id: "main-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let bassNote = NotationMeasureItem( + id: "bass-note", + partID: .stem(.bass), + kind: .note, + pitch: NotationPitch(step: .e, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let notationItems = [mainNote, bassNote] + let region = TimecodedNote( + kind: .region, + time: 0.5, + duration: 2, + title: "Intro" + ) + let mainScore = NotationViewportFactory().scoreState( + tempoMap: tempoMap, + duration: 4, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: nil, + partID: .main, + notationItems: notationItems, + harmonySymbols: [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") + ], + notes: [region] + ) + let bassScore = NotationViewportFactory().scoreState( + tempoMap: tempoMap, + duration: 4, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: nil, + partID: .stem(.bass), + includesHarmonies: false, + notationItems: notationItems, + notes: [region] + ) + + let data = try NotationExportService(renderers: [ + MusicXMLNotationExportRenderer(appVersionProvider: { nil }) + ]).export( + NotationExportRequest( + displayName: "Song", + score: mainScore, + parts: [ + NotationExportPart(descriptor: .main, score: mainScore), + NotationExportPart(descriptor: .stem(.bass), score: bassScore) + ] + ), + format: .musicXML + ) + let document = try XMLDocument(data: data) + let root = try XCTUnwrap(document.rootElement()) + let partList = try XCTUnwrap(root.elements(forName: "part-list").first) + let scoreParts = partList.elements(forName: "score-part") + let mainPart = try partElement(id: "P1", in: root) + let bassPart = try partElement(id: "P2", in: root) + let mainPitch = try firstXMLChild(named: "pitch", in: try firstXMLChild(named: "note", in: try XCTUnwrap(mainPart.elements(forName: "measure").first))) + let bassPitch = try firstXMLChild(named: "pitch", in: try firstXMLChild(named: "note", in: try XCTUnwrap(bassPart.elements(forName: "measure").first))) + + XCTAssertEqual(scoreParts.map { $0.attribute(forName: "id")?.stringValue }, ["P1", "P2"]) + XCTAssertEqual(scoreParts.compactMap { $0.elements(forName: "part-name").first?.stringValue }, ["Song", "Bass"]) + XCTAssertEqual(try firstXMLChild(named: "step", in: mainPitch).stringValue, "C") + XCTAssertEqual(try firstXMLChild(named: "step", in: bassPitch).stringValue, "E") + XCTAssertFalse(mainPart.elements(forName: "measure").flatMap { $0.elements(forName: "harmony") }.isEmpty) + XCTAssertTrue(bassPart.elements(forName: "measure").flatMap { $0.elements(forName: "harmony") }.isEmpty) + XCTAssertEqual(mainPart.elements(forName: "measure").flatMap { $0.elements(forName: "direction") }.count, 1) + XCTAssertTrue(bassPart.elements(forName: "measure").flatMap { $0.elements(forName: "direction") }.isEmpty) + } + private func childElements(in element: XMLElement) -> [XMLElement] { (element.children ?? []).compactMap { $0 as? XMLElement } } + private func partElement( + id: String, + in root: XMLElement, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> XMLElement { + try XCTUnwrap(root.elements(forName: "part").first { + $0.attribute(forName: "id")?.stringValue == id + }, file: file, line: line) + } + private func assertXMLChild( _ firstElement: XMLElement, precedes secondElement: XMLElement, diff --git a/JammLabTests/NotationTrackLayoutItemsTests.swift b/JammLabTests/NotationTrackLayoutItemsTests.swift index 88882b5..fe5ba12 100644 --- a/JammLabTests/NotationTrackLayoutItemsTests.swift +++ b/JammLabTests/NotationTrackLayoutItemsTests.swift @@ -2,6 +2,119 @@ import XCTest @testable import JammLab final class NotationTrackLayoutItemsTests: XCTestCase { + func testTimelineNotationActionAdapterSuppressesOnlyStemHarmonyActions() { + let recorder = TimelineNotationActionRecorder() + let actions = timelineViewActions(recorder: recorder) + let mainActions = actions.notationTrackActions(allowsHarmony: true) + let stemActions = actions.notationTrackActions(allowsHarmony: false) + let harmony = HarmonySymbol( + time: 0, + measureNumber: 1, + offsetInQuarterNotes: 0, + rawText: "C" + ) + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble + ) + let notePlacement = NotationNotePlacement( + measure: measure, + targetRestID: "rest", + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + pitch: NotationPitch(step: .c, octave: 4), + x: 0, + y: 0 + ) + let restPlacement = NotationRestPlacement( + measure: measure, + targetRestID: "rest", + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + x: 0 + ) + + mainActions.selectHarmony(harmony.id) + mainActions.saveHarmony(harmony) + mainActions.deleteHarmony(harmony.id) + _ = mainActions.adjacentHarmonyPlacement(0, .next) + stemActions.selectHarmony(harmony.id) + stemActions.saveHarmony(harmony) + stemActions.deleteHarmony(harmony.id) + _ = stemActions.adjacentHarmonyPlacement(0, .next) + + mainActions.selectMeasure(nil, false, .main) + stemActions.selectMeasure(nil, false, .stem(.bass)) + mainActions.selectItem(nil, false) + stemActions.selectItem(nil, false) + mainActions.locatePlaybackMarkerExactly(1) + stemActions.locatePlaybackMarkerExactly(2) + XCTAssertTrue(mainActions.insertNotationNote(notePlacement)) + XCTAssertTrue(stemActions.insertNotationNote(notePlacement)) + XCTAssertTrue(mainActions.insertNotationRest(restPlacement)) + XCTAssertTrue(stemActions.insertNotationRest(restPlacement)) + XCTAssertTrue(mainActions.deleteSelectedNotationNote()) + XCTAssertTrue(stemActions.deleteSelectedNotationNote()) + + XCTAssertEqual(recorder.harmonyCallCount, 4) + XCTAssertEqual(recorder.measureSelectionCount, 2) + XCTAssertEqual(recorder.itemSelectionCount, 2) + XCTAssertEqual(recorder.playbackLocationCount, 2) + XCTAssertEqual(recorder.notationInsertionCount, 4) + XCTAssertEqual(recorder.deleteNotationCount, 2) + } + + func testSelectedMeasureIndicesOnlyMatchTheRenderedPart() { + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble + ) + let bassSelection = NotationMeasureSelection( + measure: measure, + partID: .stem(.bass) + ) + + XCTAssertEqual( + NotationTrackLayoutItems.selectedMeasureIndices( + visibleMeasures: [measure], + selectedMeasures: [bassSelection], + partID: .stem(.bass) + ), + [0] + ) + XCTAssertTrue( + NotationTrackLayoutItems.selectedMeasureIndices( + visibleMeasures: [measure], + selectedMeasures: [bassSelection], + partID: .main + ).isEmpty + ) + + let mainAccessibilityValue = NotationTrackAccessibility.value( + visibleMeasures: [measure], + keySignature: .cMajor, + timeSignature: .fourFour, + selectedMeasures: [bassSelection], + partID: .main + ) + let bassAccessibilityValue = NotationTrackAccessibility.value( + visibleMeasures: [measure], + keySignature: .cMajor, + timeSignature: .fourFour, + selectedMeasures: [bassSelection], + partID: .stem(.bass) + ) + + XCTAssertFalse(mainAccessibilityValue.contains("selected measure")) + XCTAssertTrue(bassAccessibilityValue.contains("selected measure 1")) + } + func testNotationTrackLayoutItemsBuildsMeasureItemsFromPureInputs() throws { let geometry = NotationMeasureCanvasGeometry( measureIndex: 0, @@ -108,3 +221,67 @@ final class NotationTrackLayoutItemsTests: XCTestCase { ) } } + +private final class TimelineNotationActionRecorder { + var harmonyCallCount = 0 + var measureSelectionCount = 0 + var itemSelectionCount = 0 + var playbackLocationCount = 0 + var notationInsertionCount = 0 + var deleteNotationCount = 0 +} + +private func timelineViewActions( + recorder: TimelineNotationActionRecorder +) -> TimelineViewActions { + TimelineViewActions( + locatePlaybackMarker: { _ in }, + locatePlaybackMarkerExactly: { _ in recorder.playbackLocationCount += 1 }, + addNote: { _ in }, + selectHarmony: { _ in recorder.harmonyCallCount += 1 }, + selectNotationMeasure: { _, _, _ in recorder.measureSelectionCount += 1 }, + selectNotationItem: { _, _ in recorder.itemSelectionCount += 1 }, + saveHarmony: { _ in recorder.harmonyCallCount += 1 }, + deleteHarmony: { _ in recorder.harmonyCallCount += 1 }, + adjacentHarmonyPlacement: { _, _ in + recorder.harmonyCallCount += 1 + return nil + }, + addTempoTimeSignatureMarker: { _ in }, + editNote: { _ in }, + deleteNote: { _ in }, + noteColorChanged: { _, _ in }, + noteCustomColorChanged: { _, _ in }, + markerTimeChanged: { _, _ in }, + saveLoopRegion: {}, + selectRegion: { _ in }, + activateRegionAsLoop: { _ in }, + focusRegion: { _ in }, + regionRangeChanged: { _, _, _ in }, + loopStartChanged: { _ in }, + loopEndChanged: { _ in }, + loopRegionChanged: { _, _ in }, + timelineScroll: { _, _, _ in }, + mainTrackVolumeChanged: { _ in }, + notationTrackCollapsedChanged: { _ in }, + stemNotationTrackCollapsedChanged: { _, _ in }, + notationDurationChanged: { _ in }, + notationNoteEntryModeToggled: {}, + notationRestEntryModeToggled: {}, + insertNotationNote: { _ in + recorder.notationInsertionCount += 1 + return true + }, + insertNotationRest: { _ in + recorder.notationInsertionCount += 1 + return true + }, + changeSelectedNotePitch: { _, _ in true }, + auditionNotePitch: { _ in }, + deleteSelectedNotationNote: { + recorder.deleteNotationCount += 1 + return true + }, + showNotationWindow: {} + ) +} diff --git a/JammLabTests/NotationViewportTests.swift b/JammLabTests/NotationViewportTests.swift index a11a7b1..a344b7b 100644 --- a/JammLabTests/NotationViewportTests.swift +++ b/JammLabTests/NotationViewportTests.swift @@ -180,6 +180,125 @@ final class NotationViewportTests: XCTestCase { XCTAssertEqual(second, first) XCTAssertTrue(second.isReady) + XCTAssertEqual(cache.cachedScopeCount, 1) + XCTAssertEqual(cache.cacheMissCount, 1) + } + + func testNotationProjectionCacheKeepsPartScopesAndGlobalRegionsIndependent() throws { + let tempoMap = fourFourTempoMap(duration: 8) + let cache = NotationProjectionCache() + let regionID = UUID(uuidString: "00000000-0000-0000-0000-000000000401")! + let harmony = HarmonySymbol( + time: 0, + measureNumber: 1, + offsetInQuarterNotes: 0, + rawText: "C" + ) + + let main = cache.content( + tempoMap: tempoMap, + duration: tempoMap.duration, + keyName: "C major", + partID: .main, + includesHarmonies: true, + notationItems: [], + harmonySymbols: [harmony], + notes: [TimecodedNote(id: regionID, kind: .region, time: 0.5, duration: 2, title: "Intro")] + ) + let bass = cache.content( + tempoMap: tempoMap, + duration: tempoMap.duration, + keyName: "C major", + partID: .stem(.bass), + includesHarmonies: false, + notationItems: [], + harmonySymbols: [harmony], + notes: [TimecodedNote(id: regionID, kind: .region, time: 0.5, duration: 2, title: "Intro")] + ) + let mainAgain = cache.content( + tempoMap: tempoMap, + duration: tempoMap.duration, + keyName: "C major", + partID: .main, + includesHarmonies: true, + notationItems: [], + harmonySymbols: [harmony], + notes: [TimecodedNote(id: regionID, kind: .region, time: 0.5, duration: 2, title: "Intro")] + ) + let mainOnlyItem = NotationMeasureItem( + id: "main-note", + partID: .main, + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let bassWithUnrelatedChanges = cache.content( + tempoMap: tempoMap, + duration: tempoMap.duration, + keyName: "C major", + partID: .stem(.bass), + includesHarmonies: false, + notationItems: [mainOnlyItem], + harmonySymbols: [ + HarmonySymbol(time: 1, measureNumber: 1, offsetInQuarterNotes: 2, rawText: "G7") + ], + notes: [ + TimecodedNote(id: regionID, kind: .region, time: 0.5, duration: 2, title: "Intro"), + TimecodedNote(kind: .marker, time: 1, title: "Marker") + ] + ) + + XCTAssertEqual(cache.cachedScopeCount, 2) + XCTAssertEqual(cache.cacheMissCount, 2) + XCTAssertEqual(mainAgain, main) + XCTAssertEqual(bassWithUnrelatedChanges, bass) + XCTAssertEqual(main.measures.flatMap(\.harmonies).map(\.id), [harmony.id]) + XCTAssertTrue(bass.measures.flatMap(\.harmonies).isEmpty) + XCTAssertEqual(main.measures.flatMap(\.regionLabels).map(\.id), [regionID]) + XCTAssertEqual(bass.measures.flatMap(\.regionLabels).map(\.id), [regionID]) + + let bassItem = NotationMeasureItem( + id: "bass-note", + partID: .stem(.bass), + kind: .note, + pitch: NotationPitch(step: .e, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + _ = cache.content( + tempoMap: tempoMap, + duration: tempoMap.duration, + keyName: "C major", + partID: .stem(.bass), + includesHarmonies: false, + notationItems: [bassItem], + harmonySymbols: [], + notes: [TimecodedNote(id: regionID, kind: .region, time: 0.5, duration: 2, title: "Intro")] + ) + XCTAssertEqual(cache.cacheMissCount, 3) + + _ = cache.content( + tempoMap: tempoMap, + duration: tempoMap.duration, + keyName: "C major", + partID: .stem(.bass), + includesHarmonies: false, + notationItems: [bassItem], + harmonySymbols: [], + notes: [TimecodedNote(id: regionID, kind: .region, time: 0.5, duration: 2, title: "Renamed Intro")] + ) + XCTAssertEqual(cache.cacheMissCount, 4) + + cache.invalidate() + XCTAssertEqual(cache.cachedScopeCount, 0) + XCTAssertEqual(cache.cacheMissCount, 0) } private func fourFourTempoMap( diff --git a/JammLabTests/NotationVisibleMeasureFitterTests.swift b/JammLabTests/NotationVisibleMeasureFitterTests.swift index 4f55172..997a7ec 100644 --- a/JammLabTests/NotationVisibleMeasureFitterTests.swift +++ b/JammLabTests/NotationVisibleMeasureFitterTests.swift @@ -122,3 +122,112 @@ final class NotationVisibleMeasureFitterTests: XCTestCase { ) } } + +final class NotationWindowScoreLayoutTests: XCTestCase { + func testMultiPartLayoutAlignsMeasureRangesAndHostsRegionsOnTopStaffOnly() { + let partStates = [ + partState(part: .main, currentTime: 5.1), + partState(part: .stem(.bass), currentTime: 5.1), + partState(part: .stem(.drums), currentTime: 5.1) + ] + + let layout = NotationWindowScoreLayout.make( + partStates: partStates, + contentWidth: 1_400 + ) + + XCTAssertTrue(layout.usesPartGutter) + XCTAssertEqual(layout.measuresPerSystem, AppTheme.NotationWindow.maximumMeasuresPerSystem) + XCTAssertFalse(layout.systems.isEmpty) + XCTAssertNotNil(layout.activeSystemID) + for system in layout.systems { + XCTAssertEqual(system.staves.map(\.part.id), [.main, .stem(.bass), .stem(.drums)]) + XCTAssertEqual(system.staves.map(\.showsRegionLabels), [true, false, false]) + XCTAssertTrue(system.staves.allSatisfy { staff in + staff.system.viewportState.visibleMeasures.allSatisfy { + $0.attributes.clef == .treble + } + }) + + let referenceMeasureNumbers = system.staves[0].system.viewportState.visibleMeasures.map(\.number) + XCTAssertTrue(system.staves.dropFirst().allSatisfy { + $0.system.viewportState.visibleMeasures.map(\.number) == referenceMeasureNumbers + }) + } + } + + func testLayoutKeepsTopRegionHostWhenMainPartIsHidden() { + let layout = NotationWindowScoreLayout.make( + partStates: [ + partState(part: .stem(.bass), currentTime: 1), + partState(part: .stem(.drums), currentTime: 1) + ], + contentWidth: 1_000 + ) + + XCTAssertEqual(layout.systems.first?.staves.map(\.part.id), [.stem(.bass), .stem(.drums)]) + XCTAssertEqual(layout.systems.first?.staves.map(\.showsRegionLabels), [true, false]) + } + + func testSinglePartLayoutUsesFullWidthAndStillShowsRegions() { + let layout = NotationWindowScoreLayout.make( + partStates: [partState(part: .stem(.bass), currentTime: 1)], + contentWidth: 1_000 + ) + + XCTAssertFalse(layout.usesPartGutter) + XCTAssertEqual(layout.systems.first?.staves.map(\.showsRegionLabels), [true]) + } + + func testLayoutReflowChangesSignatureAndResolvesActiveSystem() { + let partStates = [ + partState(part: .main, currentTime: 5.1), + partState(part: .stem(.bass), currentTime: 5.1) + ] + let wideLayout = NotationWindowScoreLayout.make( + partStates: partStates, + contentWidth: 1_400 + ) + let narrowLayout = NotationWindowScoreLayout.make( + partStates: partStates, + contentWidth: 300 + ) + + XCTAssertLessThan(narrowLayout.measuresPerSystem, wideLayout.measuresPerSystem) + XCTAssertNotEqual(narrowLayout.signature, wideLayout.signature) + XCTAssertNotNil(narrowLayout.activeSystemID) + XCTAssertNotNil(wideLayout.activeSystemID) + XCTAssertNotEqual(narrowLayout.activeSystemID, wideLayout.activeSystemID) + } + + private func partState( + part: NotationPartDescriptor, + currentTime: TimeInterval + ) -> NotationWindowPartRenderState { + let tempoMap = TempoMap( + baseSettings: BeatGridSettings( + bpm: 120, + firstBeatTime: 0, + timeSignature: .fourFour + ), + markers: [], + duration: 16 + ) + let scoreState = NotationViewportFactory().scoreState( + tempoMap: tempoMap, + duration: tempoMap.duration, + currentTime: currentTime, + playbackMarkerTime: 0, + isPlaying: true, + keyName: "C major", + partID: part.id, + includesHarmonies: part.id.isMain, + notationItems: [], + harmonySymbols: [], + notes: [ + TimecodedNote(kind: .region, time: 0.5, duration: 3, title: "Intro") + ] + ) + return NotationWindowPartRenderState(part: part, scoreState: scoreState) + } +} diff --git a/JammLabTests/StemWorkflowLogicTests.swift b/JammLabTests/StemWorkflowLogicTests.swift index 435b414..6ac0ab9 100644 --- a/JammLabTests/StemWorkflowLogicTests.swift +++ b/JammLabTests/StemWorkflowLogicTests.swift @@ -162,4 +162,75 @@ final class StemWorkflowLogicTests: XCTestCase { XCTAssertGreaterThan(sixStemCollapsedHeight, collapsedHeight) } + func testTimelineHeightHelpersIncludeExpandedStemNotationRowsOnce() { + let rowCount = StemSeparationMethod.fourStem.stemTypes.count + let baseStemHeight = AppTheme.Timeline.stemTracksHeight(rowCount: rowCount) + let oneExpandedHeight = AppTheme.Timeline.stemTracksHeight( + rowCount: rowCount, + expandedStemNotationCount: 1 + ) + let allExpandedHeight = AppTheme.Timeline.stemTracksHeight( + rowCount: rowCount, + expandedStemNotationCount: rowCount + ) + let clampedExpandedHeight = AppTheme.Timeline.stemTracksHeight( + rowCount: rowCount, + expandedStemNotationCount: rowCount + 3 + ) + let notationRowExpansion = AppTheme.Timeline.trackSpacing + + AppTheme.Timeline.notationTrackHeight + + XCTAssertEqual( + AppTheme.Timeline.stemRowHeight(isNotationExpanded: false), + AppTheme.Timeline.stemTrackHeight + ) + XCTAssertEqual( + AppTheme.Timeline.stemRowHeight(isNotationExpanded: true), + AppTheme.Timeline.stemTrackHeight + notationRowExpansion + ) + XCTAssertEqual(oneExpandedHeight - baseStemHeight, notationRowExpansion) + XCTAssertEqual( + allExpandedHeight - baseStemHeight, + CGFloat(rowCount) * notationRowExpansion + ) + XCTAssertEqual(clampedExpandedHeight, allExpandedHeight) + XCTAssertEqual( + AppTheme.Timeline.tracksMinimumHeight( + stemRowCount: rowCount, + isNotationTrackCollapsed: true, + expandedStemNotationCount: 1 + ) - AppTheme.Timeline.tracksMinimumHeight( + stemRowCount: rowCount, + isNotationTrackCollapsed: true + ), + notationRowExpansion + ) + XCTAssertEqual( + AppTheme.Timeline.timelineBlockMinimumHeight( + stemRowCount: rowCount, + isNotationTrackCollapsed: true, + expandedStemNotationCount: 1 + ), + AppTheme.Timeline.tracksMinimumHeight( + stemRowCount: rowCount, + isNotationTrackCollapsed: true, + expandedStemNotationCount: 1 + ) + + AppTheme.Timeline.viewportFooterGap + + AppTheme.Timeline.viewportControlBarHeight + ) + XCTAssertEqual( + AppTheme.Timeline.minimumContentHeight( + stemRowCount: rowCount, + isNotationTrackCollapsed: true, + expandedStemNotationCount: 1 + ), + AppTheme.Timeline.timelineBlockMinimumHeight( + stemRowCount: rowCount, + isNotationTrackCollapsed: true, + expandedStemNotationCount: 1 + ) + ) + } + } diff --git a/JammLabTests/TimelineProjectLogicTests.swift b/JammLabTests/TimelineProjectLogicTests.swift index e62ba48..f4bf3a0 100644 --- a/JammLabTests/TimelineProjectLogicTests.swift +++ b/JammLabTests/TimelineProjectLogicTests.swift @@ -97,4 +97,66 @@ final class TimelineProjectLogicTests: XCTestCase { XCTAssertEqual(decoded.harmonySymbols, [symbol]) } + func testProjectPersistsNotationPartState() throws { + let notationItem = NotationMeasureItem( + id: "bass-note", + partID: .stem(.bass), + kind: .note, + pitch: NotationPitch(step: .e, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let project = JammLabProject( + audioBookmarkData: Data([1, 2, 3]), + audioDisplayName: "song.wav", + audioDuration: 12, + notes: [], + notationItems: [notationItem], + loopStart: 0, + loopEnd: 4, + playbackRate: 1, + pitchShiftSemitones: 0, + stemNotationTrackCollapsed: [.bass: false, .drums: true], + visibleNotationPartIDs: [.main, .stem(.bass), .stem(.drums)] + ) + + let decoded = try JSONDecoder().decode( + JammLabProject.self, + from: JSONEncoder().encode(project) + ) + + XCTAssertEqual(decoded.notationItems.first?.partID, .stem(.bass)) + XCTAssertEqual(decoded.stemNotationTrackCollapsed, [.bass: false, .drums: true]) + XCTAssertEqual(decoded.visibleNotationPartIDs, [.main, .stem(.bass), .stem(.drums)]) + } + + func testProjectDefaultsMissingNotationPartState() throws { + let project = JammLabProject( + audioBookmarkData: Data([1, 2, 3]), + audioDisplayName: "song.wav", + audioDuration: 12, + notes: [], + loopStart: 0, + loopEnd: 4, + playbackRate: 1, + pitchShiftSemitones: 0 + ) + var object = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(project)) as? [String: Any] + ) + object.removeValue(forKey: "stemNotationTrackCollapsed") + object.removeValue(forKey: "visibleNotationPartIDs") + + let decoded = try JSONDecoder().decode( + JammLabProject.self, + from: JSONSerialization.data(withJSONObject: object) + ) + + XCTAssertTrue(decoded.stemNotationTrackCollapsed.isEmpty) + XCTAssertEqual(decoded.visibleNotationPartIDs, [.main]) + } + } diff --git a/JammLabTests/ViewModelNotationDurationSelectionTests.swift b/JammLabTests/ViewModelNotationDurationSelectionTests.swift index 559d2d4..309032f 100644 --- a/JammLabTests/ViewModelNotationDurationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationDurationSelectionTests.swift @@ -75,6 +75,44 @@ final class ViewModelNotationDurationSelectionTests: XCTestCase { XCTAssertEqual(undoneMeasure.notationItems.first?.kind, .rest) } + @MainActor + func testAddingNotationNoteToStemPartDoesNotReplaceMainNotationItems() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.notationItems = [ + NotationMeasureItem( + id: "main-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let stemMeasure = try notationMeasure(1, in: viewModel, partID: .stem(.bass)) + let rest = try XCTUnwrap(stemMeasure.notationItems.first) + let placement = NotationNotePlacement( + measure: stemMeasure, + partID: .stem(.bass), + targetRestID: rest.id, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + pitch: NotationPitch(step: .e, octave: 2), + x: 0, + y: 0 + ) + + XCTAssertTrue(viewModel.insertNotationNote(placement)) + + let mainMeasure = try notationMeasure(1, in: viewModel) + let updatedStemMeasure = try notationMeasure(1, in: viewModel, partID: .stem(.bass)) + XCTAssertNotNil(mainMeasure.notationItems.first { $0.id == "main-note" && $0.partID == .main }) + XCTAssertEqual(updatedStemMeasure.notationItems.first?.partID, .stem(.bass)) + XCTAssertEqual(updatedStemMeasure.notationItems.first?.pitch, NotationPitch(step: .e, octave: 2)) + } + @MainActor func testAddingNotationNoteCanConsumeFollowingRestsFromShortTargetRest() throws { let viewModel = try loadedNotationViewModel(duration: 8) diff --git a/JammLabTests/ViewModelNotationExportTests.swift b/JammLabTests/ViewModelNotationExportTests.swift index 633531c..a47ed6c 100644 --- a/JammLabTests/ViewModelNotationExportTests.swift +++ b/JammLabTests/ViewModelNotationExportTests.swift @@ -1,3 +1,4 @@ +import Foundation import XCTest @testable import JammLab @@ -49,4 +50,37 @@ final class ViewModelNotationExportTests: XCTestCase { XCTAssertTrue(viewModel.canUndo) XCTAssertNil(viewModel.errorMessage) } + + @MainActor + func testViewModelExportIncludesStemPartAndWritesRegionOnce() async throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.notationItems = [ + NotationMeasureItem( + id: "bass-note", + partID: .stem(.bass), + kind: .note, + pitch: NotationPitch(step: .e, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + viewModel.notes = [ + TimecodedNote(kind: .region, time: 0.5, duration: 2, title: "Intro") + ] + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("notation-stem-export-\(UUID().uuidString)") + .appendingPathExtension("musicxml") + defer { try? FileManager.default.removeItem(at: outputURL) } + + let didExport = await viewModel.exportNotation(format: .musicXML, to: outputURL) + XCTAssertTrue(didExport) + + let xml = try String(contentsOf: outputURL, encoding: .utf8) + XCTAssertTrue(xml.contains("Bass")) + XCTAssertTrue(xml.contains("E")) + XCTAssertEqual(xml.components(separatedBy: ">Intro").count - 1, 1) + } } diff --git a/JammLabTests/ViewModelNotationSelectionTests.swift b/JammLabTests/ViewModelNotationSelectionTests.swift index 5cdf087..9b11ba6 100644 --- a/JammLabTests/ViewModelNotationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationSelectionTests.swift @@ -2,6 +2,59 @@ import XCTest @testable import JammLab final class ViewModelNotationSelectionTests: XCTestCase { + @MainActor + func testNotationWindowPartVisibilityCanShowOnlyStemPartButNeverEmpty() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.visibleNotationPartIDs = [.main, .stem(.bass)] + + viewModel.toggleNotationWindowPartVisibility(.main) + + XCTAssertEqual(viewModel.normalizedVisibleNotationPartIDs(), [.stem(.bass)]) + + viewModel.toggleNotationWindowPartVisibility(.stem(.bass)) + + XCTAssertEqual(viewModel.normalizedVisibleNotationPartIDs(), [.main]) + } + + @MainActor + func testStemSelectionCannotEditHarmonyAndIsPreservedWhenRequested() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.notationItems = [ + NotationMeasureItem( + id: "bass-note", + partID: .stem(.bass), + kind: .note, + pitch: NotationPitch(step: .e, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let measure = try notationMeasure(1, in: viewModel, partID: .stem(.bass)) + let item = try XCTUnwrap(measure.notationItems.first { $0.id == "bass-note" }) + let selection = NotationItemSelection(measure: measure, item: item) + viewModel.selectNotationItem(selection) + + XCTAssertTrue(viewModel.canEditSelectedNotationItem) + XCTAssertFalse(viewModel.canEditHarmonyAtSelectedNotationItem) + XCTAssertFalse(viewModel.requestEditSelectedNotationItem()) + XCTAssertEqual(viewModel.selectedNotationItem, selection) + } + + @MainActor + func testMainSelectionRemainsEligibleForHarmonyEditing() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let measure = try notationMeasure(1, in: viewModel) + let item = try XCTUnwrap(measure.notationItems.first) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: item)) + + XCTAssertTrue(viewModel.canEditHarmonyAtSelectedNotationItem) + XCTAssertTrue(viewModel.requestEditSelectedNotationItem()) + XCTAssertNotNil(viewModel.pendingHarmonyEditorRequest) + } + @MainActor func testSelectingNotationMeasureAtCurrentMarkerDoesNotMarkProjectModified() throws { let viewModel = try loadedNotationViewModel(duration: 8) @@ -336,7 +389,11 @@ extension XCTestCase { } @MainActor - func notationMeasure(_ number: Int, in viewModel: AudioPlayerViewModel) throws -> ScoreMeasure { + func notationMeasure( + _ number: Int, + in viewModel: AudioPlayerViewModel, + partID: NotationPartID = .main + ) throws -> ScoreMeasure { let score = NotationViewportFactory().scoreState( tempoMap: viewModel.tempoMap, duration: viewModel.duration, @@ -344,6 +401,8 @@ extension XCTestCase { playbackMarkerTime: viewModel.playbackMarkerTime, isPlaying: viewModel.playbackState == .playing, keyName: viewModel.effectiveKeyName, + partID: partID, + includesHarmonies: partID.isMain, notationItems: viewModel.notationItems, harmonySymbols: viewModel.harmonySymbols, notes: viewModel.notes diff --git a/JammLabTests/ViewModelNotationTrackCollapseTests.swift b/JammLabTests/ViewModelNotationTrackCollapseTests.swift index 69e808a..eec2ca7 100644 --- a/JammLabTests/ViewModelNotationTrackCollapseTests.swift +++ b/JammLabTests/ViewModelNotationTrackCollapseTests.swift @@ -109,6 +109,62 @@ final class ViewModelNotationTrackCollapseTests: XCTestCase { XCTAssertEqual(viewModel.mainTrackVolume, AppSliderDefaults.mainTrackVolume, accuracy: 0.0001) } + @MainActor + func testProjectSaveAndOpenPreservesStemNotationPartState() async throws { + let audioURL = try temporaryAudioFile() + let projectURL = temporaryDirectory().appendingPathComponent("stem-notation-state.jammlab") + try FileManager.default.createDirectory(at: projectURL.deletingLastPathComponent(), withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let projectService = ProjectDocumentService() + let makeViewModel: () throws -> AudioPlayerViewModel = { + AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try self.temporaryUserDefaults()), + isSandboxed: { false } + ) + } + let savingViewModel = try makeViewModel() + try savingViewModel.loadImportedAudio( + ImportedAudioFile(url: audioURL, displayName: "notation.wav", duration: 0.5) + ) + savingViewModel.notationItems = [ + NotationMeasureItem( + id: "bass-note", + partID: .stem(.bass), + kind: .note, + pitch: NotationPitch(step: .e, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + savingViewModel.toggleNotationWindowPartVisibility(.stem(.bass)) + savingViewModel.setStemNotationTrackCollapsed(.bass, isCollapsed: false) + + let didSave = await savingViewModel.saveProject(to: projectURL) + XCTAssertTrue(didSave) + let savedProject = try projectService.load(from: projectURL) + XCTAssertEqual(savedProject.visibleNotationPartIDs, [.main, .stem(.bass)]) + XCTAssertEqual(savedProject.stemNotationTrackCollapsed[.bass], false) + + let restoredViewModel = try makeViewModel() + await restoredViewModel.openProject(at: projectURL) + + XCTAssertEqual(restoredViewModel.visibleNotationPartIDs, [.main, .stem(.bass)]) + XCTAssertFalse(restoredViewModel.isStemNotationTrackCollapsed(.bass)) + XCTAssertEqual(restoredViewModel.notationItems.first?.partID, .stem(.bass)) + XCTAssertFalse(restoredViewModel.isProjectModified) + } + private func notationCollapseProject( audioURL: URL, projectService: ProjectDocumentService, From 8bb6675e094080de57ffcdc6d38366bfdb4e226b Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 14 Jul 2026 07:23:25 +0300 Subject: [PATCH 04/12] fix(notation): align controls and filter exported parts --- AGENTS.md | 35 ++++++++ CHANGELOG.md | 1 + JammLab/Services/NotationExportService.swift | 12 ++- .../AudioPlayerViewModel+NotationExport.swift | 35 +++----- JammLab/Views/Components/AppControls.swift | 11 +-- JammLab/Views/NotationWindowView.swift | 2 - JammLab/Views/WaveformTimelineView.swift | 88 +++++++++++-------- .../NotationTrackLayoutItemsTests.swift | 11 +++ .../ViewModelNotationExportTests.swift | 66 ++++++++++++++ 9 files changed, 190 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 295e20c..b3c33f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,3 +161,38 @@ Do not spawn `test_reviewer` for pure analysis-only tasks, DISCUSS mode, documen The main agent owns the final decision. The main agent must not silently ignore `test_reviewer` findings. + +## Maintainability review workflow + +For implementation tasks that create, modify, rename, or delete source files, project files, or behavior-affecting configuration: + +1. After the implementation is complete and before the final report, spawn the `maintainability_reviewer` subagent. +2. The `maintainability_reviewer` subagent must be read-only and must not edit files. +3. Ask `maintainability_reviewer` to review: + - maintainability risks; + - avoidable complexity; + - duplicated logic; + - unclear ownership boundaries; + - unnecessary coupling; + - premature or weak abstractions; + - oversized functions, types, SwiftUI views, services, or managers; + - duplicated or inconsistent state; + - whether a smaller refactor should be done before finalizing. +4. Wait for the review result. +5. Address valid maintainability reviewer findings before the final report when the fix is in scope. +6. If a reviewer finding is rejected or deferred, explain why briefly in the final report. + +Use `maintainability_reviewer` especially for changes involving: +- SwiftUI views or custom controls; +- state management; +- timeline, waveform, markers, regions, notation, harmony, or MusicXML logic; +- AVAudioEngine, AVFoundation, CoreAudio, playback, timing, BPM/key detection, or metronome logic; +- persistence, project loading/saving, import/export, or migrations; +- refactoring tasks; +- new services, managers, protocols, models, or shared utilities; +- changes that touch several files or architectural boundaries. + +Do not spawn `maintainability_reviewer` for pure analysis-only tasks, DISCUSS mode, documentation-only edits, comment-only edits, formatting-only changes, or trivial one-line fixes unless maintainability/refactoring review is explicitly requested. + +The main agent owns the final decision. +The main agent must not silently ignore `maintainability_reviewer` findings. diff --git a/CHANGELOG.md b/CHANGELOG.md index a98f5f3..76f0276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. - Added clearer hover tooltips and numpad shortcuts for Notation duration buttons. - Added a bar/beat position readout to the transport time display. - Fixed multi-part Notation accessibility selection announcements and prevented harmony editing from clearing a selected stem note. +- Stabilized the Rest entry icon, matched Notation track controls, aligned stem Mute/Solo buttons, and limited MusicXML export to the selected Parts. ## 1.1.0 diff --git a/JammLab/Services/NotationExportService.swift b/JammLab/Services/NotationExportService.swift index 808f60d..e970d7b 100644 --- a/JammLab/Services/NotationExportService.swift +++ b/JammLab/Services/NotationExportService.swift @@ -133,7 +133,8 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { id: musicXMLPartID(for: index), measures: exportPart.score.measures, tempoBPM: index == 0 ? request.tempoBPM : nil, - includesAnnotations: exportPart.descriptor.id.isMain + includesRegionLabels: index == 0, + includesHarmonies: exportPart.descriptor.id.isMain )) } @@ -212,7 +213,8 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { id: String, measures: [ScoreMeasure], tempoBPM: Double?, - includesAnnotations: Bool + includesRegionLabels: Bool, + includesHarmonies: Bool ) throws -> XMLElement { let part = element("part", attributes: ["id": id]) var previousAttributes: MeasureAttributes? @@ -227,13 +229,15 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { measureElement.addChild(metronomeDirection) } - if includesAnnotations { + if includesRegionLabels { for regionLabel in measure.regionLabels { measureElement.addChild(regionDirection(for: regionLabel)) } } - let sortedHarmonies = includesAnnotations ? measure.harmonies.sorted(by: isHarmonyOrderedByNotationPosition) : [] + let sortedHarmonies = includesHarmonies + ? measure.harmonies.sorted(by: isHarmonyOrderedByNotationPosition) + : [] var harmonyIndex = sortedHarmonies.startIndex var notationCursorOffsetInQuarterNotes = 0.0 let sortedItems = measure.notationItems.sorted(by: isNotationItemOrderedByNotationPosition) diff --git a/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift b/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift index 06810b9..c4e655f 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift @@ -2,7 +2,8 @@ import Foundation extension AudioPlayerViewModel { var canExportNotation: Bool { - let score = currentNotationExportScore() + guard let firstVisiblePart = visibleNotationParts.first else { return false } + let score = currentNotationExportScore(for: firstVisiblePart.id) return score.isReady && !score.measures.isEmpty } @@ -59,20 +60,20 @@ extension AudioPlayerViewModel { } private func makeNotationExportRequest() throws -> NotationExportRequest { - let score = currentNotationExportScore() - guard score.isReady, !score.measures.isEmpty else { + let parts = currentNotationExportParts() + guard let firstPart = parts.first else { throw NotationExportError.emptyScore } return NotationExportRequest( displayName: notationExportDisplayName, - score: score, - parts: currentNotationExportParts(mainScore: score), + score: firstPart.score, + parts: parts, tempoBPM: beatGridSettings.bpm ) } - private func currentNotationExportScore() -> NotationScoreState { + private func currentNotationExportScore(for partID: NotationPartID) -> NotationScoreState { NotationViewportFactory().scoreState( tempoMap: tempoMap, duration: duration, @@ -80,29 +81,17 @@ extension AudioPlayerViewModel { playbackMarkerTime: playbackMarkerTime, isPlaying: playbackState == .playing, keyName: effectiveKeyName, + partID: partID, + includesHarmonies: partID.isMain, notationItems: notationItems, harmonySymbols: harmonySymbols, notes: notes ) } - private func currentNotationExportParts(mainScore: NotationScoreState) -> [NotationExportPart] { - availableNotationParts.compactMap { part in - let score = part.id.isMain - ? mainScore - : NotationViewportFactory().scoreState( - tempoMap: tempoMap, - duration: duration, - currentTime: currentTime, - playbackMarkerTime: playbackMarkerTime, - isPlaying: playbackState == .playing, - keyName: effectiveKeyName, - partID: part.id, - includesHarmonies: false, - notationItems: notationItems, - harmonySymbols: harmonySymbols, - notes: notes - ) + private func currentNotationExportParts() -> [NotationExportPart] { + visibleNotationParts.compactMap { part in + let score = currentNotationExportScore(for: part.id) guard score.isReady, !score.measures.isEmpty else { return nil } return NotationExportPart(descriptor: part, score: score) } diff --git a/JammLab/Views/Components/AppControls.swift b/JammLab/Views/Components/AppControls.swift index b80402d..1a603cb 100644 --- a/JammLab/Views/Components/AppControls.swift +++ b/JammLab/Views/Components/AppControls.swift @@ -85,7 +85,6 @@ struct NotationDurationControl: View { struct NotationEntryModeButton: View { let mode: NotationEntryMode - let selectedDuration: NotationDuration let isActive: Bool let action: () -> Void @Environment(\.isEnabled) private var isEnabled @@ -100,12 +99,10 @@ struct NotationEntryModeButton: View { .font(.system(size: 14, weight: .semibold)) .foregroundStyle(iconColor) case .rest: - if let symbol = NotationSMuFLSymbol(duration: selectedDuration) { - NotationRestControlGlyphView( - symbol: symbol, - color: iconColor - ) - } + NotationRestControlGlyphView( + symbol: .restQuarter, + color: iconColor + ) } } .frame( diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index a4675c6..14706cb 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -53,7 +53,6 @@ struct NotationWindowView: View { HStack(spacing: AppTheme.Spacing.md) { NotationEntryModeButton( mode: .note, - selectedDuration: NotationDuration(denominator: viewModel.notationDurationDenominator), isActive: viewModel.isNotationNoteEntryModeEnabled ) { viewModel.toggleNotationNoteEntryMode() @@ -73,7 +72,6 @@ struct NotationWindowView: View { NotationEntryModeButton( mode: .rest, - selectedDuration: NotationDuration(denominator: viewModel.notationDurationDenominator), isActive: viewModel.isNotationRestEntryModeEnabled ) { viewModel.toggleNotationRestEntryMode() diff --git a/JammLab/Views/WaveformTimelineView.swift b/JammLab/Views/WaveformTimelineView.swift index e6e5b5f..f8c0956 100644 --- a/JammLab/Views/WaveformTimelineView.swift +++ b/JammLab/Views/WaveformTimelineView.swift @@ -118,6 +118,12 @@ extension TimelineViewActions { } } +enum NotationTrackTogglePresentation { + static func systemName(isCollapsed: Bool) -> String { + isCollapsed ? "music.note.list" : "music.note" + } +} + struct StemTrackActions { var volumeChanged: (StemType, Float) -> Void var muteToggled: (StemType) -> Void @@ -378,7 +384,6 @@ struct WaveformTimelineView: View { HStack(spacing: AppTheme.Spacing.xs) { NotationEntryModeButton( mode: .note, - selectedDuration: NotationDuration(denominator: state.notationDurationDenominator), isActive: isNotationNoteEntryModeEnabled ) { actions.notationNoteEntryModeToggled() @@ -390,7 +395,6 @@ struct WaveformTimelineView: View { NotationEntryModeButton( mode: .rest, - selectedDuration: NotationDuration(denominator: state.notationDurationDenominator), isActive: isNotationRestEntryModeEnabled ) { actions.notationRestEntryModeToggled() @@ -416,7 +420,9 @@ struct WaveformTimelineView: View { Spacer(minLength: AppTheme.Spacing.sm) TimelineIconButton( - systemName: state.isNotationTrackCollapsed ? "plus" : "minus", + systemName: NotationTrackTogglePresentation.systemName( + isCollapsed: state.isNotationTrackCollapsed + ), helpText: notationTrackToggleHelpText, accessibilityLabel: notationTrackToggleHelpText, accessibilityValue: state.isNotationTrackCollapsed ? "Collapsed" : "Expanded" @@ -613,27 +619,31 @@ private struct StemTracksSection: View { } .frame(maxWidth: .infinity, alignment: .leading) - AppLetterToggleButton( - title: "M", - isActive: item.isMuted, - activeFillColor: appColors.statusButtonCriticalFill, - inactiveTextColor: appColors.statusButtonCriticalFill - ) { - actions.muteToggled(type) - } - .disabled(!isRowEnabled) - .help(ControlHelpText.muteTrack(type.title)) - .accessibilityLabel("Mute \(type.title)") - - TimelineIconButton( - systemName: isNotationCollapsed(type) ? "music.note.list" : "music.note", - helpText: notationToggleHelpText(type), - accessibilityLabel: notationToggleHelpText(type), - accessibilityValue: isNotationCollapsed(type) ? "Collapsed" : "Expanded" - ) { - notationActions.stemNotationTrackCollapsedChanged(type, !isNotationCollapsed(type)) + HStack(spacing: AppTheme.Spacing.md) { + AppLetterToggleButton( + title: "M", + isActive: item.isMuted, + activeFillColor: appColors.statusButtonCriticalFill, + inactiveTextColor: appColors.statusButtonCriticalFill + ) { + actions.muteToggled(type) + } + .disabled(!isRowEnabled) + .help(ControlHelpText.muteTrack(type.title)) + .accessibilityLabel("Mute \(type.title)") + + TimelineIconButton( + systemName: NotationTrackTogglePresentation.systemName( + isCollapsed: isNotationCollapsed(type) + ), + helpText: notationToggleHelpText(type), + accessibilityLabel: notationToggleHelpText(type), + accessibilityValue: isNotationCollapsed(type) ? "Collapsed" : "Expanded" + ) { + notationActions.stemNotationTrackCollapsedChanged(type, !isNotationCollapsed(type)) + } + .disabled(!item.isAvailable) } - .disabled(!item.isAvailable) } HStack(spacing: AppTheme.Spacing.md) { @@ -660,17 +670,27 @@ private struct StemTracksSection: View { Spacer(minLength: AppTheme.Spacing.none) - AppLetterToggleButton( - title: "S", - isActive: item.isSoloed, - activeFillColor: appColors.statusButtonAttentionFill, - inactiveTextColor: appColors.statusButtonAttentionFill - ) { - actions.soloToggled(type) + HStack(spacing: AppTheme.Spacing.md) { + AppLetterToggleButton( + title: "S", + isActive: item.isSoloed, + activeFillColor: appColors.statusButtonAttentionFill, + inactiveTextColor: appColors.statusButtonAttentionFill + ) { + actions.soloToggled(type) + } + .disabled(!isRowEnabled) + .help(ControlHelpText.soloTrack(type.title)) + .accessibilityLabel("Solo \(type.title)") + + Color.clear + .frame( + width: AppTheme.Timeline.viewportControlButtonSize, + height: AppTheme.Timeline.viewportControlButtonSize + ) + .allowsHitTesting(false) + .accessibilityHidden(true) } - .disabled(!isRowEnabled) - .help(ControlHelpText.soloTrack(type.title)) - .accessibilityLabel("Solo \(type.title)") } } .padding(.horizontal, AppTheme.Spacing.md) @@ -724,7 +744,6 @@ private struct StemTracksSection: View { HStack(spacing: AppTheme.Spacing.xs) { NotationEntryModeButton( mode: .note, - selectedDuration: selectedDuration, isActive: entryMode == .note ) { notationActions.notationNoteEntryModeToggled() @@ -735,7 +754,6 @@ private struct StemTracksSection: View { NotationEntryModeButton( mode: .rest, - selectedDuration: selectedDuration, isActive: entryMode == .rest ) { notationActions.notationRestEntryModeToggled() diff --git a/JammLabTests/NotationTrackLayoutItemsTests.swift b/JammLabTests/NotationTrackLayoutItemsTests.swift index fe5ba12..4b1fd26 100644 --- a/JammLabTests/NotationTrackLayoutItemsTests.swift +++ b/JammLabTests/NotationTrackLayoutItemsTests.swift @@ -2,6 +2,17 @@ import XCTest @testable import JammLab final class NotationTrackLayoutItemsTests: XCTestCase { + func testNotationTrackTogglePresentationUsesConsistentIcons() { + XCTAssertEqual( + NotationTrackTogglePresentation.systemName(isCollapsed: true), + "music.note.list" + ) + XCTAssertEqual( + NotationTrackTogglePresentation.systemName(isCollapsed: false), + "music.note" + ) + } + func testTimelineNotationActionAdapterSuppressesOnlyStemHarmonyActions() { let recorder = TimelineNotationActionRecorder() let actions = timelineViewActions(recorder: recorder) diff --git a/JammLabTests/ViewModelNotationExportTests.swift b/JammLabTests/ViewModelNotationExportTests.swift index a47ed6c..3f1e21c 100644 --- a/JammLabTests/ViewModelNotationExportTests.swift +++ b/JammLabTests/ViewModelNotationExportTests.swift @@ -70,6 +70,7 @@ final class ViewModelNotationExportTests: XCTestCase { viewModel.notes = [ TimecodedNote(kind: .region, time: 0.5, duration: 2, title: "Intro") ] + viewModel.visibleNotationPartIDs = [.main, .stem(.bass)] let outputURL = FileManager.default.temporaryDirectory .appendingPathComponent("notation-stem-export-\(UUID().uuidString)") .appendingPathExtension("musicxml") @@ -83,4 +84,69 @@ final class ViewModelNotationExportTests: XCTestCase { XCTAssertTrue(xml.contains("E")) XCTAssertEqual(xml.components(separatedBy: ">Intro").count - 1, 1) } + + @MainActor + func testViewModelExportIncludesOnlyPartsSelectedInNotationWindow() async throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.notationItems = [ + NotationMeasureItem( + id: "main-note", + partID: .main, + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "bass-note", + partID: .stem(.bass), + kind: .note, + pitch: NotationPitch(step: .e, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C") + ] + viewModel.notes = [ + TimecodedNote(kind: .region, time: 0.5, duration: 2, title: "Intro") + ] + viewModel.visibleNotationPartIDs = [.stem(.bass)] + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("notation-selected-parts-export-\(UUID().uuidString)") + .appendingPathExtension("musicxml") + defer { try? FileManager.default.removeItem(at: outputURL) } + + XCTAssertTrue(viewModel.canExportNotation) + let didExport = await viewModel.exportNotation(format: .musicXML, to: outputURL) + XCTAssertTrue(didExport) + + let data = try Data(contentsOf: outputURL) + let document = try XMLDocument(data: data) + let root = try XCTUnwrap(document.rootElement()) + let partList = try XCTUnwrap(root.elements(forName: "part-list").first) + let scoreParts = partList.elements(forName: "score-part") + let parts = root.elements(forName: "part") + let measures = try XCTUnwrap(parts.first).elements(forName: "measure") + + XCTAssertEqual(scoreParts.count, 1) + XCTAssertEqual(scoreParts.first?.elements(forName: "part-name").first?.stringValue, "Bass") + XCTAssertEqual(parts.count, 1) + XCTAssertTrue(measures.flatMap { $0.elements(forName: "harmony") }.isEmpty) + XCTAssertEqual( + measures.flatMap { $0.elements(forName: "direction") } + .filter { direction in + direction.stringValue?.contains("Intro") == true + } + .count, + 1 + ) + } } From 2ed267c9c125492e1f0b1ecf3e0b14aae14fb372 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 14 Jul 2026 08:07:28 +0300 Subject: [PATCH 05/12] feat(notation): add MusicXML instrument metadata --- CHANGELOG.md | 1 + JammLab/Models/NotationScoreModels.swift | 78 ++++++++++++++++++- JammLab/Services/NotationExportService.swift | 11 ++- JammLab/Views/NotationWindowView.swift | 2 +- JammLabTests/NotationMusicXMLTests.swift | 26 ++++++- JammLabTests/NotationPrimitivesTests.swift | 20 +++++ .../ViewModelNotationExportTests.swift | 11 ++- 7 files changed, 140 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76f0276..e242dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. - Added a bar/beat position readout to the transport time display. - Fixed multi-part Notation accessibility selection announcements and prevented harmony editing from clearing a selected stem note. - Stabilized the Rest entry icon, matched Notation track controls, aligned stem Mute/Solo buttons, and limited MusicXML export to the selected Parts. +- Added full instrument names, abbreviations, and standard instrument sounds to exported MusicXML parts and Notation score labels. ## 1.1.0 diff --git a/JammLab/Models/NotationScoreModels.swift b/JammLab/Models/NotationScoreModels.swift index 10f4451..42220e4 100644 --- a/JammLab/Models/NotationScoreModels.swift +++ b/JammLab/Models/NotationScoreModels.swift @@ -169,14 +169,84 @@ struct NotationPartID: Codable, Hashable, Identifiable, Equatable { } } -struct NotationPartDescriptor: Codable, Equatable, Identifiable { +struct NotationPartDescriptor: Equatable, Identifiable { var id: NotationPartID var title: String - - static let main = NotationPartDescriptor(id: .main, title: "Main") + var abbreviation: String + var instrumentName: String + var instrumentSound: String? + + static let main = make( + id: .main, + title: "Main", + abbreviation: "Main" + ) static func stem(_ type: StemType) -> NotationPartDescriptor { - NotationPartDescriptor(id: .stem(type), title: type.title) + switch type { + case .vocals: + return make( + id: .stem(type), + title: "Vocals", + abbreviation: "Voc.", + instrumentSound: "voice.vocals" + ) + case .instrumental: + return make( + id: .stem(type), + title: "Instrumental", + abbreviation: "Instr." + ) + case .drums: + return make( + id: .stem(type), + title: "Drum Set", + abbreviation: "Dr.", + instrumentSound: "drum.group.set" + ) + case .bass: + return make( + id: .stem(type), + title: "Bass Guitar", + abbreviation: "B. Guit.", + instrumentSound: "pluck.bass" + ) + case .other: + return make( + id: .stem(type), + title: "Other", + abbreviation: "Other" + ) + case .guitar: + return make( + id: .stem(type), + title: "Guitar", + abbreviation: "Guit.", + instrumentSound: "pluck.guitar" + ) + case .piano: + return make( + id: .stem(type), + title: "Piano", + abbreviation: "Pno.", + instrumentSound: "keyboard.piano" + ) + } + } + + private static func make( + id: NotationPartID, + title: String, + abbreviation: String, + instrumentSound: String? = nil + ) -> NotationPartDescriptor { + NotationPartDescriptor( + id: id, + title: title, + abbreviation: abbreviation, + instrumentName: title, + instrumentSound: instrumentSound + ) } } diff --git a/JammLab/Services/NotationExportService.swift b/JammLab/Services/NotationExportService.swift index e970d7b..4d37285 100644 --- a/JammLab/Services/NotationExportService.swift +++ b/JammLab/Services/NotationExportService.swift @@ -179,10 +179,19 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { private func partList(parts: [NotationExportPart], title: String) -> XMLElement { let partList = element("part-list") for (index, part) in parts.enumerated() { - let scorePart = element("score-part", attributes: ["id": musicXMLPartID(for: index)]) + let partID = musicXMLPartID(for: index) + let scorePart = element("score-part", attributes: ["id": partID]) let partName = part.descriptor.id.isMain ? title : part.descriptor.title let attributes = part.descriptor.id.isMain ? ["print-object": "no"] : [:] scorePart.addChild(element("part-name", stringValue: partName, attributes: attributes)) + scorePart.addChild(element("part-abbreviation", stringValue: part.descriptor.abbreviation)) + + let scoreInstrument = element("score-instrument", attributes: ["id": "\(partID)-I1"]) + scoreInstrument.addChild(element("instrument-name", stringValue: part.descriptor.instrumentName)) + if let instrumentSound = part.descriptor.instrumentSound { + scoreInstrument.addChild(element("instrument-sound", stringValue: instrumentSound)) + } + scorePart.addChild(scoreInstrument) partList.addChild(scorePart) } return partList diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index 14706cb..be90e7d 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -163,7 +163,7 @@ struct NotationWindowView: View { ForEach(system.staves) { staff in HStack(spacing: AppTheme.Spacing.none) { if usesPartGutter { - Text(staff.part.title) + Text(staff.part.abbreviation) .font(AppTheme.Typography.noteTitle) .foregroundStyle(appColors.secondaryText) .lineLimit(2) diff --git a/JammLabTests/NotationMusicXMLTests.swift b/JammLabTests/NotationMusicXMLTests.swift index 10f61b3..e9b1ec9 100644 --- a/JammLabTests/NotationMusicXMLTests.swift +++ b/JammLabTests/NotationMusicXMLTests.swift @@ -41,6 +41,9 @@ final class NotationMusicXMLTests: XCTestCase { let software = try firstXMLChild(named: "software", in: encoding) let scorePart = try firstXMLChild(named: "score-part", in: partList) let partName = try firstXMLChild(named: "part-name", in: scorePart) + let partAbbreviation = try firstXMLChild(named: "part-abbreviation", in: scorePart) + let scoreInstrument = try firstXMLChild(named: "score-instrument", in: scorePart) + let instrumentName = try firstXMLChild(named: "instrument-name", in: scoreInstrument) let creditWords = try firstXMLChild(named: "credit-words", in: credit) let part = try XCTUnwrap(rootElements.first { $0.name == "part" }) let measures = part.elements(forName: "measure") @@ -105,6 +108,12 @@ final class NotationMusicXMLTests: XCTestCase { XCTAssertEqual(creditWords.attribute(forName: "font-size")?.stringValue, "22") XCTAssertEqual(partName.stringValue, "Song") XCTAssertEqual(partName.attribute(forName: "print-object")?.stringValue, "no") + XCTAssertEqual(partAbbreviation.stringValue, "Main") + XCTAssertEqual(scoreInstrument.attribute(forName: "id")?.stringValue, "P1-I1") + XCTAssertEqual(instrumentName.stringValue, "Main") + XCTAssertTrue(scoreInstrument.elements(forName: "instrument-sound").isEmpty) + try assertXMLChild(partName, precedes: partAbbreviation, in: scorePart) + try assertXMLChild(partAbbreviation, precedes: scoreInstrument, in: scorePart) XCTAssertEqual(firstMeasure.attribute(forName: "number")?.stringValue, "1") let firstMeasureAttributes = try firstXMLChild(named: "attributes", in: firstMeasure) @@ -229,7 +238,22 @@ final class NotationMusicXMLTests: XCTestCase { let bassPitch = try firstXMLChild(named: "pitch", in: try firstXMLChild(named: "note", in: try XCTUnwrap(bassPart.elements(forName: "measure").first))) XCTAssertEqual(scoreParts.map { $0.attribute(forName: "id")?.stringValue }, ["P1", "P2"]) - XCTAssertEqual(scoreParts.compactMap { $0.elements(forName: "part-name").first?.stringValue }, ["Song", "Bass"]) + let bassScorePart = try XCTUnwrap(scoreParts.last) + let bassPartName = try firstXMLChild(named: "part-name", in: bassScorePart) + let bassPartAbbreviation = try firstXMLChild(named: "part-abbreviation", in: bassScorePart) + let bassScoreInstrument = try firstXMLChild(named: "score-instrument", in: bassScorePart) + let bassInstrumentName = try firstXMLChild(named: "instrument-name", in: bassScoreInstrument) + let bassInstrumentSound = try firstXMLChild(named: "instrument-sound", in: bassScoreInstrument) + + XCTAssertEqual(scoreParts.compactMap { $0.elements(forName: "part-name").first?.stringValue }, ["Song", "Bass Guitar"]) + XCTAssertEqual(bassPartName.stringValue, "Bass Guitar") + XCTAssertEqual(bassPartAbbreviation.stringValue, "B. Guit.") + XCTAssertEqual(bassScoreInstrument.attribute(forName: "id")?.stringValue, "P2-I1") + XCTAssertEqual(bassInstrumentName.stringValue, "Bass Guitar") + XCTAssertEqual(bassInstrumentSound.stringValue, "pluck.bass") + try assertXMLChild(bassPartName, precedes: bassPartAbbreviation, in: bassScorePart) + try assertXMLChild(bassPartAbbreviation, precedes: bassScoreInstrument, in: bassScorePart) + try assertXMLChild(bassInstrumentName, precedes: bassInstrumentSound, in: bassScoreInstrument) XCTAssertEqual(try firstXMLChild(named: "step", in: mainPitch).stringValue, "C") XCTAssertEqual(try firstXMLChild(named: "step", in: bassPitch).stringValue, "E") XCTAssertFalse(mainPart.elements(forName: "measure").flatMap { $0.elements(forName: "harmony") }.isEmpty) diff --git a/JammLabTests/NotationPrimitivesTests.swift b/JammLabTests/NotationPrimitivesTests.swift index 28e1d3c..51ab477 100644 --- a/JammLabTests/NotationPrimitivesTests.swift +++ b/JammLabTests/NotationPrimitivesTests.swift @@ -3,6 +3,26 @@ import XCTest @testable import JammLab final class NotationPrimitivesTests: XCTestCase { + func testNotationPartDescriptorsProvideMusicXMLMetadata() { + let descriptors: [(NotationPartDescriptor, String, String, String?)] = [ + (.main, "Main", "Main", nil), + (.stem(.vocals), "Vocals", "Voc.", "voice.vocals"), + (.stem(.instrumental), "Instrumental", "Instr.", nil), + (.stem(.drums), "Drum Set", "Dr.", "drum.group.set"), + (.stem(.bass), "Bass Guitar", "B. Guit.", "pluck.bass"), + (.stem(.other), "Other", "Other", nil), + (.stem(.guitar), "Guitar", "Guit.", "pluck.guitar"), + (.stem(.piano), "Piano", "Pno.", "keyboard.piano") + ] + + for (descriptor, title, abbreviation, instrumentSound) in descriptors { + XCTAssertEqual(descriptor.title, title) + XCTAssertEqual(descriptor.abbreviation, abbreviation) + XCTAssertEqual(descriptor.instrumentName, title) + XCTAssertEqual(descriptor.instrumentSound, instrumentSound) + } + } + func testNotationSMuFLRestSymbolsMapDurationsToCodepoints() throws { let whole = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 1))) let half = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 2))) diff --git a/JammLabTests/ViewModelNotationExportTests.swift b/JammLabTests/ViewModelNotationExportTests.swift index 3f1e21c..6f5475e 100644 --- a/JammLabTests/ViewModelNotationExportTests.swift +++ b/JammLabTests/ViewModelNotationExportTests.swift @@ -80,7 +80,7 @@ final class ViewModelNotationExportTests: XCTestCase { XCTAssertTrue(didExport) let xml = try String(contentsOf: outputURL, encoding: .utf8) - XCTAssertTrue(xml.contains("Bass")) + XCTAssertTrue(xml.contains("Bass Guitar")) XCTAssertTrue(xml.contains("E")) XCTAssertEqual(xml.components(separatedBy: ">Intro").count - 1, 1) } @@ -133,11 +133,18 @@ final class ViewModelNotationExportTests: XCTestCase { let root = try XCTUnwrap(document.rootElement()) let partList = try XCTUnwrap(root.elements(forName: "part-list").first) let scoreParts = partList.elements(forName: "score-part") + let bassScorePart = try XCTUnwrap(scoreParts.first) + let scoreInstrument = try XCTUnwrap(bassScorePart.elements(forName: "score-instrument").first) let parts = root.elements(forName: "part") let measures = try XCTUnwrap(parts.first).elements(forName: "measure") XCTAssertEqual(scoreParts.count, 1) - XCTAssertEqual(scoreParts.first?.elements(forName: "part-name").first?.stringValue, "Bass") + XCTAssertEqual(bassScorePart.elements(forName: "part-name").first?.stringValue, "Bass Guitar") + XCTAssertEqual(bassScorePart.elements(forName: "part-abbreviation").first?.stringValue, "B. Guit.") + XCTAssertEqual(scoreInstrument.attribute(forName: "id")?.stringValue, "P1-I1") + XCTAssertEqual(scoreInstrument.elements(forName: "instrument-name").first?.stringValue, "Bass Guitar") + XCTAssertEqual(scoreInstrument.elements(forName: "instrument-sound").first?.stringValue, "pluck.bass") + XCTAssertFalse(bassScorePart.stringValue?.contains("Main") == true) XCTAssertEqual(parts.count, 1) XCTAssertTrue(measures.flatMap { $0.elements(forName: "harmony") }.isEmpty) XCTAssertEqual( From 5f786070323960962dda608a7077f2c4a803901b Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 14 Jul 2026 08:28:59 +0300 Subject: [PATCH 06/12] fix(notation): make expanded tracks vertically scrollable --- CHANGELOG.md | 1 + JammLab/ContentView.swift | 4 +--- JammLab/Views/MainWorkspacePanels.swift | 21 +++++++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e242dc6..93e1dbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. - Fixed multi-part Notation accessibility selection announcements and prevented harmony editing from clearing a selected stem note. - Stabilized the Rest entry icon, matched Notation track controls, aligned stem Mute/Solo buttons, and limited MusicXML export to the selected Parts. - Added full instrument names, abbreviations, and standard instrument sounds to exported MusicXML parts and Notation score labels. +- Added vertical workspace scrolling when expanded Notation tracks exceed the main window height. ## 1.1.0 diff --git a/JammLab/ContentView.swift b/JammLab/ContentView.swift index a54371c..d68473e 100644 --- a/JammLab/ContentView.swift +++ b/JammLab/ContentView.swift @@ -25,9 +25,7 @@ struct ContentView: View { Divider() - workspaceContent - .padding(AppTheme.Spacing.pagePadding) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + scrollableWorkspaceContent .background(appColors.appBackground) } .background(WindowTitleUpdater(title: viewModel.windowTitle)) diff --git a/JammLab/Views/MainWorkspacePanels.swift b/JammLab/Views/MainWorkspacePanels.swift index 93a1cc7..a16417c 100644 --- a/JammLab/Views/MainWorkspacePanels.swift +++ b/JammLab/Views/MainWorkspacePanels.swift @@ -21,6 +21,27 @@ enum NotesFilter: String, CaseIterable, Identifiable { } extension ContentView { + var scrollableWorkspaceContent: some View { + GeometryReader { proxy in + let viewportContentHeight = max( + 0, + proxy.size.height - AppTheme.Spacing.pagePadding * 2 + ) + let workspaceHeight = max( + viewportContentHeight, + timelineColumnMinimumHeight + ) + + ScrollView(.vertical) { + workspaceContent + .frame(maxWidth: .infinity, alignment: .topLeading) + .frame(height: workspaceHeight, alignment: .topLeading) + .padding(AppTheme.Spacing.pagePadding) + } + .scrollBounceBehavior(.basedOnSize, axes: .vertical) + } + } + var workspaceContent: some View { HStack(alignment: .top, spacing: AppTheme.Spacing.sectionGap) { timelineColumn From dbf08f5afa888b26cbeab5234817981845c88be0 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 14 Jul 2026 09:41:20 +0300 Subject: [PATCH 07/12] fix(notation): convert selected note when entering rest mode --- CHANGELOG.md | 1 + .../AudioPlayerViewModel+Notation.swift | 25 +++- ...wModelNotationDurationSelectionTests.swift | 128 ++++++++++++++++++ 3 files changed, 150 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e1dbe..ffc68ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. - Stabilized the Rest entry icon, matched Notation track controls, aligned stem Mute/Solo buttons, and limited MusicXML export to the selected Parts. - Added full instrument names, abbreviations, and standard instrument sounds to exported MusicXML parts and Notation score labels. - Added vertical workspace scrolling when expanded Notation tracks exceed the main window height. +- Changed Rest entry mode to replace a selected note with its corresponding rest without recomposing the measure. ## 1.1.0 diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift index 23b940f..0759111 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift @@ -90,10 +90,15 @@ extension AudioPlayerViewModel { } func toggleNotationRestEntryMode() { - setNotationEntryMode(isNotationRestEntryModeEnabled ? nil : .rest) + setNotationRestEntryModeEnabled(!isNotationRestEntryModeEnabled) } func setNotationRestEntryModeEnabled(_ isEnabled: Bool) { + if isEnabled, duration > 0 { + _ = replaceSelectedNotationNoteWithRest( + undoActionName: "Convert Notation Note to Rest" + ) + } setNotationEntryMode(isEnabled ? .rest : nil) } @@ -413,6 +418,15 @@ extension AudioPlayerViewModel { @discardableResult func deleteSelectedNotationNote() -> Bool { + replaceSelectedNotationNoteWithRest( + undoActionName: "Delete Notation Note" + ) + } + + @discardableResult + private func replaceSelectedNotationNoteWithRest( + undoActionName: String + ) -> Bool { guard let selection = selectedNotationItem, let match = notationItemMatch(for: selection), match.item.kind == .note @@ -434,7 +448,7 @@ extension AudioPlayerViewModel { displayDuration: item.displayDuration ) - performUndoableEdit("Delete Notation Note") { + performUndoableEdit(undoActionName) { replaceNotationItem( in: measure, matching: item, @@ -970,8 +984,11 @@ extension AudioPlayerViewModel { matching targetItem: NotationMeasureItem, with replacementItem: NotationMeasureItem ) { - let updatedItems = measure.notationItems.map { item in - item.id == targetItem.id && item.partID == targetItem.partID ? replacementItem : item.persistedCopy() + let updatedItems = measure.notationItems.compactMap { item in + if item.id == targetItem.id && item.partID == targetItem.partID { + return replacementItem + } + return item.isSynthesized ? nil : item.persistedCopy() } replaceNotationMeasureItems(in: measure, with: updatedItems) } diff --git a/JammLabTests/ViewModelNotationDurationSelectionTests.swift b/JammLabTests/ViewModelNotationDurationSelectionTests.swift index 309032f..43c04c4 100644 --- a/JammLabTests/ViewModelNotationDurationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationDurationSelectionTests.swift @@ -790,6 +790,134 @@ final class ViewModelNotationDurationSelectionTests: XCTestCase { XCTAssertEqual(redoneRest.kind, .rest) } + @MainActor + func testEnablingRestEntryModeReplacesSelectedNoteWithoutChangingOtherMeasureItems() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let selectedNote = NotationMeasureItem( + id: "selected-eighth-note", + kind: .note, + pitch: NotationPitch(step: .e, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ) + let otherItems = [ + NotationMeasureItem( + id: "eighth-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0.5, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ), + NotationMeasureItem( + id: "other-quarter-note", + kind: .note, + pitch: NotationPitch(step: .g, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "half-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + ] + viewModel.notationItems = [selectedNote] + otherItems + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let measure = try notationMeasure(1, in: viewModel) + let selectedItem = try XCTUnwrap( + measure.notationItems.first { $0.id == selectedNote.id } + ) + viewModel.selectNotationItem( + NotationItemSelection(measure: measure, item: selectedItem), + shouldAudition: false + ) + viewModel.markProjectClean() + + viewModel.toggleNotationRestEntryMode() + + let updatedMeasure = try notationMeasure(1, in: viewModel) + let replacementRest = try XCTUnwrap( + updatedMeasure.notationItems.first { $0.id == selectedNote.id } + ) + XCTAssertEqual(replacementRest.kind, .rest) + XCTAssertNil(replacementRest.pitch) + XCTAssertEqual(replacementRest.offsetInQuarterNotes, selectedNote.offsetInQuarterNotes) + XCTAssertEqual(replacementRest.durationInQuarterNotes, selectedNote.durationInQuarterNotes) + XCTAssertEqual(replacementRest.displayDuration, selectedNote.displayDuration) + XCTAssertEqual( + updatedMeasure.notationItems.filter { $0.id != selectedNote.id }, + otherItems + ) + XCTAssertEqual(viewModel.notationEntryMode, .rest) + XCTAssertNil(viewModel.selectedNotationItem) + XCTAssertTrue(viewModel.isProjectModified) + XCTAssertTrue(viewModel.canUndo) + + viewModel.undoLastEdit() + + let restoredMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual( + restoredMeasure.notationItems.first { $0.id == selectedNote.id }, + selectedNote + ) + XCTAssertEqual( + restoredMeasure.notationItems.filter { $0.id != selectedNote.id }, + otherItems + ) + } + + @MainActor + func testEnablingRestEntryModePreservesSynthesizedNeighboringRests() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let note = NotationMeasureItem( + id: "explicit-quarter-note", + kind: .note, + pitch: NotationPitch(step: .e, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + viewModel.notationItems = [note] + let measureBeforeConversion = try notationMeasure(1, in: viewModel) + let selectedNote = try XCTUnwrap( + measureBeforeConversion.notationItems.first { $0.id == note.id } + ) + let synthesizedNeighbors = measureBeforeConversion.notationItems.filter { + $0.id != note.id + } + XCTAssertFalse(synthesizedNeighbors.isEmpty) + XCTAssertTrue(synthesizedNeighbors.allSatisfy(\.isSynthesized)) + viewModel.selectNotationItem( + NotationItemSelection(measure: measureBeforeConversion, item: selectedNote), + shouldAudition: false + ) + + viewModel.toggleNotationRestEntryMode() + + let convertedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual( + convertedMeasure.notationItems.filter { $0.id != note.id }, + synthesizedNeighbors + ) + XCTAssertEqual( + convertedMeasure.notationItems.first { $0.id == note.id }?.kind, + .rest + ) + } + @MainActor func testDeletingSelectedNotationRestIsNoOp() throws { let viewModel = try loadedNotationViewModel(duration: 8) From d465ae12fdeea30d6cc23bbb51eabe10a8bdcbe0 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Tue, 14 Jul 2026 14:41:00 +0300 Subject: [PATCH 08/12] refactor(notation): centralize per-part clef rules --- CHANGELOG.md | 1 + JammLab/DesignSystem/AppTheme.swift | 3 +- JammLab/Models/JammLabProject.swift | 8 +- JammLab/Models/NotationScoreModels.swift | 171 ++++++++++++------ JammLab/Models/NotationViewportState.swift | 4 + JammLab/Models/ProjectEditableState.swift | 2 + .../NotationNotePlacementResolver.swift | 47 +++-- JammLab/Services/NotationSMuFL.swift | 79 ++++++++ .../Services/NotationViewportFactory.swift | 25 ++- .../ProjectPersistenceCoordinator.swift | 2 + .../AudioPlayerViewModel+Notation.swift | 78 +++++++- .../AudioPlayerViewModel+NotationExport.swift | 1 + .../AudioPlayerViewModel+Project.swift | 4 + .../AudioPlayerViewModel+UndoDirty.swift | 3 + JammLab/ViewModels/AudioPlayerViewModel.swift | 1 + JammLab/Views/MainWorkspacePanels.swift | 2 + JammLab/Views/NotationTrackView.swift | 126 +++++++++---- JammLab/Views/NotationWindowView.swift | 2 + JammLab/Views/WaveformTimelineView.swift | 2 + JammLabTests/NotationMusicXMLTests.swift | 11 ++ JammLabTests/NotationPrimitivesTests.swift | 147 +++++++++++++++ .../NotationTrackLayoutItemsTests.swift | 6 + JammLabTests/NotationViewportTests.swift | 33 ++++ .../StemWorkflowPersistenceTests.swift | 15 +- ...wModelNotationDurationSelectionTests.swift | 159 ++++++++++++++++ .../ViewModelNotationExportTests.swift | 4 + .../ViewModelNotationSelectionTests.swift | 1 + .../ViewModelProjectSaveCloseTests.swift | 31 ++++ 28 files changed, 846 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffc68ea..9aae2d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. - Added full instrument names, abbreviations, and standard instrument sounds to exported MusicXML parts and Notation score labels. - Added vertical workspace scrolling when expanded Notation tracks exceed the main window height. - Changed Rest entry mode to replace a selected note with its corresponding rest without recomposing the measure. +- Added per-part treble and bass clefs with Leland glyphs, clef-aware note editing, pitch transposition, and MusicXML export. ## 1.1.0 diff --git a/JammLab/DesignSystem/AppTheme.swift b/JammLab/DesignSystem/AppTheme.swift index b49d81c..368fb55 100644 --- a/JammLab/DesignSystem/AppTheme.swift +++ b/JammLab/DesignSystem/AppTheme.swift @@ -295,9 +295,8 @@ enum AppTheme { static let notationStaffLineSpacing: CGFloat = 8 static let notationStaffHorizontalInset: CGFloat = 10 static let notationAttributeStaffTopInset: CGFloat = AppTheme.Spacing.xxl - static let notationClefFontSize: CGFloat = 48 + static let notationClefFontSize: CGFloat = 33 static let notationClefWidth: CGFloat = 38 - static let notationTrebleClefVerticalOffset: CGFloat = -6 static let notationTimeSignatureWidth: CGFloat = 26 static let notationAccidentalWidth: CGFloat = 11 static let notationMinimumMeasureContentWidth: CGFloat = 28 diff --git a/JammLab/Models/JammLabProject.swift b/JammLab/Models/JammLabProject.swift index 9f26f64..80caab6 100644 --- a/JammLab/Models/JammLabProject.swift +++ b/JammLab/Models/JammLabProject.swift @@ -24,6 +24,7 @@ struct JammLabProject: Codable { var notes: [TimecodedNote] var harmonySymbols: [HarmonySymbol] var notationItems: [NotationMeasureItem] + var notationPartClefs: [NotationPartID: Clef] var projectKeySelection: ProjectKeySelection? var loopStart: TimeInterval var loopEnd: TimeInterval @@ -46,7 +47,7 @@ struct JammLabProject: Codable { var visibleNotationPartIDs: Set init( - formatVersion: Int = 11, + formatVersion: Int = 12, audioBookmarkData: Data, artifactRootBookmarkData: Data? = nil, audioDisplayName: String, @@ -55,6 +56,7 @@ struct JammLabProject: Codable { notes: [TimecodedNote], harmonySymbols: [HarmonySymbol] = [], notationItems: [NotationMeasureItem] = [], + notationPartClefs: [NotationPartID: Clef] = [:], projectKeySelection: ProjectKeySelection? = nil, loopStart: TimeInterval, loopEnd: TimeInterval, @@ -85,6 +87,7 @@ struct JammLabProject: Codable { self.notes = notes self.harmonySymbols = harmonySymbols self.notationItems = notationItems + self.notationPartClefs = notationPartClefs self.projectKeySelection = projectKeySelection self.loopStart = loopStart self.loopEnd = loopEnd @@ -117,6 +120,7 @@ struct JammLabProject: Codable { case notes case harmonySymbols case notationItems + case notationPartClefs case projectKeySelection case loopStart case loopEnd @@ -150,6 +154,7 @@ struct JammLabProject: Codable { notes = try container.decode([TimecodedNote].self, forKey: .notes) harmonySymbols = try container.decodeIfPresent([HarmonySymbol].self, forKey: .harmonySymbols) ?? [] notationItems = try container.decodeIfPresent([NotationMeasureItem].self, forKey: .notationItems) ?? [] + notationPartClefs = try container.decodeIfPresent([NotationPartID: Clef].self, forKey: .notationPartClefs) ?? [:] projectKeySelection = try container.decodeIfPresent(ProjectKeySelection.self, forKey: .projectKeySelection) loopStart = try container.decode(TimeInterval.self, forKey: .loopStart) loopEnd = try container.decode(TimeInterval.self, forKey: .loopEnd) @@ -183,6 +188,7 @@ struct JammLabProject: Codable { try container.encode(notes, forKey: .notes) try container.encode(harmonySymbols, forKey: .harmonySymbols) try container.encode(notationItems, forKey: .notationItems) + try container.encode(notationPartClefs, forKey: .notationPartClefs) try container.encodeIfPresent(projectKeySelection, forKey: .projectKeySelection) try container.encode(loopStart, forKey: .loopStart) try container.encode(loopEnd, forKey: .loopEnd) diff --git a/JammLab/Models/NotationScoreModels.swift b/JammLab/Models/NotationScoreModels.swift index 42220e4..993ccb0 100644 --- a/JammLab/Models/NotationScoreModels.swift +++ b/JammLab/Models/NotationScoreModels.swift @@ -465,6 +465,18 @@ enum NotationPitchStep: String, Codable, CaseIterable, Equatable { case g = "G" case a = "A" case b = "B" + + var diatonicIndex: Int { + switch self { + case .c: return 0 + case .d: return 1 + case .e: return 2 + case .f: return 3 + case .g: return 4 + case .a: return 5 + case .b: return 6 + } + } } struct NotationPitch: Codable, Equatable { @@ -502,71 +514,61 @@ struct NotationPitch: Codable, Equatable { } enum NotationPitchMapper { - private static let staffPitches: [NotationPitch] = [ - NotationPitch(step: .d, octave: 6), - NotationPitch(step: .c, octave: 6), - NotationPitch(step: .b, octave: 5), - NotationPitch(step: .a, octave: 5), - NotationPitch(step: .g, octave: 5), - NotationPitch(step: .f, octave: 5), - NotationPitch(step: .e, octave: 5), - NotationPitch(step: .d, octave: 5), - NotationPitch(step: .c, octave: 5), - NotationPitch(step: .b, octave: 4), - NotationPitch(step: .a, octave: 4), - NotationPitch(step: .g, octave: 4), - NotationPitch(step: .f, octave: 4), - NotationPitch(step: .e, octave: 4), - NotationPitch(step: .d, octave: 4), - NotationPitch(step: .c, octave: 4), - NotationPitch(step: .b, octave: 3), - NotationPitch(step: .a, octave: 3), - NotationPitch(step: .g, octave: 3) - ] + static var minimumStaffPosition: Int { + Clef.treble.notationMetrics.editableStaffPositionRange.lowerBound + } - static let minimumStaffPosition = -5 - static let maximumStaffPosition = 13 + static var maximumStaffPosition: Int { + Clef.treble.notationMetrics.editableStaffPositionRange.upperBound + } + + static func editableStaffPositionRange(for clef: Clef) -> ClosedRange { + clef.notationMetrics.editableStaffPositionRange + } static func pitch( forStaffPosition staffPosition: Int, - keySignature: KeySignature + keySignature: KeySignature, + clef: Clef = .treble ) -> NotationPitch { - let clampedPosition = min(maximumStaffPosition, max(minimumStaffPosition, staffPosition)) - let index = clampedPosition - minimumStaffPosition - var pitch = staffPitches[index] + let range = editableStaffPositionRange(for: clef) + let clampedPosition = min(range.upperBound, max(range.lowerBound, staffPosition)) + let ordinal = clef.notationMetrics.topLineDiatonicOrdinal - clampedPosition + let octave = Int(floor(Double(ordinal) / Double(NotationPitchStep.allCases.count))) + let stepIndex = ordinal - octave * NotationPitchStep.allCases.count + var pitch = NotationPitch( + step: NotationPitchStep.allCases[stepIndex], + octave: octave + ) pitch.alter = keySignature.defaultAlter(for: pitch.step) return pitch } - static func staffPosition(for pitch: NotationPitch) -> Int? { - staffPitches.firstIndex { - $0.step == pitch.step && $0.octave == pitch.octave - }.map { - $0 + minimumStaffPosition - } + static func staffPosition(for pitch: NotationPitch, clef: Clef = .treble) -> Int { + let ordinal = pitch.octave * NotationPitchStep.allCases.count + pitch.step.diatonicIndex + return clef.notationMetrics.topLineDiatonicOrdinal - ordinal } static func adjacentPitch( from pitch: NotationPitch, staffPositionDelta: Int, - keySignature: KeySignature + keySignature: KeySignature, + clef: Clef = .treble ) -> NotationPitch? { - guard staffPositionDelta != 0, - let currentPosition = staffPosition(for: pitch) - else { - return nil - } + guard staffPositionDelta != 0 else { return nil } + let currentPosition = staffPosition(for: pitch, clef: clef) let targetPosition = currentPosition + staffPositionDelta - guard targetPosition >= minimumStaffPosition, - targetPosition <= maximumStaffPosition + let range = editableStaffPositionRange(for: clef) + guard range.contains(targetPosition) else { return nil } return self.pitch( forStaffPosition: targetPosition, - keySignature: keySignature + keySignature: keySignature, + clef: clef ) } } @@ -717,18 +719,22 @@ struct KeySignature: Equatable { } func notationAccidentalGlyphs(for clef: Clef) -> [KeySignatureAccidental] { - switch clef { - case .treble: - if fifths > 0 { - return Array(Self.trebleSharpAccidentals.prefix(accidentalCount)) - } - - if fifths < 0 { - return Array(Self.trebleFlatAccidentals.prefix(accidentalCount)) - } - + let source: ArraySlice + if fifths > 0 { + source = Self.trebleSharpAccidentals.prefix(accidentalCount) + } else if fifths < 0 { + source = Self.trebleFlatAccidentals.prefix(accidentalCount) + } else { return [] } + + let positionOffset = clef.notationMetrics.keySignatureStaffPositionOffset + return source.map { accidental in + KeySignatureAccidental( + symbol: accidental.symbol, + staffPositionFromTopLine: accidental.staffPositionFromTopLine + positionOffset + ) + } } func defaultAlter(for step: NotationPitchStep) -> Int { @@ -850,18 +856,69 @@ struct KeySignature: Equatable { ] } -enum Clef: String, Equatable { +struct NotationClefMetrics: Equatable { + let editableStaffPositionRange: ClosedRange + let topLineDiatonicOrdinal: Int + let keySignatureStaffPositionOffset: Int + let storedPitchOctaveOffset: Int +} + +enum Clef: String, Codable, CaseIterable, Identifiable, Equatable { case treble + case bass + + var id: String { rawValue } var sign: String { - "G" + switch self { + case .treble: return "G" + case .bass: return "F" + } } var line: Int { - 2 + switch self { + case .treble: return 2 + case .bass: return 4 + } + } + + var displayName: String { + switch self { + case .treble: return "Treble Clef" + case .bass: return "Bass Clef" + } + } + + var notationMetrics: NotationClefMetrics { + switch self { + case .treble: + return NotationClefMetrics( + editableStaffPositionRange: -5...13, + topLineDiatonicOrdinal: 5 * NotationPitchStep.allCases.count + NotationPitchStep.f.diatonicIndex, + keySignatureStaffPositionOffset: 0, + storedPitchOctaveOffset: 0 + ) + case .bass: + return NotationClefMetrics( + editableStaffPositionRange: -3...15, + topLineDiatonicOrdinal: 3 * NotationPitchStep.allCases.count + NotationPitchStep.a.diatonicIndex, + keySignatureStaffPositionOffset: 2, + storedPitchOctaveOffset: -2 + ) + } + } +} + +enum NotationPartClefOverrides { + static func normalized(_ overrides: [NotationPartID: Clef]) -> [NotationPartID: Clef] { + overrides.filter { $0.value != .treble } } - var displaySymbol: String { - "𝄞" + static func clef( + for partID: NotationPartID, + in overrides: [NotationPartID: Clef] + ) -> Clef { + overrides[partID] ?? .treble } } diff --git a/JammLab/Models/NotationViewportState.swift b/JammLab/Models/NotationViewportState.swift index a4e83fe..1bd0438 100644 --- a/JammLab/Models/NotationViewportState.swift +++ b/JammLab/Models/NotationViewportState.swift @@ -139,6 +139,7 @@ final class NotationProjectionCache { tempoMap: TempoMap, duration: TimeInterval, keyName: String?, + clef: Clef = .treble, partID: NotationPartID = .main, includesHarmonies: Bool = true, notationItems: [NotationMeasureItem], @@ -152,6 +153,7 @@ final class NotationProjectionCache { tempoMap: tempoMap, duration: duration, keyName: keyName, + clef: clef, notationItems: scopedNotationItems, harmonySymbols: scopedHarmonySymbols, regionNotes: regionNotes @@ -167,6 +169,7 @@ final class NotationProjectionCache { tempoMap: tempoMap, duration: duration, keyName: keyName, + clef: clef, partID: partID, includesHarmonies: includesHarmonies, notationItems: scopedNotationItems, @@ -196,6 +199,7 @@ final class NotationProjectionCache { var tempoMap: TempoMap var duration: TimeInterval var keyName: String? + var clef: Clef var notationItems: [NotationMeasureItem] var harmonySymbols: [HarmonySymbol] var regionNotes: [TimecodedNote] diff --git a/JammLab/Models/ProjectEditableState.swift b/JammLab/Models/ProjectEditableState.swift index a5c9241..70a3215 100644 --- a/JammLab/Models/ProjectEditableState.swift +++ b/JammLab/Models/ProjectEditableState.swift @@ -4,6 +4,7 @@ struct ProjectEditableState: Equatable { var notes: [TimecodedNote] var harmonySymbols: [HarmonySymbol] = [] var notationItems: [NotationMeasureItem] = [] + var notationPartClefs: [NotationPartID: Clef] = [:] var stemNotationTrackCollapsed: [StemType: Bool] = [:] var visibleNotationPartIDs: Set = [.main] var projectKeySelection: ProjectKeySelection? = nil @@ -28,6 +29,7 @@ struct ProjectPersistedEditableState: Equatable { var notes: [TimecodedNote] var harmonySymbols: [HarmonySymbol] = [] var notationItems: [NotationMeasureItem] = [] + var notationPartClefs: [NotationPartID: Clef] = [:] var stemNotationTrackCollapsed: [StemType: Bool] = [:] var visibleNotationPartIDs: Set = [.main] var projectKeySelection: ProjectKeySelection? = nil diff --git a/JammLab/Services/NotationNotePlacementResolver.swift b/JammLab/Services/NotationNotePlacementResolver.swift index b7cae92..e477ae8 100644 --- a/JammLab/Services/NotationNotePlacementResolver.swift +++ b/JammLab/Services/NotationNotePlacementResolver.swift @@ -93,7 +93,12 @@ enum NotationNotePlacementResolver { lineSpacing: CGFloat = AppTheme.Timeline.notationStaffLineSpacing ) -> NotationNotePlacement? { guard lineSpacing > 0, - let pitchPosition = staffPosition(forY: point.y, staffTop: staffTop, lineSpacing: lineSpacing) + let pitchPosition = staffPosition( + forY: point.y, + staffTop: staffTop, + clef: measure.attributes.clef, + lineSpacing: lineSpacing + ) else { return nil } @@ -110,7 +115,8 @@ enum NotationNotePlacementResolver { let pitch = NotationPitchMapper.pitch( forStaffPosition: pitchPosition, - keySignature: measure.attributes.keySignature + keySignature: measure.attributes.keySignature, + clef: measure.attributes.clef ) let x = NotationMeasureLayout.harmonyX( geometry: geometry, @@ -141,7 +147,12 @@ enum NotationNotePlacementResolver { partID: NotationPartID = .main, lineSpacing: CGFloat = AppTheme.Timeline.notationStaffLineSpacing ) -> NotationRestPlacement? { - guard isWithinEntryYRange(point.y, staffTop: staffTop, lineSpacing: lineSpacing) else { + guard isWithinEntryYRange( + point.y, + staffTop: staffTop, + clef: measure.attributes.clef, + lineSpacing: lineSpacing + ) else { return nil } @@ -365,29 +376,37 @@ enum NotationNotePlacementResolver { static func staffPosition( forY y: CGFloat, staffTop: CGFloat, + clef: Clef = .treble, lineSpacing: CGFloat = AppTheme.Timeline.notationStaffLineSpacing ) -> Int? { - guard isWithinEntryYRange(y, staffTop: staffTop, lineSpacing: lineSpacing) else { return nil } + guard isWithinEntryYRange( + y, + staffTop: staffTop, + clef: clef, + lineSpacing: lineSpacing + ) else { return nil } - let halfSpacing = lineSpacing / 2 - let rawPosition = ((y - staffTop) / halfSpacing).rounded() - return min( - NotationPitchMapper.maximumStaffPosition, - max(NotationPitchMapper.minimumStaffPosition, Int(rawPosition)) + return clampedStaffPosition( + forY: y, + staffTop: staffTop, + clef: clef, + lineSpacing: lineSpacing ) } static func clampedStaffPosition( forY y: CGFloat, staffTop: CGFloat, + clef: Clef = .treble, lineSpacing: CGFloat = AppTheme.Timeline.notationStaffLineSpacing ) -> Int? { guard lineSpacing > 0 else { return nil } let halfSpacing = lineSpacing / 2 let rawPosition = ((y - staffTop) / halfSpacing).rounded() + let range = NotationPitchMapper.editableStaffPositionRange(for: clef) return min( - NotationPitchMapper.maximumStaffPosition, - max(NotationPitchMapper.minimumStaffPosition, Int(rawPosition)) + range.upperBound, + max(range.lowerBound, Int(rawPosition)) ) } @@ -402,17 +421,19 @@ enum NotationNotePlacementResolver { private static func isWithinEntryYRange( _ y: CGFloat, staffTop: CGFloat, + clef: Clef = .treble, lineSpacing: CGFloat ) -> Bool { guard lineSpacing > 0 else { return false } let halfSpacing = lineSpacing / 2 + let range = NotationPitchMapper.editableStaffPositionRange(for: clef) let minimumY = yPosition( - forStaffPosition: NotationPitchMapper.minimumStaffPosition, + forStaffPosition: range.lowerBound, staffTop: staffTop, lineSpacing: lineSpacing ) - halfSpacing let maximumY = yPosition( - forStaffPosition: NotationPitchMapper.maximumStaffPosition, + forStaffPosition: range.upperBound, staffTop: staffTop, lineSpacing: lineSpacing ) + halfSpacing diff --git a/JammLab/Services/NotationSMuFL.swift b/JammLab/Services/NotationSMuFL.swift index f573cba..9d30028 100644 --- a/JammLab/Services/NotationSMuFL.swift +++ b/JammLab/Services/NotationSMuFL.swift @@ -136,6 +136,78 @@ enum NotationStaffNoteSymbol: Equatable { } } +enum NotationClefSymbol: Equatable { + case treble + case bass + + init(_ clef: Clef) { + switch clef { + case .treble: + self = .treble + case .bass: + self = .bass + } + } + + var codepoint: UInt32 { + switch self { + case .treble: + return 0xE050 + case .bass: + return 0xE062 + } + } + + var referenceStaffLineFromTop: Int { + switch self { + case .treble: + return 3 + case .bass: + return 1 + } + } +} + +enum NotationClefLayout { + static let referenceAnchorY: CGFloat = 0 + + static var frameSize: CGSize { + CGSize( + width: AppTheme.Timeline.notationClefWidth, + height: AppTheme.Timeline.notationAttributeStaffTopInset * 2 + + AppTheme.Timeline.notationStaffLineSpacing * 4 + ) + } + + static func targetY(for symbol: NotationClefSymbol) -> CGFloat { + AppTheme.Timeline.notationAttributeStaffTopInset + + CGFloat(symbol.referenceStaffLineFromTop) + * AppTheme.Timeline.notationStaffLineSpacing + } + + static func target( + for symbol: NotationClefSymbol, + in size: CGSize + ) -> CGPoint { + CGPoint(x: size.width / 2, y: targetY(for: symbol)) + } + + static func referenceAnchor(for glyphPath: NotationSMuFLGlyphPath) -> CGPoint { + CGPoint(x: glyphPath.bounds.midX, y: referenceAnchorY) + } + + static func transform( + for glyphPath: NotationSMuFLGlyphPath, + symbol: NotationClefSymbol, + in size: CGSize + ) -> CGAffineTransform { + glyphPath.anchoredTransform( + anchor: referenceAnchor(for: glyphPath), + target: target(for: symbol, in: size) + ) + } +} + enum NotationMusicFontRegistry { static let fallbackFontName = "Leland" private static let glyphPathCache = NSCache() @@ -177,6 +249,13 @@ enum NotationMusicFontRegistry { glyphPath(forCodepoint: symbol.codepoint, fontSize: fontSize) } + static func glyphPath( + for symbol: NotationClefSymbol, + fontSize: CGFloat + ) -> NotationSMuFLGlyphPath? { + glyphPath(forCodepoint: symbol.codepoint, fontSize: fontSize) + } + static func noteheadAnchor( for symbol: NotationStaffNoteSymbol, fontSize: CGFloat diff --git a/JammLab/Services/NotationViewportFactory.swift b/JammLab/Services/NotationViewportFactory.swift index 75559a0..f223a80 100644 --- a/JammLab/Services/NotationViewportFactory.swift +++ b/JammLab/Services/NotationViewportFactory.swift @@ -8,6 +8,7 @@ struct NotationViewportFactory { playbackMarkerTime: TimeInterval, isPlaying: Bool, keyName: String?, + clef: Clef = .treble, partID: NotationPartID = .main, includesHarmonies: Bool = true, notationItems: [NotationMeasureItem] = [], @@ -18,6 +19,7 @@ struct NotationViewportFactory { tempoMap: tempoMap, duration: duration, keyName: keyName, + clef: clef, partID: partID, includesHarmonies: includesHarmonies, notationItems: notationItems, @@ -38,6 +40,7 @@ struct NotationViewportFactory { tempoMap: TempoMap, duration: TimeInterval, keyName: String?, + clef: Clef = .treble, partID: NotationPartID = .main, includesHarmonies: Bool = true, notationItems: [NotationMeasureItem] = [], @@ -60,6 +63,7 @@ struct NotationViewportFactory { measures.append(decoratedMeasure( cursor, keySignature: keySignature, + clef: clef, partID: partID, includesHarmonies: includesHarmonies, notationItems: notationItems, @@ -128,6 +132,7 @@ struct NotationViewportFactory { isPlaying: Bool, keyName: String?, visibleMeasureCount: Int, + clef: Clef = .treble, partID: NotationPartID = .main, includesHarmonies: Bool = true, notationItems: [NotationMeasureItem] = [], @@ -138,6 +143,7 @@ struct NotationViewportFactory { tempoMap: tempoMap, duration: duration, keyName: keyName, + clef: clef, partID: partID, includesHarmonies: includesHarmonies, notationItems: notationItems, @@ -207,17 +213,20 @@ struct NotationViewportFactory { private func decoratedMeasure( _ measure: ScoreMeasure, keySignature: KeySignature, + clef: Clef, partID: NotationPartID, includesHarmonies: Bool, notationItems: [NotationMeasureItem], harmonySymbols: [HarmonySymbol], regionNotes: [TimecodedNote] ) -> ScoreMeasure { - let keyedMeasure = measure.withKeySignature(keySignature) - return keyedMeasure - .withNotationItems(Self.notationItems(for: keyedMeasure, from: notationItems, partID: partID)) - .withHarmonies(includesHarmonies ? harmonies(for: keyedMeasure, from: harmonySymbols) : []) - .withRegionLabels(regionLabels(for: keyedMeasure, from: regionNotes)) + let attributedMeasure = measure + .withKeySignature(keySignature) + .withClef(clef) + return attributedMeasure + .withNotationItems(Self.notationItems(for: attributedMeasure, from: notationItems, partID: partID)) + .withHarmonies(includesHarmonies ? harmonies(for: attributedMeasure, from: harmonySymbols) : []) + .withRegionLabels(regionLabels(for: attributedMeasure, from: regionNotes)) } static func anchorTime( @@ -644,6 +653,12 @@ private extension ScoreMeasure { return copy } + func withClef(_ clef: Clef) -> ScoreMeasure { + var copy = self + copy.attributes.clef = clef + return copy + } + func withNotationItems(_ notationItems: [NotationMeasureItem]) -> ScoreMeasure { var copy = self copy.notationItems = notationItems diff --git a/JammLab/Services/ProjectPersistenceCoordinator.swift b/JammLab/Services/ProjectPersistenceCoordinator.swift index aaa0df1..be610a9 100644 --- a/JammLab/Services/ProjectPersistenceCoordinator.swift +++ b/JammLab/Services/ProjectPersistenceCoordinator.swift @@ -32,6 +32,7 @@ struct ProjectDocumentSnapshot { let notes: [TimecodedNote] let harmonySymbols: [HarmonySymbol] let notationItems: [NotationMeasureItem] + let notationPartClefs: [NotationPartID: Clef] let projectKeySelection: ProjectKeySelection? let loopRegion: LoopRegion let loopMinimumLength: TimeInterval @@ -214,6 +215,7 @@ struct ProjectPersistenceCoordinator { notes: ProjectStateNormalizer.normalizedNotes(snapshot.notes, duration: snapshot.duration), harmonySymbols: ProjectStateNormalizer.normalizedHarmonySymbols(snapshot.harmonySymbols, duration: snapshot.duration), notationItems: ProjectStateNormalizer.normalizedNotationItems(snapshot.notationItems, duration: snapshot.duration), + notationPartClefs: NotationPartClefOverrides.normalized(snapshot.notationPartClefs), projectKeySelection: snapshot.projectKeySelection, loopStart: snapshot.loopRegion.clamped(to: snapshot.duration, minimumLength: snapshot.loopMinimumLength).start, loopEnd: snapshot.loopRegion.clamped(to: snapshot.duration, minimumLength: snapshot.loopMinimumLength).end, diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift index 0759111..9b2bbd1 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift @@ -41,6 +41,45 @@ extension AudioPlayerViewModel { return normalized } + func notationClef(for partID: NotationPartID) -> Clef { + NotationPartClefOverrides.clef(for: partID, in: notationPartClefs) + } + + func setNotationClef(_ clef: Clef, for partID: NotationPartID) { + let sourceClef = notationClef(for: partID) + guard sourceClef != clef else { return } + + let octaveDelta = clef.notationMetrics.storedPitchOctaveOffset + - sourceClef.notationMetrics.storedPitchOctaveOffset + performUndoableEdit("Change Notation Clef") { + if clef == .treble { + notationPartClefs.removeValue(forKey: partID) + } else { + notationPartClefs[partID] = clef + } + + notationItems = notationItems.map { item in + guard item.partID == partID, + !item.isSynthesized, + item.kind == .note, + var pitch = item.pitch + else { + return item + } + + pitch.octave += octaveDelta + var transposed = item + transposed.pitch = pitch + return transposed + } + notationItems = ProjectStateNormalizer.normalizedNotationItems( + notationItems, + duration: duration + ) + refreshNotationSelections(for: partID) + } + } + private func knownStemNotationPartTypes() -> [StemType] { let stemTypes = Set(stemFiles.map(\.type)) let notationStemTypes = Set(notationItems.compactMap(\.partID.stemType)) @@ -971,6 +1010,7 @@ extension AudioPlayerViewModel { playbackMarkerTime: playbackMarkerTime, isPlaying: playbackState == .playing, keyName: effectiveKeyName, + clef: notationClef(for: partID), partID: partID, includesHarmonies: partID.isMain, notationItems: notationItems, @@ -1018,10 +1058,46 @@ extension AudioPlayerViewModel { return NotationPitchMapper.adjacentPitch( from: pitch, staffPositionDelta: staffPositionDelta, - keySignature: match.measure.attributes.keySignature + keySignature: match.measure.attributes.keySignature, + clef: match.measure.attributes.clef ) } + private func refreshNotationSelections(for partID: NotationPartID) { + let measures = currentNotationScoreMeasures(partID: partID) + + if let selection = selectedNotationItem, selection.partID == partID { + if let measure = measures.first(where: { + $0.number == selection.measureNumber + && abs($0.startTime - selection.measureStartTime) < NotationMeasureTiming.timelineTolerance + }), let item = measure.notationItems.first(where: { + $0.id == selection.itemID && $0.partID == partID + }) { + selectedNotationItem = NotationItemSelection(measure: measure, item: item, partID: partID) + } else { + selectedNotationItem = nil + } + } + + selectedNotationMeasures = selectedNotationMeasures.compactMap { selection in + guard selection.partID == partID else { return selection } + guard let measure = measures.first(where: { + $0.number == selection.number + && abs($0.startTime - selection.startTime) < NotationMeasureTiming.timelineTolerance + }) else { + return nil + } + return NotationMeasureSelection(measure: measure, partID: partID) + } + + if let anchor = notationMeasureSelectionAnchor, anchor.partID == partID { + notationMeasureSelectionAnchor = measures.first(where: { + $0.number == anchor.number + && abs($0.startTime - anchor.startTime) < NotationMeasureTiming.timelineTolerance + }).map { NotationMeasureSelection(measure: $0, partID: partID) } + } + } + private func reselectNotationItem( inMeasureNumber measureNumber: Int, measureStartTime: TimeInterval, diff --git a/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift b/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift index c4e655f..f376d20 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift @@ -81,6 +81,7 @@ extension AudioPlayerViewModel { playbackMarkerTime: playbackMarkerTime, isPlaying: playbackState == .playing, keyName: effectiveKeyName, + clef: notationClef(for: partID), partID: partID, includesHarmonies: partID.isMain, notationItems: notationItems, diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift index 29d18c6..6187897 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift @@ -115,6 +115,7 @@ extension AudioPlayerViewModel { notes = [] harmonySymbols = [] notationItems = [] + notationPartClefs = [:] stemNotationTrackCollapsed = [:] visibleNotationPartIDs = [.main] projectKeySelection = nil @@ -175,6 +176,7 @@ extension AudioPlayerViewModel { notes = [] harmonySymbols = [] notationItems = [] + notationPartClefs = [:] stemNotationTrackCollapsed = [:] visibleNotationPartIDs = [.main] projectKeySelection = nil @@ -255,6 +257,7 @@ extension AudioPlayerViewModel { project.notationItems, duration: resolvedProjectDuration ) + notationPartClefs = NotationPartClefOverrides.normalized(project.notationPartClefs) projectKeySelection = project.projectKeySelection clearTransientEditingState() loopRegion = ProjectStateNormalizer.normalizedLoopRegion( @@ -393,6 +396,7 @@ extension AudioPlayerViewModel { notes: notes, harmonySymbols: harmonySymbols, notationItems: notationItems, + notationPartClefs: NotationPartClefOverrides.normalized(notationPartClefs), projectKeySelection: projectKeySelection, loopRegion: loopRegion, loopMinimumLength: activeRangeMinimumLength, diff --git a/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift b/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift index 0c2e057..f899920 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift @@ -6,6 +6,7 @@ extension AudioPlayerViewModel { notes: notes, harmonySymbols: harmonySymbols, notationItems: notationItems, + notationPartClefs: NotationPartClefOverrides.normalized(notationPartClefs), stemNotationTrackCollapsed: stemNotationTrackCollapsed, visibleNotationPartIDs: normalizedVisibleNotationPartIDs(), projectKeySelection: projectKeySelection, @@ -36,6 +37,7 @@ extension AudioPlayerViewModel { notes: ProjectStateNormalizer.normalizedNotes(notes, duration: duration), harmonySymbols: ProjectStateNormalizer.normalizedHarmonySymbols(harmonySymbols, duration: duration), notationItems: ProjectStateNormalizer.normalizedNotationItems(notationItems, duration: duration), + notationPartClefs: NotationPartClefOverrides.normalized(notationPartClefs), stemNotationTrackCollapsed: stemNotationTrackCollapsed, visibleNotationPartIDs: normalizedVisibleNotationPartIDs(), projectKeySelection: projectKeySelection, @@ -81,6 +83,7 @@ extension AudioPlayerViewModel { notes = ProjectStateNormalizer.normalizedNotes(state.notes, duration: duration) harmonySymbols = ProjectStateNormalizer.normalizedHarmonySymbols(state.harmonySymbols, duration: duration) notationItems = ProjectStateNormalizer.normalizedNotationItems(state.notationItems, duration: duration) + notationPartClefs = NotationPartClefOverrides.normalized(state.notationPartClefs) stemNotationTrackCollapsed = state.stemNotationTrackCollapsed visibleNotationPartIDs = normalizedVisibleNotationPartIDs(from: state.visibleNotationPartIDs) projectKeySelection = state.projectKeySelection diff --git a/JammLab/ViewModels/AudioPlayerViewModel.swift b/JammLab/ViewModels/AudioPlayerViewModel.swift index 38157c2..6844e72 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel.swift @@ -51,6 +51,7 @@ final class AudioPlayerViewModel: ObservableObject { @Published var notationMeasureClipboard: NotationMeasureClipboard? @Published var notationDurationDenominator = NotationDuration.defaultDenominator @Published var notationItems: [NotationMeasureItem] = [] + @Published var notationPartClefs: [NotationPartID: Clef] = [:] @Published var notationEntryMode: NotationEntryMode? @Published var pendingHarmonyEditorRequest: HarmonyEditorRequest? @Published var activeLoopRegionID: TimecodedNote.ID? diff --git a/JammLab/Views/MainWorkspacePanels.swift b/JammLab/Views/MainWorkspacePanels.swift index a16417c..58fe59c 100644 --- a/JammLab/Views/MainWorkspacePanels.swift +++ b/JammLab/Views/MainWorkspacePanels.swift @@ -202,6 +202,7 @@ extension ContentView { tempoMap: viewModel.tempoMap, duration: viewModel.duration, keyName: viewModel.effectiveKeyName, + clef: viewModel.notationClef(for: partID), partID: partID, includesHarmonies: partID.isMain, notationItems: viewModel.notationItems, @@ -284,6 +285,7 @@ extension ContentView { insertNotationNote: { viewModel.insertNotationNote($0) }, insertNotationRest: { viewModel.insertNotationRest($0) }, changeSelectedNotePitch: { viewModel.changeSelectedNotationNotePitch(to: $0, shouldAudition: $1) }, + changeNotationClef: { viewModel.setNotationClef($1, for: $0) }, auditionNotePitch: { viewModel.auditionNotationNotePitch($0) }, deleteSelectedNotationNote: { viewModel.deleteSelectedNotationNote() }, showNotationWindow: { openWindow(id: AppWindowID.notation) } diff --git a/JammLab/Views/NotationTrackView.swift b/JammLab/Views/NotationTrackView.swift index b24de51..2c7f2d7 100644 --- a/JammLab/Views/NotationTrackView.swift +++ b/JammLab/Views/NotationTrackView.swift @@ -10,6 +10,7 @@ struct NotationTrackActions { var insertNotationNote: (NotationNotePlacement) -> Bool var insertNotationRest: (NotationRestPlacement) -> Bool var changeSelectedNotePitch: (NotationPitch, Bool) -> Bool + var changeClef: (NotationPartID, Clef) -> Void var auditionNotePitch: (NotationPitch) -> Void var deleteSelectedNotationNote: () -> Bool var locatePlaybackMarkerExactly: (TimeInterval) -> Void @@ -114,11 +115,6 @@ struct NotationTrackView: View { height: proxy.size.height, attributeDisplays: attributeDisplays ) - attributeLabels( - width: contentWidth, - height: proxy.size.height, - attributeDisplays: attributeDisplays - ) playheadIndicator( width: contentWidth, height: proxy.size.height, @@ -136,6 +132,11 @@ struct NotationTrackView: View { height: proxy.size.height, attributeDisplays: attributeDisplays ) + attributeLabels( + width: contentWidth, + height: proxy.size.height, + attributeDisplays: attributeDisplays + ) } .coordinateSpace(name: notationTrackCoordinateSpaceName) .frame(width: contentWidth, height: proxy.size.height) @@ -326,11 +327,11 @@ struct NotationTrackView: View { let pitch = draggedNotePitchPreview?.matches(item.selection) == true ? draggedNotePitchPreview?.pitch : item.notationItem.pitch - guard let pitch, - let staffPosition = NotationPitchMapper.staffPosition(for: pitch) - else { - continue - } + guard let pitch else { continue } + let staffPosition = NotationPitchMapper.staffPosition( + for: pitch, + clef: item.measure.attributes.clef + ) drawNoteGlyphWithLedgerLines( duration: item.notationItem.displayDuration, x: item.x, @@ -344,17 +345,19 @@ struct NotationTrackView: View { } if let hoveredNotePlacement { - if let staffPosition = NotationPitchMapper.staffPosition(for: hoveredNotePlacement.pitch) { - drawNoteGlyphWithLedgerLines( - duration: hoveredNotePlacement.displayDuration, - x: hoveredNotePlacement.x, - staffPosition: staffPosition, - staffTop: staffTop, - color: appColors.accent, - opacity: 0.56, - in: &context - ) - } + let staffPosition = NotationPitchMapper.staffPosition( + for: hoveredNotePlacement.pitch, + clef: hoveredNotePlacement.measure.attributes.clef + ) + drawNoteGlyphWithLedgerLines( + duration: hoveredNotePlacement.displayDuration, + x: hoveredNotePlacement.x, + staffPosition: staffPosition, + staffTop: staffTop, + color: appColors.accent, + opacity: 0.56, + in: &context + ) } else if let hoveredRestPlacement, let symbol = NotationSMuFLSymbol(duration: hoveredRestPlacement.displayDuration) { drawRestGlyph( @@ -686,8 +689,11 @@ struct NotationTrackView: View { staffTop: CGFloat ) -> CGFloat { if item.notationItem.kind == .note, - let pitch = item.notationItem.pitch, - let staffPosition = NotationPitchMapper.staffPosition(for: pitch) { + let pitch = item.notationItem.pitch { + let staffPosition = NotationPitchMapper.staffPosition( + for: pitch, + clef: item.measure.attributes.clef + ) return NotationNotePlacementResolver.yPosition( forStaffPosition: staffPosition, staffTop: staffTop @@ -850,14 +856,16 @@ struct NotationTrackView: View { ) -> NotationPitch? { guard let staffPosition = NotationNotePlacementResolver.clampedStaffPosition( forY: y, - staffTop: staffTop + staffTop: staffTop, + clef: measure.attributes.clef ) else { return nil } return NotationPitchMapper.pitch( forStaffPosition: staffPosition, - keySignature: measure.attributes.keySignature + keySignature: measure.attributes.keySignature, + clef: measure.attributes.clef ) } @@ -1099,7 +1107,7 @@ struct NotationTrackView: View { display: display ) let visibleSpacingWidth = NotationMeasureLayout.spacingWidth(forVisibleComponentCount: visibleComponentCount) - let fixedComponentWidth = (display.showsClef ? AppTheme.Timeline.notationClefWidth : 0) + let fixedComponentWidth = (display.showsClef ? NotationClefLayout.frameSize.width : 0) + (display.showsTimeSignature ? AppTheme.Timeline.notationTimeSignatureWidth : 0) let availableAccidentalWidth = max( 0, @@ -1111,12 +1119,28 @@ struct NotationTrackView: View { return HStack(alignment: .center, spacing: AppTheme.Spacing.xs) { if display.showsClef { - Text(attributes.clef.displaySymbol) - .font(.system(size: AppTheme.Timeline.notationClefFontSize)) - .foregroundStyle(appColors.notationSymbolsAndLines) - .frame(width: AppTheme.Timeline.notationClefWidth, alignment: .center) - .offset(y: clefVerticalOffset(for: attributes.clef)) - .accessibilityLabel("Treble clef") + ClefGlyphView( + clef: attributes.clef, + color: appColors.notationSymbolsAndLines + ) + .contentShape(Rectangle()) + .contextMenu { + ForEach(Clef.allCases) { clef in + Button { + actions.changeClef(partID, clef) + } label: { + Label( + clef.displayName, + systemImage: clef == attributes.clef ? "checkmark" : "music.note" + ) + } + .disabled(clef == attributes.clef) + } + } + .menuOrder(.fixed) + .help("Change clef") + .accessibilityLabel("Notation clef") + .accessibilityValue(attributes.clef.displayName) } if display.showsKeySignature && !keySignatureGlyphs.isEmpty { @@ -1133,6 +1157,7 @@ struct NotationTrackView: View { ) .accessibilityElement(children: .ignore) .accessibilityLabel("\(attributes.keySignature.displayName) key signature") + .allowsHitTesting(false) } if display.showsTimeSignature { @@ -1145,19 +1170,13 @@ struct NotationTrackView: View { .lineLimit(1) .frame(width: AppTheme.Timeline.notationTimeSignatureWidth) .accessibilityLabel("Time signature \(attributes.timeSignature.displayText)") + .allowsHitTesting(false) } } .frame(width: max(0, blockWidth), alignment: .leading) .clipped() } - private func clefVerticalOffset(for clef: Clef) -> CGFloat { - switch clef { - case .treble: - return AppTheme.Timeline.notationTrebleClefVerticalOffset - } - } - @ViewBuilder private func playheadIndicator( width: CGFloat, @@ -1602,6 +1621,34 @@ struct NotationTrackView: View { } } +private struct ClefGlyphView: View { + let clef: Clef + let color: Color + + var body: some View { + Canvas { context, size in + let symbol = NotationClefSymbol(clef) + guard let glyphPath = NotationMusicFontRegistry.glyphPath( + for: symbol, + fontSize: AppTheme.Timeline.notationClefFontSize + ) else { + return + } + + let transform = NotationClefLayout.transform( + for: glyphPath, + symbol: symbol, + in: size + ) + context.fill(Path(glyphPath.path).applying(transform), with: .color(color)) + } + .frame( + width: NotationClefLayout.frameSize.width, + height: NotationClefLayout.frameSize.height + ) + } +} + private struct KeySignatureAccidentalsView: View { let glyphs: [KeySignatureAccidental] let color: Color @@ -1677,6 +1724,7 @@ private extension NotationTrackActions { insertNotationNote: { _ in false }, insertNotationRest: { _ in false }, changeSelectedNotePitch: { _, _ in false }, + changeClef: { _, _ in }, auditionNotePitch: { _ in }, deleteSelectedNotationNote: { false }, locatePlaybackMarkerExactly: { _ in }, diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index be90e7d..dbcb788 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -254,6 +254,7 @@ struct NotationWindowView: View { tempoMap: viewModel.tempoMap, duration: viewModel.duration, keyName: viewModel.effectiveKeyName, + clef: viewModel.notationClef(for: partID), partID: partID, includesHarmonies: partID.isMain, notationItems: viewModel.notationItems, @@ -277,6 +278,7 @@ struct NotationWindowView: View { insertNotationNote: { viewModel.insertNotationNote($0) }, insertNotationRest: { viewModel.insertNotationRest($0) }, changeSelectedNotePitch: { viewModel.changeSelectedNotationNotePitch(to: $0, shouldAudition: $1) }, + changeClef: { viewModel.setNotationClef($1, for: $0) }, auditionNotePitch: { viewModel.auditionNotationNotePitch($0) }, deleteSelectedNotationNote: { viewModel.deleteSelectedNotationNote() }, locatePlaybackMarkerExactly: { viewModel.locatePlaybackMarkerExactly(to: $0) }, diff --git a/JammLab/Views/WaveformTimelineView.swift b/JammLab/Views/WaveformTimelineView.swift index f8c0956..f0e1ace 100644 --- a/JammLab/Views/WaveformTimelineView.swift +++ b/JammLab/Views/WaveformTimelineView.swift @@ -81,6 +81,7 @@ struct TimelineViewActions { var insertNotationNote: (NotationNotePlacement) -> Bool var insertNotationRest: (NotationRestPlacement) -> Bool var changeSelectedNotePitch: (NotationPitch, Bool) -> Bool + var changeNotationClef: (NotationPartID, Clef) -> Void var auditionNotePitch: (NotationPitch) -> Void var deleteSelectedNotationNote: () -> Bool var showNotationWindow: () -> Void @@ -108,6 +109,7 @@ extension TimelineViewActions { insertNotationNote: insertNotationNote, insertNotationRest: insertNotationRest, changeSelectedNotePitch: changeSelectedNotePitch, + changeClef: changeNotationClef, auditionNotePitch: auditionNotePitch, deleteSelectedNotationNote: deleteSelectedNotationNote, locatePlaybackMarkerExactly: locatePlaybackMarkerExactly, diff --git a/JammLabTests/NotationMusicXMLTests.swift b/JammLabTests/NotationMusicXMLTests.swift index e9b1ec9..076759c 100644 --- a/JammLabTests/NotationMusicXMLTests.swift +++ b/JammLabTests/NotationMusicXMLTests.swift @@ -209,6 +209,7 @@ final class NotationMusicXMLTests: XCTestCase { playbackMarkerTime: 0, isPlaying: false, keyName: nil, + clef: .bass, partID: .stem(.bass), includesHarmonies: false, notationItems: notationItems, @@ -236,6 +237,10 @@ final class NotationMusicXMLTests: XCTestCase { let bassPart = try partElement(id: "P2", in: root) let mainPitch = try firstXMLChild(named: "pitch", in: try firstXMLChild(named: "note", in: try XCTUnwrap(mainPart.elements(forName: "measure").first))) let bassPitch = try firstXMLChild(named: "pitch", in: try firstXMLChild(named: "note", in: try XCTUnwrap(bassPart.elements(forName: "measure").first))) + let mainAttributes = try firstXMLChild(named: "attributes", in: try XCTUnwrap(mainPart.elements(forName: "measure").first)) + let bassAttributes = try firstXMLChild(named: "attributes", in: try XCTUnwrap(bassPart.elements(forName: "measure").first)) + let mainClef = try firstXMLChild(named: "clef", in: mainAttributes) + let bassClef = try firstXMLChild(named: "clef", in: bassAttributes) XCTAssertEqual(scoreParts.map { $0.attribute(forName: "id")?.stringValue }, ["P1", "P2"]) let bassScorePart = try XCTUnwrap(scoreParts.last) @@ -256,6 +261,12 @@ final class NotationMusicXMLTests: XCTestCase { try assertXMLChild(bassInstrumentName, precedes: bassInstrumentSound, in: bassScoreInstrument) XCTAssertEqual(try firstXMLChild(named: "step", in: mainPitch).stringValue, "C") XCTAssertEqual(try firstXMLChild(named: "step", in: bassPitch).stringValue, "E") + XCTAssertEqual(try firstXMLChild(named: "octave", in: mainPitch).stringValue, "4") + XCTAssertEqual(try firstXMLChild(named: "octave", in: bassPitch).stringValue, "2") + XCTAssertEqual(try firstXMLChild(named: "sign", in: mainClef).stringValue, "G") + XCTAssertEqual(try firstXMLChild(named: "line", in: mainClef).stringValue, "2") + XCTAssertEqual(try firstXMLChild(named: "sign", in: bassClef).stringValue, "F") + XCTAssertEqual(try firstXMLChild(named: "line", in: bassClef).stringValue, "4") XCTAssertFalse(mainPart.elements(forName: "measure").flatMap { $0.elements(forName: "harmony") }.isEmpty) XCTAssertTrue(bassPart.elements(forName: "measure").flatMap { $0.elements(forName: "harmony") }.isEmpty) XCTAssertEqual(mainPart.elements(forName: "measure").flatMap { $0.elements(forName: "direction") }.count, 1) diff --git a/JammLabTests/NotationPrimitivesTests.swift b/JammLabTests/NotationPrimitivesTests.swift index 51ab477..5226771 100644 --- a/JammLabTests/NotationPrimitivesTests.swift +++ b/JammLabTests/NotationPrimitivesTests.swift @@ -40,6 +40,36 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertEqual(quarter.glyph.unicodeScalars.first?.value, 0xE4E5) } + func testNotationClefSymbolsUseLelandSMuFLCodepointsAndReferenceLines() { + let treble = NotationClefSymbol(.treble) + let bass = NotationClefSymbol(.bass) + + XCTAssertEqual(treble.codepoint, 0xE050) + XCTAssertEqual(treble.referenceStaffLineFromTop, 3) + XCTAssertEqual(bass.codepoint, 0xE062) + XCTAssertEqual(bass.referenceStaffLineFromTop, 1) + XCTAssertEqual(Clef.treble.sign, "G") + XCTAssertEqual(Clef.treble.line, 2) + XCTAssertEqual(Clef.bass.sign, "F") + XCTAssertEqual(Clef.bass.line, 4) + } + + func testNotationPartClefOverridesRemoveOnlyDefaultTrebleValues() { + let unknownPart = NotationPartID(rawValue: "future:baritone") + let normalized = NotationPartClefOverrides.normalized([ + .main: .treble, + .stem(.bass): .bass, + unknownPart: .bass + ]) + + XCTAssertEqual(normalized, [ + .stem(.bass): .bass, + unknownPart: .bass + ]) + XCTAssertEqual(NotationPartClefOverrides.clef(for: .main, in: normalized), .treble) + XCTAssertEqual(NotationPartClefOverrides.clef(for: unknownPart, in: normalized), .bass) + } + func testNotationSMuFLDurationControlSymbolsMapDurationsToLelandMetNoteCodepoints() throws { let whole = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 1))) let half = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 2))) @@ -252,6 +282,73 @@ final class NotationPrimitivesTests: XCTestCase { ) } + func testNotationPitchMapperUsesBassRangeAndKeepsStoredPitchesMappable() { + let cMajor = KeySignature.normalized(from: "C major") + + XCTAssertEqual(NotationPitchMapper.editableStaffPositionRange(for: .treble), -5...13) + XCTAssertEqual(NotationPitchMapper.editableStaffPositionRange(for: .bass), -3...15) + XCTAssertEqual( + NotationPitchMapper.pitch(forStaffPosition: -3, keySignature: cMajor, clef: .bass), + NotationPitch(step: .d, octave: 4) + ) + XCTAssertEqual( + NotationPitchMapper.pitch(forStaffPosition: 15, keySignature: cMajor, clef: .bass), + NotationPitch(step: .g, octave: 1) + ) + XCTAssertEqual( + NotationPitchMapper.staffPosition(for: NotationPitch(step: .d, octave: 4), clef: .bass), + -3 + ) + XCTAssertEqual( + NotationPitchMapper.staffPosition(for: NotationPitch(step: .g, octave: 1), clef: .bass), + 15 + ) + XCTAssertEqual( + NotationPitchMapper.staffPosition(for: NotationPitch(step: .c, octave: 5), clef: .bass), + -9 + ) + XCTAssertNil(NotationPitchMapper.adjacentPitch( + from: NotationPitch(step: .d, octave: 4), + staffPositionDelta: -1, + keySignature: cMajor, + clef: .bass + )) + XCTAssertNil(NotationPitchMapper.adjacentPitch( + from: NotationPitch(step: .g, octave: 1), + staffPositionDelta: 1, + keySignature: cMajor, + clef: .bass + )) + + let staffTop: CGFloat = 50 + XCTAssertEqual( + NotationNotePlacementResolver.staffPosition( + forY: NotationNotePlacementResolver.yPosition(forStaffPosition: -3, staffTop: staffTop), + staffTop: staffTop, + clef: .bass + ), + -3 + ) + XCTAssertEqual( + NotationNotePlacementResolver.staffPosition( + forY: NotationNotePlacementResolver.yPosition(forStaffPosition: 15, staffTop: staffTop), + staffTop: staffTop, + clef: .bass + ), + 15 + ) + } + + func testBassKeySignatureAccidentalsUseBassStaffPositions() { + let sharps = KeySignature(fifths: 7, mode: .major, displayName: "C sharp major") + .notationAccidentalGlyphs(for: .bass) + let flats = KeySignature(fifths: -7, mode: .major, displayName: "C flat major") + .notationAccidentalGlyphs(for: .bass) + + XCTAssertEqual(sharps.map(\.staffPositionFromTopLine), [2, 5, 1, 4, 7, 3, 6]) + XCTAssertEqual(flats.map(\.staffPositionFromTopLine), [6, 3, 7, 4, 8, 5, 9]) + } + func testNotationPitchMapperAdjacentPitchRespectsBoundsAndKeySignature() throws { let cMajor = KeySignature.normalized(from: "C major") let fMajor = KeySignature.normalized(from: "F major") @@ -817,6 +914,56 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertGreaterThan(glyphPath.bounds.height, 0) } + func testNotationClefLayoutUsesExpectedFrameAndStaffTargets() { + XCTAssertEqual(NotationClefLayout.frameSize, CGSize(width: 38, height: 60)) + XCTAssertEqual(NotationClefLayout.referenceAnchorY, 0) + XCTAssertEqual(NotationClefLayout.targetY(for: .treble), 38) + XCTAssertEqual(NotationClefLayout.targetY(for: .bass), 22) + } + + func testNotationClefLayoutTransformAnchorsGlyphAtStaffTarget() throws { + for symbol in [NotationClefSymbol.treble, .bass] { + let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( + for: symbol, + fontSize: AppTheme.Timeline.notationClefFontSize + )) + let target = NotationClefLayout.target(for: symbol, in: NotationClefLayout.frameSize) + let transformedAnchor = NotationClefLayout.referenceAnchor(for: glyphPath).applying( + NotationClefLayout.transform( + for: glyphPath, + symbol: symbol, + in: NotationClefLayout.frameSize + ) + ) + + XCTAssertEqual(transformedAnchor.x, target.x, accuracy: 0.0001) + XCTAssertEqual(transformedAnchor.y, target.y, accuracy: 0.0001) + } + } + + func testLelandClefGlyphPathsHaveBounds() throws { + for symbol in [NotationClefSymbol.treble, .bass] { + let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( + for: symbol, + fontSize: AppTheme.Timeline.notationClefFontSize + )) + var transform = NotationClefLayout.transform( + for: glyphPath, + symbol: symbol, + in: NotationClefLayout.frameSize + ) + let renderedBounds = try XCTUnwrap(glyphPath.path.copy(using: &transform)).boundingBox + + XCTAssertFalse(glyphPath.path.isEmpty) + XCTAssertGreaterThan(glyphPath.bounds.width, 0) + XCTAssertGreaterThan(glyphPath.bounds.height, 0) + XCTAssertGreaterThanOrEqual(renderedBounds.minX, 0) + XCTAssertLessThanOrEqual(renderedBounds.maxX, NotationClefLayout.frameSize.width) + XCTAssertGreaterThanOrEqual(renderedBounds.minY, 0) + XCTAssertLessThanOrEqual(renderedBounds.maxY, NotationClefLayout.frameSize.height) + } + } + func testLelandDurationControlGlyphPathsHaveBounds() throws { for symbol in [ NotationDurationControlSymbol.whole, diff --git a/JammLabTests/NotationTrackLayoutItemsTests.swift b/JammLabTests/NotationTrackLayoutItemsTests.swift index 4b1fd26..cff16d0 100644 --- a/JammLabTests/NotationTrackLayoutItemsTests.swift +++ b/JammLabTests/NotationTrackLayoutItemsTests.swift @@ -70,6 +70,8 @@ final class NotationTrackLayoutItemsTests: XCTestCase { XCTAssertTrue(stemActions.insertNotationRest(restPlacement)) XCTAssertTrue(mainActions.deleteSelectedNotationNote()) XCTAssertTrue(stemActions.deleteSelectedNotationNote()) + mainActions.changeClef(.main, .bass) + stemActions.changeClef(.stem(.bass), .bass) XCTAssertEqual(recorder.harmonyCallCount, 4) XCTAssertEqual(recorder.measureSelectionCount, 2) @@ -77,6 +79,8 @@ final class NotationTrackLayoutItemsTests: XCTestCase { XCTAssertEqual(recorder.playbackLocationCount, 2) XCTAssertEqual(recorder.notationInsertionCount, 4) XCTAssertEqual(recorder.deleteNotationCount, 2) + XCTAssertEqual(recorder.clefChanges.map(\.0), [.main, .stem(.bass)]) + XCTAssertEqual(recorder.clefChanges.map(\.1), [.bass, .bass]) } func testSelectedMeasureIndicesOnlyMatchTheRenderedPart() { @@ -240,6 +244,7 @@ private final class TimelineNotationActionRecorder { var playbackLocationCount = 0 var notationInsertionCount = 0 var deleteNotationCount = 0 + var clefChanges: [(NotationPartID, Clef)] = [] } private func timelineViewActions( @@ -288,6 +293,7 @@ private func timelineViewActions( return true }, changeSelectedNotePitch: { _, _ in true }, + changeNotationClef: { recorder.clefChanges.append(($0, $1)) }, auditionNotePitch: { _ in }, deleteSelectedNotationNote: { recorder.deleteNotationCount += 1 diff --git a/JammLabTests/NotationViewportTests.swift b/JammLabTests/NotationViewportTests.swift index a344b7b..59c64d8 100644 --- a/JammLabTests/NotationViewportTests.swift +++ b/JammLabTests/NotationViewportTests.swift @@ -184,6 +184,39 @@ final class NotationViewportTests: XCTestCase { XCTAssertEqual(cache.cacheMissCount, 1) } + func testNotationProjectionCacheInvalidatesWhenPartClefChanges() throws { + let tempoMap = fourFourTempoMap(duration: 8) + let cache = NotationProjectionCache() + + let treble = cache.content( + tempoMap: tempoMap, + duration: tempoMap.duration, + keyName: "G major", + clef: .treble, + partID: .stem(.bass), + includesHarmonies: false, + notationItems: [], + harmonySymbols: [], + notes: [] + ) + let bass = cache.content( + tempoMap: tempoMap, + duration: tempoMap.duration, + keyName: "G major", + clef: .bass, + partID: .stem(.bass), + includesHarmonies: false, + notationItems: [], + harmonySymbols: [], + notes: [] + ) + + XCTAssertEqual(try XCTUnwrap(treble.measures.first).attributes.clef, .treble) + XCTAssertEqual(try XCTUnwrap(bass.measures.first).attributes.clef, .bass) + XCTAssertEqual(cache.cachedScopeCount, 1) + XCTAssertEqual(cache.cacheMissCount, 2) + } + func testNotationProjectionCacheKeepsPartScopesAndGlobalRegionsIndependent() throws { let tempoMap = fourFourTempoMap(duration: 8) let cache = NotationProjectionCache() diff --git a/JammLabTests/StemWorkflowPersistenceTests.swift b/JammLabTests/StemWorkflowPersistenceTests.swift index 1b4d143..91a4805 100644 --- a/JammLabTests/StemWorkflowPersistenceTests.swift +++ b/JammLabTests/StemWorkflowPersistenceTests.swift @@ -2,7 +2,7 @@ import XCTest @testable import JammLab final class StemWorkflowPersistenceTests: XCTestCase { - func testProjectVersionSevenPersistsProjectEditablePlaybackStateMediaKindArtifactRootBookmarkAndVideoWindowState() throws { + func testProjectVersionTwelvePersistsProjectEditablePlaybackStateMediaKindArtifactRootBookmarkAndVideoWindowState() throws { let artifactRootBookmarkData = Data("artifact-root-bookmark".utf8) let metadata = StemProjectState( cacheKey: "cache-123", @@ -43,7 +43,7 @@ final class StemWorkflowPersistenceTests: XCTestCase { let decoded = try JSONDecoder().decode(JammLabProject.self, from: JSONEncoder().encode(project)) - XCTAssertEqual(decoded.formatVersion, 11) + XCTAssertEqual(decoded.formatVersion, 12) XCTAssertEqual(decoded.artifactRootBookmarkData, artifactRootBookmarkData) XCTAssertEqual(decoded.mediaKind, .video) XCTAssertEqual(decoded.isLoopEnabled, true) @@ -62,7 +62,7 @@ final class StemWorkflowPersistenceTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(decoded.stemState?.mixState.effectiveVolume(for: .drums)), 0.8, accuracy: 0.0001) } - func testProjectVersionElevenPersistsPitchedNotationItems() throws { + func testProjectVersionTwelvePersistsPitchedNotationItemsAndPartClefs() throws { let pitch = NotationPitch(step: .f, octave: 4, alter: 1) let project = JammLabProject( audioBookmarkData: Data("bookmark".utf8), @@ -89,6 +89,10 @@ final class StemWorkflowPersistenceTests: XCTestCase { displayDuration: NotationDuration(denominator: 2) ) ], + notationPartClefs: [ + .main: .bass, + .stem(.bass): .bass + ], loopStart: 0, loopEnd: 8, playbackRate: 1, @@ -97,13 +101,15 @@ final class StemWorkflowPersistenceTests: XCTestCase { let decoded = try JSONDecoder().decode(JammLabProject.self, from: JSONEncoder().encode(project)) - XCTAssertEqual(decoded.formatVersion, 11) + XCTAssertEqual(decoded.formatVersion, 12) XCTAssertEqual(decoded.notationItems.map(\.id), ["note", "rest"]) XCTAssertEqual(decoded.notationItems.map(\.kind), [.note, .rest]) XCTAssertEqual(decoded.notationItems.map(\.pitch), [pitch, nil]) XCTAssertEqual(decoded.notationItems.map(\.offsetInQuarterNotes), [0, 1]) XCTAssertEqual(decoded.notationItems.map(\.durationInQuarterNotes), [1, 2]) XCTAssertEqual(decoded.notationItems.map(\.displayDuration.denominator), [4, 2]) + XCTAssertEqual(decoded.notationPartClefs[.main], .bass) + XCTAssertEqual(decoded.notationPartClefs[.stem(.bass)], .bass) } func testLegacyProjectWithoutStemStateStillDecodes() throws { @@ -138,5 +144,6 @@ final class StemWorkflowPersistenceTests: XCTestCase { XCTAssertNil(decoded.mediaKind) XCTAssertNil(decoded.isVideoWindowOpen) XCTAssertNil(decoded.isNotationTrackCollapsed) + XCTAssertTrue(decoded.notationPartClefs.isEmpty) } } diff --git a/JammLabTests/ViewModelNotationDurationSelectionTests.swift b/JammLabTests/ViewModelNotationDurationSelectionTests.swift index 43c04c4..f211048 100644 --- a/JammLabTests/ViewModelNotationDurationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationDurationSelectionTests.swift @@ -972,6 +972,165 @@ final class ViewModelNotationDurationSelectionTests: XCTestCase { XCTAssertEqual(updatedMeasure.notationItems.first?.displayDuration.denominator, 1) } + @MainActor + func testChangingStemClefRoundTripsPitchAndRefreshesSelection() throws { + let (viewModel, bassPart) = try clefChangeViewModel(selectsBassNote: true) + viewModel.markProjectClean() + + viewModel.setNotationClef(.bass, for: bassPart) + + XCTAssertEqual(viewModel.notationClef(for: bassPart), .bass) + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "bass-note" }?.pitch, + NotationPitch(step: .f, octave: 2, alter: 1) + ) + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "main-note" }?.pitch, + NotationPitch(step: .c, octave: 4) + ) + XCTAssertEqual(viewModel.notationItems.first { $0.id == "bass-rest" }?.kind, .rest) + XCTAssertEqual(viewModel.selectedNotationItem?.attributes.clef, .bass) + XCTAssertEqual(viewModel.selectedNotationItem?.itemID, "bass-note") + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.setNotationClef(.treble, for: bassPart) + + XCTAssertEqual(viewModel.notationClef(for: bassPart), .treble) + XCTAssertTrue(viewModel.notationPartClefs.isEmpty) + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "bass-note" }?.pitch, + NotationPitch(step: .f, octave: 4, alter: 1) + ) + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "main-note" }?.pitch, + NotationPitch(step: .c, octave: 4) + ) + XCTAssertEqual(viewModel.notationItems.first { $0.id == "bass-rest" }?.kind, .rest) + XCTAssertEqual(viewModel.selectedNotationItem?.attributes.clef, .treble) + XCTAssertEqual(viewModel.selectedNotationItem?.itemID, "bass-note") + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testChangingStemClefSupportsTwoUndoAndRedoSteps() throws { + let (viewModel, bassPart) = try clefChangeViewModel(selectsBassNote: false) + let undoManager = UndoManager() + undoManager.groupsByEvent = false + viewModel.undoManager = undoManager + viewModel.markProjectClean() + + undoManager.beginUndoGrouping() + viewModel.setNotationClef(.bass, for: bassPart) + undoManager.endUndoGrouping() + + undoManager.beginUndoGrouping() + viewModel.setNotationClef(.treble, for: bassPart) + undoManager.endUndoGrouping() + + XCTAssertEqual(viewModel.notationClef(for: bassPart), .treble) + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "bass-note" }?.pitch, + NotationPitch(step: .f, octave: 4, alter: 1) + ) + XCTAssertTrue(viewModel.canUndo) + + viewModel.undoLastEdit() + + XCTAssertEqual(viewModel.notationClef(for: bassPart), .bass) + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "bass-note" }?.pitch, + NotationPitch(step: .f, octave: 2, alter: 1) + ) + + viewModel.undoLastEdit() + + XCTAssertEqual(viewModel.notationClef(for: bassPart), .treble) + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "bass-note" }?.pitch, + NotationPitch(step: .f, octave: 4, alter: 1) + ) + + viewModel.redoLastEdit() + + XCTAssertEqual(viewModel.notationClef(for: bassPart), .bass) + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "bass-note" }?.pitch, + NotationPitch(step: .f, octave: 2, alter: 1) + ) + + viewModel.redoLastEdit() + + XCTAssertEqual(viewModel.notationClef(for: bassPart), .treble) + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "bass-note" }?.pitch, + NotationPitch(step: .f, octave: 4, alter: 1) + ) + } + + @MainActor + private func clefChangeViewModel( + selectsBassNote: Bool + ) throws -> (viewModel: AudioPlayerViewModel, bassPart: NotationPartID) { + let viewModel = try loadedNotationViewModel(duration: 8) + let bassPart = NotationPartID.stem(.bass) + viewModel.notationItems = [ + NotationMeasureItem( + id: "main-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "bass-note", + partID: bassPart, + kind: .note, + pitch: NotationPitch(step: .f, octave: 4, alter: 1), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "bass-rest", + partID: bassPart, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + + guard selectsBassNote else { return (viewModel, bassPart) } + + let bassMeasure = try notationMeasure(1, in: viewModel, partID: bassPart) + let selectedNote = try XCTUnwrap(bassMeasure.notationItems.first { $0.id == "bass-note" }) + viewModel.selectNotationItem( + NotationItemSelection(measure: bassMeasure, item: selectedNote, partID: bassPart), + shouldAudition: false + ) + return (viewModel, bassPart) + } + + @MainActor + func testSelectingDefaultTrebleClefIsNoOp() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.markProjectClean() + + viewModel.setNotationClef(.treble, for: .main) + + XCTAssertTrue(viewModel.notationPartClefs.isEmpty) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertFalse(viewModel.canUndo) + } + @MainActor private func insertQuarterNote( pitch: NotationPitch, diff --git a/JammLabTests/ViewModelNotationExportTests.swift b/JammLabTests/ViewModelNotationExportTests.swift index 6f5475e..e22c8e6 100644 --- a/JammLabTests/ViewModelNotationExportTests.swift +++ b/JammLabTests/ViewModelNotationExportTests.swift @@ -70,6 +70,7 @@ final class ViewModelNotationExportTests: XCTestCase { viewModel.notes = [ TimecodedNote(kind: .region, time: 0.5, duration: 2, title: "Intro") ] + viewModel.notationPartClefs[.stem(.bass)] = .bass viewModel.visibleNotationPartIDs = [.main, .stem(.bass)] let outputURL = FileManager.default.temporaryDirectory .appendingPathComponent("notation-stem-export-\(UUID().uuidString)") @@ -82,6 +83,9 @@ final class ViewModelNotationExportTests: XCTestCase { let xml = try String(contentsOf: outputURL, encoding: .utf8) XCTAssertTrue(xml.contains("Bass Guitar")) XCTAssertTrue(xml.contains("E")) + XCTAssertTrue(xml.contains("2")) + XCTAssertTrue(xml.contains("F")) + XCTAssertTrue(xml.contains("4")) XCTAssertEqual(xml.components(separatedBy: ">Intro").count - 1, 1) } diff --git a/JammLabTests/ViewModelNotationSelectionTests.swift b/JammLabTests/ViewModelNotationSelectionTests.swift index 9b11ba6..ee685f8 100644 --- a/JammLabTests/ViewModelNotationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationSelectionTests.swift @@ -401,6 +401,7 @@ extension XCTestCase { playbackMarkerTime: viewModel.playbackMarkerTime, isPlaying: viewModel.playbackState == .playing, keyName: viewModel.effectiveKeyName, + clef: viewModel.notationClef(for: partID), partID: partID, includesHarmonies: partID.isMain, notationItems: viewModel.notationItems, diff --git a/JammLabTests/ViewModelProjectSaveCloseTests.swift b/JammLabTests/ViewModelProjectSaveCloseTests.swift index 0b25fa2..401d768 100644 --- a/JammLabTests/ViewModelProjectSaveCloseTests.swift +++ b/JammLabTests/ViewModelProjectSaveCloseTests.swift @@ -41,6 +41,21 @@ final class ViewModelProjectSaveCloseTests: XCTestCase { ) await viewModel.openRecentProject(entry) + let bassPart = NotationPartID.stem(.bass) + viewModel.notationItems = [ + NotationMeasureItem( + id: "bass-note", + partID: bassPart, + kind: .note, + pitch: NotationPitch(step: .e, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + viewModel.setNotationClef(.bass, for: bassPart) viewModel.setMainTrackVolume(0.2) XCTAssertTrue(viewModel.isProjectModified) @@ -52,8 +67,24 @@ final class ViewModelProjectSaveCloseTests: XCTestCase { let savedProject = try projectService.load(from: projectURL) XCTAssertEqual(try XCTUnwrap(savedProject.mainTrackVolume), 0.2, accuracy: 0.0001) + XCTAssertEqual(savedProject.notationPartClefs[bassPart], .bass) + XCTAssertEqual(savedProject.notationItems.first?.pitch, NotationPitch(step: .e, octave: 2)) XCTAssertNotNil(savedProject.artifactRootBookmarkData) XCTAssertNil(savedProject.isVideoWindowOpen) + + let restoredViewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()), + isSandboxed: { false } + ) + await restoredViewModel.openRecentProject(entry) + + XCTAssertEqual(restoredViewModel.notationClef(for: bassPart), .bass) + XCTAssertEqual(restoredViewModel.notationItems.first?.pitch, NotationPitch(step: .e, octave: 2)) + XCTAssertFalse(restoredViewModel.isProjectModified) } @MainActor From 82c7eeae0c2b7cb027fe9b5e6b5a5d093592f439 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 15 Jul 2026 07:42:11 +0300 Subject: [PATCH 09/12] feat(notation): add sixteenth notes and downward stems --- CHANGELOG.md | 1 + JammLab/ContentView.swift | 3 +- JammLab/DesignSystem/AppTheme.swift | 4 +- JammLab/Models/NotationScoreModels.swift | 8 +- JammLab/Services/NotationSMuFL.swift | 70 +++++--- JammLab/Utilities/AppHotkey.swift | 23 ++- JammLab/Views/Components/AppControls.swift | 10 +- JammLab/Views/NotationTrackView.swift | 13 +- JammLab/Views/NotationWindowView.swift | 3 +- ...tkeyNotationDurationEventFilterTests.swift | 76 +++++---- .../AppHotkeyNotationMappingTests.swift | 62 ++++--- .../NotationMusicXMLRestExportTests.swift | 51 ++++++ JammLabTests/NotationPrimitivesTests.swift | 157 ++++++++++++++---- JammLabTests/TimelineProjectLogicTests.swift | 18 +- ...wModelNotationDurationSelectionTests.swift | 90 ++++++++++ 15 files changed, 451 insertions(+), 138 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aae2d9..ab28af9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased +- Added sixteenth-note and rest entry with `3`/`Num3` duration shortcuts, plus downward stems for notes above the middle staff line. - Added Notation note-entry mode for placing pitched notes with Leland-rendered note glyphs, note selection preview, cross-rest insertion, rest recomposition, and MusicXML export. - Expanded Notation entry to ledger-line pitches from G3 through D6 and added rest-entry controls for inserting selected rest durations. - Added Notation note editing for dragging note pitch, arrow-key pitch changes, and deleting selected notes back to rests. diff --git a/JammLab/ContentView.swift b/JammLab/ContentView.swift index d68473e..fca6d18 100644 --- a/JammLab/ContentView.swift +++ b/JammLab/ContentView.swift @@ -256,7 +256,8 @@ struct ContentView: View { return viewModel.changeSelectedNotationNotePitch(byStaffPositionDelta: -1) case .moveSelectedNotationNotePitchDown: return viewModel.changeSelectedNotationNotePitch(byStaffPositionDelta: 1) - case .setNotationDurationEighth, + case .setNotationDurationSixteenth, + .setNotationDurationEighth, .setNotationDurationQuarter, .setNotationDurationHalf, .setNotationDurationWhole: diff --git a/JammLab/DesignSystem/AppTheme.swift b/JammLab/DesignSystem/AppTheme.swift index 368fb55..28198ac 100644 --- a/JammLab/DesignSystem/AppTheme.swift +++ b/JammLab/DesignSystem/AppTheme.swift @@ -194,7 +194,9 @@ enum AppTheme { static let hotkeyKeyWidth: CGFloat = 70 static let controlHeight: CGFloat = 28 static let buttonHeight: CGFloat = 28 - static let notationDurationButtonWidth: CGFloat = 32 + static let notationDurationButtonWidth: CGFloat = 27 + static let notationDurationButtonSpacing: CGFloat = AppTheme.Spacing.xxxs + static let notationEntryModeButtonWidth: CGFloat = 32 static let notationDurationControlHeight: CGFloat = controlHeight static let notationDurationGlyphSize: CGFloat = 20 static let transportBarMinHeight: CGFloat = 70 diff --git a/JammLab/Models/NotationScoreModels.swift b/JammLab/Models/NotationScoreModels.swift index 993ccb0..bc96de8 100644 --- a/JammLab/Models/NotationScoreModels.swift +++ b/JammLab/Models/NotationScoreModels.swift @@ -324,7 +324,7 @@ enum HarmonyNavigationDirection: Equatable { } struct NotationDuration: Codable, Equatable, Identifiable { - static let allowedDenominators = [1, 2, 4, 8] + static let allowedDenominators = [1, 2, 4, 8, 16] static let defaultDenominator = 1 var denominator: Int @@ -349,6 +349,8 @@ struct NotationDuration: Codable, Equatable, Identifiable { return "quarter" case 8: return "eighth" + case 16: + return "16th" default: return "duration" } @@ -358,6 +360,10 @@ struct NotationDuration: Codable, Equatable, Identifiable { "\(displayName) notes" } + var capitalizedDisplayName: String { + displayName.prefix(1).uppercased() + displayName.dropFirst() + } + static func normalizedDenominator(_ denominator: Int) -> Int { allowedDenominators.min { lhs, rhs in abs(lhs - denominator) < abs(rhs - denominator) diff --git a/JammLab/Services/NotationSMuFL.swift b/JammLab/Services/NotationSMuFL.swift index 9d30028..d16fab1 100644 --- a/JammLab/Services/NotationSMuFL.swift +++ b/JammLab/Services/NotationSMuFL.swift @@ -7,6 +7,7 @@ enum NotationSMuFLSymbol: Equatable { case restHalf case restQuarter case rest8th + case rest16th init?(duration: NotationDuration) { switch duration.denominator { @@ -18,6 +19,8 @@ enum NotationSMuFLSymbol: Equatable { self = .restQuarter case 8: self = .rest8th + case 16: + self = .rest16th default: return nil } @@ -33,6 +36,8 @@ enum NotationSMuFLSymbol: Equatable { return 0xE4E5 case .rest8th: return 0xE4E6 + case .rest16th: + return 0xE4E7 } } @@ -47,6 +52,7 @@ enum NotationDurationControlSymbol: Equatable { case half case quarter case eighth + case sixteenth init?(duration: NotationDuration) { switch duration.denominator { @@ -58,6 +64,8 @@ enum NotationDurationControlSymbol: Equatable { self = .quarter case 8: self = .eighth + case 16: + self = .sixteenth default: return nil } @@ -73,6 +81,8 @@ enum NotationDurationControlSymbol: Equatable { return 0xECA5 case .eighth: return 0xECA7 + case .sixteenth: + return 0xECA9 } } @@ -82,57 +92,75 @@ enum NotationDurationControlSymbol: Equatable { } } +enum NotationStemDirection: Equatable { + case up + case down + + static func direction(forStaffPosition staffPosition: Int) -> NotationStemDirection { + staffPosition < 4 ? .down : .up + } +} + enum NotationStaffNoteSymbol: Equatable { case whole - case half - case quarter - case eighth + case half(NotationStemDirection) + case quarter(NotationStemDirection) + case eighth(NotationStemDirection) + case sixteenth(NotationStemDirection) - init?(duration: NotationDuration) { + init?(duration: NotationDuration, stemDirection: NotationStemDirection = .up) { switch duration.denominator { case 1: self = .whole case 2: - self = .half + self = .half(stemDirection) case 4: - self = .quarter + self = .quarter(stemDirection) case 8: - self = .eighth + self = .eighth(stemDirection) + case 16: + self = .sixteenth(stemDirection) default: return nil } } - var durationControlSymbol: NotationDurationControlSymbol { + var codepoint: UInt32 { switch self { case .whole: - return .whole - case .half: - return .half - case .quarter: - return .quarter - case .eighth: - return .eighth + return 0xE1D2 + case .half(let direction): + return Self.directionalCodepoint(up: 0xE1D3, direction: direction) + case .quarter(let direction): + return Self.directionalCodepoint(up: 0xE1D5, direction: direction) + case .eighth(let direction): + return Self.directionalCodepoint(up: 0xE1D7, direction: direction) + case .sixteenth(let direction): + return Self.directionalCodepoint(up: 0xE1D9, direction: direction) } } - var codepoint: UInt32 { - durationControlSymbol.codepoint - } - fileprivate var anchorReferenceCodepoint: UInt32? { switch self { case .whole: return nil case .half: return 0xE0A3 - case .quarter, .eighth: + case .quarter, .eighth, .sixteenth: return 0xE0A4 } } var glyph: String { - durationControlSymbol.glyph + guard let scalar = UnicodeScalar(codepoint) else { return "" } + return String(Character(scalar)) + } + + private static func directionalCodepoint( + up: UInt32, + direction: NotationStemDirection + ) -> UInt32 { + up + (direction == .down ? 1 : 0) } } diff --git a/JammLab/Utilities/AppHotkey.swift b/JammLab/Utilities/AppHotkey.swift index 81f4eab..df4832a 100644 --- a/JammLab/Utilities/AppHotkey.swift +++ b/JammLab/Utilities/AppHotkey.swift @@ -20,17 +20,15 @@ enum AppHotkey: CaseIterable, Hashable { case toggleNotationNoteEntryMode case moveSelectedNotationNotePitchUp case moveSelectedNotationNotePitchDown + case setNotationDurationSixteenth case setNotationDurationEighth case setNotationDurationQuarter case setNotationDurationHalf case setNotationDurationWhole - static let notationDurationHotkeys: Set = [ - .setNotationDurationEighth, - .setNotationDurationQuarter, - .setNotationDurationHalf, - .setNotationDurationWhole - ] + static let notationDurationHotkeys = Set(allCases.filter { + $0.notationDurationDenominator != nil + }) // Keep this enum as the single source of truth for keyboard shortcuts. // When adding a new handled hotkey, add a case here with its help metadata @@ -86,6 +84,8 @@ enum AppHotkey: CaseIterable, Hashable { self = .setBeatOne case 8: self = .toggleClick + case 20, 85: + self = .setNotationDurationSixteenth case 21, 86: self = .setNotationDurationEighth case 23, 87: @@ -139,6 +139,8 @@ enum AppHotkey: CaseIterable, Hashable { return "Arrow Up" case .moveSelectedNotationNotePitchDown: return "Arrow Down" + case .setNotationDurationSixteenth: + return "3" case .setNotationDurationEighth: return "4" case .setNotationDurationQuarter: @@ -188,6 +190,8 @@ enum AppHotkey: CaseIterable, Hashable { return "Move Notation Note Up" case .moveSelectedNotationNotePitchDown: return "Move Notation Note Down" + case .setNotationDurationSixteenth: + return "Set Sixteenth Note Duration" case .setNotationDurationEighth: return "Set Eighth Note Duration" case .setNotationDurationQuarter: @@ -237,6 +241,8 @@ enum AppHotkey: CaseIterable, Hashable { return "Move the selected notation note to the next higher staff position." case .moveSelectedNotationNotePitchDown: return "Move the selected notation note to the next lower staff position." + case .setNotationDurationSixteenth: + return "Set notation duration to sixteenth notes for the selected notation item." case .setNotationDurationEighth: return "Set notation duration to eighth notes for the selected notation item." case .setNotationDurationQuarter: @@ -250,6 +256,8 @@ enum AppHotkey: CaseIterable, Hashable { var notationDurationDenominator: Int? { switch self { + case .setNotationDurationSixteenth: + return 16 case .setNotationDurationEighth: return 8 case .setNotationDurationQuarter: @@ -265,8 +273,7 @@ enum AppHotkey: CaseIterable, Hashable { static func notationDurationShortcutText(for denominator: Int) -> String? { guard let hotkey = allCases.first(where: { - notationDurationHotkeys.contains($0) - && $0.notationDurationDenominator == denominator + $0.notationDurationDenominator == denominator }) else { return nil } return "\(hotkey.key); Num\(hotkey.key)" } diff --git a/JammLab/Views/Components/AppControls.swift b/JammLab/Views/Components/AppControls.swift index 1a603cb..68098ae 100644 --- a/JammLab/Views/Components/AppControls.swift +++ b/JammLab/Views/Components/AppControls.swift @@ -28,7 +28,7 @@ struct NotationDurationControl: View { } var body: some View { - HStack(spacing: AppTheme.Spacing.xs) { + HStack(spacing: AppTheme.ControlSize.notationDurationButtonSpacing) { ForEach(options) { option in durationButton(for: option) } @@ -106,7 +106,7 @@ struct NotationEntryModeButton: View { } } .frame( - width: AppTheme.ControlSize.notationDurationButtonWidth, + width: AppTheme.ControlSize.notationEntryModeButtonWidth, height: AppTheme.ControlSize.notationDurationControlHeight ) .background(backgroundColor) @@ -219,7 +219,7 @@ enum NotationDurationControlHelpText { } static func accessibilityLabel(for duration: NotationDuration) -> String { - "\(duration.displayName.capitalized) note duration" + "\(duration.capitalizedDisplayName) note duration" } static func accessibilityHint(for duration: NotationDuration) -> String { @@ -227,7 +227,7 @@ enum NotationDurationControlHelpText { } private static func title(for duration: NotationDuration) -> String { - "\(duration.displayName.capitalized) (\(traditionalName(for: duration))) note" + "\(duration.capitalizedDisplayName) (\(traditionalName(for: duration))) note" } private static func lowercaseTitle(for duration: NotationDuration) -> String { @@ -244,6 +244,8 @@ enum NotationDurationControlHelpText { return "crotchet" case 8: return "quaver" + case 16: + return "semiquaver" default: return duration.displayName } diff --git a/JammLab/Views/NotationTrackView.swift b/JammLab/Views/NotationTrackView.swift index 2c7f2d7..0b08847 100644 --- a/JammLab/Views/NotationTrackView.swift +++ b/JammLab/Views/NotationTrackView.swift @@ -394,6 +394,7 @@ struct NotationTrackView: View { forStaffPosition: staffPosition, staffTop: staffTop ), + stemDirection: NotationStemDirection.direction(forStaffPosition: staffPosition), color: color, opacity: opacity, in: &context @@ -430,13 +431,17 @@ struct NotationTrackView: View { duration: NotationDuration, x: CGFloat, y: CGFloat, + stemDirection: NotationStemDirection, color: Color, opacity: Double, in context: inout GraphicsContext ) { let spacing = AppTheme.Timeline.notationStaffLineSpacing let fontSize = spacing * 3.25 - guard let symbol = NotationStaffNoteSymbol(duration: duration), + guard let symbol = NotationStaffNoteSymbol( + duration: duration, + stemDirection: stemDirection + ), let glyphPath = NotationMusicFontRegistry.glyphPath(for: symbol, fontSize: fontSize), let anchor = NotationMusicFontRegistry.noteheadAnchor(for: symbol, fontSize: fontSize) else { @@ -497,7 +502,7 @@ struct NotationTrackView: View { return line3Y - spacing * 0.08 case .restQuarter: return line3Y + spacing * 0.06 - case .rest8th: + case .rest8th, .rest16th: return line3Y - spacing * 0.12 } } @@ -1599,12 +1604,12 @@ struct NotationTrackView: View { private func notationItemAccessibilityLabel(_ item: NotationItemLayoutItem) -> String { switch item.notationItem.kind { case .rest: - return "\(item.notationItem.displayDuration.displayName.capitalized) rest in measure \(item.selection.measureNumber)" + return "\(item.notationItem.displayDuration.capitalizedDisplayName) rest in measure \(item.selection.measureNumber)" case .note: let pitchText = item.notationItem.pitch.map { "\($0.step.rawValue)\($0.alter == 1 ? " sharp" : $0.alter == -1 ? " flat" : "")\($0.octave)" } ?? "note" - return "\(item.notationItem.displayDuration.displayName.capitalized) \(pitchText) in measure \(item.selection.measureNumber)" + return "\(item.notationItem.displayDuration.capitalizedDisplayName) \(pitchText) in measure \(item.selection.measureNumber)" } } diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index dbcb788..dfd77e6 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -343,7 +343,8 @@ struct NotationWindowView: View { return viewModel.changeSelectedNotationNotePitch(byStaffPositionDelta: -1) case .moveSelectedNotationNotePitchDown: return viewModel.changeSelectedNotationNotePitch(byStaffPositionDelta: 1) - case .setNotationDurationEighth, + case .setNotationDurationSixteenth, + .setNotationDurationEighth, .setNotationDurationQuarter, .setNotationDurationHalf, .setNotationDurationWhole: diff --git a/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift b/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift index c1c31a0..4943d3c 100644 --- a/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift +++ b/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift @@ -4,30 +4,8 @@ import XCTest final class AppHotkeyNotationDurationEventFilterTests: XCTestCase { func testAppHotkeyEventFilterDoesNotStealNotationDurationKeysFromTextRespondersOrUnavailableScopes() throws { - let durationEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "4", - charactersIgnoringModifiers: "4", - isARepeat: false, - keyCode: 21 - )) - let repeatDurationEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "4", - charactersIgnoringModifiers: "4", - isARepeat: true, - keyCode: 21 - )) + let durationEvent = try keyEvent(key: "4", keyCode: 21) + let repeatDurationEvent = try keyEvent(key: "4", keyCode: 21, isRepeat: true) XCTAssertEqual( AppHotkeyEventFilter.hotkey( @@ -39,18 +17,7 @@ final class AppHotkeyNotationDurationEventFilterTests: XCTestCase { .setNotationDurationEighth ) - let numpadDurationEvent = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [.numericPad], - timestamp: 0, - windowNumber: 42, - context: nil, - characters: "4", - charactersIgnoringModifiers: "4", - isARepeat: false, - keyCode: 86 - )) + let numpadDurationEvent = try keyEvent(key: "4", keyCode: 86, modifierFlags: [.numericPad]) XCTAssertEqual( AppHotkeyEventFilter.hotkey( for: numpadDurationEvent, @@ -94,4 +61,41 @@ final class AppHotkeyNotationDurationEventFilterTests: XCTestCase { ) ) } + + func testAppHotkeyEventFilterHandlesSixteenthDurationFromNumberRowAndNumpad() throws { + let numberRowEvent = try keyEvent(key: "3", keyCode: 20) + let numpadEvent = try keyEvent(key: "3", keyCode: 85, modifierFlags: [.numericPad]) + + for event in [numberRowEvent, numpadEvent] { + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: event, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: AppHotkey.notationDurationHotkeys + ), + .setNotationDurationSixteenth + ) + } + } + + private func keyEvent( + key: String, + keyCode: UInt16, + modifierFlags: NSEvent.ModifierFlags = [], + isRepeat: Bool = false + ) throws -> NSEvent { + try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifierFlags, + timestamp: 0, + windowNumber: 42, + context: nil, + characters: key, + charactersIgnoringModifiers: key, + isARepeat: isRepeat, + keyCode: keyCode + )) + } } diff --git a/JammLabTests/AppHotkeyNotationMappingTests.swift b/JammLabTests/AppHotkeyNotationMappingTests.swift index cf2a199..b72ea76 100644 --- a/JammLabTests/AppHotkeyNotationMappingTests.swift +++ b/JammLabTests/AppHotkeyNotationMappingTests.swift @@ -15,6 +15,7 @@ final class AppHotkeyNotationMappingTests: XCTestCase { func testAppHotkeyRecognizesNotationDurationNumberKeys() throws { let expectations: [(key: String, keyCode: UInt16, hotkey: AppHotkey, denominator: Int)] = [ + ("3", 20, .setNotationDurationSixteenth, 16), ("4", 21, .setNotationDurationEighth, 8), ("5", 23, .setNotationDurationQuarter, 4), ("6", 22, .setNotationDurationHalf, 2), @@ -22,18 +23,7 @@ final class AppHotkeyNotationMappingTests: XCTestCase { ] for expectation in expectations { - let event = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: expectation.key, - charactersIgnoringModifiers: expectation.key, - isARepeat: false, - keyCode: expectation.keyCode - )) + let event = try keyEvent(key: expectation.key, keyCode: expectation.keyCode) XCTAssertEqual(AppHotkey(event: event), expectation.hotkey) XCTAssertEqual(expectation.hotkey.key, expectation.key) @@ -41,12 +31,23 @@ final class AppHotkeyNotationMappingTests: XCTestCase { XCTAssertTrue(AppHotkey.notationDurationHotkeys.contains(expectation.hotkey)) } + XCTAssertEqual(AppHotkey.setNotationDurationSixteenth.title, "Set Sixteenth Note Duration") XCTAssertEqual(AppHotkey.setNotationDurationEighth.title, "Set Eighth Note Duration") XCTAssertEqual( AppHotkey.setNotationDurationEighth.detail, "Set notation duration to eighth notes for the selected notation item." ) XCTAssertTrue(AppHotkey.allCases.contains(.setNotationDurationWhole)) + XCTAssertEqual( + AppHotkey.notationDurationHotkeys, + [ + .setNotationDurationSixteenth, + .setNotationDurationEighth, + .setNotationDurationQuarter, + .setNotationDurationHalf, + .setNotationDurationWhole + ] + ) } func testAppHotkeyRecognizesNotationNoteEntryModeToggle() throws { @@ -118,6 +119,7 @@ final class AppHotkeyNotationMappingTests: XCTestCase { func testAppHotkeyRecognizesNotationDurationNumpadKeys() throws { let expectations: [(key: String, keyCode: UInt16, hotkey: AppHotkey, denominator: Int)] = [ + ("3", 85, .setNotationDurationSixteenth, 16), ("4", 86, .setNotationDurationEighth, 8), ("5", 87, .setNotationDurationQuarter, 4), ("6", 88, .setNotationDurationHalf, 2), @@ -125,18 +127,7 @@ final class AppHotkeyNotationMappingTests: XCTestCase { ] for expectation in expectations { - let event = try XCTUnwrap(NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: [], - timestamp: 0, - windowNumber: 0, - context: nil, - characters: expectation.key, - charactersIgnoringModifiers: expectation.key, - isARepeat: false, - keyCode: expectation.keyCode - )) + let event = try keyEvent(key: expectation.key, keyCode: expectation.keyCode) XCTAssertEqual(AppHotkey(event: event), expectation.hotkey) XCTAssertEqual(expectation.hotkey.notationDurationDenominator, expectation.denominator) @@ -145,6 +136,7 @@ final class AppHotkeyNotationMappingTests: XCTestCase { func testAppHotkeyNotationDurationShortcutTextUsesPrimaryAndNumpadKeys() { let expectations: [(denominator: Int, shortcutText: String)] = [ + (16, "3; Num3"), (8, "4; Num4"), (4, "5; Num5"), (2, "6; Num6"), @@ -158,7 +150,7 @@ final class AppHotkeyNotationMappingTests: XCTestCase { ) } - XCTAssertNil(AppHotkey.notationDurationShortcutText(for: 16)) + XCTAssertNil(AppHotkey.notationDurationShortcutText(for: 32)) } func testAppHotkeyRecognizesCmdKButNotOldHarmonyKeys() throws { @@ -282,4 +274,24 @@ final class AppHotkeyNotationMappingTests: XCTestCase { XCTAssertEqual(AppHotkey.clearNotationMeasureSelection.key, "Esc") XCTAssertEqual(AppHotkey.clearNotationMeasureSelection.title, "Clear Measure Selection") } + + private func keyEvent( + key: String, + keyCode: UInt16, + modifierFlags: NSEvent.ModifierFlags = [], + isRepeat: Bool = false + ) throws -> NSEvent { + try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifierFlags, + timestamp: 0, + windowNumber: 0, + context: nil, + characters: key, + charactersIgnoringModifiers: key, + isARepeat: isRepeat, + keyCode: keyCode + )) + } } diff --git a/JammLabTests/NotationMusicXMLRestExportTests.swift b/JammLabTests/NotationMusicXMLRestExportTests.swift index 0147d59..494ded7 100644 --- a/JammLabTests/NotationMusicXMLRestExportTests.swift +++ b/JammLabTests/NotationMusicXMLRestExportTests.swift @@ -102,6 +102,57 @@ final class NotationMusicXMLRestExportTests: XCTestCase { XCTAssertTrue(note.elements(forName: "beam").isEmpty) } + func testMusicXMLExportIncludesSixteenthNoteAndRestWithoutBeams() throws { + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 4), + duration: 4, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "C major", + notationItems: [ + NotationMeasureItem( + kind: .note, + pitch: NotationPitch(step: .c, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 0.25, + displayDuration: NotationDuration(denominator: 16) + ), + NotationMeasureItem( + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0.25, + durationInQuarterNotes: 0.25, + displayDuration: NotationDuration(denominator: 16) + ), + NotationMeasureItem( + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0.5, + durationInQuarterNotes: 3.5, + displayDuration: NotationDuration(denominator: 2) + ) + ] + ) + + let document = try exportedMusicXMLDocument(for: state) + let part = try XCTUnwrap(document.rootElement()?.elements(forName: "part").first) + let firstMeasure = try XCTUnwrap(part.elements(forName: "measure").first) + let notes = firstMeasure.elements(forName: "note") + let sixteenthNote = try XCTUnwrap(notes.first) + let sixteenthRest = try XCTUnwrap(notes.dropFirst().first) + + XCTAssertNotNil(sixteenthNote.elements(forName: "pitch").first) + XCTAssertNotNil(sixteenthRest.elements(forName: "rest").first) + for item in [sixteenthNote, sixteenthRest] { + XCTAssertEqual(try firstXMLChild(named: "duration", in: item).stringValue, "120") + XCTAssertEqual(try firstXMLChild(named: "type", in: item).stringValue, "16th") + XCTAssertTrue(item.elements(forName: "beam").isEmpty) + } + } + private func exportedMusicXMLDocument(for state: NotationScoreState) throws -> XMLDocument { let data = try NotationExportService().export( NotationExportRequest(displayName: "Song", score: state), diff --git a/JammLabTests/NotationPrimitivesTests.swift b/JammLabTests/NotationPrimitivesTests.swift index 5226771..87fb26f 100644 --- a/JammLabTests/NotationPrimitivesTests.swift +++ b/JammLabTests/NotationPrimitivesTests.swift @@ -3,6 +3,23 @@ import XCTest @testable import JammLab final class NotationPrimitivesTests: XCTestCase { + private let directionalStaffSymbolExpectations: [( + denominator: Int, + up: NotationStaffNoteSymbol, + down: NotationStaffNoteSymbol, + upCodepoint: UInt32, + downCodepoint: UInt32 + )] = [ + (2, .half(.up), .half(.down), 0xE1D3, 0xE1D4), + (4, .quarter(.up), .quarter(.down), 0xE1D5, 0xE1D6), + (8, .eighth(.up), .eighth(.down), 0xE1D7, 0xE1D8), + (16, .sixteenth(.up), .sixteenth(.down), 0xE1D9, 0xE1DA) + ] + + private var allStaffNoteSymbols: [NotationStaffNoteSymbol] { + [.whole] + directionalStaffSymbolExpectations.flatMap { [$0.up, $0.down] } + } + func testNotationPartDescriptorsProvideMusicXMLMetadata() { let descriptors: [(NotationPartDescriptor, String, String, String?)] = [ (.main, "Main", "Main", nil), @@ -28,6 +45,7 @@ final class NotationPrimitivesTests: XCTestCase { let half = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 2))) let quarter = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 4))) let eighth = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 8))) + let sixteenth = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 16))) XCTAssertEqual(whole, .restWhole) XCTAssertEqual(whole.codepoint, 0xE4E3) @@ -37,6 +55,8 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertEqual(quarter.codepoint, 0xE4E5) XCTAssertEqual(eighth, .rest8th) XCTAssertEqual(eighth.codepoint, 0xE4E6) + XCTAssertEqual(sixteenth, .rest16th) + XCTAssertEqual(sixteenth.codepoint, 0xE4E7) XCTAssertEqual(quarter.glyph.unicodeScalars.first?.value, 0xE4E5) } @@ -75,6 +95,7 @@ final class NotationPrimitivesTests: XCTestCase { let half = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 2))) let quarter = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 4))) let eighth = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 8))) + let sixteenth = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 16))) XCTAssertEqual(whole, .whole) XCTAssertEqual(whole.codepoint, 0xECA2) @@ -84,19 +105,57 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertEqual(quarter.codepoint, 0xECA5) XCTAssertEqual(eighth, .eighth) XCTAssertEqual(eighth.codepoint, 0xECA7) + XCTAssertEqual(sixteenth, .sixteenth) + XCTAssertEqual(sixteenth.codepoint, 0xECA9) XCTAssertEqual(eighth.glyph.unicodeScalars.first?.value, 0xECA7) } - func testNotationStaffNoteSymbolsReuseLelandMetNoteCodepoints() throws { + func testNotationStaffNoteSymbolsUseDirectionSpecificLelandCodepoints() throws { let whole = try XCTUnwrap(NotationStaffNoteSymbol(duration: NotationDuration(denominator: 1))) - let half = try XCTUnwrap(NotationStaffNoteSymbol(duration: NotationDuration(denominator: 2))) - let quarter = try XCTUnwrap(NotationStaffNoteSymbol(duration: NotationDuration(denominator: 4))) - let eighth = try XCTUnwrap(NotationStaffNoteSymbol(duration: NotationDuration(denominator: 8))) + let wholeWithDownDirection = try XCTUnwrap(NotationStaffNoteSymbol( + duration: NotationDuration(denominator: 1), + stemDirection: .down + )) - XCTAssertEqual(whole.codepoint, 0xECA2) - XCTAssertEqual(half.codepoint, 0xECA3) - XCTAssertEqual(quarter.codepoint, 0xECA5) - XCTAssertEqual(eighth.codepoint, 0xECA7) + XCTAssertEqual(whole, .whole) + XCTAssertEqual(wholeWithDownDirection, .whole) + XCTAssertEqual(whole.codepoint, 0xE1D2) + for expectation in directionalStaffSymbolExpectations { + let duration = NotationDuration(denominator: expectation.denominator) + let up = try XCTUnwrap(NotationStaffNoteSymbol(duration: duration, stemDirection: .up)) + let down = try XCTUnwrap(NotationStaffNoteSymbol(duration: duration, stemDirection: .down)) + XCTAssertEqual(up, expectation.up) + XCTAssertEqual(down, expectation.down) + XCTAssertEqual(up.codepoint, expectation.upCodepoint) + XCTAssertEqual(down.codepoint, expectation.downCodepoint) + } + } + + func testNotationStemDirectionChangesOnlyAboveMiddleStaffLine() { + XCTAssertEqual(NotationStemDirection.direction(forStaffPosition: 3), .down) + XCTAssertEqual(NotationStemDirection.direction(forStaffPosition: 4), .up) + XCTAssertEqual(NotationStemDirection.direction(forStaffPosition: 5), .up) + } + + func testSixteenthDurationUsesQuarterBeatAndDisplayNames() { + let duration = NotationDuration(denominator: 16) + + XCTAssertEqual(duration.denominator, 16) + XCTAssertEqual(duration.durationInQuarterNotes, 0.25, accuracy: 0.0001) + XCTAssertEqual(duration.displayName, "16th") + XCTAssertEqual(duration.capitalizedDisplayName, "16th") + XCTAssertEqual(duration.pluralDisplayName, "16th notes") + } + + func testFiveDurationButtonsFitNotationTrackControls() { + let buttonCount = CGFloat(NotationDuration.allowedDenominators.count) + let spacingCount = max(0, buttonCount - 1) + let controlWidth = buttonCount * AppTheme.ControlSize.notationDurationButtonWidth + + spacingCount * AppTheme.ControlSize.notationDurationButtonSpacing + let availableWidth = AppTheme.Timeline.trackControlWidth - 2 * AppTheme.Spacing.md + + XCTAssertLessThanOrEqual(controlWidth, availableWidth) + XCTAssertEqual(AppTheme.ControlSize.notationEntryModeButtonWidth, 32) } func testNotationRestItemFactoryUsesGreedyAllowedDurationDecomposition() { @@ -113,18 +172,26 @@ final class NotationPrimitivesTests: XCTestCase { let withinTolerance = NotationRestItemFactory.greedySegments( startOffset: 1, - remaining: 0.5 - tolerance / 2 + remaining: 0.25 - tolerance / 2 ) let outsideTolerance = NotationRestItemFactory.greedySegments( startOffset: 1, - remaining: 0.5 - tolerance * 2 + remaining: 0.25 - tolerance * 2 ) - XCTAssertEqual(withinTolerance.map(\.displayDuration.denominator), [8]) + XCTAssertEqual(withinTolerance.map(\.displayDuration.denominator), [16]) XCTAssertEqual(withinTolerance.map(\.offsetInQuarterNotes), [1]) XCTAssertTrue(outsideTolerance.isEmpty) } + func testNotationRestItemFactoryUsesSixteenthAsMinimumExactDuration() { + let segments = NotationRestItemFactory.greedySegments(startOffset: 1.5, remaining: 0.25) + + XCTAssertEqual(segments.map(\.displayDuration.denominator), [16]) + XCTAssertEqual(segments.map(\.offsetInQuarterNotes), [1.5]) + XCTAssertEqual(segments.map(\.durationInQuarterNotes), [0.25]) + } + func testNotationRestItemFactoryLetsCallerControlIDsAndSynthesizedState() throws { let synthesized = NotationRestItemFactory.restItems( measureNumber: 2, @@ -903,15 +970,23 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertEqual(NotationMusicFontRegistry.fontName, "Leland") } - func testLelandWholeRestGlyphPathHasBounds() throws { - let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( - for: .restWhole, - fontSize: 32.5 - )) + func testLelandRestGlyphPathsHaveBounds() throws { + for symbol in [ + NotationSMuFLSymbol.restWhole, + .restHalf, + .restQuarter, + .rest8th, + .rest16th + ] { + let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( + for: symbol, + fontSize: 32.5 + )) - XCTAssertFalse(glyphPath.path.isEmpty) - XCTAssertGreaterThan(glyphPath.bounds.width, 0) - XCTAssertGreaterThan(glyphPath.bounds.height, 0) + XCTAssertFalse(glyphPath.path.isEmpty) + XCTAssertGreaterThan(glyphPath.bounds.width, 0) + XCTAssertGreaterThan(glyphPath.bounds.height, 0) + } } func testNotationClefLayoutUsesExpectedFrameAndStaffTargets() { @@ -969,7 +1044,8 @@ final class NotationPrimitivesTests: XCTestCase { NotationDurationControlSymbol.whole, .half, .quarter, - .eighth + .eighth, + .sixteenth ] { let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( for: symbol, @@ -983,12 +1059,7 @@ final class NotationPrimitivesTests: XCTestCase { } func testLelandStaffNoteGlyphPathsHaveBounds() throws { - for symbol in [ - NotationStaffNoteSymbol.whole, - .half, - .quarter, - .eighth - ] { + for symbol in allStaffNoteSymbols { let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( for: symbol, fontSize: 32.5 @@ -1003,12 +1074,7 @@ final class NotationPrimitivesTests: XCTestCase { func testLelandStaffNoteGlyphTransformAnchorsNoteheadAtTargetPoint() throws { let target = CGPoint(x: 42, y: 73) - for symbol in [ - NotationStaffNoteSymbol.whole, - .half, - .quarter, - .eighth - ] { + for symbol in allStaffNoteSymbols { let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( for: symbol, fontSize: 32.5 @@ -1027,6 +1093,26 @@ final class NotationPrimitivesTests: XCTestCase { } } + func testDirectionSpecificStaffGlyphsExtendFromOppositeSidesOfNotehead() throws { + let target = CGPoint(x: 42, y: 73) + + for expectation in directionalStaffSymbolExpectations { + let upSymbol = expectation.up + let downSymbol = expectation.down + let upPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath(for: upSymbol, fontSize: 32.5)) + let downPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath(for: downSymbol, fontSize: 32.5)) + let upAnchor = try XCTUnwrap(NotationMusicFontRegistry.noteheadAnchor(for: upSymbol, fontSize: 32.5)) + let downAnchor = try XCTUnwrap(NotationMusicFontRegistry.noteheadAnchor(for: downSymbol, fontSize: 32.5)) + let upBounds = upPath.bounds.applying(upPath.anchoredTransform(anchor: upAnchor, target: target)) + let downBounds = downPath.bounds.applying(downPath.anchoredTransform(anchor: downAnchor, target: target)) + + XCTAssertLessThan(upBounds.minY, target.y - 10) + XCTAssertGreaterThan(downBounds.maxY, target.y + 10) + XCTAssertGreaterThan(target.y - upBounds.minY, upBounds.maxY - target.y) + XCTAssertGreaterThan(downBounds.maxY - target.y, target.y - downBounds.minY) + } + } + func testLelandDurationControlGlyphPathsCenterInsideButtonFrame() throws { let buttonSize = CGSize( width: AppTheme.ControlSize.notationDurationButtonWidth, @@ -1037,7 +1123,8 @@ final class NotationPrimitivesTests: XCTestCase { NotationDurationControlSymbol.whole, .half, .quarter, - .eighth + .eighth, + .sixteenth ] { let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( for: symbol, @@ -1069,6 +1156,10 @@ final class NotationPrimitivesTests: XCTestCase { ( 8, "Eighth (quaver) note (4; Num4)\nSet duration: eighth (quaver) note" + ), + ( + 16, + "16th (semiquaver) note (3; Num3)\nSet duration: 16th (semiquaver) note" ) ] diff --git a/JammLabTests/TimelineProjectLogicTests.swift b/JammLabTests/TimelineProjectLogicTests.swift index f4bf3a0..26b2afc 100644 --- a/JammLabTests/TimelineProjectLogicTests.swift +++ b/JammLabTests/TimelineProjectLogicTests.swift @@ -106,15 +106,24 @@ final class TimelineProjectLogicTests: XCTestCase { measureNumber: 1, measureStartTime: 0, offsetInQuarterNotes: 0, - durationInQuarterNotes: 1, - displayDuration: NotationDuration(denominator: 4) + durationInQuarterNotes: 0.25, + displayDuration: NotationDuration(denominator: 16) + ) + let notationRest = NotationMeasureItem( + id: "main-rest", + kind: .rest, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0.25, + durationInQuarterNotes: 0.25, + displayDuration: NotationDuration(denominator: 16) ) let project = JammLabProject( audioBookmarkData: Data([1, 2, 3]), audioDisplayName: "song.wav", audioDuration: 12, notes: [], - notationItems: [notationItem], + notationItems: [notationItem, notationRest], loopStart: 0, loopEnd: 4, playbackRate: 1, @@ -129,6 +138,9 @@ final class TimelineProjectLogicTests: XCTestCase { ) XCTAssertEqual(decoded.notationItems.first?.partID, .stem(.bass)) + XCTAssertEqual(decoded.notationItems.map(\.kind), [.note, .rest]) + XCTAssertEqual(decoded.notationItems.map(\.displayDuration.denominator), [16, 16]) + XCTAssertEqual(decoded.notationItems.map(\.durationInQuarterNotes), [0.25, 0.25]) XCTAssertEqual(decoded.stemNotationTrackCollapsed, [.bass: false, .drums: true]) XCTAssertEqual(decoded.visibleNotationPartIDs, [.main, .stem(.bass), .stem(.drums)]) } diff --git a/JammLabTests/ViewModelNotationDurationSelectionTests.swift b/JammLabTests/ViewModelNotationDurationSelectionTests.swift index f211048..80d650f 100644 --- a/JammLabTests/ViewModelNotationDurationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationDurationSelectionTests.swift @@ -3,6 +3,96 @@ import XCTest @testable import JammLab final class ViewModelNotationDurationSelectionTests: XCTestCase { + @MainActor + func testChangingSelectedWholeRestToSixteenthCreatesTwoSixteenthsAndCanUndo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let measure = try notationMeasure(1, in: viewModel) + let item = try XCTUnwrap(measure.notationItems.first) + + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: item)) + viewModel.setNotationDurationDenominator(16) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [16, 16, 2, 4, 8]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 0.25, 0.5, 2.5, 3.5]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [0.25, 0.25, 2, 1, 0.5]) + XCTAssertEqual(viewModel.selectedNotationItem?.durationInQuarterNotes, 0.25) + + viewModel.undoLastEdit() + + let undoneMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(undoneMeasure.notationItems.count, 1) + XCTAssertEqual(undoneMeasure.notationItems.first?.displayDuration.denominator, 1) + } + + @MainActor + func testAddingSixteenthNoteRecomposesRestsAndCanUndo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let measure = try notationMeasure(1, in: viewModel) + let rest = try XCTUnwrap(measure.notationItems.first) + let pitch = NotationPitch(step: .g, octave: 5) + let placement = NotationNotePlacement( + measure: measure, + targetRestID: rest.id, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 0.25, + displayDuration: NotationDuration(denominator: 16), + pitch: pitch, + x: 0, + y: 0 + ) + + XCTAssertTrue(viewModel.insertNotationNote(placement)) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(updatedMeasure.notationItems.first?.kind, .note) + XCTAssertEqual(updatedMeasure.notationItems.first?.pitch, pitch) + XCTAssertEqual(updatedMeasure.notationItems.first?.displayDuration.denominator, 16) + XCTAssertEqual(updatedMeasure.notationItems.first?.durationInQuarterNotes, 0.25) + XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes).reduce(0, +), 4, accuracy: 0.0001) + + viewModel.undoLastEdit() + + let undoneMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(undoneMeasure.notationItems.count, 1) + XCTAssertEqual(undoneMeasure.notationItems.first?.kind, .rest) + } + + @MainActor + func testAddingSixteenthRestRecomposesRestsAndCanUndo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let measure = try notationMeasure(1, in: viewModel) + let rest = try XCTUnwrap(measure.notationItems.first) + let placement = NotationRestPlacement( + measure: measure, + targetRestID: rest.id, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 0.25, + displayDuration: NotationDuration(denominator: 16), + x: 0 + ) + + XCTAssertTrue(viewModel.insertNotationRest(placement)) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(updatedMeasure.notationItems.first?.kind, .rest) + XCTAssertEqual(updatedMeasure.notationItems.first?.displayDuration.denominator, 16) + XCTAssertEqual(updatedMeasure.notationItems.first?.durationInQuarterNotes, 0.25) + XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes).reduce(0, +), 4, accuracy: 0.0001) + + viewModel.undoLastEdit() + + let undoneMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(undoneMeasure.notationItems.count, 1) + XCTAssertEqual(undoneMeasure.notationItems.first?.displayDuration.denominator, 1) + } + @MainActor func testChangingSelectedWholeRestToQuarterCreatesTwoQuartersAndHalfInFourFour() throws { let viewModel = try loadedNotationViewModel(duration: 8) From 5d87bc262122a506c5158943dfe00934d1ae51d2 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 15 Jul 2026 08:51:47 +0300 Subject: [PATCH 10/12] refactor: improve notation and audio maintainability --- CHANGELOG.md | 1 + Configurations/Base.xcconfig | 1 + JammLab.xcodeproj/project.pbxproj | 12 ++ JammLab/Models/AppSettingsStore.swift | 13 ++ JammLab/Models/JammLabProject.swift | 4 - JammLab/Models/NotationDurationEditor.swift | 105 +++++++++++++++ JammLab/Services/StemMixAudioPlayer.swift | 120 ++++++++++-------- .../AudioPlayerViewModel+Markers.swift | 13 -- .../AudioPlayerViewModel+Notation.swift | 119 +---------------- .../AudioPlayerViewModel+Playback.swift | 12 +- .../AudioPlayerViewModel+Stems.swift | 7 - .../AudioPlayerViewModel+Timeline.swift | 5 - JammLab/ViewModels/AudioPlayerViewModel.swift | 13 +- JammLabTests/AppSettingsStoreTests.swift | 13 ++ ...MultiTrackAudioPlayerConversionTests.swift | 57 +++++++++ .../NotationDurationEditorTests.swift | 74 +++++++++++ JammLabTests/TestSupport.swift | 25 ++++ .../TunerInputServiceTestDoubles.swift | 9 +- JammLabTests/ViewModelResetTests.swift | 37 ++---- JammLabTests/ViewModelSettingsTests.swift | 15 +++ ...ModelStemSeparationCancellationTests.swift | 18 ++- 21 files changed, 419 insertions(+), 254 deletions(-) create mode 100644 JammLab/Models/NotationDurationEditor.swift create mode 100644 JammLabTests/MultiTrackAudioPlayerConversionTests.swift create mode 100644 JammLabTests/NotationDurationEditorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index ab28af9..aece4e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. - Added clearer hover tooltips and numpad shortcuts for Notation duration buttons. - Added a bar/beat position readout to the transport time display. - Fixed multi-part Notation accessibility selection announcements and prevented harmony editing from clearing a selected stem note. +- Prevented an oversized Notation duration change from removing later notes or rests in the measure. - Stabilized the Rest entry icon, matched Notation track controls, aligned stem Mute/Solo buttons, and limited MusicXML export to the selected Parts. - Added full instrument names, abbreviations, and standard instrument sounds to exported MusicXML parts and Notation score labels. - Added vertical workspace scrolling when expanded Notation tracks exceed the main window height. diff --git a/Configurations/Base.xcconfig b/Configurations/Base.xcconfig index c28322c..53d6a9b 100644 --- a/Configurations/Base.xcconfig +++ b/Configurations/Base.xcconfig @@ -4,6 +4,7 @@ PRODUCT_NAME = $(TARGET_NAME) MACOSX_DEPLOYMENT_TARGET = 14.0 SWIFT_VERSION = 5.0 +SWIFT_STRICT_CONCURRENCY = targeted SDKROOT = macosx CLANG_CXX_LANGUAGE_STANDARD = gnu++20 GCC_C_LANGUAGE_STANDARD = gnu17 diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 09f4ff2..ffc8db4 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -230,6 +230,9 @@ 9FBA01042D40000100112233 /* TunerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA00042D40000100112233 /* TunerView.swift */; }; 9FBA01052D40000100112233 /* PitchDetectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA00052D40000100112233 /* PitchDetectionTests.swift */; }; 9FBA01062D40000100112233 /* AudioSampleConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA00062D40000100112233 /* AudioSampleConverterTests.swift */; }; + 9FCE01012F20000100112233 /* MultiTrackAudioPlayerConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCE00012F20000100112233 /* MultiTrackAudioPlayerConversionTests.swift */; }; + 9FCE01022F20000100112233 /* NotationDurationEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCE00022F20000100112233 /* NotationDurationEditor.swift */; }; + 9FCE01032F20000100112233 /* NotationDurationEditorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCE00032F20000100112233 /* NotationDurationEditorTests.swift */; }; 9FD001012D20000100112233 /* ControlHelpText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FD001002D20000100112233 /* ControlHelpText.swift */; }; /* End PBXBuildFile section */ @@ -494,6 +497,9 @@ 9FBA00042D40000100112233 /* TunerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunerView.swift; sourceTree = ""; }; 9FBA00052D40000100112233 /* PitchDetectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitchDetectionTests.swift; sourceTree = ""; }; 9FBA00062D40000100112233 /* AudioSampleConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSampleConverterTests.swift; sourceTree = ""; }; + 9FCE00012F20000100112233 /* MultiTrackAudioPlayerConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiTrackAudioPlayerConversionTests.swift; sourceTree = ""; }; + 9FCE00022F20000100112233 /* NotationDurationEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationDurationEditor.swift; sourceTree = ""; }; + 9FCE00032F20000100112233 /* NotationDurationEditorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationDurationEditorTests.swift; sourceTree = ""; }; 9FC100012D10000100112233 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = ""; }; 9FC100022D10000100112233 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; 9FC100032D10000100112233 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; @@ -581,6 +587,7 @@ 9F8B00022C10000100112233 /* ProjectStateNormalizer.swift */, 9FAC00012CF0000100112233 /* ProjectEditableState.swift */, 9FEA00012D70000100112233 /* NotationScoreModels.swift */, + 9FCE00022F20000100112233 /* NotationDurationEditor.swift */, 9F9300012C90000100112233 /* StemBackendResolver.swift */, 9F8D00012C30000100112233 /* StemModels.swift */, 9F8E00012C40000100112233 /* StemSeparationJobModels.swift */, @@ -723,6 +730,8 @@ 9FAB00022CE0000100112233 /* PeakformLogicTests.swift */, 9FBA00052D40000100112233 /* PitchDetectionTests.swift */, 9FBA00062D40000100112233 /* AudioSampleConverterTests.swift */, + 9FCE00012F20000100112233 /* MultiTrackAudioPlayerConversionTests.swift */, + 9FCE00032F20000100112233 /* NotationDurationEditorTests.swift */, 9FAB00032CE0000100112233 /* TimelineProjectLogicTests.swift */, 9FAB00682CE0000100112233 /* ProjectStateNormalizerTests.swift */, 9FAB00552CE0000100112233 /* ProjectSaveDestinationTests.swift */, @@ -1138,6 +1147,7 @@ 9F8A01132C00000100112233 /* RecentProjectsStore.swift in Sources */, 9F9401012CA0000100112233 /* AppSettingsStore.swift in Sources */, 9FEA01012D70000100112233 /* NotationScoreModels.swift in Sources */, + 9FCE01022F20000100112233 /* NotationDurationEditor.swift in Sources */, 9F2701012EA0000100112233 /* NotationSMuFL.swift in Sources */, 9F8D01042C30000100112233 /* AudioRenderState.swift in Sources */, 9F8D01052C30000100112233 /* ClickRenderState.swift in Sources */, @@ -1193,6 +1203,8 @@ 9FAB01022CE0000100112233 /* PeakformLogicTests.swift in Sources */, 9FBA01052D40000100112233 /* PitchDetectionTests.swift in Sources */, 9FBA01062D40000100112233 /* AudioSampleConverterTests.swift in Sources */, + 9FCE01012F20000100112233 /* MultiTrackAudioPlayerConversionTests.swift in Sources */, + 9FCE01032F20000100112233 /* NotationDurationEditorTests.swift in Sources */, 9FAB01032CE0000100112233 /* TimelineProjectLogicTests.swift in Sources */, 9FAB01682CE0000100112233 /* ProjectStateNormalizerTests.swift in Sources */, 9FAB01552CE0000100112233 /* ProjectSaveDestinationTests.swift in Sources */, diff --git a/JammLab/Models/AppSettingsStore.swift b/JammLab/Models/AppSettingsStore.swift index 44434ed..a15be63 100644 --- a/JammLab/Models/AppSettingsStore.swift +++ b/JammLab/Models/AppSettingsStore.swift @@ -323,6 +323,7 @@ struct AppColorPalette: Codable, Equatable { } final class AppSettingsStore: ObservableObject { + static let clickVolumeKey = "metronome.volume" static let clickSoundSettingsKey = "click.soundSettings" static let stemBackendComputeModeKey = "stemBackend.computeMode" static let colorPaletteKey = "theme.colorPalette" @@ -357,6 +358,14 @@ final class AppSettingsStore: ObservableObject { updateClickSoundSettings(.defaultValue) } + func restoredClickVolume(defaultValue: Float = AppSliderDefaults.clickVolume) -> Float { + guard defaults.object(forKey: Self.clickVolumeKey) != nil else { + return defaultValue + } + + return Self.clampedVolume(defaults.float(forKey: Self.clickVolumeKey)) + } + func updateColor(_ role: AppColorRole, hex: String) { colorPalette = colorPalette.updating(role, hex: hex).normalized() persistColorPalette() @@ -446,4 +455,8 @@ final class AppSettingsStore: ObservableObject { return settings.normalized() } + + private static func clampedVolume(_ volume: Float) -> Float { + min(1, max(0, volume)) + } } diff --git a/JammLab/Models/JammLabProject.swift b/JammLab/Models/JammLabProject.swift index 80caab6..1f44a90 100644 --- a/JammLab/Models/JammLabProject.swift +++ b/JammLab/Models/JammLabProject.swift @@ -211,10 +211,6 @@ struct JammLabProject: Codable { try container.encode(visibleNotationPartIDs, forKey: .visibleNotationPartIDs) } - func resolvedAudioURL() throws -> URL { - try resolvedMediaURL() - } - func resolvedMediaURL() throws -> URL { var isStale = false return try URL( diff --git a/JammLab/Models/NotationDurationEditor.swift b/JammLab/Models/NotationDurationEditor.swift new file mode 100644 index 0000000..a7ca3f9 --- /dev/null +++ b/JammLab/Models/NotationDurationEditor.swift @@ -0,0 +1,105 @@ +import Foundation + +enum NotationDurationEditor { + static func replacementSuffix( + in measure: ScoreMeasure, + selectedItem: NotationMeasureItem, + selectedDuration: NotationDuration + ) -> [NotationMeasureItem]? { + var items: [NotationMeasureItem] = [] + var cursor = selectedItem.offsetInQuarterNotes + let measureLength = NotationMeasureTiming.quarterLength(for: measure.attributes.timeSignature) + var remaining = measureLength - cursor + guard remaining > NotationMeasureTiming.timelineTolerance else { return nil } + + let selectedLength = selectedDuration.durationInQuarterNotes + if selectedItem.kind == .note { + guard selectedLength <= remaining + NotationMeasureTiming.timelineTolerance, + let pitch = selectedItem.pitch + else { + return nil + } + + let duration = min(selectedLength, remaining) + items.append(NotationMeasureItem( + id: selectedItem.id, + partID: selectedItem.partID, + kind: .note, + pitch: pitch, + measureNumber: measure.number, + measureStartTime: measure.startTime, + offsetInQuarterNotes: cursor, + durationInQuarterNotes: duration, + displayDuration: selectedDuration + )) + cursor += duration + remaining -= duration + items.append(contentsOf: fillerItems( + in: measure, + partID: selectedItem.partID, + startOffset: cursor, + remaining: remaining + )) + return items + } + + let selectedCount = min( + 2, + Int(floor((remaining + NotationMeasureTiming.timelineTolerance) / selectedLength)) + ) + if selectedCount > 0 { + for _ in 0.. [NotationMeasureItem] { + NotationRestItemFactory.restItems( + measureNumber: measure.number, + measureStartTime: measure.startTime, + startOffset: startOffset, + remaining: remaining, + partID: partID + ) + } + + private static func largestDuration(fitting remaining: Double) -> NotationDuration? { + NotationRestItemFactory.greedySegments(startOffset: 0, remaining: remaining).first?.displayDuration + } +} diff --git a/JammLab/Services/StemMixAudioPlayer.swift b/JammLab/Services/StemMixAudioPlayer.swift index add7974..46619ce 100644 --- a/JammLab/Services/StemMixAudioPlayer.swift +++ b/JammLab/Services/StemMixAudioPlayer.swift @@ -2,6 +2,75 @@ import AudioToolbox import Foundation +private final class SingleUseAudioBufferProvider: @unchecked Sendable { + private let lock = NSLock() + private var buffer: AVAudioPCMBuffer? + + init(buffer: AVAudioPCMBuffer) { + self.buffer = buffer + } + + func take() -> AVAudioPCMBuffer? { + lock.lock() + defer { lock.unlock() } + + let nextBuffer = buffer + buffer = nil + return nextBuffer + } +} + +enum AudioFileBufferDecoder { + static func decode(file: AVAudioFile, to outputFormat: AVAudioFormat) throws -> AVAudioPCMBuffer { + let inputFormat = file.processingFormat + guard let inputBuffer = AVAudioPCMBuffer( + pcmFormat: inputFormat, + frameCapacity: AVAudioFrameCount(file.length) + ) else { + throw MultiTrackAudioPlayerError.audioConversionFailed + } + + try file.read(into: inputBuffer) + if formatsMatch(inputFormat, outputFormat), inputBuffer.floatChannelData != nil { + return inputBuffer + } + + guard let converter = AVAudioConverter(from: inputFormat, to: outputFormat) else { + throw MultiTrackAudioPlayerError.audioConversionFailed + } + + let ratio = outputFormat.sampleRate / inputFormat.sampleRate + let outputCapacity = AVAudioFrameCount(Double(inputBuffer.frameLength) * ratio) + 1_024 + guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: outputCapacity) else { + throw MultiTrackAudioPlayerError.audioConversionFailed + } + + let inputProvider = SingleUseAudioBufferProvider(buffer: inputBuffer) + var conversionError: NSError? + converter.convert(to: outputBuffer, error: &conversionError) { _, status in + guard let nextBuffer = inputProvider.take() else { + status.pointee = .noDataNow + return nil + } + + status.pointee = .haveData + return nextBuffer + } + + if conversionError != nil || outputBuffer.floatChannelData == nil { + throw MultiTrackAudioPlayerError.audioConversionFailed + } + return outputBuffer + } + + private static func formatsMatch(_ lhs: AVAudioFormat, _ rhs: AVAudioFormat) -> Bool { + lhs.commonFormat == rhs.commonFormat && + lhs.sampleRate == rhs.sampleRate && + lhs.channelCount == rhs.channelCount && + lhs.isInterleaved == rhs.isInterleaved + } +} + enum MultiTrackAudioPlayerError: LocalizedError { case noStems case unsupportedStemLoad @@ -331,52 +400,10 @@ final class MultiTrackAudioPlayer: AudioPlaybackControlling { volume: Float ) throws -> AudioRenderTrack { let file = try AVAudioFile(forReading: url) - let buffer = try decode(file: file, to: outputFormat) + let buffer = try AudioFileBufferDecoder.decode(file: file, to: outputFormat) return AudioRenderTrack(id: id, buffer: buffer, volume: volume) } - private func decode(file: AVAudioFile, to outputFormat: AVAudioFormat) throws -> AVAudioPCMBuffer { - let inputFormat = file.processingFormat - guard let inputBuffer = AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: AVAudioFrameCount(file.length)) else { - throw MultiTrackAudioPlayerError.audioConversionFailed - } - - try file.read(into: inputBuffer) - - if formatsMatch(inputFormat, outputFormat), inputBuffer.floatChannelData != nil { - return inputBuffer - } - - guard let converter = AVAudioConverter(from: inputFormat, to: outputFormat) else { - throw MultiTrackAudioPlayerError.audioConversionFailed - } - - let ratio = outputFormat.sampleRate / inputFormat.sampleRate - let outputCapacity = AVAudioFrameCount(Double(inputBuffer.frameLength) * ratio) + 1_024 - guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: outputCapacity) else { - throw MultiTrackAudioPlayerError.audioConversionFailed - } - - var didProvideInput = false - var conversionError: NSError? - converter.convert(to: outputBuffer, error: &conversionError) { _, status in - if didProvideInput { - status.pointee = .noDataNow - return nil - } - - didProvideInput = true - status.pointee = .haveData - return inputBuffer - } - - if conversionError != nil || outputBuffer.floatChannelData == nil { - throw MultiTrackAudioPlayerError.audioConversionFailed - } - - return outputBuffer - } - private func renderFormat(for sourceFormat: AVAudioFormat) throws -> AVAudioFormat { guard let format = AVAudioFormat( commonFormat: .pcmFormatFloat32, @@ -390,13 +417,6 @@ final class MultiTrackAudioPlayer: AudioPlaybackControlling { return format } - private func formatsMatch(_ lhs: AVAudioFormat, _ rhs: AVAudioFormat) -> Bool { - lhs.commonFormat == rhs.commonFormat && - lhs.sampleRate == rhs.sampleRate && - lhs.channelCount == rhs.channelCount && - lhs.isInterleaved == rhs.isInterleaved - } - private func applyLoopState() { guard let outputFormat else { return } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Markers.swift b/JammLab/ViewModels/AudioPlayerViewModel+Markers.swift index 92db919..4603311 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Markers.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Markers.swift @@ -9,19 +9,6 @@ extension AudioPlayerViewModel { addNote(at: currentTime) } - func addTempoTimeSignatureMarkerAtCurrentTime( - bpm: Double, - beatsPerBar: Int, - setsNewFirstBeat: Bool = false - ) { - addTempoTimeSignatureMarker( - at: currentTime, - bpm: bpm, - beatsPerBar: beatsPerBar, - setsNewFirstBeat: setsNewFirstBeat - ) - } - func addNote(at time: TimeInterval) { performUndoableEdit("Add Marker") { guard duration > 0 else { return } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift index 9b2bbd1..9578542 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift @@ -159,10 +159,6 @@ extension AudioPlayerViewModel { } } - func clearNotationNoteEntryMode() { - clearNotationEntryMode() - } - @discardableResult func requestEditSelectedNotationItem() -> Bool { guard duration > 0, @@ -740,23 +736,18 @@ extension AudioPlayerViewModel { else { return } let measure = match.measure - let measureLength = NotationMeasureTiming.quarterLength(for: measure.attributes.timeSignature) let startOffset = match.item.offsetInQuarterNotes - let remaining = measureLength - startOffset - guard remaining > NotationMeasureTiming.timelineTolerance else { return } + guard let suffix = NotationDurationEditor.replacementSuffix( + in: measure, + selectedItem: match.item, + selectedDuration: selectedDuration + ) else { return } performUndoableEdit("Change Notation Duration") { let prefix = measure.notationItems .filter { $0.offsetInQuarterNotes < startOffset - NotationMeasureTiming.timelineTolerance } .filter { !$0.isSynthesized } - let suffix = notationDurationSuffix( - measure: measure, - selectedItem: match.item, - startOffset: startOffset, - remaining: remaining, - selectedDuration: selectedDuration - ) replaceNotationMeasureItems(in: measure, with: prefix + suffix) if let selected = suffix.first { @@ -765,106 +756,6 @@ extension AudioPlayerViewModel { } } - private func notationDurationSuffix( - measure: ScoreMeasure, - selectedItem: NotationMeasureItem, - startOffset: Double, - remaining: Double, - selectedDuration: NotationDuration - ) -> [NotationMeasureItem] { - var items: [NotationMeasureItem] = [] - var cursor = startOffset - var rest = remaining - let selectedLength = selectedDuration.durationInQuarterNotes - - if selectedItem.kind == .note { - guard selectedLength <= rest + NotationMeasureTiming.timelineTolerance, - let pitch = selectedItem.pitch - else { - return [selectedItem.persistedCopy()] - } - - let duration = min(selectedLength, rest) - items.append(NotationMeasureItem( - id: selectedItem.id, - partID: selectedItem.partID, - kind: .note, - pitch: pitch, - measureNumber: measure.number, - measureStartTime: measure.startTime, - offsetInQuarterNotes: cursor, - durationInQuarterNotes: duration, - displayDuration: selectedDuration - )) - cursor += duration - rest -= duration - items.append(contentsOf: fillerNotationItems( - measure: measure, - partID: selectedItem.partID, - startOffset: cursor, - remaining: rest - )) - return items - } - - let selectedCount = min(2, Int(floor((rest + NotationMeasureTiming.timelineTolerance) / selectedLength))) - - if selectedCount > 0 { - for _ in 0.. [NotationMeasureItem] { - NotationRestItemFactory.restItems( - measureNumber: measure.number, - measureStartTime: measure.startTime, - startOffset: startOffset, - remaining: remaining, - partID: partID - ) - } - - private func largestNotationDuration(fitting remaining: Double) -> NotationDuration? { - NotationRestItemFactory.greedySegments(startOffset: 0, remaining: remaining).first?.displayDuration - } - private func harmonySymbolID(at time: TimeInterval) -> HarmonySymbol.ID? { harmonySymbols.first { sameHarmonyPosition($0.time, time) }?.id } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Playback.swift b/JammLab/ViewModels/AudioPlayerViewModel+Playback.swift index a2f2783..b0e9bf6 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Playback.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Playback.swift @@ -68,10 +68,6 @@ extension AudioPlayerViewModel { } } - func togglePlayPause() { - togglePlayStop() - } - func seek(to time: TimeInterval) { guard canPlay else { return } let targetTime = snappedTimelineTime(time) @@ -160,21 +156,19 @@ extension AudioPlayerViewModel { func setClickVolume(_ volume: Float) { performUndoableEdit("Change Click Volume") { - applyClickVolume(volume, shouldPersist: false) + applyClickVolume(volume) } } func resetClickVolumeToDefault() { performUndoableEdit("Reset Click Volume") { - applyClickVolume(AppSliderDefaults.clickVolume, shouldPersist: false) + applyClickVolume(AppSliderDefaults.clickVolume) } } - func applyClickVolume(_ volume: Float, shouldPersist: Bool) { + func applyClickVolume(_ volume: Float) { clickVolume = clampedVolume(volume) playbackEngine.setClickVolume(clickVolume) - guard shouldPersist else { return } - UserDefaults.standard.set(clickVolume, forKey: "metronome.volume") } func setMainTrackVolume(_ volume: Float) { diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Stems.swift b/JammLab/ViewModels/AudioPlayerViewModel+Stems.swift index 7dfc132..1d0a147 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Stems.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Stems.swift @@ -96,13 +96,6 @@ extension AudioPlayerViewModel { return false } - func retryStemSeparation() { - let method = StemSeparationMethod.method(forID: stemCacheMetadata?.separationMethodID) - ?? StemSeparationMethod.method(forModelName: stemCacheMetadata?.modelName ?? "") - ?? .defaultValue - separateStems(method: method) - } - func setPlaybackMode(_ mode: PlaybackMode) { performUndoableEdit("Change Playback Mode") { switchPlaybackMode(mode, preservedTime: currentTime, errorPrefix: "Playback mode switch failed") diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift b/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift index 6c0ee16..676d370 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift @@ -129,11 +129,6 @@ extension AudioPlayerViewModel { applyTempoMapToPlaybackEngine() } - func loopRegionContains(_ time: TimeInterval) -> Bool { - time >= loopRegion.start && time <= loopRegion.end - } - - func snappedTimelineTime(_ time: TimeInterval) -> TimeInterval { let clampedTime = max(0, min(time, duration)) guard diff --git a/JammLab/ViewModels/AudioPlayerViewModel.swift b/JammLab/ViewModels/AudioPlayerViewModel.swift index 6844e72..62ac003 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel.swift @@ -75,7 +75,7 @@ final class AudioPlayerViewModel: ObservableObject { @Published var stemNotationTrackCollapsed: [StemType: Bool] = [:] @Published var visibleNotationPartIDs: Set = [.main] @Published var mainTrackVolume: Float = AppSliderDefaults.mainTrackVolume - @Published var clickVolume: Float = AudioPlayerViewModel.restoredClickVolume() + @Published var clickVolume: Float = AppSliderDefaults.clickVolume @Published var undoStateRevision = 0 @Published var isProjectModified = false @Published var errorMessage: String? @@ -120,16 +120,6 @@ final class AudioPlayerViewModel: ObservableObject { var userTimelineVisibleRange: ClosedRange = 0...0 var lastSavedProjectState: ProjectPersistedEditableState? - private static func restoredClickVolume() -> Float { - let key = "metronome.volume" - - guard UserDefaults.standard.object(forKey: key) != nil else { - return AppSliderDefaults.clickVolume - } - - return min(1, max(0, UserDefaults.standard.float(forKey: key))) - } - nonisolated private static func defaultSandboxDetection() -> Bool { ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil } @@ -212,6 +202,7 @@ final class AudioPlayerViewModel: ObservableObject { self.notationNoteAuditioner = notationNoteAuditioner ?? SamplerNotationNoteAuditioner() self.recentProjectsStore = recentProjectsStore ?? .shared self.isSandboxed = isSandboxed + self.clickVolume = appSettingsStore.restoredClickVolume() self.playbackEngine.setClickVolume(clickVolume) self.playbackEngine.setMainVolume(mainTrackVolume) self.playbackEngine.setClickSettings(beatGridSettings) diff --git a/JammLabTests/AppSettingsStoreTests.swift b/JammLabTests/AppSettingsStoreTests.swift index 825ce1b..df483f5 100644 --- a/JammLabTests/AppSettingsStoreTests.swift +++ b/JammLabTests/AppSettingsStoreTests.swift @@ -2,6 +2,19 @@ import XCTest @testable import JammLab final class AppSettingsStoreTests: XCTestCase { + func testAppSettingsStoreDefaultsAndClampsRestoredClickVolume() throws { + let defaults = try temporaryUserDefaults() + let store = AppSettingsStore(defaults: defaults) + + XCTAssertEqual(store.restoredClickVolume(), AppSliderDefaults.clickVolume) + + defaults.set(1.5, forKey: AppSettingsStore.clickVolumeKey) + XCTAssertEqual(store.restoredClickVolume(), 1) + + defaults.set(-0.5, forKey: AppSettingsStore.clickVolumeKey) + XCTAssertEqual(store.restoredClickVolume(), 0) + } + func testAppSettingsStoreDefaultsAndPersistsStemBackendComputeMode() throws { let defaults = try temporaryUserDefaults() let store = AppSettingsStore(defaults: defaults) diff --git a/JammLabTests/MultiTrackAudioPlayerConversionTests.swift b/JammLabTests/MultiTrackAudioPlayerConversionTests.swift new file mode 100644 index 0000000..8ac2b76 --- /dev/null +++ b/JammLabTests/MultiTrackAudioPlayerConversionTests.swift @@ -0,0 +1,57 @@ +import AVFoundation +import XCTest +@testable import JammLab + +final class MultiTrackAudioPlayerConversionTests: XCTestCase { + func testDecodeConvertsIntegerPCMWithoutTruncatingSamples() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let url = directory.appendingPathComponent("integer-pcm.caf") + try writeIntegerPCMFile(to: url, duration: 0.5) + + let file = try AVAudioFile(forReading: url) + let outputFormat = try XCTUnwrap(AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: file.processingFormat.sampleRate, + channels: file.processingFormat.channelCount, + interleaved: false + )) + let buffer = try AudioFileBufferDecoder.decode(file: file, to: outputFormat) + let samples = try XCTUnwrap(buffer.floatChannelData?[0]) + + XCTAssertEqual(buffer.frameLength, AVAudioFrameCount(44_100 * 0.5)) + XCTAssertTrue((0.. 0.01 }) + } + + private func writeIntegerPCMFile(to url: URL, duration: TimeInterval) throws { + let sampleRate = 44_100.0 + let frameCount = AVAudioFrameCount(sampleRate * duration) + let format = try XCTUnwrap(AVAudioFormat( + commonFormat: .pcmFormatInt16, + sampleRate: sampleRate, + channels: 1, + interleaved: false + )) + let file = try AVAudioFile( + forWriting: url, + settings: format.settings, + commonFormat: .pcmFormatInt16, + interleaved: false + ) + let buffer = try XCTUnwrap(AVAudioPCMBuffer( + pcmFormat: file.processingFormat, + frameCapacity: frameCount + )) + buffer.frameLength = frameCount + + let samples = try XCTUnwrap(buffer.int16ChannelData?[0]) + for frame in 0.. ScoreMeasure { + let attributes = MeasureAttributes( + keySignature: .cMajor, + timeSignature: TimeSignature(beatsPerBar: 4, beatUnit: 4), + clef: .treble + ) + let rest = NotationMeasureItem( + kind: .rest, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 4, + displayDuration: NotationDuration(denominator: 1) + ) + return ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: attributes, + notationItems: [rest] + ) + } +} diff --git a/JammLabTests/TestSupport.swift b/JammLabTests/TestSupport.swift index 2294866..0faf8b7 100644 --- a/JammLabTests/TestSupport.swift +++ b/JammLabTests/TestSupport.swift @@ -3,6 +3,31 @@ import XCTest @testable import JammLab extension XCTestCase { + @MainActor + func waitForMainActorCondition( + timeout: TimeInterval = 2, + pollIntervalNanoseconds: UInt64 = 20_000_000, + condition: @escaping () -> Bool + ) async -> Bool { + let clock = ContinuousClock() + let timeoutMilliseconds = Int64(max(0, timeout) * 1_000) + let deadline = clock.now.advanced(by: .milliseconds(timeoutMilliseconds)) + while clock.now < deadline { + guard !Task.isCancelled else { return false } + if condition() { + return true + } + do { + try await clock.sleep(for: .nanoseconds(Int64(pollIntervalNanoseconds))) + } catch is CancellationError { + return false + } catch { + return false + } + } + return condition() + } + func temporaryDirectory() -> URL { FileManager.default.temporaryDirectory .appendingPathComponent("JammLabTests", isDirectory: true) diff --git a/JammLabTests/TunerInputServiceTestDoubles.swift b/JammLabTests/TunerInputServiceTestDoubles.swift index 9fe00cc..46ed9de 100644 --- a/JammLabTests/TunerInputServiceTestDoubles.swift +++ b/JammLabTests/TunerInputServiceTestDoubles.swift @@ -217,14 +217,7 @@ extension XCTestCase { timeout: TimeInterval = 2, condition: @escaping () -> Bool ) async -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if condition() { - return true - } - try? await Task.sleep(nanoseconds: 20_000_000) - } - return condition() + await waitForMainActorCondition(timeout: timeout, condition: condition) } func makeTunerInt16Buffer(samples: [Int16], sampleRate: Double = 44_100) throws -> AVAudioPCMBuffer { diff --git a/JammLabTests/ViewModelResetTests.swift b/JammLabTests/ViewModelResetTests.swift index 1ea76f9..897e01a 100644 --- a/JammLabTests/ViewModelResetTests.swift +++ b/JammLabTests/ViewModelResetTests.swift @@ -4,18 +4,12 @@ import XCTest final class ViewModelResetTests: XCTestCase { @MainActor func testViewModelResetMethodsUseSliderDefaults() throws { - let clickVolumeKey = "metronome.volume" - let originalClickVolumeValue = UserDefaults.standard.object(forKey: clickVolumeKey) - defer { - if let originalClickVolumeValue { - UserDefaults.standard.set(originalClickVolumeValue, forKey: clickVolumeKey) - } else { - UserDefaults.standard.removeObject(forKey: clickVolumeKey) - } - } - let engine = MockPlaybackEngine() - let viewModel = AudioPlayerViewModel(playbackEngine: engine) + let settingsStore = AppSettingsStore(defaults: try temporaryUserDefaults()) + let viewModel = AudioPlayerViewModel( + playbackEngine: engine, + appSettingsStore: settingsStore + ) XCTAssertEqual(try XCTUnwrap(viewModel.tempoBPM), AppDefaults.defaultTempoBPM, accuracy: 0.0001) XCTAssertEqual(try XCTUnwrap(viewModel.beatGridSettings.bpm), AppDefaults.defaultTempoBPM, accuracy: 0.0001) @@ -44,11 +38,6 @@ final class ViewModelResetTests: XCTestCase { XCTAssertEqual(viewModel.stemMixState.item(for: .drums).volume, 0.4, accuracy: 0.0001) XCTAssertTrue(viewModel.stemMixState.item(for: .vocals).isMuted) XCTAssertTrue(viewModel.stemMixState.item(for: .drums).isSoloed) - if let originalClickVolumeValue { - XCTAssertEqual(UserDefaults.standard.object(forKey: clickVolumeKey) as? Float, originalClickVolumeValue as? Float) - } else { - XCTAssertNil(UserDefaults.standard.object(forKey: clickVolumeKey)) - } XCTAssertEqual(engine.playbackRate, AppSliderDefaults.playbackRate, accuracy: 0.0001) XCTAssertEqual(engine.pitchShiftSemitones, AppSliderDefaults.pitchShiftSemitones, accuracy: 0.0001) XCTAssertEqual(engine.mainVolume, AppSliderDefaults.mainTrackVolume, accuracy: 0.0001) @@ -57,19 +46,13 @@ final class ViewModelResetTests: XCTestCase { @MainActor func testViewModelNewProjectResetsPlaybackControlsAndUnloadsEngine() throws { - let clickVolumeKey = "metronome.volume" - let originalClickVolumeValue = UserDefaults.standard.object(forKey: clickVolumeKey) - defer { - if let originalClickVolumeValue { - UserDefaults.standard.set(originalClickVolumeValue, forKey: clickVolumeKey) - } else { - UserDefaults.standard.removeObject(forKey: clickVolumeKey) - } - } - let engine = MockPlaybackEngine() engine.isLoaded = true - let viewModel = AudioPlayerViewModel(playbackEngine: engine) + let settingsStore = AppSettingsStore(defaults: try temporaryUserDefaults()) + let viewModel = AudioPlayerViewModel( + playbackEngine: engine, + appSettingsStore: settingsStore + ) viewModel.setPlaybackRate(0.5) viewModel.setPitchShift(semitones: 7) diff --git a/JammLabTests/ViewModelSettingsTests.swift b/JammLabTests/ViewModelSettingsTests.swift index 5b2d925..aad6dd9 100644 --- a/JammLabTests/ViewModelSettingsTests.swift +++ b/JammLabTests/ViewModelSettingsTests.swift @@ -2,6 +2,21 @@ import XCTest @testable import JammLab final class ViewModelSettingsTests: XCTestCase { + @MainActor + func testViewModelRestoresClickVolumeFromInjectedSettingsStore() throws { + let defaults = try temporaryUserDefaults() + defaults.set(0.42, forKey: AppSettingsStore.clickVolumeKey) + let engine = MockPlaybackEngine() + + let viewModel = AudioPlayerViewModel( + playbackEngine: engine, + appSettingsStore: AppSettingsStore(defaults: defaults) + ) + + XCTAssertEqual(viewModel.clickVolume, 0.42, accuracy: 0.0001) + XCTAssertEqual(engine.clickVolume, 0.42, accuracy: 0.0001) + } + @MainActor func testViewModelSetTimelineVisibleRangeClampsWithoutAudio() { let viewModel = AudioPlayerViewModel(playbackEngine: MockPlaybackEngine()) diff --git a/JammLabTests/ViewModelStemSeparationCancellationTests.swift b/JammLabTests/ViewModelStemSeparationCancellationTests.swift index f51e69b..0328157 100644 --- a/JammLabTests/ViewModelStemSeparationCancellationTests.swift +++ b/JammLabTests/ViewModelStemSeparationCancellationTests.swift @@ -32,11 +32,13 @@ final class ViewModelStemSeparationCancellationTests: XCTestCase { try viewModel.loadImportedAudio(media) viewModel.separateStems() - try await Task.sleep(nanoseconds: 100_000_000) viewModel.cancelStemSeparation() - try await Task.sleep(nanoseconds: 700_000_000) + let didFinishCancellation = await waitForMainActorCondition { + viewModel.stemSeparationTask == nil + } + XCTAssertTrue(didFinishCancellation) if case .cancelled = viewModel.stemSeparationState.phase { } else { XCTFail("Expected cancelled stem separation phase, got \(viewModel.stemSeparationState.phase)") @@ -78,7 +80,6 @@ final class ViewModelStemSeparationCancellationTests: XCTestCase { try viewModel.loadImportedAudio(media) viewModel.separateStems() - try await Task.sleep(nanoseconds: 100_000_000) let firstRunID = try XCTUnwrap(viewModel.stemSeparationRunID) viewModel.cancelStemSeparation() @@ -89,18 +90,23 @@ final class ViewModelStemSeparationCancellationTests: XCTestCase { XCTAssertEqual(viewModel.stemSeparationState.phase, .processing) XCTAssertEqual(viewModel.stemSeparationState.status, "Cancelling stem separation") - try await Task.sleep(nanoseconds: 700_000_000) + let didFinishFirstCancellation = await waitForMainActorCondition { + viewModel.stemSeparationTask == nil + } + XCTAssertTrue(didFinishFirstCancellation) XCTAssertNil(viewModel.stemSeparationTask) XCTAssertNil(viewModel.stemSeparationRunID) viewModel.separateStems() - try await Task.sleep(nanoseconds: 100_000_000) let secondRunID = try XCTUnwrap(viewModel.stemSeparationRunID) XCTAssertNotEqual(secondRunID, firstRunID) viewModel.cancelStemSeparation() - try await Task.sleep(nanoseconds: 700_000_000) + let didFinishSecondCancellation = await waitForMainActorCondition { + viewModel.stemSeparationTask == nil + } + XCTAssertTrue(didFinishSecondCancellation) XCTAssertEqual(viewModel.stemSeparationState.phase, .cancelled) XCTAssertEqual(viewModel.stemSeparationState.status, "Stem separation cancelled") XCTAssertNil(viewModel.stemSeparationTask) From 6675fa9a7b99adb5aa8be5e0ee6ae86c4651ab61 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Wed, 15 Jul 2026 12:50:35 +0300 Subject: [PATCH 11/12] feat(notation): add dotted note and rest durations --- CHANGELOG.md | 1 + JammLab/ContentView.swift | 4 +- JammLab/DesignSystem/AppTheme.swift | 5 +- JammLab/Models/NotationDurationEditor.swift | 149 ++++----- JammLab/Models/NotationScoreModels.swift | 109 ++++++- JammLab/Services/NotationExportService.swift | 13 +- .../NotationNotePlacementResolver.swift | 53 +--- JammLab/Services/NotationSMuFL.swift | 55 ++++ JammLab/Utilities/AppHotkey.swift | 13 + .../AudioPlayerViewModel+Notation.swift | 78 +++-- .../AudioPlayerViewModel+Project.swift | 1 + JammLab/ViewModels/AudioPlayerViewModel.swift | 1 + JammLab/Views/Components/AppControls.swift | 116 +++++-- JammLab/Views/MainWorkspacePanels.swift | 2 + JammLab/Views/NotationTrackView.swift | 59 +++- JammLab/Views/NotationWindowView.swift | 16 +- JammLab/Views/WaveformTimelineView.swift | 26 +- ...tkeyNotationDurationEventFilterTests.swift | 26 ++ .../AppHotkeyNotationMappingTests.swift | 18 ++ .../NotationDurationEditorTests.swift | 169 ++++++++++- .../NotationMusicXMLRestExportTests.swift | 70 +++++ JammLabTests/NotationPrimitivesTests.swift | 84 ++++- .../NotationTrackLayoutItemsTests.swift | 1 + JammLabTests/TimelineProjectLogicTests.swift | 24 +- ...wModelNotationDurationSelectionTests.swift | 286 +++++++++++++++++- JammLabTests/ViewModelResetTests.swift | 4 + 26 files changed, 1148 insertions(+), 235 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aece4e2..6086c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased +- Added persistent augmentation-dot entry and editing for Notation notes and rests, with Leland glyphs, keyboard shortcuts, and MusicXML export. - Added sixteenth-note and rest entry with `3`/`Num3` duration shortcuts, plus downward stems for notes above the middle staff line. - Added Notation note-entry mode for placing pitched notes with Leland-rendered note glyphs, note selection preview, cross-rest insertion, rest recomposition, and MusicXML export. - Expanded Notation entry to ledger-line pitches from G3 through D6 and added rest-entry controls for inserting selected rest durations. diff --git a/JammLab/ContentView.swift b/JammLab/ContentView.swift index fca6d18..454f3f1 100644 --- a/JammLab/ContentView.swift +++ b/JammLab/ContentView.swift @@ -186,7 +186,7 @@ struct ContentView: View { hotkeys.remove(.editHarmonyAtSelectedNotationItem) } if !viewModel.canChangeNotationDuration { - hotkeys.subtract(AppHotkey.notationDurationHotkeys) + hotkeys.subtract(AppHotkey.notationDurationEditingHotkeys) } if !viewModel.canChangeSelectedNotationNotePitch(byStaffPositionDelta: -1) { hotkeys.remove(.moveSelectedNotationNotePitchUp) @@ -264,6 +264,8 @@ struct ContentView: View { guard let denominator = hotkey.notationDurationDenominator else { return false } viewModel.setNotationDurationDenominator(denominator) return true + case .toggleNotationDurationDot: + return viewModel.toggleNotationDurationDot() } } diff --git a/JammLab/DesignSystem/AppTheme.swift b/JammLab/DesignSystem/AppTheme.swift index 28198ac..4add6d4 100644 --- a/JammLab/DesignSystem/AppTheme.swift +++ b/JammLab/DesignSystem/AppTheme.swift @@ -196,9 +196,12 @@ enum AppTheme { static let buttonHeight: CGFloat = 28 static let notationDurationButtonWidth: CGFloat = 27 static let notationDurationButtonSpacing: CGFloat = AppTheme.Spacing.xxxs - static let notationEntryModeButtonWidth: CGFloat = 32 + static let notationModeButtonWidth: CGFloat = 32 static let notationDurationControlHeight: CGFloat = controlHeight static let notationDurationGlyphSize: CGFloat = 20 + static let notationAugmentationDotNoteOffsetX: CGFloat = -3 + static let notationAugmentationDotGlyphOffsetX: CGFloat = 7 + static let notationAugmentationDotGlyphOffsetY: CGFloat = 5 static let transportBarMinHeight: CGFloat = 70 static let abletonNumberFieldHeight: CGFloat = 24 static let jammValueSliderWidth: CGFloat = 70 diff --git a/JammLab/Models/NotationDurationEditor.swift b/JammLab/Models/NotationDurationEditor.swift index a7ca3f9..ab6f97f 100644 --- a/JammLab/Models/NotationDurationEditor.swift +++ b/JammLab/Models/NotationDurationEditor.swift @@ -1,105 +1,84 @@ import Foundation enum NotationDurationEditor { - static func replacementSuffix( + struct Replacement: Equatable { + let items: [NotationMeasureItem] + let selectedItem: NotationMeasureItem + } + + static func replacement( in measure: ScoreMeasure, selectedItem: NotationMeasureItem, selectedDuration: NotationDuration - ) -> [NotationMeasureItem]? { - var items: [NotationMeasureItem] = [] - var cursor = selectedItem.offsetInQuarterNotes + ) -> Replacement? { + let orderedItems = measure.notationItems.sorted(by: itemSort) + guard let selectedIndex = orderedItems.firstIndex(where: { + $0.id == selectedItem.id + && $0.partID == selectedItem.partID + && abs($0.offsetInQuarterNotes - selectedItem.offsetInQuarterNotes) + < NotationMeasureTiming.timelineTolerance + }) else { + return nil + } + + let selected = orderedItems[selectedIndex] + let startOffset = selected.offsetInQuarterNotes + let sourceEnd = startOffset + selected.durationInQuarterNotes + let targetEnd = startOffset + selectedDuration.durationInQuarterNotes let measureLength = NotationMeasureTiming.quarterLength(for: measure.attributes.timeSignature) - var remaining = measureLength - cursor - guard remaining > NotationMeasureTiming.timelineTolerance else { return nil } + guard targetEnd <= measureLength + NotationMeasureTiming.timelineTolerance, + selected.kind == .rest || selected.pitch != nil + else { + return nil + } - let selectedLength = selectedDuration.durationInQuarterNotes - if selectedItem.kind == .note { - guard selectedLength <= remaining + NotationMeasureTiming.timelineTolerance, - let pitch = selectedItem.pitch - else { - return nil - } + var consumedEnd = sourceEnd + var suffixIndex = orderedItems.index(after: selectedIndex) + while suffixIndex < orderedItems.endIndex { + let item = orderedItems[suffixIndex] + guard item.kind == .rest, + item.partID == selected.partID, + abs(item.offsetInQuarterNotes - consumedEnd) < NotationMeasureTiming.timelineTolerance + else { break } - let duration = min(selectedLength, remaining) - items.append(NotationMeasureItem( - id: selectedItem.id, - partID: selectedItem.partID, - kind: .note, - pitch: pitch, - measureNumber: measure.number, - measureStartTime: measure.startTime, - offsetInQuarterNotes: cursor, - durationInQuarterNotes: duration, - displayDuration: selectedDuration - )) - cursor += duration - remaining -= duration - items.append(contentsOf: fillerItems( - in: measure, - partID: selectedItem.partID, - startOffset: cursor, - remaining: remaining - )) - return items + consumedEnd = item.offsetInQuarterNotes + item.durationInQuarterNotes + suffixIndex = orderedItems.index(after: suffixIndex) } - let selectedCount = min( - 2, - Int(floor((remaining + NotationMeasureTiming.timelineTolerance) / selectedLength)) - ) - if selectedCount > 0 { - for _ in 0.. [NotationMeasureItem] { - NotationRestItemFactory.restItems( + let replacement = NotationMeasureItem( + id: selected.isSynthesized ? UUID().uuidString : selected.id, + partID: selected.partID, + kind: selected.kind, + pitch: selected.pitch, measureNumber: measure.number, measureStartTime: measure.startTime, - startOffset: startOffset, - remaining: remaining, - partID: partID + offsetInQuarterNotes: startOffset, + durationInQuarterNotes: selectedDuration.durationInQuarterNotes, + displayDuration: selectedDuration ) + let fillerEnd = max(sourceEnd, consumedEnd) + let fillerItems = NotationRestItemFactory.metricAwareRestItems( + in: measure, + partID: selected.partID, + startOffset: targetEnd, + remaining: fillerEnd - targetEnd + ) + + let items = Array(orderedItems[.. NotationDuration? { - NotationRestItemFactory.greedySegments(startOffset: 0, remaining: remaining).first?.displayDuration + private static func itemSort(_ lhs: NotationMeasureItem, _ rhs: NotationMeasureItem) -> Bool { + if abs(lhs.offsetInQuarterNotes - rhs.offsetInQuarterNotes) > NotationMeasureTiming.timelineTolerance { + return lhs.offsetInQuarterNotes < rhs.offsetInQuarterNotes + } + return lhs.id < rhs.id } } diff --git a/JammLab/Models/NotationScoreModels.swift b/JammLab/Models/NotationScoreModels.swift index bc96de8..15f7d30 100644 --- a/JammLab/Models/NotationScoreModels.swift +++ b/JammLab/Models/NotationScoreModels.swift @@ -324,21 +324,30 @@ enum HarmonyNavigationDirection: Equatable { } struct NotationDuration: Codable, Equatable, Identifiable { - static let allowedDenominators = [1, 2, 4, 8, 16] + static let entryDenominators = [1, 2, 4, 8, 16] + static let restDecompositionDenominators = entryDenominators + [32] static let defaultDenominator = 1 var denominator: Int + var isDotted: Bool - init(denominator: Int = Self.defaultDenominator) { - self.denominator = Self.normalizedDenominator(denominator) + init(denominator: Int = Self.defaultDenominator, isDotted: Bool = false) { + self.denominator = Self.restDecompositionDenominators.contains(denominator) + ? denominator + : Self.normalizedDenominator(denominator) + self.isDotted = isDotted } - var id: Int { denominator } + var id: String { "\(denominator)-\(isDotted ? 1 : 0)" } - var durationInQuarterNotes: Double { + var baseDurationInQuarterNotes: Double { 4.0 / Double(denominator) } + var durationInQuarterNotes: Double { + baseDurationInQuarterNotes * (isDotted ? 1.5 : 1) + } + var displayName: String { switch denominator { case 1: @@ -351,24 +360,51 @@ struct NotationDuration: Codable, Equatable, Identifiable { return "eighth" case 16: return "16th" + case 32: + return "32nd" default: return "duration" } } + var humanDisplayName: String { + isDotted ? "dotted \(displayName)" : displayName + } + var pluralDisplayName: String { - "\(displayName) notes" + "\(humanDisplayName) notes" } var capitalizedDisplayName: String { - displayName.prefix(1).uppercased() + displayName.dropFirst() + humanDisplayName.prefix(1).uppercased() + humanDisplayName.dropFirst() } static func normalizedDenominator(_ denominator: Int) -> Int { - allowedDenominators.min { lhs, rhs in + entryDenominators.min { lhs, rhs in abs(lhs - denominator) < abs(rhs - denominator) } ?? defaultDenominator } + + private enum CodingKeys: String, CodingKey { + case denominator + case isDotted + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + denominator: try container.decodeIfPresent(Int.self, forKey: .denominator) ?? Self.defaultDenominator, + isDotted: try container.decodeIfPresent(Bool.self, forKey: .isDotted) ?? false + ) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(denominator, forKey: .denominator) + if isDotted { + try container.encode(true, forKey: .isDotted) + } + } } struct NotationMeasureItem: Identifiable, Codable, Equatable { @@ -596,7 +632,7 @@ enum NotationRestItemFactory { var segments: [Segment] = [] var cursor = startOffset var rest = remaining - for denominator in NotationDuration.allowedDenominators { + for denominator in NotationDuration.restDecompositionDenominators { let duration = NotationDuration(denominator: denominator) let length = duration.durationInQuarterNotes while rest >= length - tolerance { @@ -612,7 +648,7 @@ enum NotationRestItemFactory { } if includeTail, rest > tolerance { - let duration = NotationDuration(denominator: NotationDuration.allowedDenominators.last ?? 8) + let duration = NotationDuration(denominator: NotationDuration.entryDenominators.last ?? 8) segments.append(Segment( offsetInQuarterNotes: cursor, durationInQuarterNotes: rest, @@ -649,6 +685,59 @@ enum NotationRestItemFactory { } } + static func metricAwareRestItems( + in measure: ScoreMeasure, + partID: NotationPartID, + startOffset: Double, + remaining: Double + ) -> [NotationMeasureItem] { + guard remaining > NotationMeasureTiming.timelineTolerance else { return [] } + + var output: [NotationMeasureItem] = [] + var cursor = startOffset + var rest = remaining + let nextQuarterBoundary = floor(cursor + NotationMeasureTiming.timelineTolerance) + 1 + let distanceToQuarterBoundary = nextQuarterBoundary - cursor + let isOnQuarterBoundary = abs(cursor.rounded() - cursor) <= NotationMeasureTiming.timelineTolerance + + if !isOnQuarterBoundary, + distanceToQuarterBoundary > NotationMeasureTiming.timelineTolerance, + distanceToQuarterBoundary < rest - NotationMeasureTiming.timelineTolerance, + let duration = exactDuration(for: distanceToQuarterBoundary) { + output.append(restItem( + partID: partID, + measureNumber: measure.number, + measureStartTime: measure.startTime, + offsetInQuarterNotes: cursor, + durationInQuarterNotes: distanceToQuarterBoundary, + displayDuration: duration + )) + cursor += distanceToQuarterBoundary + rest -= distanceToQuarterBoundary + } + + output.append(contentsOf: restItems( + measureNumber: measure.number, + measureStartTime: measure.startTime, + startOffset: cursor, + remaining: rest, + partID: partID + )) + return output + } + + private static func exactDuration(for length: Double) -> NotationDuration? { + restDecompositionDurations.first { + abs($0.durationInQuarterNotes - length) <= NotationMeasureTiming.timelineTolerance + } + } + + private static var restDecompositionDurations: [NotationDuration] { + NotationDuration.restDecompositionDenominators.map { + NotationDuration(denominator: $0) + } + } + static func restItem( id: String? = nil, partID: NotationPartID = .main, diff --git a/JammLab/Services/NotationExportService.swift b/JammLab/Services/NotationExportService.swift index 4d37285..e895fa2 100644 --- a/JammLab/Services/NotationExportService.swift +++ b/JammLab/Services/NotationExportService.swift @@ -445,9 +445,7 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { let note = element("note") let isMeasureRest = isOnlyItem && item.isSynthesized && item.offsetInQuarterNotes == 0 note.addChild(element("rest", attributes: isMeasureRest ? ["measure": "yes"] : [:])) - note.addChild(element("duration", stringValue: "\(durationValue(forQuarterOffset: item.durationInQuarterNotes))")) - note.addChild(element("voice", stringValue: "1")) - note.addChild(element("type", stringValue: item.displayDuration.displayName)) + appendDurationElements(to: note, for: item) return note } @@ -461,10 +459,17 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { } pitchElement.addChild(element("octave", stringValue: "\(pitch.octave)")) note.addChild(pitchElement) + appendDurationElements(to: note, for: item) + return note + } + + private func appendDurationElements(to note: XMLElement, for item: NotationMeasureItem) { note.addChild(element("duration", stringValue: "\(durationValue(forQuarterOffset: item.durationInQuarterNotes))")) note.addChild(element("voice", stringValue: "1")) note.addChild(element("type", stringValue: item.displayDuration.displayName)) - return note + if item.displayDuration.isDotted { + note.addChild(element("dot")) + } } private func durationValue(forQuarterOffset offset: Double) -> Int { diff --git a/JammLab/Services/NotationNotePlacementResolver.swift b/JammLab/Services/NotationNotePlacementResolver.swift index e477ae8..5527ae9 100644 --- a/JammLab/Services/NotationNotePlacementResolver.swift +++ b/JammLab/Services/NotationNotePlacementResolver.swift @@ -456,8 +456,8 @@ enum NotationEntryRecomposer { if !didInsertItem { output.append(insertedItem) let insertedItemEnd = insertedItem.offsetInQuarterNotes + insertedItem.durationInQuarterNotes - output.append(contentsOf: metricAwareFillerNotationItems( - measure: measure, + output.append(contentsOf: NotationRestItemFactory.metricAwareRestItems( + in: measure, partID: insertedItem.partID, startOffset: insertedItemEnd, remaining: restSpan.endOffsetInQuarterNotes - insertedItemEnd @@ -473,55 +473,6 @@ enum NotationEntryRecomposer { return output } - private static func metricAwareFillerNotationItems( - measure: ScoreMeasure, - partID: NotationPartID, - startOffset: Double, - remaining: Double - ) -> [NotationMeasureItem] { - guard remaining > NotationMeasureTiming.timelineTolerance else { return [] } - - var output: [NotationMeasureItem] = [] - var cursor = startOffset - var rest = remaining - let nextQuarterBoundary = floor(cursor + NotationMeasureTiming.timelineTolerance) + 1 - let distanceToQuarterBoundary = nextQuarterBoundary - cursor - let isOnQuarterBoundary = abs(cursor.rounded() - cursor) <= NotationMeasureTiming.timelineTolerance - - if !isOnQuarterBoundary, - distanceToQuarterBoundary > NotationMeasureTiming.timelineTolerance, - distanceToQuarterBoundary < rest - NotationMeasureTiming.timelineTolerance, - let duration = exactNotationDuration(for: distanceToQuarterBoundary) { - output.append(NotationRestItemFactory.restItem( - partID: partID, - measureNumber: measure.number, - measureStartTime: measure.startTime, - offsetInQuarterNotes: cursor, - durationInQuarterNotes: distanceToQuarterBoundary, - displayDuration: duration - )) - cursor += distanceToQuarterBoundary - rest -= distanceToQuarterBoundary - } - - output.append(contentsOf: NotationRestItemFactory.restItems( - measureNumber: measure.number, - measureStartTime: measure.startTime, - startOffset: cursor, - remaining: rest, - partID: partID - )) - return output - } - - private static func exactNotationDuration(for length: Double) -> NotationDuration? { - NotationDuration.allowedDenominators - .map(NotationDuration.init(denominator:)) - .first { - abs($0.durationInQuarterNotes - length) <= NotationMeasureTiming.timelineTolerance - } - } - private static func notationItemSort(_ lhs: NotationMeasureItem, _ rhs: NotationMeasureItem) -> Bool { if abs(lhs.offsetInQuarterNotes - rhs.offsetInQuarterNotes) > NotationMeasureTiming.timelineTolerance { return lhs.offsetInQuarterNotes < rhs.offsetInQuarterNotes diff --git a/JammLab/Services/NotationSMuFL.swift b/JammLab/Services/NotationSMuFL.swift index d16fab1..89493b0 100644 --- a/JammLab/Services/NotationSMuFL.swift +++ b/JammLab/Services/NotationSMuFL.swift @@ -8,6 +8,7 @@ enum NotationSMuFLSymbol: Equatable { case restQuarter case rest8th case rest16th + case rest32nd init?(duration: NotationDuration) { switch duration.denominator { @@ -21,6 +22,8 @@ enum NotationSMuFLSymbol: Equatable { self = .rest8th case 16: self = .rest16th + case 32: + self = .rest32nd default: return nil } @@ -38,6 +41,8 @@ enum NotationSMuFLSymbol: Equatable { return 0xE4E6 case .rest16th: return 0xE4E7 + case .rest32nd: + return 0xE4E8 } } @@ -47,6 +52,49 @@ enum NotationSMuFLSymbol: Equatable { } } +enum NotationAugmentationDotSymbol: Equatable { + case augmentationDot + + var codepoint: UInt32 { 0xE1E7 } + + var glyph: String { + guard let scalar = UnicodeScalar(codepoint) else { return "" } + return String(Character(scalar)) + } +} + +enum NotationAugmentationDotLayout { + static func noteDotStaffPosition(for noteStaffPosition: Int) -> Int { + noteStaffPosition.isMultiple(of: 2) ? noteStaffPosition - 1 : noteStaffPosition + } + + static func noteTarget( + noteX: CGFloat, + noteStaffPosition: Int, + staffTop: CGFloat + ) -> CGPoint { + let spacing = AppTheme.Timeline.notationStaffLineSpacing + return CGPoint( + x: noteX + spacing, + y: NotationNotePlacementResolver.yPosition( + forStaffPosition: noteDotStaffPosition(for: noteStaffPosition), + staffTop: staffTop + ) + ) + } + + static func restTarget(restX: CGFloat, staffTop: CGFloat) -> CGPoint { + let spacing = AppTheme.Timeline.notationStaffLineSpacing + return CGPoint( + x: restX + spacing, + y: NotationNotePlacementResolver.yPosition( + forStaffPosition: 3, + staffTop: staffTop + ) + ) + } +} + enum NotationDurationControlSymbol: Equatable { case whole case half @@ -277,6 +325,13 @@ enum NotationMusicFontRegistry { glyphPath(forCodepoint: symbol.codepoint, fontSize: fontSize) } + static func glyphPath( + for symbol: NotationAugmentationDotSymbol, + fontSize: CGFloat + ) -> NotationSMuFLGlyphPath? { + glyphPath(forCodepoint: symbol.codepoint, fontSize: fontSize) + } + static func glyphPath( for symbol: NotationClefSymbol, fontSize: CGFloat diff --git a/JammLab/Utilities/AppHotkey.swift b/JammLab/Utilities/AppHotkey.swift index df4832a..f5c5207 100644 --- a/JammLab/Utilities/AppHotkey.swift +++ b/JammLab/Utilities/AppHotkey.swift @@ -25,11 +25,16 @@ enum AppHotkey: CaseIterable, Hashable { case setNotationDurationQuarter case setNotationDurationHalf case setNotationDurationWhole + case toggleNotationDurationDot static let notationDurationHotkeys = Set(allCases.filter { $0.notationDurationDenominator != nil }) + static let notationDurationEditingHotkeys = notationDurationHotkeys.union([ + .toggleNotationDurationDot + ]) + // Keep this enum as the single source of truth for keyboard shortcuts. // When adding a new handled hotkey, add a case here with its help metadata // so the Help > Keyboard Shortcuts page stays up to date automatically. @@ -94,6 +99,8 @@ enum AppHotkey: CaseIterable, Hashable { self = .setNotationDurationHalf case 26, 89: self = .setNotationDurationWhole + case 47, 65: + self = .toggleNotationDurationDot case 53: self = .clearNotationMeasureSelection default: @@ -149,6 +156,8 @@ enum AppHotkey: CaseIterable, Hashable { return "6" case .setNotationDurationWhole: return "7" + case .toggleNotationDurationDot: + return ".; Num.; Num," } } @@ -200,6 +209,8 @@ enum AppHotkey: CaseIterable, Hashable { return "Set Half Note Duration" case .setNotationDurationWhole: return "Set Whole Note Duration" + case .toggleNotationDurationDot: + return "Augmentation dot" } } @@ -251,6 +262,8 @@ enum AppHotkey: CaseIterable, Hashable { return "Set notation duration to half notes for the selected notation item." case .setNotationDurationWhole: return "Set notation duration to whole notes for the selected notation item." + case .toggleNotationDurationDot: + return "Toggle duration dot" } } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift index 9578542..6d6616e 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift @@ -97,15 +97,60 @@ extension AudioPlayerViewModel { duration > 0 && (isNotationEntryModeEnabled || canEditSelectedNotationItem) } + var notationDurationIsDotted: Bool { + selectedNotationItemDuration?.isDotted ?? notationEntryDurationIsDotted + } + var canChangeSelectedNotationNotePitch: Bool { canChangeSelectedNotationNotePitch(byStaffPositionDelta: -1) || canChangeSelectedNotationNotePitch(byStaffPositionDelta: 1) } func setNotationDurationDenominator(_ denominator: Int) { - notationDurationDenominator = NotationDuration.normalizedDenominator(denominator) - guard !isNotationEntryModeEnabled else { return } - changeSelectedNotationItemDuration(to: NotationDuration(denominator: notationDurationDenominator)) + let normalizedDenominator = NotationDuration.normalizedDenominator(denominator) + guard !isNotationEntryModeEnabled else { + notationDurationDenominator = normalizedDenominator + return + } + + let targetDuration = NotationDuration( + denominator: normalizedDenominator, + isDotted: notationDurationIsDotted + ) + guard selectedNotationItemDuration != targetDuration else { + notationDurationDenominator = normalizedDenominator + return + } + guard changeSelectedNotationItemDuration(to: targetDuration) else { return } + notationDurationDenominator = normalizedDenominator + } + + @discardableResult + func toggleNotationDurationDot() -> Bool { + if isNotationEntryModeEnabled { + notationEntryDurationIsDotted.toggle() + return true + } + + guard let selection = selectedNotationItem, + let match = notationItemMatch(for: selection) + else { + return false + } + + let targetDuration = NotationDuration( + denominator: match.item.displayDuration.denominator, + isDotted: !match.item.displayDuration.isDotted + ) + guard changeSelectedNotationItemDuration(to: targetDuration) else { return false } + return true + } + + private var selectedNotationItemDuration: NotationDuration? { + guard let selection = selectedNotationItem, + let match = notationItemMatch(for: selection) + else { return nil } + return match.item.displayDuration } var isNotationEntryModeEnabled: Bool { @@ -730,30 +775,29 @@ extension AudioPlayerViewModel { return nil } - private func changeSelectedNotationItemDuration(to selectedDuration: NotationDuration) { + @discardableResult + private func changeSelectedNotationItemDuration(to selectedDuration: NotationDuration) -> Bool { guard let selection = selectedNotationItem, let match = notationItemMatch(for: selection) - else { return } + else { return false } let measure = match.measure - let startOffset = match.item.offsetInQuarterNotes - guard let suffix = NotationDurationEditor.replacementSuffix( + guard let replacement = NotationDurationEditor.replacement( in: measure, selectedItem: match.item, selectedDuration: selectedDuration - ) else { return } + ) else { return false } performUndoableEdit("Change Notation Duration") { - let prefix = measure.notationItems - .filter { $0.offsetInQuarterNotes < startOffset - NotationMeasureTiming.timelineTolerance } - .filter { !$0.isSynthesized } - - replaceNotationMeasureItems(in: measure, with: prefix + suffix) - - if let selected = suffix.first { - selectedNotationItem = NotationItemSelection(measure: measure, item: selected) - } + replaceNotationMeasureItems(in: measure, with: replacement.items) + reselectNotationItem( + inMeasureNumber: measure.number, + measureStartTime: measure.startTime, + itemID: replacement.selectedItem.id, + partID: replacement.selectedItem.partID + ) } + return true } private func harmonySymbolID(at time: TimeInterval) -> HarmonySymbol.ID? { diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift index 6187897..f621a55 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift @@ -313,6 +313,7 @@ extension AudioPlayerViewModel { clearNotationMeasureSelectionAndClipboard() pendingHarmonyEditorRequest = nil notationDurationDenominator = NotationDuration.defaultDenominator + notationEntryDurationIsDotted = false notationEntryMode = nil activeLoopRegionID = nil } diff --git a/JammLab/ViewModels/AudioPlayerViewModel.swift b/JammLab/ViewModels/AudioPlayerViewModel.swift index 62ac003..63db9fa 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel.swift @@ -50,6 +50,7 @@ final class AudioPlayerViewModel: ObservableObject { @Published var selectedNotationItem: NotationItemSelection? @Published var notationMeasureClipboard: NotationMeasureClipboard? @Published var notationDurationDenominator = NotationDuration.defaultDenominator + @Published var notationEntryDurationIsDotted = false @Published var notationItems: [NotationMeasureItem] = [] @Published var notationPartClefs: [NotationPartID: Clef] = [:] @Published var notationEntryMode: NotationEntryMode? diff --git a/JammLab/Views/Components/AppControls.swift b/JammLab/Views/Components/AppControls.swift index 68098ae..57bf58f 100644 --- a/JammLab/Views/Components/AppControls.swift +++ b/JammLab/Views/Components/AppControls.swift @@ -22,7 +22,7 @@ struct NotationDurationControl: View { @Environment(\.appColors) private var appColors private var options: [NotationDurationOption] { - NotationDuration.allowedDenominators + NotationDuration.entryDenominators .reversed() .compactMap(NotationDurationOption.init) } @@ -87,11 +87,9 @@ struct NotationEntryModeButton: View { let mode: NotationEntryMode let isActive: Bool let action: () -> Void - @Environment(\.isEnabled) private var isEnabled - @Environment(\.appColors) private var appColors var body: some View { - Button(action: action) { + NotationSelectableButton(isActive: isActive, action: action) { iconColor in ZStack { switch mode { case .note: @@ -105,17 +103,58 @@ struct NotationEntryModeButton: View { ) } } - .frame( - width: AppTheme.ControlSize.notationEntryModeButtonWidth, - height: AppTheme.ControlSize.notationDurationControlHeight - ) - .background(backgroundColor) - .clipShape(RoundedRectangle(cornerRadius: AppTheme.Radius.small, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: AppTheme.Radius.small, style: .continuous) - .stroke(borderColor, lineWidth: AppTheme.Stroke.thin) - } - .contentShape(Rectangle()) + } + } +} + +struct NotationAugmentationDotButton: View { + let isActive: Bool + let action: () -> Void + + var body: some View { + NotationSelectableButton(isActive: isActive, action: action) { iconColor in + NotationAugmentationDotGlyphView(color: iconColor) + .accessibilityHidden(true) + } + .help(Text(NotationAugmentationDotHelpText.tooltip)) + .accessibilityLabel(NotationAugmentationDotHelpText.accessibilityLabel) + .accessibilityHint(NotationAugmentationDotHelpText.accessibilityHint) + .accessibilityValue(isActive ? "Enabled" : "Disabled") + .accessibilityAddTraits(isActive ? [.isSelected] : []) + } +} + +private struct NotationSelectableButton: View { + let isActive: Bool + let action: () -> Void + private let label: (Color) -> Label + @Environment(\.isEnabled) private var isEnabled + @Environment(\.appColors) private var appColors + + init( + isActive: Bool, + action: @escaping () -> Void, + @ViewBuilder label: @escaping (Color) -> Label + ) { + self.isActive = isActive + self.action = action + self.label = label + } + + var body: some View { + Button(action: action) { + label(iconColor) + .frame( + width: AppTheme.ControlSize.notationModeButtonWidth, + height: AppTheme.ControlSize.notationDurationControlHeight + ) + .background(isActive ? appColors.accent : appColors.statusButtonFill) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.Radius.small, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: AppTheme.Radius.small, style: .continuous) + .stroke(isActive ? appColors.accent : appColors.border, lineWidth: AppTheme.Stroke.thin) + } + .contentShape(Rectangle()) } .buttonStyle(.plain) .opacity(isEnabled ? 1 : 0.45) @@ -125,13 +164,50 @@ struct NotationEntryModeButton: View { guard isEnabled else { return appColors.disabledText } return isActive ? appColors.primaryText : appColors.secondaryText } +} - private var backgroundColor: Color { - isActive ? appColors.accent : appColors.statusButtonFill - } +enum NotationAugmentationDotHelpText { + static let tooltip = [ + "\(AppHotkey.toggleNotationDurationDot.title) (\(AppHotkey.toggleNotationDurationDot.key))", + AppHotkey.toggleNotationDurationDot.detail + ].joined(separator: "\n") + static let accessibilityLabel = AppHotkey.toggleNotationDurationDot.title + static let accessibilityHint = AppHotkey.toggleNotationDurationDot.detail +} - private var borderColor: Color { - isActive ? appColors.accent : appColors.border +private struct NotationAugmentationDotGlyphView: View { + let color: Color + + var body: some View { + Canvas { context, size in + let fontSize = AppTheme.ControlSize.notationDurationGlyphSize + if let notePath = NotationMusicFontRegistry.glyphPath( + for: NotationDurationControlSymbol.quarter, + fontSize: fontSize + ) { + let transform = notePath.centeredTransform(in: size) + .translatedBy( + x: AppTheme.ControlSize.notationAugmentationDotNoteOffsetX, + y: 0 + ) + context.fill(Path(notePath.path).applying(transform), with: .color(color)) + } + + if let dotPath = NotationMusicFontRegistry.glyphPath( + for: NotationAugmentationDotSymbol.augmentationDot, + fontSize: fontSize + ) { + let target = CGPoint( + x: size.width / 2 + AppTheme.ControlSize.notationAugmentationDotGlyphOffsetX, + y: size.height / 2 + AppTheme.ControlSize.notationAugmentationDotGlyphOffsetY + ) + let anchor = CGPoint(x: dotPath.bounds.midX, y: dotPath.bounds.midY) + context.fill( + Path(dotPath.path).applying(dotPath.anchoredTransform(anchor: anchor, target: target)), + with: .color(color) + ) + } + } } } diff --git a/JammLab/Views/MainWorkspacePanels.swift b/JammLab/Views/MainWorkspacePanels.swift index 58fe59c..0921c9d 100644 --- a/JammLab/Views/MainWorkspacePanels.swift +++ b/JammLab/Views/MainWorkspacePanels.swift @@ -179,6 +179,7 @@ extension ContentView { : notationViewportState(availableWidth: notationTrackContentWidth, partID: .main), stemNotationViewports: stemNotationViewports(availableWidth: notationTrackContentWidth), notationDurationDenominator: viewModel.notationDurationDenominator, + notationDurationIsDotted: viewModel.notationDurationIsDotted, canChangeNotationDuration: viewModel.canChangeNotationDuration, notationEntryMode: viewModel.notationEntryMode, isNotationTrackCollapsed: viewModel.isNotationTrackCollapsed, @@ -280,6 +281,7 @@ extension ContentView { notationTrackCollapsedChanged: { viewModel.setNotationTrackCollapsed($0) }, stemNotationTrackCollapsedChanged: { viewModel.setStemNotationTrackCollapsed($0, isCollapsed: $1) }, notationDurationChanged: { viewModel.setNotationDurationDenominator($0) }, + notationDurationDotToggled: { viewModel.toggleNotationDurationDot() }, notationNoteEntryModeToggled: { viewModel.toggleNotationNoteEntryMode() }, notationRestEntryModeToggled: { viewModel.toggleNotationRestEntryMode() }, insertNotationNote: { viewModel.insertNotationNote($0) }, diff --git a/JammLab/Views/NotationTrackView.swift b/JammLab/Views/NotationTrackView.swift index 0b08847..4262561 100644 --- a/JammLab/Views/NotationTrackView.swift +++ b/JammLab/Views/NotationTrackView.swift @@ -318,6 +318,7 @@ struct NotationTrackView: View { } drawRestGlyph( symbol: symbol, + duration: item.notationItem.displayDuration, x: item.x, staffTop: staffTop, color: color, @@ -362,6 +363,7 @@ struct NotationTrackView: View { let symbol = NotationSMuFLSymbol(duration: hoveredRestPlacement.displayDuration) { drawRestGlyph( symbol: symbol, + duration: hoveredRestPlacement.displayDuration, x: hoveredRestPlacement.x, staffTop: staffTop, color: appColors.accent.opacity(0.56), @@ -399,6 +401,18 @@ struct NotationTrackView: View { opacity: opacity, in: &context ) + if duration.isDotted { + drawAugmentationDot( + at: NotationAugmentationDotLayout.noteTarget( + noteX: x, + noteStaffPosition: staffPosition, + staffTop: staffTop + ), + color: color, + opacity: opacity, + in: &context + ) + } } private func drawLedgerLines( @@ -460,6 +474,7 @@ struct NotationTrackView: View { private func drawRestGlyph( symbol: NotationSMuFLSymbol, + duration: NotationDuration, x: CGFloat, staffTop: CGFloat, color: Color, @@ -478,16 +493,42 @@ struct NotationTrackView: View { Path(glyphPath.path).applying(transform), with: .color(color) ) - return + } else { + let text = Text(symbol.glyph) + .font(.custom(NotationMusicFontRegistry.fontName, size: fontSize)) + .foregroundStyle(color) + context.draw( + text, + at: CGPoint(x: x, y: restGlyphY(symbol: symbol, staffTop: staffTop)), + anchor: .center + ) + } + + if duration.isDotted { + drawAugmentationDot( + at: NotationAugmentationDotLayout.restTarget(restX: x, staffTop: staffTop), + color: color, + opacity: 1, + in: &context + ) } + } - let text = Text(symbol.glyph) - .font(.custom(NotationMusicFontRegistry.fontName, size: fontSize)) - .foregroundStyle(color) - context.draw( - text, - at: CGPoint(x: x, y: restGlyphY(symbol: symbol, staffTop: staffTop)), - anchor: .center + private func drawAugmentationDot( + at target: CGPoint, + color: Color, + opacity: Double, + in context: inout GraphicsContext + ) { + let fontSize = AppTheme.Timeline.notationStaffLineSpacing * 3.25 + guard let glyphPath = NotationMusicFontRegistry.glyphPath( + for: NotationAugmentationDotSymbol.augmentationDot, + fontSize: fontSize + ) else { return } + let anchor = CGPoint(x: glyphPath.bounds.midX, y: glyphPath.bounds.midY) + context.fill( + Path(glyphPath.path).applying(glyphPath.anchoredTransform(anchor: anchor, target: target)), + with: .color(color.opacity(opacity)) ) } @@ -502,7 +543,7 @@ struct NotationTrackView: View { return line3Y - spacing * 0.08 case .restQuarter: return line3Y + spacing * 0.06 - case .rest8th, .rest16th: + case .rest8th, .rest16th, .rest32nd: return line3Y - spacing * 0.12 } } diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index dfd77e6..fc6f5eb 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -70,6 +70,13 @@ struct NotationWindowView: View { isEnabled: viewModel.canChangeNotationDuration ) + NotationAugmentationDotButton( + isActive: viewModel.notationDurationIsDotted + ) { + viewModel.toggleNotationDurationDot() + } + .disabled(!viewModel.canChangeNotationDuration) + NotationEntryModeButton( mode: .rest, isActive: viewModel.isNotationRestEntryModeEnabled @@ -185,7 +192,10 @@ struct NotationWindowView: View { selectedHarmonySymbolID: staff.part.id.isMain ? viewModel.selectedHarmonySymbolID : nil, selectedMeasures: viewModel.selectedNotationMeasures, selectedItem: viewModel.selectedNotationItem, - selectedDuration: NotationDuration(denominator: viewModel.notationDurationDenominator), + selectedDuration: NotationDuration( + denominator: viewModel.notationDurationDenominator, + isDotted: viewModel.notationDurationIsDotted + ), entryMode: viewModel.notationEntryMode, pendingEditorRequest: staff.part.id.isMain ? viewModel.pendingHarmonyEditorRequest : nil, showsRegionLabels: staff.showsRegionLabels, @@ -303,7 +313,7 @@ struct NotationWindowView: View { hotkeys.insert(.editHarmonyAtSelectedNotationItem) } if viewModel.canChangeNotationDuration { - hotkeys.formUnion(AppHotkey.notationDurationHotkeys) + hotkeys.formUnion(AppHotkey.notationDurationEditingHotkeys) } if viewModel.duration > 0 { hotkeys.insert(.toggleNotationNoteEntryMode) @@ -351,6 +361,8 @@ struct NotationWindowView: View { guard let denominator = hotkey.notationDurationDenominator else { return false } viewModel.setNotationDurationDenominator(denominator) return true + case .toggleNotationDurationDot: + return viewModel.toggleNotationDurationDot() default: return false } diff --git a/JammLab/Views/WaveformTimelineView.swift b/JammLab/Views/WaveformTimelineView.swift index f0e1ace..92c656f 100644 --- a/JammLab/Views/WaveformTimelineView.swift +++ b/JammLab/Views/WaveformTimelineView.swift @@ -34,6 +34,7 @@ struct TimelineViewState: Equatable { var notationViewport: NotationViewportState var stemNotationViewports: [StemType: NotationViewportState] var notationDurationDenominator: Int + var notationDurationIsDotted: Bool var canChangeNotationDuration: Bool var notationEntryMode: NotationEntryMode? var isNotationTrackCollapsed: Bool @@ -76,6 +77,7 @@ struct TimelineViewActions { var notationTrackCollapsedChanged: (Bool) -> Void var stemNotationTrackCollapsedChanged: (StemType, Bool) -> Void var notationDurationChanged: (Int) -> Void + var notationDurationDotToggled: () -> Void var notationNoteEntryModeToggled: () -> Void var notationRestEntryModeToggled: () -> Void var insertNotationNote: (NotationNotePlacement) -> Bool @@ -170,10 +172,14 @@ struct WaveformTimelineView: View { trackControlWidth: trackControlWidth, selectedMeasures: state.selectedNotationMeasures, selectedItem: state.selectedNotationItem, - selectedDuration: NotationDuration(denominator: state.notationDurationDenominator), + selectedDuration: NotationDuration( + denominator: state.notationDurationDenominator, + isDotted: state.notationDurationIsDotted + ), entryMode: state.notationEntryMode, canChangeNotationDuration: state.canChangeNotationDuration, notationDurationDenominator: state.notationDurationDenominator, + notationDurationIsDotted: state.notationDurationIsDotted, notationViewports: state.stemNotationViewports, notationCollapsed: state.stemNotationTrackCollapsed, notationActions: actions, @@ -348,7 +354,10 @@ struct WaveformTimelineView: View { selectedHarmonySymbolID: state.selectedHarmonySymbolID, selectedMeasures: state.selectedNotationMeasures, selectedItem: state.selectedNotationItem, - selectedDuration: NotationDuration(denominator: state.notationDurationDenominator), + selectedDuration: NotationDuration( + denominator: state.notationDurationDenominator, + isDotted: state.notationDurationIsDotted + ), entryMode: state.notationEntryMode, pendingEditorRequest: state.pendingHarmonyEditorRequest, actions: actions.notationTrackActions(allowsHarmony: true) @@ -405,6 +414,12 @@ struct WaveformTimelineView: View { .help("Add rests to Notation") .accessibilityLabel("Notation Rest Entry") .accessibilityValue(isNotationRestEntryModeEnabled ? "Enabled" : "Disabled") + + NotationAugmentationDotButton( + isActive: state.notationDurationIsDotted, + action: actions.notationDurationDotToggled + ) + .disabled(!state.canChangeNotationDuration) } } } @@ -550,6 +565,7 @@ private struct StemTracksSection: View { let entryMode: NotationEntryMode? let canChangeNotationDuration: Bool let notationDurationDenominator: Int + let notationDurationIsDotted: Bool let notationViewports: [StemType: NotationViewportState] let notationCollapsed: [StemType: Bool] let notationActions: TimelineViewActions @@ -763,6 +779,12 @@ private struct StemTracksSection: View { .disabled(duration <= 0) .help("Add rests to \(type.title) Notation") .accessibilityLabel("\(type.title) Notation Rest Entry") + + NotationAugmentationDotButton( + isActive: notationDurationIsDotted, + action: notationActions.notationDurationDotToggled + ) + .disabled(!canChangeNotationDuration) } } .padding(.horizontal, AppTheme.Spacing.md) diff --git a/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift b/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift index 4943d3c..85515d5 100644 --- a/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift +++ b/JammLabTests/AppHotkeyNotationDurationEventFilterTests.swift @@ -79,6 +79,32 @@ final class AppHotkeyNotationDurationEventFilterTests: XCTestCase { } } + func testAppHotkeyEventFilterScopesAugmentationDotToDurationEditing() throws { + let event = try keyEvent(key: ".", keyCode: 47) + + XCTAssertEqual( + AppHotkeyEventFilter.hotkey( + for: event, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: AppHotkey.notationDurationEditingHotkeys + ), + .toggleNotationDurationDot + ) + XCTAssertNil(AppHotkeyEventFilter.hotkey( + for: event, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: AppHotkey.notationDurationHotkeys + )) + XCTAssertNil(AppHotkeyEventFilter.hotkey( + for: event, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: AppHotkey.notationDurationEditingHotkeys + )) + } + private func keyEvent( key: String, keyCode: UInt16, diff --git a/JammLabTests/AppHotkeyNotationMappingTests.swift b/JammLabTests/AppHotkeyNotationMappingTests.swift index b72ea76..844ffd0 100644 --- a/JammLabTests/AppHotkeyNotationMappingTests.swift +++ b/JammLabTests/AppHotkeyNotationMappingTests.swift @@ -134,6 +134,24 @@ final class AppHotkeyNotationMappingTests: XCTestCase { } } + func testAppHotkeyRecognizesAugmentationDotFromPeriodAndNumpadDecimal() throws { + let rowPeriod = try keyEvent(key: ".", keyCode: 47) + let numpadPeriod = try keyEvent(key: ".", keyCode: 65) + let numpadComma = try keyEvent(key: ",", keyCode: 65) + let rowComma = try keyEvent(key: ",", keyCode: 43) + + XCTAssertEqual(AppHotkey(event: rowPeriod), .toggleNotationDurationDot) + XCTAssertEqual(AppHotkey(event: numpadPeriod), .toggleNotationDurationDot) + XCTAssertEqual(AppHotkey(event: numpadComma), .toggleNotationDurationDot) + XCTAssertNil(AppHotkey(event: rowComma)) + XCTAssertEqual(AppHotkey.toggleNotationDurationDot.key, ".; Num.; Num,") + XCTAssertEqual(AppHotkey.toggleNotationDurationDot.title, "Augmentation dot") + XCTAssertEqual(AppHotkey.toggleNotationDurationDot.detail, "Toggle duration dot") + XCTAssertNil(AppHotkey.toggleNotationDurationDot.notationDurationDenominator) + XCTAssertFalse(AppHotkey.notationDurationHotkeys.contains(.toggleNotationDurationDot)) + XCTAssertTrue(AppHotkey.notationDurationEditingHotkeys.contains(.toggleNotationDurationDot)) + } + func testAppHotkeyNotationDurationShortcutTextUsesPrimaryAndNumpadKeys() { let expectations: [(denominator: Int, shortcutText: String)] = [ (16, "3; Num3"), diff --git a/JammLabTests/NotationDurationEditorTests.swift b/JammLabTests/NotationDurationEditorTests.swift index cb96222..ccc2e6c 100644 --- a/JammLabTests/NotationDurationEditorTests.swift +++ b/JammLabTests/NotationDurationEditorTests.swift @@ -6,18 +6,95 @@ final class NotationDurationEditorTests: XCTestCase { let measure = makeMeasure() let selectedRest = try XCTUnwrap(measure.notationItems.first) - let items = try XCTUnwrap(NotationDurationEditor.replacementSuffix( + let replacement = try XCTUnwrap(NotationDurationEditor.replacement( in: measure, selectedItem: selectedRest, selectedDuration: NotationDuration(denominator: 4) )) + let items = replacement.items - XCTAssertEqual(items.map(\.displayDuration.denominator), [4, 4, 2]) - XCTAssertEqual(items.map(\.offsetInQuarterNotes), [0, 1, 2]) - XCTAssertEqual(items.map(\.durationInQuarterNotes), [1, 1, 2]) + XCTAssertEqual(items.map(\.displayDuration.denominator), [4, 2, 4]) + XCTAssertEqual(items.map(\.offsetInQuarterNotes), [0, 1, 3]) + XCTAssertEqual(items.map(\.durationInQuarterNotes), [1, 2, 1]) + XCTAssertEqual(items.first?.id, selectedRest.id) } - func testRejectsDurationThatWouldDropTrailingItems() { + func testExpandingNoteConsumesOnlyContiguousRestAndPreservesLaterNote() throws { + let selectedNote = NotationMeasureItem( + id: "selected-note", + kind: .note, + pitch: NotationPitch(step: .e, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let followingRest = NotationMeasureItem( + id: "following-rest", + kind: .rest, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let laterNote = NotationMeasureItem( + id: "later-note", + kind: .note, + pitch: NotationPitch(step: .g, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + var measure = makeMeasure() + measure.notationItems = [selectedNote, followingRest, laterNote] + + let replacement = try XCTUnwrap(NotationDurationEditor.replacement( + in: measure, + selectedItem: selectedNote, + selectedDuration: NotationDuration(denominator: 4, isDotted: true) + )) + let items = replacement.items + + XCTAssertEqual(items.count, 3) + XCTAssertEqual(items.first?.id, "selected-note") + XCTAssertEqual(items.first?.pitch, selectedNote.pitch) + XCTAssertEqual(items.first?.durationInQuarterNotes, 1.5) + XCTAssertEqual(items[1].kind, .rest) + XCTAssertEqual(items[1].durationInQuarterNotes, 0.5) + XCTAssertEqual(items.last, laterNote) + } + + func testDottedSixteenthUsesThirtySecondRestToReachNextBeatBoundary() throws { + var measure = makeMeasure() + let selectedRest = NotationMeasureItem( + id: "selected-rest", + kind: .rest, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + measure.notationItems = [selectedRest] + + let replacement = try XCTUnwrap(NotationDurationEditor.replacement( + in: measure, + selectedItem: selectedRest, + selectedDuration: NotationDuration(denominator: 16, isDotted: true) + )) + let items = replacement.items + + XCTAssertEqual(items.map(\.displayDuration.denominator), [16, 8, 32]) + XCTAssertEqual(items.map(\.durationInQuarterNotes), [0.375, 0.5, 0.125]) + XCTAssertTrue(items.first?.displayDuration.isDotted == true) + XCTAssertTrue(items.dropFirst().allSatisfy { !$0.displayDuration.isDotted }) + } + + func testRejectsDottedExpansionWhenNextItemIsNote() { var measure = makeMeasure() let note = NotationMeasureItem( id: "selected-note", @@ -25,28 +102,90 @@ final class NotationDurationEditorTests: XCTestCase { pitch: NotationPitch(step: .e, octave: 4), measureNumber: measure.number, measureStartTime: measure.startTime, - offsetInQuarterNotes: 1, + offsetInQuarterNotes: 0, durationInQuarterNotes: 1, displayDuration: NotationDuration(denominator: 4) ) - let trailingRest = NotationMeasureItem( - id: "trailing-rest", - kind: .rest, + let trailingNote = NotationMeasureItem( + id: "trailing-note", + kind: .note, + pitch: NotationPitch(step: .g, octave: 4), measureNumber: measure.number, measureStartTime: measure.startTime, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + measure.notationItems = [note, trailingNote] + + let replacement = NotationDurationEditor.replacement( + in: measure, + selectedItem: note, + selectedDuration: NotationDuration(denominator: 4, isDotted: true) + ) + + XCTAssertNil(replacement) + } + + func testReplacementReturnsExactSelectedItemWhenPrefixIsPreserved() throws { + let prefixNote = NotationMeasureItem( + id: "prefix-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let selectedRest = NotationMeasureItem( + id: "selected-rest", + kind: .rest, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let followingRest = NotationMeasureItem( + id: "following-rest", + kind: .rest, + measureNumber: 1, + measureStartTime: 0, offsetInQuarterNotes: 2, durationInQuarterNotes: 2, displayDuration: NotationDuration(denominator: 2) ) - measure.notationItems = [note, trailingRest] + var measure = makeMeasure() + measure.notationItems = [followingRest, selectedRest, prefixNote] - let items = NotationDurationEditor.replacementSuffix( + let replacement = try XCTUnwrap(NotationDurationEditor.replacement( in: measure, - selectedItem: note, - selectedDuration: NotationDuration(denominator: 1) - ) + selectedItem: selectedRest, + selectedDuration: NotationDuration(denominator: 4, isDotted: true) + )) + + XCTAssertEqual(replacement.items.first, prefixNote) + XCTAssertEqual(replacement.items[1], replacement.selectedItem) + XCTAssertEqual(replacement.selectedItem.id, selectedRest.id) + XCTAssertEqual(replacement.selectedItem.durationInQuarterNotes, 1.5) + } + + func testMaterializesSynthesizedRestWhenDurationChanges() throws { + var measure = makeMeasure() + measure.notationItems[0].isSynthesized = true + let selectedRest = measure.notationItems[0] + + let replacement = try XCTUnwrap(NotationDurationEditor.replacement( + in: measure, + selectedItem: selectedRest, + selectedDuration: NotationDuration(denominator: 2, isDotted: true) + )) - XCTAssertNil(items) + XCTAssertNotEqual(replacement.selectedItem.id, selectedRest.id) + XCTAssertFalse(replacement.selectedItem.isSynthesized) + XCTAssertEqual(replacement.selectedItem.durationInQuarterNotes, 3) + XCTAssertEqual(replacement.items.first, replacement.selectedItem) } private func makeMeasure() -> ScoreMeasure { diff --git a/JammLabTests/NotationMusicXMLRestExportTests.swift b/JammLabTests/NotationMusicXMLRestExportTests.swift index 494ded7..0906140 100644 --- a/JammLabTests/NotationMusicXMLRestExportTests.swift +++ b/JammLabTests/NotationMusicXMLRestExportTests.swift @@ -33,6 +33,7 @@ final class NotationMusicXMLRestExportTests: XCTestCase { XCTAssertTrue(notes.allSatisfy { note in note.elements(forName: "rest").first?.attribute(forName: "measure") == nil }) + XCTAssertTrue(notes.allSatisfy { $0.elements(forName: "dot").isEmpty }) } func testMusicXMLHarmonyAtNotationItemBoundaryUsesNextItemCursor() throws { @@ -99,6 +100,7 @@ final class NotationMusicXMLRestExportTests: XCTestCase { XCTAssertEqual(try firstXMLChild(named: "octave", in: pitch).stringValue, "5") XCTAssertEqual(try firstXMLChild(named: "duration", in: note).stringValue, "240") XCTAssertEqual(try firstXMLChild(named: "type", in: note).stringValue, "eighth") + XCTAssertTrue(note.elements(forName: "dot").isEmpty) XCTAssertTrue(note.elements(forName: "beam").isEmpty) } @@ -153,6 +155,74 @@ final class NotationMusicXMLRestExportTests: XCTestCase { } } + func testMusicXMLExportIncludesAugmentationDotForDottedNoteAndRest() throws { + let dottedQuarter = NotationDuration(denominator: 4, isDotted: true) + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 4), + duration: 4, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "C major", + notationItems: [ + NotationMeasureItem( + kind: .note, + pitch: NotationPitch(step: .c, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: dottedQuarter.durationInQuarterNotes, + displayDuration: dottedQuarter + ), + NotationMeasureItem( + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1.5, + durationInQuarterNotes: dottedQuarter.durationInQuarterNotes, + displayDuration: dottedQuarter + ), + NotationMeasureItem( + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 3, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + ) + + let document = try exportedMusicXMLDocument(for: state) + let part = try XCTUnwrap(document.rootElement()?.elements(forName: "part").first) + let firstMeasure = try XCTUnwrap(part.elements(forName: "measure").first) + let exportedItems = firstMeasure.elements(forName: "note") + let dottedItems = Array(exportedItems.prefix(2)) + + XCTAssertEqual(dottedItems.count, 2) + for item in dottedItems { + XCTAssertEqual(try firstXMLChild(named: "duration", in: item).stringValue, "720") + XCTAssertEqual(try firstXMLChild(named: "type", in: item).stringValue, "quarter") + XCTAssertEqual(item.elements(forName: "dot").count, 1) + let children = childElements(in: item) + let typeIndex = try XCTUnwrap(children.firstIndex { $0.name == "type" }) + let dotIndex = try XCTUnwrap(children.firstIndex { $0.name == "dot" }) + XCTAssertEqual(dotIndex, typeIndex + 1) + } + XCTAssertNotNil(dottedItems.first?.elements(forName: "pitch").first) + XCTAssertNotNil(dottedItems.last?.elements(forName: "rest").first) + XCTAssertEqual(childElements(in: dottedItems[0]).compactMap(\.name), [ + "pitch", "duration", "voice", "type", "dot" + ]) + XCTAssertEqual(childElements(in: dottedItems[1]).compactMap(\.name), [ + "rest", "duration", "voice", "type", "dot" + ]) + + let undottedRest = try XCTUnwrap(exportedItems.last) + XCTAssertEqual(childElements(in: undottedRest).compactMap(\.name), [ + "rest", "duration", "voice", "type" + ]) + XCTAssertTrue(undottedRest.elements(forName: "dot").isEmpty) + } + private func exportedMusicXMLDocument(for state: NotationScoreState) throws -> XMLDocument { let data = try NotationExportService().export( NotationExportRequest(displayName: "Song", score: state), diff --git a/JammLabTests/NotationPrimitivesTests.swift b/JammLabTests/NotationPrimitivesTests.swift index 87fb26f..8064aa5 100644 --- a/JammLabTests/NotationPrimitivesTests.swift +++ b/JammLabTests/NotationPrimitivesTests.swift @@ -46,6 +46,7 @@ final class NotationPrimitivesTests: XCTestCase { let quarter = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 4))) let eighth = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 8))) let sixteenth = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 16))) + let thirtySecond = try XCTUnwrap(NotationSMuFLSymbol(duration: NotationDuration(denominator: 32))) XCTAssertEqual(whole, .restWhole) XCTAssertEqual(whole.codepoint, 0xE4E3) @@ -57,7 +58,10 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertEqual(eighth.codepoint, 0xE4E6) XCTAssertEqual(sixteenth, .rest16th) XCTAssertEqual(sixteenth.codepoint, 0xE4E7) + XCTAssertEqual(thirtySecond, .rest32nd) + XCTAssertEqual(thirtySecond.codepoint, 0xE4E8) XCTAssertEqual(quarter.glyph.unicodeScalars.first?.value, 0xE4E5) + XCTAssertEqual(NotationAugmentationDotSymbol.augmentationDot.codepoint, 0xE1E7) } func testNotationClefSymbolsUseLelandSMuFLCodepointsAndReferenceLines() { @@ -147,15 +151,66 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertEqual(duration.pluralDisplayName, "16th notes") } + func testDottedDurationUsesOneAndAHalfTimesBaseValueAndHumanName() { + let dottedQuarter = NotationDuration(denominator: 4, isDotted: true) + let dottedSixteenth = NotationDuration(denominator: 16, isDotted: true) + + XCTAssertEqual(dottedQuarter.baseDurationInQuarterNotes, 1, accuracy: 0.0001) + XCTAssertEqual(dottedQuarter.durationInQuarterNotes, 1.5, accuracy: 0.0001) + XCTAssertEqual(dottedQuarter.displayName, "quarter") + XCTAssertEqual(dottedQuarter.capitalizedDisplayName, "Dotted quarter") + XCTAssertEqual(dottedSixteenth.durationInQuarterNotes, 0.375, accuracy: 0.0001) + XCTAssertNotEqual(dottedQuarter.id, NotationDuration(denominator: 4).id) + } + + func testNotationDurationCodableDefaultsLegacyValuesToUndottedAndOmitsFalseFlag() throws { + let legacy = try JSONDecoder().decode( + NotationDuration.self, + from: Data(#"{"denominator":4}"#.utf8) + ) + let undottedData = try JSONEncoder().encode(NotationDuration(denominator: 4)) + let dottedData = try JSONEncoder().encode(NotationDuration(denominator: 4, isDotted: true)) + + XCTAssertFalse(legacy.isDotted) + XCTAssertFalse(String(decoding: undottedData, as: UTF8.self).contains("isDotted")) + XCTAssertTrue(String(decoding: dottedData, as: UTF8.self).contains("isDotted")) + } + + func testAugmentationDotLayoutMovesLineNotesIntoSpaceAbove() { + let spacing = AppTheme.Timeline.notationStaffLineSpacing + let staffTop: CGFloat = 20 + + XCTAssertEqual(NotationAugmentationDotLayout.noteDotStaffPosition(for: 4), 3) + XCTAssertEqual(NotationAugmentationDotLayout.noteDotStaffPosition(for: 3), 3) + XCTAssertEqual(NotationAugmentationDotLayout.noteDotStaffPosition(for: -2), -3) + + let noteTarget = NotationAugmentationDotLayout.noteTarget( + noteX: 100, + noteStaffPosition: 4, + staffTop: staffTop + ) + let restTarget = NotationAugmentationDotLayout.restTarget(restX: 100, staffTop: staffTop) + XCTAssertEqual(noteTarget.x, 100 + spacing, accuracy: 0.0001) + XCTAssertEqual(noteTarget.y, restTarget.y, accuracy: 0.0001) + } + func testFiveDurationButtonsFitNotationTrackControls() { - let buttonCount = CGFloat(NotationDuration.allowedDenominators.count) + let buttonCount = CGFloat(NotationDuration.entryDenominators.count) let spacingCount = max(0, buttonCount - 1) let controlWidth = buttonCount * AppTheme.ControlSize.notationDurationButtonWidth + spacingCount * AppTheme.ControlSize.notationDurationButtonSpacing let availableWidth = AppTheme.Timeline.trackControlWidth - 2 * AppTheme.Spacing.md XCTAssertLessThanOrEqual(controlWidth, availableWidth) - XCTAssertEqual(AppTheme.ControlSize.notationEntryModeButtonWidth, 32) + XCTAssertEqual(AppTheme.ControlSize.notationModeButtonWidth, 32) + } + + func testThreeEntryModeButtonsFitNotationTrackControls() { + let controlWidth = 3 * AppTheme.ControlSize.notationModeButtonWidth + + 2 * AppTheme.Spacing.sm + let availableWidth = AppTheme.Timeline.trackControlWidth - 2 * AppTheme.Spacing.md + + XCTAssertLessThanOrEqual(controlWidth, availableWidth) } func testNotationRestItemFactoryUsesGreedyAllowedDurationDecomposition() { @@ -172,14 +227,14 @@ final class NotationPrimitivesTests: XCTestCase { let withinTolerance = NotationRestItemFactory.greedySegments( startOffset: 1, - remaining: 0.25 - tolerance / 2 + remaining: 0.125 - tolerance / 2 ) let outsideTolerance = NotationRestItemFactory.greedySegments( startOffset: 1, - remaining: 0.25 - tolerance * 2 + remaining: 0.125 - tolerance * 2 ) - XCTAssertEqual(withinTolerance.map(\.displayDuration.denominator), [16]) + XCTAssertEqual(withinTolerance.map(\.displayDuration.denominator), [32]) XCTAssertEqual(withinTolerance.map(\.offsetInQuarterNotes), [1]) XCTAssertTrue(outsideTolerance.isEmpty) } @@ -1058,6 +1113,17 @@ final class NotationPrimitivesTests: XCTestCase { } } + func testLelandAugmentationDotGlyphPathHasBounds() throws { + let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( + for: NotationAugmentationDotSymbol.augmentationDot, + fontSize: AppTheme.ControlSize.notationDurationGlyphSize + )) + + XCTAssertFalse(glyphPath.path.isEmpty) + XCTAssertGreaterThan(glyphPath.bounds.width, 0) + XCTAssertGreaterThan(glyphPath.bounds.height, 0) + } + func testLelandStaffNoteGlyphPathsHaveBounds() throws { for symbol in allStaffNoteSymbols { let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( @@ -1181,6 +1247,14 @@ final class NotationPrimitivesTests: XCTestCase { ) } + func testAugmentationDotHelpTextMatchesShortcutDescription() { + XCTAssertEqual( + NotationAugmentationDotHelpText.tooltip, + "Augmentation dot (.; Num.; Num,)\nToggle duration dot" + ) + XCTAssertEqual(NotationAugmentationDotHelpText.accessibilityLabel, "Augmentation dot") + } + func testWholeRestVisualCenterUsesStandardStaffPosition() { let y = NotationMeasureLayout.wholeRestVisualCenterY( staffTop: 10, diff --git a/JammLabTests/NotationTrackLayoutItemsTests.swift b/JammLabTests/NotationTrackLayoutItemsTests.swift index cff16d0..0c9652d 100644 --- a/JammLabTests/NotationTrackLayoutItemsTests.swift +++ b/JammLabTests/NotationTrackLayoutItemsTests.swift @@ -282,6 +282,7 @@ private func timelineViewActions( notationTrackCollapsedChanged: { _ in }, stemNotationTrackCollapsedChanged: { _, _ in }, notationDurationChanged: { _ in }, + notationDurationDotToggled: {}, notationNoteEntryModeToggled: {}, notationRestEntryModeToggled: {}, insertNotationNote: { _ in diff --git a/JammLabTests/TimelineProjectLogicTests.swift b/JammLabTests/TimelineProjectLogicTests.swift index 26b2afc..98e3ccf 100644 --- a/JammLabTests/TimelineProjectLogicTests.swift +++ b/JammLabTests/TimelineProjectLogicTests.swift @@ -106,24 +106,33 @@ final class TimelineProjectLogicTests: XCTestCase { measureNumber: 1, measureStartTime: 0, offsetInQuarterNotes: 0, - durationInQuarterNotes: 0.25, - displayDuration: NotationDuration(denominator: 16) + durationInQuarterNotes: 0.375, + displayDuration: NotationDuration(denominator: 16, isDotted: true) ) let notationRest = NotationMeasureItem( id: "main-rest", kind: .rest, measureNumber: 1, measureStartTime: 0, - offsetInQuarterNotes: 0.25, + offsetInQuarterNotes: 0.375, durationInQuarterNotes: 0.25, displayDuration: NotationDuration(denominator: 16) ) + let notationFillerRest = NotationMeasureItem( + id: "main-filler-rest", + kind: .rest, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0.625, + durationInQuarterNotes: 0.125, + displayDuration: NotationDuration(denominator: 32) + ) let project = JammLabProject( audioBookmarkData: Data([1, 2, 3]), audioDisplayName: "song.wav", audioDuration: 12, notes: [], - notationItems: [notationItem, notationRest], + notationItems: [notationItem, notationRest, notationFillerRest], loopStart: 0, loopEnd: 4, playbackRate: 1, @@ -138,9 +147,10 @@ final class TimelineProjectLogicTests: XCTestCase { ) XCTAssertEqual(decoded.notationItems.first?.partID, .stem(.bass)) - XCTAssertEqual(decoded.notationItems.map(\.kind), [.note, .rest]) - XCTAssertEqual(decoded.notationItems.map(\.displayDuration.denominator), [16, 16]) - XCTAssertEqual(decoded.notationItems.map(\.durationInQuarterNotes), [0.25, 0.25]) + XCTAssertEqual(decoded.notationItems.map(\.kind), [.note, .rest, .rest]) + XCTAssertEqual(decoded.notationItems.map(\.displayDuration.denominator), [16, 16, 32]) + XCTAssertEqual(decoded.notationItems.map(\.displayDuration.isDotted), [true, false, false]) + XCTAssertEqual(decoded.notationItems.map(\.durationInQuarterNotes), [0.375, 0.25, 0.125]) XCTAssertEqual(decoded.stemNotationTrackCollapsed, [.bass: false, .drums: true]) XCTAssertEqual(decoded.visibleNotationPartIDs, [.main, .stem(.bass), .stem(.drums)]) } diff --git a/JammLabTests/ViewModelNotationDurationSelectionTests.swift b/JammLabTests/ViewModelNotationDurationSelectionTests.swift index 80d650f..b07716a 100644 --- a/JammLabTests/ViewModelNotationDurationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationDurationSelectionTests.swift @@ -3,6 +3,230 @@ import XCTest @testable import JammLab final class ViewModelNotationDurationSelectionTests: XCTestCase { + @MainActor + func testAugmentationDotModePersistsUntilToggledOff() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.setNotationNoteEntryModeEnabled(true) + + XCTAssertTrue(viewModel.toggleNotationDurationDot()) + XCTAssertTrue(viewModel.notationDurationIsDotted) + + viewModel.setNotationDurationDenominator(8) + viewModel.toggleNotationRestEntryMode() + XCTAssertTrue(viewModel.notationDurationIsDotted) + + XCTAssertTrue(viewModel.toggleNotationDurationDot()) + XCTAssertFalse(viewModel.notationDurationIsDotted) + } + + @MainActor + func testAddingDottedQuarterNoteRecomposesRestsSelectsNoteAndCanUndo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let measure = try notationMeasure(1, in: viewModel) + let duration = NotationDuration(denominator: 4, isDotted: true) + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 200, + contentStartX: 20, + contentEndX: 180, + staffStartX: 0, + staffEndX: 200 + ) + let placement = try XCTUnwrap(NotationNotePlacementResolver.placement( + in: measure, + geometry: geometry, + point: CGPoint(x: 100, y: 72), + staffTop: 40, + selectedDuration: duration + )) + + XCTAssertTrue(viewModel.insertNotationNote(placement)) + + let updated = try notationMeasure(1, in: viewModel) + let note = try XCTUnwrap(updated.notationItems.first) + XCTAssertEqual(note.kind, .note) + XCTAssertTrue(note.displayDuration.isDotted) + XCTAssertEqual(note.durationInQuarterNotes, 1.5, accuracy: 0.0001) + XCTAssertEqual(viewModel.selectedNotationItem?.itemID, note.id) + XCTAssertEqual(updated.notationItems.map(\.durationInQuarterNotes).reduce(0, +), 4, accuracy: 0.0001) + + viewModel.undoLastEdit() + XCTAssertEqual(try notationMeasure(1, in: viewModel).notationItems.first?.displayDuration.denominator, 1) + } + + @MainActor + func testAddingDottedSixteenthRestCreatesThirtySecondFillerAndCanUndo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let measure = try notationMeasure(1, in: viewModel) + let duration = NotationDuration(denominator: 16, isDotted: true) + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 200, + contentStartX: 20, + contentEndX: 180, + staffStartX: 0, + staffEndX: 200 + ) + let placement = try XCTUnwrap(NotationNotePlacementResolver.restPlacement( + in: measure, + geometry: geometry, + point: CGPoint(x: 100, y: 72), + staffTop: 40, + selectedDuration: duration + )) + + XCTAssertTrue(viewModel.insertNotationRest(placement)) + + let updated = try notationMeasure(1, in: viewModel) + XCTAssertEqual(updated.notationItems.map(\.displayDuration.denominator), [16, 2, 4, 8, 32]) + XCTAssertEqual(updated.notationItems.map(\.displayDuration.isDotted), [true, false, false, false, false]) + XCTAssertEqual(updated.notationItems.map(\.durationInQuarterNotes).reduce(0, +), 4, accuracy: 0.0001) + + viewModel.undoLastEdit() + XCTAssertEqual(try notationMeasure(1, in: viewModel).notationItems.first?.displayDuration.denominator, 1) + } + + @MainActor + func testSelectingNotationItemSynchronizesAugmentationDotMode() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.notationItems = [ + NotationMeasureItem( + id: "dotted-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1.5, + displayDuration: NotationDuration(denominator: 4, isDotted: true) + ), + NotationMeasureItem( + id: "plain-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1.5, + durationInQuarterNotes: 0.5, + displayDuration: NotationDuration(denominator: 8) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: measure.notationItems[0])) + XCTAssertTrue(viewModel.notationDurationIsDotted) + + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: measure.notationItems[1])) + XCTAssertFalse(viewModel.notationDurationIsDotted) + } + + @MainActor + func testTogglingSelectedNoteDotPreservesIdentityAndCanUndo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let note = try insertQuarterNote( + pitch: NotationPitch(step: .e, octave: 4), + inMeasure: 1, + viewModel: viewModel + ) + undoManager.removeAllActions() + viewModel.markProjectClean() + + XCTAssertTrue(viewModel.toggleNotationDurationDot()) + + let dotted = try XCTUnwrap(try notationMeasure(1, in: viewModel).notationItems.first) + XCTAssertEqual(dotted.id, note.id) + XCTAssertEqual(dotted.pitch, note.pitch) + XCTAssertEqual(dotted.durationInQuarterNotes, 1.5, accuracy: 0.0001) + XCTAssertTrue(dotted.displayDuration.isDotted) + XCTAssertTrue(viewModel.notationDurationIsDotted) + XCTAssertTrue(viewModel.isProjectModified) + + viewModel.undoLastEdit() + + let restored = try XCTUnwrap(try notationMeasure(1, in: viewModel).notationItems.first) + XCTAssertEqual(restored.id, note.id) + XCTAssertFalse(restored.displayDuration.isDotted) + XCTAssertEqual(restored.durationInQuarterNotes, 1, accuracy: 0.0001) + } + + @MainActor + func testTogglingSelectedDottedNoteOffShrinksDuration() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.notationItems = [ + NotationMeasureItem( + id: "dotted-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1.5, + displayDuration: NotationDuration(denominator: 4, isDotted: true) + ), + NotationMeasureItem( + id: "following-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1.5, + durationInQuarterNotes: 2.5, + displayDuration: NotationDuration(denominator: 2) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: measure.notationItems[0])) + + XCTAssertTrue(viewModel.toggleNotationDurationDot()) + + let updatedNote = try XCTUnwrap(try notationMeasure(1, in: viewModel).notationItems.first) + XCTAssertFalse(updatedNote.displayDuration.isDotted) + XCTAssertEqual(updatedNote.durationInQuarterNotes, 1, accuracy: 0.0001) + XCTAssertFalse(viewModel.notationDurationIsDotted) + } + + @MainActor + func testRejectedDotToggleDoesNotChangeStateDirtyFlagOrUndoHistory() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.notationItems = [ + NotationMeasureItem( + id: "selected-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "blocking-note", + kind: .note, + pitch: NotationPitch(step: .d, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: measure.notationItems[0])) + viewModel.markProjectClean() + + XCTAssertFalse(viewModel.toggleNotationDurationDot()) + XCTAssertFalse(viewModel.notationDurationIsDotted) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertFalse(viewModel.canUndo) + XCTAssertFalse(try XCTUnwrap(viewModel.notationItems.first).displayDuration.isDotted) + } + @MainActor func testChangingSelectedWholeRestToSixteenthCreatesTwoSixteenthsAndCanUndo() throws { let viewModel = try loadedNotationViewModel(duration: 8) @@ -15,9 +239,9 @@ final class ViewModelNotationDurationSelectionTests: XCTestCase { viewModel.setNotationDurationDenominator(16) let updatedMeasure = try notationMeasure(1, in: viewModel) - XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [16, 16, 2, 4, 8]) - XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 0.25, 0.5, 2.5, 3.5]) - XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [0.25, 0.25, 2, 1, 0.5]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [16, 2, 4, 8, 16]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 0.25, 2.25, 3.25, 3.75]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [0.25, 2, 1, 0.5, 0.25]) XCTAssertEqual(viewModel.selectedNotationItem?.durationInQuarterNotes, 0.25) viewModel.undoLastEdit() @@ -104,9 +328,9 @@ final class ViewModelNotationDurationSelectionTests: XCTestCase { viewModel.setNotationDurationDenominator(4) let updatedMeasure = try notationMeasure(1, in: viewModel) - XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [4, 4, 2]) - XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 1, 2]) - XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [1, 1, 2]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.displayDuration.denominator), [4, 2, 4]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.offsetInQuarterNotes), [0, 1, 3]) + XCTAssertEqual(updatedMeasure.notationItems.map(\.durationInQuarterNotes), [1, 2, 1]) XCTAssertEqual(viewModel.selectedNotationItem?.offsetInQuarterNotes, 0) XCTAssertTrue(viewModel.isProjectModified) } @@ -727,6 +951,56 @@ final class ViewModelNotationDurationSelectionTests: XCTestCase { XCTAssertEqual(redoneNote.durationInQuarterNotes, 2) } + @MainActor + func testChangingLaterNotationItemDurationReselectsReplacementAfterPreservedPrefix() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.notationItems = [ + NotationMeasureItem( + id: "prefix-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "selected-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "following-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + let selectedRest = try XCTUnwrap(measure.notationItems.first { $0.id == "selected-rest" }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: selectedRest)) + + XCTAssertTrue(viewModel.toggleNotationDurationDot()) + + let updatedMeasure = try notationMeasure(1, in: viewModel) + let updatedRest = try XCTUnwrap(updatedMeasure.notationItems.first { $0.id == "selected-rest" }) + XCTAssertEqual(updatedMeasure.notationItems.first?.id, "prefix-note") + XCTAssertEqual(updatedRest.durationInQuarterNotes, 1.5, accuracy: 0.0001) + XCTAssertTrue(updatedRest.displayDuration.isDotted) + XCTAssertEqual(viewModel.selectedNotationItem?.itemID, updatedRest.id) + XCTAssertEqual( + try XCTUnwrap(viewModel.selectedNotationItem?.durationInQuarterNotes), + 1.5, + accuracy: 0.0001 + ) + } + @MainActor func testChangingSelectedNotationNotePitchByStaffStepPreservesRhythmAndCanUndoRedo() throws { let viewModel = try loadedNotationViewModel(duration: 8) diff --git a/JammLabTests/ViewModelResetTests.swift b/JammLabTests/ViewModelResetTests.swift index 897e01a..7c1ff6b 100644 --- a/JammLabTests/ViewModelResetTests.swift +++ b/JammLabTests/ViewModelResetTests.swift @@ -63,6 +63,8 @@ final class ViewModelResetTests: XCTestCase { viewModel.toggleSnap() viewModel.setLooping(true) viewModel.setNotationTrackCollapsed(false) + viewModel.notationDurationDenominator = 8 + viewModel.notationEntryDurationIsDotted = true viewModel.newProject() @@ -76,6 +78,8 @@ final class ViewModelResetTests: XCTestCase { XCTAssertFalse(viewModel.isSnapEnabled) XCTAssertFalse(viewModel.isLooping) XCTAssertTrue(viewModel.isNotationTrackCollapsed) + XCTAssertEqual(viewModel.notationDurationDenominator, NotationDuration.defaultDenominator) + XCTAssertFalse(viewModel.notationDurationIsDotted) XCTAssertNil(viewModel.importedFile) XCTAssertEqual(try XCTUnwrap(viewModel.tempoBPM), AppDefaults.defaultTempoBPM, accuracy: 0.0001) XCTAssertEqual(try XCTUnwrap(viewModel.beatGridSettings.bpm), AppDefaults.defaultTempoBPM, accuracy: 0.0001) From 8643ff6ccc33b1e06b8f89bfc1dc7aa16bf791a7 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Fri, 17 Jul 2026 13:48:12 +0300 Subject: [PATCH 12/12] feat(notation): add augmentation dots for notes and rests --- .gitignore | 6 + AGENTS.md | 13 + CHANGELOG.md | 1 + JammLab.xcodeproj/project.pbxproj | 8 + JammLab/ContentView.swift | 5 + JammLab/DesignSystem/AppTheme.swift | 10 + JammLab/Models/NotationDurationEditor.swift | 3 +- JammLab/Models/NotationScoreModels.swift | 11 +- JammLab/Models/NotationViewportState.swift | 11 +- JammLab/Models/ProjectStateNormalizer.swift | 46 +- JammLab/Services/NotationExportService.swift | 58 +- .../NotationNotePlacementResolver.swift | 15 +- JammLab/Services/NotationSMuFL.swift | 49 +- JammLab/Services/NotationTieService.swift | 624 +++++++ .../Services/NotationViewportFactory.swift | 10 +- JammLab/Utilities/AppHotkey.swift | 9 + .../AudioPlayerViewModel+Markers.swift | 12 +- .../AudioPlayerViewModel+Notation.swift | 278 ++- .../AudioPlayerViewModel+Project.swift | 1 + .../AudioPlayerViewModel+Timeline.swift | 11 +- .../AudioPlayerViewModel+UndoDirty.swift | 1 + JammLab/Views/Components/AppControls.swift | 85 + .../Components/AppHotkeyMonitorView.swift | 6 +- JammLab/Views/MainWorkspacePanels.swift | 3 + JammLab/Views/NotationTrackLayoutItems.swift | 75 + JammLab/Views/NotationTrackView.swift | 39 +- JammLab/Views/NotationWindowView.swift | 10 + JammLab/Views/WaveformTimelineView.swift | 16 + .../AppHotkeyNotationEventFilterTests.swift | 133 ++ .../AppHotkeyNotationMappingTests.swift | 33 + JammLabTests/NotationPrimitivesTests.swift | 65 +- JammLabTests/NotationTieTests.swift | 1555 +++++++++++++++++ .../NotationTrackLayoutItemsTests.swift | 2 + .../ViewModelNotationClipboardTests.swift | 2 + 34 files changed, 3128 insertions(+), 78 deletions(-) create mode 100644 JammLab/Services/NotationTieService.swift create mode 100644 JammLabTests/NotationTieTests.swift diff --git a/.gitignore b/.gitignore index 92fe1e1..a0f45dd 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,9 @@ dist/ # Others AGENTS.local.md .codex/ + +# Graphify +graphify-out/ + +# Xcode asset catalog metadata +**/*.xcassets/ diff --git a/AGENTS.md b/AGENTS.md index b3c33f6..9ad4aa5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -196,3 +196,16 @@ Do not spawn `maintainability_reviewer` for pure analysis-only tasks, DISCUSS mo The main agent owns the final decision. The main agent must not silently ignore `maintainability_reviewer` findings. + +## graphify + +This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. + +When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else. + +Rules: +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. +- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). diff --git a/CHANGELOG.md b/CHANGELOG.md index 6086c17..72b4ff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased +- Added tied-note entry with the `T` shortcut, Leland-based controls, automatic note splitting and tie chains across measure boundaries, selected-note continuation outside note-entry mode, blocked-state tooltips without system alert sounds, score rendering, project persistence, and MusicXML export. - Added persistent augmentation-dot entry and editing for Notation notes and rests, with Leland glyphs, keyboard shortcuts, and MusicXML export. - Added sixteenth-note and rest entry with `3`/`Num3` duration shortcuts, plus downward stems for notes above the middle staff line. - Added Notation note-entry mode for placing pitched notes with Leland-rendered note glyphs, note selection preview, cross-rest insertion, rest recomposition, and MusicXML export. diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index ffc8db4..c2fe95c 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -52,6 +52,7 @@ 9FEA01042D70000100112233 /* NotationTrackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00042D70000100112233 /* NotationTrackView.swift */; }; 9FCB01062D80000100112233 /* NotationMeasureLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB00062D80000100112233 /* NotationMeasureLayout.swift */; }; 9FAE01012F10000100112233 /* NotationNotePlacementResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00012F10000100112233 /* NotationNotePlacementResolver.swift */; }; + 9FC001012F30000100112233 /* NotationTieService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FC000012F30000100112233 /* NotationTieService.swift */; }; 9FAE01022F10000100112233 /* NotationNoteAuditioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022F10000100112233 /* NotationNoteAuditioner.swift */; }; 9FEA01052D70000100112233 /* NotationVisibleMeasureFitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */; }; 9FEA01062D70000100112233 /* ProjectKeySelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00062D70000100112233 /* ProjectKeySelection.swift */; }; @@ -179,6 +180,7 @@ 9FBD01022F00000100112233 /* TransportPositionDisplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBD00022F00000100112233 /* TransportPositionDisplayTests.swift */; }; 9FAB010C2CE0000100112233 /* NotationPrimitivesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */; }; 9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */; }; + 9FC101012F30000100112233 /* NotationTieTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FC100012F30000100112233 /* NotationTieTests.swift */; }; 9FAB01402CE0000100112233 /* NotationMusicXMLChordParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */; }; 9FAB01472CE0000100112233 /* NotationMusicXMLRestExportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00472CE0000100112233 /* NotationMusicXMLRestExportTests.swift */; }; 9FAB01522CE0000100112233 /* NotationMusicXMLErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00522CE0000100112233 /* NotationMusicXMLErrorTests.swift */; }; @@ -322,6 +324,7 @@ 9FEA00042D70000100112233 /* NotationTrackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationTrackView.swift; sourceTree = ""; }; 9FCB00062D80000100112233 /* NotationMeasureLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureLayout.swift; sourceTree = ""; }; 9FAE00012F10000100112233 /* NotationNotePlacementResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationNotePlacementResolver.swift; sourceTree = ""; }; + 9FC000012F30000100112233 /* NotationTieService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationTieService.swift; sourceTree = ""; }; 9FAE00022F10000100112233 /* NotationNoteAuditioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationNoteAuditioner.swift; sourceTree = ""; }; 9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationVisibleMeasureFitter.swift; sourceTree = ""; }; 9FEA00062D70000100112233 /* ProjectKeySelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectKeySelection.swift; sourceTree = ""; }; @@ -446,6 +449,7 @@ 9FBD00022F00000100112233 /* TransportPositionDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportPositionDisplayTests.swift; sourceTree = ""; }; 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationPrimitivesTests.swift; sourceTree = ""; }; 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLTests.swift; sourceTree = ""; }; + 9FC100012F30000100112233 /* NotationTieTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationTieTests.swift; sourceTree = ""; }; 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLChordParserTests.swift; sourceTree = ""; }; 9FAB00472CE0000100112233 /* NotationMusicXMLRestExportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLRestExportTests.swift; sourceTree = ""; }; 9FAB00522CE0000100112233 /* NotationMusicXMLErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLErrorTests.swift; sourceTree = ""; }; @@ -612,6 +616,7 @@ 9FEE00012D90000100112233 /* NotationExportService.swift */, 9FAE00022F10000100112233 /* NotationNoteAuditioner.swift */, 9FAE00012F10000100112233 /* NotationNotePlacementResolver.swift */, + 9FC000012F30000100112233 /* NotationTieService.swift */, 9F2700012EA0000100112233 /* NotationSMuFL.swift */, 9FEA00032D70000100112233 /* NotationViewportFactory.swift */, 9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */, @@ -802,6 +807,7 @@ 9FBD00022F00000100112233 /* TransportPositionDisplayTests.swift */, 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */, 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */, + 9FC100012F30000100112233 /* NotationTieTests.swift */, 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */, 9FAB00472CE0000100112233 /* NotationMusicXMLRestExportTests.swift */, 9FAB00522CE0000100112233 /* NotationMusicXMLErrorTests.swift */, @@ -1134,6 +1140,7 @@ 9FEE01012D90000100112233 /* NotationExportService.swift in Sources */, 9FAE01022F10000100112233 /* NotationNoteAuditioner.swift in Sources */, 9FAE01012F10000100112233 /* NotationNotePlacementResolver.swift in Sources */, + 9FC001012F30000100112233 /* NotationTieService.swift in Sources */, 9FEA01032D70000100112233 /* NotationViewportFactory.swift in Sources */, 9FEA01052D70000100112233 /* NotationVisibleMeasureFitter.swift in Sources */, 9FEA01022D70000100112233 /* NotationViewportState.swift in Sources */, @@ -1275,6 +1282,7 @@ 9FBD01022F00000100112233 /* TransportPositionDisplayTests.swift in Sources */, 9FAB010C2CE0000100112233 /* NotationPrimitivesTests.swift in Sources */, 9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */, + 9FC101012F30000100112233 /* NotationTieTests.swift in Sources */, 9FAB01402CE0000100112233 /* NotationMusicXMLChordParserTests.swift in Sources */, 9FAB01472CE0000100112233 /* NotationMusicXMLRestExportTests.swift in Sources */, 9FAB01522CE0000100112233 /* NotationMusicXMLErrorTests.swift in Sources */, diff --git a/JammLab/ContentView.swift b/JammLab/ContentView.swift index 454f3f1..0c79cf2 100644 --- a/JammLab/ContentView.swift +++ b/JammLab/ContentView.swift @@ -188,6 +188,9 @@ struct ContentView: View { if !viewModel.canChangeNotationDuration { hotkeys.subtract(AppHotkey.notationDurationEditingHotkeys) } + if !viewModel.isTieCommandInScope { + hotkeys.remove(.addTiedNotationNote) + } if !viewModel.canChangeSelectedNotationNotePitch(byStaffPositionDelta: -1) { hotkeys.remove(.moveSelectedNotationNotePitchUp) } @@ -266,6 +269,8 @@ struct ContentView: View { return true case .toggleNotationDurationDot: return viewModel.toggleNotationDurationDot() + case .addTiedNotationNote: + return viewModel.handleAddTiedNotationNoteCommand() } } diff --git a/JammLab/DesignSystem/AppTheme.swift b/JammLab/DesignSystem/AppTheme.swift index 4add6d4..137d7de 100644 --- a/JammLab/DesignSystem/AppTheme.swift +++ b/JammLab/DesignSystem/AppTheme.swift @@ -202,6 +202,11 @@ enum AppTheme { static let notationAugmentationDotNoteOffsetX: CGFloat = -3 static let notationAugmentationDotGlyphOffsetX: CGFloat = 7 static let notationAugmentationDotGlyphOffsetY: CGFloat = 5 + static let notationTieNoteOffsetX: CGFloat = 6 + static let notationTieArcOffsetY: CGFloat = 5 + static let notationTieArcHeight: CGFloat = 3 + static let notationTieEndpointThickness: CGFloat = 0.6 + static let notationTieMidpointThickness: CGFloat = 1.4 static let transportBarMinHeight: CGFloat = 70 static let abletonNumberFieldHeight: CGFloat = 24 static let jammValueSliderWidth: CGFloat = 70 @@ -311,6 +316,11 @@ enum AppTheme { static let notationSlashLineWidth: CGFloat = 3 static let notationSlashMinimumBeatSpacing: CGFloat = 16 static let notationItemAnchorInset: CGFloat = AppTheme.Spacing.lg + static let notationTieNoteheadInset: CGFloat = 5 + static let notationTieVerticalOffset: CGFloat = 5 + static let notationTieArcHeight: CGFloat = 5 + static let notationTieEndpointThickness: CGFloat = 0.7 + static let notationTieMidpointThickness: CGFloat = 1.7 static let notationHarmonyAnchorLeadingOffset: CGFloat = AppTheme.Spacing.md static let notationHarmonySymbolWidth: CGFloat = 84 static let notationHarmonyEditorWidth: CGFloat = 64 diff --git a/JammLab/Models/NotationDurationEditor.swift b/JammLab/Models/NotationDurationEditor.swift index ab6f97f..e4d4cf5 100644 --- a/JammLab/Models/NotationDurationEditor.swift +++ b/JammLab/Models/NotationDurationEditor.swift @@ -58,7 +58,8 @@ enum NotationDurationEditor { measureStartTime: measure.startTime, offsetInQuarterNotes: startOffset, durationInQuarterNotes: selectedDuration.durationInQuarterNotes, - displayDuration: selectedDuration + displayDuration: selectedDuration, + tieTargetItemID: selected.tieTargetItemID ) let fillerEnd = max(sourceEnd, consumedEnd) let fillerItems = NotationRestItemFactory.metricAwareRestItems( diff --git a/JammLab/Models/NotationScoreModels.swift b/JammLab/Models/NotationScoreModels.swift index 15f7d30..7520b98 100644 --- a/JammLab/Models/NotationScoreModels.swift +++ b/JammLab/Models/NotationScoreModels.swift @@ -141,11 +141,13 @@ struct NotationMeasureClipboardItem: Equatable { } struct NotationMeasureClipboardNotationItem: Equatable { + var sourceItemID: String var kind: NotationMeasureItem.Kind = .rest var pitch: NotationPitch? = nil var offsetInQuarterNotes: Double var durationInQuarterNotes: Double var displayDuration: NotationDuration + var tieTargetItemID: String? = nil } struct NotationPartID: Codable, Hashable, Identifiable, Equatable { @@ -422,6 +424,7 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { var offsetInQuarterNotes: Double var durationInQuarterNotes: Double var displayDuration: NotationDuration + var tieTargetItemID: String? var isSynthesized: Bool init( @@ -434,6 +437,7 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { offsetInQuarterNotes: Double, durationInQuarterNotes: Double, displayDuration: NotationDuration, + tieTargetItemID: String? = nil, isSynthesized: Bool = false ) { self.id = id @@ -445,6 +449,7 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { self.offsetInQuarterNotes = offsetInQuarterNotes self.durationInQuarterNotes = durationInQuarterNotes self.displayDuration = displayDuration + self.tieTargetItemID = kind == .note ? tieTargetItemID : nil self.isSynthesized = isSynthesized } @@ -466,7 +471,8 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { measureStartTime: measureStartTime, offsetInQuarterNotes: offsetInQuarterNotes, durationInQuarterNotes: durationInQuarterNotes, - displayDuration: displayDuration + displayDuration: displayDuration, + tieTargetItemID: tieTargetItemID ) } @@ -480,6 +486,7 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { case offsetInQuarterNotes case durationInQuarterNotes case displayDuration + case tieTargetItemID case isSynthesized } @@ -495,6 +502,8 @@ struct NotationMeasureItem: Identifiable, Codable, Equatable { offsetInQuarterNotes = try container.decode(Double.self, forKey: .offsetInQuarterNotes) durationInQuarterNotes = try container.decode(Double.self, forKey: .durationInQuarterNotes) displayDuration = try container.decode(NotationDuration.self, forKey: .displayDuration) + let decodedTieTargetItemID = try container.decodeIfPresent(String.self, forKey: .tieTargetItemID) + tieTargetItemID = kind == .note ? decodedTieTargetItemID : nil isSynthesized = try container.decodeIfPresent(Bool.self, forKey: .isSynthesized) ?? false } } diff --git a/JammLab/Models/NotationViewportState.swift b/JammLab/Models/NotationViewportState.swift index 1bd0438..438c15a 100644 --- a/JammLab/Models/NotationViewportState.swift +++ b/JammLab/Models/NotationViewportState.swift @@ -15,6 +15,7 @@ struct NotationViewportState: Equatable { var visibleMeasures: [ScoreMeasure] var anchorTime: TimeInterval var activeMeasureNumber: Int? + var tieConnections: [NotationTieConnection] = [] var isReady: Bool { availability == .ready @@ -44,6 +45,7 @@ struct NotationScoreState: Equatable { var measures: [ScoreMeasure] var anchorTime: TimeInterval var activeMeasureNumber: Int? + var tieConnections: [NotationTieConnection] = [] var isReady: Bool { availability == .ready @@ -55,7 +57,8 @@ struct NotationScoreState: Equatable { keySignature: keySignature, measures: [], anchorTime: 0, - activeMeasureNumber: nil + activeMeasureNumber: nil, + tieConnections: [] ) } @@ -78,7 +81,11 @@ struct NotationScoreState: Equatable { visibleMeasureCount: systemMeasures.count, visibleMeasures: systemMeasures, anchorTime: anchorTime, - activeMeasureNumber: activeMeasureNumber + activeMeasureNumber: activeMeasureNumber, + tieConnections: NotationTieResolver.connections( + tieConnections, + visibleIn: systemMeasures + ) ) ) } diff --git a/JammLab/Models/ProjectStateNormalizer.swift b/JammLab/Models/ProjectStateNormalizer.swift index 8f2cd56..c2bf893 100644 --- a/JammLab/Models/ProjectStateNormalizer.swift +++ b/JammLab/Models/ProjectStateNormalizer.swift @@ -123,17 +123,20 @@ struct ProjectStateNormalizer { duration: TimeInterval ) -> [NotationMeasureItem] { let duration = normalizedDuration(duration) - return items - .filter { item in - !item.isSynthesized - && (item.kind == .rest || item.pitch != nil) - && item.measureStartTime.isFinite - && item.measureStartTime >= 0 - && item.measureStartTime <= duration - && item.offsetInQuarterNotes.isFinite - && item.durationInQuarterNotes.isFinite - && item.durationInQuarterNotes > 0 - } + let persistedItems = items.filter { item in + !item.isSynthesized + && (item.kind == .rest || item.pitch != nil) + && item.measureStartTime.isFinite + && item.measureStartTime >= 0 + && item.measureStartTime <= duration + && item.offsetInQuarterNotes.isFinite + && item.durationInQuarterNotes.isFinite + && item.durationInQuarterNotes > 0 + } + let availableItemsByID = persistedItems.reduce(into: [String: NotationMeasureItem]()) { + if $0[$1.id] == nil { $0[$1.id] = $1 } + } + return persistedItems .map { item in NotationMeasureItem( id: item.id, @@ -145,6 +148,10 @@ struct ProjectStateNormalizer { offsetInQuarterNotes: max(0, finiteTime(item.offsetInQuarterNotes)), durationInQuarterNotes: max(0, finiteTime(item.durationInQuarterNotes)), displayDuration: item.displayDuration, + tieTargetItemID: normalizedTieTargetItemID( + for: item, + availableItemsByID: availableItemsByID + ), isSynthesized: false ) } @@ -169,6 +176,23 @@ struct ProjectStateNormalizer { } } + private static func normalizedTieTargetItemID( + for item: NotationMeasureItem, + availableItemsByID: [String: NotationMeasureItem] + ) -> String? { + guard item.kind == .note, + let targetID = item.tieTargetItemID, + targetID != item.id, + let target = availableItemsByID[targetID], + target.kind == .note, + target.partID == item.partID, + target.pitch == item.pitch + else { + return nil + } + return targetID + } + private static func notationPartSortKey(_ partID: NotationPartID) -> String { if partID == .main { return "0-main" diff --git a/JammLab/Services/NotationExportService.swift b/JammLab/Services/NotationExportService.swift index e895fa2..3184dce 100644 --- a/JammLab/Services/NotationExportService.swift +++ b/JammLab/Services/NotationExportService.swift @@ -227,6 +227,7 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { ) throws -> XMLElement { let part = element("part", attributes: ["id": id]) var previousAttributes: MeasureAttributes? + let tieRoles = musicXMLTieRoles(in: measures) for (measureIndex, measure) in measures.enumerated() { let measureElement = element("measure", attributes: ["number": "\(measure.number)"]) @@ -263,7 +264,11 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { includeBoundaryHarmony: false, harmonyIndex: &harmonyIndex ) - measureElement.addChild(notationNote(for: item, isOnlyItem: sortedItems.count == 1)) + measureElement.addChild(notationNote( + for: item, + isOnlyItem: sortedItems.count == 1, + tieRole: tieRoles[item.id] + )) notationCursorOffsetInQuarterNotes = restEndOffsetInQuarterNotes } @@ -432,12 +437,16 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { return pitchElement } - private func notationNote(for item: NotationMeasureItem, isOnlyItem: Bool) -> XMLElement { + private func notationNote( + for item: NotationMeasureItem, + isOnlyItem: Bool, + tieRole: MusicXMLTieRole? + ) -> XMLElement { switch item.kind { case .rest: return restNote(for: item, isOnlyItem: isOnlyItem) case .note: - return pitchNote(for: item) + return pitchNote(for: item, tieRole: tieRole) } } @@ -449,7 +458,10 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { return note } - private func pitchNote(for item: NotationMeasureItem) -> XMLElement { + private func pitchNote( + for item: NotationMeasureItem, + tieRole: MusicXMLTieRole? + ) -> XMLElement { let note = element("note") let pitch = item.pitch ?? NotationPitch(step: .c, octave: 4) let pitchElement = element("pitch") @@ -459,17 +471,46 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { } pitchElement.addChild(element("octave", stringValue: "\(pitch.octave)")) note.addChild(pitchElement) - appendDurationElements(to: note, for: item) + appendDurationElements(to: note, for: item, tieRole: tieRole) return note } - private func appendDurationElements(to note: XMLElement, for item: NotationMeasureItem) { + private func appendDurationElements( + to note: XMLElement, + for item: NotationMeasureItem, + tieRole: MusicXMLTieRole? = nil + ) { note.addChild(element("duration", stringValue: "\(durationValue(forQuarterOffset: item.durationInQuarterNotes))")) + if tieRole?.stops == true { + note.addChild(element("tie", attributes: ["type": "stop"])) + } + if tieRole?.starts == true { + note.addChild(element("tie", attributes: ["type": "start"])) + } note.addChild(element("voice", stringValue: "1")) note.addChild(element("type", stringValue: item.displayDuration.displayName)) if item.displayDuration.isDotted { note.addChild(element("dot")) } + if let tieRole, tieRole.starts || tieRole.stops { + let notations = element("notations") + if tieRole.stops { + notations.addChild(element("tied", attributes: ["type": "stop"])) + } + if tieRole.starts { + notations.addChild(element("tied", attributes: ["type": "start"])) + } + note.addChild(notations) + } + } + + private func musicXMLTieRoles(in measures: [ScoreMeasure]) -> [String: MusicXMLTieRole] { + var roles: [String: MusicXMLTieRole] = [:] + for connection in NotationTieResolver.connections(in: measures) { + roles[connection.source.item.id, default: MusicXMLTieRole()].starts = true + roles[connection.target.item.id, default: MusicXMLTieRole()].stops = true + } + return roles } private func durationValue(forQuarterOffset offset: Double) -> Int { @@ -499,6 +540,11 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { } } +private struct MusicXMLTieRole { + var starts = false + var stops = false +} + struct MusicXMLChord: Equatable { var root: MusicXMLPitchStep var kindValue: String diff --git a/JammLab/Services/NotationNotePlacementResolver.swift b/JammLab/Services/NotationNotePlacementResolver.swift index 5527ae9..45d4bea 100644 --- a/JammLab/Services/NotationNotePlacementResolver.swift +++ b/JammLab/Services/NotationNotePlacementResolver.swift @@ -110,7 +110,8 @@ enum NotationNotePlacementResolver { in: measure, geometry: geometry, x: point.x, - requiredDurationInQuarterNotes: selectedLength + requiredDurationInQuarterNotes: selectedLength, + allowsOverflowToNextMeasure: true ) else { return nil } let pitch = NotationPitchMapper.pitch( @@ -311,13 +312,19 @@ enum NotationNotePlacementResolver { in measure: ScoreMeasure, geometry: NotationMeasureCanvasGeometry, x: CGFloat, - requiredDurationInQuarterNotes requiredDuration: Double + requiredDurationInQuarterNotes requiredDuration: Double, + allowsOverflowToNextMeasure: Bool = false ) -> NotationMeasureItem? { + let requiredDurationForRest: (NotationMeasureItem) -> Double = { rest in + guard allowsOverflowToNextMeasure else { return requiredDuration } + let measureLength = NotationMeasureTiming.quarterLength(for: measure.attributes.timeSignature) + return min(requiredDuration, max(0, measureLength - rest.offsetInQuarterNotes)) + } if let fullMeasureWholeRest = singleFullMeasureWholeRest(in: measure), restSpan( in: measure, from: fullMeasureWholeRest, - requiredDurationInQuarterNotes: requiredDuration + requiredDurationInQuarterNotes: requiredDurationForRest(fullMeasureWholeRest) ) != nil { return fullMeasureWholeRest } @@ -333,7 +340,7 @@ enum NotationNotePlacementResolver { && restSpan( in: measure, from: $0, - requiredDurationInQuarterNotes: requiredDuration + requiredDurationInQuarterNotes: requiredDurationForRest($0) ) != nil } guard !eligibleRests.isEmpty else { return nil } diff --git a/JammLab/Services/NotationSMuFL.swift b/JammLab/Services/NotationSMuFL.swift index 89493b0..ef047e5 100644 --- a/JammLab/Services/NotationSMuFL.swift +++ b/JammLab/Services/NotationSMuFL.swift @@ -149,12 +149,55 @@ enum NotationStemDirection: Equatable { } } +enum NotationTiePlacement: Equatable { + case above + case below +} + +enum NotationTiePath { + static func path( + start: CGPoint, + end: CGPoint, + placement: NotationTiePlacement, + arcHeight: CGFloat, + endpointThickness: CGFloat, + midpointThickness: CGFloat + ) -> CGPath { + let direction: CGFloat = placement == .below ? 1 : -1 + let controlY = (start.y + end.y) / 2 + direction * arcHeight + let upperControlY = controlY - midpointThickness / 2 + let lowerControlY = controlY + midpointThickness / 2 + let startUpper = CGPoint(x: start.x, y: start.y - endpointThickness / 2) + let endUpper = CGPoint(x: end.x, y: end.y - endpointThickness / 2) + let startLower = CGPoint(x: start.x, y: start.y + endpointThickness / 2) + let endLower = CGPoint(x: end.x, y: end.y + endpointThickness / 2) + let horizontalDistance = end.x - start.x + + let path = CGMutablePath() + path.move(to: startUpper) + path.addCurve( + to: endUpper, + control1: CGPoint(x: start.x + horizontalDistance * 0.33, y: upperControlY), + control2: CGPoint(x: start.x + horizontalDistance * 0.67, y: upperControlY) + ) + path.addLine(to: endLower) + path.addCurve( + to: startLower, + control1: CGPoint(x: start.x + horizontalDistance * 0.67, y: lowerControlY), + control2: CGPoint(x: start.x + horizontalDistance * 0.33, y: lowerControlY) + ) + path.closeSubpath() + return path + } +} + enum NotationStaffNoteSymbol: Equatable { case whole case half(NotationStemDirection) case quarter(NotationStemDirection) case eighth(NotationStemDirection) case sixteenth(NotationStemDirection) + case thirtySecond(NotationStemDirection) init?(duration: NotationDuration, stemDirection: NotationStemDirection = .up) { switch duration.denominator { @@ -168,6 +211,8 @@ enum NotationStaffNoteSymbol: Equatable { self = .eighth(stemDirection) case 16: self = .sixteenth(stemDirection) + case 32: + self = .thirtySecond(stemDirection) default: return nil } @@ -185,6 +230,8 @@ enum NotationStaffNoteSymbol: Equatable { return Self.directionalCodepoint(up: 0xE1D7, direction: direction) case .sixteenth(let direction): return Self.directionalCodepoint(up: 0xE1D9, direction: direction) + case .thirtySecond(let direction): + return Self.directionalCodepoint(up: 0xE1DB, direction: direction) } } @@ -194,7 +241,7 @@ enum NotationStaffNoteSymbol: Equatable { return nil case .half: return 0xE0A3 - case .quarter, .eighth, .sixteenth: + case .quarter, .eighth, .sixteenth, .thirtySecond: return 0xE0A4 } } diff --git a/JammLab/Services/NotationTieService.swift b/JammLab/Services/NotationTieService.swift new file mode 100644 index 0000000..d943ad0 --- /dev/null +++ b/JammLab/Services/NotationTieService.swift @@ -0,0 +1,624 @@ +import Foundation + +struct NotationTieEndpoint: Equatable { + var measureNumber: Int + var measureStartTime: TimeInterval + var measureAttributes: MeasureAttributes + var item: NotationMeasureItem +} + +struct NotationTieConnection: Equatable, Identifiable { + var source: NotationTieEndpoint + var target: NotationTieEndpoint + + var id: String { + "\(source.item.id)->\(target.item.id)" + } +} + +enum NotationTieCommandBlockReason: Equatable { + case selectNote + case alreadyTied + case noFreeFollowingDuration + case audioBoundary +} + +enum NotationTieCommandStatus: Equatable { + case unavailable + case ready + case blocked(NotationTieCommandBlockReason) + + var isInCommandScope: Bool { + self != .unavailable + } +} + +enum NotationTieResolver { + static func connections(in measures: [ScoreMeasure]) -> [NotationTieConnection] { + let orderedMeasures = measures.sorted(by: notationTieMeasureSort) + let flattened = orderedMeasures.flatMap { measure in + measure.notationItems + .sorted(by: notationTieItemSort) + .map { (measure: measure, item: $0) } + } + let targetIndexByID = flattened.enumerated().reduce(into: [String: Int]()) { + if $0[$1.element.item.id] == nil { $0[$1.element.item.id] = $1.offset } + } + var claimedTargetIDs: Set = [] + var output: [NotationTieConnection] = [] + + for (sourceIndex, entry) in flattened.enumerated() { + let source = entry.item + guard !source.isSynthesized, + source.kind == .note, + let sourcePitch = source.pitch, + let targetID = source.tieTargetItemID, + sourceIndex + 1 < flattened.count, + let targetIndex = targetIndexByID[targetID], + targetIndex == sourceIndex + 1, + !claimedTargetIDs.contains(targetID) + else { + continue + } + + let targetEntry = flattened[targetIndex] + let target = targetEntry.item + guard !target.isSynthesized, + target.kind == .note, + target.pitch == sourcePitch, + target.partID == source.partID, + areContiguous( + source: source, + sourceMeasure: entry.measure, + target: target, + targetMeasure: targetEntry.measure + ) + else { + continue + } + + claimedTargetIDs.insert(targetID) + output.append(NotationTieConnection( + source: endpoint(measure: entry.measure, item: source), + target: endpoint(measure: targetEntry.measure, item: target) + )) + } + + return output + } + + static func sanitizedPersistedItems( + _ items: [NotationMeasureItem], + validConnections: [NotationTieConnection] + ) -> [NotationMeasureItem] { + let validTargetsBySource = Dictionary( + uniqueKeysWithValues: validConnections.map { ($0.source.item.id, $0.target.item.id) } + ) + return items.map { item in + var copy = item + copy.tieTargetItemID = item.kind == .note ? validTargetsBySource[item.id] : nil + return copy + } + } + + static func connections( + _ connections: [NotationTieConnection], + visibleIn measures: [ScoreMeasure] + ) -> [NotationTieConnection] { + let visibleMeasureIDs = Set(measures.map { measureIdentity($0.number, $0.startTime) }) + return connections.filter { connection in + visibleMeasureIDs.contains(measureIdentity( + connection.source.measureNumber, + connection.source.measureStartTime + )) || visibleMeasureIDs.contains(measureIdentity( + connection.target.measureNumber, + connection.target.measureStartTime + )) + } + } + + private static func endpoint( + measure: ScoreMeasure, + item: NotationMeasureItem + ) -> NotationTieEndpoint { + NotationTieEndpoint( + measureNumber: measure.number, + measureStartTime: measure.startTime, + measureAttributes: measure.attributes, + item: item + ) + } + + private static func measureIdentity(_ number: Int, _ startTime: TimeInterval) -> String { + "\(number)-\(startTime)" + } + + private static func areContiguous( + source: NotationMeasureItem, + sourceMeasure: ScoreMeasure, + target: NotationMeasureItem, + targetMeasure: ScoreMeasure + ) -> Bool { + let sourceEnd = source.offsetInQuarterNotes + source.durationInQuarterNotes + if sourceMeasure.number == targetMeasure.number, + abs(sourceMeasure.startTime - targetMeasure.startTime) < NotationMeasureTiming.timelineTolerance { + return abs(sourceEnd - target.offsetInQuarterNotes) < NotationMeasureTiming.timelineTolerance + } + + let sourceLength = NotationMeasureTiming.quarterLength(for: sourceMeasure.attributes.timeSignature) + return targetMeasure.number == sourceMeasure.number + 1 + && abs(sourceEnd - sourceLength) < NotationMeasureTiming.timelineTolerance + && abs(target.offsetInQuarterNotes) < NotationMeasureTiming.timelineTolerance + } + +} + +struct NotationNoteInsertionMeasureReplacement: Equatable { + var measureNumber: Int + var measureStartTime: TimeInterval + var partID: NotationPartID + var items: [NotationMeasureItem] +} + +struct NotationNoteInsertionPlan: Equatable { + var replacements: [NotationNoteInsertionMeasureReplacement] + var firstItemID: String + var firstMeasureNumber: Int + var firstMeasureStartTime: TimeInterval + var finalItemID: String + var finalMeasureNumber: Int + var finalMeasureStartTime: TimeInterval + var partID: NotationPartID +} + +enum NotationNoteInsertionPlanner { + static func plan( + in measures: [ScoreMeasure], + sourceItemID: String, + selectedDuration: NotationDuration + ) -> NotationNoteInsertionPlan? { + guard let context = planningContext( + in: measures, + sourceItemID: sourceItemID, + selectedDuration: selectedDuration + ) else { return nil } + + return materializedPlan(from: context) + } + + static func planInsertion( + in measures: [ScoreMeasure], + placement: NotationNotePlacement + ) -> NotationNoteInsertionPlan? { + guard let context = insertionPlanningContext( + in: measures, + placement: placement + ) else { return nil } + + return materializedPlan(from: context) + } + + static func canPlanInsertion( + in measures: [ScoreMeasure], + placement: NotationNotePlacement + ) -> Bool { + insertionPlanningContext(in: measures, placement: placement) != nil + } + + private static func insertionPlanningContext( + in measures: [ScoreMeasure], + placement: NotationNotePlacement + ) -> PlanningContext? { + let orderedMeasures = measures.sorted(by: notationTieMeasureSort) + guard let measureIndex = orderedMeasures.firstIndex(where: { + $0.number == placement.measure.number + && abs($0.startTime - placement.measure.startTime) < NotationMeasureTiming.timelineTolerance + && abs($0.endTime - placement.measure.endTime) < NotationMeasureTiming.timelineTolerance + }), orderedMeasures[measureIndex].notationItems.contains(where: { + $0.kind == .rest + && $0.partID == placement.partID + && $0.id == placement.targetRestID + && abs($0.offsetInQuarterNotes - placement.offsetInQuarterNotes) + < NotationMeasureTiming.timelineTolerance + }), placement.displayDuration.durationInQuarterNotes > NotationMeasureTiming.timelineTolerance, + abs( + placement.durationInQuarterNotes - placement.displayDuration.durationInQuarterNotes + ) < NotationMeasureTiming.timelineTolerance, + let consumedSpans = consumedRestSpans( + in: orderedMeasures, + startMeasureIndex: measureIndex, + startOffset: placement.offsetInQuarterNotes, + partID: placement.partID, + requiredDuration: placement.durationInQuarterNotes + ) + else { + return nil + } + + return PlanningContext( + orderedMeasures: orderedMeasures, + sourceLocation: nil, + partID: placement.partID, + pitch: placement.pitch, + selectedDuration: placement.displayDuration, + consumedSpans: consumedSpans + ) + } + + private static func materializedPlan( + from context: PlanningContext + ) -> NotationNoteInsertionPlan? { + let orderedMeasures = context.orderedMeasures + let consumedSpans = context.consumedSpans + + var insertedByMeasureIndex: [Int: [NotationMeasureItem]] = [:] + var insertedItems: [NotationMeasureItem] = [] + let usesSelectedDurationDirectly = consumedSpans.count == 1 + && abs(consumedSpans[0].duration - context.selectedDuration.durationInQuarterNotes) + < NotationMeasureTiming.timelineTolerance + + for span in consumedSpans { + let durations: [NotationDuration] + if usesSelectedDurationDirectly { + durations = [context.selectedDuration] + } else { + guard let decomposition = generatedDurations(for: span.duration) else { + return nil + } + durations = decomposition + } + + let measure = orderedMeasures[span.measureIndex] + var offset = span.startOffset + for duration in durations { + let item = NotationMeasureItem( + partID: context.partID, + kind: .note, + pitch: context.pitch, + measureNumber: measure.number, + measureStartTime: measure.startTime, + offsetInQuarterNotes: offset, + durationInQuarterNotes: duration.durationInQuarterNotes, + displayDuration: duration + ) + insertedByMeasureIndex[span.measureIndex, default: []].append(item) + insertedItems.append(item) + offset += duration.durationInQuarterNotes + } + } + + guard let firstInserted = insertedItems.first, + let finalInserted = insertedItems.last + else { + return nil + } + + var linkedItemsByID: [String: NotationMeasureItem] = [:] + if let sourceLocation = context.sourceLocation { + var linkedSource = sourceLocation.item.persistedCopy() + linkedSource.tieTargetItemID = firstInserted.id + linkedItemsByID[linkedSource.id] = linkedSource + } + for index in insertedItems.indices { + var item = insertedItems[index] + item.tieTargetItemID = index < insertedItems.index(before: insertedItems.endIndex) + ? insertedItems[insertedItems.index(after: index)].id + : nil + linkedItemsByID[item.id] = item + } + + var affectedMeasureIndices = Set(consumedSpans.map(\.measureIndex)) + if let sourceLocation = context.sourceLocation { + affectedMeasureIndices.insert(sourceLocation.measureIndex) + } + let replacements = affectedMeasureIndices.sorted().compactMap { measureIndex -> NotationNoteInsertionMeasureReplacement? in + let measure = orderedMeasures[measureIndex] + let span = consumedSpans.first { $0.measureIndex == measureIndex } + let inserted = insertedByMeasureIndex[measureIndex, default: []].compactMap { + linkedItemsByID[$0.id] + } + let items = replacementItems( + in: measure, + consumedSpan: span, + insertedItems: inserted, + linkedItemsByID: linkedItemsByID + ) + return NotationNoteInsertionMeasureReplacement( + measureNumber: measure.number, + measureStartTime: measure.startTime, + partID: context.partID, + items: items + ) + } + + return NotationNoteInsertionPlan( + replacements: replacements, + firstItemID: firstInserted.id, + firstMeasureNumber: firstInserted.measureNumber, + firstMeasureStartTime: firstInserted.measureStartTime, + finalItemID: finalInserted.id, + finalMeasureNumber: finalInserted.measureNumber, + finalMeasureStartTime: finalInserted.measureStartTime, + partID: context.partID + ) + } + + private struct SourceLocation { + var measureIndex: Int + var item: NotationMeasureItem + } + + private struct PlanningContext { + var orderedMeasures: [ScoreMeasure] + var sourceLocation: SourceLocation? + var partID: NotationPartID + var pitch: NotationPitch + var selectedDuration: NotationDuration + var consumedSpans: [ConsumedSpan] + } + + private struct ConsumedSpan { + var measureIndex: Int + var startOffset: Double + var duration: Double + + var endOffset: Double { startOffset + duration } + } + + private static func planningContext( + in measures: [ScoreMeasure], + sourceItemID: String, + selectedDuration: NotationDuration + ) -> PlanningContext? { + let orderedMeasures = measures.sorted(by: notationTieMeasureSort) + guard let sourceLocation = sourceLocation( + in: orderedMeasures, + sourceItemID: sourceItemID + ), + !sourceLocation.item.isSynthesized, + sourceLocation.item.kind == .note, + let pitch = sourceLocation.item.pitch, + sourceLocation.item.tieTargetItemID == nil, + selectedDuration.durationInQuarterNotes > NotationMeasureTiming.timelineTolerance, + let consumedSpans = consumedRestSpans( + in: orderedMeasures, + startMeasureIndex: sourceLocation.measureIndex, + startOffset: sourceLocation.item.offsetInQuarterNotes + + sourceLocation.item.durationInQuarterNotes, + partID: sourceLocation.item.partID, + requiredDuration: selectedDuration.durationInQuarterNotes + ) + else { + return nil + } + + return PlanningContext( + orderedMeasures: orderedMeasures, + sourceLocation: sourceLocation, + partID: sourceLocation.item.partID, + pitch: pitch, + selectedDuration: selectedDuration, + consumedSpans: consumedSpans + ) + } + + private static func sourceLocation( + in measures: [ScoreMeasure], + sourceItemID: String + ) -> SourceLocation? { + for measureIndex in measures.indices { + if let item = measures[measureIndex].notationItems.first(where: { $0.id == sourceItemID }) { + return SourceLocation(measureIndex: measureIndex, item: item) + } + } + return nil + } + + private static func consumedRestSpans( + in measures: [ScoreMeasure], + startMeasureIndex: Int, + startOffset: Double, + partID: NotationPartID, + requiredDuration: Double + ) -> [ConsumedSpan]? { + var measureIndex = startMeasureIndex + var cursor = startOffset + var remaining = requiredDuration + var spans: [ConsumedSpan] = [] + + while remaining > NotationMeasureTiming.timelineTolerance { + guard measures.indices.contains(measureIndex) else { return nil } + let measure = measures[measureIndex] + let measureLength = NotationMeasureTiming.quarterLength(for: measure.attributes.timeSignature) + if cursor >= measureLength - NotationMeasureTiming.timelineTolerance { + measureIndex += 1 + cursor = 0 + continue + } + + let orderedItems = measure.notationItems.sorted(by: notationTieItemSort) + guard let rest = orderedItems.first(where: { item in + let end = item.offsetInQuarterNotes + item.durationInQuarterNotes + return item.kind == .rest + && item.partID == partID + && item.offsetInQuarterNotes <= cursor + NotationMeasureTiming.timelineTolerance + && end > cursor + NotationMeasureTiming.timelineTolerance + }) else { + return nil + } + + let restEnd = min( + measureLength, + rest.offsetInQuarterNotes + rest.durationInQuarterNotes + ) + let consumed = min(remaining, restEnd - cursor) + guard consumed > NotationMeasureTiming.timelineTolerance else { return nil } + + if let lastIndex = spans.indices.last, spans[lastIndex].measureIndex == measureIndex { + guard abs(spans[lastIndex].endOffset - cursor) < NotationMeasureTiming.timelineTolerance else { + return nil + } + spans[lastIndex].duration += consumed + } else { + spans.append(ConsumedSpan( + measureIndex: measureIndex, + startOffset: cursor, + duration: consumed + )) + } + + cursor += consumed + remaining -= consumed + } + + return spans + } + + private static func generatedDurations(for length: Double) -> [NotationDuration]? { + var candidates = NotationDuration.entryDenominators.flatMap { denominator in + [ + NotationDuration(denominator: denominator, isDotted: true), + NotationDuration(denominator: denominator) + ] + } + candidates.append(NotationDuration(denominator: 32)) + candidates.sort { $0.durationInQuarterNotes > $1.durationInQuarterNotes } + + var remaining = length + var output: [NotationDuration] = [] + for duration in candidates { + while remaining >= duration.durationInQuarterNotes - NotationMeasureTiming.timelineTolerance { + output.append(duration) + remaining -= duration.durationInQuarterNotes + } + } + + return abs(remaining) < NotationMeasureTiming.timelineTolerance ? output : nil + } + + private static func replacementItems( + in measure: ScoreMeasure, + consumedSpan: ConsumedSpan?, + insertedItems: [NotationMeasureItem], + linkedItemsByID: [String: NotationMeasureItem] + ) -> [NotationMeasureItem] { + var output: [NotationMeasureItem] = [] + let orderedItems = measure.notationItems.sorted(by: notationTieItemSort) + + for item in orderedItems { + if let linked = linkedItemsByID[item.id] { + output.append(linked) + continue + } + + guard let consumedSpan else { + if !item.isSynthesized { output.append(item.persistedCopy()) } + continue + } + + let itemStart = item.offsetInQuarterNotes + let itemEnd = itemStart + item.durationInQuarterNotes + let overlaps = item.kind == .rest + && itemEnd > consumedSpan.startOffset + NotationMeasureTiming.timelineTolerance + && itemStart < consumedSpan.endOffset - NotationMeasureTiming.timelineTolerance + if overlaps { + if itemStart < consumedSpan.startOffset - NotationMeasureTiming.timelineTolerance { + output.append(contentsOf: NotationRestItemFactory.metricAwareRestItems( + in: measure, + partID: item.partID, + startOffset: itemStart, + remaining: consumedSpan.startOffset - itemStart + )) + } + if itemEnd > consumedSpan.endOffset + NotationMeasureTiming.timelineTolerance { + output.append(contentsOf: NotationRestItemFactory.metricAwareRestItems( + in: measure, + partID: item.partID, + startOffset: consumedSpan.endOffset, + remaining: itemEnd - consumedSpan.endOffset + )) + } + continue + } + + if !item.isSynthesized { output.append(item.persistedCopy()) } + } + + output.append(contentsOf: insertedItems) + return output.sorted(by: notationTieItemSort) + } + +} + +enum NotationTieContinuationBoundary { + static func endTime( + in measures: [ScoreMeasure], + sourceItemID: String, + continuationDurationInQuarterNotes: Double + ) -> TimeInterval? { + let orderedMeasures = measures.sorted(by: notationTieMeasureSort) + guard continuationDurationInQuarterNotes > NotationMeasureTiming.timelineTolerance, + let sourceMeasureIndex = orderedMeasures.firstIndex(where: { measure in + measure.notationItems.contains(where: { $0.id == sourceItemID }) + }), + let sourceItem = orderedMeasures[sourceMeasureIndex].notationItems.first(where: { + $0.id == sourceItemID + }) + else { + return nil + } + + var measureIndex = sourceMeasureIndex + var cursor = sourceItem.offsetInQuarterNotes + sourceItem.durationInQuarterNotes + var remaining = continuationDurationInQuarterNotes + + while remaining > NotationMeasureTiming.timelineTolerance { + guard orderedMeasures.indices.contains(measureIndex) else { return nil } + let measure = orderedMeasures[measureIndex] + let measureLength = NotationMeasureTiming.quarterLength( + for: measure.attributes.timeSignature + ) + guard cursor <= measureLength + NotationMeasureTiming.timelineTolerance else { + return nil + } + + if cursor >= measureLength - NotationMeasureTiming.timelineTolerance { + measureIndex += 1 + cursor = 0 + continue + } + + let consumed = min(remaining, measureLength - cursor) + cursor += consumed + remaining -= consumed + + if remaining <= NotationMeasureTiming.timelineTolerance { + return NotationMeasureTiming.time(forQuarterOffset: cursor, in: measure) + } + + measureIndex += 1 + cursor = 0 + } + + return nil + } + +} + +private func notationTieMeasureSort( + _ lhs: ScoreMeasure, + _ rhs: ScoreMeasure +) -> Bool { + if lhs.number != rhs.number { return lhs.number < rhs.number } + return lhs.startTime < rhs.startTime +} + +private func notationTieItemSort( + _ lhs: NotationMeasureItem, + _ rhs: NotationMeasureItem +) -> Bool { + if abs(lhs.offsetInQuarterNotes - rhs.offsetInQuarterNotes) > NotationMeasureTiming.timelineTolerance { + return lhs.offsetInQuarterNotes < rhs.offsetInQuarterNotes + } + return lhs.id < rhs.id +} diff --git a/JammLab/Services/NotationViewportFactory.swift b/JammLab/Services/NotationViewportFactory.swift index f223a80..7062fbe 100644 --- a/JammLab/Services/NotationViewportFactory.swift +++ b/JammLab/Services/NotationViewportFactory.swift @@ -120,7 +120,8 @@ struct NotationViewportFactory { keySignature: content.keySignature, measures: content.measures, anchorTime: Self.viewportAnchorTime(rawAnchorTime, in: activeMeasure), - activeMeasureNumber: activeMeasure.number + activeMeasureNumber: activeMeasure.number, + tieConnections: NotationTieResolver.connections(in: content.measures) ) } @@ -197,6 +198,7 @@ struct NotationViewportFactory { } let activeMeasure = content.measures[activeMeasureIndex] + let scoreTieConnections = NotationTieResolver.connections(in: content.measures) return NotationViewportState( availability: .ready, clef: firstVisibleMeasure.attributes.clef, @@ -206,7 +208,11 @@ struct NotationViewportFactory { visibleMeasureCount: safeVisibleMeasureCount, visibleMeasures: visibleMeasures, anchorTime: Self.viewportAnchorTime(rawAnchorTime, in: activeMeasure), - activeMeasureNumber: activeMeasure.number + activeMeasureNumber: activeMeasure.number, + tieConnections: NotationTieResolver.connections( + scoreTieConnections, + visibleIn: visibleMeasures + ) ) } diff --git a/JammLab/Utilities/AppHotkey.swift b/JammLab/Utilities/AppHotkey.swift index f5c5207..0656c6a 100644 --- a/JammLab/Utilities/AppHotkey.swift +++ b/JammLab/Utilities/AppHotkey.swift @@ -26,6 +26,7 @@ enum AppHotkey: CaseIterable, Hashable { case setNotationDurationHalf case setNotationDurationWhole case toggleNotationDurationDot + case addTiedNotationNote static let notationDurationHotkeys = Set(allCases.filter { $0.notationDurationDenominator != nil @@ -101,6 +102,8 @@ enum AppHotkey: CaseIterable, Hashable { self = .setNotationDurationWhole case 47, 65: self = .toggleNotationDurationDot + case 17: + self = .addTiedNotationNote case 53: self = .clearNotationMeasureSelection default: @@ -158,6 +161,8 @@ enum AppHotkey: CaseIterable, Hashable { return "7" case .toggleNotationDurationDot: return ".; Num.; Num," + case .addTiedNotationNote: + return "T" } } @@ -211,6 +216,8 @@ enum AppHotkey: CaseIterable, Hashable { return "Set Whole Note Duration" case .toggleNotationDurationDot: return "Augmentation dot" + case .addTiedNotationNote: + return "Tie" } } @@ -264,6 +271,8 @@ enum AppHotkey: CaseIterable, Hashable { return "Set notation duration to whole notes for the selected notation item." case .toggleNotationDurationDot: return "Toggle duration dot" + case .addTiedNotationNote: + return "Add tied note" } } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Markers.swift b/JammLab/ViewModels/AudioPlayerViewModel+Markers.swift index 4603311..93f6e5f 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Markers.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Markers.swift @@ -47,7 +47,7 @@ extension AudioPlayerViewModel { notes.append(note) notes.sort { $0.time < $1.time } clearNotationMeasureSelection() - applyTempoMapToPlaybackEngine() + applyNotationAffectingTempoMapChange() } } @@ -176,7 +176,7 @@ extension AudioPlayerViewModel { ) else { notes.remove(at: index) clearNotationMeasureSelection() - applyTempoMapToPlaybackEngine() + applyNotationAffectingTempoMapChange() return } @@ -184,7 +184,7 @@ extension AudioPlayerViewModel { notes[index].title = payload.title notes.sort { $0.time < $1.time } clearNotationMeasureSelection() - applyTempoMapToPlaybackEngine() + applyNotationAffectingTempoMapChange() } } @@ -215,7 +215,7 @@ extension AudioPlayerViewModel { notes.sort { $0.time < $1.time } if notes.contains(where: { $0.id == id && $0.isTempoTimeSignatureMarker }) { clearNotationMeasureSelection() - applyTempoMapToPlaybackEngine() + applyNotationAffectingTempoMapChange() } } } @@ -236,8 +236,10 @@ extension AudioPlayerViewModel { if deletesTempoMapMarker { clearNotationMeasureSelection() + applyNotationAffectingTempoMapChange() + } else { + applyTempoMapToPlaybackEngine() } - applyTempoMapToPlaybackEngine() } } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift index 6d6616e..11eadf2 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift @@ -1,5 +1,27 @@ import Foundation +private struct TiedNotationNoteInput { + var measures: [ScoreMeasure] + var sourceItem: NotationMeasureItem +} + +private enum TiedNotationNoteCommandResolution { + case unavailable + case blocked(NotationTieCommandBlockReason) + case ready(NotationNoteInsertionPlan) + + var status: NotationTieCommandStatus { + switch self { + case .unavailable: + return .unavailable + case let .blocked(reason): + return .blocked(reason) + case .ready: + return .ready + } + } +} + extension AudioPlayerViewModel { var canShowNotationWindow: Bool { duration > 0 @@ -165,6 +187,40 @@ extension AudioPlayerViewModel { notationEntryMode == .rest } + var tieCommandStatus: NotationTieCommandStatus { + resolveTiedNotationNoteCommand().status + } + + var isTieCommandInScope: Bool { + tieCommandStatus.isInCommandScope + } + + /// Returns whether the command should consume its input event, including a blocked no-op. + @discardableResult + func handleAddTiedNotationNoteCommand() -> Bool { + switch resolveTiedNotationNoteCommand() { + case .unavailable: + return false + case .blocked: + return true + case let .ready(plan): + performUndoableEdit("Add Tied Notation Note") { + applyNotationNoteInsertionPlan(plan) + selectedNotationMeasures = [] + notationMeasureSelectionAnchor = nil + selectedHarmonySymbolID = nil + reselectNotationItem( + inMeasureNumber: plan.finalMeasureNumber, + measureStartTime: plan.finalMeasureStartTime, + itemID: plan.finalItemID, + partID: plan.partID + ) + } + + return true + } + } + func toggleNotationNoteEntryMode() { setNotationNoteEntryModeEnabled(!isNotationNoteEntryModeEnabled) } @@ -330,43 +386,18 @@ extension AudioPlayerViewModel { @discardableResult func insertNotationNote(_ placement: NotationNotePlacement) -> Bool { - guard duration > 0, - let measure = currentNotationScoreMeasures(partID: placement.partID).first(where: { - $0.number == placement.measure.number - && abs($0.startTime - placement.measure.startTime) < NotationMeasureTiming.timelineTolerance - && abs($0.endTime - placement.measure.endTime) < NotationMeasureTiming.timelineTolerance - }), - let restSpan = NotationNotePlacementResolver.restSpan(in: measure, matching: placement) - else { - return false - } - - let noteItem = NotationMeasureItem( - partID: placement.partID, - kind: .note, - pitch: placement.pitch, - measureNumber: measure.number, - measureStartTime: measure.startTime, - offsetInQuarterNotes: restSpan.startOffsetInQuarterNotes, - durationInQuarterNotes: placement.durationInQuarterNotes, - displayDuration: placement.displayDuration - ) - let recomposedItems = NotationEntryRecomposer.recomposedItems( - in: measure, - replacing: restSpan, - with: noteItem - ) + guard let plan = notationNoteInsertionPlan(for: placement) else { return false } performUndoableEdit("Add Notation Note") { - replaceNotationMeasureItems(in: measure, with: recomposedItems) + applyNotationNoteInsertionPlan(plan) selectedNotationMeasures = [] notationMeasureSelectionAnchor = nil selectedHarmonySymbolID = nil reselectNotationItem( - inMeasureNumber: measure.number, - measureStartTime: measure.startTime, - itemID: noteItem.id, - partID: placement.partID + inMeasureNumber: plan.firstMeasureNumber, + measureStartTime: plan.firstMeasureStartTime, + itemID: plan.firstItemID, + partID: plan.partID ) } @@ -374,6 +405,14 @@ extension AudioPlayerViewModel { return true } + func canInsertNotationNote(_ placement: NotationNotePlacement) -> Bool { + guard duration > 0 else { return false } + return NotationNoteInsertionPlanner.canPlanInsertion( + in: currentNotationScoreMeasures(partID: placement.partID), + placement: placement + ) + } + @discardableResult func insertNotationRest(_ placement: NotationRestPlacement) -> Bool { guard duration > 0, @@ -456,7 +495,8 @@ extension AudioPlayerViewModel { measureStartTime: item.measureStartTime, offsetInQuarterNotes: item.offsetInQuarterNotes, durationInQuarterNotes: item.durationInQuarterNotes, - displayDuration: item.displayDuration + displayDuration: item.displayDuration, + tieTargetItemID: item.tieTargetItemID ) performUndoableEdit("Change Notation Note Pitch") { @@ -548,12 +588,18 @@ extension AudioPlayerViewModel { @discardableResult func copySelectedNotationMeasure() -> Bool { guard let measures = validatedSelectedNotationMeasures() else { return false } + let copiedNotationItemIDs = Set(measures.flatMap { measure in + measure.notationItems.filter { !$0.isSynthesized }.map(\.id) + }) notationMeasureClipboard = NotationMeasureClipboard( measures: measures.map { measure in NotationMeasureClipboardMeasure( items: notationClipboardItems(in: measure), - notationItems: notationClipboardNotationItems(in: measure) + notationItems: notationClipboardNotationItems( + in: measure, + copiedItemIDs: copiedNotationItemIDs + ) ) } ) @@ -592,10 +638,18 @@ extension AudioPlayerViewModel { .sorted(by: notationClipboardNotationItemSort) } let currentItemsByMeasure = targetMeasures.map { notationClipboardItems(in: $0) } - let currentNotationItemsByMeasure = targetMeasures.map { notationClipboardNotationItems(in: $0) } + let targetItemIDs = Set(targetMeasures.flatMap { measure in + measure.notationItems.filter { !$0.isSynthesized }.map(\.id) + }) + let currentNotationItemsByMeasure = targetMeasures.map { + notationClipboardNotationItems(in: $0, copiedItemIDs: targetItemIDs) + } guard currentItemsByMeasure != pastedItemsByMeasure - || currentNotationItemsByMeasure != pastedNotationItemsByMeasure + || !notationClipboardContentsAreEqual( + currentNotationItemsByMeasure, + pastedNotationItemsByMeasure + ) else { selectedHarmonySymbolID = nil selectedNotationItem = nil @@ -604,6 +658,11 @@ extension AudioPlayerViewModel { return true } + let pastedSourceItemIDs = pastedNotationItemsByMeasure.flatMap { $0.map(\.sourceItemID) } + let pastedItemIDsBySourceID = pastedSourceItemIDs.reduce(into: [String: String]()) { + if $0[$1] == nil { $0[$1] = UUID().uuidString } + } + performUndoableEdit(targetMeasures.count == 1 ? "Paste Measure" : "Paste Measures") { harmonySymbols.removeAll { symbol in guard targetPartID == .main else { return false } @@ -633,6 +692,7 @@ extension AudioPlayerViewModel { }) notationItems.append(contentsOf: pastedNotationItems.map { item in NotationMeasureItem( + id: pastedItemIDsBySourceID[item.sourceItemID] ?? UUID().uuidString, partID: targetPartID, kind: item.kind, pitch: item.kind == .note ? item.pitch : nil, @@ -640,13 +700,17 @@ extension AudioPlayerViewModel { measureStartTime: targetMeasure.startTime, offsetInQuarterNotes: item.offsetInQuarterNotes, durationInQuarterNotes: item.durationInQuarterNotes, - displayDuration: item.displayDuration + displayDuration: item.displayDuration, + tieTargetItemID: item.tieTargetItemID.flatMap { + pastedItemIDsBySourceID[$0] + } ) }) } harmonySymbols = ProjectStateNormalizer.normalizedHarmonySymbols(harmonySymbols, duration: duration) notationItems = ProjectStateNormalizer.normalizedNotationItems(notationItems, duration: duration) + sanitizeNotationTieRelationships() selectedHarmonySymbolID = nil selectedNotationItem = nil selectedNotationMeasures = targetMeasures.map { NotationMeasureSelection(measure: $0, partID: targetPartID) } @@ -856,21 +920,66 @@ extension AudioPlayerViewModel { } } - private func notationClipboardNotationItems(in measure: ScoreMeasure) -> [NotationMeasureClipboardNotationItem] { + private func notationClipboardNotationItems( + in measure: ScoreMeasure, + copiedItemIDs: Set + ) -> [NotationMeasureClipboardNotationItem] { measure.notationItems .filter { !$0.isSynthesized } .map { NotationMeasureClipboardNotationItem( + sourceItemID: $0.id, kind: $0.kind, pitch: $0.pitch, offsetInQuarterNotes: $0.offsetInQuarterNotes, durationInQuarterNotes: $0.durationInQuarterNotes, - displayDuration: $0.displayDuration + displayDuration: $0.displayDuration, + tieTargetItemID: $0.tieTargetItemID.flatMap { + copiedItemIDs.contains($0) ? $0 : nil + } ) } .sorted(by: notationClipboardNotationItemSort) } + private struct NotationClipboardSemanticItem: Equatable { + var kind: NotationMeasureItem.Kind + var pitch: NotationPitch? + var offsetInQuarterNotes: Double + var durationInQuarterNotes: Double + var displayDuration: NotationDuration + var tieTargetPosition: String? + } + + private func notationClipboardContentsAreEqual( + _ lhs: [[NotationMeasureClipboardNotationItem]], + _ rhs: [[NotationMeasureClipboardNotationItem]] + ) -> Bool { + canonicalNotationClipboardContents(lhs) == canonicalNotationClipboardContents(rhs) + } + + private func canonicalNotationClipboardContents( + _ contents: [[NotationMeasureClipboardNotationItem]] + ) -> [[NotationClipboardSemanticItem]] { + let positionsBySourceID = contents.enumerated().reduce(into: [String: String]()) { output, measure in + for (itemIndex, item) in measure.element.enumerated() where output[item.sourceItemID] == nil { + output[item.sourceItemID] = "\(measure.offset):\(itemIndex)" + } + } + return contents.map { measureItems in + measureItems.map { item in + NotationClipboardSemanticItem( + kind: item.kind, + pitch: item.pitch, + offsetInQuarterNotes: item.offsetInQuarterNotes, + durationInQuarterNotes: item.durationInQuarterNotes, + displayDuration: item.displayDuration, + tieTargetPosition: item.tieTargetItemID.flatMap { positionsBySourceID[$0] } + ) + } + } + } + private func validatedSelectedNotationMeasures() -> [ScoreMeasure]? { let measures = currentSelectedNotationMeasures() guard !measures.isEmpty else { @@ -979,6 +1088,99 @@ extension AudioPlayerViewModel { } notationItems.append(contentsOf: replacementItems) notationItems = ProjectStateNormalizer.normalizedNotationItems(notationItems, duration: duration) + sanitizeNotationTieRelationships() + } + + func sanitizeNotationTieRelationships() { + guard notationItems.contains(where: { $0.tieTargetItemID != nil }) else { return } + let partIDs = Set(notationItems.map(\.partID)) + let validConnections = partIDs.flatMap { partID in + NotationTieResolver.connections(in: currentNotationScoreMeasures(partID: partID)) + } + notationItems = NotationTieResolver.sanitizedPersistedItems( + notationItems, + validConnections: validConnections + ) + } + + private func applyNotationNoteInsertionPlan(_ plan: NotationNoteInsertionPlan) { + for replacement in plan.replacements { + notationItems.removeAll { item in + item.partID == replacement.partID + && item.measureNumber == replacement.measureNumber + && abs(item.measureStartTime - replacement.measureStartTime) + < NotationMeasureTiming.timelineTolerance + } + notationItems.append(contentsOf: replacement.items) + } + notationItems = ProjectStateNormalizer.normalizedNotationItems( + notationItems, + duration: duration + ) + sanitizeNotationTieRelationships() + } + + private func notationNoteInsertionPlan( + for placement: NotationNotePlacement + ) -> NotationNoteInsertionPlan? { + guard duration > 0 else { return nil } + return NotationNoteInsertionPlanner.planInsertion( + in: currentNotationScoreMeasures(partID: placement.partID), + placement: placement + ) + } + + private func tiedNotationNoteInput() -> TiedNotationNoteInput? { + guard duration > 0, + let selection = selectedNotationItem + else { + return nil + } + + let measures = currentNotationScoreMeasures(partID: selection.partID) + guard let measure = measures.first(where: { + $0.number == selection.measureNumber + && abs($0.startTime - selection.measureStartTime) < NotationMeasureTiming.timelineTolerance + }), let item = measure.notationItems.first(where: { + $0.id == selection.itemID && $0.partID == selection.partID + }), item.kind == .note, !item.isSynthesized, item.pitch != nil else { + return nil + } + + return TiedNotationNoteInput( + measures: measures, + sourceItem: item + ) + } + + private func resolveTiedNotationNoteCommand() -> TiedNotationNoteCommandResolution { + guard let input = tiedNotationNoteInput() else { + return isNotationNoteEntryModeEnabled + ? .blocked(.selectNote) + : .unavailable + } + + guard input.sourceItem.tieTargetItemID == nil else { + return .blocked(.alreadyTied) + } + + guard let continuationEndTime = NotationTieContinuationBoundary.endTime( + in: input.measures, + sourceItemID: input.sourceItem.id, + continuationDurationInQuarterNotes: input.sourceItem.displayDuration.durationInQuarterNotes + ), continuationEndTime <= duration + NotationMeasureTiming.timelineTolerance else { + return .blocked(.audioBoundary) + } + + guard let plan = NotationNoteInsertionPlanner.plan( + in: input.measures, + sourceItemID: input.sourceItem.id, + selectedDuration: input.sourceItem.displayDuration + ) else { + return .blocked(.noFreeFollowingDuration) + } + + return .ready(plan) } private func selectedNotationNotePitchChange(byStaffPositionDelta staffPositionDelta: Int) -> NotationPitch? { diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift index f621a55..755737d 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift @@ -258,6 +258,7 @@ extension AudioPlayerViewModel { duration: resolvedProjectDuration ) notationPartClefs = NotationPartClefOverrides.normalized(project.notationPartClefs) + sanitizeNotationTieRelationships() projectKeySelection = project.projectKeySelection clearTransientEditingState() loopRegion = ProjectStateNormalizer.normalizedLoopRegion( diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift b/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift index 676d370..8f2bb42 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift @@ -27,6 +27,11 @@ extension AudioPlayerViewModel { playbackEngine.setTempoMap(tempoMap) } + func applyNotationAffectingTempoMapChange() { + applyTempoMapToPlaybackEngine() + sanitizeNotationTieRelationships() + } + func setTempoBPM(_ bpm: Double) { performUndoableEdit("Change Tempo") { tempoBPM = ProjectStateNormalizer.normalizedTempo(bpm) @@ -34,7 +39,7 @@ extension AudioPlayerViewModel { beatGridSettings.lastChangedAt = Date() shouldAcceptAnalyzedTempo = false clearNotationMeasureSelection() - applyTempoMapToPlaybackEngine() + applyNotationAffectingTempoMapChange() } } @@ -44,7 +49,7 @@ extension AudioPlayerViewModel { beatGridSettings.lastChangedAt = Date() shouldAcceptAnalyzedTempo = false clearNotationMeasureSelection() - applyTempoMapToPlaybackEngine() + applyNotationAffectingTempoMapChange() } } @@ -126,7 +131,7 @@ extension AudioPlayerViewModel { beatGridSettings.firstBeatTime = max(0, min(time, duration)) beatGridSettings.alignmentSource = source beatGridSettings.lastChangedAt = Date() - applyTempoMapToPlaybackEngine() + applyNotationAffectingTempoMapChange() } func snappedTimelineTime(_ time: TimeInterval) -> TimeInterval { diff --git a/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift b/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift index f899920..7f04522 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift @@ -84,6 +84,7 @@ extension AudioPlayerViewModel { harmonySymbols = ProjectStateNormalizer.normalizedHarmonySymbols(state.harmonySymbols, duration: duration) notationItems = ProjectStateNormalizer.normalizedNotationItems(state.notationItems, duration: duration) notationPartClefs = NotationPartClefOverrides.normalized(state.notationPartClefs) + sanitizeNotationTieRelationships() stemNotationTrackCollapsed = state.stemNotationTrackCollapsed visibleNotationPartIDs = normalizedVisibleNotationPartIDs(from: state.visibleNotationPartIDs) projectKeySelection = state.projectKeySelection diff --git a/JammLab/Views/Components/AppControls.swift b/JammLab/Views/Components/AppControls.swift index 57bf58f..9f323db 100644 --- a/JammLab/Views/Components/AppControls.swift +++ b/JammLab/Views/Components/AppControls.swift @@ -124,6 +124,22 @@ struct NotationAugmentationDotButton: View { } } +struct NotationTieButton: View { + let status: NotationTieCommandStatus + let action: () -> Void + + var body: some View { + NotationSelectableButton(isActive: false, action: action) { iconColor in + NotationTieControlGlyphView(color: iconColor) + .accessibilityHidden(true) + } + .disabled(!status.isInCommandScope) + .help(Text(NotationTieHelpText.tooltip(for: status))) + .accessibilityLabel(NotationTieHelpText.accessibilityLabel) + .accessibilityHint(Text(NotationTieHelpText.accessibilityHint(for: status))) + } +} + private struct NotationSelectableButton: View { let isActive: Bool let action: () -> Void @@ -175,6 +191,75 @@ enum NotationAugmentationDotHelpText { static let accessibilityHint = AppHotkey.toggleNotationDurationDot.detail } +enum NotationTieHelpText { + private static let defaultTooltip = [ + "\(AppHotkey.addTiedNotationNote.title) (\(AppHotkey.addTiedNotationNote.key))", + AppHotkey.addTiedNotationNote.detail + ].joined(separator: "\n") + static let accessibilityLabel = AppHotkey.addTiedNotationNote.title + + static func tooltip(for status: NotationTieCommandStatus) -> String { + guard let blockedExplanation = blockedExplanation(for: status) else { + return defaultTooltip + } + return [defaultTooltip, blockedExplanation].joined(separator: "\n") + } + + static func accessibilityHint(for status: NotationTieCommandStatus) -> String { + blockedExplanation(for: status) ?? AppHotkey.addTiedNotationNote.detail + } + + private static func blockedExplanation( + for status: NotationTieCommandStatus + ) -> String? { + guard case let .blocked(reason) = status else { return nil } + switch reason { + case .selectNote: + return "Select a note to add a tie." + case .alreadyTied: + return "The selected note already starts a tie." + case .noFreeFollowingDuration: + return "There is not enough empty notation time after the selected note." + case .audioBoundary: + return "There is not enough audio time after the selected note." + } + } +} + +private struct NotationTieControlGlyphView: View { + let color: Color + + var body: some View { + Canvas { context, size in + let fontSize = AppTheme.ControlSize.notationDurationGlyphSize + if let notePath = NotationMusicFontRegistry.glyphPath( + for: NotationDurationControlSymbol.quarter, + fontSize: fontSize + ) { + for direction in [-1.0, 1.0] { + let transform = notePath.centeredTransform(in: size) + .translatedBy( + x: CGFloat(direction) * AppTheme.ControlSize.notationTieNoteOffsetX, + y: 0 + ) + context.fill(Path(notePath.path).applying(transform), with: .color(color)) + } + } + + let centerY = size.height / 2 + AppTheme.ControlSize.notationTieArcOffsetY + let tiePath = NotationTiePath.path( + start: CGPoint(x: size.width / 2 - AppTheme.ControlSize.notationTieNoteOffsetX, y: centerY), + end: CGPoint(x: size.width / 2 + AppTheme.ControlSize.notationTieNoteOffsetX, y: centerY), + placement: .below, + arcHeight: AppTheme.ControlSize.notationTieArcHeight, + endpointThickness: AppTheme.ControlSize.notationTieEndpointThickness, + midpointThickness: AppTheme.ControlSize.notationTieMidpointThickness + ) + context.fill(Path(tiePath), with: .color(color)) + } + } +} + private struct NotationAugmentationDotGlyphView: View { let color: Color diff --git a/JammLab/Views/Components/AppHotkeyMonitorView.swift b/JammLab/Views/Components/AppHotkeyMonitorView.swift index 368d39b..9dee00e 100644 --- a/JammLab/Views/Components/AppHotkeyMonitorView.swift +++ b/JammLab/Views/Components/AppHotkeyMonitorView.swift @@ -70,10 +70,14 @@ final class AppHotkeyMonitorNSView: NSView { guard let self else { return event } guard let hotkey = self.hotkey(for: event) else { return event } - return self.onHotkey?(hotkey) == true ? nil : event + return self.eventAfterHandling(event, hotkey: hotkey) } } + func eventAfterHandling(_ event: NSEvent, hotkey: AppHotkey) -> NSEvent? { + onHotkey?(hotkey) == true ? nil : event + } + func removeMonitor() { guard let monitor else { return } NSEvent.removeMonitor(monitor) diff --git a/JammLab/Views/MainWorkspacePanels.swift b/JammLab/Views/MainWorkspacePanels.swift index 0921c9d..558928e 100644 --- a/JammLab/Views/MainWorkspacePanels.swift +++ b/JammLab/Views/MainWorkspacePanels.swift @@ -181,6 +181,7 @@ extension ContentView { notationDurationDenominator: viewModel.notationDurationDenominator, notationDurationIsDotted: viewModel.notationDurationIsDotted, canChangeNotationDuration: viewModel.canChangeNotationDuration, + tieCommandStatus: viewModel.tieCommandStatus, notationEntryMode: viewModel.notationEntryMode, isNotationTrackCollapsed: viewModel.isNotationTrackCollapsed, stemNotationTrackCollapsed: viewModel.stemNotationTrackCollapsed, @@ -284,6 +285,8 @@ extension ContentView { notationDurationDotToggled: { viewModel.toggleNotationDurationDot() }, notationNoteEntryModeToggled: { viewModel.toggleNotationNoteEntryMode() }, notationRestEntryModeToggled: { viewModel.toggleNotationRestEntryMode() }, + addTiedNotationNote: { viewModel.handleAddTiedNotationNoteCommand() }, + canInsertNotationNote: { viewModel.canInsertNotationNote($0) }, insertNotationNote: { viewModel.insertNotationNote($0) }, insertNotationRest: { viewModel.insertNotationRest($0) }, changeSelectedNotePitch: { viewModel.changeSelectedNotationNotePitch(to: $0, shouldAudition: $1) }, diff --git a/JammLab/Views/NotationTrackLayoutItems.swift b/JammLab/Views/NotationTrackLayoutItems.swift index 1101522..911c55c 100644 --- a/JammLab/Views/NotationTrackLayoutItems.swift +++ b/JammLab/Views/NotationTrackLayoutItems.swift @@ -22,6 +22,15 @@ struct NotationItemLayoutItem: Equatable, Identifiable { } } +struct NotationTieLayoutItem: Equatable, Identifiable { + var connection: NotationTieConnection + var start: CGPoint + var end: CGPoint + var placement: NotationTiePlacement + + var id: String { connection.id } +} + enum NotationTrackLayoutItems { static func selectedMeasures( _ selectedMeasures: [NotationMeasureSelection], @@ -132,6 +141,72 @@ enum NotationTrackLayoutItems { } } } + + static func ties( + visibleMeasures: [ScoreMeasure], + geometries: [NotationMeasureCanvasGeometry], + connections: [NotationTieConnection], + staffTop: CGFloat + ) -> [NotationTieLayoutItem] { + guard let firstGeometry = geometries.first, + let lastGeometry = geometries.last + else { + return [] + } + + let visibleItemsByID = notationItems( + visibleMeasures: visibleMeasures, + geometries: geometries + ).reduce(into: [String: NotationItemLayoutItem]()) { + if $0[$1.notationItem.id] == nil { $0[$1.notationItem.id] = $1 } + } + + return connections.compactMap { connection in + let sourceVisible = visibleItemsByID[connection.source.item.id] + let targetVisible = visibleItemsByID[connection.target.item.id] + guard sourceVisible != nil || targetVisible != nil, + let sourcePitch = connection.source.item.pitch, + let targetPitch = connection.target.item.pitch + else { + return nil + } + + let sourceStaffPosition = NotationPitchMapper.staffPosition( + for: sourcePitch, + clef: connection.source.measureAttributes.clef + ) + let targetStaffPosition = NotationPitchMapper.staffPosition( + for: targetPitch, + clef: connection.target.measureAttributes.clef + ) + let placement: NotationTiePlacement = NotationStemDirection.direction( + forStaffPosition: sourceStaffPosition + ) == .up ? .below : .above + let verticalDirection: CGFloat = placement == .below ? 1 : -1 + let sourceY = NotationNotePlacementResolver.yPosition( + forStaffPosition: sourceStaffPosition, + staffTop: staffTop + ) + verticalDirection * AppTheme.Timeline.notationTieVerticalOffset + let targetY = NotationNotePlacementResolver.yPosition( + forStaffPosition: targetStaffPosition, + staffTop: staffTop + ) + verticalDirection * AppTheme.Timeline.notationTieVerticalOffset + let startX = sourceVisible.map { + $0.x + AppTheme.Timeline.notationTieNoteheadInset + } ?? firstGeometry.staffStartX + let endX = targetVisible.map { + $0.x - AppTheme.Timeline.notationTieNoteheadInset + } ?? lastGeometry.staffEndX + guard endX > startX + NotationMeasureTiming.timelineTolerance else { return nil } + + return NotationTieLayoutItem( + connection: connection, + start: CGPoint(x: startX, y: sourceY), + end: CGPoint(x: endX, y: targetY), + placement: placement + ) + } + } } enum NotationTrackAccessibility { diff --git a/JammLab/Views/NotationTrackView.swift b/JammLab/Views/NotationTrackView.swift index 4262561..fb1dd92 100644 --- a/JammLab/Views/NotationTrackView.swift +++ b/JammLab/Views/NotationTrackView.swift @@ -7,6 +7,7 @@ struct NotationTrackActions { var selectHarmony: (HarmonySymbol.ID?) -> Void var selectMeasure: (ScoreMeasure?, Bool, NotationPartID) -> Void var selectItem: (NotationItemSelection?, Bool) -> Void + var canInsertNotationNote: (NotationNotePlacement) -> Bool var insertNotationNote: (NotationNotePlacement) -> Bool var insertNotationRest: (NotationRestPlacement) -> Bool var changeSelectedNotePitch: (NotationPitch, Bool) -> Bool @@ -240,6 +241,11 @@ struct NotationTrackView: View { staffTop: staffTop, in: &context ) + drawNotationTies( + geometries: geometries, + staffTop: staffTop, + in: &context + ) drawBarlines( geometries: geometries, staffTop: staffTop, @@ -372,6 +378,33 @@ struct NotationTrackView: View { } } + private func drawNotationTies( + geometries: [NotationMeasureCanvasGeometry], + staffTop: CGFloat, + in context: inout GraphicsContext + ) { + let selectedItemID = selectedItem?.itemID + for item in NotationTrackLayoutItems.ties( + visibleMeasures: state.visibleMeasures, + geometries: geometries, + connections: state.tieConnections, + staffTop: staffTop + ) { + let isSelected = selectedItemID == item.connection.source.item.id + || selectedItemID == item.connection.target.item.id + let color = isSelected ? appColors.accent : appColors.notationSymbolsAndLines + let path = NotationTiePath.path( + start: item.start, + end: item.end, + placement: item.placement, + arcHeight: AppTheme.Timeline.notationTieArcHeight, + endpointThickness: AppTheme.Timeline.notationTieEndpointThickness, + midpointThickness: AppTheme.Timeline.notationTieMidpointThickness + ) + context.fill(Path(path), with: .color(color)) + } + } + private func drawNoteGlyphWithLedgerLines( duration: NotationDuration, x: CGFloat, @@ -1564,14 +1597,15 @@ struct NotationTrackView: View { attributeDisplays: attributeDisplays ) else { return nil } - return NotationNotePlacementResolver.placement( + guard let placement = NotationNotePlacementResolver.placement( in: target.measure, geometry: target.geometry, point: point, staffTop: staffTop(in: height), selectedDuration: selectedDuration, partID: partID - ) + ), actions.canInsertNotationNote(placement) else { return nil } + return placement } private func restPlacement( @@ -1767,6 +1801,7 @@ private extension NotationTrackActions { selectHarmony: { _ in }, selectMeasure: { _, _, _ in }, selectItem: { _, _ in }, + canInsertNotationNote: { _ in false }, insertNotationNote: { _ in false }, insertNotationRest: { _ in false }, changeSelectedNotePitch: { _, _ in false }, diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index fc6f5eb..ef9e31a 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -77,6 +77,10 @@ struct NotationWindowView: View { } .disabled(!viewModel.canChangeNotationDuration) + NotationTieButton(status: viewModel.tieCommandStatus) { + viewModel.handleAddTiedNotationNoteCommand() + } + NotationEntryModeButton( mode: .rest, isActive: viewModel.isNotationRestEntryModeEnabled @@ -285,6 +289,7 @@ struct NotationWindowView: View { selectHarmony: { viewModel.selectHarmonySymbol(id: $0) }, selectMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1, partID: $2) }, selectItem: { viewModel.selectNotationItem($0, shouldAudition: $1) }, + canInsertNotationNote: { viewModel.canInsertNotationNote($0) }, insertNotationNote: { viewModel.insertNotationNote($0) }, insertNotationRest: { viewModel.insertNotationRest($0) }, changeSelectedNotePitch: { viewModel.changeSelectedNotationNotePitch(to: $0, shouldAudition: $1) }, @@ -315,6 +320,9 @@ struct NotationWindowView: View { if viewModel.canChangeNotationDuration { hotkeys.formUnion(AppHotkey.notationDurationEditingHotkeys) } + if viewModel.isTieCommandInScope { + hotkeys.insert(.addTiedNotationNote) + } if viewModel.duration > 0 { hotkeys.insert(.toggleNotationNoteEntryMode) } @@ -363,6 +371,8 @@ struct NotationWindowView: View { return true case .toggleNotationDurationDot: return viewModel.toggleNotationDurationDot() + case .addTiedNotationNote: + return viewModel.handleAddTiedNotationNoteCommand() default: return false } diff --git a/JammLab/Views/WaveformTimelineView.swift b/JammLab/Views/WaveformTimelineView.swift index 92c656f..36d2b47 100644 --- a/JammLab/Views/WaveformTimelineView.swift +++ b/JammLab/Views/WaveformTimelineView.swift @@ -36,6 +36,7 @@ struct TimelineViewState: Equatable { var notationDurationDenominator: Int var notationDurationIsDotted: Bool var canChangeNotationDuration: Bool + var tieCommandStatus: NotationTieCommandStatus var notationEntryMode: NotationEntryMode? var isNotationTrackCollapsed: Bool var stemNotationTrackCollapsed: [StemType: Bool] @@ -80,6 +81,8 @@ struct TimelineViewActions { var notationDurationDotToggled: () -> Void var notationNoteEntryModeToggled: () -> Void var notationRestEntryModeToggled: () -> Void + var addTiedNotationNote: () -> Void + var canInsertNotationNote: (NotationNotePlacement) -> Bool var insertNotationNote: (NotationNotePlacement) -> Bool var insertNotationRest: (NotationRestPlacement) -> Bool var changeSelectedNotePitch: (NotationPitch, Bool) -> Bool @@ -108,6 +111,7 @@ extension TimelineViewActions { selectHarmony: selectHarmonyAction, selectMeasure: selectNotationMeasure, selectItem: selectNotationItem, + canInsertNotationNote: canInsertNotationNote, insertNotationNote: insertNotationNote, insertNotationRest: insertNotationRest, changeSelectedNotePitch: changeSelectedNotePitch, @@ -178,6 +182,7 @@ struct WaveformTimelineView: View { ), entryMode: state.notationEntryMode, canChangeNotationDuration: state.canChangeNotationDuration, + tieCommandStatus: state.tieCommandStatus, notationDurationDenominator: state.notationDurationDenominator, notationDurationIsDotted: state.notationDurationIsDotted, notationViewports: state.stemNotationViewports, @@ -420,6 +425,11 @@ struct WaveformTimelineView: View { action: actions.notationDurationDotToggled ) .disabled(!state.canChangeNotationDuration) + + NotationTieButton( + status: state.tieCommandStatus, + action: actions.addTiedNotationNote + ) } } } @@ -564,6 +574,7 @@ private struct StemTracksSection: View { let selectedDuration: NotationDuration let entryMode: NotationEntryMode? let canChangeNotationDuration: Bool + let tieCommandStatus: NotationTieCommandStatus let notationDurationDenominator: Int let notationDurationIsDotted: Bool let notationViewports: [StemType: NotationViewportState] @@ -785,6 +796,11 @@ private struct StemTracksSection: View { action: notationActions.notationDurationDotToggled ) .disabled(!canChangeNotationDuration) + + NotationTieButton( + status: tieCommandStatus, + action: notationActions.addTiedNotationNote + ) } } .padding(.horizontal, AppTheme.Spacing.md) diff --git a/JammLabTests/AppHotkeyNotationEventFilterTests.swift b/JammLabTests/AppHotkeyNotationEventFilterTests.swift index 7f4a42c..242b615 100644 --- a/JammLabTests/AppHotkeyNotationEventFilterTests.swift +++ b/JammLabTests/AppHotkeyNotationEventFilterTests.swift @@ -239,4 +239,137 @@ final class AppHotkeyNotationEventFilterTests: XCTestCase { ) } + func testTieHotkeyRequiresAvailableScopeAndDoesNotStealTextOrRepeatEvents() throws { + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "t", + charactersIgnoringModifiers: "t", + isARepeat: false, + keyCode: 17 + )) + let repeatEvent = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "t", + charactersIgnoringModifiers: "t", + isARepeat: true, + keyCode: 17 + )) + + XCTAssertEqual(AppHotkeyEventFilter.hotkey( + for: event, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.addTiedNotationNote] + ), .addTiedNotationNote) + XCTAssertNil(AppHotkeyEventFilter.hotkey( + for: event, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.playPause] + )) + XCTAssertNil(AppHotkeyEventFilter.hotkey( + for: event, + attachedWindowNumber: 42, + firstResponder: NSTextView(), + allowedHotkeys: [.addTiedNotationNote] + )) + XCTAssertNil(AppHotkeyEventFilter.hotkey( + for: repeatEvent, + attachedWindowNumber: 42, + firstResponder: nil, + allowedHotkeys: [.addTiedNotationNote] + )) + } + + func testHotkeyMonitorConsumesTieOnlyWhenHandlerReportsItHandled() throws { + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "t", + charactersIgnoringModifiers: "t", + isARepeat: false, + keyCode: 17 + )) + let monitor = AppHotkeyMonitorNSView() + monitor.onHotkey = { _ in false } + + XCTAssertIdentical( + monitor.eventAfterHandling(event, hotkey: .addTiedNotationNote), + event + ) + + monitor.onHotkey = { _ in true } + XCTAssertNil(monitor.eventAfterHandling(event, hotkey: .addTiedNotationNote)) + } + + @MainActor + func testHotkeyMonitorConsumesBlockedTieCommandWithoutMutatingNotation() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + viewModel.notationItems = [ + NotationMeasureItem( + id: "source", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "blocker", + kind: .note, + pitch: NotationPitch(step: .e, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + let source = try XCTUnwrap(measure.notationItems.first { $0.id == "source" }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: source)) + viewModel.markProjectClean() + let originalItems = viewModel.notationItems + let originalSelection = viewModel.selectedNotationItem + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 42, + context: nil, + characters: "t", + charactersIgnoringModifiers: "t", + isARepeat: false, + keyCode: 17 + )) + let monitor = AppHotkeyMonitorNSView() + monitor.onHotkey = { hotkey in + hotkey == .addTiedNotationNote + && viewModel.handleAddTiedNotationNoteCommand() + } + + XCTAssertEqual(viewModel.tieCommandStatus, .blocked(.noFreeFollowingDuration)) + XCTAssertNil(monitor.eventAfterHandling(event, hotkey: .addTiedNotationNote)) + XCTAssertEqual(viewModel.notationItems, originalItems) + XCTAssertEqual(viewModel.selectedNotationItem, originalSelection) + XCTAssertFalse(viewModel.isProjectModified) + } + } diff --git a/JammLabTests/AppHotkeyNotationMappingTests.swift b/JammLabTests/AppHotkeyNotationMappingTests.swift index 844ffd0..5b26d14 100644 --- a/JammLabTests/AppHotkeyNotationMappingTests.swift +++ b/JammLabTests/AppHotkeyNotationMappingTests.swift @@ -70,6 +70,39 @@ final class AppHotkeyNotationMappingTests: XCTestCase { XCTAssertTrue(AppHotkey.allCases.contains(.toggleNotationNoteEntryMode)) } + func testAppHotkeyRecognizesTieCommandAndMetadata() throws { + let event = try keyEvent(key: "t", keyCode: 17) + + XCTAssertEqual(AppHotkey(event: event), .addTiedNotationNote) + XCTAssertEqual(AppHotkey.addTiedNotationNote.key, "T") + XCTAssertEqual(AppHotkey.addTiedNotationNote.title, "Tie") + XCTAssertEqual(AppHotkey.addTiedNotationNote.detail, "Add tied note") + XCTAssertEqual( + NotationTieHelpText.tooltip(for: .ready), + "Tie (T)\nAdd tied note" + ) + XCTAssertEqual( + NotationTieHelpText.tooltip(for: .blocked(.selectNote)), + "Tie (T)\nAdd tied note\nSelect a note to add a tie." + ) + XCTAssertEqual( + NotationTieHelpText.tooltip(for: .blocked(.alreadyTied)), + "Tie (T)\nAdd tied note\nThe selected note already starts a tie." + ) + XCTAssertEqual( + NotationTieHelpText.tooltip(for: .blocked(.noFreeFollowingDuration)), + "Tie (T)\nAdd tied note\nThere is not enough empty notation time after the selected note." + ) + XCTAssertEqual( + NotationTieHelpText.tooltip(for: .blocked(.audioBoundary)), + "Tie (T)\nAdd tied note\nThere is not enough audio time after the selected note." + ) + XCTAssertEqual( + NotationTieHelpText.accessibilityHint(for: .blocked(.audioBoundary)), + "There is not enough audio time after the selected note." + ) + } + func testAppHotkeyRecognizesNotationNotePitchArrowKeys() throws { let upEvent = try XCTUnwrap(NSEvent.keyEvent( with: .keyDown, diff --git a/JammLabTests/NotationPrimitivesTests.swift b/JammLabTests/NotationPrimitivesTests.swift index 8064aa5..2b8d1ff 100644 --- a/JammLabTests/NotationPrimitivesTests.swift +++ b/JammLabTests/NotationPrimitivesTests.swift @@ -13,7 +13,8 @@ final class NotationPrimitivesTests: XCTestCase { (2, .half(.up), .half(.down), 0xE1D3, 0xE1D4), (4, .quarter(.up), .quarter(.down), 0xE1D5, 0xE1D6), (8, .eighth(.up), .eighth(.down), 0xE1D7, 0xE1D8), - (16, .sixteenth(.up), .sixteenth(.down), 0xE1D9, 0xE1DA) + (16, .sixteenth(.up), .sixteenth(.down), 0xE1D9, 0xE1DA), + (32, .thirtySecond(.up), .thirtySecond(.down), 0xE1DB, 0xE1DC) ] private var allStaffNoteSymbols: [NotationStaffNoteSymbol] { @@ -639,6 +640,68 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertEqual(placement.durationInQuarterNotes, 1, accuracy: 0.0001) } + func testNotationNotePlacementAllowsOverflowFromTrailingRestWithoutChangingRestPlacement() throws { + let measure = ScoreMeasure( + number: 1, + startTime: 0, + endTime: 2, + attributes: .defaultTreble, + notationItems: [ + NotationMeasureItem( + id: "existing-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 5), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 3, + displayDuration: NotationDuration(denominator: 2, isDotted: true) + ), + NotationMeasureItem( + id: "trailing-quarter-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 3, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + ) + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 200, + contentStartX: 20, + contentEndX: 180, + staffStartX: 0, + staffEndX: 200 + ) + let xInsideTrailingRest = NotationMeasureLayout.notationAnchorX( + geometry: geometry, + offsetInQuarterNotes: 3.5, + timeSignature: measure.attributes.timeSignature + ) + let duration = NotationDuration(denominator: 2) + + let placement = try XCTUnwrap(NotationNotePlacementResolver.placement( + in: measure, + geometry: geometry, + point: CGPoint(x: xInsideTrailingRest, y: 72), + staffTop: 40, + selectedDuration: duration + )) + + XCTAssertEqual(placement.targetRestID, "trailing-quarter-rest") + XCTAssertEqual(placement.offsetInQuarterNotes, 3, accuracy: 0.0001) + XCTAssertNil(NotationNotePlacementResolver.restPlacement( + in: measure, + geometry: geometry, + point: CGPoint(x: xInsideTrailingRest, y: 72), + staffTop: 40, + selectedDuration: duration + )) + } + func testNotationRestPlacementResolverTargetsContainingShortRestWithFollowingCapacity() throws { let measure = ScoreMeasure( number: 1, diff --git a/JammLabTests/NotationTieTests.swift b/JammLabTests/NotationTieTests.swift new file mode 100644 index 0000000..3cf12bb --- /dev/null +++ b/JammLabTests/NotationTieTests.swift @@ -0,0 +1,1555 @@ +import Foundation +import XCTest +@testable import JammLab + +final class NotationTiePlannerTests: XCTestCase { + func testPlannerCreatesSamePitchContinuationAndLinksSource() throws { + let pitch = NotationPitch(step: .c, octave: 4) + let measures = scoreMeasures( + duration: 4, + notationItems: [ + note(id: "source", pitch: pitch, measure: 1, start: 0, offset: 0, duration: 1), + rest(id: "rest", measure: 1, start: 0, offset: 1, duration: 3) + ] + ) + + let plan = try XCTUnwrap(NotationNoteInsertionPlanner.plan( + in: measures, + sourceItemID: "source", + selectedDuration: NotationDuration(denominator: 4) + )) + let replacementItems = plan.replacements.flatMap(\.items) + let source = try XCTUnwrap(replacementItems.first { $0.id == "source" }) + let target = try XCTUnwrap(replacementItems.first { $0.id == plan.finalItemID }) + + XCTAssertEqual(source.tieTargetItemID, target.id) + XCTAssertEqual(target.pitch, pitch) + XCTAssertEqual(target.offsetInQuarterNotes, 1, accuracy: 0.0001) + XCTAssertEqual(target.durationInQuarterNotes, 1, accuracy: 0.0001) + XCTAssertNil(target.tieTargetItemID) + } + + func testPlannerSplitsDottedSixteenthAcrossBarlineUsingInternalThirtySecond() throws { + let pitch = NotationPitch(step: .e, octave: 4) + let measures = scoreMeasures( + duration: 8, + notationItems: [ + note(id: "source", pitch: pitch, measure: 1, start: 0, offset: 3.625, duration: 0.25), + rest(id: "tail", measure: 1, start: 0, offset: 3.875, duration: 0.125, denominator: 32) + ] + ) + + let plan = try XCTUnwrap(NotationNoteInsertionPlanner.plan( + in: measures, + sourceItemID: "source", + selectedDuration: NotationDuration(denominator: 16, isDotted: true) + )) + let inserted = plan.replacements + .flatMap(\.items) + .filter { $0.kind == .note && $0.id != "source" } + .sorted { + if $0.measureNumber != $1.measureNumber { return $0.measureNumber < $1.measureNumber } + return $0.offsetInQuarterNotes < $1.offsetInQuarterNotes + } + + XCTAssertEqual(inserted.map(\.measureNumber), [1, 2]) + XCTAssertEqual(inserted.map(\.displayDuration.denominator), [32, 16]) + XCTAssertEqual(inserted.map(\.durationInQuarterNotes), [0.125, 0.25]) + XCTAssertEqual(inserted.first?.tieTargetItemID, inserted.last?.id) + } + + func testPlannerFailsAtomicallyWhenNextItemIsNote() { + let pitch = NotationPitch(step: .g, octave: 4) + let measures = scoreMeasures( + duration: 4, + notationItems: [ + note(id: "source", pitch: pitch, measure: 1, start: 0, offset: 0, duration: 1), + note(id: "blocker", pitch: pitch, measure: 1, start: 0, offset: 1, duration: 1) + ] + ) + + XCTAssertNil(NotationNoteInsertionPlanner.plan( + in: measures, + sourceItemID: "source", + selectedDuration: NotationDuration(denominator: 4) + )) + } + + func testInsertionPlannerUsesThreeFourMeasureBoundary() throws { + let pitch = NotationPitch(step: .b, octave: 4) + let measures = scoreMeasures( + duration: 3, + timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4), + notationItems: [ + note(id: "existing", pitch: pitch, measure: 1, start: 0, offset: 0, duration: 2), + rest(id: "tail", measure: 1, start: 0, offset: 2, duration: 1) + ] + ) + let firstMeasure = try XCTUnwrap(measures.first { $0.number == 1 }) + let plan = try XCTUnwrap(NotationNoteInsertionPlanner.planInsertion( + in: measures, + placement: NotationNotePlacement( + measure: firstMeasure, + targetRestID: "tail", + offsetInQuarterNotes: 2, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2), + pitch: pitch, + x: 0, + y: 0 + ) + )) + let inserted = plan.replacements + .flatMap(\.items) + .filter { $0.kind == .note && $0.id != "existing" } + .sorted { + if $0.measureNumber != $1.measureNumber { return $0.measureNumber < $1.measureNumber } + return $0.offsetInQuarterNotes < $1.offsetInQuarterNotes + } + + XCTAssertEqual(inserted.map(\.measureNumber), [1, 2]) + XCTAssertEqual(inserted.map(\.offsetInQuarterNotes), [2, 0]) + XCTAssertEqual(inserted.map(\.durationInQuarterNotes), [1, 1]) + XCTAssertEqual(inserted[0].tieTargetItemID, inserted[1].id) + } + + func testResolverAcceptsCrossMeasureConnectionAndRejectsPitchMismatch() throws { + let pitch = NotationPitch(step: .a, octave: 4) + let source = note( + id: "source", + pitch: pitch, + measure: 1, + start: 0, + offset: 3, + duration: 1, + tieTargetItemID: "target" + ) + let target = note(id: "target", pitch: pitch, measure: 2, start: 2, offset: 0, duration: 1) + let measures = scoreMeasures(duration: 8, notationItems: [source, target]) + + XCTAssertEqual(NotationTieResolver.connections(in: measures).map(\.id), ["source->target"]) + + var mismatchedTarget = target + mismatchedTarget.pitch = NotationPitch(step: .b, octave: 4) + let mismatchedMeasures = scoreMeasures(duration: 8, notationItems: [source, mismatchedTarget]) + XCTAssertTrue(NotationTieResolver.connections(in: mismatchedMeasures).isEmpty) + } + + func testOldNotationItemJSONDecodesWithoutTie() throws { + let json = """ + { + "id":"legacy", + "kind":"note", + "pitch":{"step":"C","octave":4,"alter":0}, + "measureNumber":1, + "measureStartTime":0, + "offsetInQuarterNotes":0, + "durationInQuarterNotes":1, + "displayDuration":{"denominator":4} + } + """ + let item = try JSONDecoder().decode(NotationMeasureItem.self, from: Data(json.utf8)) + XCTAssertNil(item.tieTargetItemID) + } + + func testNotationItemTieRoundTripsThroughJSON() throws { + let item = note( + id: "source", + pitch: NotationPitch(step: .c, octave: 4), + measure: 1, + start: 0, + offset: 0, + duration: 1, + tieTargetItemID: "target" + ) + + let decoded = try JSONDecoder().decode( + NotationMeasureItem.self, + from: JSONEncoder().encode(item) + ) + XCTAssertEqual(decoded.tieTargetItemID, "target") + } + + func testNormalizerClearsTieToTargetRemovedAsInvalid() throws { + let pitch = NotationPitch(step: .c, octave: 4) + let source = note( + id: "source", + pitch: pitch, + measure: 1, + start: 0, + offset: 0, + duration: 1, + tieTargetItemID: "target" + ) + let invalidTarget = note( + id: "target", + pitch: pitch, + measure: 1, + start: 0, + offset: 1, + duration: 0 + ) + + let normalized = ProjectStateNormalizer.normalizedNotationItems( + [source, invalidTarget], + duration: 4 + ) + + XCTAssertEqual(normalized.map(\.id), ["source"]) + XCTAssertNil(try XCTUnwrap(normalized.first).tieTargetItemID) + } + + private func scoreMeasures( + duration: TimeInterval, + timeSignature: TimeSignature = .fourFour, + notationItems: [NotationMeasureItem] + ) -> [ScoreMeasure] { + NotationViewportFactory().scoreState( + tempoMap: tempoMap(duration: duration, timeSignature: timeSignature), + duration: duration, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: nil, + notationItems: notationItems + ).measures + } + + private func tempoMap( + duration: TimeInterval, + timeSignature: TimeSignature = .fourFour + ) -> TempoMap { + TempoMap( + baseSettings: BeatGridSettings(bpm: 120, timeSignature: timeSignature), + markers: [], + duration: duration + ) + } + + private func note( + id: String, + pitch: NotationPitch, + measure: Int, + start: TimeInterval, + offset: Double, + duration: Double, + tieTargetItemID: String? = nil + ) -> NotationMeasureItem { + NotationMeasureItem( + id: id, + kind: .note, + pitch: pitch, + measureNumber: measure, + measureStartTime: start, + offsetInQuarterNotes: offset, + durationInQuarterNotes: duration, + displayDuration: displayDuration(for: duration), + tieTargetItemID: tieTargetItemID + ) + } + + private func rest( + id: String, + measure: Int, + start: TimeInterval, + offset: Double, + duration: Double, + denominator: Int = 4 + ) -> NotationMeasureItem { + NotationMeasureItem( + id: id, + measureNumber: measure, + measureStartTime: start, + offsetInQuarterNotes: offset, + durationInQuarterNotes: duration, + displayDuration: NotationDuration(denominator: denominator) + ) + } + + private func displayDuration(for duration: Double) -> NotationDuration { + [1, 2, 4, 8, 16, 32] + .map { NotationDuration(denominator: $0) } + .first { abs($0.durationInQuarterNotes - duration) < 0.0001 } + ?? NotationDuration(denominator: 4) + } +} + +final class ViewModelNotationTieTests: XCTestCase { + @MainActor + func testTieCommandExtendsSelectionWithoutAuditionAndUsesSingleUndoStep() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let pitch = NotationPitch(step: .d, octave: 4) + viewModel.notationItems = [ + NotationMeasureItem( + id: "source", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 3, + displayDuration: NotationDuration(denominator: 2) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + let source = try XCTUnwrap(measure.notationItems.first { $0.id == "source" }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: source)) + + XCTAssertNil(viewModel.notationEntryMode) + XCTAssertEqual(viewModel.tieCommandStatus, .ready) + XCTAssertTrue(viewModel.isTieCommandInScope) + XCTAssertTrue(viewModel.handleAddTiedNotationNoteCommand()) + XCTAssertNil(viewModel.notationEntryMode) + XCTAssertTrue(auditioner.auditionedPitches.isEmpty) + + let tiedMeasure = try notationMeasure(1, in: viewModel) + let tiedNotes = tiedMeasure.notationItems.filter { $0.kind == .note } + XCTAssertEqual(tiedNotes.count, 2) + XCTAssertEqual(tiedNotes[0].tieTargetItemID, tiedNotes[1].id) + XCTAssertEqual(viewModel.selectedNotationItem?.itemID, tiedNotes[1].id) + XCTAssertEqual(viewModel.selectedNotationItem?.measureNumber, tiedNotes[1].measureNumber) + XCTAssertEqual(viewModel.selectedNotationItem?.measureStartTime, tiedNotes[1].measureStartTime) + XCTAssertEqual(viewModel.selectedNotationItem?.partID, tiedNotes[1].partID) + XCTAssertTrue(undoManager.canUndo) + + viewModel.undoLastEdit() + XCTAssertEqual(try notationMeasure(1, in: viewModel).notationItems.filter { $0.kind == .note }.count, 1) + XCTAssertFalse(undoManager.canUndo) + + viewModel.redoLastEdit() + XCTAssertEqual(try notationMeasure(1, in: viewModel).notationItems.filter { $0.kind == .note }.count, 2) + XCTAssertFalse(undoManager.canRedo) + } + + @MainActor + func testRepeatedTieCommandExtendsFromFinalGeneratedNote() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let pitch = NotationPitch(step: .f, octave: 4) + viewModel.notationItems = [ + NotationMeasureItem( + id: "source", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + let source = try XCTUnwrap(measure.notationItems.first { $0.id == "source" }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: source)) + + XCTAssertTrue(viewModel.handleAddTiedNotationNoteCommand()) + XCTAssertTrue(viewModel.handleAddTiedNotationNoteCommand()) + + let notes = try notationMeasure(1, in: viewModel).notationItems.filter { $0.kind == .note } + XCTAssertEqual(notes.count, 3) + XCTAssertEqual(notes[0].tieTargetItemID, notes[1].id) + XCTAssertEqual(notes[1].tieTargetItemID, notes[2].id) + XCTAssertNil(notes[2].tieTargetItemID) + } + + @MainActor + func testTieCommandSpansMeasuresAndUndoRestoresExactNotationState() throws { + let viewModel = try loadedNotationViewModel(duration: 10) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let pitch = NotationPitch(step: .g, octave: 4) + viewModel.notationItems = [ + NotationMeasureItem( + id: "source", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + ] + let originalItems = viewModel.notationItems + let firstMeasure = try notationMeasure(1, in: viewModel) + let source = try XCTUnwrap(firstMeasure.notationItems.first { $0.id == "source" }) + viewModel.selectNotationItem(NotationItemSelection(measure: firstMeasure, item: source)) + + XCTAssertTrue(viewModel.handleAddTiedNotationNoteCommand()) + + let tiedItems = viewModel.notationItems + let tiedNotes = tiedItems.filter { $0.kind == .note }.sorted { + if $0.measureNumber != $1.measureNumber { return $0.measureNumber < $1.measureNumber } + return $0.offsetInQuarterNotes < $1.offsetInQuarterNotes + } + XCTAssertEqual(tiedNotes.map(\.measureNumber), [1, 1, 2]) + XCTAssertEqual(tiedNotes.map(\.durationInQuarterNotes), [2, 1, 1]) + XCTAssertEqual(tiedNotes[0].tieTargetItemID, tiedNotes[1].id) + XCTAssertEqual(tiedNotes[1].tieTargetItemID, tiedNotes[2].id) + XCTAssertNil(tiedNotes[2].tieTargetItemID) + XCTAssertEqual(viewModel.selectedNotationItem?.measureNumber, 2) + XCTAssertEqual(viewModel.selectedNotationItem?.itemID, tiedNotes[2].id) + + viewModel.undoLastEdit() + XCTAssertEqual(viewModel.notationItems, originalItems) + + viewModel.redoLastEdit() + XCTAssertEqual(viewModel.notationItems, tiedItems) + } + + @MainActor + func testTieCommandIsAvailableInNoteEntryModeWithoutSelectionAndNoOpsCleanly() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.setNotationNoteEntryModeEnabled(true) + viewModel.markProjectClean() + undoManager.removeAllActions() + let originalItems = viewModel.notationItems + + XCTAssertEqual(viewModel.tieCommandStatus, .blocked(.selectNote)) + XCTAssertTrue(viewModel.isTieCommandInScope) + XCTAssertTrue(viewModel.handleAddTiedNotationNoteCommand()) + + XCTAssertEqual(viewModel.notationItems, originalItems) + XCTAssertTrue(viewModel.isNotationNoteEntryModeEnabled) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertFalse(undoManager.canUndo) + XCTAssertTrue(auditioner.attemptedPitches.isEmpty) + } + + @MainActor + func testBlockedSelectedNoteTiePreservesSelectionModeAndUndoState() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.notationItems = [ + NotationMeasureItem( + id: "source", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "blocker", + kind: .note, + pitch: NotationPitch(step: .e, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + let source = try XCTUnwrap(measure.notationItems.first { $0.id == "source" }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: source)) + viewModel.markProjectClean() + undoManager.removeAllActions() + let originalItems = viewModel.notationItems + let originalSelection = viewModel.selectedNotationItem + + XCTAssertNil(viewModel.notationEntryMode) + XCTAssertEqual(viewModel.tieCommandStatus, .blocked(.noFreeFollowingDuration)) + XCTAssertTrue(viewModel.isTieCommandInScope) + XCTAssertTrue(viewModel.handleAddTiedNotationNoteCommand()) + + XCTAssertEqual(viewModel.notationItems, originalItems) + XCTAssertEqual(viewModel.selectedNotationItem, originalSelection) + XCTAssertNil(viewModel.notationEntryMode) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertFalse(undoManager.canUndo) + XCTAssertTrue(auditioner.attemptedPitches.isEmpty) + } + + @MainActor + func testSelectedNoteWithOutgoingTieRemainsInCommandScopeAndNoOpsCleanly() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let pitch = NotationPitch(step: .c, octave: 4) + viewModel.notationItems = [ + NotationMeasureItem( + id: "source", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + tieTargetItemID: "target" + ), + NotationMeasureItem( + id: "target", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + let source = try XCTUnwrap(measure.notationItems.first { $0.id == "source" }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: source)) + viewModel.markProjectClean() + undoManager.removeAllActions() + let originalItems = viewModel.notationItems + let originalSelection = viewModel.selectedNotationItem + + XCTAssertEqual(viewModel.tieCommandStatus, .blocked(.alreadyTied)) + XCTAssertTrue(viewModel.isTieCommandInScope) + XCTAssertTrue(viewModel.handleAddTiedNotationNoteCommand()) + + XCTAssertEqual(viewModel.notationItems, originalItems) + XCTAssertEqual(viewModel.selectedNotationItem, originalSelection) + XCTAssertNil(viewModel.notationEntryMode) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertFalse(undoManager.canUndo) + XCTAssertTrue(auditioner.attemptedPitches.isEmpty) + } + + @MainActor + func testSelectedRestTieNoOpsWithoutChangingSelectionOrMode() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.notationItems = [ + NotationMeasureItem( + id: "selected-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + let rest = try XCTUnwrap(measure.notationItems.first { $0.id == "selected-rest" }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: rest)) + viewModel.markProjectClean() + undoManager.removeAllActions() + let originalSelection = viewModel.selectedNotationItem + + XCTAssertEqual(viewModel.tieCommandStatus, .unavailable) + XCTAssertFalse(viewModel.isTieCommandInScope) + XCTAssertFalse(viewModel.handleAddTiedNotationNoteCommand()) + + XCTAssertEqual(viewModel.selectedNotationItem, originalSelection) + XCTAssertNil(viewModel.notationEntryMode) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertFalse(undoManager.canUndo) + XCTAssertTrue(auditioner.attemptedPitches.isEmpty) + } + + @MainActor + func testStaleSelectedNoteTieDoesNotContinueReplacementAtSameOffset() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let pitch = NotationPitch(step: .d, octave: 4) + viewModel.notationItems = [ + NotationMeasureItem( + id: "selected-source", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let selectedMeasure = try notationMeasure(1, in: viewModel) + let selectedSource = try XCTUnwrap(selectedMeasure.notationItems.first { $0.id == "selected-source" }) + viewModel.selectNotationItem(NotationItemSelection(measure: selectedMeasure, item: selectedSource)) + viewModel.notationItems = [ + NotationMeasureItem( + id: "replacement-source", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + viewModel.markProjectClean() + undoManager.removeAllActions() + let originalItems = viewModel.notationItems + let staleSelection = viewModel.selectedNotationItem + + XCTAssertEqual(viewModel.tieCommandStatus, .unavailable) + XCTAssertFalse(viewModel.isTieCommandInScope) + XCTAssertFalse(viewModel.handleAddTiedNotationNoteCommand()) + + XCTAssertEqual(viewModel.notationItems, originalItems) + XCTAssertEqual(viewModel.selectedNotationItem, staleSelection) + XCTAssertNil(viewModel.notationEntryMode) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertFalse(undoManager.canUndo) + XCTAssertTrue(auditioner.attemptedPitches.isEmpty) + } + + @MainActor + func testSelectedNoteTieAtAudioEndNoOpsWithoutUsingPartialMeasureTail() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 1, + notationNoteAuditioner: auditioner + ) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.notationItems = [ + NotationMeasureItem( + id: "source-at-audio-end", + kind: .note, + pitch: NotationPitch(step: .g, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + let source = try XCTUnwrap(measure.notationItems.first { $0.id == "source-at-audio-end" }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: source)) + viewModel.markProjectClean() + undoManager.removeAllActions() + let originalItems = viewModel.notationItems + let originalSelection = viewModel.selectedNotationItem + + XCTAssertEqual(viewModel.tieCommandStatus, .blocked(.audioBoundary)) + XCTAssertTrue(viewModel.isTieCommandInScope) + XCTAssertTrue(viewModel.handleAddTiedNotationNoteCommand()) + + XCTAssertEqual(viewModel.notationItems, originalItems) + XCTAssertEqual(viewModel.selectedNotationItem, originalSelection) + XCTAssertNil(viewModel.notationEntryMode) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertFalse(undoManager.canUndo) + XCTAssertTrue(auditioner.attemptedPitches.isEmpty) + } + + @MainActor + func testCrossMeasureNoteInsertionSplitsAtBarlineAndUsesSingleUndoStep() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let pitch = NotationPitch(step: .e, octave: 4) + viewModel.notationItems = [ + NotationMeasureItem( + id: "existing-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 3, + displayDuration: NotationDuration(denominator: 2, isDotted: true) + ), + NotationMeasureItem( + id: "trailing-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 3, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + viewModel.markProjectClean() + undoManager.removeAllActions() + let originalItems = viewModel.notationItems + let firstMeasure = try notationMeasure(1, in: viewModel) + let placement = NotationNotePlacement( + measure: firstMeasure, + targetRestID: "trailing-rest", + offsetInQuarterNotes: 3, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2), + pitch: pitch, + x: 0, + y: 0 + ) + + XCTAssertTrue(viewModel.insertNotationNote(placement)) + + let insertedItems = viewModel.notationItems + let insertedNotes = insertedItems + .filter { $0.kind == .note && $0.id != "existing-note" } + .sorted { + if $0.measureNumber != $1.measureNumber { return $0.measureNumber < $1.measureNumber } + return $0.offsetInQuarterNotes < $1.offsetInQuarterNotes + } + XCTAssertEqual(insertedNotes.map(\.measureNumber), [1, 2]) + XCTAssertEqual(insertedNotes.map(\.offsetInQuarterNotes), [3, 0]) + XCTAssertEqual(insertedNotes.map(\.durationInQuarterNotes), [1, 1]) + XCTAssertEqual(insertedNotes.map(\.pitch), [pitch, pitch]) + XCTAssertEqual(insertedNotes[0].tieTargetItemID, insertedNotes[1].id) + XCTAssertNil(insertedNotes[1].tieTargetItemID) + XCTAssertEqual(viewModel.selectedNotationItem?.itemID, insertedNotes[0].id) + XCTAssertEqual(auditioner.auditionedPitches, [pitch]) + + viewModel.undoLastEdit() + XCTAssertEqual(viewModel.notationItems, originalItems) + XCTAssertFalse(undoManager.canUndo) + + viewModel.redoLastEdit() + XCTAssertEqual(viewModel.notationItems, insertedItems) + } + + @MainActor + func testCrossMeasureStemInsertionDoesNotChangeMainPart() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let bassPart = NotationPartID.stem(.bass) + let mainItems = [ + NotationMeasureItem( + id: "main-first", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "main-second", + kind: .note, + pitch: NotationPitch(step: .d, octave: 4), + measureNumber: 2, + measureStartTime: 2, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + viewModel.notationItems = mainItems + [ + NotationMeasureItem( + id: "bass-existing", + partID: bassPart, + kind: .note, + pitch: NotationPitch(step: .c, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 3, + displayDuration: NotationDuration(denominator: 2, isDotted: true) + ), + NotationMeasureItem( + id: "bass-tail", + partID: bassPart, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 3, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let bassMeasure = try notationMeasure(1, in: viewModel, partID: bassPart) + let pitch = NotationPitch(step: .e, octave: 2) + let placement = NotationNotePlacement( + measure: bassMeasure, + partID: bassPart, + targetRestID: "bass-tail", + offsetInQuarterNotes: 3, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2), + pitch: pitch, + x: 0, + y: 0 + ) + + XCTAssertTrue(viewModel.insertNotationNote(placement)) + + XCTAssertEqual(viewModel.notationItems.filter { $0.partID == .main }, mainItems) + let insertedBassNotes = viewModel.notationItems + .filter { $0.partID == bassPart && $0.kind == .note && $0.id != "bass-existing" } + .sorted { + if $0.measureNumber != $1.measureNumber { return $0.measureNumber < $1.measureNumber } + return $0.offsetInQuarterNotes < $1.offsetInQuarterNotes + } + XCTAssertEqual(insertedBassNotes.map(\.measureNumber), [1, 2]) + XCTAssertEqual(insertedBassNotes.map(\.pitch), [pitch, pitch]) + XCTAssertEqual(insertedBassNotes[0].tieTargetItemID, insertedBassNotes[1].id) + } + + @MainActor + func testCrossMeasureNoteInsertionFailsAtomicallyWhenNextMeasureStartsWithNote() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let pitch = NotationPitch(step: .e, octave: 4) + viewModel.notationItems = [ + NotationMeasureItem( + id: "existing-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 3, + displayDuration: NotationDuration(denominator: 2, isDotted: true) + ), + NotationMeasureItem( + id: "trailing-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 3, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "blocker", + kind: .note, + pitch: NotationPitch(step: .g, octave: 4), + measureNumber: 2, + measureStartTime: 2, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + viewModel.markProjectClean() + let originalItems = viewModel.notationItems + let placement = NotationNotePlacement( + measure: try notationMeasure(1, in: viewModel), + targetRestID: "trailing-rest", + offsetInQuarterNotes: 3, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2), + pitch: pitch, + x: 0, + y: 0 + ) + + XCTAssertFalse(viewModel.insertNotationNote(placement)) + XCTAssertEqual(viewModel.notationItems, originalItems) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertTrue(auditioner.attemptedPitches.isEmpty) + } + + @MainActor + func testCrossMeasureNoteInsertionFailsAtProjectEnd() throws { + let viewModel = try loadedNotationViewModel(duration: 2) + viewModel.notationItems = [ + NotationMeasureItem( + id: "existing-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 3, + displayDuration: NotationDuration(denominator: 2, isDotted: true) + ), + NotationMeasureItem( + id: "trailing-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 3, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + viewModel.markProjectClean() + let originalItems = viewModel.notationItems + let placement = NotationNotePlacement( + measure: try notationMeasure(1, in: viewModel), + targetRestID: "trailing-rest", + offsetInQuarterNotes: 3, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2), + pitch: NotationPitch(step: .e, octave: 4), + x: 0, + y: 0 + ) + + XCTAssertFalse(viewModel.insertNotationNote(placement)) + XCTAssertEqual(viewModel.notationItems, originalItems) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testOverflowingRestPlacementRemainsMeasureLocalAndFailsWithoutMutation() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.notationItems = [ + NotationMeasureItem( + id: "existing-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 3, + displayDuration: NotationDuration(denominator: 2, isDotted: true) + ), + NotationMeasureItem( + id: "trailing-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 3, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + let selectedNote = try XCTUnwrap(measure.notationItems.first { $0.id == "existing-note" }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: selectedNote)) + viewModel.markProjectClean() + undoManager.removeAllActions() + let originalItems = viewModel.notationItems + let originalSelection = viewModel.selectedNotationItem + let placement = NotationRestPlacement( + measure: measure, + targetRestID: "trailing-rest", + offsetInQuarterNotes: 3, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2), + x: 0 + ) + + XCTAssertFalse(viewModel.insertNotationRest(placement)) + XCTAssertEqual(viewModel.notationItems, originalItems) + XCTAssertEqual(viewModel.selectedNotationItem, originalSelection) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertFalse(undoManager.canUndo) + } + + @MainActor + func testSelectedStemTieUsesSourceDurationAcrossThreeFourBoundary() throws { + let viewModel = try loadedNotationViewModel(duration: 6) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.setTimeSignature(beatsPerBar: 3, beatUnit: 4) + let bassPart = NotationPartID.stem(.bass) + let mainNote = NotationMeasureItem( + id: "main-note", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let source = NotationMeasureItem( + id: "bass-source", + partID: bassPart, + kind: .note, + pitch: NotationPitch(step: .e, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 2, + displayDuration: NotationDuration(denominator: 2) + ) + viewModel.notationItems = [mainNote, source] + viewModel.setNotationNoteEntryModeEnabled(true) + viewModel.setNotationDurationDenominator(16) + let bassMeasure = try notationMeasure(1, in: viewModel, partID: bassPart) + let selectedSource = try XCTUnwrap(bassMeasure.notationItems.first { $0.id == source.id }) + viewModel.selectNotationItem(NotationItemSelection( + measure: bassMeasure, + item: selectedSource, + partID: bassPart + )) + viewModel.markProjectClean() + undoManager.removeAllActions() + let originalItems = viewModel.notationItems + + XCTAssertNil(viewModel.notationEntryMode) + XCTAssertEqual(viewModel.tieCommandStatus, .ready) + XCTAssertTrue(viewModel.handleAddTiedNotationNoteCommand()) + XCTAssertNil(viewModel.notationEntryMode) + + let tiedItems = viewModel.notationItems + XCTAssertEqual(tiedItems.filter { $0.partID == .main }, [mainNote]) + let bassNotes = tiedItems + .filter { $0.partID == bassPart && $0.kind == .note } + .sorted { + if $0.measureNumber != $1.measureNumber { return $0.measureNumber < $1.measureNumber } + return $0.offsetInQuarterNotes < $1.offsetInQuarterNotes + } + XCTAssertEqual(bassNotes.map(\.measureNumber), [1, 1, 2]) + XCTAssertEqual(bassNotes.map(\.durationInQuarterNotes), [2, 1, 1]) + XCTAssertEqual(bassNotes.map(\.displayDuration.denominator), [2, 4, 4]) + XCTAssertEqual(bassNotes[0].tieTargetItemID, bassNotes[1].id) + XCTAssertEqual(bassNotes[1].tieTargetItemID, bassNotes[2].id) + XCTAssertEqual(viewModel.selectedNotationItem?.partID, bassPart) + XCTAssertEqual(viewModel.selectedNotationItem?.itemID, bassNotes[2].id) + + viewModel.undoLastEdit() + XCTAssertEqual(viewModel.notationItems, originalItems) + + viewModel.redoLastEdit() + XCTAssertEqual(viewModel.notationItems, tiedItems) + } + + @MainActor + func testTempoTimeSignatureMarkerInvalidatesTieAndUndoRestoresIt() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let pitch = NotationPitch(step: .c, octave: 4) + viewModel.notationItems = [ + NotationMeasureItem( + id: "source", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 3, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + tieTargetItemID: "target" + ), + NotationMeasureItem( + id: "target", + kind: .note, + pitch: pitch, + measureNumber: 2, + measureStartTime: 2, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + viewModel.markProjectClean() + undoManager.removeAllActions() + + viewModel.addTempoTimeSignatureMarker( + at: 1, + bpm: 120, + beatsPerBar: 3 + ) + + XCTAssertNil(viewModel.notationItems.first { $0.id == "source" }?.tieTargetItemID) + + viewModel.undoLastEdit() + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "source" }?.tieTargetItemID, + "target" + ) + } + + @MainActor + func testProjectSaveAndOpenPreservesValidCrossMeasureTie() async throws { + let audioURL = try temporaryAudioFile(duration: 8) + let directory = temporaryDirectory() + let projectURL = directory.appendingPathComponent("notation-tie.jammlab") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: directory) + } + + let projectService = ProjectDocumentService() + let makeViewModel: () throws -> AudioPlayerViewModel = { + AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try self.temporaryUserDefaults()), + isSandboxed: { false } + ) + } + let bassPart = NotationPartID.stem(.bass) + let pitch = NotationPitch(step: .a, octave: 2) + let source = NotationMeasureItem( + id: "source", + partID: bassPart, + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 3, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + tieTargetItemID: "target" + ) + let target = NotationMeasureItem( + id: "target", + partID: bassPart, + kind: .note, + pitch: pitch, + measureNumber: 2, + measureStartTime: 2, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + let savingViewModel = try makeViewModel() + try savingViewModel.loadImportedAudio( + ImportedAudioFile(url: audioURL, displayName: "notation.wav", duration: 8) + ) + savingViewModel.beatGridSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour) + savingViewModel.tempoBPM = 120 + savingViewModel.applyTempoMapToPlaybackEngine() + savingViewModel.notationItems = [source, target] + + let didSave = await savingViewModel.saveProject(to: projectURL) + XCTAssertTrue(didSave) + let savedProject = try projectService.load(from: projectURL) + XCTAssertEqual(savedProject.notationItems, [source, target]) + + let restoredViewModel = try makeViewModel() + await restoredViewModel.openProject(at: projectURL) + + XCTAssertEqual(restoredViewModel.notationItems, [source, target]) + let restoredMeasures = [ + try notationMeasure(1, in: restoredViewModel, partID: bassPart), + try notationMeasure(2, in: restoredViewModel, partID: bassPart) + ] + XCTAssertEqual( + NotationTieResolver.connections(in: restoredMeasures).map(\.id), + ["source->target"] + ) + XCTAssertFalse(restoredViewModel.isProjectModified) + } + + @MainActor + func testTimeSignatureChangeClearsInvalidCrossMeasureTieAndUndoRestoresIt() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let pitch = NotationPitch(step: .c, octave: 4) + viewModel.notationItems = [ + NotationMeasureItem( + id: "source", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 3, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + tieTargetItemID: "target" + ), + NotationMeasureItem( + id: "target", + kind: .note, + pitch: pitch, + measureNumber: 2, + measureStartTime: 2, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + + viewModel.setTimeSignature(beatsPerBar: 3, beatUnit: 4) + XCTAssertNil(viewModel.notationItems.first { $0.id == "source" }?.tieTargetItemID) + + viewModel.undoLastEdit() + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "source" }?.tieTargetItemID, + "target" + ) + } + + @MainActor + func testTargetPitchEditClearsInvalidIncomingTieRelationship() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let pitch = NotationPitch(step: .c, octave: 4) + viewModel.notationItems = [ + NotationMeasureItem( + id: "source", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + tieTargetItemID: "target" + ), + NotationMeasureItem( + id: "target", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + let target = try XCTUnwrap(measure.notationItems.first { $0.id == "target" }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: target)) + + XCTAssertTrue(viewModel.changeSelectedNotationNotePitch( + to: NotationPitch(step: .d, octave: 4), + shouldAudition: false + )) + XCTAssertNil(viewModel.notationItems.first { $0.id == "source" }?.tieTargetItemID) + } + + @MainActor + func testSourceDurationEditClearsNoncontiguousOutgoingTie() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let pitch = NotationPitch(step: .c, octave: 4) + viewModel.notationItems = [ + NotationMeasureItem( + id: "source", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + tieTargetItemID: "target" + ), + NotationMeasureItem( + id: "target", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let measure = try notationMeasure(1, in: viewModel) + let source = try XCTUnwrap(measure.notationItems.first { $0.id == "source" }) + viewModel.selectNotationItem(NotationItemSelection(measure: measure, item: source)) + + viewModel.setNotationDurationDenominator(8) + + XCTAssertNil(viewModel.notationItems.first { $0.id == "source" }?.tieTargetItemID) + } + + @MainActor + func testMeasureClipboardRemapsOnlyInternalTieTargets() throws { + let viewModel = try loadedNotationViewModel(duration: 16) + let pitch = NotationPitch(step: .a, octave: 4) + viewModel.notationItems = [ + NotationMeasureItem( + id: "source", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 3, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + tieTargetItemID: "target" + ), + NotationMeasureItem( + id: "target", + kind: .note, + pitch: pitch, + measureNumber: 2, + measureStartTime: 2, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let first = try notationMeasure(1, in: viewModel) + let second = try notationMeasure(2, in: viewModel) + viewModel.selectNotationMeasure(first) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + XCTAssertNil(viewModel.notationMeasureClipboard?.measures.first?.notationItems.first { + $0.sourceItemID == "source" + }?.tieTargetItemID) + + viewModel.selectNotationMeasure(first) + viewModel.selectNotationMeasure(second, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + let third = try notationMeasure(3, in: viewModel) + viewModel.selectNotationMeasure(third) + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + + let pastedSource = try XCTUnwrap(try notationMeasure(3, in: viewModel).notationItems.first { + $0.kind == .note + }) + let pastedTarget = try XCTUnwrap(try notationMeasure(4, in: viewModel).notationItems.first { + $0.kind == .note + }) + XCTAssertNotEqual(pastedSource.id, "source") + XCTAssertNotEqual(pastedTarget.id, "target") + XCTAssertEqual(pastedSource.tieTargetItemID, pastedTarget.id) + } + + @MainActor + func testPastingSemanticallyIdenticalTieChainIsNoOpDespiteDifferentIDs() throws { + let viewModel = try loadedNotationViewModel(duration: 16) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let pitch = NotationPitch(step: .a, octave: 4) + viewModel.notationItems = [ + tiedNote(id: "source-1", targetID: "target-1", pitch: pitch, measure: 1, start: 0, offset: 3), + tiedNote(id: "target-1", targetID: nil, pitch: pitch, measure: 2, start: 2, offset: 0), + tiedNote(id: "source-2", targetID: "target-2", pitch: pitch, measure: 3, start: 4, offset: 3), + tiedNote(id: "target-2", targetID: nil, pitch: pitch, measure: 4, start: 6, offset: 0) + ] + let originalItems = viewModel.notationItems + let first = try notationMeasure(1, in: viewModel) + let second = try notationMeasure(2, in: viewModel) + viewModel.selectNotationMeasure(first) + viewModel.selectNotationMeasure(second, extendingSelection: true) + XCTAssertTrue(viewModel.copySelectedNotationMeasure()) + viewModel.selectNotationMeasure(try notationMeasure(3, in: viewModel)) + viewModel.markProjectClean() + undoManager.removeAllActions() + + XCTAssertTrue(viewModel.pasteNotationMeasureClipboard()) + XCTAssertEqual(viewModel.notationItems, originalItems) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertFalse(undoManager.canUndo) + } + + private func tiedNote( + id: String, + targetID: String?, + pitch: NotationPitch, + measure: Int, + start: TimeInterval, + offset: Double + ) -> NotationMeasureItem { + NotationMeasureItem( + id: id, + kind: .note, + pitch: pitch, + measureNumber: measure, + measureStartTime: start, + offsetInQuarterNotes: offset, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + tieTargetItemID: targetID + ) + } +} + +final class NotationTieLayoutAndExportTests: XCTestCase { + func testSystemProjectionProvidesPartialTieAtBothBoundaries() throws { + let pitch = NotationPitch(step: .c, octave: 4) + let state = NotationViewportFactory().scoreState( + tempoMap: TempoMap( + baseSettings: BeatGridSettings(bpm: 120, timeSignature: .fourFour), + markers: [], + duration: 8 + ), + duration: 8, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: nil, + notationItems: [ + NotationMeasureItem( + id: "source", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 3, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + tieTargetItemID: "target" + ), + NotationMeasureItem( + id: "target", + kind: .note, + pitch: pitch, + measureNumber: 2, + measureStartTime: 2, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + ) + let systems = state.systems(measuresPerSystem: 1) + XCTAssertEqual(systems.prefix(2).map { $0.viewportState.tieConnections.count }, [1, 1]) + let content = NotationScoreContent( + availability: .ready, + keySignature: state.keySignature, + measures: state.measures + ) + let factory = NotationViewportFactory() + let sourceViewport = factory.viewportState( + content: content, + duration: 8, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + visibleMeasureCount: 1 + ) + let targetViewport = factory.viewportState( + content: content, + duration: 8, + currentTime: 0, + playbackMarkerTime: 2.1, + isPlaying: false, + visibleMeasureCount: 1 + ) + XCTAssertEqual(sourceViewport.tieConnections.count, 1) + XCTAssertEqual(targetViewport.tieConnections.count, 1) + + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 200, + contentStartX: 20, + contentEndX: 180, + staffStartX: 10, + staffEndX: 190 + ) + let outgoing = try XCTUnwrap(NotationTrackLayoutItems.ties( + visibleMeasures: systems[0].viewportState.visibleMeasures, + geometries: [geometry], + connections: systems[0].viewportState.tieConnections, + staffTop: 40 + ).first) + let incoming = try XCTUnwrap(NotationTrackLayoutItems.ties( + visibleMeasures: systems[1].viewportState.visibleMeasures, + geometries: [geometry], + connections: systems[1].viewportState.tieConnections, + staffTop: 40 + ).first) + XCTAssertEqual(outgoing.end.x, geometry.staffEndX, accuracy: 0.0001) + XCTAssertEqual(incoming.start.x, geometry.staffStartX, accuracy: 0.0001) + XCTAssertEqual(outgoing.placement, .below) + } + + func testMusicXMLExportsTieAndTiedRolesInSchemaOrder() throws { + let pitch = NotationPitch(step: .e, octave: 4) + let state = NotationViewportFactory().scoreState( + tempoMap: TempoMap( + baseSettings: BeatGridSettings(bpm: 120, timeSignature: .fourFour), + markers: [], + duration: 4 + ), + duration: 4, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: nil, + notationItems: [ + NotationMeasureItem( + id: "source", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + tieTargetItemID: "middle" + ), + NotationMeasureItem( + id: "middle", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + tieTargetItemID: "target" + ), + NotationMeasureItem( + id: "target", + kind: .note, + pitch: pitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 2, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + ) + let data = try MusicXMLNotationExportRenderer(appVersionProvider: { nil }).render( + NotationExportRequest(displayName: "Tie", score: state, tempoBPM: 120) + ) + let document = try XMLDocument(data: data) + let notes = try XCTUnwrap(document.rootElement()) + .elements(forName: "part").flatMap { $0.elements(forName: "measure") } + .flatMap { $0.elements(forName: "note") } + .filter { !$0.elements(forName: "pitch").isEmpty } + let sourceChildren = childElements(in: try XCTUnwrap(notes.first)) + let middleChildren = childElements(in: try XCTUnwrap(notes.dropFirst().first)) + let targetChildren = childElements(in: try XCTUnwrap(notes.dropFirst(2).first)) + + XCTAssertEqual(sourceChildren.compactMap(\.name), ["pitch", "duration", "tie", "voice", "type", "notations"]) + XCTAssertEqual(sourceChildren.first { $0.name == "tie" }?.attribute(forName: "type")?.stringValue, "start") + XCTAssertEqual(sourceChildren.first { $0.name == "notations" }?.elements(forName: "tied").first?.attribute(forName: "type")?.stringValue, "start") + XCTAssertEqual( + middleChildren.compactMap(\.name), + ["pitch", "duration", "tie", "tie", "voice", "type", "notations"] + ) + XCTAssertEqual( + middleChildren.filter { $0.name == "tie" }.compactMap { + $0.attribute(forName: "type")?.stringValue + }, + ["stop", "start"] + ) + XCTAssertEqual( + middleChildren.first { $0.name == "notations" }?.elements(forName: "tied").compactMap { + $0.attribute(forName: "type")?.stringValue + }, + ["stop", "start"] + ) + XCTAssertEqual(targetChildren.first { $0.name == "tie" }?.attribute(forName: "type")?.stringValue, "stop") + XCTAssertEqual(targetChildren.first { $0.name == "notations" }?.elements(forName: "tied").first?.attribute(forName: "type")?.stringValue, "stop") + } + + private func childElements(in element: XMLElement) -> [XMLElement] { + (element.children ?? []).compactMap { $0 as? XMLElement } + } +} diff --git a/JammLabTests/NotationTrackLayoutItemsTests.swift b/JammLabTests/NotationTrackLayoutItemsTests.swift index 0c9652d..df86576 100644 --- a/JammLabTests/NotationTrackLayoutItemsTests.swift +++ b/JammLabTests/NotationTrackLayoutItemsTests.swift @@ -285,6 +285,8 @@ private func timelineViewActions( notationDurationDotToggled: {}, notationNoteEntryModeToggled: {}, notationRestEntryModeToggled: {}, + addTiedNotationNote: {}, + canInsertNotationNote: { _ in true }, insertNotationNote: { _ in recorder.notationInsertionCount += 1 return true diff --git a/JammLabTests/ViewModelNotationClipboardTests.swift b/JammLabTests/ViewModelNotationClipboardTests.swift index 0cad8b0..5f27079 100644 --- a/JammLabTests/ViewModelNotationClipboardTests.swift +++ b/JammLabTests/ViewModelNotationClipboardTests.swift @@ -81,6 +81,7 @@ final class ViewModelNotationClipboardTests: XCTestCase { ]) XCTAssertEqual(copiedMeasure.notationItems, [ NotationMeasureClipboardNotationItem( + sourceItemID: "note", kind: .note, pitch: pitch, offsetInQuarterNotes: 0, @@ -88,6 +89,7 @@ final class ViewModelNotationClipboardTests: XCTestCase { displayDuration: NotationDuration(denominator: 4) ), NotationMeasureClipboardNotationItem( + sourceItemID: "rest", offsetInQuarterNotes: 1, durationInQuarterNotes: 2, displayDuration: NotationDuration(denominator: 2)