1 Commits
0.5.6 ... 0.6.0

Author SHA1 Message Date
25d7ddcd1b Major refactor of internal note storage to better support transposition.
Refactor to store notes as key-agnostic scale degrees + alterations
(flat, sharp, etc.).

We now think of a pitch in different ways:
- *Note*: the 7 notes diatonic to C major. Notes capture the principle
  in western harmony of the common scales having seven distinct diatonic
  "notes" and allows us to do arithmetic with them (do up three scale
  degrees).
- *Pitch*: the 12 chromatic pitches, regardless key. Pitch allows us to
  assign unique names and ordinal values to each note of the chromatic
  scale, allowing us to do arithmetic with them.
- *SpelledPitch*: a unique spelling of one of the chromatic pitches
  (*Note* + alteration) allowing the stylistic choice to use
  different *Notes* to describe a single *Pitch*.
- *ScaleDegree*: a variant of *SpelledPitch* that uses the scale degree
  instead of the *Pitch* to store a pitch in a key-agnostic manner.

To illustrate, the difference, consider the flat-six in the key of Eb.
- *Note*: The 6th scale degree in Eb is C (1-E 2-F 3-G 4-A 5-B 6-C).
- *Pitch*: In the chromatic scale, ignoring the key, this is the *Pitch*
  called B.
- *SpelledPitch*: In the context of the key of Eb, because this is the
  *Note* C, we should spell this as Cb, not B. So the spelled pitch is
  *Note*(C), *Alteration*(flat).
- *ScaleDegree*: This captures the key-agnostic representation that we
  used in the begining: *Number*(6) and *Alteration*(flat)

With these four ways of representing a note, we can transpose any
pitches that follow western 12-tone harmony arbitrarily between keys
preserving the author's choice of chord function (remembering that this
is the b6 and not the #5, in our example).

Building on this new notational data model, the AST now uses the
*ScaleDegree* relative to the provided key as the internal
representation of a pitch. Formatting of a *ScaleDegree* always requires
the key in which it is being rendered. Transposition is now only a
matter or updating the current key.
2025-06-02 23:07:41 -05:00
6 changed files with 301 additions and 213 deletions

View File

@ -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"

View File

@ -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,

View File

@ -1,4 +1,4 @@
const PCO_CHORDS_VERSION* = "0.5.6"
const PCO_CHORDS_VERSION* = "0.6.0"
const USAGE* = """Usage:
pco_chords [options]

View File

@ -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 {
</style>
"""
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 = "<span class=root>" &
ctx.currentKey.scaleDegree(chord.rootPitch) & "</span>"
result = "<span class=root>"
if useNumber: result &= $chord.root
else: result &= $ctx.currentKey.spellPitch(chord.root)
result &= "</span>"
if chord.flavor.isSome:
result &= "<span class=flavor>" & chord.flavor.get & "</span>"
if chord.bassPitch.isSome:
result &= "<span class=bass>/" &
ctx.currentKey.scaleDegree(chord.bassPitch.get) & "</span>"
if chord.bass.isSome:
result &= "<span class=bass>/"
if useNumber: result &= $chord.bass.get
else: result &= $ctx.currentKey.spellPitch(chord.bass.get)
result &= "</span>"
else:
result = "<span class=root>" &
renderPitchInKey(chord.rootPitch, ctx.currentKey) & "</span>"
if chord.flavor.isSome:
result &= "<span class=flavor>" & chord.flavor.get & "</span>"
if chord.bassPitch.isSome:
result &= "<span class=bass>/" &
renderPitchInKey(chord.bassPitch.get, ctx.currentKey) & "</span>"
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 & "<section class=song-order>\p" &
@ -298,7 +259,7 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
if node.chord.isSome:
result &= "<span class=chord>" &
ctx.format(ctx.transpose(node.chord.get), ctx.numberChart) & "</span>"
ctx.format(node.chord.get, ctx.numberChart) & "</span>"
result &= "<span class=lyric>"
if node.word.isSome: result &= node.word.get
@ -317,13 +278,13 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
result &= "</div><div class=page-contents>"
of ccnkTransposeKey:
ctx.currentKey = ctx.currentKey + node.transposeSteps
ctx.currentKey = ctx.currentKey.transpose(node.transposeSteps)
result &= indent & "<h4 class=note>Key Change: " & $ctx.currentKey & "</h4>"
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 & "<h4 class=note>Key Change: " & $ctx.currentKey & "</h4>"
@ -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 &= " </head>\p"
var bodyClasses = newSeq[string]()
if numberChart: bodyClasses.add("number-chart")
if cc.metadata.contains("columns") and cc.metadata["columns"] == "1":
result &= " <body class='one-column'>"
else:
result &= " <body class='two-column'>"
bodyClasses.add("one-column")
else: bodyClasses.add("two-column")
result &= " <body class='" & bodyClasses.join(" ") & "'>"
var indent = " "
# Title
result &= indent & "<h1>" & cc.metadata.title & "</h1>\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"):

BIN
src/pco_chordspkg/notation Executable file

Binary file not shown.

View File

@ -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"))