Files
pco_chords/src/pco_chordspkg/ast.nim
Jonathan Bernard 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

557 lines
18 KiB
Nim

import std/[nre, strtabs, strutils]
import zero_functional
type
ChordChartMetadata* = object
title*: string
key*: ChordChartChord
optionalProps: StringTableRef
ChordChart* = ref object
rawContent*: string
metadata*: ChordChartMetadata
nodes*: seq[ChordChartNode]
ChordChartNodeKind* = enum
ccnkSection,
ccnkLine,
ccnkWord,
ccnkNote,
ccnkColBreak,
ccnkPageBreak,
ccnkTransposeKey,
ccnkRedefineKey,
ccnkNone
ChordChartPitch* = enum Af, A, Bf, B, C, Df, D, Ef, E, F, Gf, G
ChordChartChord* = object
original*: Option[string]
rootPitch*: ChordChartPitch
flavor*: Option[string]
bassPitch*: Option[ChordChartPitch]
ChordChartNode* = ref object
case kind: ChordChartNodeKind
of ccnkSection:
sectionName*: string
remainingSectionLine*: Option[string]
sectionContents*: seq[ChordChartNode]
of ccnkLine:
line*: seq[ChordChartNode]
of ccnkWord:
spaceBefore*: bool
spaceAfter*: bool
chord*: Option[ChordChartChord]
word*: Option[string]
of ccnkNote:
note*: string
inclInLyrics*: bool
of ccnkColBreak: discard
of ccnkPageBreak: discard
of ccnkTransposeKey:
transposeSteps*: int
of ccnkRedefineKey:
newKey*: ChordChartPitch
of ccnkNone: discard
ParserContext = ref object
lines: seq[string]
curKeyCenter: ChordChartPitch
curLineNum: int
curSection: ChordChartNode
unparsedLineParts: seq[string]
let EMPTY_CHORD_CHART_NODE* = ChordChartNode(kind: ccnkNone)
func `[]`*(ccmd: ChordChartMetadata, key: string): string =
ccmd.optionalProps[key]
func contains*(ccmd: ChordChartMetadata, key: string): bool =
return key == "title" or key == "key" or ccmd.optionalProps.contains(key)
iterator pairs*(ccmd: ChordChartMetadata): tuple[key, value: string] =
yield ("title", ccmd.title)
yield ("key", $ccmd.key)
for p in ccmd.optionalProps.pairs: yield p
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")
func dump*(ccn: ChordChartNode, indent = ""): string =
case ccn.kind
of ccnkSection:
let formattedChildren = ccn.sectionContents --> map(dump(it, indent & " "))
result = indent & "Section -- " & ccn.sectionName & "\p" &
formattedChildren.join("\p")
of ccnkColBreak: result = indent & "Column Break"
of ccnkPageBreak: result = indent & "Page Break"
of ccnkTransposeKey:
result = indent & "Transpose key by " & $ccn.transposeSteps
of ccnkRedefineKey:
result = indent & "Redefine key to " & $ccn.newKey
of ccnkLine:
result = indent & "Line "
for child in ccn.line:
let formattedChild = dump(child, indent)
if child.kind == ccnkWord: result &= formattedChild
else: result &= formattedChild & "\p"
of ccnkWord:
result = ""
if ccn.spaceBefore: result &= " "
if ccn.chord.isSome:
let chord = ccn.chord.get
result &= "["
if chord.original.isSome and chord.flavor.isNone and
chord.bassPitch.isNone:
result &= chord.original.get
else:
result &= $chord.rootPitch
if chord.flavor.isSome:
result &= "_" & chord.flavor.get
if chord.bassPitch.isSome:
result &= "/" & $chord.bassPitch.get
result &= "]"
if ccn.word.isSome: result &= ccn.word.get
if ccn.spaceAfter: result &= " "
of ccnkNote:
result = indent & "Note "
if not ccn.inclInLyrics: result &= "(chords only) "
result &= ccn.note
of ccnkNone:
result &= indent & "NONE"
func `$`*(cc: ChordChart): string =
result = "ChordChart\p" &
dump(cc.metadata, " ") & "\p" &
join(cc.nodes --> map(dump(it, " ")), "\p")
# -----------------------------------------------------------------------------
# PARSER
# -----------------------------------------------------------------------------
func hasNextLine(ctx: ParserContext): bool =
ctx.unparsedLineParts.len > 1 or
ctx.curLineNum + 1 < ctx.lines.len
func nextLine(ctx: var ParserContext): string =
if ctx.unparsedLineParts.len > 0:
result = ctx.unparsedLineParts[0]
ctx.unparsedLineParts.delete(0)
else:
ctx.curLineNum += 1
result = ctx.lines[ctx.curLineNum]
func pushPartialsToParse(ctx: var ParserContext, parts: varargs[string]): void =
ctx.unparsedLineParts &= parts
template makeError(ctx: ParserContext, msg: string): untyped =
newException(ValueError,
"error parsing input at line " & $ctx.curLineNum & ":\p\t" & msg)
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
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
"(\\/([b#♭♮𝄫𝄪]?[A-G1-7][b#♭♮𝄫𝄪]?))?" # optional bass
let CHORD_PAT = re(CHORD_REGEX)
proc parseChord*(
ctx: ParserContext, chordValue: string
): Option[ChordChartChord] =
let m = chordValue.match(CHORD_PAT)
if m.isNone: none[ChordChartChord]()
else:
let flavor =
if m.get.captures.contains(1) and m.get.captures[1].len > 0:
some(m.get.captures[1])
else: none[string]()
let bassPitch =
if m.get.captures.contains(4) and m.get.captures[4].len > 0:
some(ctx.parsePitch(m.get.captures[4]))
else: none[ChordChartPitch]()
return some(ChordChartChord(
original: some(chordValue),
rootPitch: ctx.parsePitch(m.get.captures[0]),
flavor: flavor,
bassPitch: bassPitch))
let METADATA_LINE_PAT = re"^([^:]+):(.*)$"
let METADATA_END_PAT = re"^-+$"
proc parseMetadata(ctx: var ParserContext): ChordChartMetadata =
var title = "MISSING"
var optProps = newStringTable()
var songKey: Option[ChordChartChord]
while ctx.curLineNum < ctx.lines.len:
let line = ctx.nextLine
if line.match(METADATA_END_PAT).isSome:
break
let m = line.match(METADATA_LINE_PAT)
if m.isNone:
raise ctx.makeError("expected metadata property or ending marker")
let key = m.get.captures[0].strip.tolower
let value = m.get.captures[1].strip
if key == "title": title = value
elif key == "key":
songKey = ctx.parseChord(value)
if songKey.isNone:
raise ctx.makeError("unrecognized key: " & value)
else: optProps[key] = value
if title == "MISSING":
raise ctx.makeError("metadata is missing the 'title' property")
if songKey.isNone:
raise ctx.makeError("metadata is missing the 'key' property")
result = ChordChartMetadata(
title: title,
key: songKey.get,
optionalProps: optProps)
const KNOWN_SECTION_NAMES = [
"chorus", "verse", "bridge", "breakdown", "vamp", "intstrumental",
"interlude", "intro", "outtro", "ending", "end", "tag", "prechorus",
"pre-chorus", "pre chorus"
]
let SECTION_LINE_PAT = re(
"^((" & "((?i)" & # case insensitive
KNOWN_SECTION_NAMES.join("|") & ")" & # known names
"|[[:upper:]]{3,}" & # all upper-case words
") ?\\d*)" & # numeric suffix (Verse 2)
# Allow notes or other text to follow on the same line
"(.*)$"
)
let COL_BREAK_PAT = re"\s*COLUMN_BREAK\s*$"
let PAGE_BREAK_PAT = re"\s*PAGE_BREAK\s*$"
let TRANSPOSE_PAT = re"\s*TRANSPOSE KEY ([+-]\d+)\s*$"
let REDEFINE_KEY_PAT = re"\s*REDEFINE KEY ([+-]\d+)\s*$"
#let NOTE_PAT = re"^(.*)({{[^}]+}}|{[^}]+})(.*)$"
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 BRACED_CHORD_PAT = re"^\[([^\]]+)\]$"
let NAKED_CHORDS_ONLY_PAT = re("^\\s*(" & CHORD_REGEX & "\\s*\\|*\\s*)+$")
proc readNote(ctx: var ParserContext, firstLine: string): tuple[pre, note, post: string] =
let startLineNum = ctx.curLineNum
result = ("", "", "")
let startMatch = firstLine.find(NOTE_START_PAT)
var endMatch = firstLine.find(NOTE_END_PAT)
if startMatch.isNone: return
if startMatch.get.matchBounds.a > 0:
result.pre = firstLine[0..<startMatch.get.matchBounds.a]
if endMatch.isSome:
result.note = firstLine[(startMatch.get.matchBounds.b + 1)..<endMatch.get.matchBounds.a]
result.post = firstLine[(endMatch.get.matchBounds.b + 1)..^1]
else:
# if we don't find the end of the note, then we need to read more lines
# until we do
result.note = firstLine[(startMatch.get.matchBounds.b + 1)..^1]
while ctx.lines.len > ctx.curLineNum and ctx.hasNextLine:
let line = ctx.nextLine
endMatch = line.find(NOTE_END_PAT)
if endMatch.isSome:
result.note &= line[0..<endMatch.get.matchBounds.a]
result.post = line[(endMatch.get.matchBounds.b + 1)..^1]
return
else: result.note &= line
raise ctx.makeError("a note section was started on line " &
$startLineNum & " and never closed")
proc parseLineParts(ctx: ParserContext, parts: varargs[string]): seq[ChordChartNode] =
result = @[]
for p in parts:
var m = p.match(SPACE_PAT)
if m.isSome:
result &= ctx.parseLineParts(p.splitWhitespace)
continue
m = p.match(CHORD_IN_LYRICS_PAT)
if m.isSome:
result &= ctx.parseLineParts(m.get.captures[0], m.get.captures[1])
continue
m = p.match(CHORD_AND_LYRICS_PAT)
if m.isSome:
result.add(ChordChartNode(
kind: ccnkWord,
spaceAfter: true, #FIXME
spaceBefore: true, #FIXME
chord: ctx.parseChord(m.get.captures[0]),
word: some(m.get.captures[1])))
result &= ctx.parseLineParts(m.get.captures[2])
continue
m = p.match(BRACED_CHORD_PAT)
if m.isSome:
result.add(ChordChartNode(
kind: ccnkWord,
spaceAfter: false, #FIXME
spaceBefore: false, #FIXME
chord: ctx.parseChord(m.get.captures[0]),
word: none[string]()))
continue
if p.len > 0:
result.add(ChordChartNode(
kind: ccnkWord,
spaceAfter: true, #FIXME
spaceBefore: true, #FIXME
chord: none[ChordChartChord](),
word: some(p)))
proc parseLine(lentCtx: var ParserContext, line: string): ChordChartNode =
let ctx = lentCtx
result = ChordChartNode(kind: ccnkLine)
var m = line.match(NAKED_CHORDS_ONLY_PAT)
if m.isSome:
result.line = line.splitWhitespace --> map(
ChordChartNode(
kind: ccnkWord,
spaceAfter: false, #FIXME
spaceBefore: false, #FIXME
chord: ctx.parseChord(it.strip),
word: none[string]()))
return
m = line.match(NOTE_START_PAT)
if m.isSome:
let (pre, note, post) = lentCtx.readNote(line)
result.line = ctx.parseLineParts(pre) &
@[ChordChartNode(
kind: ccnkNote,
inclInLyrics: true,
note: note)] &
ctx.parseLineParts(post)
return
else: result.line = ctx.parseLineParts(line.splitWhitespace)
proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
result = @[]
while ctx.hasNextLine:
var line = ctx.nextLine
var m = line.match(TRANSPOSE_PAT)
if m.isSome:
addToCurSection(ChordChartNode(
kind: ccnkTransposeKey,
transposeSteps: parseInt(m.get.captures[0])))
continue
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)))
continue
m = line.match(COL_BREAK_PAT)
if m.isSome:
result.add(ChordChartNode(kind: ccnkColBreak))
continue
m = line.match(PAGE_BREAK_PAT)
if m.isSome:
result.add(ChordChartNode(kind: ccnkPageBreak))
ctx.curSection = EMPTY_CHORD_CHART_NODE
continue
m = line.match(SECTION_LINE_PAT)
if m.isSome:
let captures = m.get.captures.toSeq
ctx.curSection = ChordChartNode(
kind: ccnkSection,
sectionName:
if captures[0].isSome: captures[0].get.strip
else: raise ctx.makeError("unknown error parsing section header: " & line),
remainingSectionLine:
if captures[3].isSome: some(captures[3].get.strip)
else: none[string](),
sectionContents: @[])
result.add(ctx.curSection)
continue
else:
addToCurSection(ctx.parseLine(line))
continue
proc parseChordChart*(s: string): ChordChart =
var parserCtx = ParserContext(
lines: s.splitLines,
curLineNum: -1,
curSection: EMPTY_CHORD_CHART_NODE,
unparsedLineParts: @[])
let metadata = parseMetadata(parserCtx)
parserCtx.curKeyCenter = metadata.key.rootPitch
result = ChordChart(
rawContent: s,
metadata: metadata,
nodes: parseBody(parserCtx))