songsheet 6.4.0 → 7.1.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.
@@ -0,0 +1,18 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(/usr/bin/git -C /Users/administrator/Projects/songsheet log --oneline --all)",
5
+ "Bash(/usr/bin/git -C /Users/administrator/Projects/songsheet diff v4.0.0..v5.0.0 -- index.js)",
6
+ "Bash(npm install:*)",
7
+ "Bash(npx vitest run:*)",
8
+ "Bash(node -e:*)",
9
+ "WebSearch",
10
+ "WebFetch(domain:tonejs.github.io)",
11
+ "WebFetch(domain:github.com)",
12
+ "WebFetch(domain:www.andronio.me)",
13
+ "WebFetch(domain:www.guitarland.com)",
14
+ "Bash(npm test:*)",
15
+ "Bash(/usr/bin/git diff HEAD~2..HEAD -- package.json)"
16
+ ]
17
+ }
18
+ }
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
@@ -1,3 +1,4 @@
1
1
  export { parse } from './src/parser.js'
2
2
  export { transpose } from './src/transpose.js'
3
3
  export { toNashville, toStandard } from './src/notation.js'
4
+ export { buildPlaybackTimeline } from './src/playback.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "songsheet",
3
- "version": "6.4.0",
3
+ "version": "7.1.0",
4
4
  "description": "A songsheet interpreter",
5
5
  "type": "module",
6
6
  "exports": "./index.js",
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
- return { title, author, bpm, timeSignature, key, sections, structure }
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) {
@@ -0,0 +1,150 @@
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
+ * 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
+ * Build the playback timeline from the song's structure array.
68
+ *
69
+ * @param {Array} structure - song.structure entries
70
+ * @param {Object|null} timeSignature - { beats, value } or null (defaults to 4/4)
71
+ * @returns {Array} PlaybackMeasure[]
72
+ */
73
+ export function buildPlaybackTimeline(structure, timeSignature) {
74
+ const ts = timeSignature || { beats: 4, value: 4 }
75
+ const measures = []
76
+ let measureIndex = 0
77
+
78
+ for (let si = 0; si < structure.length; si++) {
79
+ const entry = structure[si]
80
+
81
+ if (entry.lines.length > 0) {
82
+ // Lines with chords and barlines
83
+ for (let li = 0; li < entry.lines.length; li++) {
84
+ const line = entry.lines[li]
85
+ if (line.chords.length === 0 && line.barLines.length === 0) continue
86
+
87
+ // Merge chords and barlines into column-sorted markers
88
+ const markers = []
89
+ for (const chord of line.chords) {
90
+ markers.push({ col: chord.column, type: 'chord', chord })
91
+ }
92
+ for (const bar of line.barLines) {
93
+ markers.push({ col: bar.column, type: 'bar' })
94
+ }
95
+ markers.sort((a, b) => a.col - b.col)
96
+
97
+ // Detect whether barlines actually separate chords on this line.
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) {
103
+ // Group chords by barline boundaries
104
+ let currentChords = []
105
+ for (const m of markers) {
106
+ if (m.type === 'chord') {
107
+ currentChords.push(...expandChord(m.chord))
108
+ } else {
109
+ // Bar line: flush accumulated chords as a measure
110
+ if (currentChords.length > 0) {
111
+ measures.push(buildMeasure(currentChords, measureIndex, si, li, ts))
112
+ measureIndex++
113
+ currentChords = []
114
+ }
115
+ // Leading barline with no preceding chords → discard (visual marker only)
116
+ }
117
+ }
118
+ // Any remaining chords after the last barline form a measure
119
+ if (currentChords.length > 0) {
120
+ measures.push(buildMeasure(currentChords, measureIndex, si, li, ts))
121
+ measureIndex++
122
+ }
123
+ } else {
124
+ // No inter-barlines: each chord is its own measure.
125
+ // Trailing barlines repeat the preceding chord for one additional measure.
126
+ let lastExpanded = null
127
+ for (const m of markers) {
128
+ if (m.type === 'chord') {
129
+ lastExpanded = expandChord(m.chord)
130
+ measures.push(buildMeasure(lastExpanded, measureIndex, si, li, ts))
131
+ measureIndex++
132
+ } else if (m.type === 'bar' && lastExpanded) {
133
+ measures.push(buildMeasure([...lastExpanded], measureIndex, si, li, ts))
134
+ measureIndex++
135
+ }
136
+ }
137
+ }
138
+ }
139
+ } else {
140
+ // Expression-only entries (FILL, INSTRUMENTAL, etc.) — chords only, no lines
141
+ for (const chord of entry.chords) {
142
+ const expanded = expandChord(chord)
143
+ measures.push(buildMeasure(expanded, measureIndex, si, -1, ts))
144
+ measureIndex++
145
+ }
146
+ }
147
+ }
148
+
149
+ return measures
150
+ }
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,233 @@
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('trailing barline repeats last chord: C D | → C, D, D', () => {
41
+ const song = parse('TITLE\n\nC D |\n Lyrics')
42
+ const playback = song.playback
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)
54
+ expect(playback[0].chords.length).toBe(2)
55
+ expect(playback[0].chords[0].root).toBe('C')
56
+ expect(playback[0].chords[0].beatStart).toBe(0)
57
+ expect(playback[0].chords[0].durationInBeats).toBe(2)
58
+ expect(playback[0].chords[1].root).toBe('D')
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')
62
+ })
63
+
64
+ it('D | creates 2 measures: D then barline repeats D', () => {
65
+ const song = parse('TITLE\n\nD |\n Lyrics')
66
+ const playback = song.playback
67
+ expect(playback.length).toBe(2)
68
+ expect(playback[0].chords[0].root).toBe('D')
69
+ expect(playback[1].chords[0].root).toBe('D')
70
+ })
71
+
72
+ it('| G | with no inter-barlines: G then barline repeats G', () => {
73
+ const song = parse('TITLE\n\n| G |\n Lyrics')
74
+ const playback = song.playback
75
+ // Leading | has no preceding chord → ignored. G then trailing | repeats G.
76
+ expect(playback.length).toBe(2)
77
+ expect(playback[0].chords[0].root).toBe('G')
78
+ expect(playback[1].chords[0].root).toBe('G')
79
+ })
80
+ })
81
+
82
+ describe('split measure expansion', () => {
83
+ it('[G C] expands into 2 PlaybackChords sharing one measure', () => {
84
+ const song = parse('TITLE\n\n[G C]\n Lyrics')
85
+ const playback = song.playback
86
+ expect(playback.length).toBe(1)
87
+ expect(playback[0].chords.length).toBe(2)
88
+ expect(playback[0].chords[0].root).toBe('G')
89
+ expect(playback[0].chords[0].beatStart).toBe(0)
90
+ expect(playback[0].chords[0].durationInBeats).toBe(2)
91
+ expect(playback[0].chords[1].root).toBe('C')
92
+ expect(playback[0].chords[1].beatStart).toBe(2)
93
+ expect(playback[0].chords[1].durationInBeats).toBe(2)
94
+ })
95
+ })
96
+
97
+ describe('decorator preservation', () => {
98
+ it('preserves push flag', () => {
99
+ const song = parse('TITLE\n\n^G\n Lyrics')
100
+ expect(song.playback[0].chords[0].push).toBe(true)
101
+ })
102
+
103
+ it('preserves diamond flag', () => {
104
+ const song = parse('TITLE\n\n<G>\n Lyrics')
105
+ expect(song.playback[0].chords[0].diamond).toBe(true)
106
+ })
107
+
108
+ it('preserves stop flag', () => {
109
+ const song = parse('TITLE\n\nG!\n Lyrics')
110
+ expect(song.playback[0].chords[0].stop).toBe(true)
111
+ })
112
+ })
113
+
114
+ describe('expression-only entries', () => {
115
+ it('FILL chords become measures with lineIndex -1', () => {
116
+ const song = parse('TITLE\n\nG\n Lyrics\n\nFILL: D G A')
117
+ const fillMeasures = song.playback.filter(m => m.lineIndex === -1)
118
+ expect(fillMeasures.length).toBe(3)
119
+ expect(fillMeasures[0].chords[0].root).toBe('D')
120
+ expect(fillMeasures[1].chords[0].root).toBe('G')
121
+ expect(fillMeasures[2].chords[0].root).toBe('A')
122
+ })
123
+ })
124
+
125
+ describe('3/4 time signature', () => {
126
+ it('uses 3 beats per measure', () => {
127
+ const song = parse('TITLE\n(3/4 time)\n\nG\n Lyrics')
128
+ const playback = song.playback
129
+ expect(playback.length).toBe(1)
130
+ expect(playback[0].chords[0].durationInBeats).toBe(3)
131
+ expect(playback[0].timeSignature.beats).toBe(3)
132
+ })
133
+
134
+ it('inter-barline grouping divides beats by 3', () => {
135
+ const song = parse('TITLE\n(3/4 time)\n\n| G | C D |\n Lyrics')
136
+ const playback = song.playback
137
+ expect(playback.length).toBe(2)
138
+ expect(playback[0].chords[0].root).toBe('G')
139
+ expect(playback[0].chords[0].durationInBeats).toBe(3)
140
+ expect(playback[1].chords.length).toBe(2)
141
+ expect(playback[1].chords[0].durationInBeats).toBe(1.5)
142
+ expect(playback[1].chords[1].durationInBeats).toBe(1.5)
143
+ })
144
+ })
145
+
146
+ describe('back-references', () => {
147
+ it('sets correct structureIndex and lineIndex', () => {
148
+ const song = parse('TITLE\n\nG\n Line 1\n D\n Line 2')
149
+ expect(song.playback[0].structureIndex).toBe(0)
150
+ expect(song.playback[0].lineIndex).toBe(0)
151
+ expect(song.playback[1].structureIndex).toBe(0)
152
+ expect(song.playback[1].lineIndex).toBe(1)
153
+ })
154
+
155
+ it('increments measureIndex correctly', () => {
156
+ const song = parse('TITLE\n\nG\n Line 1\n D\n Line 2')
157
+ expect(song.playback[0].measureIndex).toBe(0)
158
+ expect(song.playback[1].measureIndex).toBe(1)
159
+ })
160
+ })
161
+
162
+ describe('fixture: spent-some-time-in-buffalo.txt', () => {
163
+ const song = loadSong('./spent-some-time-in-buffalo.txt')
164
+
165
+ it('produces playback measures', () => {
166
+ expect(song.playback.length).toBeGreaterThan(0)
167
+ })
168
+
169
+ it('has correct structure back-references', () => {
170
+ for (const m of song.playback) {
171
+ expect(m.structureIndex).toBeGreaterThanOrEqual(0)
172
+ expect(m.structureIndex).toBeLessThan(song.structure.length)
173
+ }
174
+ })
175
+
176
+ it('trailing barlines repeat last chord: D|, G|, A C D| → D D G G A C D D', () => {
177
+ const verseMeasures = song.playback.filter(m => m.structureIndex === 0)
178
+ // Line 1: "D |" → D, D (barline repeats)
179
+ // Line 2: "G |" → G, G (barline repeats)
180
+ // Line 3: "A C D |" → A, C, D, D (barline repeats last)
181
+ expect(verseMeasures.length).toBe(8)
182
+ const roots = verseMeasures.map(m => m.chords[0].root)
183
+ expect(roots).toEqual(['D', 'D', 'G', 'G', 'A', 'C', 'D', 'D'])
184
+ })
185
+
186
+ it('measure indices are sequential', () => {
187
+ for (let i = 0; i < song.playback.length; i++) {
188
+ expect(song.playback[i].measureIndex).toBe(i)
189
+ }
190
+ })
191
+ })
192
+
193
+ describe('fixture: riot-on-a-screen.txt', () => {
194
+ const song = loadSong('./riot-on-a-screen.txt')
195
+
196
+ it('produces playback measures', () => {
197
+ expect(song.playback.length).toBeGreaterThan(0)
198
+ })
199
+
200
+ it('has no barlines — each chord is its own measure', () => {
201
+ // Riot on a screen has no barlines, so each chord should be its own measure
202
+ for (const m of song.playback) {
203
+ // Each measure should have 1 chord (unless split measure)
204
+ expect(m.chords.length).toBeGreaterThanOrEqual(1)
205
+ }
206
+ })
207
+
208
+ it('measure indices are sequential', () => {
209
+ for (let i = 0; i < song.playback.length; i++) {
210
+ expect(song.playback[i].measureIndex).toBe(i)
211
+ }
212
+ })
213
+ })
214
+
215
+ describe('buildPlaybackTimeline standalone', () => {
216
+ it('returns empty array for empty structure', () => {
217
+ expect(buildPlaybackTimeline([], null)).toEqual([])
218
+ })
219
+
220
+ it('defaults to 4/4 when timeSignature is null', () => {
221
+ const structure = [{
222
+ sectionType: 'verse',
223
+ sectionIndex: 0,
224
+ chords: [{ root: 'G', type: '' }],
225
+ lyrics: [],
226
+ lines: [],
227
+ expression: null,
228
+ }]
229
+ const playback = buildPlaybackTimeline(structure, null)
230
+ expect(playback[0].timeSignature).toEqual({ beats: 4, value: 4 })
231
+ })
232
+ })
233
+ })