Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`.

## Unreleased

- Added Notation clicks for moving the playback position marker from measure selections and barlines in both the main track and Notation window.
- Added Leland glyph duration controls to the Notation window and main Notation track, with 4/5/6/7 shortcuts for eighth, quarter, half, and whole notes.
- Added selectable Notation rest durations with persisted measure layouts and split-rest MusicXML export.
- Added MusicXML export metadata, project tempo marking, and boxed bold Region labels.
Expand Down
1 change: 1 addition & 0 deletions JammLab/DesignSystem/AppTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ enum AppTheme {
static let notationRegionLabelFontSize: CGFloat = 10
static let notationRegionLabelCornerRadius: CGFloat = 1.5
static let notationRegionLabelGap: CGFloat = AppTheme.Spacing.xs
static let notationBarlineHitWidth: CGFloat = 8
static let stemTrackHeight: CGFloat = 48
static let defaultVisibleStemRows = 4
static let trackSpacing: CGFloat = 6
Expand Down
45 changes: 45 additions & 0 deletions JammLab/Services/NotationMeasureLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ struct NotationBarlineGeometry: Equatable {
let isOuterBoundary: Bool
}

struct NotationBarlineHitTarget: Equatable, Identifiable {
enum Boundary: Equatable {
case leading
case trailing
}

let measureIndex: Int
let boundary: Boundary
let x: CGFloat
let targetTime: TimeInterval

var id: String {
"\(measureIndex)-\(boundary)"
}
}

struct NotationSelectionOverlayRun: Equatable, Identifiable {
let startMeasureIndex: Int
let endMeasureIndex: Int
Expand Down Expand Up @@ -297,6 +313,35 @@ struct NotationMeasureLayout {
return barlines
}

static func barlineHitTargets(
for geometries: [NotationMeasureCanvasGeometry],
measures: [ScoreMeasure]
) -> [NotationBarlineHitTarget] {
let measureCount = min(geometries.count, measures.count)
guard measureCount > 0 else { return [] }

var targets: [NotationBarlineHitTarget] = []
for index in 0..<measureCount {
guard let x = geometries[index].leadingBarlineX else { continue }

targets.append(NotationBarlineHitTarget(
measureIndex: index,
boundary: .leading,
x: x,
targetTime: measures[index].startTime
))
}

let lastIndex = measureCount - 1
targets.append(NotationBarlineHitTarget(
measureIndex: lastIndex,
boundary: .trailing,
x: geometries[lastIndex].staffEndX,
targetTime: measures[lastIndex].endTime
))
return targets
}

static func selectionOverlayRuns(
selectedMeasureIndices: [Int],
geometries: [NotationMeasureCanvasGeometry]
Expand Down
6 changes: 6 additions & 0 deletions JammLab/ViewModels/AudioPlayerViewModel+Notation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ extension AudioPlayerViewModel {
}
selectedHarmonySymbolID = nil
selectedNotationItem = nil
locatePlaybackMarkerAtFirstSelectedNotationMeasure()
}

func clearNotationMeasureSelection() {
Expand Down Expand Up @@ -564,4 +565,9 @@ extension AudioPlayerViewModel {
).measures
}

private func locatePlaybackMarkerAtFirstSelectedNotationMeasure() {
guard let firstSelectedMeasure = currentSelectedNotationMeasures().first else { return }
locatePlaybackMarkerExactly(to: firstSelectedMeasure.startTime)
}

}
5 changes: 5 additions & 0 deletions JammLab/ViewModels/AudioPlayerViewModel+Playback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ extension AudioPlayerViewModel {
refreshProjectModifiedState()
}

func locatePlaybackMarkerExactly(to time: TimeInterval) {
setPlaybackMarkerExactly(to: time)
refreshProjectModifiedState()
}

func seekToStart() {
setPlaybackMarkerExactly(to: 0)
refreshProjectModifiedState()
Expand Down
1 change: 1 addition & 0 deletions JammLab/Views/MainWorkspacePanels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ extension ContentView {
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) },
Expand Down
59 changes: 59 additions & 0 deletions JammLab/Views/NotationTrackView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ struct NotationTrackActions {
var selectHarmony: (HarmonySymbol.ID?) -> Void
var selectMeasure: (ScoreMeasure?, Bool) -> Void
var selectItem: (NotationItemSelection?) -> Void
var locatePlaybackMarkerExactly: (TimeInterval) -> Void
var saveHarmony: (HarmonySymbol) -> Void
var deleteHarmony: (HarmonySymbol.ID) -> Void
var adjacentHarmonyPlacement: (TimeInterval, HarmonyNavigationDirection) -> HarmonyPlacement?
Expand Down Expand Up @@ -61,6 +62,11 @@ struct NotationTrackView: View {
height: proxy.size.height,
attributeDisplays: attributeDisplays
)
barlineHitLayer(
width: contentWidth,
height: proxy.size.height,
attributeDisplays: attributeDisplays
)
notationItemSelectionHitLayer(
width: contentWidth,
height: proxy.size.height,
Expand Down Expand Up @@ -484,6 +490,46 @@ struct NotationTrackView: View {
}
}

private func barlineHitLayer(
width: CGFloat,
height: CGFloat,
attributeDisplays: [NotationAttributeDisplay]
) -> some View {
let geometries = measureCanvasGeometries(
measureCount: renderedMeasureCount,
width: width,
attributeDisplays: attributeDisplays
)
let targets = NotationMeasureLayout.barlineHitTargets(
for: geometries,
measures: state.visibleMeasures
)
let staffTop = staffTop(in: height)
let hitY = max(0, staffTop - AppTheme.Spacing.xs)
let hitHeight = AppTheme.Timeline.notationStaffLineSpacing * 4 + AppTheme.Spacing.sm
let hitWidth = AppTheme.Timeline.notationBarlineHitWidth

return ZStack(alignment: .topLeading) {
ForEach(targets) { target in
Rectangle()
.fill(Color.clear)
.contentShape(Rectangle())
.frame(width: hitWidth, height: hitHeight)
.offset(
x: target.x - hitWidth / 2,
y: hitY
)
.onTapGesture {
isTrackFocused = true
editingDraft = nil
actions.locatePlaybackMarkerExactly(target.targetTime)
}
.accessibilityLabel(barlineAccessibilityLabel(for: target))
.help(barlineAccessibilityLabel(for: target))
}
}
}

private func regionLabelsLayer(
width: CGFloat,
height: CGFloat,
Expand Down Expand Up @@ -1053,6 +1099,18 @@ struct NotationTrackView: View {
return "Measures \(first.number) through \(last.number), \(state.keySignature.displayName), \(state.timeSignature.displayText)\(selectedMeasureText)"
}

private func barlineAccessibilityLabel(for target: NotationBarlineHitTarget) -> String {
switch target.boundary {
case .leading:
guard state.visibleMeasures.indices.contains(target.measureIndex) else {
return "Move position marker to measure start"
}
return "Move position marker to measure \(state.visibleMeasures[target.measureIndex].number)"
case .trailing:
return "Move position marker to end of measure"
}
}

private var isShiftClickActive: Bool {
NSApp.currentEvent?.modifierFlags.contains(.shift) == true
}
Expand Down Expand Up @@ -1128,6 +1186,7 @@ private extension NotationTrackActions {
selectHarmony: { _ in },
selectMeasure: { _, _ in },
selectItem: { _ in },
locatePlaybackMarkerExactly: { _ in },
saveHarmony: { _ in },
deleteHarmony: { _ in },
adjacentHarmonyPlacement: { _, _ in nil }
Expand Down
1 change: 1 addition & 0 deletions JammLab/Views/NotationWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ struct NotationWindowView: View {
selectHarmony: { viewModel.selectHarmonySymbol(id: $0) },
selectMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1) },
selectItem: { viewModel.selectNotationItem($0) },
locatePlaybackMarkerExactly: { viewModel.locatePlaybackMarkerExactly(to: $0) },
saveHarmony: { viewModel.saveHarmonySymbol($0) },
deleteHarmony: { viewModel.deleteHarmonySymbol(id: $0) },
adjacentHarmonyPlacement: { viewModel.adjacentHarmonyPlacement(from: $0, direction: $1) }
Expand Down
2 changes: 2 additions & 0 deletions JammLab/Views/WaveformTimelineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ struct TimelineViewState: Equatable {

struct TimelineViewActions {
var locatePlaybackMarker: (TimeInterval) -> Void
var locatePlaybackMarkerExactly: (TimeInterval) -> Void
var addNote: (TimeInterval) -> Void
var selectHarmony: (HarmonySymbol.ID?) -> Void
var selectNotationMeasure: (ScoreMeasure?, Bool) -> Void
Expand Down Expand Up @@ -271,6 +272,7 @@ struct WaveformTimelineView: View {
selectHarmony: actions.selectHarmony,
selectMeasure: actions.selectNotationMeasure,
selectItem: actions.selectNotationItem,
locatePlaybackMarkerExactly: actions.locatePlaybackMarkerExactly,
saveHarmony: actions.saveHarmony,
deleteHarmony: actions.deleteHarmony,
adjacentHarmonyPlacement: actions.adjacentHarmonyPlacement
Expand Down
110 changes: 110 additions & 0 deletions JammLabTests/NotationMeasureGeometryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,114 @@ final class NotationMeasureGeometryTests: XCTestCase {
XCTAssertFalse(barlines.contains { abs($0.x - geometries[2].contentStartX) < 0.0001 })
}

func testBarlineHitTargetsMapVisibleBoundariesToMeasureTimes() {
let geometries = NotationMeasureLayout.fallbackCanvasGeometries(
measureCount: 3,
totalWidth: 300
)
let measures = [
scoreMeasure(number: 1, start: 0, end: 2),
scoreMeasure(number: 2, start: 2, end: 4),
scoreMeasure(number: 3, start: 4, end: 6)
]

let targets = NotationMeasureLayout.barlineHitTargets(
for: geometries,
measures: measures
)

XCTAssertEqual(targets.map(\.measureIndex), [0, 1, 2, 2])
XCTAssertEqual(targets.map(\.boundary), [.leading, .leading, .leading, .trailing])
XCTAssertEqual(targets.map(\.id), ["0-leading", "1-leading", "2-leading", "2-trailing"])
XCTAssertEqual(targets.map(\.targetTime), [0, 2, 4, 6])
XCTAssertEqual(targets[0].x, geometries[0].staffStartX, accuracy: 0.0001)
XCTAssertEqual(targets[1].x, geometries[1].cellStartX, accuracy: 0.0001)
XCTAssertEqual(targets[3].x, geometries[2].staffEndX, accuracy: 0.0001)
}

func testBarlineHitTargetsKeepAttributedMeasureBoundaryTimes() {
let fullAttributes = MeasureAttributes(
keySignature: KeySignature.normalized(from: "F major"),
timeSignature: .fourFour,
clef: .treble
)
let changedAttributes = MeasureAttributes(
keySignature: KeySignature.normalized(from: "Bb major"),
timeSignature: TimeSignature(beatsPerBar: 3, beatUnit: 4),
clef: .treble
)
let firstReserve = NotationMeasureLayout.attributeReserveWidth(
for: fullAttributes,
display: .full
)
let secondReserve = NotationMeasureLayout.attributeReserveWidth(
for: changedAttributes,
display: .full
)
let geometries = NotationMeasureLayout.canvasGeometries(
measureCount: 2,
totalWidth: 300 + firstReserve + secondReserve,
attributeReserveWidths: [firstReserve, secondReserve]
)
let measures = [
scoreMeasure(number: 1, start: 0, end: 2, attributes: fullAttributes),
scoreMeasure(number: 2, start: 2, end: 3.5, attributes: changedAttributes)
]

let targets = NotationMeasureLayout.barlineHitTargets(
for: geometries,
measures: measures
)

XCTAssertEqual(targets.map(\.targetTime), [0, 2, 3.5])
XCTAssertEqual(targets.map(\.id), ["0-leading", "1-leading", "1-trailing"])
XCTAssertEqual(targets[1].x, geometries[1].cellStartX, accuracy: 0.0001)
XCTAssertNotEqual(targets[1].x, geometries[1].contentStartX, accuracy: 0.0001)
}

func testBarlineHitTargetIDsStayStableWhenOnlyTimesChange() {
let geometries = NotationMeasureLayout.fallbackCanvasGeometries(
measureCount: 2,
totalWidth: 200
)
let originalMeasures = [
scoreMeasure(number: 1, start: 0, end: 2),
scoreMeasure(number: 2, start: 2, end: 4)
]
let retimedMeasures = [
scoreMeasure(number: 1, start: 1, end: 3),
scoreMeasure(number: 2, start: 3, end: 5)
]

let originalTargets = NotationMeasureLayout.barlineHitTargets(
for: geometries,
measures: originalMeasures
)
let retimedTargets = NotationMeasureLayout.barlineHitTargets(
for: geometries,
measures: retimedMeasures
)

XCTAssertEqual(retimedTargets.map(\.id), originalTargets.map(\.id))
XCTAssertNotEqual(retimedTargets.map(\.targetTime), originalTargets.map(\.targetTime))
}

private func scoreMeasure(
number: Int,
start: TimeInterval,
end: TimeInterval,
attributes: MeasureAttributes = MeasureAttributes(
keySignature: .cMajor,
timeSignature: .fourFour,
clef: .treble
)
) -> ScoreMeasure {
ScoreMeasure(
number: number,
startTime: start,
endTime: end,
attributes: attributes
)
}

}
1 change: 1 addition & 0 deletions JammLabTests/ViewModelNotationPasteTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ final class ViewModelNotationPasteTests: XCTestCase {
XCTAssertTrue(viewModel.copySelectedNotationMeasure())
viewModel.selectNotationMeasure(targetMeasure)
let beforePaste = viewModel.harmonySymbols
viewModel.markProjectClean()

XCTAssertTrue(viewModel.pasteNotationMeasureClipboard())

Expand Down
Loading