diff --git a/CHANGELOG.md b/CHANGELOG.md index 4baa435..86a7ae4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased +- Fixed automatic Stem transcription notation to show flats, naturals, and sharps consistently with the key signature and common-practice measure rules. - Improved Notation measure spacing to prevent late notes from stretching a single measure across the view, keep visible parts aligned, backfill the final page, and balance score systems without avoidable one-measure rows. - Added Backspace and Delete support for clearing selected Notation measures in the selected part while preserving harmony symbols and leaving default whole-measure rests. - Added a separate Bass 8 clef with Leland notation, octave-down note preview and MusicXML export, and made it the default clef for new bass-guitar notation tracks while preserving existing projects. diff --git a/JammLab/Services/StemTranscriptionNotationMapper.swift b/JammLab/Services/StemTranscriptionNotationMapper.swift index 0861412..83c201b 100644 --- a/JammLab/Services/StemTranscriptionNotationMapper.swift +++ b/JammLab/Services/StemTranscriptionNotationMapper.swift @@ -87,6 +87,12 @@ enum StemTranscriptionNotationMapper { )) } + notationItems = applyingCommonPracticeAccidentals( + to: notationItems, + storedNotes: storedNotes, + measures: content.measures + ) + let track = StemTranscriptionTrack( id: trackID, stemType: stemType, @@ -184,4 +190,185 @@ enum StemTranscriptionNotationMapper { < abs($1.durationInQuarterNotes - quarterDuration) } ?? NotationDuration() } + + private static func applyingCommonPracticeAccidentals( + to sourceItems: [NotationMeasureItem], + storedNotes: [StemTranscriptionNote], + measures: [ScoreMeasure] + ) -> [NotationMeasureItem] { + var items = sourceItems + let itemIndexByID = Dictionary( + uniqueKeysWithValues: items.indices.map { (items[$0].id, $0) } + ) + let keySignatureByMeasure = Dictionary( + uniqueKeysWithValues: measures.map { + (AccidentalMeasureKey(measure: $0), $0.attributes.keySignature) + } + ) + let candidates = storedNotes.enumerated().compactMap { + sourceOrder, + storedNote -> TranscribedAccidentalCandidate? in + guard let rootItemID = storedNote.notationItemIDs.first, + let itemIndex = itemIndexByID[rootItemID], + let pitch = items[itemIndex].pitch + else { + return nil + } + + let item = items[itemIndex] + let measureKey = AccidentalMeasureKey(item: item) + guard let keySignature = keySignatureByMeasure[measureKey] else { + return nil + } + return TranscribedAccidentalCandidate( + rootItemID: rootItemID, + sourceOrder: sourceOrder, + measureKey: measureKey, + offsetInQuarterNotes: item.offsetInQuarterNotes, + pitch: pitch, + keySignature: keySignature + ) + } + .sorted(by: accidentalCandidatePrecedes) + + var activeAlters: [AccidentalPitchPosition: Int] = [:] + var currentMeasureKey: AccidentalMeasureKey? + var candidateIndex = 0 + while candidateIndex < candidates.count { + let first = candidates[candidateIndex] + if currentMeasureKey != first.measureKey { + currentMeasureKey = first.measureKey + activeAlters.removeAll(keepingCapacity: true) + } + + var onsetEndIndex = candidateIndex + 1 + while onsetEndIndex < candidates.count, + candidates[onsetEndIndex].measureKey == first.measureKey, + candidates[onsetEndIndex].offsetInQuarterNotes == first.offsetInQuarterNotes { + onsetEndIndex += 1 + } + + applyAccidentals( + to: candidates[candidateIndex.. Bool { + if lhs.measureKey.startTime != rhs.measureKey.startTime { + return lhs.measureKey.startTime < rhs.measureKey.startTime + } + if lhs.measureKey.number != rhs.measureKey.number { + return lhs.measureKey.number < rhs.measureKey.number + } + if lhs.offsetInQuarterNotes != rhs.offsetInQuarterNotes { + return lhs.offsetInQuarterNotes < rhs.offsetInQuarterNotes + } + return lhs.sourceOrder < rhs.sourceOrder + } + + private static func applyAccidentals( + to onsetCandidates: ArraySlice, + activeAlters: inout [AccidentalPitchPosition: Int], + itemIndexByID: [String: Int], + items: inout [NotationMeasureItem] + ) { + let candidatesByPosition = Dictionary( + grouping: onsetCandidates, + by: \.pitchPosition + ) + + for (position, positionCandidates) in candidatesByPosition { + let distinctAlters = Set(positionCandidates.map(\.pitch.alter)) + if distinctAlters.count > 1 { + for candidate in positionCandidates { + setExplicitAccidental( + for: candidate, + itemIndexByID: itemIndexByID, + items: &items + ) + } + continue + } + + guard let first = positionCandidates.first else { continue } + let currentAlter = activeAlters[position] + ?? first.keySignature.defaultAlter(for: first.pitch.step) + if first.pitch.alter != currentAlter { + setExplicitAccidental( + for: first, + itemIndexByID: itemIndexByID, + items: &items + ) + } + activeAlters[position] = first.pitch.alter + } + } + + private static func setExplicitAccidental( + for candidate: TranscribedAccidentalCandidate, + itemIndexByID: [String: Int], + items: inout [NotationMeasureItem] + ) { + guard let itemIndex = itemIndexByID[candidate.rootItemID], + items[itemIndex].pitch == candidate.pitch + else { + return + } + items[itemIndex].explicitAccidental = notationAccidental(forAlter: candidate.pitch.alter) + } + + private static func notationAccidental(forAlter alter: Int) -> NotationAccidental? { + switch alter { + case -1: return .flat + case 0: return .natural + case 1: return .sharp + default: return nil + } + } + + private struct TranscribedAccidentalCandidate { + var rootItemID: String + var sourceOrder: Int + var measureKey: AccidentalMeasureKey + var offsetInQuarterNotes: Double + var pitch: NotationPitch + var keySignature: KeySignature + + var pitchPosition: AccidentalPitchPosition { + AccidentalPitchPosition( + stepIndex: pitch.step.diatonicIndex, + octave: pitch.octave + ) + } + } + + private struct AccidentalMeasureKey: Hashable { + var number: Int + var startTime: TimeInterval + + init(measure: ScoreMeasure) { + number = measure.number + startTime = measure.startTime + } + + init(item: NotationMeasureItem) { + number = item.measureNumber + startTime = item.measureStartTime + } + } + + private struct AccidentalPitchPosition: Hashable { + var stepIndex: Int + var octave: Int + } } diff --git a/JammLabTests/StemTranscriptionTests.swift b/JammLabTests/StemTranscriptionTests.swift index 8a05d6b..28c8365 100644 --- a/JammLabTests/StemTranscriptionTests.swift +++ b/JammLabTests/StemTranscriptionTests.swift @@ -212,6 +212,137 @@ final class StemTranscriptionTests: XCTestCase { XCTAssertNil(output.notationItems[1].tieTargetItemID) } + func testNotationMappingAppliesCommonPracticeAccidentalsInChronologicalOrder() throws { + let rawNotes = [ + transcriptionNote(midiPitch: 66, startTime: 0.75, pitchBends: [2]), + transcriptionNote(midiPitch: 66, startTime: 0), + transcriptionNote(midiPitch: 65, startTime: 0.5), + transcriptionNote(midiPitch: 65, startTime: 0.25) + ] + + let output = try mapNotation(notes: rawNotes, keyName: "G major") + + XCTAssertNil( + try rootNotationItem(atRawStartTime: 0, in: output).explicitAccidental + ) + XCTAssertEqual( + try rootNotationItem(atRawStartTime: 0.25, in: output).explicitAccidental, + .natural + ) + XCTAssertNil( + try rootNotationItem(atRawStartTime: 0.5, in: output).explicitAccidental + ) + XCTAssertEqual( + try rootNotationItem(atRawStartTime: 0.75, in: output).explicitAccidental, + .sharp + ) + XCTAssertEqual( + output.track.notes.map(\.rawStartTimeSeconds), + rawNotes.map(\.startTimeSeconds) + ) + XCTAssertEqual(output.track.notes.first?.pitchBends, [2]) + for note in output.track.notes { + let root = try rootNotationItem(for: note, in: output) + XCTAssertEqual(root.pitch?.midiNoteNumber, note.midiPitch) + } + } + + func testNotationMappingHandlesFlatKeysAndKeepsOctavesIndependent() throws { + let output = try mapNotation( + notes: [ + transcriptionNote(midiPitch: 70, startTime: 0), + transcriptionNote(midiPitch: 63, startTime: 0.25), + transcriptionNote(midiPitch: 76, startTime: 0.5), + transcriptionNote(midiPitch: 64, startTime: 0.75), + transcriptionNote(midiPitch: 71, startTime: 1), + transcriptionNote(midiPitch: 70, startTime: 1.25) + ], + keyName: "F major" + ) + + XCTAssertNil( + try rootNotationItem(atRawStartTime: 0, in: output).explicitAccidental + ) + XCTAssertEqual( + try rootNotationItem(atRawStartTime: 0.25, in: output).explicitAccidental, + .flat + ) + XCTAssertNil( + try rootNotationItem(atRawStartTime: 0.5, in: output).explicitAccidental + ) + XCTAssertEqual( + try rootNotationItem(atRawStartTime: 0.75, in: output).explicitAccidental, + .natural + ) + XCTAssertEqual( + try rootNotationItem(atRawStartTime: 1, in: output).explicitAccidental, + .natural + ) + XCTAssertEqual( + try rootNotationItem(atRawStartTime: 1.25, in: output).explicitAccidental, + .flat + ) + } + + func testNotationMappingResetsAccidentalsAtBarlineWithoutSeedingFromTieContinuation() throws { + let output = try mapNotation( + notes: [ + transcriptionNote(midiPitch: 61, startTime: 1.75, duration: 0.5), + transcriptionNote(midiPitch: 61, startTime: 2.5), + transcriptionNote(midiPitch: 61, startTime: 2.75) + ], + keyName: "C major", + projectDuration: 4 + ) + let tiedNote = output.track.notes[0] + + XCTAssertEqual(tiedNote.notationItemIDs.count, 2) + XCTAssertEqual( + try notationItem(id: tiedNote.notationItemIDs[0], in: output).explicitAccidental, + .sharp + ) + XCTAssertNil( + try notationItem(id: tiedNote.notationItemIDs[1], in: output).explicitAccidental + ) + XCTAssertEqual( + try rootNotationItem(atRawStartTime: 2.5, in: output).explicitAccidental, + .sharp + ) + XCTAssertNil( + try rootNotationItem(atRawStartTime: 2.75, in: output).explicitAccidental + ) + } + + func testNotationMappingShowsAllConflictingAccidentalsAtSameOnset() throws { + for conflictingMIDIPitches in [[65, 66], [66, 65]] { + let output = try mapNotation( + notes: [ + transcriptionNote(midiPitch: 65, startTime: 0), + transcriptionNote(midiPitch: conflictingMIDIPitches[0], startTime: 0.25), + transcriptionNote(midiPitch: conflictingMIDIPitches[1], startTime: 0.25), + transcriptionNote(midiPitch: 65, startTime: 0.5) + ], + keyName: "G major" + ) + let sameOnsetNotes = output.track.notes.filter { $0.rawStartTimeSeconds == 0.25 } + let sameOnsetAccidentals = try sameOnsetNotes.map { + try rootNotationItem(for: $0, in: output).explicitAccidental + } + + XCTAssertEqual( + try rootNotationItem(atRawStartTime: 0, in: output).explicitAccidental, + .natural + ) + let renderedAccidentals = sameOnsetAccidentals.compactMap { $0 } + XCTAssertEqual(renderedAccidentals.count, 2) + XCTAssertTrue(renderedAccidentals.contains(.natural)) + XCTAssertTrue(renderedAccidentals.contains(.sharp)) + XCTAssertNil( + try rootNotationItem(atRawStartTime: 0.5, in: output).explicitAccidental + ) + } + } + func testNotationMappingAppliesSourceTrimAndProjectRateWithoutChangingRawTimes() throws { let result = RawStemTranscriptionResult( notes: [ @@ -613,6 +744,80 @@ final class StemTranscriptionTests: XCTestCase { ) } + private func transcriptionNote( + midiPitch: Int, + startTime: TimeInterval, + duration: TimeInterval = 0.1, + confidence: Double = 0.9, + pitchBends: [Int] = [] + ) -> RawStemTranscriptionNote { + RawStemTranscriptionNote( + midiPitch: midiPitch, + startTimeSeconds: startTime, + endTimeSeconds: startTime + duration, + confidence: confidence, + pitchBends: pitchBends + ) + } + + private func mapNotation( + notes: [RawStemTranscriptionNote], + keyName: String, + projectDuration: TimeInterval = 4 + ) throws -> StemTranscriptionNotationOutput { + try StemTranscriptionNotationMapper.map( + result: RawStemTranscriptionResult( + notes: notes, + timings: timings, + warnings: [] + ), + stemType: .piano, + sourceFingerprint: fingerprint, + timelineMapping: .aligned(duration: projectDuration), + configuration: .neuralNoteDefaults, + tempoMap: TempoMap( + baseSettings: BeatGridSettings( + bpm: 120, + timeSignature: .fourFour + ), + markers: [], + duration: projectDuration + ), + projectDuration: projectDuration, + keyName: keyName + ) + } + + private func rootNotationItem( + atRawStartTime rawStartTime: TimeInterval, + in output: StemTranscriptionNotationOutput + ) throws -> NotationMeasureItem { + let storedNote = try XCTUnwrap( + output.track.notes.first { + abs($0.rawStartTimeSeconds - rawStartTime) + < NotationMeasureTiming.timelineTolerance + } + ) + return try rootNotationItem(for: storedNote, in: output) + } + + private func rootNotationItem( + for storedNote: StemTranscriptionNote, + in output: StemTranscriptionNotationOutput + ) throws -> NotationMeasureItem { + try notationItem( + id: XCTUnwrap(storedNote.notationItemIDs.first), + in: output + ) + } + + private func notationItem( + id: String, + in output: StemTranscriptionNotationOutput + ) throws -> NotationMeasureItem { + try XCTUnwrap(output.notationItems.first { $0.id == id }) + } + private func waitUntil( _ condition: @escaping @MainActor () -> Bool, file: StaticString = #filePath,