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 measure range selection with Shift-click, Cmd+C/Cmd+V measure copy and replace-paste for harmony contents, and Esc to clear the selection.
- Added boxed Region start labels to Notation in the timeline track and Notation window.
- Added beat slash notation to Notation measures in the timeline track and Notation window.
- Added a synced Notation window that opens from the View menu or the Notation track context menu, showing the full chart in a notebook-style layout while sharing harmony editing with the timeline track.
Expand Down
39 changes: 36 additions & 3 deletions JammLab/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ struct ContentView: View {
.background(WindowCloseGuard())
.background(
AppHotkeyMonitorView(
allowedHotkeys: Set(AppHotkey.allCases),
onHotkey: handleHotkey
allowedHotkeys: allowedHotkeys,
onHotkeyShouldConsume: handleHotkey
)
)
.task {
Expand Down Expand Up @@ -174,33 +174,66 @@ struct ContentView: View {
}
}

private func handleHotkey(_ hotkey: AppHotkey) {
private var allowedHotkeys: Set<AppHotkey> {
var hotkeys = Set(AppHotkey.allCases)
if !viewModel.canCopySelectedNotationMeasure {
hotkeys.remove(.copyMeasure)
}
if !viewModel.canPasteNotationMeasureClipboard {
hotkeys.remove(.pasteMeasure)
}
if !viewModel.hasSelectedNotationMeasures {
hotkeys.remove(.clearNotationMeasureSelection)
}
return hotkeys
}

@discardableResult
private func handleHotkey(_ hotkey: AppHotkey) -> Bool {
// AppHotkey is the single source of truth for handled shortcuts.
// Add new hotkeys there first so Help > Keyboard Shortcuts updates
// together with this dispatch switch.
switch hotkey {
case .playPause:
viewModel.togglePlayStop()
return true
case .toggleLoop:
viewModel.toggleLooping()
return true
case .setLoopStart:
viewModel.setLoopStartAtCurrentTime()
return true
case .setLoopEnd:
viewModel.setLoopEndAtCurrentTime()
return true
case .addNote:
viewModel.addNoteAtCurrentTime()
return true
case .addTempoTimeSignatureMarker:
beginAddingTempoTimeSignatureMarker(at: viewModel.currentTime)
return true
case .setBeatOne:
viewModel.setCurrentTimeAsBeatOne()
return true
case .toggleClick:
viewModel.toggleClick()
return true
case .toggleSnap:
viewModel.toggleSnap()
return true
case .togglePlaybackMode:
viewModel.togglePlaybackMode()
return true
case .toggleVideoWindow:
viewModel.toggleVideoWindow()
return true
case .copyMeasure:
return viewModel.copySelectedNotationMeasure()
case .pasteMeasure:
return viewModel.pasteNotationMeasureClipboard()
case .clearNotationMeasureSelection:
viewModel.clearNotationMeasureSelection()
return true
}
}

Expand Down
72 changes: 72 additions & 0 deletions JammLab/Models/NotationScoreModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,78 @@ struct NotationRegionLabel: Identifiable, Equatable {
}
}

struct NotationMeasureSelection: Equatable, Identifiable {
var number: Int
var startTime: TimeInterval
var endTime: TimeInterval
var attributes: MeasureAttributes

init(measure: ScoreMeasure) {
self.number = measure.number
self.startTime = measure.startTime
self.endTime = measure.endTime
self.attributes = measure.attributes
}

var id: String {
"\(number)-\(startTime)-\(endTime)"
}

func matches(_ measure: ScoreMeasure) -> Bool {
number == measure.number
&& abs(startTime - measure.startTime) < NotationMeasureTiming.timelineTolerance
&& abs(endTime - measure.endTime) < NotationMeasureTiming.timelineTolerance
}
}

struct NotationMeasureClipboard: Equatable {
var measures: [NotationMeasureClipboardMeasure]
}

struct NotationMeasureClipboardMeasure: Equatable {
var items: [NotationMeasureClipboardItem]
}

struct NotationMeasureClipboardItem: Equatable {
var offsetInQuarterNotes: Double
var rawText: String
}

enum NotationMeasureTiming {
static let timelineTolerance: TimeInterval = 0.000_001

static func containsEventTime(_ time: TimeInterval, in measure: ScoreMeasure) -> Bool {
time >= measure.startTime - timelineTolerance
&& (
time < measure.endTime - timelineTolerance
|| abs(time - measure.startTime) < timelineTolerance
)
}

static func quarterOffset(for time: TimeInterval, in measure: ScoreMeasure) -> Double {
let length = quarterLength(for: measure.attributes.timeSignature)
guard measure.duration > 0, length > 0 else { return 0 }
let progress = max(0, min((time - measure.startTime) / measure.duration, 1))
return progress * length
}

static func time(forQuarterOffset offset: Double, in measure: ScoreMeasure) -> TimeInterval {
let length = quarterLength(for: measure.attributes.timeSignature)
guard measure.duration > 0, length > 0 else { return measure.startTime }
let progress = max(0, min(offset / length, 1))
return measure.startTime + progress * measure.duration
}

static func isValidHarmonyOffset(_ offset: Double, in timeSignature: TimeSignature) -> Bool {
let length = quarterLength(for: timeSignature)
return offset >= -timelineTolerance && offset < length - timelineTolerance
}

static func quarterLength(for timeSignature: TimeSignature) -> Double {
Double(timeSignature.beatsPerBar) * 4.0 / Double(max(1, timeSignature.beatUnit))
}
}

struct HarmonySymbol: Identifiable, Codable, Equatable {
var id: UUID
var time: TimeInterval
Expand Down
7 changes: 2 additions & 5 deletions JammLab/Services/NotationMeasureLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -486,14 +486,11 @@ struct NotationMeasureLayout {
}

static func time(forHarmonyOffset offset: Double, in measure: ScoreMeasure) -> TimeInterval {
let quarterLength = quarterLength(for: measure.attributes.timeSignature)
guard measure.duration > 0, quarterLength > 0 else { return measure.startTime }
let progress = max(0, min(offset / quarterLength, 1))
return measure.startTime + progress * measure.duration
NotationMeasureTiming.time(forQuarterOffset: offset, in: measure)
}

static func quarterLength(for timeSignature: TimeSignature) -> Double {
Double(timeSignature.beatsPerBar) * 4.0 / Double(max(1, timeSignature.beatUnit))
NotationMeasureTiming.quarterLength(for: timeSignature)
}

static func canvasGeometry(
Expand Down
28 changes: 7 additions & 21 deletions JammLab/Services/NotationViewportFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -374,13 +374,13 @@ struct NotationViewportFactory {
private func harmonies(for measure: ScoreMeasure, from harmonySymbols: [HarmonySymbol]) -> [HarmonySymbol] {
harmonySymbols
.compactMap { symbol -> HarmonySymbol? in
guard Self.isNotationEventTime(symbol.time, within: measure) else {
guard NotationMeasureTiming.containsEventTime(symbol.time, in: measure) else {
return nil
}

return symbol.withPosition(
measureNumber: measure.number,
offsetInQuarterNotes: quarterOffset(for: symbol.time, in: measure)
offsetInQuarterNotes: NotationMeasureTiming.quarterOffset(for: symbol.time, in: measure)
)
}
.sorted {
Expand All @@ -396,15 +396,15 @@ struct NotationViewportFactory {
private func regionLabels(for measure: ScoreMeasure, from regionNotes: [TimecodedNote]) -> [NotationRegionLabel] {
regionNotes
.compactMap { note -> NotationRegionLabel? in
guard Self.isNotationEventTime(note.time, within: measure) else {
guard NotationMeasureTiming.containsEventTime(note.time, in: measure) else {
return nil
}

return NotationRegionLabel(
id: note.id,
time: note.time,
measureNumber: measure.number,
offsetInQuarterNotes: quarterOffset(for: note.time, in: measure),
offsetInQuarterNotes: NotationMeasureTiming.quarterOffset(for: note.time, in: measure),
title: Self.regionLabelTitle(for: note)
)
}
Expand Down Expand Up @@ -435,14 +435,6 @@ struct NotationViewportFactory {
return trimmedTitle.isEmpty ? "Region" : trimmedTitle
}

private static func isNotationEventTime(_ time: TimeInterval, within measure: ScoreMeasure) -> Bool {
time >= measure.startTime - timelineTolerance
&& (
time < measure.endTime - timelineTolerance
|| abs(time - measure.startTime) < timelineTolerance
)
}

private static func isOrderedByNotationPosition(
lhsOffset: Double,
lhsID: UUID,
Expand All @@ -457,17 +449,11 @@ struct NotationViewportFactory {
}

private func quarterOffset(for time: TimeInterval, in measure: ScoreMeasure) -> Double {
let length = quarterLength(for: measure.attributes.timeSignature)
guard measure.duration > 0, length > 0 else { return 0 }
let progress = max(0, min((time - measure.startTime) / measure.duration, 1))
return progress * length
NotationMeasureTiming.quarterOffset(for: time, in: measure)
}

private func timeForQuarterOffset(_ offset: Double, in measure: ScoreMeasure) -> TimeInterval {
let length = quarterLength(for: measure.attributes.timeSignature)
guard measure.duration > 0, length > 0 else { return measure.startTime }
let progress = max(0, min(offset / length, 1))
return measure.startTime + progress * measure.duration
NotationMeasureTiming.time(forQuarterOffset: offset, in: measure)
}

private func snappedOffset(
Expand All @@ -494,7 +480,7 @@ struct NotationViewportFactory {
}

private func quarterLength(for timeSignature: TimeSignature) -> Double {
Double(timeSignature.beatsPerBar) * 4.0 / Double(max(1, timeSignature.beatUnit))
NotationMeasureTiming.quarterLength(for: timeSignature)
}

private static let maximumMeasureTraversalCount = 100_000
Expand Down
27 changes: 27 additions & 0 deletions JammLab/Utilities/AppHotkey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ enum AppHotkey: CaseIterable, Hashable {
case toggleSnap
case togglePlaybackMode
case toggleVideoWindow
case copyMeasure
case pasteMeasure
case clearNotationMeasureSelection

// 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
Expand All @@ -23,6 +26,10 @@ enum AppHotkey: CaseIterable, Hashable {
let relevantModifiers = modifierFlags.intersection(shortcutModifiers)

switch (event.keyCode, relevantModifiers) {
case (8, [.command]):
self = .copyMeasure
case (9, [.command]):
self = .pasteMeasure
case (1, [.option]):
self = .toggleSnap
case (9, [.option]):
Expand Down Expand Up @@ -56,6 +63,8 @@ enum AppHotkey: CaseIterable, Hashable {
self = .setBeatOne
case 8:
self = .toggleClick
case 53:
self = .clearNotationMeasureSelection
default:
return nil
}
Expand Down Expand Up @@ -85,6 +94,12 @@ enum AppHotkey: CaseIterable, Hashable {
return "Tab"
case .toggleVideoWindow:
return "Opt+V"
case .copyMeasure:
return "Cmd+C"
case .pasteMeasure:
return "Cmd+V"
case .clearNotationMeasureSelection:
return "Esc"
}
}

Expand Down Expand Up @@ -112,6 +127,12 @@ enum AppHotkey: CaseIterable, Hashable {
return "Original / Stems"
case .toggleVideoWindow:
return "Video Window"
case .copyMeasure:
return "Copy Measure"
case .pasteMeasure:
return "Paste Measure"
case .clearNotationMeasureSelection:
return "Clear Measure Selection"
}
}

Expand Down Expand Up @@ -139,6 +160,12 @@ enum AppHotkey: CaseIterable, Hashable {
return "Switch between original playback and stems playback when stems are available."
case .toggleVideoWindow:
return "Open or close the sidecar video window for the current video project."
case .copyMeasure:
return "Copy the selected notation measure."
case .pasteMeasure:
return "Replace the selected notation measure with the copied measure contents."
case .clearNotationMeasureSelection:
return "Clear the selected notation measure or measure range."
}
}
}
Loading