diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d0b776..eda9529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased +- Added Backspace and Delete support for clearing selected Notation measures in the selected part while preserving harmony symbols and leaving default whole-measure rests. - Added fully offline per-stem Audio-to-MIDI transcription with a bundled Basic Pitch model, native C++ inference, cancellable track progress, polyphonic Notation/MIDI notes, and project persistence. Basic Pitch is unavailable for Drum stems, and re-transcription now warns before replacing existing stem notes and rests. - Added inline flat, natural, and sharp signs to Notation with Leland glyphs, one-shot note entry, selected tied-note editing, compact duration and accidental track menus, keyboard shortcuts, persistence, and MusicXML export. - Added automatic rhythmic beaming for eighth and sixteenth notes in supported simple and compound Notation meters, including shared stem direction, sloped beams, secondary beam breaks, and beamlets. diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift index 4cc9145..73f6d7d 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Notation.swift @@ -863,6 +863,28 @@ extension AudioPlayerViewModel { return changeSelectedNotationNotePitch(to: pitch) } + @discardableResult + func deleteSelectedNotationMeasureContents() -> Bool { + guard let measures = validatedSelectedNotationMeasures() else { return false } + let partID = selectedNotationMeasures.first?.partID ?? .main + let belongsToSelection: (NotationMeasureItem) -> Bool = { item in + item.partID == partID + && measures.contains { measure in + item.measureNumber == measure.number + && abs(item.measureStartTime - measure.startTime) + < NotationMeasureTiming.timelineTolerance + } + } + guard notationItems.contains(where: belongsToSelection) else { return false } + + performUndoableEdit("Delete Measure Contents") { + notationItems.removeAll(where: belongsToSelection) + sanitizeNotationTieRelationships() + } + + return true + } + @discardableResult func deleteSelectedNotationNote() -> Bool { replaceSelectedNotationNoteWithRest( diff --git a/JammLab/Views/MainWorkspacePanels.swift b/JammLab/Views/MainWorkspacePanels.swift index 913b017..bc8f8fc 100644 --- a/JammLab/Views/MainWorkspacePanels.swift +++ b/JammLab/Views/MainWorkspacePanels.swift @@ -313,6 +313,7 @@ extension ContentView { changeNotationClef: { viewModel.setNotationClef($1, for: $0) }, selectDrumInstrument: { viewModel.selectDrumInstrument(midiNoteNumber: $0) }, auditionNotePitch: { viewModel.auditionNotationNotePitch($0, clef: $1) }, + deleteSelectedNotationMeasureContents: { viewModel.deleteSelectedNotationMeasureContents() }, deleteSelectedNotationNote: { viewModel.deleteSelectedNotationNote() }, showNotationWindow: { openWindow(id: AppWindowID.notation) }, beginNotationNoteEdit: { viewModel.beginNotationNoteEdit(partID: $0) }, diff --git a/JammLab/Views/NotationTrackView.swift b/JammLab/Views/NotationTrackView.swift index d4713e2..39f53d3 100644 --- a/JammLab/Views/NotationTrackView.swift +++ b/JammLab/Views/NotationTrackView.swift @@ -13,6 +13,7 @@ struct NotationTrackActions { var changeSelectedNotePitch: (NotationPitch, Bool) -> Bool var changeClef: (NotationPartID, Clef) -> Void var auditionNotePitch: (NotationPitch, Clef) -> Void + var deleteSelectedNotationMeasureContents: () -> Bool var deleteSelectedNotationNote: () -> Bool var locatePlaybackMarkerExactly: (TimeInterval) -> Void var saveHarmony: (HarmonySymbol) -> Void @@ -157,7 +158,7 @@ struct NotationTrackView: View { .focused($isTrackFocused) .focusEffectDisabled(true) .onDeleteCommand { - deleteSelectedNotationItemOrHarmony() + deleteSelectedNotationSelectionOrHarmony() } .onChange(of: pendingEditorRequest?.id) { _, _ in if partID.isMain { @@ -1956,7 +1957,15 @@ struct NotationTrackView: View { editingDraft = nil } - private func deleteSelectedNotationItemOrHarmony() { + private func deleteSelectedNotationSelectionOrHarmony() { + if selectedMeasures.contains(where: { $0.partID == partID }) { + if actions.deleteSelectedNotationMeasureContents() { + editingDraft = nil + draggedNotePitchPreview = nil + } + return + } + if actions.deleteSelectedNotationNote() { editingDraft = nil draggedNotePitchPreview = nil @@ -2295,6 +2304,7 @@ private extension NotationTrackActions { changeSelectedNotePitch: { _, _ in false }, changeClef: { _, _ in }, auditionNotePitch: { _, _ in }, + deleteSelectedNotationMeasureContents: { false }, deleteSelectedNotationNote: { false }, locatePlaybackMarkerExactly: { _ in }, saveHarmony: { _ in }, diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index 810bf8d..3d79029 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -318,6 +318,7 @@ struct NotationWindowView: View { changeSelectedNotePitch: { viewModel.changeSelectedNotationNotePitch(to: $0, shouldAudition: $1) }, changeClef: { viewModel.setNotationClef($1, for: $0) }, auditionNotePitch: { viewModel.auditionNotationNotePitch($0, clef: $1) }, + deleteSelectedNotationMeasureContents: { viewModel.deleteSelectedNotationMeasureContents() }, deleteSelectedNotationNote: { viewModel.deleteSelectedNotationNote() }, locatePlaybackMarkerExactly: { viewModel.locatePlaybackMarkerExactly(to: $0) }, saveHarmony: { viewModel.saveHarmonySymbol($0) }, diff --git a/JammLab/Views/WaveformTimelineView.swift b/JammLab/Views/WaveformTimelineView.swift index 92567c6..018a156 100644 --- a/JammLab/Views/WaveformTimelineView.swift +++ b/JammLab/Views/WaveformTimelineView.swift @@ -98,6 +98,7 @@ struct TimelineViewActions { var changeNotationClef: (NotationPartID, Clef) -> Void var selectDrumInstrument: (Int) -> Void = { _ in } var auditionNotePitch: (NotationPitch, Clef) -> Void + var deleteSelectedNotationMeasureContents: () -> Bool = { false } var deleteSelectedNotationNote: () -> Bool var showNotationWindow: () -> Void var beginNotationNoteEdit: (NotationPartID) -> Void = { _ in } @@ -133,6 +134,7 @@ extension TimelineViewActions { changeSelectedNotePitch: changeSelectedNotePitch, changeClef: changeNotationClef, auditionNotePitch: auditionNotePitch, + deleteSelectedNotationMeasureContents: deleteSelectedNotationMeasureContents, deleteSelectedNotationNote: deleteSelectedNotationNote, locatePlaybackMarkerExactly: locatePlaybackMarkerExactly, saveHarmony: saveHarmonyAction, diff --git a/JammLabTests/NotationTrackLayoutItemsTests.swift b/JammLabTests/NotationTrackLayoutItemsTests.swift index d21e56d..1c56191 100644 --- a/JammLabTests/NotationTrackLayoutItemsTests.swift +++ b/JammLabTests/NotationTrackLayoutItemsTests.swift @@ -135,6 +135,8 @@ final class NotationTrackLayoutItemsTests: XCTestCase { XCTAssertTrue(stemActions.insertNotationNote(notePlacement)) XCTAssertTrue(mainActions.insertNotationRest(restPlacement)) XCTAssertTrue(stemActions.insertNotationRest(restPlacement)) + XCTAssertTrue(mainActions.deleteSelectedNotationMeasureContents()) + XCTAssertTrue(stemActions.deleteSelectedNotationMeasureContents()) XCTAssertTrue(mainActions.deleteSelectedNotationNote()) XCTAssertTrue(stemActions.deleteSelectedNotationNote()) mainActions.changeClef(.main, .bass) @@ -145,6 +147,7 @@ final class NotationTrackLayoutItemsTests: XCTestCase { XCTAssertEqual(recorder.itemSelectionCount, 2) XCTAssertEqual(recorder.playbackLocationCount, 2) XCTAssertEqual(recorder.notationInsertionCount, 4) + XCTAssertEqual(recorder.deleteMeasureContentsCount, 2) XCTAssertEqual(recorder.deleteNotationCount, 2) XCTAssertEqual(recorder.clefChanges.map(\.0), [.main, .stem(.bass)]) XCTAssertEqual(recorder.clefChanges.map(\.1), [.bass, .bass]) @@ -767,6 +770,7 @@ private final class TimelineNotationActionRecorder { var itemSelectionCount = 0 var playbackLocationCount = 0 var notationInsertionCount = 0 + var deleteMeasureContentsCount = 0 var deleteNotationCount = 0 var clefChanges: [(NotationPartID, Clef)] = [] } @@ -823,6 +827,10 @@ private func timelineViewActions( changeSelectedNotePitch: { _, _ in true }, changeNotationClef: { recorder.clefChanges.append(($0, $1)) }, auditionNotePitch: { _, _ in }, + deleteSelectedNotationMeasureContents: { + recorder.deleteMeasureContentsCount += 1 + return true + }, deleteSelectedNotationNote: { recorder.deleteNotationCount += 1 return true diff --git a/JammLabTests/ViewModelNotationSelectionTests.swift b/JammLabTests/ViewModelNotationSelectionTests.swift index 4d12456..a13a9cf 100644 --- a/JammLabTests/ViewModelNotationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationSelectionTests.swift @@ -363,6 +363,256 @@ final class ViewModelNotationSelectionTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } + @MainActor + func testDeletingSelectedMeasureContentsKeepsHarmonySelectionAndSupportsUndoRedo() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + 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 mainRest = NotationMeasureItem( + id: "main-rest", + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 1, + durationInQuarterNotes: 3, + displayDuration: NotationDuration(denominator: 2, isDotted: true) + ) + let bassNote = NotationMeasureItem( + id: "bass-note", + partID: .stem(.bass), + kind: .note, + pitch: NotationPitch(step: .c, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 4, + displayDuration: NotationDuration(denominator: 1) + ) + let harmony = HarmonySymbol( + time: 0, + measureNumber: 1, + offsetInQuarterNotes: 0, + rawText: "Cmaj7" + ) + viewModel.notationItems = [mainNote, mainRest, bassNote] + viewModel.harmonySymbols = [harmony] + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.markProjectClean() + let measure = try notationMeasure(1, in: viewModel) + viewModel.selectNotationMeasure(measure) + + XCTAssertTrue(viewModel.deleteSelectedNotationMeasureContents()) + + XCTAssertEqual(viewModel.notationItems, [bassNote]) + XCTAssertEqual(viewModel.harmonySymbols, [harmony]) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [1]) + XCTAssertEqual(viewModel.notationMeasureSelectionAnchor?.number, 1) + let clearedMeasure = try notationMeasure(1, in: viewModel) + let defaultRest = try XCTUnwrap(clearedMeasure.notationItems.first) + XCTAssertEqual(clearedMeasure.notationItems.count, 1) + XCTAssertEqual(defaultRest.kind, .rest) + XCTAssertEqual(defaultRest.offsetInQuarterNotes, 0) + XCTAssertEqual(defaultRest.durationInQuarterNotes, 4) + XCTAssertTrue(defaultRest.isSynthesized) + XCTAssertTrue(viewModel.isProjectModified) + XCTAssertEqual(undoManager.undoActionName, "Delete Measure Contents") + + viewModel.undoLastEdit() + + XCTAssertEqual(viewModel.notationItems, [mainNote, mainRest, bassNote]) + XCTAssertEqual(viewModel.harmonySymbols, [harmony]) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [1]) + XCTAssertFalse(viewModel.isProjectModified) + + viewModel.redoLastEdit() + + XCTAssertEqual(viewModel.notationItems, [bassNote]) + XCTAssertEqual(viewModel.harmonySymbols, [harmony]) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [1]) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testDeletingSelectedMeasureRangeOnlyClearsSelectedPart() throws { + let viewModel = try loadedNotationViewModel(duration: 10) + let mainItems = [ + NotationMeasureItem( + id: "main-1", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "main-2", + kind: .note, + pitch: NotationPitch(step: .d, octave: 4), + measureNumber: 2, + measureStartTime: 2, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "main-3", + kind: .note, + pitch: NotationPitch(step: .e, octave: 4), + measureNumber: 3, + measureStartTime: 4, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let bassItems = [ + NotationMeasureItem( + id: "bass-1", + partID: .stem(.bass), + kind: .note, + pitch: NotationPitch(step: .c, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "bass-2", + partID: .stem(.bass), + kind: .note, + pitch: NotationPitch(step: .d, octave: 2), + measureNumber: 2, + measureStartTime: 2, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let harmonies = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "C"), + HarmonySymbol(time: 2, measureNumber: 2, offsetInQuarterNotes: 0, rawText: "Dm") + ] + viewModel.notationItems = mainItems + bassItems + viewModel.harmonySymbols = harmonies + let firstMeasure = try notationMeasure(1, in: viewModel) + let secondMeasure = try notationMeasure(2, in: viewModel) + viewModel.selectNotationMeasure(firstMeasure) + viewModel.selectNotationMeasure(secondMeasure, extendingSelection: true) + + XCTAssertTrue(viewModel.deleteSelectedNotationMeasureContents()) + + XCTAssertEqual(Set(viewModel.notationItems.map(\.id)), ["main-3", "bass-1", "bass-2"]) + XCTAssertEqual(viewModel.harmonySymbols, harmonies) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [1, 2]) + for measureNumber in 1...2 { + let measure = try notationMeasure(measureNumber, in: viewModel) + XCTAssertEqual(measure.notationItems.count, 1) + XCTAssertTrue(try XCTUnwrap(measure.notationItems.first).isSynthesized) + } + } + + @MainActor + func testDeletingSelectedStemMeasureContentsKeepsMainPart() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + 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: .c, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + viewModel.notationItems = [mainNote, bassNote] + let bassMeasure = try notationMeasure(1, in: viewModel, partID: .stem(.bass)) + viewModel.selectNotationMeasure(bassMeasure, partID: .stem(.bass)) + + XCTAssertTrue(viewModel.deleteSelectedNotationMeasureContents()) + + XCTAssertEqual(viewModel.notationItems, [mainNote]) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.partID), [.stem(.bass)]) + let clearedBassMeasure = try notationMeasure(1, in: viewModel, partID: .stem(.bass)) + XCTAssertEqual(clearedBassMeasure.notationItems.count, 1) + XCTAssertTrue(try XCTUnwrap(clearedBassMeasure.notationItems.first).isSynthesized) + let preservedMainMeasure = try notationMeasure(1, in: viewModel) + XCTAssertEqual(preservedMainMeasure.notationItems.first(where: { $0.id == mainNote.id }), mainNote) + } + + @MainActor + func testDeletingAlreadyEmptySelectedMeasureIsNoOp() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + let measure = try notationMeasure(1, in: viewModel) + viewModel.selectNotationMeasure(measure) + viewModel.markProjectClean() + + XCTAssertFalse(viewModel.deleteSelectedNotationMeasureContents()) + + XCTAssertTrue(viewModel.notationItems.isEmpty) + XCTAssertEqual(viewModel.selectedNotationMeasures.map(\.number), [1]) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertFalse(undoManager.canUndo) + } + + @MainActor + func testDeletingMeasureContentsSanitizesTieFromRemainingMeasure() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let source = NotationMeasureItem( + id: "tie-source", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 3, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4), + tieTargetItemID: "tie-target" + ) + let target = NotationMeasureItem( + id: "tie-target", + kind: .note, + pitch: NotationPitch(step: .c, octave: 4), + measureNumber: 2, + measureStartTime: 2, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + viewModel.notationItems = [source, target] + let secondMeasure = try notationMeasure(2, in: viewModel) + viewModel.selectNotationMeasure(secondMeasure) + + XCTAssertTrue(viewModel.deleteSelectedNotationMeasureContents()) + + let remaining = try XCTUnwrap(viewModel.notationItems.first) + XCTAssertEqual(remaining.id, source.id) + XCTAssertNil(remaining.tieTargetItemID) + } + } extension XCTestCase {