diff --git a/src/pco_chordspkg/notation.nim b/src/pco_chordspkg/notation.nim index 597d933..13d3665 100644 --- a/src/pco_chordspkg/notation.nim +++ b/src/pco_chordspkg/notation.nim @@ -1,19 +1,101 @@ import std/[strutils, unicode] +## This notational model is based on the 12-tone modal, harmonic system +## standard in Western music. A key concept from this is the key-agnostic +## naming system common to Jazz and Gospel music, an expansion of the +## "Nashville Number" system that refers to pitches by their scale degree and +## "alteration" (for lack of a better word). For example, "flat III," +## "natural/perfect V." +## +## In this model scale degree names are always counted from the tonic, +## regardless of mode. Alterations are based on how the note differs from the +## note at the same scale degree in the Ionian scale that shares the same +## tonic. We also make a distiction between the key-agnostic scale degree and +## alteration, and the key-specific spelling of the same. For example, consider +## the following scales with named notes and key-agnostic scale degrees: +## +## Key of C major +## +## C D E F G A B +## 1 2 3 4 5 6 7 +## +## Key of C minor (Aeolian) +## +## C D E♭ F G A♭ B♭ +## 1 2 ♭3 4 5 ♭6 ♭7 +## +## Key of A minor +## +## A B C D E F G +## 1 2 ♭3 4 5 ♭6 ♭7 +## +## Key of A major +## +## A B C# D E F# G# +## 1 2 3 4 5 6 7 +## +## Key of G♭ Locrian +## +## G♭ A𝄫 B𝄫 C♭ D𝄫 E𝄫 F♭ +## 1 ♭2 ♭3 4 ♭5 ♭6 ♭7 +## +## Key of G Ionian (major) +## +## G A B C D E F# +## 1 2 3 4 5 6 7 +## +## In these examples, C major and A minor are enharomonic (sharing the same +## pitches), spell all of their pitches the same, but have different flavors of +## scale degrees. G Ionian and Gb Locrian are also enharmonic, but have very +## different spellings of the pitches and flavors of scale degrees. Most people +## would use F# Locrian rather than Gb Locrian, because F# Locrian has the same +## pitch spellings as the familiari relative major of G Ionian. The key point +## for understanding this data model is that it allows the author to use either +## and will correctly transpose, using the correctly named pitches based on the +## mode and spelling choice for the key. +## +## This preservation of choices extends to non-diatonic notes as well. For +## example, in the key of C the notes Ab and G# are enharmonically equivalent, +## but functionally different. Ab is the flat VI, whereas G# is the +## augmented/sharp V. There are situations where an author may prefer either of +## these. For example, as a submediant in a walk-up progression, ## bVI-bVII-bI, +## writing it as Cb, the bVI emphasises the whole-tone pattern of the walk up, +## the relationship to VII. In a different progression, a IV-vdim-vi walk up, +## the same pitch might be thought of as a sharp V approaching the VI, +## emphasizing its role as a continuation of the V tension before the +## resolution to the v. Again, this data model allows us to preserve the +## notation of both Cbmaj7-Db6-Eb2 and Bb-Bdim-Cm7 in the key of Eb, notating +## them in the key of D as Bbmaj7-C6-D2 and A-A#dim-Bbm7 respectively. + + type Note* {.pure.} = enum A, B, C, D, E, F, G + ## Notes capture the principle in western harmony of the common scales + ## having seven distinct diatonic "notes" and allows us to do arithmetic + ## with them (go up three scale degrees). + Pitch* = enum Af, A, Bf, B, C, Df, D, Ef, E, F, Gf, G + ## 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. NoteAlteration* = enum naDoubleFlat = -2, naFlat, naNatural, naSharp, naDoubleSharp + ## The supported alterations to a Note or scale number. ScaleDegree* = object number: int alteration: NoteAlteration + ## A key-agnostic representation of a pitch, capturing the scale degree + ## relative to the tonic, and the alteration of that. So, for example: + ## b3 -> b (alteration) 3 (number) SpelledPitch* = object note: Note alteration: NoteAlteration + ## A unique spelling of one of the chromatic pitches allowing the stylistic + ## choice to use different *Notes* to describe a single *Pitch*. For + ## example, Cb, A𝄪, and B♮ are three distinct ways to spell Pitch.B Mode* = enum Ionian = 0, @@ -32,21 +114,32 @@ const MajorIntervals = [0, 2, 4, 5, 7, 9, 11] func `+`*[T: Pitch or Note](val: T, steps: int): T = + ## Move up or down the diatonic scale or the chromatic scale by the given + ## number of steps. cast[T]((ord(val) + steps) mod (ord(high(T)) + 1)) func `-`*[T: Pitch or Note](val: T, steps: int): T = + ## Move down the diatonic or chromatic scale by the given number of steps. var newOrd = (ord(val) - steps) mod (ord(high(T)) + 1) if newOrd < 0: newOrd += 12 return cast[T](newOrd) func `-`*(a, b: Note): int = + ## Find the distance between two notes of the diatonic scale. This always + ## returns a positive distance, as if `a` was higher in pitch than `b`. For + ## example, C - D returns +6 (the distance from D3 to C4) rather than -1 + ## (the distance from D4 to C4). result = ord(a) - ord(b) if result < 0: result += 7 func `-`*(a, b: Pitch): int = + ## Find the distance between two notes of the chromatic scale. This always + ## returns a positive distance, as if `a` was higher in pitch than `b`. For + ## example, C - D returns +10 (the distance from D3 to C4) rather than -2 + ## (the distance from D4 to C4). result = ord(a) - ord(b) if result < 0: result += 12 @@ -86,6 +179,9 @@ func `$`*(k: Key): string = func chromaticDistanceFromTonic*(degree: ScaleDegree): int = + ## Return the number of semitones from the tonic to the given scale degree. + ## This ignores modes and key signatures and speaks in terms of intervals + ## instead. if degree.number < 1 or degree.number > 7: raise newException(ValueError, "Invalid scale degree: " & $degree.number) @@ -93,14 +189,17 @@ func chromaticDistanceFromTonic*(degree: ScaleDegree): int = func toPitch*(n: Note): Pitch = + ## Return the chromatic pitch for the given natural note. cast[Pitch]((4 + MajorIntervals[(ord(n) + 5) mod 7]) mod 12) func toPitch*(sp: SpelledPitch): Pitch = + ## Return the chromatic pitch for a given spelled note. cast[Pitch]((ord(sp.note.toPitch) + ord(sp.alteration) + 12) mod 12) func toNote*(p: Pitch): Note = + ## Return the natural note for a given chromatic pitch. case p of Af, A: Note.A of Bf, B: Note.B @@ -111,6 +210,18 @@ func toNote*(p: Pitch): Note = of Gf, G: Note.D +func toSpelledPitch*(p: Pitch): SpelledPitch = + ## Get a SpelledPitch version of a chromatic Pitch. Note that this always + ## uses the simplest natural or flat spelling for the Pitch (Pitch.Bf is + ## always spelled as B♭, never C𝄫) + SpelledPitch( + note: p.toNote, + alteration: + case p + of Af, Bf, Df, Ef, Gf: naFlat + else: naNatural) + + func parseMode*(str: string): Mode = case str.toLower.strip of "ionian", "major", "": Ionian @@ -159,20 +270,10 @@ 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 = + ## Given a key and scale degree, spell it correctly in that key. For example, + ## the ♭7 in C major is spelled B♭, the ♭7 in key of Db Locrian is spelled + ## B𝄫, and the ♭7 in F# major is E. result.note = key.tonic.note + (sd.number - 1) let resultingPitch = ord(ionianPitch(key, sd.number)) + ord(sd.alteration) result.alteration = cast[NoteAlteration]( @@ -180,6 +281,9 @@ func spellPitch*(key: Key, sd: ScaleDegree): SpelledPitch = func toScaleDegree*(key: Key, sp: SpelledPitch): ScaleDegree = + ## Determine the ScaleDegree of a pitch according to how it is spelled and + ## the key it is in. For example, Pitch.B will be the ♮2 in the key of A + ## major, the ♭2 in the key of A# major, or the #1 in the key of B♭ major. result.number = sp.note - key.tonic.note + 1 result.alteration = cast[NoteAlteration]( ord(sp.toPitch) - ord(ionianPitch(key, result.number)))