songsheet 7.2.0 → 7.4.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/CLAUDE.md +30 -3
- package/README.md +42 -3
- package/package.json +1 -1
- package/src/notation.js +3 -1
- package/src/playback.js +29 -91
- package/src/transpose.js +3 -1
- package/test/playback.test.js +47 -24
package/CLAUDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Songsheet
|
|
2
2
|
|
|
3
|
-
A zero-dependency songsheet parser and transposer. Parses plaintext songsheet files into a structured AST with chord-lyric character alignment.
|
|
3
|
+
A zero-dependency songsheet parser and transposer. Parses plaintext songsheet files into a structured AST with chord-lyric character alignment and a playback timeline.
|
|
4
4
|
|
|
5
5
|
## Quick Start
|
|
6
6
|
|
|
@@ -15,16 +15,20 @@ const songInBb = transpose(song, 3, { preferFlats: true })
|
|
|
15
15
|
## Architecture
|
|
16
16
|
|
|
17
17
|
```
|
|
18
|
-
index.js — Public API:
|
|
18
|
+
index.js — Public API: parse, transpose, toNashville, toStandard, buildPlaybackTimeline
|
|
19
19
|
index.d.ts — TypeScript type definitions (adjacent to index.js)
|
|
20
20
|
src/
|
|
21
21
|
lexer.js — scanChordLine(), isChordLine(), lexExpression()
|
|
22
22
|
parser.js — parse(), expression parser, section assembly
|
|
23
|
+
playback.js — buildPlaybackTimeline(), barline/measure expansion
|
|
24
|
+
notation.js — toNashville(), toStandard()
|
|
23
25
|
transpose.js — transpose(), note math
|
|
24
26
|
test/
|
|
25
27
|
lexer.test.js — Chord line detection, bar lines, slash chords, edge cases
|
|
26
28
|
parser.test.js — All 4 song fixture tests + bar line + time signature tests
|
|
27
29
|
expression.test.js — Expression parsing and resolution
|
|
30
|
+
playback.test.js — Timeline generation, barline semantics, marker indexing
|
|
31
|
+
nns.test.js — Nashville Number System parsing/conversion coverage
|
|
28
32
|
transpose.test.js — Semitone math, round-trips, flat/sharp preference, slash chords
|
|
29
33
|
*.txt — Song fixtures (do not modify)
|
|
30
34
|
```
|
|
@@ -62,10 +66,13 @@ npx vitest run test/parser.test.js # single file
|
|
|
62
66
|
lines: [
|
|
63
67
|
{
|
|
64
68
|
chords: [{ root: 'G', type: '', column: 0 }, ...],
|
|
65
|
-
barLines: [
|
|
69
|
+
barLines: [
|
|
70
|
+
{ column: 12, chord: { root: 'G', type: '' } } // optional carried context
|
|
71
|
+
],
|
|
66
72
|
lyrics: 'lyric line 1',
|
|
67
73
|
characters: [
|
|
68
74
|
{ character: 'B', chord: { root: 'G', type: '' } },
|
|
75
|
+
{ character: '|', barLine: true },
|
|
69
76
|
{ character: 'l' },
|
|
70
77
|
...
|
|
71
78
|
]
|
|
@@ -84,6 +91,23 @@ npx vitest run test/parser.test.js # single file
|
|
|
84
91
|
expression: null, // non-null on directive entries
|
|
85
92
|
},
|
|
86
93
|
...
|
|
94
|
+
],
|
|
95
|
+
playback: [
|
|
96
|
+
{
|
|
97
|
+
measureIndex: 0,
|
|
98
|
+
structureIndex: 0,
|
|
99
|
+
lineIndex: 0,
|
|
100
|
+
timeSignature: { beats: 3, value: 4 },
|
|
101
|
+
chords: [
|
|
102
|
+
{
|
|
103
|
+
root: 'G',
|
|
104
|
+
type: '',
|
|
105
|
+
markerIndex: 0, // marker index in merged chord/bar marker row (optional)
|
|
106
|
+
beatStart: 0,
|
|
107
|
+
durationInBeats: 3
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
}
|
|
87
111
|
]
|
|
88
112
|
}
|
|
89
113
|
```
|
|
@@ -96,8 +120,11 @@ npx vitest run test/parser.test.js # single file
|
|
|
96
120
|
- **Synchronous parse**: No Promise wrapper, plain objects (no Immutable.js)
|
|
97
121
|
- **Expression AST preserved**: `(VERSE, CHORUS*2)` stored as tree AND resolved to flat chords
|
|
98
122
|
- **Character alignment includes barLines**: `{ character: 'r', barLine: true }` at `|` column positions
|
|
123
|
+
- **Barline context is carried across chord lines**: leading `|` on a new line can repeat the prior chord
|
|
99
124
|
- **Column preservation**: Chord lines are never trimmed — column positions match the original file
|
|
100
125
|
- **Title metadata**: BPM and time signature parsed from `(120 BPM, 3/4 time)` in the title block
|
|
126
|
+
- **Playback timeline**: parser output includes `playback[]` measures with `beatStart`/`durationInBeats` and optional `markerIndex` for UI highlighting
|
|
127
|
+
- **Strict bracket split syntax**: only `[A B ...]` creates multi-chord measures; `|` markers repeat measures and never group adjacent chords
|
|
101
128
|
- **TypeScript types**: `index.d.ts` adjacent to `index.js` — consumers get types automatically with bundler module resolution
|
|
102
129
|
|
|
103
130
|
## Songsheet Format
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# songsheet
|
|
2
2
|
|
|
3
|
-
A zero-dependency songsheet parser and transposer. Parses plaintext songsheet files into a structured AST with chord-lyric
|
|
3
|
+
A zero-dependency songsheet parser and transposer. Parses plaintext songsheet files into a structured AST with chord-lyric alignment and builds a playback timeline.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -111,10 +111,13 @@ Examples: `(VERSE, CHORUS*2)`, `(D G D A)*4`, `D G D A D`
|
|
|
111
111
|
lines: [
|
|
112
112
|
{
|
|
113
113
|
chords: [{ root: 'G', type: '', column: 0 }, ...],
|
|
114
|
-
barLines: [
|
|
114
|
+
barLines: [ // | markers with optional carried chord context
|
|
115
|
+
{ column: 12, chord: { root: 'G', type: '' } }
|
|
116
|
+
],
|
|
115
117
|
lyrics: 'lyric line 1',
|
|
116
118
|
characters: [
|
|
117
119
|
{ character: 'B', chord: { root: 'G', type: '' } },
|
|
120
|
+
{ character: '|', barLine: true },
|
|
118
121
|
{ character: 'l' },
|
|
119
122
|
...
|
|
120
123
|
]
|
|
@@ -135,13 +138,49 @@ Examples: `(VERSE, CHORUS*2)`, `(D G D A)*4`, `D G D A D`
|
|
|
135
138
|
expression: null, // non-null on directive entries
|
|
136
139
|
},
|
|
137
140
|
...
|
|
141
|
+
],
|
|
142
|
+
|
|
143
|
+
// Flattened playback timeline (measure-by-measure)
|
|
144
|
+
playback: [
|
|
145
|
+
{
|
|
146
|
+
measureIndex: 0,
|
|
147
|
+
structureIndex: 0,
|
|
148
|
+
lineIndex: 0,
|
|
149
|
+
timeSignature: { beats: 3, value: 4 },
|
|
150
|
+
chords: [
|
|
151
|
+
{
|
|
152
|
+
root: 'G',
|
|
153
|
+
type: '',
|
|
154
|
+
markerIndex: 0, // marker index in the rendered line (optional)
|
|
155
|
+
beatStart: 0,
|
|
156
|
+
durationInBeats: 3
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
}
|
|
138
160
|
]
|
|
139
161
|
}
|
|
140
162
|
```
|
|
141
163
|
|
|
164
|
+
## Playback Barline Semantics
|
|
165
|
+
|
|
166
|
+
Playback is derived from each line's chords and `|` markers using these rules:
|
|
167
|
+
|
|
168
|
+
1. Each chord token is its own measure unless written as bracket split syntax (`[C D]`).
|
|
169
|
+
2. Each `|` repeats a measure.
|
|
170
|
+
3. Barline repeats use chord context carried by the parser, so leading bars on a line can repeat the previous line's chord.
|
|
171
|
+
|
|
172
|
+
Examples:
|
|
173
|
+
|
|
174
|
+
- `[C D]` -> one measure split evenly between `C` and `D`
|
|
175
|
+
- `C D |` -> `C`, `D`, `D`
|
|
176
|
+
- `| C D |` -> `C`, `D`, `D`
|
|
177
|
+
- `| G | C | D |` -> `G`, `G`, `C`, `C`, `D`, `D`
|
|
178
|
+
- `C C/B Am G F | Fsus4 F` -> `C`, `C/B`, `Am`, `G`, `F`, `F`, `Fsus4`, `F`
|
|
179
|
+
- `C | F C` then `| | G |` -> `C`, `C`, `F`, `C`, `C`, `C`, `G`, `G`
|
|
180
|
+
|
|
142
181
|
## Transposition
|
|
143
182
|
|
|
144
|
-
`transpose()` deep-walks the AST and replaces every chord root (and bass note on slash chords). It auto-detects whether the song uses flats or sharps, or you can override with `{ preferFlats: true }`.
|
|
183
|
+
`transpose()` deep-walks the AST and replaces every chord root (and bass note on slash chords). It auto-detects whether the song uses flats or sharps, or you can override with `{ preferFlats: true }`. Playback timing metadata (`beatStart`, `durationInBeats`, `markerIndex`) is preserved.
|
|
145
184
|
|
|
146
185
|
```js
|
|
147
186
|
const song = parse(rawText)
|
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
|
|
|
@@ -44,62 +46,6 @@ function buildMeasure(chords, measureIndex, structureIndex, lineIndex, ts) {
|
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
|
|
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
|
-
|
|
103
49
|
/**
|
|
104
50
|
* Build the playback timeline from the song's structure array.
|
|
105
51
|
*
|
|
@@ -127,51 +73,43 @@ export function buildPlaybackTimeline(structure, timeSignature) {
|
|
|
127
73
|
markers.push({ col: chord.column, type: 'chord', chord })
|
|
128
74
|
}
|
|
129
75
|
for (const bar of line.barLines) {
|
|
130
|
-
markers.push({ col: bar.column, type: 'bar' })
|
|
76
|
+
markers.push({ col: bar.column, type: 'bar', bar })
|
|
131
77
|
}
|
|
132
78
|
markers.sort((a, b) => a.col - b.col)
|
|
133
79
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
measures.push(buildMeasure(currentChords, measureIndex, si, li, ts))
|
|
144
|
-
measureIndex++
|
|
145
|
-
currentChords = []
|
|
146
|
-
}
|
|
147
|
-
// Leading barline with no preceding chords → discard (visual marker only)
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
// Any remaining chords after the last barline form a measure
|
|
151
|
-
if (currentChords.length > 0) {
|
|
152
|
-
measures.push(buildMeasure(currentChords, measureIndex, si, li, ts))
|
|
80
|
+
// Strict bracket syntax: only [A B ...] creates multi-chord measures.
|
|
81
|
+
// Barlines never group multiple adjacent chord tokens into one measure;
|
|
82
|
+
// instead they repeat the current/last carried chord for another measure.
|
|
83
|
+
let lastExpanded = null
|
|
84
|
+
for (let mi = 0; mi < markers.length; mi++) {
|
|
85
|
+
const m = markers[mi]
|
|
86
|
+
if (m.type === 'chord') {
|
|
87
|
+
lastExpanded = expandChord(m.chord, mi)
|
|
88
|
+
measures.push(buildMeasure(lastExpanded, measureIndex, si, li, ts))
|
|
153
89
|
measureIndex++
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
measures.push(buildMeasure(
|
|
90
|
+
} else if (m.type === 'bar') {
|
|
91
|
+
// Use parser-provided bar chord context when available.
|
|
92
|
+
// This enables leading bars on a new line (e.g. "| | G |")
|
|
93
|
+
// to repeat the previous line's carried chord.
|
|
94
|
+
let repeatedAtBar = null
|
|
95
|
+
if (m.bar && m.bar.chord) {
|
|
96
|
+
repeatedAtBar = expandChord(m.bar.chord, mi)
|
|
97
|
+
} else if (lastExpanded) {
|
|
98
|
+
repeatedAtBar = lastExpanded.map(c => ({ ...c, markerIndex: mi }))
|
|
99
|
+
}
|
|
100
|
+
if (repeatedAtBar) {
|
|
101
|
+
measures.push(buildMeasure(repeatedAtBar, measureIndex, si, li, ts))
|
|
166
102
|
measureIndex++
|
|
103
|
+
lastExpanded = repeatedAtBar
|
|
167
104
|
}
|
|
168
105
|
}
|
|
169
106
|
}
|
|
170
107
|
}
|
|
171
108
|
} else {
|
|
172
109
|
// Expression-only entries (FILL, INSTRUMENTAL, etc.) — chords only, no lines
|
|
173
|
-
for (
|
|
174
|
-
const
|
|
110
|
+
for (let ci = 0; ci < entry.chords.length; ci++) {
|
|
111
|
+
const chord = entry.chords[ci]
|
|
112
|
+
const expanded = expandChord(chord, ci)
|
|
175
113
|
measures.push(buildMeasure(expanded, measureIndex, si, -1, ts))
|
|
176
114
|
measureIndex++
|
|
177
115
|
}
|
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
|
@@ -22,14 +22,12 @@ describe('buildPlaybackTimeline', () => {
|
|
|
22
22
|
})
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
describe('barlines present —
|
|
26
|
-
it('| G | C | D |
|
|
25
|
+
describe('barlines present — bars repeat measures (no multi-chord grouping)', () => {
|
|
26
|
+
it('| G | C | D | repeats each bar-marked chord', () => {
|
|
27
27
|
const song = parse('TITLE\n\n| G | C | D |\n Lyrics')
|
|
28
28
|
const playback = song.playback
|
|
29
|
-
expect(playback.length).toBe(
|
|
30
|
-
expect(playback
|
|
31
|
-
expect(playback[1].chords[0].root).toBe('C')
|
|
32
|
-
expect(playback[2].chords[0].root).toBe('D')
|
|
29
|
+
expect(playback.length).toBe(6)
|
|
30
|
+
expect(playback.map(m => m.chords[0].root)).toEqual(['G', 'G', 'C', 'C', 'D', 'D'])
|
|
33
31
|
for (const m of playback) {
|
|
34
32
|
expect(m.chords.length).toBe(1)
|
|
35
33
|
expect(m.chords[0].beatStart).toBe(0)
|
|
@@ -46,19 +44,16 @@ describe('buildPlaybackTimeline', () => {
|
|
|
46
44
|
expect(playback[2].chords[0].root).toBe('D')
|
|
47
45
|
})
|
|
48
46
|
|
|
49
|
-
it('| C D | G |
|
|
47
|
+
it('| C D | G | does not form multi-chord measures without brackets', () => {
|
|
50
48
|
const song = parse('TITLE\n\n| C D | G |\n Lyrics')
|
|
51
49
|
const playback = song.playback
|
|
52
|
-
|
|
53
|
-
expect(playback.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
expect(playback[0].chords[1].beatStart).toBe(2)
|
|
60
|
-
expect(playback[0].chords[1].durationInBeats).toBe(2)
|
|
61
|
-
expect(playback[1].chords[0].root).toBe('G')
|
|
50
|
+
expect(playback.length).toBe(5)
|
|
51
|
+
expect(playback.map(m => m.chords[0].root)).toEqual(['C', 'D', 'D', 'G', 'G'])
|
|
52
|
+
for (const m of playback) {
|
|
53
|
+
expect(m.chords.length).toBe(1)
|
|
54
|
+
expect(m.chords[0].beatStart).toBe(0)
|
|
55
|
+
expect(m.chords[0].durationInBeats).toBe(4)
|
|
56
|
+
}
|
|
62
57
|
})
|
|
63
58
|
|
|
64
59
|
it('sparse inter-barline usage stays chord-per-measure with bar repeat', () => {
|
|
@@ -75,6 +70,18 @@ describe('buildPlaybackTimeline', () => {
|
|
|
75
70
|
expect(playback[7].chords[0].type).toBe('')
|
|
76
71
|
})
|
|
77
72
|
|
|
73
|
+
it('leading bars on next line repeat carried chord context', () => {
|
|
74
|
+
const song = parse(
|
|
75
|
+
'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'
|
|
76
|
+
)
|
|
77
|
+
const playback = song.playback
|
|
78
|
+
const roots = playback.map(m => m.chords[0].root + (m.chords[0].bass ? '/' + m.chords[0].bass : ''))
|
|
79
|
+
expect(roots).toEqual(['C', 'C', 'F', 'C', 'C', 'C', 'G', 'G'])
|
|
80
|
+
for (const m of playback) {
|
|
81
|
+
expect(m.chords[0].durationInBeats).toBe(3)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
78
85
|
it('D | creates 2 measures: D then barline repeats D', () => {
|
|
79
86
|
const song = parse('TITLE\n\nD |\n Lyrics')
|
|
80
87
|
const playback = song.playback
|
|
@@ -145,15 +152,15 @@ describe('buildPlaybackTimeline', () => {
|
|
|
145
152
|
expect(playback[0].timeSignature.beats).toBe(3)
|
|
146
153
|
})
|
|
147
154
|
|
|
148
|
-
it('
|
|
155
|
+
it('barline repeats in 3/4 remain full-measure chords', () => {
|
|
149
156
|
const song = parse('TITLE\n(3/4 time)\n\n| G | C D |\n Lyrics')
|
|
150
157
|
const playback = song.playback
|
|
151
|
-
expect(playback.length).toBe(
|
|
152
|
-
expect(playback
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
158
|
+
expect(playback.length).toBe(5)
|
|
159
|
+
expect(playback.map(m => m.chords[0].root)).toEqual(['G', 'G', 'C', 'D', 'D'])
|
|
160
|
+
for (const m of playback) {
|
|
161
|
+
expect(m.chords.length).toBe(1)
|
|
162
|
+
expect(m.chords[0].durationInBeats).toBe(3)
|
|
163
|
+
}
|
|
157
164
|
})
|
|
158
165
|
})
|
|
159
166
|
|
|
@@ -171,6 +178,22 @@ describe('buildPlaybackTimeline', () => {
|
|
|
171
178
|
expect(song.playback[0].measureIndex).toBe(0)
|
|
172
179
|
expect(song.playback[1].measureIndex).toBe(1)
|
|
173
180
|
})
|
|
181
|
+
|
|
182
|
+
it('assigns playback markerIndex values for chords and bar repeats', () => {
|
|
183
|
+
const song = parse('TITLE\n\nC D |\n Lyrics')
|
|
184
|
+
expect(song.playback[0].chords[0].markerIndex).toBe(0) // C marker
|
|
185
|
+
expect(song.playback[1].chords[0].markerIndex).toBe(1) // D marker
|
|
186
|
+
expect(song.playback[2].chords[0].markerIndex).toBe(2) // trailing bar marker
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('assigns markerIndex for bar repeats without barline grouping', () => {
|
|
190
|
+
const song = parse('TITLE\n\n| C D | G |\n Lyrics')
|
|
191
|
+
expect(song.playback[0].chords[0].markerIndex).toBe(1) // C marker
|
|
192
|
+
expect(song.playback[1].chords[0].markerIndex).toBe(2) // D marker
|
|
193
|
+
expect(song.playback[2].chords[0].markerIndex).toBe(3) // repeated D at bar marker
|
|
194
|
+
expect(song.playback[3].chords[0].markerIndex).toBe(4) // G marker
|
|
195
|
+
expect(song.playback[4].chords[0].markerIndex).toBe(5) // repeated G at bar marker
|
|
196
|
+
})
|
|
174
197
|
})
|
|
175
198
|
|
|
176
199
|
describe('fixture: spent-some-time-in-buffalo.txt', () => {
|