8 Commits
0.5.1 ... main

Author SHA1 Message Date
97ef8a7491 Fix parsing of G#. Tweak large print stylesheet. 2025-06-03 01:13:11 -05:00
9741c55cee Additional documentation about the new notation data model. 2025-06-03 00:34:58 -05:00
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
73bdb97881 Support one- and two-column layouts. Transposing keys doesn't start a new section. 2025-04-26 11:40:55 -05:00
a57aed0715 Fix chord parsing, formatting. 2025-04-11 15:47:53 -05:00
2712f9ff52 Key of C should use flats for accidentals. 2024-03-21 18:51:48 -05:00
7 changed files with 622 additions and 238 deletions

View File

@ -1,6 +1,6 @@
# Package # Package
version = "0.5.1" version = "0.6.1"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Chord chart formatter compatible with Planning Center Online" description = "Chord chart formatter compatible with Planning Center Online"
license = "MIT" license = "MIT"
@ -12,3 +12,9 @@ bin = @["pco_chords"]
# Dependencies # Dependencies
requires "nim >= 1.6.6", "docopt", "zero_functional" requires "nim >= 1.6.6", "docopt", "zero_functional"
# Dependencies from git.jdbernard.com/jdb/nim-package
requires "update_nim_package_version"
task updateVersion, "Update the version of this tool.":
exec "update_nim_package_version pco_chords 'src/pco_chordspkg/cliconstants.nim'"

View File

@ -38,7 +38,12 @@ when isMainModule:
if args["--transpose"]: parseInt($args["--transpose"]) if args["--transpose"]: parseInt($args["--transpose"])
else: 0 else: 0
let outputHtml = ast.toHtml(transpose, args["--number-chart"]) let outputHtml =
if args["--large-print"]:
ast.toHtml(transpose, args["--number-chart"],
stylesheets = @[LARGE_PRINT_STYLESHEET])
else:
ast.toHtml(transpose, args["--number-chart"])
if args["--output"]: writeFile($args["--output"], outputHtml) if args["--output"]: writeFile($args["--output"], outputHtml)
else: stdout.write(outputHtml) else: stdout.write(outputHtml)

View File

@ -1,10 +1,11 @@
import std/[nre, strtabs, strutils] import std/[nre, strtabs, strutils]
import ./notation
import zero_functional import zero_functional
type type
ChordChartMetadata* = object ChordChartMetadata* = object
title*: string title*: string
key*: ChordChartChord key*: Key
optionalProps: StringTableRef optionalProps: StringTableRef
ChordChart* = ref object ChordChart* = ref object
@ -23,13 +24,11 @@ type
ccnkRedefineKey, ccnkRedefineKey,
ccnkNone ccnkNone
ChordChartPitch* = enum Af, A, Bf, B, C, Df, D, Ef, E, F, Gf, G
ChordChartChord* = object ChordChartChord* = object
original*: Option[string] original*: Option[string]
rootPitch*: ChordChartPitch root*: ScaleDegree
flavor*: Option[string] flavor*: Option[string]
bassPitch*: Option[ChordChartPitch] bass*: Option[ScaleDegree]
ChordChartNode* = ref object ChordChartNode* = ref object
case kind: ChordChartNodeKind case kind: ChordChartNodeKind
@ -52,12 +51,12 @@ type
of ccnkTransposeKey: of ccnkTransposeKey:
transposeSteps*: int transposeSteps*: int
of ccnkRedefineKey: of ccnkRedefineKey:
newKey*: ChordChartPitch newKey*: Key
of ccnkNone: discard of ccnkNone: discard
ParserContext = ref object ParserContext = ref object
lines: seq[string] lines: seq[string]
curKeyCenter: ChordChartPitch curKey: Key
curLineNum: int curLineNum: int
curSection: ChordChartNode curSection: ChordChartNode
unparsedLineParts: seq[string] unparsedLineParts: seq[string]
@ -77,68 +76,6 @@ iterator pairs*(ccmd: ChordChartMetadata): tuple[key, value: string] =
func kind*(ccn: ChordChartNode): ChordChartNodeKind = ccn.kind 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, C, D, E, G:
["G#", "A", "A#", "B", "C", "C#", "D", "D#", "E", "F", "F#", "G"]
of Af, Bf, 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 = func dump*(m: ChordChartMetadata, indent = ""): string =
return indent & "Metadata\p" & join(m.pairs --> map(indent & " " & it.key & ": " & it.value), "\p") 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 let chord = ccn.chord.get
result &= "[" result &= "["
if chord.original.isSome and chord.flavor.isNone and if chord.original.isSome and chord.flavor.isNone and
chord.bassPitch.isNone: chord.bass.isNone:
result &= chord.original.get result &= chord.original.get
else: else:
result &= $chord.rootPitch result &= $chord.root
if chord.flavor.isSome: if chord.flavor.isSome:
result &= "_" & chord.flavor.get result &= "_" & chord.flavor.get
if chord.bassPitch.isSome: if chord.bass.isSome:
result &= "/" & $chord.bassPitch.get result &= "/" & $chord.bass.get
result &= "]" result &= "]"
if ccn.word.isSome: result &= ccn.word.get if ccn.word.isSome: result &= ccn.word.get
@ -227,44 +164,16 @@ template addToCurSection(n: ChordChartNode): untyped =
if ctx.curSection.kind == ccnkSection: ctx.curSection.sectionContents.add(n) if ctx.curSection.kind == ccnkSection: ctx.curSection.sectionContents.add(n)
else: result.add(n) else: result.add(n)
proc parsePitch*(ctx: ParserContext, keyValue: string): ChordChartPitch = # see https://regexr.com/8f4ru
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 = let CHORD_REGEX =
"([b#♭♮𝄫𝄪]?[A-G1-7][b#♭♮𝄫𝄪]?)" & # chord root "([b#♭♮𝄫𝄪]?[A-G1-7][b#♭♮𝄫𝄪]?)" & # chord root
"(([mM1-9#b♭♮𝄫𝄪Δ+oøoø][0-9]?|min|maj|aug|dim|sus|6\\/9|\\([1-9#b♭]+\\))*)" & # chord flavor/type "((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 "(\\/([b#♭♮𝄫𝄪]?[A-G1-7][b#♭♮𝄫𝄪]?))?" # optional bass
let CHORD_PAT = re(CHORD_REGEX) let CHORD_PAT = re(CHORD_REGEX)
proc parseChord*( proc parseChord*(
ctx: ParserContext, chordValue: string ctx: ParserContext,
chordValue: string
): Option[ChordChartChord] = ): Option[ChordChartChord] =
let m = chordValue.match(CHORD_PAT) let m = chordValue.match(CHORD_PAT)
@ -275,16 +184,16 @@ proc parseChord*(
some(m.get.captures[1]) some(m.get.captures[1])
else: none[string]() else: none[string]()
let bassPitch = let bass =
if m.get.captures.contains(4) and m.get.captures[4].len > 0: if m.get.captures.contains(4) and m.get.captures[4].len > 0:
some(ctx.parsePitch(m.get.captures[4])) some(toScaleDegree(ctx.curKey, parseSpelledPitch(m.get.captures[4])))
else: none[ChordChartPitch]() else: none[ScaleDegree]()
return some(ChordChartChord( return some(ChordChartChord(
original: some(chordValue), original: some(chordValue),
rootPitch: ctx.parsePitch(m.get.captures[0]), root: toScaleDegree(ctx.curKey, parseSpelledPitch(m.get.captures[0])),
flavor: flavor, flavor: flavor,
bassPitch: bassPitch)) bass: bass))
let METADATA_LINE_PAT = re"^([^:]+):(.*)$" let METADATA_LINE_PAT = re"^([^:]+):(.*)$"
let METADATA_END_PAT = re"^-+$" let METADATA_END_PAT = re"^-+$"
@ -292,7 +201,7 @@ proc parseMetadata(ctx: var ParserContext): ChordChartMetadata =
var title = "MISSING" var title = "MISSING"
var optProps = newStringTable() var optProps = newStringTable()
var songKey: Option[ChordChartChord] var songKey: Option[Key]
while ctx.curLineNum < ctx.lines.len: while ctx.curLineNum < ctx.lines.len:
let line = ctx.nextLine let line = ctx.nextLine
@ -308,7 +217,12 @@ proc parseMetadata(ctx: var ParserContext): ChordChartMetadata =
let value = m.get.captures[1].strip let value = m.get.captures[1].strip
if key == "title": title = value if key == "title": title = value
elif key == "key": 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: if songKey.isNone:
raise ctx.makeError("unrecognized key: " & value) raise ctx.makeError("unrecognized key: " & value)
else: optProps[key] = value else: optProps[key] = value
@ -344,13 +258,46 @@ let PAGE_BREAK_PAT = re"\s*PAGE_BREAK\s*$"
let TRANSPOSE_PAT = re"\s*TRANSPOSE KEY ([+-]\d+)\s*$" let TRANSPOSE_PAT = re"\s*TRANSPOSE KEY ([+-]\d+)\s*$"
let REDEFINE_KEY_PAT = re"\s*REDEFINE KEY ([+-]\d+)\s*$" let REDEFINE_KEY_PAT = re"\s*REDEFINE KEY ([+-]\d+)\s*$"
#let NOTE_PAT = re"^(.*)({{[^}]+}}|{[^}]+})(.*)$" #let NOTE_PAT = re"^(.*)({{[^}]+}}|{[^}]+})(.*)$"
let NOTE_START_PAT = re"{{" let NOTE_START_PAT = re"\{\{"
let NOTE_END_PAT = re"}}" let NOTE_END_PAT = re"\}\}"
let SPACE_PAT = re"\s" let SPACE_PAT = re"\s"
let CHORD_IN_LYRICS_PAT = re"(\w+)(\[.+)" 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 BRACED_CHORD_PAT = re"^\[([^\]]+)\]$"
let NAKED_CHORDS_ONLY_PAT = re("^(" & CHORD_REGEX & "\\s*\\|*\\s*)+$") 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] = proc parseLineParts(ctx: ParserContext, parts: varargs[string]): seq[ChordChartNode] =
result = @[] result = @[]
@ -394,10 +341,11 @@ proc parseLineParts(ctx: ParserContext, parts: varargs[string]): seq[ChordChartN
chord: none[ChordChartChord](), chord: none[ChordChartChord](),
word: some(p))) word: some(p)))
proc parseLine(ctx: ParserContext, line: string): ChordChartNode = proc parseLine(lentCtx: var ParserContext, line: string): ChordChartNode =
let ctx = lentCtx
result = ChordChartNode(kind: ccnkLine) result = ChordChartNode(kind: ccnkLine)
let m = line.match(NAKED_CHORDS_ONLY_PAT) var m = line.match(NAKED_CHORDS_ONLY_PAT)
if m.isSome: if m.isSome:
result.line = line.splitWhitespace --> map( result.line = line.splitWhitespace --> map(
ChordChartNode( ChordChartNode(
@ -406,49 +354,28 @@ proc parseLine(ctx: ParserContext, line: string): ChordChartNode =
spaceBefore: false, #FIXME spaceBefore: false, #FIXME
chord: ctx.parseChord(it.strip), chord: ctx.parseChord(it.strip),
word: none[string]())) 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) else: result.line = ctx.parseLineParts(line.splitWhitespace)
proc readNote(ctx: var ParserContext, endPat: Regex): string =
let startLineNum = ctx.curLineNum
result = ""
while ctx.lines.len > ctx.curLineNum and ctx.hasNextLine:
let line = ctx.nextLine
let m = line.find(endPat)
if m.isSome:
result &= line[0..<m.get.matchBounds.a]
ctx.pushPartialsToParse(line[m.get.matchBounds.b..^1])
break
else: result &= line
if not ctx.hasNextLine:
raise ctx.makeError("a note section was started on line " &
$startLineNum & " and never closed")
proc parseBody(ctx: var ParserContext): seq[ChordChartNode] = proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
result = @[] result = @[]
while ctx.hasNextLine: while ctx.hasNextLine:
var line = ctx.nextLine var line = ctx.nextLine
var m = line.find(NOTE_START_PAT) var m = line.match(TRANSPOSE_PAT)
if m.isSome:
if m.get.matchBounds.a > 0:
# if this is not the first character of the line then let's split the
# line and continue to parse
ctx.pushPartialsToParse(
line[0..<m.get.matchBounds.a],
line[m.get.matchBounds.a..^1])
continue
else:
# if this is the first character of the line, then let's parse the note
result.add(ChordChartNode(
kind: ccnkNote,
inclInLyrics: m.get.match.len < 2,
note: ctx.readNote(NOTE_END_PAT)))
m = line.match(TRANSPOSE_PAT)
if m.isSome: if m.isSome:
addToCurSection(ChordChartNode( addToCurSection(ChordChartNode(
kind: ccnkTransposeKey, kind: ccnkTransposeKey,
@ -457,14 +384,9 @@ proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
m = line.match(REDEFINE_KEY_PAT) m = line.match(REDEFINE_KEY_PAT)
if m.isSome: if m.isSome:
let newKeyInt = (
ord(ctx.curKeyCenter) +
parseInt(m.get.captures[0])
) mod 12
addToCurSection(ChordChartNode( addToCurSection(ChordChartNode(
kind: ccnkRedefineKey, kind: ccnkRedefineKey,
newKey: cast[ChordChartPitch](newKeyInt))) newKey: ctx.curKey.transpose(parseInt(m.get.captures[0]))))
continue continue
m = line.match(COL_BREAK_PAT) m = line.match(COL_BREAK_PAT)
@ -475,6 +397,7 @@ proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
m = line.match(PAGE_BREAK_PAT) m = line.match(PAGE_BREAK_PAT)
if m.isSome: if m.isSome:
result.add(ChordChartNode(kind: ccnkPageBreak)) result.add(ChordChartNode(kind: ccnkPageBreak))
ctx.curSection = EMPTY_CHORD_CHART_NODE
continue continue
m = line.match(SECTION_LINE_PAT) m = line.match(SECTION_LINE_PAT)
@ -505,7 +428,7 @@ proc parseChordChart*(s: string): ChordChart =
unparsedLineParts: @[]) unparsedLineParts: @[])
let metadata = parseMetadata(parserCtx) let metadata = parseMetadata(parserCtx)
parserCtx.curKeyCenter = metadata.key.rootPitch parserCtx.curKey = metadata.key
result = ChordChart( result = ChordChart(
rawContent: s, rawContent: s,

View File

@ -1,4 +1,4 @@
const PCO_CHORDS_VERSION* = "0.5.1" const PCO_CHORDS_VERSION* = "0.6.1"
const USAGE* = """Usage: const USAGE* = """Usage:
pco_chords [options] pco_chords [options]
@ -23,6 +23,7 @@ Options:
--help Print this usage information --help Print this usage information
--debug Enable debug logging. --debug Enable debug logging.
--echo-args Echo the input parameters. --echo-args Echo the input parameters.
--large-print Use the large type styling.
""" """
const ONLINE_HELP* = """ const ONLINE_HELP* = """

View File

@ -1,13 +1,13 @@
import std/[logging, options, os, strutils] import std/[logging, options, os, strutils]
import zero_functional import zero_functional
import ./ast import ./[ast, notation]
type type
FormatContext = ref object FormatContext = ref object
chart: ChordChart chart: ChordChart
currentKey: ChordChartPitch currentKey: Key
sourceKey: ChordChartPitch sourceKey: Key
currentSection: ChordChartNode currentSection: ChordChartNode
numberChart: bool numberChart: bool
transposeSteps: int transposeSteps: int
@ -22,11 +22,104 @@ const DEFAULT_STYLESHEET* = """
html { font-family: sans-serif; } html { font-family: sans-serif; }
.page-contents { .page-contents { line-height: 1.1; }
column-width: 336px; .one-column .page-contents { column-count: 1; }
column-width: 3.5in; .two-column .page-contents { column-count: 2; }
.column-break { margin-bottom: auto; }
h2 { font-size: 1.25em; }
section {
break-inside: avoid;
padding-top: 1em;
} }
h3 {
font-size: 1.125rem;
margin-bottom: 0.125em;
text-decoration: underline;
}
h3 .section-text {
font-style: italic;
font-size: 1em;
font-weight: normal;
margin: 0 0.5em;
}
.artist { font-style: italic; }
.line {
display: flex;
flex-direction: row;
align-items: end;
margin-left: 0.5em;
margin-bottom: 0.5em;
}
.word {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.word.space-after { margin-right: 0.5em; }
.word.no-chord { align-self: flex-end; }
.chord {
font-weight: 600;
margin-bottom: -0.25em;
margin-right: 0.5em;
white-space: nowrap;
}
.chord > * {
display: inline-block;
height: 1.2em;
}
.number-chart .chord .flavor {
font-variant-position: super;
}
.note { margin-right: 1em; }
.song-order h3 {
font-style: italic;
font-weight: normal;
}
.song-order li {
list-style: none;
margin-left: 1em;
}
@media screen {
body { margin: 1em; }
}
</style>
"""
const LARGE_PRINT_STYLESHEET* = """
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: sans-serif;
font-size: 1.3em;
font-variant: small-caps;
}
.page-contents { line-height: 1.1; }
.one-column .page-contents { column-count: 1; }
.two-column .page-contents { column-count: 2; }
.column-break { margin-bottom: auto; } .column-break { margin-bottom: auto; }
h2 { font-size: 1.25em; } h2 { font-size: 1.25em; }
@ -49,10 +142,12 @@ h3 .section-text {
margin: 0 0.5em; margin: 0 0.5em;
} }
.artist { font-style: italic; }
.line { .line {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: flex-end; align-items: baseline;
margin-left: 0.5em; margin-left: 0.5em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
@ -64,17 +159,31 @@ h3 .section-text {
align-items: flex-start; align-items: flex-start;
} }
.word.space-after { margin-right: 0.5em; } .word.space-after { margin-right: 0.3em; }
.word.no-chord { align-self: flex-end; }
.lyric {
font-weight: 600;
}
.chord { .chord {
font-weight: 600; font-size: 125%;
font-style: italic;
font-weight: 700;
margin-right: 0.5em; margin-right: 0.5em;
} }
.chord .flavor { .chord > * {
display: inline-block;
height: 1.2em;
}
.number-chart .chord .flavor {
font-variant-position: super; font-variant-position: super;
} }
.note { margin-right: 1em; }
.song-order h3 { .song-order h3 {
font-style: italic; font-style: italic;
font-weight: normal; font-weight: normal;
@ -88,69 +197,26 @@ h3 .section-text {
@media screen { @media screen {
body { margin: 1em; } body { margin: 1em; }
} }
@media print {
.page-contents { column-count: 2; }
}
</style> </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 = func format(ctx: FormatContext, chord: ChordChartChord, useNumber = false): string =
##if not useNumber and chord.original.isSome: return chord.original.get ##if not useNumber and chord.original.isSome: return chord.original.get
if useNumber: result = "<span class=root>"
result = "<span class=root>" & if useNumber: result &= $chord.root
ctx.currentKey.scaleDegree(chord.rootPitch) & "</span>" 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>" result &= "<span class=flavor>" & chord.flavor.get & "</span>"
if chord.bassPitch.isSome: if chord.bass.isSome:
result &= "<span class=bass>/" & result &= "<span class=bass>/"
ctx.currentKey.scaleDegree(chord.bassPitch.get) & "</span>" 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 = func makeSongOrder(songOrder, indent: string): string =
result = indent & "<section class=song-order>\p" & result = indent & "<section class=song-order>\p" &
@ -191,11 +257,12 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
result &= "<span class='word" result &= "<span class='word"
#if node.spaceBefore: result&=" space-before" #if node.spaceBefore: result&=" space-before"
if node.spaceAfter: result&=" space-after" if node.spaceAfter: result&=" space-after"
if node.chord.isNone: result&=" no-chord"
result &= "'>" result &= "'>"
if node.chord.isSome: if node.chord.isSome:
result &= "<span class=chord>" & 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>" result &= "<span class=lyric>"
if node.word.isSome: result &= node.word.get if node.word.isSome: result &= node.word.get
@ -214,16 +281,13 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
result &= "</div><div class=page-contents>" result &= "</div><div class=page-contents>"
of ccnkTransposeKey: of ccnkTransposeKey:
ctx.currentKey = ctx.currentKey + node.transposeSteps ctx.currentKey = ctx.currentKey.transpose(node.transposeSteps)
let headingVal = indent & "<h4 class=note>Key Change: " & $ctx.currentKey & "</h4>" result &= indent & "<h4 class=note>Key Change: " & $ctx.currentKey & "</h4>"
if ctx.currentSection.kind == ccnkNone: result &= headingVal
else:
result &= "</section><section>" & headingVal & "</section><section>"
of ccnkRedefineKey: of ccnkRedefineKey:
let oldKey = ctx.currentKey let oldKey = ctx.currentKey
ctx.sourceKey = node.newKey + ctx.transposeSteps ctx.sourceKey = node.newKey
ctx.currentKey = ctx.sourceKey ctx.currentKey = ctx.sourceKey.transpose(ctx.transposeSteps)
if oldKey != ctx.currentKey: if oldKey != ctx.currentKey:
let headingVal = indent & "<h4 class=note>Key Change: " & $ctx.currentKey & "</h4>" let headingVal = indent & "<h4 class=note>Key Change: " & $ctx.currentKey & "</h4>"
@ -242,8 +306,8 @@ proc toHtml*(
var ctx = FormatContext( var ctx = FormatContext(
chart: cc, chart: cc,
currentKey: cc.metadata.key.rootPitch + transpose, currentKey: cc.metadata.key.transpose(transpose),
sourceKey: cc.metadata.key.rootPitch, sourceKey: cc.metadata.key,
currentSection: EMPTY_CHORD_CHART_NODE, currentSection: EMPTY_CHORD_CHART_NODE,
numberChart: numberChart, numberChart: numberChart,
transposeSteps: transpose) transposeSteps: transpose)
@ -254,7 +318,7 @@ proc toHtml*(
result = """<!doctype html> result = """<!doctype html>
<html> <html>
<head> <head>
<title>""" & cc.metadata.title & "</title>\p" <title>""" & cc.metadata.title & "" & $ctx.currentKey & "</title>\p"
for ss in stylesheets: for ss in stylesheets:
if ss.startsWith("<style>"): result &= ss if ss.startsWith("<style>"): result &= ss
@ -266,14 +330,21 @@ proc toHtml*(
elif fileExists(sc): result &= "<script>\p" & readFile(sc) & "\p</script>" elif fileExists(sc): result &= "<script>\p" & readFile(sc) & "\p</script>"
else: warn "cannot read script file '" & sc & "'" else: warn "cannot read script file '" & sc & "'"
result &= " </head>\p <body>" result &= " </head>\p"
var bodyClasses = newSeq[string]()
if numberChart: bodyClasses.add("number-chart")
if cc.metadata.contains("columns") and cc.metadata["columns"] == "1":
bodyClasses.add("one-column")
else: bodyClasses.add("two-column")
result &= " <body class='" & bodyClasses.join(" ") & "'>"
var indent = " " var indent = " "
# Title # Title
result &= indent & "<h1>" & cc.metadata.title & "</h1>\p" 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"): if cc.metadata.contains("time signature"):
metadataPieces.add(cc.metadata["time signature"]) metadataPieces.add(cc.metadata["time signature"])
if cc.metadata.contains("bpm"): if cc.metadata.contains("bpm"):
@ -283,6 +354,9 @@ proc toHtml*(
result &= indent & "<h2>" & metadataPieces.join(" | ") & "</h2>\p" result &= indent & "<h2>" & metadataPieces.join(" | ") & "</h2>\p"
if cc.metadata.contains("artist"):
result &= indent & "<div class=artist>" & cc.metadata["artist"] & "</div>\p"
result &= "<div class=page-contents>" result &= "<div class=page-contents>"
result &= join(cc.nodes --> map(ctx.toHtml(it, indent & " ")), "\p") result &= join(cc.nodes --> map(ctx.toHtml(it, indent & " ")), "\p")

BIN
src/pco_chordspkg/notation Executable file

Binary file not shown.

View File

@ -0,0 +1,375 @@
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,
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 =
## 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
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 =
## 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)
result = MajorIntervals[(degree.number - 1)] + ord(degree.alteration)
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
of C: Note.C
of Df, D: Note.D
of Ef, E: Note.E
of F: Note.F
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
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)
func spellPitch*(k: 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 = k.tonic.note + (sd.number - 1)
let resultingPitch = ord(ionianPitch(k, sd.number)) + ord(sd.alteration)
result.alteration = cast[NoteAlteration](
resultingPitch - ord(result.note.toPitch))
#[
debugEcho "Spelling " & $sd & " in the key of " & $k & ":\n" &
"\tsd.alteration: " & $ord(sd.alteration) &
"\tkey.tonic.note: " & $k.tonic.note &
"\tsd.number - 1: " & $(sd.number - 1) &
"\tionianPitch: " & $ionianPitch(k, sd.number) &
"\tord(ionianPitch): " & $ord(ionianPitch(k, sd.number)) &
"\talteration: " & $ord(sd.alteration) &
"\tresultingPitch: " & $resultingPitch &
"\tresult.note: " & $(result.note) &
"\tresult.alteration: " & $ord(result.alteration)
]#
func toScaleDegree*(k: 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 - k.tonic.note + 1
var distance = ord(sp.toPitch) - ord(ionianPitch(k, result.number))
if distance < -2: distance += 12
elif distance > 2: distance -= 12
result.alteration = cast[NoteAlteration](distance)
#[
debugEcho "toScaleDegree: " & $sp & " in the key of " & $k & ":\n" &
"\tsp.note: " & $sp.note &
"\tkey.tonic.note: " & $k.tonic.note &
"\tresult.number: " & $result.number &
"\tsp.toPitch: " & $sp.toPitch &
"\tionianPitch: " & $ionianPitch(k, result.number) &
"\tord(sp.toPitch): " & $ord(sp.toPitch) &
"\tord(ionianPitch): " & $ord(ionianPitch(k, result.number)) &
"\tresult.alteration: " & $ord(result.alteration)
]#
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"))