3 Commits

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
533089431e Use better heuristics for chord pitch naming.
Previously we used a heuristic for choosing pitch names based on the
major scale degree, minimizing radicals. So, for example, in the key of
Gb, we render the 4 as Cb rather than B because Bb is the 3 of Gb. In
other words, we want 1:Gb, 2:Ab, 3:Bb, 4:Cb instead of re-using B. This
is standard practice in western music notation.

When rendering non-diatonic notes we prefered choosing the version that
minimized the radicals. Again in the key of Gb we would choose to render
E as E (the #6) rather than considering it as Fb (the b7) and choose to
render D as D (the #5) rather than E𝄫 (the b6). This was chosen to
reduce the number of unusual radicals like 𝄫.

However, in practice this leads to unusual charts because it is more
common when writing chordal harmony to use the b6 rather than the #5.
Similarly the b7 is far more common than the #6. This is, I think, due
to the prevalence of the major scale and minor scales and the fact that
the minor scale is built from flatting the 3, 6, and 7 of the major
scale. So when thinking in a key-center agnostic manner (like numbers)
we almost always think about these altered scale degrees as being
flatted relative to the major scale, not sharped. Because of this, in
the key of Gb, we would prefer to render a b6, b7, 1 chord walkup as
E𝄫, Fb, Gb rather than D, E, Gb.

This change redefines the heuristic used to name chord pitches to follow
a heuristic that covers all pitches in the octave based on scale degree:
1, b2, 2, b3, 3, 4, b5, 5, b6, 6, b7, 7
2025-06-02 10:59:52 -05:00
d7dd509138 Tweak styling to make the lead sheet more compact. 2025-06-01 07:28:42 -05:00
6 changed files with 305 additions and 186 deletions

View File

@ -1,6 +1,6 @@
# Package
version = "0.5.5"
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,68 +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:
scaleNames = case key
of A, B, D, E, G:
["G#", "A", "A#", "B", "C", "C#", "D", "D#", "E", "F", "F#", "G"]
of Af, Bf, C, Df, Ef, F:
["A♭", "A", "B♭", "B", "C", "D♭", "D", "E♭", "E", "F", "G♭", "G"]
of Gf:
["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.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")
@ -174,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
@ -227,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
@ -264,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)
@ -275,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"^-+$"
@ -292,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
@ -308,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
@ -348,7 +262,7 @@ let NOTE_START_PAT = re"\{\{"
let NOTE_END_PAT = re"\}\}"
let SPACE_PAT = re"\s"
let CHORD_IN_LYRICS_PAT = re"(\w+)(\[.+)"
let CHORD_AND_LYRICS_PAT = re"^\[([^\]]+)\]([^\s\[]+)(.*)$"
let CHORD_AND_LYRICS_PAT = re"^\[([^\]]*)\]([^\s\[]+)(.*)$"
let BRACED_CHORD_PAT = re"^\[([^\]]+)\]$"
let NAKED_CHORDS_ONLY_PAT = re("^\\s*(" & CHORD_REGEX & "\\s*\\|*\\s*)+$")
@ -470,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)
@ -519,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.5"
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
@ -22,6 +22,7 @@ const DEFAULT_STYLESHEET* = """
html { font-family: sans-serif; }
.page-contents { line-height: 1.1; }
.one-column .page-contents { column-count: 1; }
.two-column .page-contents { column-count: 2; }
@ -36,7 +37,7 @@ section {
h3 {
font-size: 1.125rem;
margin-bottom: 0.5em;
margin-bottom: 0.125em;
text-decoration: underline;
}
@ -69,6 +70,7 @@ h3 .section-text {
.chord {
font-weight: 600;
margin-bottom: -0.25em;
margin-right: 0.5em;
white-space: nowrap;
}
@ -78,7 +80,7 @@ h3 .section-text {
height: 1.2em;
}
.chord .flavor {
.number-chart .chord .flavor {
font-variant-position: super;
}
@ -173,7 +175,7 @@ h3 .section-text {
height: 1.2em;
}
.chord .flavor {
.number-chart .chord .flavor {
font-variant-position: super;
}
@ -195,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:
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" &
@ -296,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
@ -315,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>"
@ -340,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)
@ -366,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"))