songsheet 7.1.0 → 7.3.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.1.0",
3
+ "version": "7.3.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
 
@@ -63,6 +65,43 @@ function detectInterBarlines(markers) {
63
65
  return false
64
66
  }
65
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
+
66
105
  /**
67
106
  * Build the playback timeline from the song's structure array.
68
107
  *
@@ -90,21 +129,17 @@ export function buildPlaybackTimeline(structure, timeSignature) {
90
129
  markers.push({ col: chord.column, type: 'chord', chord })
91
130
  }
92
131
  for (const bar of line.barLines) {
93
- markers.push({ col: bar.column, type: 'bar' })
132
+ markers.push({ col: bar.column, type: 'bar', bar })
94
133
  }
95
134
  markers.sort((a, b) => a.col - b.col)
96
135
 
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)
101
-
102
- if (hasInterBarlines) {
136
+ if (shouldGroupByBarlines(markers)) {
103
137
  // Group chords by barline boundaries
104
138
  let currentChords = []
105
- for (const m of markers) {
139
+ for (let mi = 0; mi < markers.length; mi++) {
140
+ const m = markers[mi]
106
141
  if (m.type === 'chord') {
107
- currentChords.push(...expandChord(m.chord))
142
+ currentChords.push(...expandChord(m.chord, mi))
108
143
  } else {
109
144
  // Bar line: flush accumulated chords as a measure
110
145
  if (currentChords.length > 0) {
@@ -124,22 +159,36 @@ export function buildPlaybackTimeline(structure, timeSignature) {
124
159
  // No inter-barlines: each chord is its own measure.
125
160
  // Trailing barlines repeat the preceding chord for one additional measure.
126
161
  let lastExpanded = null
127
- for (const m of markers) {
162
+ for (let mi = 0; mi < markers.length; mi++) {
163
+ const m = markers[mi]
128
164
  if (m.type === 'chord') {
129
- lastExpanded = expandChord(m.chord)
165
+ lastExpanded = expandChord(m.chord, mi)
130
166
  measures.push(buildMeasure(lastExpanded, measureIndex, si, li, ts))
131
167
  measureIndex++
132
- } else if (m.type === 'bar' && lastExpanded) {
133
- measures.push(buildMeasure([...lastExpanded], measureIndex, si, li, ts))
134
- 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
+ }
135
183
  }
136
184
  }
137
185
  }
138
186
  }
139
187
  } else {
140
188
  // Expression-only entries (FILL, INSTRUMENTAL, etc.) — chords only, no lines
141
- for (const chord of entry.chords) {
142
- const expanded = expandChord(chord)
189
+ for (let ci = 0; ci < entry.chords.length; ci++) {
190
+ const chord = entry.chords[ci]
191
+ const expanded = expandChord(chord, ci)
143
192
  measures.push(buildMeasure(expanded, measureIndex, si, -1, ts))
144
193
  measureIndex++
145
194
  }
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
@@ -61,6 +61,32 @@ describe('buildPlaybackTimeline', () => {
61
61
  expect(playback[1].chords[0].root).toBe('G')
62
62
  })
63
63
 
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('leading bars on next line repeat carried chord context', () => {
79
+ const song = parse(
80
+ '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'
81
+ )
82
+ const playback = song.playback
83
+ const roots = playback.map(m => m.chords[0].root + (m.chords[0].bass ? '/' + m.chords[0].bass : ''))
84
+ expect(roots).toEqual(['C', 'C', 'F', 'C', 'C', 'C', 'G', 'G'])
85
+ for (const m of playback) {
86
+ expect(m.chords[0].durationInBeats).toBe(3)
87
+ }
88
+ })
89
+
64
90
  it('D | creates 2 measures: D then barline repeats D', () => {
65
91
  const song = parse('TITLE\n\nD |\n Lyrics')
66
92
  const playback = song.playback
@@ -157,6 +183,20 @@ describe('buildPlaybackTimeline', () => {
157
183
  expect(song.playback[0].measureIndex).toBe(0)
158
184
  expect(song.playback[1].measureIndex).toBe(1)
159
185
  })
186
+
187
+ it('assigns playback markerIndex values for chords and bar repeats', () => {
188
+ const song = parse('TITLE\n\nC D |\n Lyrics')
189
+ expect(song.playback[0].chords[0].markerIndex).toBe(0) // C marker
190
+ expect(song.playback[1].chords[0].markerIndex).toBe(1) // D marker
191
+ expect(song.playback[2].chords[0].markerIndex).toBe(2) // trailing bar marker
192
+ })
193
+
194
+ it('assigns markerIndex for grouped barline measures', () => {
195
+ const song = parse('TITLE\n\n| C D | G |\n Lyrics')
196
+ 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
199
+ })
160
200
  })
161
201
 
162
202
  describe('fixture: spent-some-time-in-buffalo.txt', () => {