Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`.
## Unreleased

- Added 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.
Expand Down
4 changes: 3 additions & 1 deletion JammLab/Models/JammLabProject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ struct ProjectTimelineVisibleRange: Codable, Equatable {
}

struct JammLabProject: Codable {
static let currentFormatVersion = 16

var formatVersion: Int
var audioBookmarkData: Data
var artifactRootBookmarkData: Data?
Expand Down Expand Up @@ -49,7 +51,7 @@ struct JammLabProject: Codable {
var visibleNotationPartIDs: Set<NotationPartID>

init(
formatVersion: Int = 15,
formatVersion: Int = JammLabProject.currentFormatVersion,
audioBookmarkData: Data,
artifactRootBookmarkData: Data? = nil,
audioDisplayName: String,
Expand Down
39 changes: 34 additions & 5 deletions JammLab/Models/NotationScoreModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1537,22 +1537,23 @@ struct NotationClefMetrics: Equatable {
enum Clef: String, Codable, CaseIterable, Identifiable, Equatable {
case treble
case bass
case bass8
case drums

var id: String { rawValue }

var sign: String {
switch self {
case .treble: return "G"
case .bass: return "F"
case .bass, .bass8: return "F"
case .drums: return "percussion"
}
}

var line: Int? {
switch self {
case .treble: return 2
case .bass: return 4
case .bass, .bass8: return 4
case .drums: return nil
}
}
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -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) }
}
Expand All @@ -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>
) -> [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)
}
}
38 changes: 24 additions & 14 deletions JammLab/Models/ProjectStateNormalizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,20 +194,9 @@ struct ProjectStateNormalizer {
notationItems.map { ($0.id, $0) },
uniquingKeysWith: { first, _ in first }
)
var seenTrackIDs = Set<UUID>()

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,
Expand Down Expand Up @@ -255,6 +244,27 @@ struct ProjectStateNormalizer {
.sorted { $0.createdAt < $1.createdAt }
}

static func acceptedStemTranscriptionTracks(
_ tracks: [StemTranscriptionTrack]
) -> [(track: StemTranscriptionTrack, partID: NotationPartID)] {
var seenTrackIDs = Set<UUID>()

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? {
Expand Down
3 changes: 3 additions & 0 deletions JammLab/Services/NotationExportService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion JammLab/Services/NotationSMuFL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ enum NotationFlagLayout {
enum NotationClefSymbol: Equatable {
case treble
case bass
case bass8
case drums

init(_ clef: Clef) {
Expand All @@ -356,6 +357,8 @@ enum NotationClefSymbol: Equatable {
self = .treble
case .bass:
self = .bass
case .bass8:
self = .bass8
case .drums:
self = .drums
}
Expand All @@ -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
}
Expand All @@ -376,7 +383,7 @@ enum NotationClefSymbol: Equatable {
switch self {
case .treble:
return 3
case .bass:
case .bass, .bass8:
return 1
case .drums:
return 2
Expand Down
23 changes: 22 additions & 1 deletion JammLab/ViewModels/AudioPlayerViewModel+Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,8 @@ extension AudioPlayerViewModel {
NotationPartClefOverrides.restored(
project.notationPartClefs,
projectFormatVersion: project.formatVersion,
hasLegacyDrumNotationEvidence: hasLegacyDrumNotationEvidence(project)
hasLegacyDrumNotationEvidence: hasLegacyDrumNotationEvidence(project),
legacyBassPartIDs: legacyBassPartIDs(project)
)
}

Expand All @@ -336,6 +337,26 @@ extension AudioPlayerViewModel {
|| project.stemNoteDisplayModes[.drums] != nil
}

private func legacyBassPartIDs(_ project: JammLabProject) -> Set<NotationPartID> {
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
Expand Down
12 changes: 10 additions & 2 deletions JammLabTests/MIDIPianoRollTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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])
}
Expand Down
56 changes: 56 additions & 0 deletions JammLabTests/NotationMusicXMLTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Loading