songsheet 6.4.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.
@@ -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.0.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,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
+ })