Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
97ef8a7491 | |||
9741c55cee | |||
25d7ddcd1b | |||
533089431e | |||
d7dd509138 | |||
73bdb97881 | |||
a57aed0715 | |||
2712f9ff52 | |||
8271129b90 | |||
5b1238f038 | |||
9ca5a1b99c |
@ -1,6 +1,6 @@
|
||||
# Package
|
||||
|
||||
version = "0.4.1"
|
||||
version = "0.6.1"
|
||||
author = "Jonathan Bernard"
|
||||
description = "Chord chart formatter compatible with Planning Center Online"
|
||||
license = "MIT"
|
||||
@ -12,3 +12,9 @@ bin = @["pco_chords"]
|
||||
# Dependencies
|
||||
|
||||
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'"
|
||||
|
@ -34,11 +34,21 @@ when isMainModule:
|
||||
$ast & "\p" &
|
||||
"-".repeat(16) & "\p"
|
||||
|
||||
let outputHtml = ast.toHtml()
|
||||
let transpose =
|
||||
if args["--transpose"]: parseInt($args["--transpose"])
|
||||
else: 0
|
||||
|
||||
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)
|
||||
else: stdout.write(outputHtml)
|
||||
|
||||
except:
|
||||
except CatchableError:
|
||||
fatal getCurrentExceptionMsg()
|
||||
debug getCurrentException().getStackTrace()
|
||||
quit(QuitFailure)
|
||||
|
@ -1,10 +1,11 @@
|
||||
import std/[nre, strtabs, strutils]
|
||||
import ./notation
|
||||
import zero_functional
|
||||
|
||||
type
|
||||
ChordChartMetadata* = object
|
||||
title*: string
|
||||
key*: ChordChartPitch
|
||||
key*: Key
|
||||
optionalProps: StringTableRef
|
||||
|
||||
ChordChart* = ref object
|
||||
@ -23,18 +24,17 @@ type
|
||||
ccnkRedefineKey,
|
||||
ccnkNone
|
||||
|
||||
ChordChartPitch* = enum Af, A, Bf, B, C, Df, D, Ef, E, F, Fs, G
|
||||
|
||||
ChordChartChord* = object
|
||||
original*: string
|
||||
rootPitch*: ChordChartPitch
|
||||
original*: Option[string]
|
||||
root*: ScaleDegree
|
||||
flavor*: Option[string]
|
||||
bassPitch*: Option[ChordChartPitch]
|
||||
bass*: Option[ScaleDegree]
|
||||
|
||||
ChordChartNode* = ref object
|
||||
case kind: ChordChartNodeKind
|
||||
of ccnkSection:
|
||||
sectionName*: string
|
||||
remainingSectionLine*: Option[string]
|
||||
sectionContents*: seq[ChordChartNode]
|
||||
of ccnkLine:
|
||||
line*: seq[ChordChartNode]
|
||||
@ -51,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]
|
||||
@ -76,28 +76,6 @@ iterator pairs*(ccmd: ChordChartMetadata): tuple[key, value: string] =
|
||||
|
||||
func kind*(ccn: ChordChartNode): ChordChartNodeKind = ccn.kind
|
||||
|
||||
func `$`*(pitch: ChordChartPitch): string =
|
||||
case pitch
|
||||
of Af: "Ab"
|
||||
of A: "A"
|
||||
of Bf: "Bb"
|
||||
of B: "B"
|
||||
of C: "C"
|
||||
of Df: "Db"
|
||||
of D: "D"
|
||||
of Ef: "Eb"
|
||||
of E: "E"
|
||||
of F: "F"
|
||||
of Fs: "F#"
|
||||
of G: "G"
|
||||
|
||||
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")
|
||||
|
||||
@ -132,13 +110,15 @@ func dump*(ccn: ChordChartNode, indent = ""): string =
|
||||
if ccn.chord.isSome:
|
||||
let chord = ccn.chord.get
|
||||
result &= "["
|
||||
if chord.flavor.isNone and chord.bassPitch.isNone:
|
||||
result &= chord.original
|
||||
if chord.original.isSome and chord.flavor.isNone and
|
||||
chord.bass.isNone:
|
||||
result &= chord.original.get
|
||||
else:
|
||||
result &= $chord.root
|
||||
if chord.flavor.isSome:
|
||||
result &= $chord.rootPitch & "_" & chord.flavor.get
|
||||
if chord.bassPitch.isSome:
|
||||
result &= $chord.rootPitch & "/" & $chord.bassPitch.get
|
||||
result &= "_" & chord.flavor.get
|
||||
if chord.bass.isSome:
|
||||
result &= "/" & $chord.bass.get
|
||||
result &= "]"
|
||||
|
||||
if ccn.word.isSome: result &= ccn.word.get
|
||||
@ -184,31 +164,16 @@ template addToCurSection(n: ChordChartNode): untyped =
|
||||
if ctx.curSection.kind == ccnkSection: ctx.curSection.sectionContents.add(n)
|
||||
else: result.add(n)
|
||||
|
||||
func parsePitch*(ctx: ParserContext, keyValue: string): ChordChartPitch =
|
||||
case keyValue.strip.toLower
|
||||
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", "cb", "ces", "cf": return ChordChartPitch.B
|
||||
of "bs", "bis", "b#", "c", "d𝄫": return ChordChartPitch.C
|
||||
of "b𝄪", "cs", "cis", "c#", "df", "db", "des": return ChordChartPitch.Df
|
||||
of "c𝄪", "d", "e𝄫": return ChordChartPitch.D
|
||||
of "ds", "dis", "d#", "ef", "eb", "ees", "f𝄫": return ChordChartPitch.Ef
|
||||
of "d𝄪", "e", "fes", "ff": return ChordChartPitch.E
|
||||
of "es", "eis", "e#", "f", "g𝄫": return ChordChartPitch.F
|
||||
of "e𝄪", "fs", "fis", "f#", "gf", "gb", "ges": return ChordChartPitch.Fs
|
||||
of "f𝄪", "g", "a𝄫": return ChordChartPitch.G
|
||||
else: raise ctx.makeError(keyValue.strip & " is not a recognized key.")
|
||||
|
||||
# see regexr.com/70nv1
|
||||
# see https://regexr.com/8f4ru
|
||||
let CHORD_REGEX =
|
||||
"([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
|
||||
"(\\/([A-G1-7][b#♭♮𝄫𝄪]?))?" # optional bass
|
||||
"([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
|
||||
ctx: ParserContext,
|
||||
chordValue: string
|
||||
): Option[ChordChartChord] =
|
||||
|
||||
let m = chordValue.match(CHORD_PAT)
|
||||
@ -219,24 +184,24 @@ 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: chordValue,
|
||||
rootPitch: ctx.parsePitch(m.get.captures[0]),
|
||||
original: some(chordValue),
|
||||
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"^-+$"
|
||||
proc parseMetadata(ctx: var ParserContext): ChordChartMetadata =
|
||||
|
||||
var title = "MISSING"
|
||||
var songKey = "MISSING"
|
||||
var optProps = newStringTable()
|
||||
var songKey: Option[Key]
|
||||
|
||||
while ctx.curLineNum < ctx.lines.len:
|
||||
let line = ctx.nextLine
|
||||
@ -251,28 +216,37 @@ proc parseMetadata(ctx: var ParserContext): ChordChartMetadata =
|
||||
let key = m.get.captures[0].strip.tolower
|
||||
let value = m.get.captures[1].strip
|
||||
if key == "title": title = value
|
||||
elif key == "key": songKey = value
|
||||
elif key == "key":
|
||||
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
|
||||
|
||||
if title == "MISSING":
|
||||
raise ctx.makeError("metadata is missing the 'title' property")
|
||||
|
||||
if songKey == "MISSING":
|
||||
if songKey.isNone:
|
||||
raise ctx.makeError("metadata is missing the 'key' property")
|
||||
|
||||
result = ChordChartMetadata(
|
||||
title: title,
|
||||
key: ctx.parsePitch(songKey),
|
||||
key: songKey.get,
|
||||
optionalProps: optProps)
|
||||
|
||||
const KNOWN_SECTION_NAMES = [
|
||||
"chorus", "verse", "bridge", "breakdown", "vamp", "intstrumental",
|
||||
"interlude", "intro", "outtro", "ending", "end", "tag"
|
||||
"interlude", "intro", "outtro", "ending", "end", "tag", "prechorus",
|
||||
"pre-chorus", "pre chorus"
|
||||
]
|
||||
|
||||
let SECTION_LINE_PAT = re(
|
||||
"^((" & # case insensitive
|
||||
"((?i)" & KNOWN_SECTION_NAMES.join("|") & ")" & # known names
|
||||
"^((" & "((?i)" & # case insensitive
|
||||
KNOWN_SECTION_NAMES.join("|") & ")" & # known names
|
||||
"|[[:upper:]]{3,}" & # all upper-case words
|
||||
") ?\\d*)" & # numeric suffix (Verse 2)
|
||||
|
||||
@ -284,13 +258,46 @@ 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 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("^(" & 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] =
|
||||
result = @[]
|
||||
@ -334,10 +341,11 @@ proc parseLineParts(ctx: ParserContext, parts: varargs[string]): seq[ChordChartN
|
||||
chord: none[ChordChartChord](),
|
||||
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)
|
||||
|
||||
let m = line.match(NAKED_CHORDS_ONLY_PAT)
|
||||
var m = line.match(NAKED_CHORDS_ONLY_PAT)
|
||||
if m.isSome:
|
||||
result.line = line.splitWhitespace --> map(
|
||||
ChordChartNode(
|
||||
@ -346,49 +354,28 @@ proc parseLine(ctx: ParserContext, line: string): ChordChartNode =
|
||||
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 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] =
|
||||
result = @[]
|
||||
while ctx.hasNextLine:
|
||||
var line = ctx.nextLine
|
||||
|
||||
var m = line.find(NOTE_START_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)
|
||||
var m = line.match(TRANSPOSE_PAT)
|
||||
if m.isSome:
|
||||
addToCurSection(ChordChartNode(
|
||||
kind: ccnkTransposeKey,
|
||||
@ -397,14 +384,9 @@ proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
|
||||
|
||||
m = line.match(REDEFINE_KEY_PAT)
|
||||
if m.isSome:
|
||||
let newKeyInt = (
|
||||
cast[int](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)
|
||||
@ -415,6 +397,7 @@ proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
|
||||
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)
|
||||
@ -422,12 +405,14 @@ proc parseBody(ctx: var ParserContext): seq[ChordChartNode] =
|
||||
let captures = m.get.captures.toSeq
|
||||
ctx.curSection = ChordChartNode(
|
||||
kind: ccnkSection,
|
||||
sectionName: if captures[0].isSome: captures[0].get.strip
|
||||
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)
|
||||
if captures[3].isSome:
|
||||
ctx.curSection.sectionContents &= ctx.parseLineParts(captures[3].get)
|
||||
continue
|
||||
|
||||
else:
|
||||
@ -443,7 +428,7 @@ proc parseChordChart*(s: string): ChordChart =
|
||||
unparsedLineParts: @[])
|
||||
|
||||
let metadata = parseMetadata(parserCtx)
|
||||
parserCtx.curKeyCenter = metadata.key
|
||||
parserCtx.curKey = metadata.key
|
||||
|
||||
result = ChordChart(
|
||||
rawContent: s,
|
||||
|
@ -1,4 +1,4 @@
|
||||
const PCO_CHORDS_VERSION* = "0.4.1"
|
||||
const PCO_CHORDS_VERSION* = "0.6.1"
|
||||
|
||||
const USAGE* = """Usage:
|
||||
pco_chords [options]
|
||||
@ -12,9 +12,18 @@ Options:
|
||||
-o, --output <out-file> Write output to <out-file> (rather than from
|
||||
STDOUT, which is the default).
|
||||
|
||||
-t, --transpose <distance> Transpose the chart by the given number of
|
||||
semitones. Distance can be expressed as a
|
||||
positive or negative number of semitones,
|
||||
for example: 4, +3 or -5.
|
||||
|
||||
-n, --number-chart Write out a chart using the Nashville number
|
||||
system rather than explicit pitches.
|
||||
|
||||
--help Print this usage information
|
||||
--debug Enable debug logging.
|
||||
--echo-args Echo the input parameters.
|
||||
--large-print Use the large type styling.
|
||||
"""
|
||||
|
||||
const ONLINE_HELP* = """
|
||||
|
@ -1,14 +1,16 @@
|
||||
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
|
||||
|
||||
const DEFAULT_STYLESHEET* = """
|
||||
<style>
|
||||
@ -20,11 +22,104 @@ const DEFAULT_STYLESHEET* = """
|
||||
|
||||
html { font-family: sans-serif; }
|
||||
|
||||
.page-contents {
|
||||
column-width: 336px;
|
||||
column-width: 3.5in;
|
||||
.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; }
|
||||
|
||||
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; }
|
||||
|
||||
h2 { font-size: 1.25em; }
|
||||
@ -40,10 +135,19 @@ h3 {
|
||||
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: flex-end;
|
||||
align-items: baseline;
|
||||
|
||||
margin-left: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
@ -55,13 +159,31 @@ h3 {
|
||||
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 {
|
||||
font-weight: 600;
|
||||
font-size: 125%;
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.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;
|
||||
@ -75,24 +197,26 @@ h3 {
|
||||
@media screen {
|
||||
body { margin: 1em; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
.page-contents { column-count: 2; }
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
proc transpose(ctx: FormatContext, chord: ChordChartChord): string =
|
||||
let distance = ctx.currentKey - ctx.sourceKey
|
||||
func format(ctx: FormatContext, chord: ChordChartChord, useNumber = false): string =
|
||||
##if not useNumber and chord.original.isSome: return chord.original.get
|
||||
|
||||
if distance != 0:
|
||||
result = $(chord.rootPitch + distance)
|
||||
if chord.flavor.isSome: result &= chord.flavor.get
|
||||
if chord.bassPitch.isSome: result &= "/" & $(chord.bassPitch.get + distance)
|
||||
#debug "transposed " & $ctx.sourceKey & " -> " & $ctx.currentKey &
|
||||
# " (distance " & $distance & ")\p\tchord: " & $chord & " to " & result
|
||||
result = "<span class=root>"
|
||||
if useNumber: result &= $chord.root
|
||||
else: result &= $ctx.currentKey.spellPitch(chord.root)
|
||||
result &= "</span>"
|
||||
|
||||
if chord.flavor.isSome:
|
||||
result &= "<span class=flavor>" & chord.flavor.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 = chord.original
|
||||
|
||||
func makeSongOrder(songOrder, indent: string): string =
|
||||
result = indent & "<section class=song-order>\p" &
|
||||
@ -108,7 +232,13 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
|
||||
of ccnkSection:
|
||||
ctx.currentSection = node
|
||||
result &= indent & "<section>\p" &
|
||||
indent & " " & "<h3>" & node.sectionName & "</h3>\p"
|
||||
indent & " " & "<h3>" & node.sectionName
|
||||
|
||||
if ctx.currentSection.remainingSectionLine.isSome:
|
||||
result &= "<span class='section-text'>" &
|
||||
ctx.currentSection.remainingSectionLine.get & "</span>"
|
||||
|
||||
result &= "</h3>\p"
|
||||
|
||||
var contents = newSeq[string]()
|
||||
for contentNode in node.sectionContents:
|
||||
@ -127,10 +257,12 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
|
||||
result &= "<span class='word"
|
||||
#if node.spaceBefore: result&=" space-before"
|
||||
if node.spaceAfter: result&=" space-after"
|
||||
if node.chord.isNone: result&=" no-chord"
|
||||
result &= "'>"
|
||||
|
||||
if node.chord.isSome:
|
||||
result &= "<span class=chord>" & ctx.transpose(node.chord.get) & "</span>"
|
||||
result &= "<span class=chord>" &
|
||||
ctx.format(node.chord.get, ctx.numberChart) & "</span>"
|
||||
|
||||
result &= "<span class=lyric>"
|
||||
if node.word.isSome: result &= node.word.get
|
||||
@ -149,16 +281,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
|
||||
let headingVal = indent & "<h4 class=note>Key Change: " & $ctx.currentKey & "</h4>"
|
||||
if ctx.currentSection.kind == ccnkNone: result &= headingVal
|
||||
else:
|
||||
result &= "</section><section>" & headingVal & "</section><section>"
|
||||
ctx.currentKey = ctx.currentKey.transpose(node.transposeSteps)
|
||||
result &= indent & "<h4 class=note>Key Change: " & $ctx.currentKey & "</h4>"
|
||||
|
||||
of ccnkRedefineKey:
|
||||
let oldKey = ctx.sourceKey
|
||||
let oldKey = ctx.currentKey
|
||||
ctx.sourceKey = node.newKey
|
||||
ctx.currentKey = ctx.sourceKey
|
||||
ctx.currentKey = ctx.sourceKey.transpose(ctx.transposeSteps)
|
||||
|
||||
if oldKey != ctx.currentKey:
|
||||
let headingVal = indent & "<h4 class=note>Key Change: " & $ctx.currentKey & "</h4>"
|
||||
@ -170,19 +299,26 @@ proc toHtml(ctx: var FormatContext, node: ChordChartNode, indent: string): strin
|
||||
|
||||
proc toHtml*(
|
||||
cc: ChordChart,
|
||||
transpose = 0,
|
||||
numberChart = false,
|
||||
stylesheets = @[DEFAULT_STYLESHEET],
|
||||
scripts: seq[string] = @[]): string =
|
||||
|
||||
var ctx = FormatContext(
|
||||
chart: cc,
|
||||
currentKey: cc.metadata.key,
|
||||
currentKey: cc.metadata.key.transpose(transpose),
|
||||
sourceKey: cc.metadata.key,
|
||||
currentSection: EMPTY_CHORD_CHART_NODE)
|
||||
currentSection: EMPTY_CHORD_CHART_NODE,
|
||||
numberChart: numberChart,
|
||||
transposeSteps: transpose)
|
||||
|
||||
debug "Formatting:\p\tsource key: " & $ctx.sourceKey & "\p\ttransposing: " &
|
||||
$transpose & "\p\ttarget key: " & $ctx.currentKey
|
||||
|
||||
result = """<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>""" & cc.metadata.title & "</title>\p"
|
||||
<title>""" & cc.metadata.title & " — " & $ctx.currentKey & "</title>\p"
|
||||
|
||||
for ss in stylesheets:
|
||||
if ss.startsWith("<style>"): result &= ss
|
||||
@ -194,14 +330,21 @@ proc toHtml*(
|
||||
elif fileExists(sc): result &= "<script>\p" & readFile(sc) & "\p</script>"
|
||||
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 = " "
|
||||
#
|
||||
|
||||
# Title
|
||||
result &= indent & "<h1>" & cc.metadata.title & "</h1>\p"
|
||||
|
||||
var metadataPieces = @["Key: " & $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"):
|
||||
@ -211,6 +354,9 @@ proc toHtml*(
|
||||
|
||||
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 &= join(cc.nodes --> map(ctx.toHtml(it, indent & " ")), "\p")
|
||||
|
||||
|
BIN
src/pco_chordspkg/notation
Executable file
BIN
src/pco_chordspkg/notation
Executable file
Binary file not shown.
375
src/pco_chordspkg/notation.nim
Normal file
375
src/pco_chordspkg/notation.nim
Normal 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"))
|
Reference in New Issue
Block a user