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.
This commit is contained in:
245
src/pco_chordspkg/notation.nim
Normal file
245
src/pco_chordspkg/notation.nim
Normal 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"))
|
Reference in New Issue
Block a user