songsheet 7.2.0 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Songsheet
2
2
 
3
- A zero-dependency songsheet parser and transposer. Parses plaintext songsheet files into a structured AST with chord-lyric character alignment.
3
+ A zero-dependency songsheet parser and transposer. Parses plaintext songsheet files into a structured AST with chord-lyric character alignment and a playback timeline.
4
4
 
5
5
  ## Quick Start
6
6
 
@@ -15,16 +15,20 @@ const songInBb = transpose(song, 3, { preferFlats: true })
15
15
  ## Architecture
16
16
 
17
17
  ```
18
- index.js — Public API: re-exports parse + transpose
18
+ index.js — Public API: parse, transpose, toNashville, toStandard, buildPlaybackTimeline
19
19
  index.d.ts — TypeScript type definitions (adjacent to index.js)
20
20
  src/
21
21
  lexer.js — scanChordLine(), isChordLine(), lexExpression()
22
22
  parser.js — parse(), expression parser, section assembly
23
+ playback.js — buildPlaybackTimeline(), barline/measure expansion
24
+ notation.js — toNashville(), toStandard()
23
25
  transpose.js — transpose(), note math
24
26
  test/
25
27
  lexer.test.js — Chord line detection, bar lines, slash chords, edge cases
26
28
  parser.test.js — All 4 song fixture tests + bar line + time signature tests
27
29
  expression.test.js — Expression parsing and resolution
30
+ playback.test.js — Timeline generation, barline semantics, marker indexing
31
+ nns.test.js — Nashville Number System parsing/conversion coverage
28
32
  transpose.test.js — Semitone math, round-trips, flat/sharp preference, slash chords
29
33
  *.txt — Song fixtures (do not modify)
30
34
  ```
@@ -62,10 +66,13 @@ npx vitest run test/parser.test.js # single file
62
66
  lines: [
63
67
  {
64
68
  chords: [{ root: 'G', type: '', column: 0 }, ...],
65
- barLines: [], // column positions of | markers
69
+ barLines: [
70
+ { column: 12, chord: { root: 'G', type: '' } } // optional carried context
71
+ ],
66
72
  lyrics: 'lyric line 1',
67
73
  characters: [
68
74
  { character: 'B', chord: { root: 'G', type: '' } },
75
+ { character: '|', barLine: true },
69
76
  { character: 'l' },
70
77
  ...
71
78
  ]
@@ -84,6 +91,23 @@ npx vitest run test/parser.test.js # single file
84
91
  expression: null, // non-null on directive entries
85
92
  },
86
93
  ...
94
+ ],
95
+ playback: [
96
+ {
97
+ measureIndex: 0,
98
+ structureIndex: 0,
99
+ lineIndex: 0,
100
+ timeSignature: { beats: 3, value: 4 },
101
+ chords: [
102
+ {
103
+ root: 'G',
104
+ type: '',
105
+ markerIndex: 0, // marker index in merged chord/bar marker row (optional)
106
+ beatStart: 0,
107
+ durationInBeats: 3
108
+ }
109
+ ]
110
+ }
87
111
  ]
88
112
  }
89
113
  ```
@@ -96,8 +120,11 @@ npx vitest run test/parser.test.js # single file
96
120
  - **Synchronous parse**: No Promise wrapper, plain objects (no Immutable.js)
97
121
  - **Expression AST preserved**: `(VERSE, CHORUS*2)` stored as tree AND resolved to flat chords
98
122
  - **Character alignment includes barLines**: `{ character: 'r', barLine: true }` at `|` column positions
123
+ - **Barline context is carried across chord lines**: leading `|` on a new line can repeat the prior chord
99
124
  - **Column preservation**: Chord lines are never trimmed — column positions match the original file
100
125
  - **Title metadata**: BPM and time signature parsed from `(120 BPM, 3/4 time)` in the title block
126
+ - **Playback timeline**: parser output includes `playback[]` measures with `beatStart`/`durationInBeats` and optional `markerIndex` for UI highlighting
127
+ - **Strict bracket split syntax**: only `[A B ...]` creates multi-chord measures; `|` markers repeat measures and never group adjacent chords
101
128
  - **TypeScript types**: `index.d.ts` adjacent to `index.js` — consumers get types automatically with bundler module resolution
102
129
 
103
130
  ## Songsheet Format
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # songsheet
2
2
 
3
- A zero-dependency songsheet parser and transposer. Parses plaintext songsheet files into a structured AST with chord-lyric character alignment.
3
+ A zero-dependency songsheet parser and transposer. Parses plaintext songsheet files into a structured AST with chord-lyric alignment and builds a playback timeline.
4
4
 
5
5
  ## Install
6
6
 
@@ -111,10 +111,13 @@ Examples: `(VERSE, CHORUS*2)`, `(D G D A)*4`, `D G D A D`
111
111
  lines: [
112
112
  {
113
113
  chords: [{ root: 'G', type: '', column: 0 }, ...],
114
- barLines: [], // column positions of | markers
114
+ barLines: [ // | markers with optional carried chord context
115
+ { column: 12, chord: { root: 'G', type: '' } }
116
+ ],
115
117
  lyrics: 'lyric line 1',
116
118
  characters: [
117
119
  { character: 'B', chord: { root: 'G', type: '' } },
120
+ { character: '|', barLine: true },
118
121
  { character: 'l' },
119
122
  ...
120
123
  ]
@@ -135,13 +138,49 @@ Examples: `(VERSE, CHORUS*2)`, `(D G D A)*4`, `D G D A D`
135
138
  expression: null, // non-null on directive entries
136
139
  },
137
140
  ...
141
+ ],
142
+
143
+ // Flattened playback timeline (measure-by-measure)
144
+ playback: [
145
+ {
146
+ measureIndex: 0,
147
+ structureIndex: 0,
148
+ lineIndex: 0,
149
+ timeSignature: { beats: 3, value: 4 },
150
+ chords: [
151
+ {
152
+ root: 'G',
153
+ type: '',
154
+ markerIndex: 0, // marker index in the rendered line (optional)
155
+ beatStart: 0,
156
+ durationInBeats: 3
157
+ }
158
+ ]
159
+ }
138
160
  ]
139
161
  }
140
162
  ```
141
163
 
164
+ ## Playback Barline Semantics
165
+
166
+ Playback is derived from each line's chords and `|` markers using these rules:
167
+
168
+ 1. Each chord token is its own measure unless written as bracket split syntax (`[C D]`).
169
+ 2. Each `|` repeats a measure.
170
+ 3. Barline repeats use chord context carried by the parser, so leading bars on a line can repeat the previous line's chord.
171
+
172
+ Examples:
173
+
174
+ - `[C D]` -> one measure split evenly between `C` and `D`
175
+ - `C D |` -> `C`, `D`, `D`
176
+ - `| C D |` -> `C`, `D`, `D`
177
+ - `| G | C | D |` -> `G`, `G`, `C`, `C`, `D`, `D`
178
+ - `C C/B Am G F | Fsus4 F` -> `C`, `C/B`, `Am`, `G`, `F`, `F`, `Fsus4`, `F`
179
+ - `C | F C` then `| | G |` -> `C`, `C`, `F`, `C`, `C`, `C`, `G`, `G`
180
+
142
181
  ## Transposition
143
182
 
144
- `transpose()` deep-walks the AST and replaces every chord root (and bass note on slash chords). It auto-detects whether the song uses flats or sharps, or you can override with `{ preferFlats: true }`.
183
+ `transpose()` deep-walks the AST and replaces every chord root (and bass note on slash chords). It auto-detects whether the song uses flats or sharps, or you can override with `{ preferFlats: true }`. Playback timing metadata (`beatStart`, `durationInBeats`, `markerIndex`) is preserved.
145
184
 
146
185
  ```js
147
186
  const song = parse(rawText)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "songsheet",
3
- "version": "7.2.0",
3
+ "version": "7.4.0",
4
4
  "description": "A songsheet interpreter",
5
5
  "type": "module",
6
6
  "exports": "./index.js",
package/src/notation.js CHANGED
@@ -173,7 +173,9 @@ function convertSong(song, key, toNNS) {
173
173
  ...measure,
174
174
  chords: measure.chords.map(c => {
175
175
  const converted = convertChord(c, keySemitone, toNNS, preferFlats)
176
- return { ...converted, beatStart: c.beatStart, durationInBeats: c.durationInBeats }
176
+ const playbackChord = { ...converted, beatStart: c.beatStart, durationInBeats: c.durationInBeats }
177
+ if (c.markerIndex !== undefined) playbackChord.markerIndex = c.markerIndex
178
+ return playbackChord
177
179
  }),
178
180
  }))
179
181
  : undefined
package/src/playback.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Expand a chord into PlaybackChord(s). If the chord has a splitMeasure,
3
3
  * each sub-chord becomes a separate PlaybackChord; otherwise a single one.
4
4
  */
5
- function expandChord(chord) {
5
+ function expandChord(chord, markerIndex) {
6
6
  if (chord.splitMeasure && chord.splitMeasure.length > 0) {
7
7
  return chord.splitMeasure.map(sc => {
8
8
  const pc = { root: sc.root, type: sc.type }
@@ -11,6 +11,7 @@ function expandChord(chord) {
11
11
  if (sc.diamond) pc.diamond = true
12
12
  if (sc.push) pc.push = true
13
13
  if (sc.stop) pc.stop = true
14
+ if (markerIndex !== undefined) pc.markerIndex = markerIndex
14
15
  return pc
15
16
  })
16
17
  }
@@ -20,6 +21,7 @@ function expandChord(chord) {
20
21
  if (chord.diamond) pc.diamond = true
21
22
  if (chord.push) pc.push = true
22
23
  if (chord.stop) pc.stop = true
24
+ if (markerIndex !== undefined) pc.markerIndex = markerIndex
23
25
  return [pc]
24
26
  }
25
27
 
@@ -44,62 +46,6 @@ function buildMeasure(chords, measureIndex, structureIndex, lineIndex, ts) {
44
46
  }
45
47
  }
46
48
 
47
- /**
48
- * Check if barlines on this line actually separate chords (e.g., | G | C | D |)
49
- * vs. just trailing after all chords (e.g., A C D |).
50
- * Returns true only when a barline appears between two chords.
51
- */
52
- function detectInterBarlines(markers) {
53
- let seenChord = false
54
- let seenBarAfterChord = false
55
- for (const m of markers) {
56
- if (m.type === 'chord') {
57
- if (seenBarAfterChord) return true
58
- seenChord = true
59
- } else if (m.type === 'bar' && seenChord) {
60
- seenBarAfterChord = true
61
- }
62
- }
63
- return false
64
- }
65
-
66
- /**
67
- * Detect consecutive barlines (e.g., C || D), which usually indicate
68
- * repeated held measures rather than explicit bar-delimited grouping.
69
- */
70
- function hasConsecutiveBars(markers) {
71
- let prevWasBar = false
72
- for (const m of markers) {
73
- if (m.type === 'bar') {
74
- if (prevWasBar) return true
75
- prevWasBar = true
76
- } else {
77
- prevWasBar = false
78
- }
79
- }
80
- return false
81
- }
82
-
83
- /**
84
- * Decide whether a line should be grouped by explicit barline boundaries.
85
- *
86
- * Heuristic:
87
- * - require at least one barline between chords
88
- * - require barline density to be at least chord density
89
- * - reject consecutive barlines (treated as hold repeats)
90
- *
91
- * Sparse barlines like "C C/B Am G F | Fsus4 F" stay in chord-per-measure mode.
92
- */
93
- function shouldGroupByBarlines(markers) {
94
- const chordCount = markers.filter(m => m.type === 'chord').length
95
- const barCount = markers.length - chordCount
96
- if (chordCount === 0 || barCount === 0) return false
97
- if (!detectInterBarlines(markers)) return false
98
- if (barCount < chordCount) return false
99
- if (hasConsecutiveBars(markers)) return false
100
- return true
101
- }
102
-
103
49
  /**
104
50
  * Build the playback timeline from the song's structure array.
105
51
  *
@@ -127,51 +73,43 @@ export function buildPlaybackTimeline(structure, timeSignature) {
127
73
  markers.push({ col: chord.column, type: 'chord', chord })
128
74
  }
129
75
  for (const bar of line.barLines) {
130
- markers.push({ col: bar.column, type: 'bar' })
76
+ markers.push({ col: bar.column, type: 'bar', bar })
131
77
  }
132
78
  markers.sort((a, b) => a.col - b.col)
133
79
 
134
- if (shouldGroupByBarlines(markers)) {
135
- // Group chords by barline boundaries
136
- let currentChords = []
137
- for (const m of markers) {
138
- if (m.type === 'chord') {
139
- currentChords.push(...expandChord(m.chord))
140
- } else {
141
- // Bar line: flush accumulated chords as a measure
142
- if (currentChords.length > 0) {
143
- measures.push(buildMeasure(currentChords, measureIndex, si, li, ts))
144
- measureIndex++
145
- currentChords = []
146
- }
147
- // Leading barline with no preceding chords → discard (visual marker only)
148
- }
149
- }
150
- // Any remaining chords after the last barline form a measure
151
- if (currentChords.length > 0) {
152
- measures.push(buildMeasure(currentChords, measureIndex, si, li, ts))
80
+ // Strict bracket syntax: only [A B ...] creates multi-chord measures.
81
+ // Barlines never group multiple adjacent chord tokens into one measure;
82
+ // instead they repeat the current/last carried chord for another measure.
83
+ let lastExpanded = null
84
+ for (let mi = 0; mi < markers.length; mi++) {
85
+ const m = markers[mi]
86
+ if (m.type === 'chord') {
87
+ lastExpanded = expandChord(m.chord, mi)
88
+ measures.push(buildMeasure(lastExpanded, measureIndex, si, li, ts))
153
89
  measureIndex++
154
- }
155
- } else {
156
- // No inter-barlines: each chord is its own measure.
157
- // Trailing barlines repeat the preceding chord for one additional measure.
158
- let lastExpanded = null
159
- for (const m of markers) {
160
- if (m.type === 'chord') {
161
- lastExpanded = expandChord(m.chord)
162
- measures.push(buildMeasure(lastExpanded, measureIndex, si, li, ts))
163
- measureIndex++
164
- } else if (m.type === 'bar' && lastExpanded) {
165
- measures.push(buildMeasure([...lastExpanded], measureIndex, si, li, ts))
90
+ } else if (m.type === 'bar') {
91
+ // Use parser-provided bar chord context when available.
92
+ // This enables leading bars on a new line (e.g. "| | G |")
93
+ // to repeat the previous line's carried chord.
94
+ let repeatedAtBar = null
95
+ if (m.bar && m.bar.chord) {
96
+ repeatedAtBar = expandChord(m.bar.chord, mi)
97
+ } else if (lastExpanded) {
98
+ repeatedAtBar = lastExpanded.map(c => ({ ...c, markerIndex: mi }))
99
+ }
100
+ if (repeatedAtBar) {
101
+ measures.push(buildMeasure(repeatedAtBar, measureIndex, si, li, ts))
166
102
  measureIndex++
103
+ lastExpanded = repeatedAtBar
167
104
  }
168
105
  }
169
106
  }
170
107
  }
171
108
  } else {
172
109
  // Expression-only entries (FILL, INSTRUMENTAL, etc.) — chords only, no lines
173
- for (const chord of entry.chords) {
174
- const expanded = expandChord(chord)
110
+ for (let ci = 0; ci < entry.chords.length; ci++) {
111
+ const chord = entry.chords[ci]
112
+ const expanded = expandChord(chord, ci)
175
113
  measures.push(buildMeasure(expanded, measureIndex, si, -1, ts))
176
114
  measureIndex++
177
115
  }
package/src/transpose.js CHANGED
@@ -114,7 +114,9 @@ export function transpose(song, semitones, options = {}) {
114
114
  ...measure,
115
115
  chords: measure.chords.map(c => {
116
116
  const transposed = transposeChordObj(c, semitones, preferFlats)
117
- return { ...transposed, beatStart: c.beatStart, durationInBeats: c.durationInBeats }
117
+ const playbackChord = { ...transposed, beatStart: c.beatStart, durationInBeats: c.durationInBeats }
118
+ if (c.markerIndex !== undefined) playbackChord.markerIndex = c.markerIndex
119
+ return playbackChord
118
120
  }),
119
121
  }))
120
122
  : undefined
@@ -22,14 +22,12 @@ describe('buildPlaybackTimeline', () => {
22
22
  })
23
23
  })
24
24
 
25
- describe('barlines present — chords grouped into measures', () => {
26
- it('| G | C | D | creates 3 measures, 1 chord each', () => {
25
+ describe('barlines present — bars repeat measures (no multi-chord grouping)', () => {
26
+ it('| G | C | D | repeats each bar-marked chord', () => {
27
27
  const song = parse('TITLE\n\n| G | C | D |\n Lyrics')
28
28
  const playback = song.playback
29
- expect(playback.length).toBe(3)
30
- expect(playback[0].chords[0].root).toBe('G')
31
- expect(playback[1].chords[0].root).toBe('C')
32
- expect(playback[2].chords[0].root).toBe('D')
29
+ expect(playback.length).toBe(6)
30
+ expect(playback.map(m => m.chords[0].root)).toEqual(['G', 'G', 'C', 'C', 'D', 'D'])
33
31
  for (const m of playback) {
34
32
  expect(m.chords.length).toBe(1)
35
33
  expect(m.chords[0].beatStart).toBe(0)
@@ -46,19 +44,16 @@ describe('buildPlaybackTimeline', () => {
46
44
  expect(playback[2].chords[0].root).toBe('D')
47
45
  })
48
46
 
49
- it('| C D | G | groups by barlines when inter-barlines present', () => {
47
+ it('| C D | G | does not form multi-chord measures without brackets', () => {
50
48
  const song = parse('TITLE\n\n| C D | G |\n Lyrics')
51
49
  const playback = song.playback
52
- // Barline between D and G → barline grouping applies
53
- expect(playback.length).toBe(2)
54
- expect(playback[0].chords.length).toBe(2)
55
- expect(playback[0].chords[0].root).toBe('C')
56
- expect(playback[0].chords[0].beatStart).toBe(0)
57
- expect(playback[0].chords[0].durationInBeats).toBe(2)
58
- expect(playback[0].chords[1].root).toBe('D')
59
- expect(playback[0].chords[1].beatStart).toBe(2)
60
- expect(playback[0].chords[1].durationInBeats).toBe(2)
61
- expect(playback[1].chords[0].root).toBe('G')
50
+ expect(playback.length).toBe(5)
51
+ expect(playback.map(m => m.chords[0].root)).toEqual(['C', 'D', 'D', 'G', 'G'])
52
+ for (const m of playback) {
53
+ expect(m.chords.length).toBe(1)
54
+ expect(m.chords[0].beatStart).toBe(0)
55
+ expect(m.chords[0].durationInBeats).toBe(4)
56
+ }
62
57
  })
63
58
 
64
59
  it('sparse inter-barline usage stays chord-per-measure with bar repeat', () => {
@@ -75,6 +70,18 @@ describe('buildPlaybackTimeline', () => {
75
70
  expect(playback[7].chords[0].type).toBe('')
76
71
  })
77
72
 
73
+ it('leading bars on next line repeat carried chord context', () => {
74
+ const song = parse(
75
+ 'TITLE\n(3/4 time)\n\nC | F C\nA way out on-line\n | | G |\nA thousand miles from what I know as mine'
76
+ )
77
+ const playback = song.playback
78
+ const roots = playback.map(m => m.chords[0].root + (m.chords[0].bass ? '/' + m.chords[0].bass : ''))
79
+ expect(roots).toEqual(['C', 'C', 'F', 'C', 'C', 'C', 'G', 'G'])
80
+ for (const m of playback) {
81
+ expect(m.chords[0].durationInBeats).toBe(3)
82
+ }
83
+ })
84
+
78
85
  it('D | creates 2 measures: D then barline repeats D', () => {
79
86
  const song = parse('TITLE\n\nD |\n Lyrics')
80
87
  const playback = song.playback
@@ -145,15 +152,15 @@ describe('buildPlaybackTimeline', () => {
145
152
  expect(playback[0].timeSignature.beats).toBe(3)
146
153
  })
147
154
 
148
- it('inter-barline grouping divides beats by 3', () => {
155
+ it('barline repeats in 3/4 remain full-measure chords', () => {
149
156
  const song = parse('TITLE\n(3/4 time)\n\n| G | C D |\n Lyrics')
150
157
  const playback = song.playback
151
- expect(playback.length).toBe(2)
152
- expect(playback[0].chords[0].root).toBe('G')
153
- expect(playback[0].chords[0].durationInBeats).toBe(3)
154
- expect(playback[1].chords.length).toBe(2)
155
- expect(playback[1].chords[0].durationInBeats).toBe(1.5)
156
- expect(playback[1].chords[1].durationInBeats).toBe(1.5)
158
+ expect(playback.length).toBe(5)
159
+ expect(playback.map(m => m.chords[0].root)).toEqual(['G', 'G', 'C', 'D', 'D'])
160
+ for (const m of playback) {
161
+ expect(m.chords.length).toBe(1)
162
+ expect(m.chords[0].durationInBeats).toBe(3)
163
+ }
157
164
  })
158
165
  })
159
166
 
@@ -171,6 +178,22 @@ describe('buildPlaybackTimeline', () => {
171
178
  expect(song.playback[0].measureIndex).toBe(0)
172
179
  expect(song.playback[1].measureIndex).toBe(1)
173
180
  })
181
+
182
+ it('assigns playback markerIndex values for chords and bar repeats', () => {
183
+ const song = parse('TITLE\n\nC D |\n Lyrics')
184
+ expect(song.playback[0].chords[0].markerIndex).toBe(0) // C marker
185
+ expect(song.playback[1].chords[0].markerIndex).toBe(1) // D marker
186
+ expect(song.playback[2].chords[0].markerIndex).toBe(2) // trailing bar marker
187
+ })
188
+
189
+ it('assigns markerIndex for bar repeats without barline grouping', () => {
190
+ const song = parse('TITLE\n\n| C D | G |\n Lyrics')
191
+ expect(song.playback[0].chords[0].markerIndex).toBe(1) // C marker
192
+ expect(song.playback[1].chords[0].markerIndex).toBe(2) // D marker
193
+ expect(song.playback[2].chords[0].markerIndex).toBe(3) // repeated D at bar marker
194
+ expect(song.playback[3].chords[0].markerIndex).toBe(4) // G marker
195
+ expect(song.playback[4].chords[0].markerIndex).toBe(5) // repeated G at bar marker
196
+ })
174
197
  })
175
198
 
176
199
  describe('fixture: spent-some-time-in-buffalo.txt', () => {