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 @@ -9,6 +9,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`.
## Unreleased

- Added clearer hover tooltips and numpad shortcuts for Notation duration buttons.
- Added a bar/beat position readout to the transport time display.

## 1.1.0

Expand Down
8 changes: 8 additions & 0 deletions JammLab.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
9F8A01152C00000100112233 /* BeatGridCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8A00172C00000100112233 /* BeatGridCalculator.swift */; };
9F8A01162C00000100112233 /* MetronomeClickScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8A00182C00000100112233 /* MetronomeClickScheduler.swift */; };
9F8A01192C00000100112233 /* ProjectArtifactStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8A00192C00000100112233 /* ProjectArtifactStore.swift */; };
9FBD01012F00000100112233 /* TransportPositionDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBD00012F00000100112233 /* TransportPositionDisplay.swift */; };
9F2701012EA0000100112233 /* NotationSMuFL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2700012EA0000100112233 /* NotationSMuFL.swift */; };
9F2701022EA0000100112233 /* Leland.otf in Resources */ = {isa = PBXBuildFile; fileRef = 9F2700022EA0000100112233 /* Leland.otf */; };
9F2701032EA0000100112233 /* Leland-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = 9F2700032EA0000100112233 /* Leland-LICENSE.txt */; };
Expand Down Expand Up @@ -172,6 +173,7 @@
9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */; };
9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */; };
9FAB010B2CE0000100112233 /* BeatGridAndTempoMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */; };
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 */; };
9FAB01402CE0000100112233 /* NotationMusicXMLChordParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */; };
Expand Down Expand Up @@ -291,6 +293,7 @@
9F8A00172C00000100112233 /* BeatGridCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeatGridCalculator.swift; sourceTree = "<group>"; };
9F8A00182C00000100112233 /* MetronomeClickScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickScheduler.swift; sourceTree = "<group>"; };
9F8A00192C00000100112233 /* ProjectArtifactStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectArtifactStore.swift; sourceTree = "<group>"; };
9FBD00012F00000100112233 /* TransportPositionDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportPositionDisplay.swift; sourceTree = "<group>"; };
9F2700012EA0000100112233 /* NotationSMuFL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSMuFL.swift; sourceTree = "<group>"; };
9F2700022EA0000100112233 /* Leland.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Leland.otf; sourceTree = "<group>"; };
9F2700032EA0000100112233 /* Leland-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Leland-LICENSE.txt"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -431,6 +434,7 @@
9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickSchedulerTests.swift; sourceTree = "<group>"; };
9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculatorTests.swift; sourceTree = "<group>"; };
9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeatGridAndTempoMapTests.swift; sourceTree = "<group>"; };
9FBD00022F00000100112233 /* TransportPositionDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportPositionDisplayTests.swift; sourceTree = "<group>"; };
9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationPrimitivesTests.swift; sourceTree = "<group>"; };
9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLTests.swift; sourceTree = "<group>"; };
9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLChordParserTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -576,6 +580,7 @@
9F8E00012C40000100112233 /* StemSeparationJobModels.swift */,
9F8A00122C00000100112233 /* TimecodedNote.swift */,
9F8B00012C10000100112233 /* TimelineViewport.swift */,
9FBD00012F00000100112233 /* TransportPositionDisplay.swift */,
);
path = Models;
sourceTree = "<group>";
Expand Down Expand Up @@ -776,6 +781,7 @@
9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */,
9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */,
9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */,
9FBD00022F00000100112233 /* TransportPositionDisplayTests.swift */,
9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */,
9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */,
9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */,
Expand Down Expand Up @@ -1148,6 +1154,7 @@
9F8C01092C20000100112233 /* TopToolbarView.swift in Sources */,
9FBA01022D40000100112233 /* TrackPitchAnalyzer.swift in Sources */,
9F8C01072C20000100112233 /* TransportBarView.swift in Sources */,
9FBD01012F00000100112233 /* TransportPositionDisplay.swift in Sources */,
9FCB01082D80000100112233 /* TransportButton.swift in Sources */,
9F9001012C60000100112233 /* TransportControlsView.swift in Sources */,
9FBA01032D40000100112233 /* TunerInputService.swift in Sources */,
Expand Down Expand Up @@ -1241,6 +1248,7 @@
9FAB01092CE0000100112233 /* MetronomeClickSchedulerTests.swift in Sources */,
9FAB010A2CE0000100112233 /* TempoGridCalculatorTests.swift in Sources */,
9FAB010B2CE0000100112233 /* BeatGridAndTempoMapTests.swift in Sources */,
9FBD01022F00000100112233 /* TransportPositionDisplayTests.swift in Sources */,
9FAB010C2CE0000100112233 /* NotationPrimitivesTests.swift in Sources */,
9FAB010D2CE0000100112233 /* NotationMusicXMLTests.swift in Sources */,
9FAB01402CE0000100112233 /* NotationMusicXMLChordParserTests.swift in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion JammLab/DesignSystem/AppTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ enum AppTheme {
static let toolbarKeyFieldWidth: CGFloat = 88
static let toolbarKeyTonicFieldWidth: CGFloat = 64
static let toolbarKeyModeFieldWidth: CGFloat = 72
static let transportTimeWidth: CGFloat = 56
static let transportPositionReadoutWidth: CGFloat = 150
static let transportSliderWidth: CGFloat = 130
static let clickVolumeWidth: CGFloat = 110
static let clickVolumeTextWidth: CGFloat = 38
Expand Down
9 changes: 6 additions & 3 deletions JammLab/Models/BeatGridSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,13 @@ struct TempoMap: Equatable {
}

func settings(at time: TimeInterval) -> BeatGridSettings {
segment(at: time)?.settings ?? BeatGridSettings()
}

func segment(at time: TimeInterval) -> TempoMapSegment? {
let clampedTime = max(0, min(time, duration))
return segments.last(where: { $0.startTime <= clampedTime && clampedTime < $0.endTime })?.settings
?? segments.last?.settings
?? BeatGridSettings()
return segments.last(where: { $0.startTime <= clampedTime && clampedTime < $0.endTime })
?? segments.last
}

func nearestBeatTime(to time: TimeInterval) -> TimeInterval? {
Expand Down
57 changes: 57 additions & 0 deletions JammLab/Models/TransportPositionDisplay.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Foundation

struct TransportPositionDisplay: Equatable {
static let invalidMusicalPosition = "--.--.--"
private static let beatBoundaryTolerance = 1e-9

var musicalPosition: String
var elapsedTime: String

var displayText: String {
"\(musicalPosition) / \(elapsedTime)"
}

static func make(time: TimeInterval, tempoMap: TempoMap) -> TransportPositionDisplay {
let elapsedTime = TimeFormatter.mmssMilliseconds(time)
let musicalPosition = musicalPositionText(time: time, tempoMap: tempoMap)
return TransportPositionDisplay(musicalPosition: musicalPosition, elapsedTime: elapsedTime)
}

private static func musicalPositionText(time: TimeInterval, tempoMap: TempoMap) -> String {
let normalizedTime = normalizedPlaybackTime(time)
guard
let segment = tempoMap.segment(at: normalizedTime),
let beatDuration = segment.settings.beatDuration,
beatDuration.isFinite,
beatDuration > 0
else {
return invalidMusicalPosition
}

let beatsPerBar = max(1, segment.settings.timeSignature.beatsPerBar)
let rawBeatPosition = (normalizedTime - segment.settings.firstBeatTime) / beatDuration
let beatPosition = rawBeatPosition + beatBoundaryTolerance
guard beatPosition.isFinite else { return invalidMusicalPosition }

let beatIndex = Int(floor(beatPosition))
let barOrdinal = floorDiv(beatIndex, beatsPerBar)
let beatInBar = beatIndex - (barOrdinal * beatsPerBar) + 1
let beatFraction = beatPosition - Double(beatIndex)
let hundredths = min(99, max(0, Int(floor(beatFraction * 100))))
let barNumber = TempoMap.displayedBarNumber(
for: barOrdinal,
firstBarNumber: segment.firstBarNumber
)

return String(format: "%d.%d.%02d", barNumber, beatInBar, hundredths)
}

private static func normalizedPlaybackTime(_ time: TimeInterval) -> TimeInterval {
guard time.isFinite, time >= 0 else { return 0 }
return time
}

private static func floorDiv(_ value: Int, _ divisor: Int) -> Int {
Int(floor(Double(value) / Double(divisor)))
}
}
10 changes: 10 additions & 0 deletions JammLab/Utilities/TimeFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,14 @@ enum TimeFormatter {
let tenths = totalTenths % 10
return String(format: "%02d:%02d.%d", minutes, seconds, tenths)
}

static func mmssMilliseconds(_ time: TimeInterval) -> String {
guard time.isFinite, time >= 0 else { return "0:00.000" }

let totalMilliseconds = Int((time * 1_000).rounded())
let minutes = totalMilliseconds / 60_000
let seconds = (totalMilliseconds / 1_000) % 60
let milliseconds = totalMilliseconds % 1_000
return String(format: "%d:%02d.%03d", minutes, seconds, milliseconds)
}
}
4 changes: 4 additions & 0 deletions JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ extension AudioPlayerViewModel {
TempoMap(baseSettings: beatGridSettings, markers: notes, duration: duration)
}

var transportPositionText: String {
TransportPositionDisplay.make(time: currentTime, tempoMap: tempoMap).displayText
}

func applyTempoMapToPlaybackEngine() {
playbackEngine.setClickSettings(beatGridSettings)
playbackEngine.setTempoMap(tempoMap)
Expand Down
1 change: 1 addition & 0 deletions JammLab/Views/Components/ControlHelpText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ enum ControlHelpText {
static let stop = "Stop and return to position marker"
static let activateLoop = "Activate loop section"
static let deactivateLoop = "Deactivate loop section"
static let transportPosition = "Playback position: bars.beats.subdivision / elapsed time"

static let tempo = "Tempo"
static let timeSignature = "Time signature"
Expand Down
10 changes: 7 additions & 3 deletions JammLab/Views/Components/TransportBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ struct TransportBarView: View {
let canPlay: Bool
let isLooping: Bool
let statusText: String
let currentTime: TimeInterval
let transportPositionText: String
let playbackRate: Float
let pitchShiftSemitones: Float
let onGoToStart: () -> Void
Expand All @@ -31,10 +31,14 @@ struct TransportBarView: View {
onLoopChanged: onLoopChanged
)

Text(TimeFormatter.mmss(currentTime))
Text(transportPositionText)
.font(AppTheme.Typography.bodyMonospaced)
.foregroundStyle(appColors.secondaryText)
.frame(width: AppTheme.ControlSize.transportTimeWidth, alignment: .leading)
.lineLimit(1)
.frame(width: AppTheme.ControlSize.transportPositionReadoutWidth, alignment: .leading)
.help(ControlHelpText.transportPosition)
.accessibilityLabel("Playback position")
.accessibilityValue(transportPositionText)

Spacer(minLength: 0)

Expand Down
2 changes: 1 addition & 1 deletion JammLab/Views/MainWorkspacePanels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ extension ContentView {
canPlay: viewModel.canPlay,
isLooping: viewModel.isLooping,
statusText: viewModel.transportStatusText,
currentTime: viewModel.currentTime,
transportPositionText: viewModel.transportPositionText,
playbackRate: viewModel.playbackRate,
pitchShiftSemitones: viewModel.pitchShiftSemitones,
onGoToStart: { viewModel.seekToStart() },
Expand Down
16 changes: 16 additions & 0 deletions JammLabTests/BeatGridAndTempoMapTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,22 @@ final class BeatGridAndTempoMapTests: XCTestCase {
XCTAssertEqual(tempoMap.segments[1].settings.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4))
}

func testTempoMapSegmentLookupUsesExpectedBoundaries() throws {
let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour)
let tempoMarker = TimecodedNote(
time: 2,
title: "3/4",
metadata: TempoTimeSignatureMarkerPayload(beatsPerBar: 3).metadata
)
let tempoMap = TempoMap(baseSettings: baseSettings, markers: [tempoMarker], duration: 6)

XCTAssertEqual(try XCTUnwrap(tempoMap.segment(at: -1)).startTime, 0, accuracy: 0.0001)
XCTAssertEqual(try XCTUnwrap(tempoMap.segment(at: 1.9999)).startTime, 0, accuracy: 0.0001)
XCTAssertEqual(try XCTUnwrap(tempoMap.segment(at: 2)).startTime, 2, accuracy: 0.0001)
XCTAssertEqual(try XCTUnwrap(tempoMap.segment(at: 6)).startTime, 2, accuracy: 0.0001)
XCTAssertEqual(try XCTUnwrap(tempoMap.segment(at: 10)).startTime, 2, accuracy: 0.0001)
}

func testTempoMapRestartsBarNumberingWhenMarkerSetsNewFirstBeat() {
let baseSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour)
let tempoMarker = TimecodedNote(
Expand Down
99 changes: 99 additions & 0 deletions JammLabTests/TransportPositionDisplayTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import XCTest
@testable import JammLab

final class TransportPositionDisplayTests: XCTestCase {
func testDisplaysBarBeatHundredthsAndElapsedMilliseconds() {
let tempoMap = makeTempoMap()

XCTAssertEqual(displayText(time: 0, tempoMap: tempoMap), "1.1.00 / 0:00.000")
XCTAssertEqual(displayText(time: 0.25, tempoMap: tempoMap), "1.1.50 / 0:00.250")
XCTAssertEqual(displayText(time: 0.5, tempoMap: tempoMap), "1.2.00 / 0:00.500")
XCTAssertEqual(displayText(time: 2, tempoMap: tempoMap), "2.1.00 / 0:02.000")
}

func testFloorsHundredthsJustBeforeBoundaryAndAdvancesAtBoundary() {
let tempoMap = makeTempoMap()

XCTAssertEqual(displayText(time: 0.5 - 0.0001, tempoMap: tempoMap), "1.1.99 / 0:00.500")
XCTAssertEqual(displayText(time: 0.5, tempoMap: tempoMap), "1.2.00 / 0:00.500")
XCTAssertEqual(displayText(time: 2 - 0.0001, tempoMap: tempoMap), "1.4.99 / 0:02.000")
XCTAssertEqual(displayText(time: 2, tempoMap: tempoMap), "2.1.00 / 0:02.000")
}

func testPreservesPreFirstBeatBarNumbering() {
let tempoMap = makeTempoMap(settings: BeatGridSettings(
bpm: 120,
firstBeatTime: 2,
timeSignature: .fourFour
))

XCTAssertEqual(displayText(time: 0, tempoMap: tempoMap), "-1.1.00 / 0:00.000")
XCTAssertEqual(displayText(time: 1.5, tempoMap: tempoMap), "-1.4.00 / 0:01.500")
XCTAssertEqual(displayText(time: 2, tempoMap: tempoMap), "1.1.00 / 0:02.000")
}

func testUsesTempoMapBarContinuityAndRestartMarkers() {
let continuingMarker = TimecodedNote(
time: 2,
title: "3/4",
metadata: TempoTimeSignatureMarkerPayload(beatsPerBar: 3).metadata
)
let continuingMap = makeTempoMap(markers: [continuingMarker])

XCTAssertEqual(displayText(time: 2, tempoMap: continuingMap), "2.1.00 / 0:02.000")
XCTAssertEqual(displayText(time: 3.5, tempoMap: continuingMap), "3.1.00 / 0:03.500")

let restartingMarker = TimecodedNote(
time: 2,
title: "3/4",
metadata: TempoTimeSignatureMarkerPayload(beatsPerBar: 3, setsNewFirstBeat: true).metadata
)
let restartingMap = makeTempoMap(markers: [restartingMarker])

XCTAssertEqual(displayText(time: 2, tempoMap: restartingMap), "1.1.00 / 0:02.000")
}

func testFallsBackWhenTempoCannotProduceBeatDuration() {
let noTempoMap = makeTempoMap(settings: BeatGridSettings())
var infiniteTempoMap = makeTempoMap()
infiniteTempoMap.segments = [
TempoMapSegment(startTime: 0, endTime: 8, settings: BeatGridSettings(bpm: .infinity))
]
var nanTempoMap = makeTempoMap()
nanTempoMap.segments = [
TempoMapSegment(startTime: 0, endTime: 8, settings: BeatGridSettings(bpm: .nan))
]

XCTAssertEqual(displayText(time: 1, tempoMap: noTempoMap), "--.--.-- / 0:01.000")
XCTAssertEqual(displayText(time: 1, tempoMap: infiniteTempoMap), "--.--.-- / 0:01.000")
XCTAssertEqual(displayText(time: 1, tempoMap: nanTempoMap), "--.--.-- / 0:01.000")
}

func testInvalidPlaybackTimeClampsToZeroTimeDisplay() {
let tempoMap = makeTempoMap()

XCTAssertEqual(displayText(time: -1, tempoMap: tempoMap), "1.1.00 / 0:00.000")
XCTAssertEqual(displayText(time: .nan, tempoMap: tempoMap), "1.1.00 / 0:00.000")
XCTAssertEqual(displayText(time: .infinity, tempoMap: tempoMap), "1.1.00 / 0:00.000")
}

func testElapsedMillisecondsFormattingHandlesInvalidValuesAndRollover() {
XCTAssertEqual(TimeFormatter.mmssMilliseconds(-1), "0:00.000")
XCTAssertEqual(TimeFormatter.mmssMilliseconds(.nan), "0:00.000")
XCTAssertEqual(TimeFormatter.mmssMilliseconds(.infinity), "0:00.000")
XCTAssertEqual(TimeFormatter.mmssMilliseconds(61.234), "1:01.234")
XCTAssertEqual(TimeFormatter.mmssMilliseconds(59.9996), "1:00.000")
}

private func displayText(time: TimeInterval, tempoMap: TempoMap) -> String {
TransportPositionDisplay.make(time: time, tempoMap: tempoMap).displayText
}

private func makeTempoMap(
settings: BeatGridSettings = BeatGridSettings(bpm: 120, timeSignature: .fourFour),
markers: [TimecodedNote] = [],
duration: TimeInterval = 8
) -> TempoMap {
TempoMap(baseSettings: settings, markers: markers, duration: duration)
}
}
Loading