songsheet 6.3.0 → 7.0.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 +0 -27
- package/index.d.ts +22 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/src/lexer.js +2 -2
- package/src/notation.js +12 -0
- package/src/parser.js +3 -1
- package/src/playback.js +121 -0
- package/src/transpose.js +12 -0
- package/test/playback.test.js +217 -0
package/CLAUDE.md
CHANGED
|
@@ -149,30 +149,3 @@ Atom = SectionRef | ChordList | '(' Sequence ')'
|
|
|
149
149
|
```
|
|
150
150
|
|
|
151
151
|
Examples: `(VERSE, CHORUS*2)`, `(D G D A)*4`, `D G D A D`
|
|
152
|
-
|
|
153
|
-
## Future Features
|
|
154
|
-
|
|
155
|
-
### Percentage-based timing (`%`)
|
|
156
|
-
Support `%` notation for specifying timing or duration within a measure:
|
|
157
|
-
|
|
158
|
-
```
|
|
159
|
-
G%50 C%50 — split measure: 50% G, 50% C
|
|
160
|
-
D%75 G%25 — 3/4 D, 1/4 G
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
This would add a `duration` or `percent` field to chord objects:
|
|
164
|
-
```js
|
|
165
|
-
{ root: 'G', type: '', percent: 50 }
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
### Key detection
|
|
169
|
-
Auto-detect the song's key from its chord progression. Useful for intelligent transposition suggestions.
|
|
170
|
-
|
|
171
|
-
### Measure/bar grouping
|
|
172
|
-
Currently `|` markers are tracked by column position. Future: group chords into explicit measures with bar lines as structural delimiters.
|
|
173
|
-
|
|
174
|
-
### Multi-voice / harmony lines
|
|
175
|
-
Support for parallel harmony notation — multiple chord lines mapped to the same lyric line.
|
|
176
|
-
|
|
177
|
-
### Nashville number system output
|
|
178
|
-
Convert chord roots to Nashville numbers (1, 2, 3...) relative to the detected or specified key.
|
package/index.d.ts
CHANGED
|
@@ -58,6 +58,26 @@ export interface TimeSignature {
|
|
|
58
58
|
value: number
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export interface PlaybackChord {
|
|
62
|
+
root: string
|
|
63
|
+
type: string
|
|
64
|
+
bass?: string
|
|
65
|
+
nashville?: boolean
|
|
66
|
+
diamond?: boolean
|
|
67
|
+
push?: boolean
|
|
68
|
+
stop?: boolean
|
|
69
|
+
beatStart: number
|
|
70
|
+
durationInBeats: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PlaybackMeasure {
|
|
74
|
+
measureIndex: number
|
|
75
|
+
structureIndex: number
|
|
76
|
+
lineIndex: number
|
|
77
|
+
timeSignature: TimeSignature
|
|
78
|
+
chords: PlaybackChord[]
|
|
79
|
+
}
|
|
80
|
+
|
|
61
81
|
export interface Song {
|
|
62
82
|
title: string
|
|
63
83
|
author: string
|
|
@@ -66,9 +86,11 @@ export interface Song {
|
|
|
66
86
|
key: string | null
|
|
67
87
|
sections: Record<string, Section>
|
|
68
88
|
structure: StructureEntry[]
|
|
89
|
+
playback: PlaybackMeasure[]
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
export function parse(raw: string): Song
|
|
72
93
|
export function transpose(song: Song, semitones: number, options?: { preferFlats?: boolean }): Song
|
|
73
94
|
export function toNashville(song: Song, key: string): Song
|
|
74
95
|
export function toStandard(song: Song, key: string): Song
|
|
96
|
+
export function buildPlaybackTimeline(structure: StructureEntry[], timeSignature: TimeSignature | null): PlaybackMeasure[]
|
package/index.js
CHANGED
package/package.json
CHANGED
package/src/lexer.js
CHANGED
|
@@ -186,8 +186,8 @@ export function scanChordLine(line) {
|
|
|
186
186
|
return null
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
// must have at least one chord
|
|
190
|
-
if (
|
|
189
|
+
// must have at least one chord or bar line
|
|
190
|
+
if (tokens.length === 0) return null
|
|
191
191
|
|
|
192
192
|
return tokens
|
|
193
193
|
}
|
package/src/notation.js
CHANGED
|
@@ -167,10 +167,22 @@ function convertSong(song, key, toNNS) {
|
|
|
167
167
|
expression: convertExpressionNode(entry.expression, keySemitone, toNNS, preferFlats),
|
|
168
168
|
}))
|
|
169
169
|
|
|
170
|
+
// Convert playback timeline
|
|
171
|
+
const newPlayback = song.playback
|
|
172
|
+
? song.playback.map(measure => ({
|
|
173
|
+
...measure,
|
|
174
|
+
chords: measure.chords.map(c => {
|
|
175
|
+
const converted = convertChord(c, keySemitone, toNNS, preferFlats)
|
|
176
|
+
return { ...converted, beatStart: c.beatStart, durationInBeats: c.durationInBeats }
|
|
177
|
+
}),
|
|
178
|
+
}))
|
|
179
|
+
: undefined
|
|
180
|
+
|
|
170
181
|
return {
|
|
171
182
|
...song,
|
|
172
183
|
sections: newSections,
|
|
173
184
|
structure: newStructure,
|
|
185
|
+
...(newPlayback !== undefined && { playback: newPlayback }),
|
|
174
186
|
}
|
|
175
187
|
}
|
|
176
188
|
|
package/src/parser.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { scanChordLine, isChordLine, lexExpression, ExprTokenTypes } from './lexer.js'
|
|
2
|
+
import { buildPlaybackTimeline } from './playback.js'
|
|
2
3
|
|
|
3
4
|
// ─── Token-to-Chord Helpers ──────────────────────────────────────────
|
|
4
5
|
|
|
@@ -499,7 +500,8 @@ export function parse(rawSongsheet) {
|
|
|
499
500
|
}
|
|
500
501
|
}
|
|
501
502
|
|
|
502
|
-
|
|
503
|
+
const playback = buildPlaybackTimeline(structure, timeSignature)
|
|
504
|
+
return { title, author, bpm, timeSignature, key, sections, structure, playback }
|
|
503
505
|
}
|
|
504
506
|
|
|
505
507
|
function inferSectionType(sections, lyrics, chords) {
|
package/src/playback.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expand a chord into PlaybackChord(s). If the chord has a splitMeasure,
|
|
3
|
+
* each sub-chord becomes a separate PlaybackChord; otherwise a single one.
|
|
4
|
+
*/
|
|
5
|
+
function expandChord(chord) {
|
|
6
|
+
if (chord.splitMeasure && chord.splitMeasure.length > 0) {
|
|
7
|
+
return chord.splitMeasure.map(sc => {
|
|
8
|
+
const pc = { root: sc.root, type: sc.type }
|
|
9
|
+
if (sc.bass) pc.bass = sc.bass
|
|
10
|
+
if (sc.nashville) pc.nashville = true
|
|
11
|
+
if (sc.diamond) pc.diamond = true
|
|
12
|
+
if (sc.push) pc.push = true
|
|
13
|
+
if (sc.stop) pc.stop = true
|
|
14
|
+
return pc
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
const pc = { root: chord.root, type: chord.type }
|
|
18
|
+
if (chord.bass) pc.bass = chord.bass
|
|
19
|
+
if (chord.nashville) pc.nashville = true
|
|
20
|
+
if (chord.diamond) pc.diamond = true
|
|
21
|
+
if (chord.push) pc.push = true
|
|
22
|
+
if (chord.stop) pc.stop = true
|
|
23
|
+
return [pc]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a PlaybackMeasure from an array of expanded PlaybackChords.
|
|
28
|
+
* Divides beats equally among the chords.
|
|
29
|
+
*/
|
|
30
|
+
function buildMeasure(chords, measureIndex, structureIndex, lineIndex, ts) {
|
|
31
|
+
const beats = ts.beats
|
|
32
|
+
const beatPerChord = chords.length > 0 ? beats / chords.length : beats
|
|
33
|
+
const assignedChords = chords.map((c, i) => ({
|
|
34
|
+
...c,
|
|
35
|
+
beatStart: i * beatPerChord,
|
|
36
|
+
durationInBeats: beatPerChord,
|
|
37
|
+
}))
|
|
38
|
+
return {
|
|
39
|
+
measureIndex,
|
|
40
|
+
structureIndex,
|
|
41
|
+
lineIndex,
|
|
42
|
+
timeSignature: ts,
|
|
43
|
+
chords: assignedChords,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the playback timeline from the song's structure array.
|
|
49
|
+
*
|
|
50
|
+
* @param {Array} structure - song.structure entries
|
|
51
|
+
* @param {Object|null} timeSignature - { beats, value } or null (defaults to 4/4)
|
|
52
|
+
* @returns {Array} PlaybackMeasure[]
|
|
53
|
+
*/
|
|
54
|
+
export function buildPlaybackTimeline(structure, timeSignature) {
|
|
55
|
+
const ts = timeSignature || { beats: 4, value: 4 }
|
|
56
|
+
const measures = []
|
|
57
|
+
let measureIndex = 0
|
|
58
|
+
|
|
59
|
+
for (let si = 0; si < structure.length; si++) {
|
|
60
|
+
const entry = structure[si]
|
|
61
|
+
|
|
62
|
+
if (entry.lines.length > 0) {
|
|
63
|
+
// Lines with chords and barlines
|
|
64
|
+
for (let li = 0; li < entry.lines.length; li++) {
|
|
65
|
+
const line = entry.lines[li]
|
|
66
|
+
if (line.chords.length === 0 && line.barLines.length === 0) continue
|
|
67
|
+
|
|
68
|
+
// Merge chords and barlines into column-sorted markers
|
|
69
|
+
const markers = []
|
|
70
|
+
for (const chord of line.chords) {
|
|
71
|
+
markers.push({ col: chord.column, type: 'chord', chord })
|
|
72
|
+
}
|
|
73
|
+
for (const bar of line.barLines) {
|
|
74
|
+
markers.push({ col: bar.column, type: 'bar' })
|
|
75
|
+
}
|
|
76
|
+
markers.sort((a, b) => a.col - b.col)
|
|
77
|
+
|
|
78
|
+
const hasBarlines = line.barLines.length > 0
|
|
79
|
+
|
|
80
|
+
if (hasBarlines) {
|
|
81
|
+
// Group chords by barline boundaries
|
|
82
|
+
let currentChords = []
|
|
83
|
+
for (const m of markers) {
|
|
84
|
+
if (m.type === 'chord') {
|
|
85
|
+
currentChords.push(...expandChord(m.chord))
|
|
86
|
+
} else {
|
|
87
|
+
// Bar line: flush accumulated chords as a measure
|
|
88
|
+
if (currentChords.length > 0) {
|
|
89
|
+
measures.push(buildMeasure(currentChords, measureIndex, si, li, ts))
|
|
90
|
+
measureIndex++
|
|
91
|
+
currentChords = []
|
|
92
|
+
}
|
|
93
|
+
// Leading barline with no preceding chords → discard (visual marker only)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Any remaining chords after the last barline form a measure
|
|
97
|
+
if (currentChords.length > 0) {
|
|
98
|
+
measures.push(buildMeasure(currentChords, measureIndex, si, li, ts))
|
|
99
|
+
measureIndex++
|
|
100
|
+
}
|
|
101
|
+
} 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++
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
// Expression-only entries (FILL, INSTRUMENTAL, etc.) — chords only, no lines
|
|
112
|
+
for (const chord of entry.chords) {
|
|
113
|
+
const expanded = expandChord(chord)
|
|
114
|
+
measures.push(buildMeasure(expanded, measureIndex, si, -1, ts))
|
|
115
|
+
measureIndex++
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return measures
|
|
121
|
+
}
|
package/src/transpose.js
CHANGED
|
@@ -108,9 +108,21 @@ export function transpose(song, semitones, options = {}) {
|
|
|
108
108
|
expression: transposeExpressionNode(entry.expression, semitones, preferFlats),
|
|
109
109
|
}))
|
|
110
110
|
|
|
111
|
+
// Transpose playback timeline
|
|
112
|
+
const newPlayback = song.playback
|
|
113
|
+
? song.playback.map(measure => ({
|
|
114
|
+
...measure,
|
|
115
|
+
chords: measure.chords.map(c => {
|
|
116
|
+
const transposed = transposeChordObj(c, semitones, preferFlats)
|
|
117
|
+
return { ...transposed, beatStart: c.beatStart, durationInBeats: c.durationInBeats }
|
|
118
|
+
}),
|
|
119
|
+
}))
|
|
120
|
+
: undefined
|
|
121
|
+
|
|
111
122
|
return {
|
|
112
123
|
...song,
|
|
113
124
|
sections: newSections,
|
|
114
125
|
structure: newStructure,
|
|
126
|
+
...(newPlayback !== undefined && { playback: newPlayback }),
|
|
115
127
|
}
|
|
116
128
|
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { parse } from '../src/parser.js'
|
|
4
|
+
import { buildPlaybackTimeline } from '../src/playback.js'
|
|
5
|
+
|
|
6
|
+
function loadSong(file) {
|
|
7
|
+
return parse(readFileSync(file, 'utf8'))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('buildPlaybackTimeline', () => {
|
|
11
|
+
describe('no barlines — each chord is its own measure', () => {
|
|
12
|
+
it('creates one measure per chord', () => {
|
|
13
|
+
const song = parse('TITLE\n\nG D\n Lyrics here\n C\n More lyrics')
|
|
14
|
+
const playback = song.playback
|
|
15
|
+
expect(playback.length).toBeGreaterThanOrEqual(3)
|
|
16
|
+
// Each measure should have exactly 1 chord with full beat allocation
|
|
17
|
+
for (const m of playback) {
|
|
18
|
+
expect(m.chords.length).toBe(1)
|
|
19
|
+
expect(m.chords[0].beatStart).toBe(0)
|
|
20
|
+
expect(m.chords[0].durationInBeats).toBe(4)
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('barlines present — chords grouped into measures', () => {
|
|
26
|
+
it('| G | C | D | creates 3 measures, 1 chord each', () => {
|
|
27
|
+
const song = parse('TITLE\n\n| G | C | D |\n Lyrics')
|
|
28
|
+
const playback = song.playback
|
|
29
|
+
expect(playback.length).toBe(3)
|
|
30
|
+
expect(playback[0].chords[0].root).toBe('G')
|
|
31
|
+
expect(playback[1].chords[0].root).toBe('C')
|
|
32
|
+
expect(playback[2].chords[0].root).toBe('D')
|
|
33
|
+
for (const m of playback) {
|
|
34
|
+
expect(m.chords.length).toBe(1)
|
|
35
|
+
expect(m.chords[0].beatStart).toBe(0)
|
|
36
|
+
expect(m.chords[0].durationInBeats).toBe(4)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
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')
|
|
42
|
+
const playback = song.playback
|
|
43
|
+
expect(playback.length).toBe(1)
|
|
44
|
+
expect(playback[0].chords.length).toBe(2)
|
|
45
|
+
expect(playback[0].chords[0].root).toBe('C')
|
|
46
|
+
expect(playback[0].chords[0].beatStart).toBe(0)
|
|
47
|
+
expect(playback[0].chords[0].durationInBeats).toBe(2)
|
|
48
|
+
expect(playback[0].chords[1].root).toBe('D')
|
|
49
|
+
expect(playback[0].chords[1].beatStart).toBe(2)
|
|
50
|
+
expect(playback[0].chords[1].durationInBeats).toBe(2)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('D | creates 1 measure with D getting full measure', () => {
|
|
54
|
+
const song = parse('TITLE\n\nD |\n Lyrics')
|
|
55
|
+
const playback = song.playback
|
|
56
|
+
expect(playback.length).toBe(1)
|
|
57
|
+
expect(playback[0].chords.length).toBe(1)
|
|
58
|
+
expect(playback[0].chords[0].root).toBe('D')
|
|
59
|
+
expect(playback[0].chords[0].durationInBeats).toBe(4)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('leading | with no preceding chords is discarded', () => {
|
|
63
|
+
const song = parse('TITLE\n\n| G |\n Lyrics')
|
|
64
|
+
const playback = song.playback
|
|
65
|
+
expect(playback.length).toBe(1)
|
|
66
|
+
expect(playback[0].chords[0].root).toBe('G')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('split measure expansion', () => {
|
|
71
|
+
it('[G C] expands into 2 PlaybackChords sharing one measure', () => {
|
|
72
|
+
const song = parse('TITLE\n\n[G C]\n Lyrics')
|
|
73
|
+
const playback = song.playback
|
|
74
|
+
expect(playback.length).toBe(1)
|
|
75
|
+
expect(playback[0].chords.length).toBe(2)
|
|
76
|
+
expect(playback[0].chords[0].root).toBe('G')
|
|
77
|
+
expect(playback[0].chords[0].beatStart).toBe(0)
|
|
78
|
+
expect(playback[0].chords[0].durationInBeats).toBe(2)
|
|
79
|
+
expect(playback[0].chords[1].root).toBe('C')
|
|
80
|
+
expect(playback[0].chords[1].beatStart).toBe(2)
|
|
81
|
+
expect(playback[0].chords[1].durationInBeats).toBe(2)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('decorator preservation', () => {
|
|
86
|
+
it('preserves push flag', () => {
|
|
87
|
+
const song = parse('TITLE\n\n^G\n Lyrics')
|
|
88
|
+
expect(song.playback[0].chords[0].push).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('preserves diamond flag', () => {
|
|
92
|
+
const song = parse('TITLE\n\n<G>\n Lyrics')
|
|
93
|
+
expect(song.playback[0].chords[0].diamond).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('preserves stop flag', () => {
|
|
97
|
+
const song = parse('TITLE\n\nG!\n Lyrics')
|
|
98
|
+
expect(song.playback[0].chords[0].stop).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('expression-only entries', () => {
|
|
103
|
+
it('FILL chords become measures with lineIndex -1', () => {
|
|
104
|
+
const song = parse('TITLE\n\nG\n Lyrics\n\nFILL: D G A')
|
|
105
|
+
const fillMeasures = song.playback.filter(m => m.lineIndex === -1)
|
|
106
|
+
expect(fillMeasures.length).toBe(3)
|
|
107
|
+
expect(fillMeasures[0].chords[0].root).toBe('D')
|
|
108
|
+
expect(fillMeasures[1].chords[0].root).toBe('G')
|
|
109
|
+
expect(fillMeasures[2].chords[0].root).toBe('A')
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
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')
|
|
116
|
+
const playback = song.playback
|
|
117
|
+
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)
|
|
122
|
+
expect(playback[0].timeSignature.beats).toBe(3)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('back-references', () => {
|
|
127
|
+
it('sets correct structureIndex and lineIndex', () => {
|
|
128
|
+
const song = parse('TITLE\n\nG\n Line 1\n D\n Line 2')
|
|
129
|
+
expect(song.playback[0].structureIndex).toBe(0)
|
|
130
|
+
expect(song.playback[0].lineIndex).toBe(0)
|
|
131
|
+
expect(song.playback[1].structureIndex).toBe(0)
|
|
132
|
+
expect(song.playback[1].lineIndex).toBe(1)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('increments measureIndex correctly', () => {
|
|
136
|
+
const song = parse('TITLE\n\nG\n Line 1\n D\n Line 2')
|
|
137
|
+
expect(song.playback[0].measureIndex).toBe(0)
|
|
138
|
+
expect(song.playback[1].measureIndex).toBe(1)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('fixture: spent-some-time-in-buffalo.txt', () => {
|
|
143
|
+
const song = loadSong('./spent-some-time-in-buffalo.txt')
|
|
144
|
+
|
|
145
|
+
it('produces playback measures', () => {
|
|
146
|
+
expect(song.playback.length).toBeGreaterThan(0)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('has correct structure back-references', () => {
|
|
150
|
+
for (const m of song.playback) {
|
|
151
|
+
expect(m.structureIndex).toBeGreaterThanOrEqual(0)
|
|
152
|
+
expect(m.structureIndex).toBeLessThan(song.structure.length)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
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
|
|
159
|
+
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)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('measure indices are sequential', () => {
|
|
171
|
+
for (let i = 0; i < song.playback.length; i++) {
|
|
172
|
+
expect(song.playback[i].measureIndex).toBe(i)
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('fixture: riot-on-a-screen.txt', () => {
|
|
178
|
+
const song = loadSong('./riot-on-a-screen.txt')
|
|
179
|
+
|
|
180
|
+
it('produces playback measures', () => {
|
|
181
|
+
expect(song.playback.length).toBeGreaterThan(0)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('has no barlines — each chord is its own measure', () => {
|
|
185
|
+
// Riot on a screen has no barlines, so each chord should be its own measure
|
|
186
|
+
for (const m of song.playback) {
|
|
187
|
+
// Each measure should have 1 chord (unless split measure)
|
|
188
|
+
expect(m.chords.length).toBeGreaterThanOrEqual(1)
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('measure indices are sequential', () => {
|
|
193
|
+
for (let i = 0; i < song.playback.length; i++) {
|
|
194
|
+
expect(song.playback[i].measureIndex).toBe(i)
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('buildPlaybackTimeline standalone', () => {
|
|
200
|
+
it('returns empty array for empty structure', () => {
|
|
201
|
+
expect(buildPlaybackTimeline([], null)).toEqual([])
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('defaults to 4/4 when timeSignature is null', () => {
|
|
205
|
+
const structure = [{
|
|
206
|
+
sectionType: 'verse',
|
|
207
|
+
sectionIndex: 0,
|
|
208
|
+
chords: [{ root: 'G', type: '' }],
|
|
209
|
+
lyrics: [],
|
|
210
|
+
lines: [],
|
|
211
|
+
expression: null,
|
|
212
|
+
}]
|
|
213
|
+
const playback = buildPlaybackTimeline(structure, null)
|
|
214
|
+
expect(playback[0].timeSignature).toEqual({ beats: 4, value: 4 })
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
})
|