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

- Improved Notation measure spacing to prevent late notes from stretching a single measure across the view, keep visible parts aligned, backfill the final page, and balance score systems without avoidable one-measure rows.
- 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 a separate Bass 8 clef with Leland notation, octave-down note preview and MusicXML export, and made it the default clef for new bass-guitar notation tracks while preserving existing projects.
- 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.
Expand Down
3 changes: 3 additions & 0 deletions JammLab/DesignSystem/AppTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,9 @@ enum AppTheme {
static let notationSlashWidth: CGFloat = 7
static let notationSlashMinimumBeatSpacing: CGFloat = 16
static let notationItemAnchorInset: CGFloat = AppTheme.Spacing.lg
static let notationRhythmicGlyphRadius: CGFloat = notationStaffLineSpacing * 1.75
static let notationRhythmicColumnGap: CGFloat = AppTheme.Spacing.sm
static let notationRhythmicDotRadius: CGFloat = notationStaffLineSpacing * 0.25
static let notationChordSecondOffset: CGFloat = 4
static let notationPolyphonicLaneSpacing: CGFloat = 9
static let notationDuplicateNoteOffset: CGFloat = notationStaffLineSpacing * 1.1
Expand Down
35 changes: 35 additions & 0 deletions JammLab/Models/NotationScoreModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,41 @@ enum NotationMeasureTiming {
static func quarterLength(for timeSignature: TimeSignature) -> Double {
Double(timeSignature.beatsPerBar) * 4.0 / Double(max(1, timeSignature.beatUnit))
}

static func isSingleFullMeasureWholeRest(
_ measure: ScoreMeasure,
item expectedItem: NotationMeasureItem? = nil
) -> Bool {
guard measure.notationItems.count == 1,
let item = measure.notationItems.first,
expectedItem == nil || expectedItem?.id == item.id,
item.kind == .rest,
item.displayDuration.denominator == 1,
abs(item.offsetInQuarterNotes) <= timelineTolerance
else {
return false
}

return abs(
item.durationInQuarterNotes
- quarterLength(for: measure.attributes.timeSignature)
) <= timelineTolerance
}

static func visibleMeasureIndex(
containing time: TimeInterval,
in measures: [ScoreMeasure]
) -> Int? {
measures.indices.first { index in
let measure = measures[index]
let isLastMeasure = index == measures.indices.upperBound - 1
return time >= measure.startTime
&& (
time < measure.endTime
|| (isLastMeasure && time <= measure.endTime)
)
}
}
}

struct HarmonySymbol: Identifiable, Codable, Equatable {
Expand Down
54 changes: 33 additions & 21 deletions JammLab/Models/NotationViewportState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,32 +68,44 @@ struct NotationScoreState: Equatable {
guard isReady, !measures.isEmpty else { return [] }

let safeMeasuresPerSystem = max(1, measuresPerSystem)
return stride(from: 0, to: measures.count, by: safeMeasuresPerSystem).map { startIndex in
return stride(from: 0, to: measures.count, by: safeMeasuresPerSystem).compactMap { startIndex in
let endIndex = min(startIndex + safeMeasuresPerSystem, measures.count)
let systemMeasures = Array(measures[startIndex..<endIndex])

return NotationSystemState(
return system(
index: startIndex / safeMeasuresPerSystem,
viewportState: NotationViewportState(
availability: availability,
clef: systemMeasures.first?.attributes.clef ?? .treble,
keySignature: systemMeasures.first?.attributes.keySignature ?? keySignature,
timeSignature: systemMeasures.first?.attributes.timeSignature ?? .fourFour,
firstVisibleMeasureNumber: systemMeasures.first?.number ?? 1,
visibleMeasureCount: systemMeasures.count,
visibleMeasures: systemMeasures,
anchorTime: anchorTime,
activeMeasureNumber: activeMeasureNumber,
tieConnections: NotationTieResolver.connections(
tieConnections,
visibleIn: systemMeasures
),
previousPageStartTime: nil,
nextPageStartTime: nil
)
measureRange: startIndex..<endIndex
)
}
}

func system(index: Int, measureRange: Range<Int>) -> NotationSystemState? {
guard isReady,
!measureRange.isEmpty,
measureRange.lowerBound >= measures.startIndex,
measureRange.upperBound <= measures.endIndex
else { return nil }

let systemMeasures = Array(measures[measureRange])
return NotationSystemState(
index: index,
viewportState: NotationViewportState(
availability: availability,
clef: systemMeasures.first?.attributes.clef ?? .treble,
keySignature: systemMeasures.first?.attributes.keySignature ?? keySignature,
timeSignature: systemMeasures.first?.attributes.timeSignature ?? .fourFour,
firstVisibleMeasureNumber: systemMeasures.first?.number ?? 1,
visibleMeasureCount: systemMeasures.count,
visibleMeasures: systemMeasures,
anchorTime: anchorTime,
activeMeasureNumber: activeMeasureNumber,
tieConnections: NotationTieResolver.connections(
tieConnections,
visibleIn: systemMeasures
),
previousPageStartTime: nil,
nextPageStartTime: nil
)
)
}
}

struct NotationScoreContent: Equatable {
Expand Down
173 changes: 137 additions & 36 deletions JammLab/Services/NotationMeasureLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,44 @@ struct NotationMeasureCanvasGeometry: Equatable {
let contentEndX: CGFloat
let staffStartX: CGFloat
let staffEndX: CGFloat
let rhythmicStartX: CGFloat
let rhythmicEndX: CGFloat
let rhythmicSpacingMap: NotationRhythmicSpacingMap?

init(
measureIndex: Int,
cellStartX: CGFloat,
cellEndX: CGFloat,
contentStartX: CGFloat,
contentEndX: CGFloat,
staffStartX: CGFloat,
staffEndX: CGFloat,
leadingAnchorInset: CGFloat = AppTheme.Timeline.notationItemAnchorInset,
trailingAnchorInset: CGFloat = AppTheme.Timeline.notationItemAnchorInset,
rhythmicSpacingMap: NotationRhythmicSpacingMap? = nil
) {
self.measureIndex = measureIndex
self.cellStartX = cellStartX
self.cellEndX = cellEndX
self.contentStartX = contentStartX
self.contentEndX = contentEndX
self.staffStartX = staffStartX
self.staffEndX = staffEndX
self.rhythmicSpacingMap = rhythmicSpacingMap

let visualStartX = max(contentStartX, staffStartX)
let visualEndX = max(visualStartX, min(contentEndX, staffEndX))
let proposedStartX = visualStartX + max(0, leadingAnchorInset)
let proposedEndX = visualEndX - max(0, trailingAnchorInset)
if proposedStartX <= proposedEndX {
rhythmicStartX = proposedStartX
rhythmicEndX = proposedEndX
} else {
let midpoint = (visualStartX + visualEndX) / 2
rhythmicStartX = midpoint
rhythmicEndX = midpoint
}
}

var includesRawStartBarline: Bool {
measureIndex > 0 || !contentStartsAfterCellBoundary
Expand Down Expand Up @@ -174,6 +212,48 @@ struct NotationMeasureLayout {
}
}

static func canvasGeometries(
totalWidth: CGFloat,
bodyWidths: [CGFloat],
attributeReserveWidths: [CGFloat],
leadingAnchorInsets: [CGFloat],
trailingAnchorInsets: [CGFloat],
rhythmicSpacingMaps: [NotationRhythmicSpacingMap?]
) -> [NotationMeasureCanvasGeometry] {
let measureCount = bodyWidths.count
guard measureCount > 0 else { return [] }

var cursorX: CGFloat = 0
return bodyWidths.indices.map { index in
let cellStartX = cursorX
let reserveWidth = attributeReserveWidths.indices.contains(index)
? max(0, attributeReserveWidths[index])
: 0
let bodyWidth = max(0, bodyWidths[index])
let contentStartX = cellStartX + reserveWidth
let cellEndX = contentStartX + bodyWidth
cursorX = cellEndX

return canvasGeometry(
measureIndex: index,
measureCount: measureCount,
cellStartX: cellStartX,
cellEndX: cellEndX,
contentStartX: contentStartX,
totalWidth: totalWidth,
leadingAnchorInset: leadingAnchorInsets.indices.contains(index)
? leadingAnchorInsets[index]
: AppTheme.Timeline.notationItemAnchorInset,
trailingAnchorInset: trailingAnchorInsets.indices.contains(index)
? trailingAnchorInsets[index]
: AppTheme.Timeline.notationItemAnchorInset,
rhythmicSpacingMap: rhythmicSpacingMaps.indices.contains(index)
? rhythmicSpacingMaps[index]
: nil
)
}
}

static func systemMeasureNumberLabelX(geometry: NotationMeasureCanvasGeometry) -> CGFloat {
systemMeasureNumberLabelTrailingX(geometry: geometry) - measureNumberLabelWidth
}
Expand Down Expand Up @@ -245,8 +325,7 @@ struct NotationMeasureLayout {
let anchorX = notationAnchorX(
geometry: geometry,
offsetInQuarterNotes: offsetInQuarterNotes,
timeSignature: timeSignature,
anchorInset: 0
timeSignature: timeSignature
)
return min(max(anchorX, bounds.lowerBound), bounds.upperBound)
}
Expand Down Expand Up @@ -481,9 +560,10 @@ struct NotationMeasureLayout {
geometry: NotationMeasureCanvasGeometry,
progress: CGFloat
) -> CGFloat {
let clampedProgress = max(0, min(progress, 1))
let width = max(0, geometry.contentEndX - geometry.contentStartX)
return geometry.contentStartX + clampedProgress * width
rhythmicX(
forProgress: Double(max(0, min(progress, 1))),
geometry: geometry
)
}

static func playheadIndicatorX(
Expand All @@ -505,48 +585,52 @@ struct NotationMeasureLayout {
minimumBeatSpacing: CGFloat = AppTheme.Timeline.notationSlashMinimumBeatSpacing
) -> [CGFloat] {
let beatCount = timeSignature.beatsPerBar
let contentWidth = geometry.contentEndX - geometry.contentStartX
let contentWidth = geometry.rhythmicEndX - geometry.rhythmicStartX
guard beatCount > 0, contentWidth > 0 else { return [] }

let beatSpacing = contentWidth / CGFloat(beatCount)
guard beatSpacing >= max(0, minimumBeatSpacing) else { return [] }

let beatLength = 4.0 / Double(max(1, timeSignature.beatUnit))
return (0..<beatCount).map { index in
let centers = (0..<beatCount).map { index in
notationAnchorX(
geometry: geometry,
offsetInQuarterNotes: Double(index) * beatLength,
timeSignature: timeSignature
)
}
let safeMinimumBeatSpacing = max(0, minimumBeatSpacing)
guard zip(centers, centers.dropFirst()).allSatisfy({
$1 - $0 >= safeMinimumBeatSpacing
}) else {
return []
}
return centers
}

static func notationAnchorX(
geometry: NotationMeasureCanvasGeometry,
offsetInQuarterNotes: Double,
timeSignature: TimeSignature,
anchorInset: CGFloat = AppTheme.Timeline.notationItemAnchorInset
timeSignature: TimeSignature
) -> CGFloat {
let quarterLength = quarterLength(for: timeSignature)
guard quarterLength > 0 else { return geometry.contentStartX }
guard quarterLength > 0 else { return geometry.rhythmicStartX }

let contentWidth = max(0, geometry.contentEndX - geometry.contentStartX)
let effectiveInset = min(max(0, anchorInset), contentWidth)
let progress = max(0, min(offsetInQuarterNotes / quarterLength, 1))
let rawX = geometry.contentStartX + effectiveInset + CGFloat(progress) * contentWidth
return min(max(rawX, geometry.contentStartX), geometry.contentEndX)
return rhythmicX(forProgress: progress, geometry: geometry)
}

static func notationAnchorProgress(
atX x: CGFloat,
geometry: NotationMeasureCanvasGeometry,
anchorInset: CGFloat = AppTheme.Timeline.notationItemAnchorInset
geometry: NotationMeasureCanvasGeometry
) -> Double {
let contentWidth = max(0, geometry.contentEndX - geometry.contentStartX)
guard contentWidth > 0 else { return 0 }

let effectiveInset = min(max(0, anchorInset), contentWidth)
let rawProgress = (x - geometry.contentStartX - effectiveInset) / contentWidth
let width = max(0, geometry.rhythmicEndX - geometry.rhythmicStartX)
guard width > 0 else { return 0 }
if let spacingMap = geometry.rhythmicSpacingMap {
return spacingMap.progress(
atX: x,
startX: geometry.rhythmicStartX,
endX: geometry.rhythmicEndX
)
}
let rawProgress = (x - geometry.rhythmicStartX) / width
return Double(max(0, min(rawProgress, 1)))
}

Expand All @@ -562,6 +646,22 @@ struct NotationMeasureLayout {
)
}

private static func rhythmicX(
forProgress progress: Double,
geometry: NotationMeasureCanvasGeometry
) -> CGFloat {
if let spacingMap = geometry.rhythmicSpacingMap {
return spacingMap.x(
forProgress: progress,
startX: geometry.rhythmicStartX,
endX: geometry.rhythmicEndX
)
}
let clampedProgress = max(0, min(progress, 1))
let width = max(0, geometry.rhythmicEndX - geometry.rhythmicStartX)
return geometry.rhythmicStartX + CGFloat(clampedProgress) * width
}

static func notationItemX(
geometry: NotationMeasureCanvasGeometry,
measure: ScoreMeasure,
Expand All @@ -580,17 +680,12 @@ struct NotationMeasureLayout {

private static func centersSingleFullMeasureWholeRest(
measure: ScoreMeasure,
item: NotationMeasureItem,
tolerance: Double = 0.0001
item: NotationMeasureItem
) -> Bool {
guard measure.notationItems.count == 1 else { return false }
guard measure.notationItems.first?.id == item.id else { return false }
guard item.kind == .rest else { return false }
guard item.displayDuration.denominator == 1 else { return false }
guard abs(item.offsetInQuarterNotes) <= tolerance else { return false }

let measureQuarterLength = quarterLength(for: measure.attributes.timeSignature)
return abs(item.durationInQuarterNotes - measureQuarterLength) <= tolerance
NotationMeasureTiming.isSingleFullMeasureWholeRest(
measure,
item: item
)
}

static func harmonyLabelX(
Expand Down Expand Up @@ -666,7 +761,10 @@ struct NotationMeasureLayout {
cellStartX: CGFloat,
cellEndX: CGFloat,
contentStartX: CGFloat,
totalWidth: CGFloat
totalWidth: CGFloat,
leadingAnchorInset: CGFloat = AppTheme.Timeline.notationItemAnchorInset,
trailingAnchorInset: CGFloat = AppTheme.Timeline.notationItemAnchorInset,
rhythmicSpacingMap: NotationRhythmicSpacingMap? = nil
) -> NotationMeasureCanvasGeometry {
let safeCellStartX = max(0, cellStartX)
let safeCellEndX = max(safeCellStartX, cellEndX)
Expand Down Expand Up @@ -696,7 +794,10 @@ struct NotationMeasureLayout {
contentStartX: clampedContentStartX,
contentEndX: safeCellEndX,
staffStartX: staffStartX,
staffEndX: max(staffStartX, staffEndX)
staffEndX: max(staffStartX, staffEndX),
leadingAnchorInset: leadingAnchorInset,
trailingAnchorInset: trailingAnchorInset,
rhythmicSpacingMap: rhythmicSpacingMap
)
}

Expand Down
Loading