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 +1 -1
- package/src/playback.js +69 -8
- package/test/playback.test.js +56 -26
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
}
|
package/test/playback.test.js
CHANGED
|
@@ -37,10 +37,20 @@ describe('buildPlaybackTimeline', () => {
|
|
|
37
37
|
}
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
-
it('
|
|
41
|
-
const song = parse('TITLE\n\
|
|
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(
|
|
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('
|
|
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(
|
|
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[
|
|
83
|
+
expect(playback[1].chords[0].root).toBe('D')
|
|
60
84
|
})
|
|
61
85
|
|
|
62
|
-
it('
|
|
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
|
-
|
|
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('
|
|
115
|
-
const song = parse('TITLE\n(3/4 time)\n\n
|
|
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.
|
|
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('
|
|
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
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
expect(
|
|
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', () => {
|