From 1f2dcf28cf1a3a00adf946f68e8d36235f4db9db Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Fri, 24 Jul 2026 17:38:12 +0300 Subject: [PATCH] feat(notation): add octave-down bass 8 clef --- CHANGELOG.md | 1 + JammLab/Models/JammLabProject.swift | 4 +- JammLab/Models/NotationScoreModels.swift | 39 ++- JammLab/Models/ProjectStateNormalizer.swift | 38 ++- JammLab/Services/NotationExportService.swift | 3 + JammLab/Services/NotationSMuFL.swift | 9 +- .../AudioPlayerViewModel+Project.swift | 23 +- JammLabTests/MIDIPianoRollTests.swift | 12 +- JammLabTests/NotationMusicXMLTests.swift | 56 ++++ JammLabTests/NotationPrimitivesTests.swift | 90 +++++- JammLabTests/StemTranscriptionTests.swift | 2 +- .../StemWorkflowPersistenceTests.swift | 4 +- ...wModelNotationDurationSelectionTests.swift | 109 ++++++- .../ViewModelProjectRestoreTests.swift | 304 ++++++++++++++++++ .../ViewModelProjectSaveCloseTests.swift | 1 + 15 files changed, 665 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eda9529..cb9e18d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased - 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. - Added inline flat, natural, and sharp signs to Notation with Leland glyphs, one-shot note entry, selected tied-note editing, compact duration and accidental track menus, keyboard shortcuts, persistence, and MusicXML export. - Added automatic rhythmic beaming for eighth and sixteenth notes in supported simple and compound Notation meters, including shared stem direction, sloped beams, secondary beam breaks, and beamlets. diff --git a/JammLab/Models/JammLabProject.swift b/JammLab/Models/JammLabProject.swift index e87b870..c65e290 100644 --- a/JammLab/Models/JammLabProject.swift +++ b/JammLab/Models/JammLabProject.swift @@ -15,6 +15,8 @@ struct ProjectTimelineVisibleRange: Codable, Equatable { } struct JammLabProject: Codable { + static let currentFormatVersion = 16 + var formatVersion: Int var audioBookmarkData: Data var artifactRootBookmarkData: Data? @@ -49,7 +51,7 @@ struct JammLabProject: Codable { var visibleNotationPartIDs: Set init( - formatVersion: Int = 15, + formatVersion: Int = JammLabProject.currentFormatVersion, audioBookmarkData: Data, artifactRootBookmarkData: Data? = nil, audioDisplayName: String, diff --git a/JammLab/Models/NotationScoreModels.swift b/JammLab/Models/NotationScoreModels.swift index 216b91b..22a104a 100644 --- a/JammLab/Models/NotationScoreModels.swift +++ b/JammLab/Models/NotationScoreModels.swift @@ -1537,6 +1537,7 @@ struct NotationClefMetrics: Equatable { enum Clef: String, Codable, CaseIterable, Identifiable, Equatable { case treble case bass + case bass8 case drums var id: String { rawValue } @@ -1544,7 +1545,7 @@ enum Clef: String, Codable, CaseIterable, Identifiable, Equatable { var sign: String { switch self { case .treble: return "G" - case .bass: return "F" + case .bass, .bass8: return "F" case .drums: return "percussion" } } @@ -1552,7 +1553,7 @@ enum Clef: String, Codable, CaseIterable, Identifiable, Equatable { var line: Int? { switch self { case .treble: return 2 - case .bass: return 4 + case .bass, .bass8: return 4 case .drums: return nil } } @@ -1561,10 +1562,15 @@ enum Clef: String, Codable, CaseIterable, Identifiable, Equatable { switch self { case .treble: return "Treble Clef" case .bass: return "Bass Clef" + case .bass8: return "Bass 8 Clef" case .drums: return "Drum Clef" } } + var musicXMLOctaveChange: Int? { + self == .bass8 ? -1 : nil + } + var notationMetrics: NotationClefMetrics { switch self { case .treble: @@ -1581,6 +1587,13 @@ enum Clef: String, Codable, CaseIterable, Identifiable, Equatable { keySignatureStaffPositionOffset: 2, storedPitchOctaveOffset: -2 ) + case .bass8: + return NotationClefMetrics( + editableStaffPositionRange: -3...15, + topLineDiatonicOrdinal: 2 * NotationPitchStep.allCases.count + NotationPitchStep.a.diatonicIndex, + keySignatureStaffPositionOffset: 2, + storedPitchOctaveOffset: -3 + ) case .drums: return NotationClefMetrics( editableStaffPositionRange: -4...9, @@ -1593,6 +1606,9 @@ enum Clef: String, Codable, CaseIterable, Identifiable, Equatable { } enum NotationPartClefOverrides { + private static let drumDefaultClefProjectFormatVersion = 14 + private static let bass8DefaultClefProjectFormatVersion = 16 + static func normalized(_ overrides: [NotationPartID: Clef]) -> [NotationPartID: Clef] { overrides.filter { $0.value != defaultClef(for: $0.key) } } @@ -1605,21 +1621,34 @@ enum NotationPartClefOverrides { } static func defaultClef(for partID: NotationPartID) -> Clef { - partID.stemType == .drums ? .drums : .treble + if partID.stemType == .drums { + return .drums + } + if partID.stemType == .bass { + return .bass8 + } + return .treble } static func restored( _ overrides: [NotationPartID: Clef], projectFormatVersion: Int, - hasLegacyDrumNotationEvidence: Bool + hasLegacyDrumNotationEvidence: Bool, + legacyBassPartIDs: Set ) -> [NotationPartID: Clef] { var restored = overrides let drumPartID = NotationPartID.stem(.drums) - if projectFormatVersion < 14, + if projectFormatVersion < drumDefaultClefProjectFormatVersion, restored[drumPartID] == nil, hasLegacyDrumNotationEvidence { restored[drumPartID] = .treble } + if projectFormatVersion < bass8DefaultClefProjectFormatVersion { + for partID in legacyBassPartIDs + where partID.stemType == .bass && restored[partID] == nil { + restored[partID] = .treble + } + } return normalized(restored) } } diff --git a/JammLab/Models/ProjectStateNormalizer.swift b/JammLab/Models/ProjectStateNormalizer.swift index ce21d11..315d06c 100644 --- a/JammLab/Models/ProjectStateNormalizer.swift +++ b/JammLab/Models/ProjectStateNormalizer.swift @@ -194,20 +194,9 @@ struct ProjectStateNormalizer { notationItems.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first } ) - var seenTrackIDs = Set() - - return tracks.compactMap { track in - guard seenTrackIDs.insert(track.id).inserted, - !track.sourceFingerprint.path.isEmpty, - track.sourceFingerprint.fileSize >= 0, - track.sourceFingerprint.modificationTime.isFinite - else { - return nil - } - - let partID = track.notationPartID.stemType == track.stemType - ? track.notationPartID - : .stem(track.stemType) + return acceptedStemTranscriptionTracks(tracks).map { acceptedTrack in + let track = acceptedTrack.track + let partID = acceptedTrack.partID let notes = track.notes.compactMap { note -> StemTranscriptionNote? in guard (0...127).contains(note.midiPitch), note.rawStartTimeSeconds.isFinite, @@ -255,6 +244,27 @@ struct ProjectStateNormalizer { .sorted { $0.createdAt < $1.createdAt } } + static func acceptedStemTranscriptionTracks( + _ tracks: [StemTranscriptionTrack] + ) -> [(track: StemTranscriptionTrack, partID: NotationPartID)] { + var seenTrackIDs = Set() + + return tracks.compactMap { track in + guard seenTrackIDs.insert(track.id).inserted, + !track.sourceFingerprint.path.isEmpty, + track.sourceFingerprint.fileSize >= 0, + track.sourceFingerprint.modificationTime.isFinite + else { + return nil + } + + let partID = track.notationPartID.stemType == track.stemType + ? track.notationPartID + : .stem(track.stemType) + return (track, partID) + } + } + private static func normalizedTranscriptionTimings( _ timings: StemTranscriptionTimings ) -> StemTranscriptionTimings? { diff --git a/JammLab/Services/NotationExportService.swift b/JammLab/Services/NotationExportService.swift index e8585db..762e5ab 100644 --- a/JammLab/Services/NotationExportService.swift +++ b/JammLab/Services/NotationExportService.swift @@ -632,6 +632,9 @@ final class MusicXMLNotationExportRenderer: NotationExportRenderer { if let line = measureAttributes.clef.line { clef.addChild(element("line", stringValue: "\(line)")) } + if let octaveChange = measureAttributes.clef.musicXMLOctaveChange { + clef.addChild(element("clef-octave-change", stringValue: "\(octaveChange)")) + } attributes.addChild(clef) return attributes diff --git a/JammLab/Services/NotationSMuFL.swift b/JammLab/Services/NotationSMuFL.swift index 2721455..a59fd24 100644 --- a/JammLab/Services/NotationSMuFL.swift +++ b/JammLab/Services/NotationSMuFL.swift @@ -348,6 +348,7 @@ enum NotationFlagLayout { enum NotationClefSymbol: Equatable { case treble case bass + case bass8 case drums init(_ clef: Clef) { @@ -356,6 +357,8 @@ enum NotationClefSymbol: Equatable { self = .treble case .bass: self = .bass + case .bass8: + self = .bass8 case .drums: self = .drums } @@ -367,6 +370,10 @@ enum NotationClefSymbol: Equatable { return 0xE050 case .bass: return 0xE062 + case .bass8: + // The requested mark uses Leland's pictured F clef with 8 above. + // Sounding-octave-down semantics are carried separately by Clef. + return 0xE065 case .drums: return 0xE069 } @@ -376,7 +383,7 @@ enum NotationClefSymbol: Equatable { switch self { case .treble: return 3 - case .bass: + case .bass, .bass8: return 1 case .drums: return 2 diff --git a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift index d4f4f8a..f5f1470 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel+Project.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel+Project.swift @@ -323,7 +323,8 @@ extension AudioPlayerViewModel { NotationPartClefOverrides.restored( project.notationPartClefs, projectFormatVersion: project.formatVersion, - hasLegacyDrumNotationEvidence: hasLegacyDrumNotationEvidence(project) + hasLegacyDrumNotationEvidence: hasLegacyDrumNotationEvidence(project), + legacyBassPartIDs: legacyBassPartIDs(project) ) } @@ -336,6 +337,26 @@ extension AudioPlayerViewModel { || project.stemNoteDisplayModes[.drums] != nil } + private func legacyBassPartIDs(_ project: JammLabProject) -> Set { + var partIDs = Set(project.notationItems.compactMap { item in + item.partID.stemType == .bass ? item.partID : nil + }) + partIDs.formUnion(project.visibleNotationPartIDs.filter { $0.stemType == .bass }) + + for acceptedTrack in ProjectStateNormalizer.acceptedStemTranscriptionTracks( + project.stemTranscriptionTracks + ) where acceptedTrack.track.stemType == .bass { + partIDs.insert(acceptedTrack.partID) + } + + if project.stemState?.mixState.item(for: .bass).isAvailable == true + || project.stemNotationTrackCollapsed[.bass] != nil + || project.stemNoteDisplayModes[.bass] != nil { + partIDs.insert(.stem(.bass)) + } + return partIDs + } + private func clearTransientEditingState() { selectedRegionID = nil selectedHarmonySymbolID = nil diff --git a/JammLabTests/MIDIPianoRollTests.swift b/JammLabTests/MIDIPianoRollTests.swift index 89959d7..df48ee2 100644 --- a/JammLabTests/MIDIPianoRollTests.swift +++ b/JammLabTests/MIDIPianoRollTests.swift @@ -720,6 +720,7 @@ final class MIDIPianoRollTests: XCTestCase { let undoManager = UndoManager() viewModel.undoManager = undoManager let partID = NotationPartID.stem(.bass) + viewModel.notationPartClefs[partID] = .treble let source = makeNote( id: "source", partID: partID, @@ -764,6 +765,7 @@ final class MIDIPianoRollTests: XCTestCase { func testPreparedSessionKeepsSamePartSnapshotAndUsesFreshContextForOtherPart() throws { let viewModel = try loadedNotationViewModel(duration: 8) let bassPart = NotationPartID.stem(.bass) + viewModel.notationPartClefs[bassPart] = .treble let initialBass = makeNote( id: "initial-bass", partID: bassPart, @@ -839,6 +841,7 @@ final class MIDIPianoRollTests: XCTestCase { let undoManager = UndoManager() viewModel.undoManager = undoManager let partID = NotationPartID.stem(.bass) + viewModel.notationPartClefs[partID] = .treble let source = makeNote( id: "source", partID: partID, @@ -904,6 +907,7 @@ final class MIDIPianoRollTests: XCTestCase { let undoManager = UndoManager() viewModel.undoManager = undoManager let partID = NotationPartID.stem(.bass) + viewModel.notationPartClefs[partID] = .treble let source = makeNote( id: "source", partID: partID, @@ -953,6 +957,7 @@ final class MIDIPianoRollTests: XCTestCase { let undoManager = UndoManager() viewModel.undoManager = undoManager let partID = NotationPartID.stem(.bass) + viewModel.notationPartClefs[partID] = .treble let continuationID = "continuation" let root = makeNote( id: "root", @@ -1012,6 +1017,7 @@ final class MIDIPianoRollTests: XCTestCase { let undoManager = UndoManager() viewModel.undoManager = undoManager let partID = NotationPartID.stem(.bass) + viewModel.notationPartClefs[partID] = .treble let source = makeNote( id: "source", partID: partID, @@ -1236,14 +1242,16 @@ final class MIDIPianoRollTests: XCTestCase { let legacy = NotationPartClefOverrides.restored( [:], projectFormatVersion: 13, - hasLegacyDrumNotationEvidence: true + hasLegacyDrumNotationEvidence: true, + legacyBassPartIDs: [] ) XCTAssertEqual(legacy[partID], .treble) let modern = NotationPartClefOverrides.restored( [:], projectFormatVersion: 14, - hasLegacyDrumNotationEvidence: true + hasLegacyDrumNotationEvidence: true, + legacyBassPartIDs: [] ) XCTAssertNil(modern[partID]) } diff --git a/JammLabTests/NotationMusicXMLTests.swift b/JammLabTests/NotationMusicXMLTests.swift index ba01a95..7aaa9d9 100644 --- a/JammLabTests/NotationMusicXMLTests.swift +++ b/JammLabTests/NotationMusicXMLTests.swift @@ -273,6 +273,62 @@ final class NotationMusicXMLTests: XCTestCase { XCTAssertTrue(bassPart.elements(forName: "measure").flatMap { $0.elements(forName: "direction") }.isEmpty) } + func testBass8ClefExportsSoundingPitchWithOctaveDownClefSemantics() throws { + let partID = NotationPartID.stem(.bass) + let soundingPitch = NotationPitch(step: .e, octave: 1) + let score = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 4), + duration: 4, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: nil, + clef: .bass8, + partID: partID, + includesHarmonies: false, + notationItems: [ + NotationMeasureItem( + id: "bass8-note", + partID: partID, + kind: .note, + pitch: soundingPitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + ) + + let data = try NotationExportService(renderers: [ + MusicXMLNotationExportRenderer(appVersionProvider: { nil }) + ]).export( + NotationExportRequest( + displayName: "Bass", + score: score, + parts: [NotationExportPart(descriptor: .stem(.bass), score: score)] + ), + format: .musicXML + ) + let document = try XMLDocument(data: data) + let root = try XCTUnwrap(document.rootElement()) + let part = try partElement(id: "P1", in: root) + let measure = try XCTUnwrap(part.elements(forName: "measure").first) + let attributes = try firstXMLChild(named: "attributes", in: measure) + let clef = try firstXMLChild(named: "clef", in: attributes) + let clefChildren = childElements(in: clef) + let note = try firstXMLChild(named: "note", in: measure) + let pitch = try firstXMLChild(named: "pitch", in: note) + + XCTAssertEqual(clefChildren.compactMap(\.name), ["sign", "line", "clef-octave-change"]) + XCTAssertEqual(try firstXMLChild(named: "sign", in: clef).stringValue, "F") + XCTAssertEqual(try firstXMLChild(named: "line", in: clef).stringValue, "4") + XCTAssertEqual(try firstXMLChild(named: "clef-octave-change", in: clef).stringValue, "-1") + XCTAssertEqual(try firstXMLChild(named: "step", in: pitch).stringValue, "E") + XCTAssertEqual(try firstXMLChild(named: "octave", in: pitch).stringValue, "1") + } + func testDrumClefExportsUnpitchedGMInstrumentsAndDrumNoteheads() throws { let drumPartID = NotationPartID.stem(.drums) let drumItems = [ diff --git a/JammLabTests/NotationPrimitivesTests.swift b/JammLabTests/NotationPrimitivesTests.swift index 0f2064f..1178ca8 100644 --- a/JammLabTests/NotationPrimitivesTests.swift +++ b/JammLabTests/NotationPrimitivesTests.swift @@ -121,27 +121,44 @@ final class NotationPrimitivesTests: XCTestCase { func testNotationClefSymbolsUseLelandSMuFLCodepointsAndReferenceLines() { let treble = NotationClefSymbol(.treble) let bass = NotationClefSymbol(.bass) + let bass8 = NotationClefSymbol(.bass8) let drums = NotationClefSymbol(.drums) + XCTAssertEqual(Clef.allCases, [.treble, .bass, .bass8, .drums]) XCTAssertEqual(treble.codepoint, 0xE050) XCTAssertEqual(treble.referenceStaffLineFromTop, 3) XCTAssertEqual(bass.codepoint, 0xE062) XCTAssertEqual(bass.referenceStaffLineFromTop, 1) + XCTAssertEqual(bass8.codepoint, 0xE065) + XCTAssertEqual(bass8.referenceStaffLineFromTop, 1) XCTAssertEqual(drums.codepoint, 0xE069) XCTAssertEqual(drums.referenceStaffLineFromTop, 2) XCTAssertEqual(Clef.treble.sign, "G") XCTAssertEqual(Clef.treble.line, 2) XCTAssertEqual(Clef.bass.sign, "F") XCTAssertEqual(Clef.bass.line, 4) + XCTAssertEqual(Clef.bass8.sign, "F") + XCTAssertEqual(Clef.bass8.line, 4) + XCTAssertEqual(Clef.bass8.displayName, "Bass 8 Clef") + XCTAssertEqual(Clef.bass8.musicXMLOctaveChange, -1) + XCTAssertNil(Clef.bass.musicXMLOctaveChange) XCTAssertEqual(Clef.drums.sign, "percussion") XCTAssertNil(Clef.drums.line) + + let encoded = try? JSONEncoder().encode(Clef.bass8) + XCTAssertEqual(encoded.flatMap { try? JSONDecoder().decode(Clef.self, from: $0) }, .bass8) } func testNotationPartClefOverridesRemovePerPartDefaultValues() { let unknownPart = NotationPartID(rawValue: "future:baritone") + let bassTranscription = NotationPartID.stemTranscription( + .bass, + trackID: UUID(uuidString: "00000000-0000-0000-0000-000000000711")! + ) let normalized = NotationPartClefOverrides.normalized([ .main: .treble, .stem(.bass): .bass, + bassTranscription: .bass8, .stem(.drums): .drums, unknownPart: .bass ]) @@ -151,10 +168,38 @@ final class NotationPrimitivesTests: XCTestCase { unknownPart: .bass ]) XCTAssertEqual(NotationPartClefOverrides.clef(for: .main, in: normalized), .treble) + XCTAssertEqual(NotationPartClefOverrides.clef(for: .stem(.bass), in: [:]), .bass8) + XCTAssertEqual(NotationPartClefOverrides.clef(for: bassTranscription, in: normalized), .bass8) XCTAssertEqual(NotationPartClefOverrides.clef(for: .stem(.drums), in: normalized), .drums) XCTAssertEqual(NotationPartClefOverrides.clef(for: unknownPart, in: normalized), .bass) } + func testLegacyBassClefDefaultsRestorePerPartWithoutOverwritingExplicitClefs() { + let canonicalBass = NotationPartID.stem(.bass) + let transcriptionBass = NotationPartID.stemTranscription( + .bass, + trackID: UUID(uuidString: "00000000-0000-0000-0000-000000000712")! + ) + + let legacy = NotationPartClefOverrides.restored( + [transcriptionBass: .bass], + projectFormatVersion: 15, + hasLegacyDrumNotationEvidence: false, + legacyBassPartIDs: [canonicalBass, transcriptionBass] + ) + XCTAssertEqual(legacy[canonicalBass], .treble) + XCTAssertEqual(legacy[transcriptionBass], .bass) + + let current = NotationPartClefOverrides.restored( + [:], + projectFormatVersion: 16, + hasLegacyDrumNotationEvidence: false, + legacyBassPartIDs: [canonicalBass] + ) + XCTAssertNil(current[canonicalBass]) + XCTAssertEqual(NotationPartClefOverrides.clef(for: canonicalBass, in: current), .bass8) + } + func testNotationSMuFLDurationControlSymbolsMapDurationsToLelandMetNoteCodepoints() throws { let whole = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 1))) let half = try XCTUnwrap(NotationDurationControlSymbol(duration: NotationDuration(denominator: 2))) @@ -590,6 +635,36 @@ final class NotationPrimitivesTests: XCTestCase { ) } + func testBass8UsesBassStaffPositionsOneSoundingOctaveLower() { + let keySignature = KeySignature.normalized(from: "G major") + let positions = NotationPitchMapper.editableStaffPositionRange(for: .bass) + + XCTAssertEqual(NotationPitchMapper.editableStaffPositionRange(for: .bass8), positions) + for position in positions { + let bassPitch = NotationPitchMapper.pitch( + forStaffPosition: position, + keySignature: keySignature, + clef: .bass + ) + let bass8Pitch = NotationPitchMapper.pitch( + forStaffPosition: position, + keySignature: keySignature, + clef: .bass8 + ) + + XCTAssertEqual(bass8Pitch.step, bassPitch.step) + XCTAssertEqual(bass8Pitch.alter, bassPitch.alter) + XCTAssertEqual(bass8Pitch.octave, bassPitch.octave - 1) + XCTAssertEqual(bass8Pitch.midiNoteNumber, bassPitch.midiNoteNumber - 12) + XCTAssertEqual(NotationPitchMapper.staffPosition(for: bass8Pitch, clef: .bass8), position) + } + + let bassBounds = NotationPitchMapper.editableMIDINoteBounds(for: .bass) + let bass8Bounds = NotationPitchMapper.editableMIDINoteBounds(for: .bass8) + XCTAssertEqual(bass8Bounds.lowerBound, bassBounds.lowerBound - 12) + XCTAssertEqual(bass8Bounds.upperBound, bassBounds.upperBound - 12) + } + func testBassKeySignatureAccidentalsUseBassStaffPositions() { let sharps = KeySignature(fifths: 7, mode: .major, displayName: "C sharp major") .notationAccidentalGlyphs(for: .bass) @@ -598,6 +673,16 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertEqual(sharps.map(\.staffPositionFromTopLine), [2, 5, 1, 4, 7, 3, 6]) XCTAssertEqual(flats.map(\.staffPositionFromTopLine), [6, 3, 7, 4, 8, 5, 9]) + XCTAssertEqual( + KeySignature(fifths: 7, mode: .major, displayName: "C sharp major") + .notationAccidentalGlyphs(for: .bass8), + sharps + ) + XCTAssertEqual( + KeySignature(fifths: -7, mode: .major, displayName: "C flat major") + .notationAccidentalGlyphs(for: .bass8), + flats + ) } func testNotationPitchMapperAdjacentPitchRespectsBoundsAndKeySignature() throws { @@ -1118,11 +1203,12 @@ final class NotationPrimitivesTests: XCTestCase { XCTAssertEqual(NotationClefLayout.referenceAnchorY, 0) XCTAssertEqual(NotationClefLayout.targetY(for: .treble), 38) XCTAssertEqual(NotationClefLayout.targetY(for: .bass), 22) + XCTAssertEqual(NotationClefLayout.targetY(for: .bass8), 22) XCTAssertEqual(NotationClefLayout.targetY(for: .drums), 30) } func testNotationClefLayoutTransformAnchorsGlyphAtStaffTarget() throws { - for symbol in [NotationClefSymbol.treble, .bass, .drums] { + for symbol in [NotationClefSymbol.treble, .bass, .bass8, .drums] { let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( for: symbol, fontSize: AppTheme.Timeline.notationClefFontSize @@ -1142,7 +1228,7 @@ final class NotationPrimitivesTests: XCTestCase { } func testLelandClefGlyphPathsHaveBounds() throws { - for symbol in [NotationClefSymbol.treble, .bass, .drums] { + for symbol in [NotationClefSymbol.treble, .bass, .bass8, .drums] { let glyphPath = try XCTUnwrap(NotationMusicFontRegistry.glyphPath( for: symbol, fontSize: AppTheme.Timeline.notationClefFontSize diff --git a/JammLabTests/StemTranscriptionTests.swift b/JammLabTests/StemTranscriptionTests.swift index 51d49a5..8a05d6b 100644 --- a/JammLabTests/StemTranscriptionTests.swift +++ b/JammLabTests/StemTranscriptionTests.swift @@ -332,7 +332,7 @@ final class StemTranscriptionTests: XCTestCase { from: JSONEncoder().encode(project) ) - XCTAssertEqual(decoded.formatVersion, 15) + XCTAssertEqual(decoded.formatVersion, 16) XCTAssertEqual(decoded.stemTranscriptionTracks, [track]) } diff --git a/JammLabTests/StemWorkflowPersistenceTests.swift b/JammLabTests/StemWorkflowPersistenceTests.swift index 48aba61..1c1e7ce 100644 --- a/JammLabTests/StemWorkflowPersistenceTests.swift +++ b/JammLabTests/StemWorkflowPersistenceTests.swift @@ -44,7 +44,7 @@ final class StemWorkflowPersistenceTests: XCTestCase { let decoded = try JSONDecoder().decode(JammLabProject.self, from: JSONEncoder().encode(project)) - XCTAssertEqual(decoded.formatVersion, 15) + XCTAssertEqual(decoded.formatVersion, 16) XCTAssertEqual(decoded.artifactRootBookmarkData, artifactRootBookmarkData) XCTAssertEqual(decoded.mediaKind, .video) XCTAssertEqual(decoded.isLoopEnabled, true) @@ -103,7 +103,7 @@ final class StemWorkflowPersistenceTests: XCTestCase { let decoded = try JSONDecoder().decode(JammLabProject.self, from: JSONEncoder().encode(project)) - XCTAssertEqual(decoded.formatVersion, 15) + XCTAssertEqual(decoded.formatVersion, 16) XCTAssertEqual(decoded.notationItems.map(\.id), ["note", "rest"]) XCTAssertEqual(decoded.notationItems.map(\.kind), [.note, .rest]) XCTAssertEqual(decoded.notationItems.map(\.pitch), [pitch, nil]) diff --git a/JammLabTests/ViewModelNotationDurationSelectionTests.swift b/JammLabTests/ViewModelNotationDurationSelectionTests.swift index ec85d6b..c39d8de 100644 --- a/JammLabTests/ViewModelNotationDurationSelectionTests.swift +++ b/JammLabTests/ViewModelNotationDurationSelectionTests.swift @@ -1790,7 +1790,7 @@ final class ViewModelNotationDurationSelectionTests: XCTestCase { viewModel.setNotationClef(.treble, for: bassPart) XCTAssertEqual(viewModel.notationClef(for: bassPart), .treble) - XCTAssertTrue(viewModel.notationPartClefs.isEmpty) + XCTAssertEqual(viewModel.notationPartClefs, [bassPart: .treble]) XCTAssertEqual( viewModel.notationItems.first { $0.id == "bass-note" }?.pitch, NotationPitch(step: .f, octave: 4, alter: 1) @@ -1805,6 +1805,112 @@ final class ViewModelNotationDurationSelectionTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } + @MainActor + func testChangingBassToBass8KeepsWrittenPositionOneOctaveLower() throws { + let viewModel = try loadedNotationViewModel(duration: 8) + let bassPart = NotationPartID.stem(.bass) + viewModel.notationPartClefs[bassPart] = .bass + viewModel.notationItems = [ + NotationMeasureItem( + id: "bass-note", + partID: bassPart, + kind: .note, + pitch: NotationPitch(step: .f, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + viewModel.markProjectClean() + + viewModel.setNotationClef(.bass8, for: bassPart) + + let pitch = try XCTUnwrap(viewModel.notationItems.first?.pitch) + XCTAssertEqual(viewModel.notationClef(for: bassPart), .bass8) + XCTAssertNil(viewModel.notationPartClefs[bassPart]) + XCTAssertEqual(pitch, NotationPitch(step: .f, octave: 1)) + XCTAssertEqual( + NotationPitchMapper.staffPosition(for: pitch, clef: .bass8), + NotationPitchMapper.staffPosition( + for: NotationPitch(step: .f, octave: 2), + clef: .bass + ) + ) + XCTAssertTrue(viewModel.isProjectModified) + } + + @MainActor + func testBass8StaffPlacementStoresAndAuditionsOneOctaveBelowBass() throws { + let auditioner = MockNotationNoteAuditioner() + let viewModel = try loadedNotationViewModel( + duration: 8, + notationNoteAuditioner: auditioner + ) + let bassPart = NotationPartID.stem(.bass) + let bass8Measure = try notationMeasure(1, in: viewModel, partID: bassPart) + XCTAssertEqual(bass8Measure.attributes.clef, .bass8) + + let bassMeasure = ScoreMeasure( + number: bass8Measure.number, + startTime: bass8Measure.startTime, + endTime: bass8Measure.endTime, + attributes: MeasureAttributes( + keySignature: bass8Measure.attributes.keySignature, + timeSignature: bass8Measure.attributes.timeSignature, + clef: .bass + ) + ) + let geometry = NotationMeasureCanvasGeometry( + measureIndex: 0, + cellStartX: 0, + cellEndX: 200, + contentStartX: 20, + contentEndX: 180, + staffStartX: 0, + staffEndX: 200 + ) + let staffTop: CGFloat = 40 + let pointer = CGPoint( + x: 100, + y: NotationNotePlacementResolver.yPosition( + forStaffPosition: 5, + staffTop: staffTop + ) + ) + let duration = NotationDuration(denominator: 4) + let bass8Placement = try XCTUnwrap(NotationNotePlacementResolver.placement( + in: bass8Measure, + geometry: geometry, + point: pointer, + staffTop: staffTop, + selectedDuration: duration, + partID: bassPart + )) + let bassPlacement = try XCTUnwrap(NotationNotePlacementResolver.placement( + in: bassMeasure, + geometry: geometry, + point: pointer, + staffTop: staffTop, + selectedDuration: duration, + partID: bassPart + )) + + XCTAssertEqual( + bass8Placement.pitch.midiNoteNumber, + bassPlacement.pitch.midiNoteNumber - 12 + ) + XCTAssertTrue(viewModel.insertNotationNote(bass8Placement)) + + let storedPitch = try XCTUnwrap( + viewModel.notationItems.first { $0.partID == bassPart && $0.kind == .note }?.pitch + ) + XCTAssertEqual(storedPitch, bass8Placement.pitch) + XCTAssertEqual(auditioner.auditionedPitches, [bass8Placement.pitch]) + XCTAssertEqual(auditioner.auditionedRoutes, [.melodic]) + } + @MainActor func testChangingStemClefSupportsTwoUndoAndRedoSteps() throws { let (viewModel, bassPart) = try clefChangeViewModel(selectsBassNote: false) @@ -1867,6 +1973,7 @@ final class ViewModelNotationDurationSelectionTests: XCTestCase { ) throws -> (viewModel: AudioPlayerViewModel, bassPart: NotationPartID) { let viewModel = try loadedNotationViewModel(duration: 8) let bassPart = NotationPartID.stem(.bass) + viewModel.notationPartClefs[bassPart] = .treble viewModel.notationItems = [ NotationMeasureItem( id: "main-note", diff --git a/JammLabTests/ViewModelProjectRestoreTests.swift b/JammLabTests/ViewModelProjectRestoreTests.swift index 293406e..da7e04c 100644 --- a/JammLabTests/ViewModelProjectRestoreTests.swift +++ b/JammLabTests/ViewModelProjectRestoreTests.swift @@ -106,6 +106,310 @@ final class ViewModelProjectRestoreTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } + @MainActor + func testLegacyProjectRestoresTreblePerBassPartAndNormalizesMismatchedTranscriptionID() async throws { + let audioURL = try temporaryAudioFile() + let projectURL = temporaryDirectory().appendingPathComponent("legacy-bass-clefs.jammlab") + try FileManager.default.createDirectory( + at: projectURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let legacyPart = NotationPartID.stemTranscription( + .bass, + trackID: UUID(uuidString: "00000000-0000-0000-0000-000000000721")! + ) + let explicitBassPart = NotationPartID.stemTranscription( + .bass, + trackID: UUID(uuidString: "00000000-0000-0000-0000-000000000722")! + ) + let validTrack = StemTranscriptionTrack( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000731")!, + stemType: .bass, + notationPartID: legacyPart, + sourceFingerprint: StemSourceFingerprint(path: "/legacy/bass-1.wav", fileSize: 1, modificationTime: 1), + configuration: .neuralNoteDefaults, + notes: [] + ) + let explicitBassTrack = StemTranscriptionTrack( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000732")!, + stemType: .bass, + notationPartID: explicitBassPart, + sourceFingerprint: StemSourceFingerprint(path: "/legacy/bass-2.wav", fileSize: 1, modificationTime: 1), + configuration: .neuralNoteDefaults, + notes: [] + ) + let mismatchedTrack = StemTranscriptionTrack( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000733")!, + stemType: .bass, + notationPartID: .main, + sourceFingerprint: StemSourceFingerprint(path: "/legacy/bass-3.wav", fileSize: 1, modificationTime: 1), + configuration: .neuralNoteDefaults, + notes: [] + ) + let invalidPart = NotationPartID.stemTranscription( + .bass, + trackID: UUID(uuidString: "00000000-0000-0000-0000-000000000723")! + ) + let invalidTrack = StemTranscriptionTrack( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000734")!, + stemType: .bass, + notationPartID: invalidPart, + sourceFingerprint: StemSourceFingerprint(path: "", fileSize: 1, modificationTime: 1), + configuration: .neuralNoteDefaults, + notes: [] + ) + let projectService = ProjectDocumentService() + let project = JammLabProject( + formatVersion: 15, + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 0.5, + notes: [], + stemTranscriptionTracks: [validTrack, explicitBassTrack, mismatchedTrack, invalidTrack], + notationPartClefs: [explicitBassPart: .bass], + loopStart: 0, + loopEnd: 0.5, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones + ) + try projectService.save(project, to: projectURL) + let entry = RecentProjectEntry( + displayName: "legacy-bass-clefs", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()) + ) + + await viewModel.openRecentProject(entry) + + XCTAssertEqual(viewModel.notationClef(for: legacyPart), .treble) + XCTAssertEqual(viewModel.notationClef(for: explicitBassPart), .bass) + XCTAssertEqual(viewModel.notationClef(for: .stem(.bass)), .treble) + XCTAssertEqual(viewModel.notationPartClefs[legacyPart], .treble) + XCTAssertEqual(viewModel.notationPartClefs[explicitBassPart], .bass) + XCTAssertEqual(viewModel.notationPartClefs[.stem(.bass)], .treble) + XCTAssertEqual(viewModel.notationClef(for: invalidPart), .bass8) + XCTAssertNil(viewModel.notationPartClefs[invalidPart]) + XCTAssertEqual( + viewModel.stemTranscriptionTracks.first { $0.id == mismatchedTrack.id }?.notationPartID, + .stem(.bass) + ) + XCTAssertFalse(viewModel.stemTranscriptionTracks.contains { $0.id == invalidTrack.id }) + XCTAssertFalse(viewModel.isProjectModified) + } + + @MainActor + func testLegacyBassNotationClefsPersistAcrossFormatUpgradeAndReopen() async throws { + let audioURL = try temporaryAudioFile(duration: 2) + let projectURL = temporaryDirectory().appendingPathComponent("legacy-bass-notation.jammlab") + try FileManager.default.createDirectory( + at: projectURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let canonicalPart = NotationPartID.stem(.bass) + let transcriptionPart = NotationPartID.stemTranscription( + .bass, + trackID: UUID(uuidString: "00000000-0000-0000-0000-000000000724")! + ) + let canonicalPitch = NotationPitch(step: .e, octave: 4) + let transcriptionPitch = NotationPitch(step: .g, octave: 4) + let notationItems = [ + NotationMeasureItem( + id: "legacy-canonical-bass-note", + partID: canonicalPart, + kind: .note, + pitch: canonicalPitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "legacy-transcription-bass-note", + partID: transcriptionPart, + kind: .note, + pitch: transcriptionPitch, + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let projectService = ProjectDocumentService() + let project = JammLabProject( + formatVersion: 15, + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + artifactRootBookmarkData: try projectService.bookmarkData( + for: projectURL.deletingLastPathComponent() + ), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 2, + notes: [], + notationItems: notationItems, + loopStart: 0, + loopEnd: 2, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: 120, + beatGridSettings: BeatGridSettings(bpm: 120) + ) + try projectService.save(project, to: projectURL) + let entry = RecentProjectEntry( + displayName: "legacy-bass-notation", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()) + ) + + await viewModel.openRecentProject(entry) + + XCTAssertEqual(viewModel.notationClef(for: canonicalPart), .treble) + XCTAssertEqual(viewModel.notationClef(for: transcriptionPart), .treble) + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "legacy-canonical-bass-note" }?.pitch, + canonicalPitch + ) + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "legacy-transcription-bass-note" }?.pitch, + transcriptionPitch + ) + let didSave = await viewModel.saveProject() + XCTAssertTrue(didSave, viewModel.errorMessage ?? "Project save returned false without an error") + + let upgradedProject = try projectService.load(from: projectURL) + XCTAssertEqual(upgradedProject.formatVersion, JammLabProject.currentFormatVersion) + XCTAssertEqual(upgradedProject.notationPartClefs, [ + canonicalPart: .treble, + transcriptionPart: .treble + ]) + + let reopenedViewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()) + ) + await reopenedViewModel.openRecentProject(entry) + + XCTAssertEqual(reopenedViewModel.notationClef(for: canonicalPart), .treble) + XCTAssertEqual(reopenedViewModel.notationClef(for: transcriptionPart), .treble) + XCTAssertEqual( + reopenedViewModel.notationItems.first { $0.id == "legacy-canonical-bass-note" }?.pitch, + canonicalPitch + ) + XCTAssertEqual( + reopenedViewModel.notationItems.first { $0.id == "legacy-transcription-bass-note" }?.pitch, + transcriptionPitch + ) + } + + @MainActor + func testCurrentBassNotationEvidenceUsesBass8Default() async throws { + let audioURL = try temporaryAudioFile(duration: 2) + let projectURL = temporaryDirectory().appendingPathComponent("current-bass-notation.jammlab") + try FileManager.default.createDirectory( + at: projectURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + defer { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: projectURL.deletingLastPathComponent()) + } + + let canonicalPart = NotationPartID.stem(.bass) + let transcriptionPart = NotationPartID.stemTranscription( + .bass, + trackID: UUID(uuidString: "00000000-0000-0000-0000-000000000725")! + ) + let notationItems = [ + NotationMeasureItem( + id: "current-canonical-bass-note", + partID: canonicalPart, + kind: .note, + pitch: NotationPitch(step: .e, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ), + NotationMeasureItem( + id: "current-transcription-bass-note", + partID: transcriptionPart, + kind: .note, + pitch: NotationPitch(step: .g, octave: 2), + measureNumber: 1, + measureStartTime: 0, + offsetInQuarterNotes: 0, + durationInQuarterNotes: 1, + displayDuration: NotationDuration(denominator: 4) + ) + ] + let projectService = ProjectDocumentService() + let project = JammLabProject( + audioBookmarkData: try projectService.bookmarkData(for: audioURL), + audioDisplayName: audioURL.lastPathComponent, + audioDuration: 2, + notes: [], + notationItems: notationItems, + loopStart: 0, + loopEnd: 2, + playbackRate: AppSliderDefaults.playbackRate, + pitchShiftSemitones: AppSliderDefaults.pitchShiftSemitones, + tempoBPM: 120, + beatGridSettings: BeatGridSettings(bpm: 120) + ) + try projectService.save(project, to: projectURL) + let entry = RecentProjectEntry( + displayName: "current-bass-notation", + bookmarkData: try projectService.bookmarkData(for: projectURL) + ) + let viewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine(), + projectService: projectService, + recentProjectsStore: RecentProjectsStore(defaults: try temporaryUserDefaults()) + ) + + await viewModel.openRecentProject(entry) + + XCTAssertEqual(viewModel.notationClef(for: canonicalPart), .bass8) + XCTAssertEqual(viewModel.notationClef(for: transcriptionPart), .bass8) + XCTAssertTrue(viewModel.notationPartClefs.isEmpty) + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "current-canonical-bass-note" }?.pitch, + NotationPitch(step: .e, octave: 2) + ) + XCTAssertEqual( + viewModel.notationItems.first { $0.id == "current-transcription-bass-note" }?.pitch, + NotationPitch(step: .g, octave: 2) + ) + } + @MainActor func testOpenRecentProjectRemovesMissingProjectEntry() async throws { let defaults = try temporaryUserDefaults() diff --git a/JammLabTests/ViewModelProjectSaveCloseTests.swift b/JammLabTests/ViewModelProjectSaveCloseTests.swift index 401d768..e2512a5 100644 --- a/JammLabTests/ViewModelProjectSaveCloseTests.swift +++ b/JammLabTests/ViewModelProjectSaveCloseTests.swift @@ -42,6 +42,7 @@ final class ViewModelProjectSaveCloseTests: XCTestCase { await viewModel.openRecentProject(entry) let bassPart = NotationPartID.stem(.bass) + viewModel.notationPartClefs[bassPart] = .treble viewModel.notationItems = [ NotationMeasureItem( id: "bass-note",