songsheet 7.3.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.3.0",
3
+ "version": "7.4.0",
4
4
  "description": "A songsheet interpreter",
5
5
  "type": "module",
6
6
  "exports": "./index.js",
package/src/playback.js CHANGED
@@ -46,62 +46,6 @@ function buildMeasure(chords, measureIndex, structureIndex, lineIndex, ts) {
46
46
  }
47
47
  }
48
48
 
49
- /**
50
- * Check if barlines on this line actually separate chords (e.g., | G | C | D |)
51
- * vs. just trailing after all chords (e.g., A C D |).
52
- * Returns true only when a barline appears between two chords.
53
- */
54
- function detectInterBarlines(markers) {
55
- let seenChord = false
56
- let seenBarAfterChord = false
57
- for (const m of markers) {
58
- if (m.type === 'chord') {
59
- if (seenBarAfterChord) return true
60
- seenChord = true
61
- } else if (m.type === 'bar' && seenChord) {
62
- seenBarAfterChord = true
63
- }
64
- }
65
- return false
66
- }
67
-
68
- /**
69
- * Detect consecutive barlines (e.g., C || D), which usually indicate
70
- * repeated held measures rather than explicit bar-delimited grouping.
71
- */
72
- function hasConsecutiveBars(markers) {
73
- let prevWasBar = false
74
- for (const m of markers) {
75
- if (m.type === 'bar') {
76
- if (prevWasBar) return true
77
- prevWasBar = true
78
- } else {
79
- prevWasBar = false
80
- }
81
- }
82
- return false
83
- }
84
-
85
- /**
86
- * Decide whether a line should be grouped by explicit barline boundaries.
87
- *
88
- * Heuristic:
89
- * - require at least one barline between chords
90
- * - require barline density to be at least chord density
91
- * - reject consecutive barlines (treated as hold repeats)
92
- *
93
- * Sparse barlines like "C C/B Am G F | Fsus4 F" stay in chord-per-measure mode.
94
- */
95
- function shouldGroupByBarlines(markers) {
96
- const chordCount = markers.filter(m => m.type === 'chord').length
97
- const barCount = markers.length - chordCount
98
- if (chordCount === 0 || barCount === 0) return false
99
- if (!detectInterBarlines(markers)) return false
100
- if (barCount < chordCount) return false
101
- if (hasConsecutiveBars(markers)) return false
102
- return true
103
- }
104
-
105
49
  /**
106
50
  * Build the playback timeline from the song's structure array.
107
51
  *
@@ -133,53 +77,30 @@ export function buildPlaybackTimeline(structure, timeSignature) {
133
77
  }
134
78
  markers.sort((a, b) => a.col - b.col)
135
79
 
136
- if (shouldGroupByBarlines(markers)) {
137
- // Group chords by barline boundaries
138
- let currentChords = []
139
- for (let mi = 0; mi < markers.length; mi++) {
140
- const m = markers[mi]
141
- if (m.type === 'chord') {
142
- currentChords.push(...expandChord(m.chord, mi))
143
- } else {
144
- // Bar line: flush accumulated chords as a measure
145
- if (currentChords.length > 0) {
146
- measures.push(buildMeasure(currentChords, measureIndex, si, li, ts))
147
- measureIndex++
148
- currentChords = []
149
- }
150
- // Leading barline with no preceding chords → discard (visual marker only)
151
- }
152
- }
153
- // Any remaining chords after the last barline form a measure
154
- if (currentChords.length > 0) {
155
- 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))
156
89
  measureIndex++
157
- }
158
- } else {
159
- // No inter-barlines: each chord is its own measure.
160
- // Trailing barlines repeat the preceding chord for one additional measure.
161
- let lastExpanded = null
162
- for (let mi = 0; mi < markers.length; mi++) {
163
- const m = markers[mi]
164
- if (m.type === 'chord') {
165
- lastExpanded = expandChord(m.chord, mi)
166
- 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))
167
102
  measureIndex++
168
- } else if (m.type === 'bar') {
169
- // Use parser-provided bar chord context when available.
170
- // This enables leading bars on a new line (e.g. "| | G |")
171
- // to repeat the previous line's carried chord.
172
- let repeatedAtBar = null
173
- if (m.bar && m.bar.chord) {
174
- repeatedAtBar = expandChord(m.bar.chord, mi)
175
- } else if (lastExpanded) {
176
- repeatedAtBar = lastExpanded.map(c => ({ ...c, markerIndex: mi }))
177
- }
178
- if (repeatedAtBar) {
179
- measures.push(buildMeasure(repeatedAtBar, measureIndex, si, li, ts))
180
- measureIndex++
181
- lastExpanded = repeatedAtBar
182
- }
103
+ lastExpanded = repeatedAtBar
183
104
  }
184
105
  }
185
106
  }
@@ -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', () => {
@@ -157,15 +152,15 @@ describe('buildPlaybackTimeline', () => {
157
152
  expect(playback[0].timeSignature.beats).toBe(3)
158
153
  })
159
154
 
160
- it('inter-barline grouping divides beats by 3', () => {
155
+ it('barline repeats in 3/4 remain full-measure chords', () => {
161
156
  const song = parse('TITLE\n(3/4 time)\n\n| G | C D |\n Lyrics')
162
157
  const playback = song.playback
163
- expect(playback.length).toBe(2)
164
- expect(playback[0].chords[0].root).toBe('G')
165
- expect(playback[0].chords[0].durationInBeats).toBe(3)
166
- expect(playback[1].chords.length).toBe(2)
167
- expect(playback[1].chords[0].durationInBeats).toBe(1.5)
168
- 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
+ }
169
164
  })
170
165
  })
171
166
 
@@ -191,11 +186,13 @@ describe('buildPlaybackTimeline', () => {
191
186
  expect(song.playback[2].chords[0].markerIndex).toBe(2) // trailing bar marker
192
187
  })
193
188
 
194
- it('assigns markerIndex for grouped barline measures', () => {
189
+ it('assigns markerIndex for bar repeats without barline grouping', () => {
195
190
  const song = parse('TITLE\n\n| C D | G |\n Lyrics')
196
191
  expect(song.playback[0].chords[0].markerIndex).toBe(1) // C marker
197
- expect(song.playback[0].chords[1].markerIndex).toBe(2) // D marker
198
- expect(song.playback[1].chords[0].markerIndex).toBe(4) // G 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
199
196
  })
200
197
  })
201
198