From 53341e14d6abe72ab54cbcc2a9bcc34b4aba4c36 Mon Sep 17 00:00:00 2001 From: Cyberflow Date: Fri, 24 Jul 2026 15:32:16 +0300 Subject: [PATCH] refactor: extract MusicXML chord parser --- JammLab.xcodeproj/project.pbxproj | 4 + JammLab/Services/MusicXMLChordParser.swift | 233 +++++++++++++++++++ JammLab/Services/NotationExportService.swift | 232 ------------------ 3 files changed, 237 insertions(+), 232 deletions(-) create mode 100644 JammLab/Services/MusicXMLChordParser.swift diff --git a/JammLab.xcodeproj/project.pbxproj b/JammLab.xcodeproj/project.pbxproj index 5f91902..5dc199e 100644 --- a/JammLab.xcodeproj/project.pbxproj +++ b/JammLab.xcodeproj/project.pbxproj @@ -58,6 +58,7 @@ 9FAE01022F10000100112233 /* NotationNoteAuditioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FAE00022F10000100112233 /* NotationNoteAuditioner.swift */; }; 9FEA01052D70000100112233 /* NotationVisibleMeasureFitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */; }; 9FEA01062D70000100112233 /* ProjectKeySelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEA00062D70000100112233 /* ProjectKeySelection.swift */; }; + A13A01013000000100112233 /* MusicXMLChordParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A13A00013000000100112233 /* MusicXMLChordParser.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 */; }; 9FD100012E00000100112233 /* NotationTrackLayoutItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FD100002E00000100112233 /* NotationTrackLayoutItems.swift */; }; @@ -359,6 +360,7 @@ 9FAE00022F10000100112233 /* NotationNoteAuditioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationNoteAuditioner.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 = ""; }; + A13A00013000000100112233 /* MusicXMLChordParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicXMLChordParser.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 = ""; }; 9FD100002E00000100112233 /* NotationTrackLayoutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationTrackLayoutItems.swift; sourceTree = ""; }; @@ -686,6 +688,7 @@ 9FBE00032F70000100112233 /* NotationBeamGrouping.swift */, 9FBE00042F70000100112233 /* NotationBeamGeometry.swift */, 9FCB00062D80000100112233 /* NotationMeasureLayout.swift */, + A13A00013000000100112233 /* MusicXMLChordParser.swift */, 9FEE00012D90000100112233 /* NotationExportService.swift */, 9FAE00022F10000100112233 /* NotationNoteAuditioner.swift */, 9FAE00012F10000100112233 /* NotationNotePlacementResolver.swift */, @@ -1278,6 +1281,7 @@ 9FEC01022D71000100112233 /* NotationWindowScoreLayout.swift in Sources */, 9F8A010D2C00000100112233 /* AppHotkey.swift in Sources */, 9FCB01062D80000100112233 /* NotationMeasureLayout.swift in Sources */, + A13A01013000000100112233 /* MusicXMLChordParser.swift in Sources */, 9FEE01012D90000100112233 /* NotationExportService.swift in Sources */, 9FAE01022F10000100112233 /* NotationNoteAuditioner.swift in Sources */, 9FAE01012F10000100112233 /* NotationNotePlacementResolver.swift in Sources */, diff --git a/JammLab/Services/MusicXMLChordParser.swift b/JammLab/Services/MusicXMLChordParser.swift new file mode 100644 index 0000000..c424d78 --- /dev/null +++ b/JammLab/Services/MusicXMLChordParser.swift @@ -0,0 +1,233 @@ +import Foundation + +struct MusicXMLChord: Equatable { + var root: MusicXMLPitchStep + var kindValue: String + var displayText: String + var degrees: [MusicXMLChordDegree] + var bass: MusicXMLPitchStep? +} + +struct MusicXMLPitchStep: Equatable { + var step: String + var alter: Int +} + +struct MusicXMLChordDegree: Equatable { + enum DegreeType: String, Equatable { + case add + case alter + case subtract + } + + var value: Int + var alter: Int + var type: DegreeType +} + +enum MusicXMLChordParser { + static func parse(_ rawText: String, measureNumber: Int) throws -> 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/Services/NotationExportService.swift b/JammLab/Services/NotationExportService.swift index 7e8490e..e8585db 100644 --- a/JammLab/Services/NotationExportService.swift +++ b/JammLab/Services/NotationExportService.swift @@ -909,235 +909,3 @@ private struct MusicXMLRenderEvent { var notes: [NotationMeasureItem] var rest: NotationMeasureItem? } - -struct MusicXMLChord: Equatable { - var root: MusicXMLPitchStep - var kindValue: String - var displayText: String - var degrees: [MusicXMLChordDegree] - var bass: MusicXMLPitchStep? -} - -struct MusicXMLPitchStep: Equatable { - var step: String - var alter: Int -} - -struct MusicXMLChordDegree: Equatable { - enum DegreeType: String, Equatable { - case add - case alter - case subtract - } - - var value: Int - var alter: Int - var type: DegreeType -} - -enum MusicXMLChordParser { - static func parse(_ rawText: String, measureNumber: Int) throws -> 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() - } -}