Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
a5faa0b
refactor: apply low-risk audit cleanup
cyberflow Jul 7, 2026
0e4978b
refactor: click tests
cyberflow Jul 7, 2026
79b250a
test: split timing calculator coverage
cyberflow Jul 7, 2026
6397296
test: split beat grid tempo map coverage
cyberflow Jul 7, 2026
6bb4eaa
test: split notation primitive coverage
cyberflow Jul 7, 2026
e66d735
test: split notation musicxml coverage
cyberflow Jul 7, 2026
c42f405
test: split notation attribute and key signature coverage
cyberflow Jul 7, 2026
6d9a2ac
test: split notation region label coverage
cyberflow Jul 7, 2026
abaa423
test: split notation visible measure fitter coverage
cyberflow Jul 7, 2026
2e1121c
test: split notation score state coverage
cyberflow Jul 7, 2026
5d82970
test: split notation harmony placement coverage
cyberflow Jul 7, 2026
35ca6e2
test: split notation measure timing coverage
cyberflow Jul 7, 2026
9ec0a56
test: split notation viewport coverage
cyberflow Jul 7, 2026
ddd8bdc
test: split notation measure geometry coverage
cyberflow Jul 7, 2026
69e2b0a
test: split notation selection overlay coverage
cyberflow Jul 7, 2026
7427abb
test: split notation slash beat layout coverage
cyberflow Jul 7, 2026
bd3cd09
test: split notation harmony label layout coverage
cyberflow Jul 7, 2026
1a6a2db
test: split audio timing and notation layout tests
cyberflow Jul 7, 2026
653b891
test: split view model reset and playback state tests
cyberflow Jul 7, 2026
21b6c10
test: split view model stem playback tests
cyberflow Jul 7, 2026
eb93c45
test: split view model video window tests
cyberflow Jul 7, 2026
dfbb001
test: split view model settings tests
cyberflow Jul 7, 2026
140e166
test: split view model project key persistence tests
cyberflow Jul 7, 2026
7bf0b42
test: split view model project restore tests
cyberflow Jul 7, 2026
0948fbe
test: split view model editing state tests
cyberflow Jul 7, 2026
a1bda7a
test: split view model tempo map tests
cyberflow Jul 7, 2026
72140fa
test: split view model region loop tests
cyberflow Jul 7, 2026
74de1d1
test: split view model notation selection tests
cyberflow Jul 7, 2026
087a5ab
test: split view model notation collapse tests
cyberflow Jul 7, 2026
2e2e1be
test: split view model video persistence tests
cyberflow Jul 7, 2026
cc1ea25
test: split view model save close tests
cyberflow Jul 7, 2026
301ef7e
test: remove leftover view model lifecycle test file
cyberflow Jul 7, 2026
95a8f18
test: split control logic tests from settings suite
cyberflow Jul 7, 2026
5257b1b
test: split recent projects store tests
cyberflow Jul 7, 2026
62f1c7e
test: split color palette tests
cyberflow Jul 8, 2026
143f0eb
test: split settings store tests
cyberflow Jul 8, 2026
ac75e57
test: split audio settings device tests
cyberflow Jul 8, 2026
f1e6c2b
test: split tuner input resolver tests
cyberflow Jul 8, 2026
5a884ef
test: rename tuner input service tests
cyberflow Jul 8, 2026
c32c3a9
test: split tuner input logic tests
cyberflow Jul 8, 2026
1633e98
test: extract tuner input service test doubles
cyberflow Jul 8, 2026
b1f7334
test: extract tuner input service helpers
cyberflow Jul 8, 2026
8418c2a
test: split timeline project logic tests
cyberflow Jul 8, 2026
3dd3105
test: split project artifact store tests
cyberflow Jul 8, 2026
710ae25
test: split large hotkey and tuner test files
cyberflow Jul 8, 2026
52694fd
test: split stem job workflow tests
cyberflow Jul 8, 2026
bc0b060
test: split notation clipboard view model tests
cyberflow Jul 8, 2026
1f7de7e
test: split tuner signal service tests
cyberflow Jul 8, 2026
e2b9b9a
test: split notation fallback geometry tests
cyberflow Jul 8, 2026
f5ef2fc
test: split focused model and artifact tests
cyberflow Jul 8, 2026
675f33b
test: split notation geometry and timeline persistence tests
cyberflow Jul 8, 2026
60aeadb
test: split hotkey filter and viewport tempo map tests
cyberflow Jul 8, 2026
5cb49e3
test: split musicxml rest export and region activation tests
cyberflow Jul 8, 2026
3d879c5
refactor: split stem workflow tests by responsibility
cyberflow Jul 8, 2026
d9329bc
refactor: split notation paste tests from clipboard tests
cyberflow Jul 8, 2026
ad9daf3
refactor: split audio sample converter tests
cyberflow Jul 8, 2026
7ced9e7
refactor: split notation hotkey mapping tests
cyberflow Jul 8, 2026
9ea13a1
refactor: split notation export view model test
cyberflow Jul 8, 2026
267765b
refactor: split tuner input analysis tests
cyberflow Jul 8, 2026
6843e8c
refactor: split video window project restore tests
cyberflow Jul 8, 2026
d56f1db
refactor: split view model note editing tests
cyberflow Jul 8, 2026
badcd30
refactor: split MusicXML export error test
cyberflow Jul 8, 2026
5482864
refactor: split stem job input tests
cyberflow Jul 8, 2026
1885e16
refactor: split playback timeline tests
cyberflow Jul 8, 2026
bcbd9eb
refactor: split project save destination tests
cyberflow Jul 8, 2026
e01b1f1
refactor: split project media coordinator tests
cyberflow Jul 8, 2026
1096501
refactor: split stem separation cancellation tests
cyberflow Jul 8, 2026
0c863b5
refactor: split notation duration hotkey filter tests
cyberflow Jul 8, 2026
2597e9d
refactor: split notation attribute staff inset tests
cyberflow Jul 8, 2026
e36fe1d
refactor: split project playback marker tests
cyberflow Jul 8, 2026
b4f6b34
refactor: split value slider logic tests
cyberflow Jul 8, 2026
a33a017
refactor: split saved project key restore tests
cyberflow Jul 8, 2026
e4ac97b
refactor: split notation duration selection tests
cyberflow Jul 8, 2026
061cd81
refactor: split tuner input error tests
cyberflow Jul 8, 2026
a332377
refactor: split notation range paste tests
cyberflow Jul 8, 2026
93e5ff1
refactor: split project state normalizer tests
cyberflow Jul 8, 2026
757ca07
refactor: share numeric control logic
cyberflow Jul 8, 2026
eb212f8
refactor: share color hex helpers
cyberflow Jul 8, 2026
355e6ab
refactor: reuse volume clamp helper
cyberflow Jul 8, 2026
37308d2
refactor: share SHA256 hex formatting
cyberflow Jul 8, 2026
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
384 changes: 368 additions & 16 deletions JammLab.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions JammLab/DesignSystem/AppTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,37 @@ struct AppThemeColors: Equatable {
}
}

extension NSColor {
convenience init?(hexString: String) {
let trimmed = hexString.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
let normalized = trimmed.hasPrefix("#") ? trimmed : "#\(trimmed)"
let digits = String(normalized.dropFirst())
guard normalized.count == 7, let value = Int(digits, radix: 16) else { return nil }

let red = CGFloat((value >> 16) & 0xFF) / 255
let green = CGFloat((value >> 8) & 0xFF) / 255
let blue = CGFloat(value & 0xFF) / 255

self.init(srgbRed: red, green: green, blue: blue, alpha: 1)
}

var hexString: String? {
hexString(using: .sRGB)
}

func hexString(using colorSpace: NSColorSpace, fallsBackToOriginalColor: Bool = false) -> String? {
guard let rgbColor = usingColorSpace(colorSpace) ?? (fallsBackToOriginalColor ? self : nil) else {
return nil
}

let red = Int((rgbColor.redComponent * 255).rounded())
let green = Int((rgbColor.greenComponent * 255).rounded())
let blue = Int((rgbColor.blueComponent * 255).rounded())

return String(format: "#%02X%02X%02X", red, green, blue)
}
}

private struct AppThemeColorsKey: EnvironmentKey {
static let defaultValue = AppThemeColors.default
}
Expand Down
16 changes: 16 additions & 0 deletions JammLab/JammLabApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ struct JammLabCommands: Commands {
@ObservedObject var recentProjectsStore: RecentProjectsStore

var body: some Commands {
fileCommands
editCommands
windowCommands
toolsCommands
helpCommands
}

private var fileCommands: some Commands {
CommandGroup(replacing: .newItem) {
Button("New Project") {
viewModel.newProject()
Expand Down Expand Up @@ -134,7 +142,9 @@ struct JammLabCommands: Commands {
}
.disabled(!viewModel.canExportNotation)
}
}

private var editCommands: some Commands {
CommandGroup(replacing: .undoRedo) {
Button("Undo") {
viewModel.undoLastEdit()
Expand All @@ -148,7 +158,9 @@ struct JammLabCommands: Commands {
.keyboardShortcut("z", modifiers: [.command, .shift])
.disabled(!viewModel.canRedo)
}
}

private var windowCommands: some Commands {
CommandGroup(after: .toolbar) {
Button("Show Notation Window") {
openWindow(id: AppWindowID.notation)
Expand All @@ -160,13 +172,17 @@ struct JammLabCommands: Commands {
}
.disabled(!viewModel.canToggleVideoWindow)
}
}

private var toolsCommands: some Commands {
CommandMenu("Tools") {
Button("Tuner") {
openWindow(id: "tuner")
}
}
}

private var helpCommands: some Commands {
CommandGroup(replacing: .help) {
Button("Keyboard Shortcuts") {
openWindow(id: "hotkeys-help")
Expand Down
23 changes: 1 addition & 22 deletions JammLab/Services/AudioAnalyzer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,6 @@ protocol AudioAnalyzing {
func analyze(url: URL, includesTempo: Bool, includesKey: Bool) async throws -> AnalysisResult
}

extension AudioAnalyzing {
func analyze(url: URL) async throws -> AnalysisResult {
try await analyze(url: url, includesTempo: true, includesKey: true)
}

func analyze(url: URL, includesTempo: Bool) async throws -> AnalysisResult {
try await analyze(url: url, includesTempo: includesTempo, includesKey: true)
}
}

enum AudioAnalyzerError: LocalizedError {
case unsupportedBuffer
case emptyAudio
Expand Down Expand Up @@ -68,21 +58,10 @@ final class AudioAnalyzer: AudioAnalyzing {

try file.read(into: buffer, frameCount: AVAudioFrameCount(framesToRead))

guard let channels = buffer.floatChannelData else {
guard let mono = AudioSampleConverter.monoFloatSamples(from: buffer) else {
throw AudioAnalyzerError.unsupportedBuffer
}

let frameLength = Int(buffer.frameLength)
let channelCount = Int(format.channelCount)
var mono = Array(repeating: Float(0), count: frameLength)

for channel in 0..<channelCount {
let data = channels[channel]
for index in 0..<frameLength {
mono[index] += data[index] / Float(channelCount)
}
}

guard mono.contains(where: { abs($0) > 0.0001 }) else {
throw AudioAnalyzerError.emptyAudio
}
Expand Down
8 changes: 7 additions & 1 deletion JammLab/Services/CachedPeakformProvider.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import CryptoKit
import Foundation

extension SHA256.Digest {
var lowercaseHexString: String {
map { String(format: "%02x", $0) }.joined()
}
}

final class CachedPeakformProvider: PeakformProvider {
let samplesPerPeakLevels: [Int]

Expand Down Expand Up @@ -72,6 +78,6 @@ final class CachedPeakformProvider: PeakformProvider {
return true
}) {}

return hasher.finalize().map { String(format: "%02x", $0) }.joined()
return hasher.finalize().lowercaseHexString
}
}
2 changes: 1 addition & 1 deletion JammLab/Services/StemSeparationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ final class StemSeparationService {
].joined(separator: "|")

let digest = SHA256.hash(data: Data(rawValue.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
return digest.lowercaseHexString
}

private func applicationSupportDirectory() -> URL {
Expand Down
2 changes: 1 addition & 1 deletion JammLab/Services/VideoAudioExtractionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ final class VideoAudioExtractionService {
static func cacheKey(for mediaURL: URL) -> String {
let identity = mediaIdentity(for: mediaURL)
let digest = SHA256.hash(data: Data(identity.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
return digest.lowercaseHexString
}

static func mediaIdentity(for mediaURL: URL) -> String {
Expand Down
4 changes: 2 additions & 2 deletions JammLab/ViewModels/AudioPlayerViewModel+Playback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,15 +156,15 @@ extension AudioPlayerViewModel {
}

func applyClickVolume(_ volume: Float, shouldPersist: Bool) {
clickVolume = min(1, max(0, volume))
clickVolume = clampedVolume(volume)
playbackEngine.setClickVolume(clickVolume)
guard shouldPersist else { return }
UserDefaults.standard.set(clickVolume, forKey: "metronome.volume")
}

func setMainTrackVolume(_ volume: Float) {
performUndoableEdit("Change Main Volume") {
mainTrackVolume = min(1, max(0, volume))
mainTrackVolume = clampedVolume(volume)
playbackEngine.setMainVolume(mainTrackVolume)
}
}
Expand Down
30 changes: 12 additions & 18 deletions JammLab/ViewModels/AudioPlayerViewModel+Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,7 @@ extension AudioPlayerViewModel {
harmonySymbols = []
notationItems = []
projectKeySelection = nil
selectedRegionID = nil
selectedHarmonySymbolID = nil
clearNotationMeasureSelectionAndClipboard()
pendingHarmonyEditorRequest = nil
activeLoopRegionID = nil
notationDurationDenominator = NotationDuration.defaultDenominator
clearTransientEditingState()
loopRegion = .empty
timelineVisibleRange = 0...0
userTimelineVisibleRange = 0...0
Expand Down Expand Up @@ -177,12 +172,7 @@ extension AudioPlayerViewModel {
harmonySymbols = []
notationItems = []
projectKeySelection = nil
selectedRegionID = nil
selectedHarmonySymbolID = nil
clearNotationMeasureSelectionAndClipboard()
pendingHarmonyEditorRequest = nil
notationDurationDenominator = NotationDuration.defaultDenominator
activeLoopRegionID = nil
clearTransientEditingState()
loopRegion = LoopRegion(start: 0, end: file.duration).clamped(to: file.duration)
timelineVisibleRange = 0...file.duration
userTimelineVisibleRange = timelineVisibleRange
Expand Down Expand Up @@ -258,12 +248,7 @@ extension AudioPlayerViewModel {
duration: resolvedProjectDuration
)
projectKeySelection = project.projectKeySelection
selectedRegionID = nil
selectedHarmonySymbolID = nil
clearNotationMeasureSelectionAndClipboard()
pendingHarmonyEditorRequest = nil
notationDurationDenominator = NotationDuration.defaultDenominator
activeLoopRegionID = nil
clearTransientEditingState()
loopRegion = ProjectStateNormalizer.normalizedLoopRegion(
start: project.loopStart,
end: project.loopEnd,
Expand Down Expand Up @@ -309,6 +294,15 @@ extension AudioPlayerViewModel {
}
}

private func clearTransientEditingState() {
selectedRegionID = nil
selectedHarmonySymbolID = nil
clearNotationMeasureSelectionAndClipboard()
pendingHarmonyEditorRequest = nil
notationDurationDenominator = NotationDuration.defaultDenominator
activeLoopRegionID = nil
}

func cancelBackgroundWork() {
analysisTask?.cancel()
analysisTask = nil
Expand Down
19 changes: 12 additions & 7 deletions JammLab/Views/Components/AbletonNumberField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,20 @@ struct AbletonNumberFieldConfiguration: Equatable {

enum AbletonNumberFieldLogic {
static func clamp(_ value: Double, configuration: AbletonNumberFieldConfiguration) -> Double {
guard value.isFinite else { return configuration.minValue }
return min(configuration.maxValue, max(configuration.minValue, value))
NumericControlLogic.clamp(
value,
minValue: configuration.minValue,
maxValue: configuration.maxValue
)
}

static func snapToStep(_ value: Double, configuration: AbletonNumberFieldConfiguration) -> Double {
let clampedValue = clamp(value, configuration: configuration)
let steps = ((clampedValue - configuration.minValue) / configuration.step).rounded()
let snapped = configuration.minValue + steps * configuration.step
return clamp(snapped, configuration: configuration)
NumericControlLogic.snapToStep(
value,
minValue: configuration.minValue,
maxValue: configuration.maxValue,
step: configuration.step
)
}

static func resetValue(configuration: AbletonNumberFieldConfiguration) -> Double {
Expand All @@ -45,7 +50,7 @@ enum AbletonNumberFieldLogic {

static func format(_ value: Double, configuration: AbletonNumberFieldConfiguration) -> String {
let snappedValue = snapToStep(value, configuration: configuration)
return String(format: "%.\(configuration.precision)f", snappedValue)
return NumericControlLogic.format(snappedValue, precision: configuration.precision)
}

static func parse(_ text: String, configuration: AbletonNumberFieldConfiguration) -> Double? {
Expand Down
18 changes: 18 additions & 0 deletions JammLab/Views/Components/AppKitControlHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ enum AppKitDragThreshold {
}
}

enum NumericControlLogic {
static func clamp(_ value: Double, minValue: Double, maxValue: Double) -> Double {
guard value.isFinite else { return minValue }
return min(maxValue, max(minValue, value))
}

static func snapToStep(_ value: Double, minValue: Double, maxValue: Double, step: Double) -> Double {
let clampedValue = clamp(value, minValue: minValue, maxValue: maxValue)
let steps = ((clampedValue - minValue) / step).rounded()
let snappedValue = minValue + steps * step
return clamp(snappedValue, minValue: minValue, maxValue: maxValue)
}

static func format(_ value: Double, precision: Int) -> String {
String(format: "%.\(precision)f", value)
}
}

extension NSView {
func configureCompactVerticalControlSizing() {
setContentHuggingPriority(.required, for: .vertical)
Expand Down
19 changes: 12 additions & 7 deletions JammLab/Views/Components/JammValueSlider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,20 @@ struct JammValueSliderConfiguration: Equatable {

enum JammValueSliderLogic {
static func clamp(_ value: Double, configuration: JammValueSliderConfiguration) -> Double {
guard value.isFinite else { return configuration.minValue }
return min(configuration.maxValue, max(configuration.minValue, value))
NumericControlLogic.clamp(
value,
minValue: configuration.minValue,
maxValue: configuration.maxValue
)
}

static func snapToStep(_ value: Double, configuration: JammValueSliderConfiguration) -> Double {
let clampedValue = clamp(value, configuration: configuration)
let steps = ((clampedValue - configuration.minValue) / configuration.step).rounded()
let snappedValue = configuration.minValue + steps * configuration.step
return clamp(snappedValue, configuration: configuration)
NumericControlLogic.snapToStep(
value,
minValue: configuration.minValue,
maxValue: configuration.maxValue,
step: configuration.step
)
}

static func resetValue(configuration: JammValueSliderConfiguration) -> Double {
Expand All @@ -51,7 +56,7 @@ enum JammValueSliderLogic {

static func format(_ value: Double, configuration: JammValueSliderConfiguration) -> String {
let snappedValue = snapToStep(value, configuration: configuration)
return String(format: "%.\(configuration.precision)f", snappedValue)
return NumericControlLogic.format(snappedValue, precision: configuration.precision)
}

static func dragValue(
Expand Down
25 changes: 0 additions & 25 deletions JammLab/Views/NoteColorPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,28 +55,3 @@ final class NoteColorPanelPresenter: NSObject {
onColorChanged?(hex)
}
}

private extension NSColor {
convenience init?(hexString: String) {
guard let normalized = TimecodedNote.normalizedColorHex(hexString) else { return nil }

let digits = String(normalized.dropFirst())
guard let value = Int(digits, radix: 16) else { return nil }

let red = CGFloat((value >> 16) & 0xFF) / 255
let green = CGFloat((value >> 8) & 0xFF) / 255
let blue = CGFloat(value & 0xFF) / 255

self.init(srgbRed: red, green: green, blue: blue, alpha: 1)
}

var hexString: String? {
guard let rgbColor = usingColorSpace(.sRGB) else { return nil }

let red = Int((rgbColor.redComponent * 255).rounded())
let green = Int((rgbColor.greenComponent * 255).rounded())
let blue = Int((rgbColor.blueComponent * 255).rounded())

return String(format: "#%02X%02X%02X", red, green, blue)
}
}
6 changes: 1 addition & 5 deletions JammLab/Views/SettingsContentViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,7 @@ struct ThemeColorsSettingsContentView: View {

private static func hexString(from color: Color) -> String {
let nsColor = NSColor(color)
let rgbColor = nsColor.usingColorSpace(.deviceRGB) ?? nsColor
let red = Int((rgbColor.redComponent * 255).rounded())
let green = Int((rgbColor.greenComponent * 255).rounded())
let blue = Int((rgbColor.blueComponent * 255).rounded())
return String(format: "#%02X%02X%02X", red, green, blue)
return nsColor.hexString(using: .deviceRGB, fallsBackToOriginalColor: true) ?? "#000000"
}
}

Expand Down
Loading