diff --git a/CHANGELOG.md b/CHANGELOG.md index 890e6dd..764e480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index cbc83f1..f64d292 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -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 */; }; @@ -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 */; }; @@ -291,6 +293,7 @@ 9F8A00172C00000100112233 /* BeatGridCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeatGridCalculator.swift; sourceTree = ""; }; 9F8A00182C00000100112233 /* MetronomeClickScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickScheduler.swift; sourceTree = ""; }; 9F8A00192C00000100112233 /* ProjectArtifactStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectArtifactStore.swift; sourceTree = ""; }; + 9FBD00012F00000100112233 /* TransportPositionDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportPositionDisplay.swift; sourceTree = ""; }; 9F2700012EA0000100112233 /* NotationSMuFL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationSMuFL.swift; sourceTree = ""; }; 9F2700022EA0000100112233 /* Leland.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Leland.otf; sourceTree = ""; }; 9F2700032EA0000100112233 /* Leland-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Leland-LICENSE.txt"; sourceTree = ""; }; @@ -431,6 +434,7 @@ 9FAB00092CE0000100112233 /* MetronomeClickSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetronomeClickSchedulerTests.swift; sourceTree = ""; }; 9FAB000A2CE0000100112233 /* TempoGridCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempoGridCalculatorTests.swift; sourceTree = ""; }; 9FAB000B2CE0000100112233 /* BeatGridAndTempoMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeatGridAndTempoMapTests.swift; sourceTree = ""; }; + 9FBD00022F00000100112233 /* TransportPositionDisplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportPositionDisplayTests.swift; sourceTree = ""; }; 9FAB000C2CE0000100112233 /* NotationPrimitivesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationPrimitivesTests.swift; sourceTree = ""; }; 9FAB000D2CE0000100112233 /* NotationMusicXMLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLTests.swift; sourceTree = ""; }; 9FAB00402CE0000100112233 /* NotationMusicXMLChordParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMusicXMLChordParserTests.swift; sourceTree = ""; }; @@ -576,6 +580,7 @@ 9F8E00012C40000100112233 /* StemSeparationJobModels.swift */, 9F8A00122C00000100112233 /* TimecodedNote.swift */, 9F8B00012C10000100112233 /* TimelineViewport.swift */, + 9FBD00012F00000100112233 /* TransportPositionDisplay.swift */, ); path = Models; sourceTree = ""; @@ -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 */, @@ -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 */, @@ -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 */, diff --git a/JammLab/DesignSystem/AppTheme.swift b/JammLab/DesignSystem/AppTheme.swift index 251f1e9..28ab5ad 100644 --- a/JammLab/DesignSystem/AppTheme.swift +++ b/JammLab/DesignSystem/AppTheme.swift @@ -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 diff --git a/JammLab/Models/BeatGridSettings.swift b/JammLab/Models/BeatGridSettings.swift index a1efe43..8218a3d 100644 --- a/JammLab/Models/BeatGridSettings.swift +++ b/JammLab/Models/BeatGridSettings.swift @@ -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? { diff --git a/JammLab/Models/TransportPositionDisplay.swift b/JammLab/Models/TransportPositionDisplay.swift new file mode 100644 index 0000000..d3d2075 --- /dev/null +++ b/JammLab/Models/TransportPositionDisplay.swift @@ -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))) + } +} diff --git a/JammLab/Utilities/TimeFormatter.swift b/JammLab/Utilities/TimeFormatter.swift index b266b72..6457f75 100644 --- a/JammLab/Utilities/TimeFormatter.swift +++ b/JammLab/Utilities/TimeFormatter.swift @@ -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) + } } diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift b/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift index 328c03e..6c0ee16 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Timeline.swift @@ -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) diff --git a/JammLab/Views/Components/ControlHelpText.swift b/JammLab/Views/Components/ControlHelpText.swift index 4beb9e5..dbd1e3f 100644 --- a/JammLab/Views/Components/ControlHelpText.swift +++ b/JammLab/Views/Components/ControlHelpText.swift @@ -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" diff --git a/JammLab/Views/Components/TransportBarView.swift b/JammLab/Views/Components/TransportBarView.swift index 2ba1872..66b9c14 100644 --- a/JammLab/Views/Components/TransportBarView.swift +++ b/JammLab/Views/Components/TransportBarView.swift @@ -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 @@ -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) diff --git a/JammLab/Views/MainWorkspacePanels.swift b/JammLab/Views/MainWorkspacePanels.swift index 79df88b..1c66bd9 100644 --- a/JammLab/Views/MainWorkspacePanels.swift +++ b/JammLab/Views/MainWorkspacePanels.swift @@ -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() }, diff --git a/JammLabTests/BeatGridAndTempoMapTests.swift b/JammLabTests/BeatGridAndTempoMapTests.swift index da7bce3..5bba6e0 100644 --- a/JammLabTests/BeatGridAndTempoMapTests.swift +++ b/JammLabTests/BeatGridAndTempoMapTests.swift @@ -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( diff --git a/JammLabTests/TransportPositionDisplayTests.swift b/JammLabTests/TransportPositionDisplayTests.swift new file mode 100644 index 0000000..9dc3ec0 --- /dev/null +++ b/JammLabTests/TransportPositionDisplayTests.swift @@ -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) + } +} diff --git a/JammLabTests/ViewModelTempoMapTests.swift b/JammLabTests/ViewModelTempoMapTests.swift index b44dea9..dc5b763 100644 --- a/JammLabTests/ViewModelTempoMapTests.swift +++ b/JammLabTests/ViewModelTempoMapTests.swift @@ -34,6 +34,31 @@ final class ViewModelTempoMapTests: XCTestCase { XCTAssertEqual(tempoMap.segments[1].settings.timeSignature, TimeSignature(beatsPerBar: 3, beatUnit: 4)) } + @MainActor + func testTransportPositionTextUsesCurrentTempoMap() throws { + let audioURL = try temporaryAudioFile(duration: 6) + defer { try? FileManager.default.removeItem(at: audioURL) } + let engine = MockPlaybackEngine() + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: engine + ) + let media = ImportedAudioFile(url: audioURL, displayName: "tempo.wav", duration: 6) + try viewModel.loadImportedAudio(media) + + viewModel.setTempoBPM(120) + viewModel.addTempoTimeSignatureMarker( + at: 2, + bpm: 120, + beatsPerBar: 3, + setsNewFirstBeat: false + ) + viewModel.currentTime = 3.5 + + XCTAssertEqual(viewModel.transportPositionText, "3.1.00 / 0:03.500") + } + @MainActor func testAddingNewFirstBeatOnlyMarkerUpdatesPlaybackTempoMap() throws { let audioURL = try temporaryAudioFile(duration: 6)