diff --git a/pco_chords.nimble b/pco_chords.nimble index 2e58e32..b13a6ea 100644 --- a/pco_chords.nimble +++ b/pco_chords.nimble @@ -1,6 +1,6 @@ # Package -version = "0.5.6" +version = "0.6.0" author = "Jonathan Bernard" description = "Chord chart formatter compatible with Planning Center Online" license = "MIT" diff --git a/src/pco_chordspkg/ast.nim b/src/pco_chordspkg/ast.nim index f2dfdf2..8fbdb1c 100644 --- a/src/pco_chordspkg/ast.nim +++ b/src/pco_chordspkg/ast.nim @@ -1,10 +1,11 @@ import std/[nre, strtabs, strutils] +import ./notation import zero_functional type ChordChartMetadata* = object title*: string - key*: ChordChartChord + key*: Key optionalProps: StringTableRef ChordChart* = ref object @@ -23,13 +24,11 @@ type ccnkRedefineKey, ccnkNone - ChordChartPitch* = enum Af, A, Bf, B, C, Df, D, Ef, E, F, Gf, G - ChordChartChord* = object original*: Option[string] - rootPitch*: ChordChartPitch + root*: ScaleDegree flavor*: Option[string] - bassPitch*: Option[ChordChartPitch] + bass*: Option[ScaleDegree] ChordChartNode* = ref object case kind: ChordChartNodeKind @@ -52,12 +51,12 @@ type of ccnkTransposeKey: transposeSteps*: int of ccnkRedefineKey: - newKey*: ChordChartPitch + newKey*: Key of ccnkNone: discard ParserContext = ref object lines: seq[string] - curKeyCenter: ChordChartPitch + curKey: Key curLineNum: int curSection: ChordChartNode unparsedLineParts: seq[string] @@ -77,97 +76,6 @@ iterator pairs*(ccmd: ChordChartMetadata): tuple[key, value: string] = func kind*(ccn: ChordChartNode): ChordChartNodeKind = ccn.kind -func `$`*(pitch: ChordChartPitch): string = - ["A♭", "A", "B♭", "B", "C", "D♭", "D", "E♭", "E", "F", "G♭", "G"][ord(pitch)] - -func renderPitchInKey*( - pitch: ChordChartPitch, - key: ChordChartPitch, - useSharps: Option[bool] = none[bool]()): string = - - var scaleNames: array[(ord(high(ChordChartPitch)) + 1), string] - if useSharps.isNone: - # If we aren't told to use sharps or flats, we render the diatonic pitches - # according to standard theory (C# in the key of D, Db in the key of Ab) - # but we render non-diatonic notes with flats (prefer the b6 and b7 over - # the #5 and #6). - # - # TODO: In the future, we should also remember the scale degree of the - # chord when parsing. So, for example, in the key of D we would parse Bb as - # the b6 and A# as the #5. The pitch would be the same, but the scale - # degree would differ. This would allow us to preserve intentional choices - # in the chart when transposing. - scaleNames = case key - of C: - ["A♭", "A", "B♭", "B", "C", "D♭", "D", "E♭", "E", "F", "G♭", "G"] - of G: - ["A♭", "A", "B♭", "B", "C", "D♭", "D", "E♭", "E", "F", "F#", "G"] - of D: - ["A♭", "A", "B♭", "B", "C", "C#", "D", "E♭", "E", "F", "F#", "G"] - of A: - ["G#", "A", "B♭", "B", "C", "C#", "D", "E♭", "E", "F", "F#", "G"] - of E: - ["G#", "A", "B♭", "B", "C", "C#", "D", "D#", "E", "F", "F#", "G"] - of B: - ["G#", "A", "A#", "B", "C", "C#", "D", "D#", "E", "F", "F#", "G"] - of Gf: - ["A♭", "B𝄫", "B♭", "C♭", "D𝄫", "D♭", "E𝄫", "E♭", "F♭", "F", "G♭", "A𝄫"] - of Df: - ["A♭", "B𝄫", "B♭", "C♭", "C", "D♭", "E𝄫", "E♭", "F♭", "F", "G♭", "A𝄫"] - of Af: - ["A♭", "B𝄫", "B♭", "C♭", "C", "D♭", "E𝄫", "E♭", "F♭", "F", "G♭", "G"] - of Ef: - ["A♭", "B𝄫", "B♭", "C♭", "C", "D♭", "D", "E♭", "F♭", "F", "G♭", "G"] - of Bf: - ["A♭", "A", "B♭", "C♭", "C", "D♭", "D", "E♭", "F♭", "F", "G♭", "G"] - of F: - ["A♭", "A", "B♭", "C♭", "C", "D♭", "D", "E♭", "E", "F", "G♭", "G"] - - - elif useSharps.isSome and useSharps.get: - scaleNames = case key - of A, B, C, D, E, G: - ["G#", "A", "A#", "B", "C", "C#", "D", "D#", "E", "F", "F#", "G"] - of Af: - ["G#", "A", "A#", "B", "B#", "C#", "D", "D#", "E", "E#", "F#", "F𝄪"] - of Bf: - ["G#", "G𝄪", "A#", "B", "B#", "C#", "C𝄪", "D#", "E", "E#", "F#", "F𝄪"] - of Df: - ["G#", "A", "A#", "B", "B#", "C#", "D", "D#", "E", "E#", "F#", "G"] - of Ef: - ["G#", "A", "A#", "B", "B#", "C#", "C𝄪", "D#", "E", "E#", "F#", "F𝄪"] - of Gf: - ["G#", "A", "A#", "B", "C", "C#", "D", "D#", "E", "E#", "F#", "G"] - of F: - ["G#", "G𝄪", "A#", "B", "B#", "C#", "C𝄪", "D#", "D𝄪", "E#", "F#", "F𝄪"] - - else: # !useSharps (useSharps.isSome and not useSharps.get) - scaleNames = case key - of C, Af, Bf, Df, Ef, F: - ["A♭", "A", "B♭", "B", "C", "D♭", "D", "E♭", "E", "F", "G♭", "G"] - of A: - ["A♭", "B𝄫", "B♭", "C♭", "C", "D♭", "E𝄫", "E♭", "F♭", "F", "G♭", "G"] - of B: - ["A♭", "A", "B♭", "C♭", "C", "D♭", "D", "E♭", "F♭", "F", "G♭", "G"] - of D: - ["A♭", "B𝄫", "B♭", "C♭", "C", "D♭", "E𝄫", "E♭", "F♭", "F", "G♭", "A𝄫"] - of E: - ["A♭", "B𝄫", "B♭", "C♭", "C", "D♭", "D", "E♭", "F♭", "F", "G♭", "G"] - of G: - ["A♭", "B𝄫", "B♭", "C♭", "D𝄫", "D♭", "E𝄫", "E♭", "F♭", "F", "G♭", "A𝄫"] - of Gf: - ["A♭", "A", "B♭", "C♭", "C", "D♭", "D", "E♭", "E", "F", "G♭", "G"] - - - return scaleNames[ord(pitch)] - -func `+`*(pitch: ChordChartPitch, steps: int): ChordChartPitch = - cast[ChordChartPitch]((ord(pitch) + steps) mod 12) - -func `-`*(a, b: ChordChartPitch): int = - result = ord(a) - ord(b) - if result < 0: result += 12 - func dump*(m: ChordChartMetadata, indent = ""): string = return indent & "Metadata\p" & join(m.pairs --> map(indent & " " & it.key & ": " & it.value), "\p") @@ -203,14 +111,14 @@ func dump*(ccn: ChordChartNode, indent = ""): string = let chord = ccn.chord.get result &= "[" if chord.original.isSome and chord.flavor.isNone and - chord.bassPitch.isNone: + chord.bass.isNone: result &= chord.original.get else: - result &= $chord.rootPitch + result &= $chord.root if chord.flavor.isSome: result &= "_" & chord.flavor.get - if chord.bassPitch.isSome: - result &= "/" & $chord.bassPitch.get + if chord.bass.isSome: + result &= "/" & $chord.bass.get result &= "]" if ccn.word.isSome: result &= ccn.word.get @@ -256,36 +164,7 @@ template addToCurSection(n: ChordChartNode): untyped = if ctx.curSection.kind == ccnkSection: ctx.curSection.sectionContents.add(n) else: result.add(n) -proc parsePitch*(ctx: ParserContext, keyValue: string): ChordChartPitch = - let normK = keyValue.strip.toLower - case normK - of "gs", "gis", "g#", "ab", "a♭", "af", "aes": return ChordChartPitch.Af - of "g𝄪", "a", "a♮", "b𝄫": return ChordChartPitch.A - of "as", "ais", "a#", "bf", "bb", "b♭", "bes", "c𝄫": return ChordChartPitch.Bf - of "a𝄪", "b", "c♭", "cb", "ces", "cf": return ChordChartPitch.B - of "bs", "bis", "b#", "c", "d𝄫": return ChordChartPitch.C - of "b𝄪", "cs", "cis", "c#", "d♭", "df", "db", "des": return ChordChartPitch.Df - of "c𝄪", "d", "e𝄫": return ChordChartPitch.D - of "ds", "dis", "d#", "ef", "e♭", "eb", "ees", "f𝄫": return ChordChartPitch.Ef - of "d𝄪", "e", "f♭", "fes", "ff": return ChordChartPitch.E - of "es", "eis", "e#", "f", "g𝄫": return ChordChartPitch.F - of "e𝄪", "fs", "fis", "f#", "g♭", "gf", "gb", "ges": return ChordChartPitch.Gf - of "f𝄪", "g", "a𝄫": return ChordChartPitch.G - of "#7", "1", "𝄫2": return ctx.curKeyCenter - of "𝄪7", "#1", "b2", "♭2": return ctx.curKeyCenter + 1 - of "𝄪1", "2", "𝄫3": return ctx.curKeyCenter + 2 - of "#2", "b3", "♭3", "𝄫4": return ctx.curKeyCenter + 3 - of "𝄪2", "3", "b4", "♭4": return ctx.curKeyCenter + 4 - of "#3", "4", "𝄫5": return ctx.curKeyCenter + 5 - of "𝄪3", "#4", "b5", "♭5": return ctx.curKeyCenter + 6 - of "𝄪4", "5", "𝄫6": return ctx.curKeyCenter + 7 - of "#5", "b6", "♭6": return ctx.curKeyCenter + 8 - of "𝄪5", "6", "𝄫7": return ctx.curKeyCenter + 9 - of "#6", "b7", "♭7", "𝄫1": return ctx.curKeyCenter + 10 - of "7", "b1", "♭1": return ctx.curKeyCenter + 11 - else: raise ctx.makeError(keyValue.strip & " is not a recognized key.") - -# see regexr.com/70nv1 +# see https://regexr.com/8f4ru let CHORD_REGEX = "([b#♭♮𝄫𝄪]?[A-G1-7][b#♭♮𝄫𝄪]?)" & # chord root "((min|maj|aug|dim|sus|6\\/9|[mM1-9#b♭♮𝄫𝄪Δ+oøoø°𝆩][0-9]?|\\([1-9#b♭]+\\))*)" & # chord flavor/type @@ -293,7 +172,8 @@ let CHORD_REGEX = let CHORD_PAT = re(CHORD_REGEX) proc parseChord*( - ctx: ParserContext, chordValue: string + ctx: ParserContext, + chordValue: string ): Option[ChordChartChord] = let m = chordValue.match(CHORD_PAT) @@ -304,16 +184,16 @@ proc parseChord*( some(m.get.captures[1]) else: none[string]() - let bassPitch = + let bass = if m.get.captures.contains(4) and m.get.captures[4].len > 0: - some(ctx.parsePitch(m.get.captures[4])) - else: none[ChordChartPitch]() + some(toScaleDegree(ctx.curKey, parseSpelledPitch(m.get.captures[4]))) + else: none[ScaleDegree]() return some(ChordChartChord( original: some(chordValue), - rootPitch: ctx.parsePitch(m.get.captures[0]), + root: toScaleDegree(ctx.curKey, parseSpelledPitch(m.get.captures[0])), flavor: flavor, - bassPitch: bassPitch)) + bass: bass)) let METADATA_LINE_PAT = re"^([^:]+):(.*)$" let METADATA_END_PAT = re"^-+$" @@ -321,7 +201,7 @@ proc parseMetadata(ctx: var ParserContext): ChordChartMetadata = var title = "MISSING" var optProps = newStringTable() - var songKey: Option[ChordChartChord] + var songKey: Option[Key] while ctx.curLineNum < ctx.lines.len: let line = ctx.nextLine @@ -337,7 +217,12 @@ proc parseMetadata(ctx: var ParserContext): ChordChartMetadata = let value = m.get.captures[1].strip if key == "title": title = value elif key == "key": - songKey = ctx.parseChord(value) + let parts = value.split(" ", 1) + songKey = some(Key( + tonic: parseSpelledPitch(parts[0]), + mode: + if parts.len > 1: parseMode(parts[1]) + else: Ionian)) if songKey.isNone: raise ctx.makeError("unrecognized key: " & value) else: optProps[key] = value @@ -499,14 +384,9 @@ proc parseBody(ctx: var ParserContext): seq[ChordChartNode] = m = line.match(REDEFINE_KEY_PAT) if m.isSome: - let newKeyInt = ( - ord(ctx.curKeyCenter) + - parseInt(m.get.captures[0]) - ) mod 12 - addToCurSection(ChordChartNode( kind: ccnkRedefineKey, - newKey: cast[ChordChartPitch](newKeyInt))) + newKey: ctx.curKey.transpose(parseInt(m.get.captures[0])))) continue m = line.match(COL_BREAK_PAT) @@ -548,7 +428,7 @@ proc parseChordChart*(s: string): ChordChart = unparsedLineParts: @[]) let metadata = parseMetadata(parserCtx) - parserCtx.curKeyCenter = metadata.key.rootPitch + parserCtx.curKey = metadata.key result = ChordChart( rawContent: s, diff --git a/src/pco_chordspkg/cliconstants.nim b/src/pco_chordspkg/cliconstants.nim index ae2cdb4..d56d722 100644 --- a/src/pco_chordspkg/cliconstants.nim +++ b/src/pco_chordspkg/cliconstants.nim @@ -1,4 +1,4 @@ -const PCO_CHORDS_VERSION* = "0.5.6" +const PCO_CHORDS_VERSION* = "0.6.0" const USAGE* = """Usage: pco_chords [options] diff --git a/src/pco_chordspkg/html.nim b/src/pco_chordspkg/html.nim index 9a6e990..9062d84 100644 --- a/src/pco_chordspkg/html.nim +++ b/src/pco_chordspkg/html.nim @@ -1,13 +1,13 @@ import std/[logging, options, os, strutils] import zero_functional -import ./ast +import ./[ast, notation] type FormatContext = ref object chart: ChordChart - currentKey: ChordChartPitch - sourceKey: ChordChartPitch + currentKey: Key + sourceKey: Key currentSection: ChordChartNode numberChart: bool transposeSteps: int @@ -80,7 +80,7 @@ h3 .section-text { height: 1.2em; } -.chord .flavor { +.number-chart .chord .flavor { font-variant-position: super; } @@ -175,7 +175,7 @@ h3 .section-text { height: 1.2em; } -.chord .flavor { +.number-chart .chord .flavor { font-variant-position: super; } @@ -197,62 +197,23 @@ h3 .section-text { """ -func scaleDegree(root: ChordChartPitch, p: ChordChartPitch): string = - let distance = p - root - case distance: - of 0: "1" - of 1: "♭2" - of 2: "2" - of 3: "♭3" - of 4: "3" - of 5: "4" - of 6: "♭5" - of 7: "5" - of 8: "♭6" - of 9: "6" - of 10: "♭7" - of 11: "7" - else: raise newException(Exception, "Impossible") - func format(ctx: FormatContext, chord: ChordChartChord, useNumber = false): string = ##if not useNumber and chord.original.isSome: return chord.original.get - if useNumber: - result = "" & - ctx.currentKey.scaleDegree(chord.rootPitch) & "" + result = "" + if useNumber: result &= $chord.root + else: result &= $ctx.currentKey.spellPitch(chord.root) + result &= "" - if chord.flavor.isSome: + if chord.flavor.isSome: result &= "" & chord.flavor.get & "" - if chord.bassPitch.isSome: - result &= "/" & - ctx.currentKey.scaleDegree(chord.bassPitch.get) & "" + if chord.bass.isSome: + result &= "/" + if useNumber: result &= $chord.bass.get + else: result &= $ctx.currentKey.spellPitch(chord.bass.get) + result &= "" - else: - result = "" & - renderPitchInKey(chord.rootPitch, ctx.currentKey) & "" - - if chord.flavor.isSome: - result &= "" & chord.flavor.get & "" - - if chord.bassPitch.isSome: - result &= "/" & - renderPitchInKey(chord.bassPitch.get, ctx.currentKey) & "" - -proc transpose(ctx: FormatContext, chord: ChordChartChord): ChordChartChord = - result = chord - - let distance = ctx.currentKey - ctx.sourceKey - if distance != 0: - result = ChordChartChord( - original: none[string](), - rootPitch: chord.rootPitch + distance, - flavor: - if chord.flavor.isSome: some(chord.flavor.get) - else: none[string](), - bassPitch: - if chord.bassPitch.isSome: some(chord.bassPitch.get + distance) - else: none[ChordChartPitch]()) func makeSongOrder(songOrder, indent: string): string = result = indent & "
\p" & @@ -298,7 +259,7 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin if node.chord.isSome: result &= "" & - ctx.format(ctx.transpose(node.chord.get), ctx.numberChart) & "" + ctx.format(node.chord.get, ctx.numberChart) & "" result &= "" if node.word.isSome: result &= node.word.get @@ -317,13 +278,13 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin result &= "
" of ccnkTransposeKey: - ctx.currentKey = ctx.currentKey + node.transposeSteps + ctx.currentKey = ctx.currentKey.transpose(node.transposeSteps) result &= indent & "

Key Change: " & $ctx.currentKey & "

" of ccnkRedefineKey: let oldKey = ctx.currentKey - ctx.sourceKey = node.newKey + ctx.transposeSteps - ctx.currentKey = ctx.sourceKey + ctx.sourceKey = node.newKey + ctx.currentKey = ctx.sourceKey.transpose(ctx.transposeSteps) if oldKey != ctx.currentKey: let headingVal = indent & "

Key Change: " & $ctx.currentKey & "

" @@ -342,8 +303,8 @@ proc toHtml*( var ctx = FormatContext( chart: cc, - currentKey: cc.metadata.key.rootPitch + transpose, - sourceKey: cc.metadata.key.rootPitch, + currentKey: cc.metadata.key.transpose(transpose), + sourceKey: cc.metadata.key, currentSection: EMPTY_CHORD_CHART_NODE, numberChart: numberChart, transposeSteps: transpose) @@ -368,17 +329,19 @@ proc toHtml*( result &= " \p" + var bodyClasses = newSeq[string]() + if numberChart: bodyClasses.add("number-chart") if cc.metadata.contains("columns") and cc.metadata["columns"] == "1": - result &= " " - else: - result &= " " + bodyClasses.add("one-column") + else: bodyClasses.add("two-column") + result &= " " var indent = " " # Title result &= indent & "

" & cc.metadata.title & "

\p" - var metadataPieces = @["Key: " & ctx.format(ctx.transpose(cc.metadata.key))] + var metadataPieces = @["Key: " & $ctx.currentKey] if cc.metadata.contains("time signature"): metadataPieces.add(cc.metadata["time signature"]) if cc.metadata.contains("bpm"): diff --git a/src/pco_chordspkg/notation b/src/pco_chordspkg/notation new file mode 100755 index 0000000..4236165 Binary files /dev/null and b/src/pco_chordspkg/notation differ diff --git a/src/pco_chordspkg/notation.nim b/src/pco_chordspkg/notation.nim new file mode 100644 index 0000000..597d933 --- /dev/null +++ b/src/pco_chordspkg/notation.nim @@ -0,0 +1,245 @@ +import std/[strutils, unicode] + +type + Note* {.pure.} = enum A, B, C, D, E, F, G + Pitch* = enum Af, A, Bf, B, C, Df, D, Ef, E, F, Gf, G + + NoteAlteration* = enum + naDoubleFlat = -2, naFlat, naNatural, naSharp, naDoubleSharp + + ScaleDegree* = object + number: int + alteration: NoteAlteration + + SpelledPitch* = object + note: Note + alteration: NoteAlteration + + Mode* = enum + Ionian = 0, + Dorian, + Phrygian, + Lydian, + Mixolydian, + Aeolian, + Locrian + + Key* = object + tonic*: SpelledPitch + mode*: Mode + +const MajorIntervals = [0, 2, 4, 5, 7, 9, 11] + + +func `+`*[T: Pitch or Note](val: T, steps: int): T = + cast[T]((ord(val) + steps) mod (ord(high(T)) + 1)) + + +func `-`*[T: Pitch or Note](val: T, steps: int): T = + var newOrd = (ord(val) - steps) mod (ord(high(T)) + 1) + if newOrd < 0: newOrd += 12 + return cast[T](newOrd) + + +func `-`*(a, b: Note): int = + result = ord(a) - ord(b) + if result < 0: result += 7 + + +func `-`*(a, b: Pitch): int = + result = ord(a) - ord(b) + if result < 0: result += 12 + + +func `$`*(pitch: Pitch): string = + ["A♭", "A", "B♭", "B", "C", "D♭", "D", "E♭", "E", "F", "G♭", "G"][ord(pitch)] + + +func `$`*(sp: SpelledPitch): string = + case sp.alteration + of naDoubleFlat: return $sp.note & "𝄫" + of naFlat: return $sp.note & "♭" + of naNatural: return $sp.note + of naSharp: return $sp.note & "#" + of naDoubleSharp: return $sp.note & "𝄪" + + +func `$`*(sd: ScaleDegree): string = + case sd.alteration + of naDoubleFlat: return "𝄫" & $sd.number + of naFlat: return "♭" & $sd.number + of naNatural: return $sd.number + of naSharp: return "#" & $sd.number + of naDoubleSharp: return "𝄪" & $sd.number + + +func `$`*(k: Key): string = + result = $k.tonic + case k.mode + of Ionian: discard + of Dorian: result &= " Dorian" + of Phrygian: result &= " Phrygian" + of Lydian: result &= " Lydian" + of Mixolydian: result &= " Mixolydian" + of Aeolian: result &= " Minor" + of Locrian: result &= " Locrian" + + +func chromaticDistanceFromTonic*(degree: ScaleDegree): int = + if degree.number < 1 or degree.number > 7: + raise newException(ValueError, "Invalid scale degree: " & $degree.number) + + result = MajorIntervals[(degree.number - 1)] + ord(degree.alteration) + + +func toPitch*(n: Note): Pitch = + cast[Pitch]((4 + MajorIntervals[(ord(n) + 5) mod 7]) mod 12) + + +func toPitch*(sp: SpelledPitch): Pitch = + cast[Pitch]((ord(sp.note.toPitch) + ord(sp.alteration) + 12) mod 12) + + +func toNote*(p: Pitch): Note = + case p + of Af, A: Note.A + of Bf, B: Note.B + of C: Note.C + of Df, D: Note.D + of Ef, E: Note.E + of F: Note.F + of Gf, G: Note.D + + +func parseMode*(str: string): Mode = + case str.toLower.strip + of "ionian", "major", "": Ionian + of "dorian": Dorian + of "phrygian": Phrygian + of "lydian": Lydian + of "mixolydian": Mixolydian + of "aeolian", "minor": Aeolian + of "locrian": Locrian + else: raise newException(ValueError, "Unrecognized mode/scale: " & str) + + +func parseSpelledPitch*(str: string): SpelledPitch = + try: result.note = parseEnum[Note]($str[0]) + except: + raise newException(ValueError, str[0] & " is not a recognized pitch name.") + + result.alteration = + case str[1..^1] + of "𝄫", "bb", "♭♭": naDoubleFlat + of "b", "♭", "f", "es": naFlat + of "", "♮": naNatural + of "s", "is", "#": naSharp + of "𝄪", "##": naDoubleSharp + else: raise newException(ValueError, + str[1..^1] & " is not a recognized accidental.") + + +func parseScaleDegree*(str: string): ScaleDegree = + try: result.number = parseInt($str[0]) + except: + raise newException(ValueError, str[0] & " is not a valid scale degree.") + + result.alteration = + case str[0..^2] + of "𝄫", "bb", "♭♭": naDoubleFlat + of "b", "♭", "f", "es": naFlat + of "", "♮": naNatural + of "s", "is", "#": naSharp + of "𝄪", "##": naDoubleSharp + else: raise newException(ValueError, + str[0..^2] & " is not a recognized accidental.") + + +func ionianPitch*(key: Key, degreeNumber: int): Pitch = + cast[Pitch]((ord(key.tonic.toPitch) + MajorIntervals[degreeNumber - 1]) mod 12) + + +#[ TODO +func spellPitch*(key: Key, p: Pitch): SpelledPitch = + +]# + +func toSpelledPitch*(p: Pitch): SpelledPitch = + SpelledPitch( + note: p.toNote, + alteration: + case p + of Af, Bf, Df, Ef, Gf: naFlat + else: naNatural) + +func spellPitch*(key: Key, sd: ScaleDegree): SpelledPitch = + result.note = key.tonic.note + (sd.number - 1) + let resultingPitch = ord(ionianPitch(key, sd.number)) + ord(sd.alteration) + result.alteration = cast[NoteAlteration]( + resultingPitch - ord(result.note.toPitch)) + + +func toScaleDegree*(key: Key, sp: SpelledPitch): ScaleDegree = + result.number = sp.note - key.tonic.note + 1 + result.alteration = cast[NoteAlteration]( + ord(sp.toPitch) - ord(ionianPitch(key, result.number))) + + +func transpose*(k: Key, steps: int): Key = + Key( + tonic: toSpelledPitch(k.tonic.toPitch + steps), + mode: k.mode) + + +when isMainModule: + assert A + 1 == Bf + assert A + 12 == A + assert A - 1 == Af + assert A + 14 == B + + assert C - A == 3 + assert A - G == 2 + assert G - A == 10 + + assert Note.D - Note.B == 2 + assert Note.A - Note.C == 5 + assert Note.C - Note.A == 2 + + assert chromaticDistanceFromTonic(ScaleDegree(number: 1, alteration: naNatural)) == 0 + assert chromaticDistanceFromTonic(ScaleDegree(number: 5, alteration: naNatural)) == 7 + assert chromaticDistanceFromTonic(ScaleDegree(number: 5, alteration: naDoubleFlat)) == 5 + assert chromaticDistanceFromTonic(ScaleDegree(number: 7, alteration: naFlat)) == 10 + assert chromaticDistanceFromTonic(ScaleDegree(number: 3, alteration: naDoubleSharp)) == 6 + + assert $SpelledPitch(note: Note.B, alteration: naFlat) == "B♭" + + assert A == parseSpelledPitch("A").toPitch + assert B == parseSpelledPitch("B").toPitch + assert C == parseSpelledPitch("C").toPitch + assert D == parseSpelledPitch("D").toPitch + assert E == parseSpelledPitch("E").toPitch + assert F == parseSpelledPitch("F").toPitch + assert G == parseSpelledPitch("G").toPitch + + assert Af == parseSpelledPitch("Ab").toPitch + assert G == parseSpelledPitch("Abb").toPitch + assert B == parseSpelledPitch("Cb").toPitch + assert Df == parseSpelledPitch("B##").toPitch + + assert "3" == $toScaleDegree( + Key(tonic: parseSpelledPitch("B"), mode: Ionian), + parseSpelledPitch("D#")) + + assert "1" == $toScaleDegree( + Key(tonic: parseSpelledPitch("Gb"), mode: Ionian), + parseSpelledPitch("Gb")) + + assert parseSpelledPitch("Cb").toPitch == parseSpelledPitch("B").toPitch + + assert "♭6" == $toScaleDegree( + Key(tonic: parseSpelledPitch("Eb"), mode: Ionian), + parseSpelledPitch("Cb")) + + assert "#5" == $toScaleDegree( + Key(tonic: parseSpelledPitch("Eb"), mode: Ionian), + parseSpelledPitch("B"))