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 +1 -1
- package/src/notation.js +3 -1
- package/src/playback.js +66 -17
- package/src/transpose.js +3 -1
- package/test/playback.test.js +40 -0
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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'
|
|
133
|
-
|
|
134
|
-
|
|
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 (
|
|
142
|
-
const
|
|
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
|
-
|
|
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
|
package/test/playback.test.js
CHANGED
|
@@ -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', () => {
|