All files / Bandstand/src/lib fillchords.ts

90% Statements 90/100
90.19% Branches 46/51
77.77% Functions 7/9
90% Lines 90/100

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134                        1x 1x 1x 1x 1x 19x 19x 19x 19x         11x 11x 11x 11x 11x 11x 11x 26x 26x 26x   26x 26x 26x 26x 26x 26x 26x 26x 11x 11x     3x 3x 3x   3x 7x 7x 7x 3x 3x 3x 3x 3x 3x   1x     4x 4x 4x   4x 4x 26x 26x 26x 26x 4x     4x 4x 26x 19x 26x 11x 26x 3x 3x 3x 4x     4x 4x 4x 26x 7x 6x 6x 6x 4x 4x   4x 4x 4x 7x 5x   5x 5x 5x 4x     7x   1x 1x 1x                     4x 4x 4x  
/**
 * Charts written by hand often give the chords for the first verse only
 * and leave the later verses bare ("same as verse 1"). This puts them back:
 *
 * - a line sung again word for word gets the chords it had the first time;
 * - a bare stanza that parallels an earlier charted one (same number of
 *   lines, each about as long) gets that stanza's chords, word for word.
 *
 * Only lines with no chords are touched. Returns what it filled, so a
 * person can check it.
 */
 
const CHORD = /\[([^\]]+)\]/g
const hasChord = (l: string) => /\[[^\]]+\]/.test(l)
const plain = (l: string) => l.replace(CHORD, '')
const isLyric = (l: string) => !/^\s*\{/.test(l) && /[A-Za-z]/.test(plain(l))
const key = (l: string) =>
  plain(l)
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, ' ')
    .trim()
 
type Placed = {word: number; chord: string}
 
/** A charted line's chords by the word they sit on (Infinity: after it). */
function chordsByWord(line: string): Placed[] {
  const out: Placed[] = []
  let text = ''
  let m: RegExpExecArray | null
  let at = 0
  const re = new RegExp(CHORD.source, 'g')
  while ((m = re.exec(line))) {
    text += line.slice(at, m.index)
    at = m.index + m[0].length
    const rest = plain(line.slice(at))
    // Words that start before this point
    const before = (text.match(/\S+/g) ?? []).length
    const midWord = /\S$/.test(text) && /^\S/.test(rest)
    const word = midWord ? before - 1 : before
    out.push({
      word: /\S/.test(rest) ? word : Infinity,
      chord: m[1],
    })
  }
  return out
}
 
/** Put chords onto a bare line, word for word. */
function apply(line: string, placed: Placed[]) {
  const words = [...line.matchAll(/\S+/g)]
  let out = line
  // Right to left, so earlier positions don't move
  const at = placed.map((p) => ({
    pos:
      p.word === Infinity || p.word >= words.length ? -1 : words[p.word].index!,
    chord: p.chord,
  }))
  const tail = at.filter((a) => a.pos < 0).map((a) => `[${a.chord}]`)
  for (const a of [...at].filter((a) => a.pos >= 0).reverse())
    out = out.slice(0, a.pos) + `[${a.chord}]` + out.slice(a.pos)
  return tail.length ? `${out.replace(/\s+$/, '')} ${tail.join(' ')}` : out
}
 
export function fillMissingChords(source: string): {
  source: string
  filled: string[]
} {
  const lines = source.split('\n')
  const filled: string[] = []
  // Inside notation or tab nothing is a lyric
  let inBlock = false
  const lyricAt = lines.map((l) => {
    if (/^\{start_of_(abc|tab)/.test(l)) inBlock = true
    const ok = !inBlock && isLyric(l)
    if (/^\{end_of_(abc|tab)/.test(l)) inBlock = false
    return ok
  })
 
  // 1. A line sung again: the chords it had before
  const seen = new Map<string, Placed[]>()
  lines.forEach((l, i) => {
    if (!lyricAt[i]) return
    const k = key(l)
    if (hasChord(l)) {
      if (k && !seen.has(k)) seen.set(k, chordsByWord(l))
    } else if (k.split(' ').length >= 3 && seen.has(k)) {
      lines[i] = apply(l, seen.get(k)!)
      filled.push(`repeat: ${plain(lines[i]).trim()}`)
    }
  })
 
  // 2. Stanzas: runs of lyric lines between blank lines and directives
  const stanzas: number[][] = []
  let cur: number[] = []
  lines.forEach((l, i) => {
    if (lyricAt[i]) cur.push(i)
    else if (!l.trim() || /^\s*\{/.test(l)) {
      if (cur.length) stanzas.push(cur)
      cur = []
    }
  })
  if (cur.length) stanzas.push(cur)
 
  const words = (i: number) => key(lines[i]).split(' ').length
  const charted = (s: number[]) => s.every((i) => hasChord(lines[i]))
  stanzas.forEach((s, n) => {
    if (s.length < 3) return
    const bare = s.filter((i) => !hasChord(lines[i]))
    // Bare, or chords only on its first line
    if (
      !(
        bare.length === s.length ||
        (bare.length === s.length - 1 && hasChord(lines[s[0]]))
      )
    )
      return
    // The nearest earlier charted stanza it parallels
    for (let t = n - 1; t >= 0; t--) {
      const tpl = stanzas[t]
      if (tpl.length !== s.length || !charted(tpl)) continue
      if (!s.every((i, k) => Math.abs(words(i) - words(tpl[k])) <= 3)) continue
      s.forEach((i, k) => {
        if (hasChord(lines[i])) return
        lines[i] = apply(lines[i], chordsByWord(lines[tpl[k]]))
      })
      filled.push(
        `verse like "${plain(lines[tpl[0]]).trim()}": ${plain(lines[s[0]]).trim()}`,
      )
      return
    }
  })
  return {source: lines.join('\n'), filled}
}