From ed39a0bccc8948b91539cc097c44f769fef8b029 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Thu, 2 Jul 2026 16:01:09 +0300 Subject: [PATCH] feat: export notation as MusicXML --- AGENTS.md | 1 + CHANGELOG.md | 1 + JammLab.xcodeproj/project.pbxproj | 8 + JammLab/JammLabApp.swift | 9 + JammLab/Services/NotationExportService.swift | 528 ++++++++++++++++++ .../AudioPlayerViewModel+NotationExport.swift | 93 +++ JammLab/ViewModels/AudioPlayerViewModel.swift | 6 + .../Views/Components/ControlHelpText.swift | 1 + JammLab/Views/NotationWindowView.swift | 12 + JammLabTests/AudioTimingLogicTests.swift | 186 ++++++ JammLabTests/ViewModelLifecycleTests.swift | 45 ++ 11 files changed, 890 insertions(+) create mode 100644 JammLab/Services/NotationExportService.swift create mode 100644 JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift diff --git a/AGENTS.md b/AGENTS.md index cdc2a9a..70cb36f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ The project is an offline-first MVP. Keep the app local-only: no server, no paid - Temporary loop range is separate from saved Regions. Editing a temporary loop must not mutate a saved Region. Editing a saved Region must not silently move the temporary loop unless the user explicitly activates that Region as loop. - Waveform, tempo track, region track, notes, loop markers, beat grid, and playhead must share one visible time range. Use `TimelineViewport` for `time <-> pixel`, zoom, pan, bounds, and intersections. - The Notation track and Notation window must share score generation, harmony editing actions, selection, and the `AudioPlayerViewModel` playback clock. The window may lay out the full score in multiple systems, but it must not create a second notation storage model or a second playback source of truth. +- Future Notation features must export through the shared Notation export mechanism so MusicXML and later formats stay in sync with the app's Notation model. - Heavy audio work must not run on the main thread. Analysis and peakform generation should stay async/background. - Do not use system macOS alert/beep sounds for the metronome. Use the app's metronome sound abstraction. - Keep hotkeys centralized in `AppHotkey`; Help > Keyboard Shortcuts is generated from it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2da180f..e1216f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`. ## Unreleased +- Added MusicXML export for Notation from the File menu and Notation window. - Added a collapsible Notation track in the main timeline, collapsed by default and saved per project. - Changed notation harmony entry so beat slashes are selectable and Cmd+K opens chord input for the selected beat. - Added notation measure range selection with Shift-click, Cmd+C/Cmd+V measure copy and replace-paste for harmony contents, and Esc to clear the selection. diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 0cb158f..dc0dd89 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -47,6 +47,8 @@ 9FCB01062D80000100112233 /* NotationMeasureLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB00062D80000100112233 /* NotationMeasureLayout.swift */; }; 9FEA01052D70000100112233 /* NotationVisibleMeasureFitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */; }; 9FEA01062D70000100112233 /* ProjectKeySelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00062D70000100112233 /* ProjectKeySelection.swift */; }; + 9FEE01012D90000100112233 /* NotationExportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE00012D90000100112233 /* NotationExportService.swift */; }; + 9FEE01022D90000100112233 /* AudioPlayerViewModel+NotationExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE00022D90000100112233 /* AudioPlayerViewModel+NotationExport.swift */; }; 9FCB01072D80000100112233 /* NotationHarmonyInlineTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCB00072D80000100112233 /* NotationHarmonyInlineTextField.swift */; }; 9FEC01012D71000100112233 /* NotationWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEC00012D71000100112233 /* NotationWindowView.swift */; }; 9F8B01062C10000100112233 /* TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8B00072C10000100112233 /* TestSupport.swift */; }; @@ -212,6 +214,8 @@ 9FCB00062D80000100112233 /* NotationMeasureLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationMeasureLayout.swift; sourceTree = ""; }; 9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationVisibleMeasureFitter.swift; sourceTree = ""; }; 9FEA00062D70000100112233 /* ProjectKeySelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectKeySelection.swift; sourceTree = ""; }; + 9FEE00012D90000100112233 /* NotationExportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationExportService.swift; sourceTree = ""; }; + 9FEE00022D90000100112233 /* AudioPlayerViewModel+NotationExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AudioPlayerViewModel+NotationExport.swift"; sourceTree = ""; }; 9FCB00072D80000100112233 /* NotationHarmonyInlineTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationHarmonyInlineTextField.swift; sourceTree = ""; }; 9FEC00012D71000100112233 /* NotationWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationWindowView.swift; sourceTree = ""; }; 9F8B00062C10000100112233 /* JammLabTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JammLabTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -394,6 +398,7 @@ 9F8A00172C00000100112233 /* BeatGridCalculator.swift */, 9F8A00182C00000100112233 /* MetronomeClickScheduler.swift */, 9FCB00062D80000100112233 /* NotationMeasureLayout.swift */, + 9FEE00012D90000100112233 /* NotationExportService.swift */, 9FEA00032D70000100112233 /* NotationViewportFactory.swift */, 9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */, 9F8A00192C00000100112233 /* ProjectArtifactStore.swift */, @@ -427,6 +432,7 @@ 9FB201032D30000100112233 /* AudioPlayerViewModel+Stems.swift */, 9FB201042D30000100112233 /* AudioPlayerViewModel+Timeline.swift */, 9FB201052D30000100112233 /* AudioPlayerViewModel+Notes.swift */, + 9FEE00022D90000100112233 /* AudioPlayerViewModel+NotationExport.swift */, 9FB201062D30000100112233 /* AudioPlayerViewModel+Video.swift */, 9FB201072D30000100112233 /* AudioPlayerViewModel+UndoDirty.swift */, ); @@ -753,6 +759,7 @@ 9FB200032D30000100112233 /* AudioPlayerViewModel+Stems.swift in Sources */, 9FB200042D30000100112233 /* AudioPlayerViewModel+Timeline.swift in Sources */, 9FB200052D30000100112233 /* AudioPlayerViewModel+Notes.swift in Sources */, + 9FEE01022D90000100112233 /* AudioPlayerViewModel+NotationExport.swift in Sources */, 9FB200062D30000100112233 /* AudioPlayerViewModel+Video.swift in Sources */, 9FB200072D30000100112233 /* AudioPlayerViewModel+UndoDirty.swift in Sources */, 9F8C01032C20000100112233 /* AppControls.swift in Sources */, @@ -787,6 +794,7 @@ 9FEC01012D71000100112233 /* NotationWindowView.swift in Sources */, 9F8A010D2C00000100112233 /* AppHotkey.swift in Sources */, 9FCB01062D80000100112233 /* NotationMeasureLayout.swift in Sources */, + 9FEE01012D90000100112233 /* NotationExportService.swift in Sources */, 9FEA01032D70000100112233 /* NotationViewportFactory.swift in Sources */, 9FEA01052D70000100112233 /* NotationVisibleMeasureFitter.swift in Sources */, 9FEA01022D70000100112233 /* NotationViewportState.swift in Sources */, diff --git a/JammLab/JammLabApp.swift b/JammLab/JammLabApp.swift index 1b4c57c..a7b5cbf 100644 --- a/JammLab/JammLabApp.swift +++ b/JammLab/JammLabApp.swift @@ -124,6 +124,15 @@ struct JammLabCommands: Commands { } } .keyboardShortcut("s", modifiers: [.command, .shift]) + + Divider() + + Button("Export Notation as MusicXML...") { + Task { + await viewModel.exportNotationAsMusicXML() + } + } + .disabled(!viewModel.canExportNotation) } CommandGroup(replacing: .undoRedo) { diff --git a/JammLab/Services/NotationExportService.swift b/JammLab/Services/NotationExportService.swift new file mode 100644 index 0000000..9b33178 --- /dev/null +++ b/JammLab/Services/NotationExportService.swift @@ -0,0 +1,528 @@ +import AppKit +import Foundation +import UniformTypeIdentifiers + +enum NotationExportFormat { + case musicXML + + var fileExtension: String { + switch self { + case .musicXML: + return "musicxml" + } + } + + var contentType: UTType { + switch self { + case .musicXML: + return UTType(filenameExtension: fileExtension) ?? .xml + } + } +} + +struct NotationExportRequest { + var displayName: String + var score: NotationScoreState +} + +protocol NotationExportRenderer { + var format: NotationExportFormat { get } + func render(_ request: NotationExportRequest) throws -> Data +} + +enum NotationExportError: LocalizedError, Equatable { + case emptyScore + case unsupportedChord(rawText: String, measureNumber: Int) + case invalidXML + + var errorDescription: String? { + switch self { + case .emptyScore: + return "No notation is available to export." + case .unsupportedChord(let rawText, let measureNumber): + return "Unsupported chord \"\(rawText)\" in measure \(measureNumber)." + case .invalidXML: + return "MusicXML data could not be generated." + } + } +} + +final class NotationExportService { + private let renderers: [NotationExportFormat: NotationExportRenderer] + + init(renderers: [NotationExportRenderer] = [MusicXMLNotationExportRenderer()]) { + self.renderers = Dictionary(uniqueKeysWithValues: renderers.map { ($0.format, $0) }) + } + + func export(_ request: NotationExportRequest, format: NotationExportFormat) throws -> Data { + guard request.score.isReady, !request.score.measures.isEmpty else { + throw NotationExportError.emptyScore + } + + guard let renderer = renderers[format] else { + throw NotationExportError.invalidXML + } + + return try renderer.render(request) + } +} + +final class NotationExportDocumentService { + @MainActor + func chooseExportURL(defaultName: String, format: NotationExportFormat) -> URL? { + let panel = NSSavePanel() + panel.title = "Export Notation" + panel.prompt = "Export" + panel.canCreateDirectories = true + panel.allowedContentTypes = [format.contentType] + panel.nameFieldStringValue = defaultName + + guard panel.runModal() == .OK else { + return nil + } + + guard let url = panel.url else { return nil } + return url.pathExtension.lowercased() == format.fileExtension + ? url + : url.deletingPathExtension().appendingPathExtension(format.fileExtension) + } + + func save(_ data: Data, to url: URL) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url, options: .atomic) + } +} + +final class MusicXMLNotationExportRenderer: NotationExportRenderer { + let format: NotationExportFormat = .musicXML + + private let divisions = 480 + private let partID = "P1" + + func render(_ request: NotationExportRequest) throws -> Data { + guard request.score.isReady, !request.score.measures.isEmpty else { + throw NotationExportError.emptyScore + } + + let root = element("score-partwise", attributes: ["version": "4.0"]) + root.addChild(titleCredit(title: request.displayName)) + root.addChild(partList(title: request.displayName)) + root.addChild(try part(measures: request.score.measures)) + + let document = XMLDocument(rootElement: root) + document.version = "1.0" + document.characterEncoding = "UTF-8" + + guard var xml = String(data: document.xmlData(options: .nodePrettyPrint), encoding: .utf8) else { + throw NotationExportError.invalidXML + } + + xml = insertMusicXMLDoctype(into: xml) + guard let data = xml.data(using: .utf8) else { + throw NotationExportError.invalidXML + } + return data + } + + private func partList(title: String) -> XMLElement { + let partList = element("part-list") + let scorePart = element("score-part", attributes: ["id": partID]) + scorePart.addChild(element("part-name", stringValue: title, attributes: ["print-object": "no"])) + partList.addChild(scorePart) + return partList + } + + private func titleCredit(title: String) -> XMLElement { + let credit = element("credit", attributes: ["page": "1"]) + credit.addChild(element("credit-type", stringValue: "title")) + credit.addChild(element( + "credit-words", + stringValue: title, + attributes: [ + "default-x": "600.17", + "default-y": "1611.01", + "justify": "center", + "valign": "top", + "font-size": "22" + ] + )) + return credit + } + + private func part(measures: [ScoreMeasure]) throws -> XMLElement { + let part = element("part", attributes: ["id": partID]) + var previousAttributes: MeasureAttributes? + + for measure in measures { + let measureElement = element("measure", attributes: ["number": "\(measure.number)"]) + if previousAttributes == nil || previousAttributes != measure.attributes { + measureElement.addChild(attributes(for: measure.attributes, includeDivisions: previousAttributes == nil)) + } + + for regionLabel in measure.regionLabels { + measureElement.addChild(direction(for: regionLabel)) + } + + for harmony in measure.harmonies { + let chord = try MusicXMLChordParser.parse(harmony.rawText, measureNumber: measure.number) + measureElement.addChild(harmonyElement(for: chord, offsetInQuarterNotes: harmony.offsetInQuarterNotes)) + } + + measureElement.addChild(measureRest(for: measure.attributes.timeSignature)) + part.addChild(measureElement) + previousAttributes = measure.attributes + } + + return part + } + + private func attributes(for measureAttributes: MeasureAttributes, includeDivisions: Bool) -> XMLElement { + let attributes = element("attributes") + if includeDivisions { + attributes.addChild(element("divisions", stringValue: "\(divisions)")) + } + + let key = element("key") + key.addChild(element("fifths", stringValue: "\(measureAttributes.keySignature.fifths)")) + key.addChild(element("mode", stringValue: measureAttributes.keySignature.mode.rawValue)) + attributes.addChild(key) + + let time = element("time") + time.addChild(element("beats", stringValue: "\(measureAttributes.timeSignature.beatsPerBar)")) + time.addChild(element("beat-type", stringValue: "\(measureAttributes.timeSignature.beatUnit)")) + attributes.addChild(time) + + let clef = element("clef") + clef.addChild(element("sign", stringValue: measureAttributes.clef.sign)) + clef.addChild(element("line", stringValue: "\(measureAttributes.clef.line)")) + attributes.addChild(clef) + + return attributes + } + + private func direction(for label: NotationRegionLabel) -> XMLElement { + let direction = element("direction", attributes: ["placement": "above"]) + let directionType = element("direction-type") + directionType.addChild(element("words", stringValue: label.title)) + direction.addChild(directionType) + direction.addChild(element("offset", stringValue: "\(durationValue(forQuarterOffset: label.offsetInQuarterNotes))")) + return direction + } + + private func harmonyElement(for chord: MusicXMLChord, offsetInQuarterNotes: Double) -> XMLElement { + let harmony = element("harmony") + + harmony.addChild(pitchElement( + "root", + stepElementName: "root-step", + alterElementName: "root-alter", + pitch: chord.root + )) + + harmony.addChild(element("kind", stringValue: chord.kindValue, attributes: ["text": chord.displayText])) + + for degree in chord.degrees { + let degreeElement = element("degree") + degreeElement.addChild(element("degree-value", stringValue: "\(degree.value)")) + degreeElement.addChild(element("degree-alter", stringValue: "\(degree.alter)")) + degreeElement.addChild(element("degree-type", stringValue: degree.type.rawValue)) + harmony.addChild(degreeElement) + } + + if let bass = chord.bass { + harmony.addChild(pitchElement( + "bass", + stepElementName: "bass-step", + alterElementName: "bass-alter", + pitch: bass + )) + } + + harmony.addChild(element("offset", stringValue: "\(durationValue(forQuarterOffset: offsetInQuarterNotes))")) + return harmony + } + + private func pitchElement( + _ name: String, + stepElementName: String, + alterElementName: String, + pitch: MusicXMLPitchStep + ) -> XMLElement { + let pitchElement = element(name) + pitchElement.addChild(element(stepElementName, stringValue: pitch.step)) + if pitch.alter != 0 { + pitchElement.addChild(element(alterElementName, stringValue: "\(pitch.alter)")) + } + return pitchElement + } + + private func measureRest(for timeSignature: TimeSignature) -> XMLElement { + let note = element("note") + note.addChild(element("rest", attributes: ["measure": "yes"])) + note.addChild(element("duration", stringValue: "\(measureDurationValue(for: timeSignature))")) + note.addChild(element("voice", stringValue: "1")) + return note + } + + private func measureDurationValue(for timeSignature: TimeSignature) -> Int { + durationValue(forQuarterOffset: NotationMeasureTiming.quarterLength(for: timeSignature)) + } + + private func durationValue(forQuarterOffset offset: Double) -> Int { + max(0, Int((offset * Double(divisions)).rounded())) + } + + private func element( + _ name: String, + stringValue: String? = nil, + attributes: [String: String] = [:] + ) -> XMLElement { + let element = XMLElement(name: name, stringValue: stringValue) + for key in attributes.keys.sorted() { + element.addAttribute(XMLNode.attribute(withName: key, stringValue: attributes[key] ?? "") as! XMLNode) + } + return element + } + + private func insertMusicXMLDoctype(into xml: String) -> String { + let doctype = "" + guard let firstLineEnd = xml.firstIndex(of: "\n") else { + return "\(doctype)\n\(xml)" + } + + let insertionIndex = xml.index(after: firstLineEnd) + return String(xml[.. MusicXMLChord { + let displayText = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !displayText.isEmpty else { + throw NotationExportError.unsupportedChord(rawText: rawText, measureNumber: measureNumber) + } + + let normalized = displayText + .replacingOccurrences(of: "♯", with: "#") + .replacingOccurrences(of: "♭", with: "b") + .replacingOccurrences(of: "∆", with: "maj") + .replacingOccurrences(of: "Δ", with: "maj") + .replacingOccurrences(of: "ø", with: "m7b5") + .replacingOccurrences(of: "°", with: "dim") + .replacingOccurrences(of: " ", with: "") + + let slashParts = normalized.split(separator: "/", omittingEmptySubsequences: false) + guard slashParts.count <= 2, let chordPart = slashParts.first, !chordPart.isEmpty else { + throw NotationExportError.unsupportedChord(rawText: displayText, measureNumber: measureNumber) + } + + let parsedRoot = parsePitchPrefix(String(chordPart)) + guard let root = parsedRoot.pitch else { + throw NotationExportError.unsupportedChord(rawText: displayText, measureNumber: measureNumber) + } + + var suffix = String(chordPart.dropFirst(parsedRoot.length)) + var degrees: [MusicXMLChordDegree] = [] + guard extractParenthesizedDegrees(from: &suffix, into: °rees) else { + throw NotationExportError.unsupportedChord(rawText: displayText, measureNumber: measureNumber) + } + extractInlineDegrees(from: &suffix, into: °rees) + + guard let kindValue = kindValue(for: suffix) else { + throw NotationExportError.unsupportedChord(rawText: displayText, measureNumber: measureNumber) + } + + let bass: MusicXMLPitchStep? + if slashParts.count == 2 { + let bassText = String(slashParts[1]) + let parsedBass = parsePitchPrefix(bassText) + guard let parsedBassPitch = parsedBass.pitch, parsedBass.length == bassText.count else { + throw NotationExportError.unsupportedChord(rawText: displayText, measureNumber: measureNumber) + } + bass = parsedBassPitch + } else { + bass = nil + } + + return MusicXMLChord( + root: root, + kindValue: kindValue, + displayText: displayText, + degrees: degrees, + bass: bass + ) + } + + private static func parsePitchPrefix(_ text: String) -> (pitch: MusicXMLPitchStep?, length: Int) { + guard let first = text.first else { return (nil, 0) } + let step = String(first).uppercased() + guard ["A", "B", "C", "D", "E", "F", "G"].contains(step) else { + return (nil, 0) + } + + let remaining = text.dropFirst() + if remaining.first == "#" { + return (MusicXMLPitchStep(step: step, alter: 1), 2) + } + if remaining.first == "b" { + return (MusicXMLPitchStep(step: step, alter: -1), 2) + } + return (MusicXMLPitchStep(step: step, alter: 0), 1) + } + + private static func extractParenthesizedDegrees( + from suffix: inout String, + into degrees: inout [MusicXMLChordDegree] + ) -> Bool { + while let open = suffix.firstIndex(of: "("), + let close = suffix[open...].firstIndex(of: ")"), + open < close { + let content = suffix[suffix.index(after: open).. Bool { + let tokens = text + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + guard !tokens.isEmpty else { return false } + + for token in tokens { + guard let degree = degree(from: token) else { + return false + } + degrees.append(degree) + } + return true + } + + private static func degree(from token: String) -> MusicXMLChordDegree? { + let normalized = token.lowercased() + if normalized.hasPrefix("add"), + let value = Int(normalized.dropFirst(3)) { + return MusicXMLChordDegree(value: value, alter: 0, type: .add) + } + if normalized.hasPrefix("no"), + let value = Int(normalized.dropFirst(2)) { + return MusicXMLChordDegree(value: value, alter: 0, type: .subtract) + } + if normalized.hasPrefix("#"), + let value = Int(normalized.dropFirst()) { + return MusicXMLChordDegree(value: value, alter: 1, type: .alter) + } + if normalized.hasPrefix("b"), + let value = Int(normalized.dropFirst()) { + return MusicXMLChordDegree(value: value, alter: -1, type: .alter) + } + return nil + } + + private static func kindValue(for suffix: String) -> String? { + let normalized = normalizedKindSuffix(suffix) + let kindValues: [String: String] = [ + "": "major", + "maj": "major", + "m": "minor", + "min": "minor", + "-": "minor", + "5": "power", + "6": "major-sixth", + "m6": "minor-sixth", + "min6": "minor-sixth", + "7": "dominant", + "maj7": "major-seventh", + "ma7": "major-seventh", + "m7": "minor-seventh", + "min7": "minor-seventh", + "-7": "minor-seventh", + "mmaj7": "minor-major-seventh", + "mm7": "minor-major-seventh", + "minmaj7": "minor-major-seventh", + "minm7": "minor-major-seventh", + "dim": "diminished", + "o": "diminished", + "dim7": "diminished-seventh", + "o7": "diminished-seventh", + "aug": "augmented", + "+": "augmented", + "sus": "suspended-fourth", + "sus4": "suspended-fourth", + "sus2": "suspended-second", + "9": "dominant-ninth", + "maj9": "major-ninth", + "m9": "minor-ninth", + "min9": "minor-ninth", + "11": "dominant-11th", + "m11": "minor-11th", + "min11": "minor-11th", + "13": "dominant-13th", + "maj13": "major-13th", + "m13": "minor-13th", + "min13": "minor-13th", + "m7b5": "half-diminished", + "min7b5": "half-diminished" + ] + + return kindValues[normalized] + } + + private static func normalizedKindSuffix(_ suffix: String) -> String { + if suffix.hasPrefix("M") { + return "maj" + suffix.dropFirst().lowercased() + } + return suffix.lowercased() + } +} diff --git a/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift b/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift new file mode 100644 index 0000000..e5424c0 --- /dev/null +++ b/JammLab/ViewModels/AudioPlayerViewModel+NotationExport.swift @@ -0,0 +1,93 @@ +import Foundation + +extension AudioPlayerViewModel { + var canExportNotation: Bool { + let score = currentNotationExportScore() + return score.isReady && !score.measures.isEmpty + } + + @discardableResult + func exportNotationAsMusicXML() async -> Bool { + errorMessage = nil + let format = NotationExportFormat.musicXML + let request: NotationExportRequest + + do { + request = try makeNotationExportRequest() + _ = try notationExportService.export(request, format: format) + } catch { + errorMessage = "Notation export failed: \(error.localizedDescription)" + return false + } + + guard let url = notationExportDocumentService.chooseExportURL( + defaultName: defaultNotationExportFilename(for: format), + format: format + ) else { + return false + } + + return await exportNotation(request, format: format, to: url) + } + + @discardableResult + func exportNotation(format: NotationExportFormat, to url: URL) async -> Bool { + do { + let request = try makeNotationExportRequest() + return await exportNotation(request, format: format, to: url) + } catch { + errorMessage = "Notation export failed: \(error.localizedDescription)" + return false + } + } + + private func exportNotation( + _ request: NotationExportRequest, + format: NotationExportFormat, + to url: URL + ) async -> Bool { + errorMessage = nil + + do { + let data = try notationExportService.export(request, format: format) + try notationExportDocumentService.save(data, to: url) + return true + } catch { + errorMessage = "Notation export failed: \(error.localizedDescription)" + return false + } + } + + private func makeNotationExportRequest() throws -> NotationExportRequest { + let score = currentNotationExportScore() + guard score.isReady, !score.measures.isEmpty else { + throw NotationExportError.emptyScore + } + + return NotationExportRequest( + displayName: notationExportDisplayName, + score: score + ) + } + + private func currentNotationExportScore() -> NotationScoreState { + NotationViewportFactory().scoreState( + tempoMap: tempoMap, + duration: duration, + currentTime: currentTime, + playbackMarkerTime: playbackMarkerTime, + isPlaying: playbackState == .playing, + keyName: effectiveKeyName, + harmonySymbols: harmonySymbols, + notes: notes + ) + } + + private var notationExportDisplayName: String { + (importedFile?.displayName as NSString?)?.deletingPathExtension ?? "Notation" + } + + private func defaultNotationExportFilename(for format: NotationExportFormat) -> String { + "\(notationExportDisplayName).\(format.fileExtension)" + } +} diff --git a/JammLab/ViewModels/AudioPlayerViewModel.swift b/JammLab/ViewModels/AudioPlayerViewModel.swift index b051069..3b8fa6c 100644 --- a/JammLab/ViewModels/AudioPlayerViewModel.swift +++ b/JammLab/ViewModels/AudioPlayerViewModel.swift @@ -85,6 +85,8 @@ final class AudioPlayerViewModel: ObservableObject { let projectService: ProjectDocumentService let projectArtifactStore: ProjectArtifactStore let projectPersistenceCoordinator: ProjectPersistenceCoordinator + let notationExportService: NotationExportService + let notationExportDocumentService: NotationExportDocumentService let recentProjectsStore: RecentProjectsStore let isSandboxed: () -> Bool var clockTask: Task? @@ -169,6 +171,8 @@ final class AudioPlayerViewModel: ObservableObject { projectService: ProjectDocumentService = ProjectDocumentService(), projectArtifactStore: ProjectArtifactStore = ProjectArtifactStore(), projectPersistenceCoordinator: ProjectPersistenceCoordinator? = nil, + notationExportService: NotationExportService = NotationExportService(), + notationExportDocumentService: NotationExportDocumentService = NotationExportDocumentService(), recentProjectsStore: RecentProjectsStore? = nil, isSandboxed: @escaping () -> Bool = AudioPlayerViewModel.defaultSandboxDetection ) { @@ -189,6 +193,8 @@ final class AudioPlayerViewModel: ObservableObject { peakformProvider: peakformProvider, stemSeparationService: resolvedStemSeparationService ) + self.notationExportService = notationExportService + self.notationExportDocumentService = notationExportDocumentService self.recentProjectsStore = recentProjectsStore ?? .shared self.isSandboxed = isSandboxed self.playbackEngine.setClickVolume(clickVolume) diff --git a/JammLab/Views/Components/ControlHelpText.swift b/JammLab/Views/Components/ControlHelpText.swift index 828b662..c4ad30c 100644 --- a/JammLab/Views/Components/ControlHelpText.swift +++ b/JammLab/Views/Components/ControlHelpText.swift @@ -33,6 +33,7 @@ enum ControlHelpText { static let timelineZoomOut = "Zoom out" static let expandNotationTrack = "Expand Notation track" static let collapseNotationTrack = "Collapse Notation track" + static let exportNotationMusicXML = "Export Notation as MusicXML" static let resetThemeColors = "Reset theme colors to defaults" static let resetClickDefaults = "Restore the current built-in click sound: 1760/1120 Hz and 36/26 ms." diff --git a/JammLab/Views/NotationWindowView.swift b/JammLab/Views/NotationWindowView.swift index 624f7b4..37a5812 100644 --- a/JammLab/Views/NotationWindowView.swift +++ b/JammLab/Views/NotationWindowView.swift @@ -60,6 +60,18 @@ struct NotationWindowView: View { Spacer(minLength: AppTheme.Spacing.md) + AppControlButton( + title: "Export MusicXML", + systemImage: "square.and.arrow.up" + ) { + Task { + await viewModel.exportNotationAsMusicXML() + } + } + .disabled(!viewModel.canExportNotation) + .help(ControlHelpText.exportNotationMusicXML) + .accessibilityLabel(ControlHelpText.exportNotationMusicXML) + HStack(spacing: AppTheme.Spacing.xxs) { Text("1/") .font(AppTheme.Typography.captionMonospaced) diff --git a/JammLabTests/AudioTimingLogicTests.swift b/JammLabTests/AudioTimingLogicTests.swift index bee0d52..a229857 100644 --- a/JammLabTests/AudioTimingLogicTests.swift +++ b/JammLabTests/AudioTimingLogicTests.swift @@ -653,6 +653,192 @@ final class AudioTimingLogicTests: XCTestCase { XCTAssertEqual(harmony.rawText, "Dm7") } + func testMusicXMLChordParserSupportsSemanticChords() throws { + let plain = try MusicXMLChordParser.parse("C", measureNumber: 1) + XCTAssertEqual(plain.root, MusicXMLPitchStep(step: "C", alter: 0)) + XCTAssertEqual(plain.kindValue, "major") + + let minor = try MusicXMLChordParser.parse("Am", measureNumber: 1) + XCTAssertEqual(minor.root, MusicXMLPitchStep(step: "A", alter: 0)) + XCTAssertEqual(minor.kindValue, "minor") + + let altered = try MusicXMLChordParser.parse("Bb13(#11)/D", measureNumber: 2) + XCTAssertEqual(altered.root, MusicXMLPitchStep(step: "B", alter: -1)) + XCTAssertEqual(altered.kindValue, "dominant-13th") + XCTAssertEqual(altered.bass, MusicXMLPitchStep(step: "D", alter: 0)) + XCTAssertEqual(altered.degrees, [ + MusicXMLChordDegree(value: 11, alter: 1, type: .alter) + ]) + + let halfDiminished = try MusicXMLChordParser.parse("C#m7b5", measureNumber: 3) + XCTAssertEqual(halfDiminished.root, MusicXMLPitchStep(step: "C", alter: 1)) + XCTAssertEqual(halfDiminished.kindValue, "half-diminished") + + let added = try MusicXMLChordParser.parse("Aadd9", measureNumber: 4) + XCTAssertEqual(added.kindValue, "major") + XCTAssertEqual(added.degrees, [ + MusicXMLChordDegree(value: 9, alter: 0, type: .add) + ]) + } + + func testMusicXMLChordParserRejectsUnsupportedChords() { + XCTAssertThrowsError(try MusicXMLChordParser.parse("", measureNumber: 1)) + XCTAssertThrowsError(try MusicXMLChordParser.parse("H7", measureNumber: 1)) + XCTAssertThrowsError(try MusicXMLChordParser.parse("G7alt", measureNumber: 1)) + XCTAssertThrowsError(try MusicXMLChordParser.parse("C(foo)", measureNumber: 1)) + XCTAssertThrowsError(try MusicXMLChordParser.parse("C/G/B", measureNumber: 1)) + } + + func testMusicXMLExportIncludesMeasuresAttributesHarmonyAndRegionDirections() throws { + let regionID = UUID(uuidString: "00000000-0000-0000-0000-000000000401")! + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap( + duration: 8, + markers: [timeSignatureMarker(time: 4, beatsPerBar: 3)] + ), + duration: 8, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "G major", + harmonySymbols: [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "Cmaj7"), + HarmonySymbol(time: 1.5, measureNumber: 1, offsetInQuarterNotes: 3, rawText: "Bb13(#11)/D") + ], + notes: [ + TimecodedNote(id: regionID, kind: .region, time: 2, duration: 2, title: "Verse") + ] + ) + let data = try NotationExportService().export( + NotationExportRequest(displayName: "Song", score: state), + format: .musicXML + ) + let xml = try XCTUnwrap(String(data: data, encoding: .utf8)) + let document = try XMLDocument(data: data) + let root = try XCTUnwrap(document.rootElement()) + let rootElements = (root.children ?? []).compactMap { $0 as? XMLElement } + let childNames = rootElements.compactMap(\.name) + let credit = try XCTUnwrap(rootElements.first { $0.name == "credit" }) + let partList = try XCTUnwrap(rootElements.first { $0.name == "part-list" }) + let scorePart = try firstXMLChild(named: "score-part", in: partList) + let partName = try firstXMLChild(named: "part-name", in: scorePart) + let creditWords = try firstXMLChild(named: "credit-words", in: credit) + let part = try XCTUnwrap(rootElements.first { $0.name == "part" }) + let measures = part.elements(forName: "measure") + let firstMeasure = try XCTUnwrap(measures.first { $0.attribute(forName: "number")?.stringValue == "1" }) + let changedTimeSignatureMeasure = try XCTUnwrap(measures.first { measure in + guard let attributes = measure.elements(forName: "attributes").first, + let time = attributes.elements(forName: "time").first else { + return false + } + return time.elements(forName: "beats").first?.stringValue == "3" + }) + let firstMeasureHarmonies = firstMeasure.elements(forName: "harmony") + let cMajorSeventhHarmony = try XCTUnwrap(firstMeasureHarmonies.first { harmony in + harmony.elements(forName: "kind").first?.attribute(forName: "text")?.stringValue == "Cmaj7" + }) + let alteredHarmony = try XCTUnwrap(firstMeasureHarmonies.first { harmony in + guard let root = harmony.elements(forName: "root").first, + let kind = harmony.elements(forName: "kind").first else { + return false + } + return root.elements(forName: "root-step").first?.stringValue == "B" + && kind.stringValue == "dominant-13th" + }) + let regionDirection = try XCTUnwrap(measures.lazy + .flatMap { $0.elements(forName: "direction") } + .first { direction in + guard let directionType = direction.elements(forName: "direction-type").first else { + return false + } + return directionType.elements(forName: "words").first?.stringValue == "Verse" + }) + let firstMeasureRest = try XCTUnwrap(firstMeasure.elements(forName: "note").first { note in + note.elements(forName: "rest").first?.attribute(forName: "measure")?.stringValue == "yes" + }) + + XCTAssertTrue(xml.contains("")) + XCTAssertEqual(root.name, "score-partwise") + XCTAssertEqual(root.attribute(forName: "version")?.stringValue, "4.0") + XCTAssertLessThan( + try XCTUnwrap(childNames.firstIndex(of: "credit")), + try XCTUnwrap(childNames.firstIndex(of: "part-list")) + ) + XCTAssertEqual(credit.attribute(forName: "page")?.stringValue, "1") + XCTAssertEqual(credit.elements(forName: "credit-type").first?.stringValue, "title") + XCTAssertEqual(creditWords.stringValue, "Song") + XCTAssertEqual(creditWords.attribute(forName: "default-x")?.stringValue, "600.17") + XCTAssertEqual(creditWords.attribute(forName: "default-y")?.stringValue, "1611.01") + XCTAssertEqual(creditWords.attribute(forName: "justify")?.stringValue, "center") + XCTAssertEqual(creditWords.attribute(forName: "valign")?.stringValue, "top") + XCTAssertEqual(creditWords.attribute(forName: "font-size")?.stringValue, "22") + XCTAssertEqual(partName.stringValue, "Song") + XCTAssertEqual(partName.attribute(forName: "print-object")?.stringValue, "no") + XCTAssertEqual(firstMeasure.attribute(forName: "number")?.stringValue, "1") + + let firstMeasureAttributes = try firstXMLChild(named: "attributes", in: firstMeasure) + let firstMeasureKey = try firstXMLChild(named: "key", in: firstMeasureAttributes) + XCTAssertEqual(try firstXMLChild(named: "fifths", in: firstMeasureKey).stringValue, "1") + + let changedTimeSignatureAttributes = try firstXMLChild(named: "attributes", in: changedTimeSignatureMeasure) + let changedTimeSignature = try firstXMLChild(named: "time", in: changedTimeSignatureAttributes) + XCTAssertEqual(try firstXMLChild(named: "beats", in: changedTimeSignature).stringValue, "3") + + let cMajorSeventhRoot = try firstXMLChild(named: "root", in: cMajorSeventhHarmony) + let cMajorSeventhKind = try firstXMLChild(named: "kind", in: cMajorSeventhHarmony) + XCTAssertEqual(try firstXMLChild(named: "root-step", in: cMajorSeventhRoot).stringValue, "C") + XCTAssertTrue(cMajorSeventhRoot.elements(forName: "root-alter").isEmpty) + XCTAssertEqual(cMajorSeventhKind.attribute(forName: "text")?.stringValue, "Cmaj7") + XCTAssertEqual(cMajorSeventhKind.stringValue, "major-seventh") + + let alteredRoot = try firstXMLChild(named: "root", in: alteredHarmony) + let alteredDegree = try firstXMLChild(named: "degree", in: alteredHarmony) + let alteredBass = try firstXMLChild(named: "bass", in: alteredHarmony) + XCTAssertEqual(try firstXMLChild(named: "root-step", in: alteredRoot).stringValue, "B") + XCTAssertEqual(try firstXMLChild(named: "root-alter", in: alteredRoot).stringValue, "-1") + XCTAssertEqual(try firstXMLChild(named: "degree-value", in: alteredDegree).stringValue, "11") + XCTAssertEqual(try firstXMLChild(named: "bass-step", in: alteredBass).stringValue, "D") + XCTAssertTrue(alteredBass.elements(forName: "bass-alter").isEmpty) + XCTAssertEqual(try firstXMLChild(named: "offset", in: alteredHarmony).stringValue, "1440") + + let regionDirectionType = try firstXMLChild(named: "direction-type", in: regionDirection) + XCTAssertEqual(try firstXMLChild(named: "words", in: regionDirectionType).stringValue, "Verse") + let rest = try firstXMLChild(named: "rest", in: firstMeasureRest) + XCTAssertEqual(rest.attribute(forName: "measure")?.stringValue, "yes") + } + + func testMusicXMLExportFailsForUnsupportedHarmony() throws { + let state = NotationViewportFactory().scoreState( + tempoMap: fourFourTempoMap(duration: 4), + duration: 4, + currentTime: 0, + playbackMarkerTime: 0, + isPlaying: false, + keyName: "C major", + harmonySymbols: [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "G7alt") + ] + ) + + XCTAssertThrowsError( + try NotationExportService().export( + NotationExportRequest(displayName: "Song", score: state), + format: .musicXML + ) + ) { error in + XCTAssertEqual(error as? NotationExportError, .unsupportedChord(rawText: "G7alt", measureNumber: 1)) + } + } + + private func firstXMLChild( + named name: String, + in element: XMLElement, + file: StaticString = #filePath, + line: UInt = #line + ) throws -> XMLElement { + try XCTUnwrap(element.elements(forName: name).first, file: file, line: line) + } + func testNotationViewportStateBuildsRegionLabelsFromRegionAndLegacyLoopStarts() throws { let introID = UUID(uuidString: "00000000-0000-0000-0000-000000000301")! let markerID = UUID(uuidString: "00000000-0000-0000-0000-000000000302")! diff --git a/JammLabTests/ViewModelLifecycleTests.swift b/JammLabTests/ViewModelLifecycleTests.swift index 7f3b16e..45a0062 100644 --- a/JammLabTests/ViewModelLifecycleTests.swift +++ b/JammLabTests/ViewModelLifecycleTests.swift @@ -1889,6 +1889,51 @@ final class ViewModelLifecycleTests: XCTestCase { XCTAssertFalse(viewModel.isProjectModified) } + @MainActor + func testExportNotationWritesMusicXMLWithoutChangingDirtyOrUndoState() async throws { + let emptyViewModel = AudioPlayerViewModel( + analyzer: MockAnalyzer(), + peakformProvider: MockPeakformProvider(), + playbackEngine: MockPlaybackEngine() + ) + XCTAssertFalse(emptyViewModel.canExportNotation) + + let viewModel = try loadedNotationViewModel(duration: 8) + let undoManager = UndoManager() + viewModel.undoManager = undoManager + viewModel.harmonySymbols = [ + HarmonySymbol(time: 0, measureNumber: 1, offsetInQuarterNotes: 0, rawText: "Cmaj7") + ] + viewModel.markProjectClean() + + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent("notation-export-\(UUID().uuidString)") + .appendingPathExtension("musicxml") + defer { try? FileManager.default.removeItem(at: outputURL) } + + XCTAssertTrue(viewModel.canExportNotation) + let didExportCleanProject = await viewModel.exportNotation(format: .musicXML, to: outputURL) + + XCTAssertTrue(didExportCleanProject) + XCTAssertFalse(viewModel.isProjectModified) + XCTAssertFalse(viewModel.canUndo) + XCTAssertNil(viewModel.errorMessage) + let xml = try String(contentsOf: outputURL, encoding: .utf8) + XCTAssertTrue(xml.contains("")) + XCTAssertTrue(xml.contains("major-seventh")) + + viewModel.setLooping(true) + XCTAssertTrue(viewModel.isProjectModified) + XCTAssertTrue(viewModel.canUndo) + + let didExportDirtyProject = await viewModel.exportNotation(format: .musicXML, to: outputURL) + + XCTAssertTrue(didExportDirtyProject) + XCTAssertTrue(viewModel.isProjectModified) + XCTAssertTrue(viewModel.canUndo) + XCTAssertNil(viewModel.errorMessage) + } + @MainActor func testLocateRegionStartSelectsRegionAndMovesPlaybackMarkerWithoutActivatingLoop() throws { let audioURL = try temporaryAudioFile(duration: 6)