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
4 changes: 4 additions & 0 deletions JammLab.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -359,6 +360,7 @@
9FAE00022F10000100112233 /* NotationNoteAuditioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationNoteAuditioner.swift; sourceTree = "<group>"; };
9FEA00052D70000100112233 /* NotationVisibleMeasureFitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationVisibleMeasureFitter.swift; sourceTree = "<group>"; };
9FEA00062D70000100112233 /* ProjectKeySelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectKeySelection.swift; sourceTree = "<group>"; };
A13A00013000000100112233 /* MusicXMLChordParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicXMLChordParser.swift; sourceTree = "<group>"; };
9FEE00012D90000100112233 /* NotationExportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationExportService.swift; sourceTree = "<group>"; };
9FEE00022D90000100112233 /* AudioPlayerViewModel+NotationExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AudioPlayerViewModel+NotationExport.swift"; sourceTree = "<group>"; };
9FD100002E00000100112233 /* NotationTrackLayoutItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotationTrackLayoutItems.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
233 changes: 233 additions & 0 deletions JammLab/Services/MusicXMLChordParser.swift
Original file line number Diff line number Diff line change
@@ -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: &degrees) else {
throw NotationExportError.unsupportedChord(rawText: displayText, measureNumber: measureNumber)
}
extractInlineDegrees(from: &suffix, into: &degrees)

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)..<close]
guard parseDegreeList(String(content), into: &degrees) else {
return false
}
suffix.removeSubrange(open...close)
}
return !suffix.contains("(") && !suffix.contains(")")
}

private static func extractInlineDegrees(
from suffix: inout String,
into degrees: inout [MusicXMLChordDegree]
) {
if ["m7b5", "min7b5"].contains(suffix.lowercased()) {
return
}

let tokens = ["add13", "add11", "add9", "no5", "no3", "#11", "b13", "#9", "b9", "#5", "b5"]
var didRemove = true
while didRemove {
didRemove = false
for token in tokens {
if suffix.hasSuffix(token), let degree = degree(from: token) {
suffix.removeLast(token.count)
degrees.append(degree)
didRemove = true
break
}
}
}
}

private static func parseDegreeList(_ text: String, into degrees: inout [MusicXMLChordDegree]) -> 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()
}
}
Loading