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.. ctx.curLineNum and ctx.hasNextLine: let line = ctx.nextLine endMatch = line.find(NOTE_END_PAT) if endMatch.isSome: result.note &= line[0.. 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))