songsheet 7.0.0 → 7.1.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.1.0",
4
4
  "description": "A songsheet interpreter",
5
5
  "type": "module",
6
6
  "exports": "./index.js",
package/src/playback.js CHANGED
@@ -44,6 +44,25 @@ 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
+
47
66
  /**
48
67
  * Build the playback timeline from the song's structure array.
49
68
  *
@@ -75,9 +94,12 @@ export function buildPlaybackTimeline(structure, timeSignature) {
75
94
  }
76
95
  markers.sort((a, b) => a.col - b.col)
77
96
 
78
- const hasBarlines = line.barLines.length > 0
97
+ // Detect whether barlines actually separate chords on this line.
98
+ // Pattern: chord...bar...chord means barlines are measure separators.
99
+ // Pattern: chord chord... bar (trailing only) means barlines are phrase markers.
100
+ const hasInterBarlines = detectInterBarlines(markers)
79
101
 
80
- if (hasBarlines) {
102
+ if (hasInterBarlines) {
81
103
  // Group chords by barline boundaries
82
104
  let currentChords = []
83
105
  for (const m of markers) {
@@ -99,11 +121,18 @@ export function buildPlaybackTimeline(structure, timeSignature) {
99
121
  measureIndex++
100
122
  }
101
123
  } 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++
124
+ // No inter-barlines: each chord is its own measure.
125
+ // Trailing barlines repeat the preceding chord for one additional measure.
126
+ let lastExpanded = null
127
+ for (const m of markers) {
128
+ if (m.type === 'chord') {
129
+ lastExpanded = expandChord(m.chord)
130
+ measures.push(buildMeasure(lastExpanded, measureIndex, si, li, ts))
131
+ measureIndex++
132
+ } else if (m.type === 'bar' && lastExpanded) {
133
+ measures.push(buildMeasure([...lastExpanded], measureIndex, si, li, ts))
134
+ measureIndex++
135
+ }
107
136
  }
108
137
  }
109
138
  }
@@ -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,24 @@ 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('D | creates 2 measures: D then barline repeats D', () => {
54
65
  const song = parse('TITLE\n\nD |\n Lyrics')
55
66
  const playback = song.playback
56
- expect(playback.length).toBe(1)
57
- expect(playback[0].chords.length).toBe(1)
67
+ expect(playback.length).toBe(2)
58
68
  expect(playback[0].chords[0].root).toBe('D')
59
- expect(playback[0].chords[0].durationInBeats).toBe(4)
69
+ expect(playback[1].chords[0].root).toBe('D')
60
70
  })
61
71
 
62
- it('leading | with no preceding chords is discarded', () => {
72
+ it('| G | with no inter-barlines: G then barline repeats G', () => {
63
73
  const song = parse('TITLE\n\n| G |\n Lyrics')
64
74
  const playback = song.playback
65
- expect(playback.length).toBe(1)
75
+ // Leading | has no preceding chord → ignored. G then trailing | repeats G.
76
+ expect(playback.length).toBe(2)
66
77
  expect(playback[0].chords[0].root).toBe('G')
78
+ expect(playback[1].chords[0].root).toBe('G')
67
79
  })
68
80
  })
69
81
 
@@ -111,16 +123,24 @@ describe('buildPlaybackTimeline', () => {
111
123
  })
112
124
 
113
125
  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')
126
+ it('uses 3 beats per measure', () => {
127
+ const song = parse('TITLE\n(3/4 time)\n\nG\n Lyrics')
116
128
  const playback = song.playback
117
129
  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)
130
+ expect(playback[0].chords[0].durationInBeats).toBe(3)
122
131
  expect(playback[0].timeSignature.beats).toBe(3)
123
132
  })
133
+
134
+ it('inter-barline grouping divides beats by 3', () => {
135
+ const song = parse('TITLE\n(3/4 time)\n\n| G | C D |\n Lyrics')
136
+ const playback = song.playback
137
+ expect(playback.length).toBe(2)
138
+ expect(playback[0].chords[0].root).toBe('G')
139
+ expect(playback[0].chords[0].durationInBeats).toBe(3)
140
+ expect(playback[1].chords.length).toBe(2)
141
+ expect(playback[1].chords[0].durationInBeats).toBe(1.5)
142
+ expect(playback[1].chords[1].durationInBeats).toBe(1.5)
143
+ })
124
144
  })
125
145
 
126
146
  describe('back-references', () => {
@@ -153,18 +173,14 @@ describe('buildPlaybackTimeline', () => {
153
173
  }
154
174
  })
155
175
 
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
176
+ it('trailing barlines repeat last chord: D|, G|, A C D| → D D G G A C D D', () => {
159
177
  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)
178
+ // Line 1: "D |" D, D (barline repeats)
179
+ // Line 2: "G |" G, G (barline repeats)
180
+ // Line 3: "A C D |" → A, C, D, D (barline repeats last)
181
+ expect(verseMeasures.length).toBe(8)
182
+ const roots = verseMeasures.map(m => m.chords[0].root)
183
+ expect(roots).toEqual(['D', 'D', 'G', 'G', 'A', 'C', 'D', 'D'])
168
184
  })
169
185
 
170
186
  it('measure indices are sequential', () => {