songsheet 7.3.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/playback.js +22 -101
- package/test/playback.test.js +24 -27
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/playback.js
CHANGED
|
@@ -46,62 +46,6 @@ function buildMeasure(chords, measureIndex, structureIndex, lineIndex, ts) {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
* Check if barlines on this line actually separate chords (e.g., | G | C | D |)
|
|
51
|
-
* vs. just trailing after all chords (e.g., A C D |).
|
|
52
|
-
* Returns true only when a barline appears between two chords.
|
|
53
|
-
*/
|
|
54
|
-
function detectInterBarlines(markers) {
|
|
55
|
-
let seenChord = false
|
|
56
|
-
let seenBarAfterChord = false
|
|
57
|
-
for (const m of markers) {
|
|
58
|
-
if (m.type === 'chord') {
|
|
59
|
-
if (seenBarAfterChord) return true
|
|
60
|
-
seenChord = true
|
|
61
|
-
} else if (m.type === 'bar' && seenChord) {
|
|
62
|
-
seenBarAfterChord = true
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return false
|
|
66
|
-
}
|
|
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
|
-
|
|
105
49
|
/**
|
|
106
50
|
* Build the playback timeline from the song's structure array.
|
|
107
51
|
*
|
|
@@ -133,53 +77,30 @@ export function buildPlaybackTimeline(structure, timeSignature) {
|
|
|
133
77
|
}
|
|
134
78
|
markers.sort((a, b) => a.col - b.col)
|
|
135
79
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (currentChords.length > 0) {
|
|
146
|
-
measures.push(buildMeasure(currentChords, measureIndex, si, li, ts))
|
|
147
|
-
measureIndex++
|
|
148
|
-
currentChords = []
|
|
149
|
-
}
|
|
150
|
-
// Leading barline with no preceding chords → discard (visual marker only)
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
// Any remaining chords after the last barline form a measure
|
|
154
|
-
if (currentChords.length > 0) {
|
|
155
|
-
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))
|
|
156
89
|
measureIndex++
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
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))
|
|
167
102
|
measureIndex++
|
|
168
|
-
|
|
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
|
-
}
|
|
103
|
+
lastExpanded = repeatedAtBar
|
|
183
104
|
}
|
|
184
105
|
}
|
|
185
106
|
}
|
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', () => {
|
|
@@ -157,15 +152,15 @@ describe('buildPlaybackTimeline', () => {
|
|
|
157
152
|
expect(playback[0].timeSignature.beats).toBe(3)
|
|
158
153
|
})
|
|
159
154
|
|
|
160
|
-
it('
|
|
155
|
+
it('barline repeats in 3/4 remain full-measure chords', () => {
|
|
161
156
|
const song = parse('TITLE\n(3/4 time)\n\n| G | C D |\n Lyrics')
|
|
162
157
|
const playback = song.playback
|
|
163
|
-
expect(playback.length).toBe(
|
|
164
|
-
expect(playback
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
}
|
|
169
164
|
})
|
|
170
165
|
})
|
|
171
166
|
|
|
@@ -191,11 +186,13 @@ describe('buildPlaybackTimeline', () => {
|
|
|
191
186
|
expect(song.playback[2].chords[0].markerIndex).toBe(2) // trailing bar marker
|
|
192
187
|
})
|
|
193
188
|
|
|
194
|
-
it('assigns markerIndex for
|
|
189
|
+
it('assigns markerIndex for bar repeats without barline grouping', () => {
|
|
195
190
|
const song = parse('TITLE\n\n| C D | G |\n Lyrics')
|
|
196
191
|
expect(song.playback[0].chords[0].markerIndex).toBe(1) // C marker
|
|
197
|
-
expect(song.playback[
|
|
198
|
-
expect(song.playback[
|
|
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
|
|
199
196
|
})
|
|
200
197
|
})
|
|
201
198
|
|