songsheet 7.0.0 → 7.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "songsheet",
3
- "version": "7.0.0",
3
+ "version": "7.2.0",
4
4
  "description": "A songsheet interpreter",
5
5
  "type": "module",
6
6
  "exports": "./index.js",
package/src/playback.js CHANGED
@@ -44,6 +44,62 @@ function buildMeasure(chords, measureIndex, structureIndex, lineIndex, ts) {
44
44
  }
45
45
  }
46
46
 
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
+
47
103
  /**
48
104
  * Build the playback timeline from the song's structure array.
49
105
  *
@@ -75,9 +131,7 @@ export function buildPlaybackTimeline(structure, timeSignature) {
75
131
  }
76
132
  markers.sort((a, b) => a.col - b.col)
77
133
 
78
- const hasBarlines = line.barLines.length > 0
79
-
80
- if (hasBarlines) {
134
+ if (shouldGroupByBarlines(markers)) {
81
135
  // Group chords by barline boundaries
82
136
  let currentChords = []
83
137
  for (const m of markers) {
@@ -99,11 +153,18 @@ export function buildPlaybackTimeline(structure, timeSignature) {
99
153
  measureIndex++
100
154
  }
101
155
  } else {
102
- // No barlines: each chord is its own measure
103
- for (const chord of line.chords) {
104
- const expanded = expandChord(chord)
105
- measures.push(buildMeasure(expanded, measureIndex, si, li, ts))
106
- measureIndex++
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))
166
+ measureIndex++
167
+ }
107
168
  }
108
169
  }
109
170
  }
@@ -37,10 +37,20 @@ describe('buildPlaybackTimeline', () => {
37
37
  }
38
38
  })
39
39
 
40
- it('| C D | creates 1 measure with 2 chords at beats 0 and 2', () => {
41
- const song = parse('TITLE\n\n| C D |\n Lyrics')
40
+ it('trailing barline repeats last chord: C D | C, D, D', () => {
41
+ const song = parse('TITLE\n\nC D |\n Lyrics')
42
42
  const playback = song.playback
43
- expect(playback.length).toBe(1)
43
+ expect(playback.length).toBe(3)
44
+ expect(playback[0].chords[0].root).toBe('C')
45
+ expect(playback[1].chords[0].root).toBe('D')
46
+ expect(playback[2].chords[0].root).toBe('D')
47
+ })
48
+
49
+ it('| C D | G | groups by barlines when inter-barlines present', () => {
50
+ const song = parse('TITLE\n\n| C D | G |\n Lyrics')
51
+ const playback = song.playback
52
+ // Barline between D and G → barline grouping applies
53
+ expect(playback.length).toBe(2)
44
54
  expect(playback[0].chords.length).toBe(2)
45
55
  expect(playback[0].chords[0].root).toBe('C')
46
56
  expect(playback[0].chords[0].beatStart).toBe(0)
@@ -48,22 +58,38 @@ describe('buildPlaybackTimeline', () => {
48
58
  expect(playback[0].chords[1].root).toBe('D')
49
59
  expect(playback[0].chords[1].beatStart).toBe(2)
50
60
  expect(playback[0].chords[1].durationInBeats).toBe(2)
61
+ expect(playback[1].chords[0].root).toBe('G')
51
62
  })
52
63
 
53
- it('D | creates 1 measure with D getting full measure', () => {
64
+ it('sparse inter-barline usage stays chord-per-measure with bar repeat', () => {
65
+ const song = parse('TITLE\n(3/4 time)\n\nC C/B Am G F | Fsus4 F\n Lyrics')
66
+ const playback = song.playback
67
+ expect(playback.length).toBe(8)
68
+ const roots = playback.map(m => m.chords[0].root + (m.chords[0].bass ? '/' + m.chords[0].bass : ''))
69
+ expect(roots).toEqual(['C', 'C/B', 'A', 'G', 'F', 'F', 'F', 'F'])
70
+ for (const m of playback) {
71
+ expect(m.chords.length).toBe(1)
72
+ expect(m.chords[0].durationInBeats).toBe(3)
73
+ }
74
+ expect(playback[6].chords[0].type).toBe('sus4')
75
+ expect(playback[7].chords[0].type).toBe('')
76
+ })
77
+
78
+ it('D | creates 2 measures: D then barline repeats D', () => {
54
79
  const song = parse('TITLE\n\nD |\n Lyrics')
55
80
  const playback = song.playback
56
- expect(playback.length).toBe(1)
57
- expect(playback[0].chords.length).toBe(1)
81
+ expect(playback.length).toBe(2)
58
82
  expect(playback[0].chords[0].root).toBe('D')
59
- expect(playback[0].chords[0].durationInBeats).toBe(4)
83
+ expect(playback[1].chords[0].root).toBe('D')
60
84
  })
61
85
 
62
- it('leading | with no preceding chords is discarded', () => {
86
+ it('| G | with no inter-barlines: G then barline repeats G', () => {
63
87
  const song = parse('TITLE\n\n| G |\n Lyrics')
64
88
  const playback = song.playback
65
- expect(playback.length).toBe(1)
89
+ // Leading | has no preceding chord → ignored. G then trailing | repeats G.
90
+ expect(playback.length).toBe(2)
66
91
  expect(playback[0].chords[0].root).toBe('G')
92
+ expect(playback[1].chords[0].root).toBe('G')
67
93
  })
68
94
  })
69
95
 
@@ -111,16 +137,24 @@ describe('buildPlaybackTimeline', () => {
111
137
  })
112
138
 
113
139
  describe('3/4 time signature', () => {
114
- it('divides beats by 3', () => {
115
- const song = parse('TITLE\n(3/4 time)\n\n| G C D |\n Lyrics')
140
+ it('uses 3 beats per measure', () => {
141
+ const song = parse('TITLE\n(3/4 time)\n\nG\n Lyrics')
116
142
  const playback = song.playback
117
143
  expect(playback.length).toBe(1)
118
- expect(playback[0].chords.length).toBe(3)
119
- expect(playback[0].chords[0].durationInBeats).toBe(1)
120
- expect(playback[0].chords[1].durationInBeats).toBe(1)
121
- expect(playback[0].chords[2].durationInBeats).toBe(1)
144
+ expect(playback[0].chords[0].durationInBeats).toBe(3)
122
145
  expect(playback[0].timeSignature.beats).toBe(3)
123
146
  })
147
+
148
+ it('inter-barline grouping divides beats by 3', () => {
149
+ const song = parse('TITLE\n(3/4 time)\n\n| G | C D |\n Lyrics')
150
+ 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)
157
+ })
124
158
  })
125
159
 
126
160
  describe('back-references', () => {
@@ -153,18 +187,14 @@ describe('buildPlaybackTimeline', () => {
153
187
  }
154
188
  })
155
189
 
156
- it('uses barline grouping for chord lines with barlines', () => {
157
- // The verse has patterns like "D |" — D followed by barline = 1 measure
158
- // Check that the first structure entry (verse) doesn't duplicate chords at barlines
190
+ it('trailing barlines repeat last chord: D|, G|, A C D| → D D G G A C D D', () => {
159
191
  const verseMeasures = song.playback.filter(m => m.structureIndex === 0)
160
- // Verse line 1: "D |" 1 chord D before barline = 1 measure
161
- // Verse line 2: "G |" 1 chord G before barline = 1 measure
162
- // Verse line 3: " A C D |"
163
- // — 3 chords (A, C, D) before barline = 1 measure with 3 chords
164
- expect(verseMeasures.length).toBe(3)
165
- expect(verseMeasures[0].chords[0].root).toBe('D')
166
- expect(verseMeasures[1].chords[0].root).toBe('G')
167
- expect(verseMeasures[2].chords.length).toBe(3)
192
+ // Line 1: "D |" D, D (barline repeats)
193
+ // Line 2: "G |" G, G (barline repeats)
194
+ // Line 3: "A C D |" → A, C, D, D (barline repeats last)
195
+ expect(verseMeasures.length).toBe(8)
196
+ const roots = verseMeasures.map(m => m.chords[0].root)
197
+ expect(roots).toEqual(['D', 'D', 'G', 'G', 'A', 'C', 'D', 'D'])
168
198
  })
169
199
 
170
200
  it('measure indices are sequential', () => {