songsheet 7.5.0 → 7.6.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/index.d.ts CHANGED
@@ -67,6 +67,7 @@ export interface PlaybackChord {
67
67
  push?: boolean
68
68
  stop?: boolean
69
69
  markerIndex?: number
70
+ splitIndex?: number
70
71
  beatStart: number
71
72
  durationInBeats: number
72
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "songsheet",
3
- "version": "7.5.0",
3
+ "version": "7.6.0",
4
4
  "description": "A songsheet interpreter",
5
5
  "type": "module",
6
6
  "exports": "./index.js",
package/src/lexer.js CHANGED
@@ -62,13 +62,49 @@ const DECORATOR_STOP_CHARS = new Set(['>', ']', '!'])
62
62
  *
63
63
  * A chord line must contain at least one chord, and every non-whitespace
64
64
  * token must parse as a valid chord, decorator, or `|`.
65
+ *
66
+ * Options:
67
+ * continueSplit: true — line is a continuation of a cross-line split measure.
68
+ * Pre-scans for chords before `]`, emits SPLIT_CLOSE, then continues normal scanning.
65
69
  */
66
- export function scanChordLine(line) {
70
+ export function scanChordLine(line, options = {}) {
67
71
  if (!line || line.trim().length === 0) return null
68
72
 
69
73
  const tokens = []
70
74
  let i = 0
71
75
 
76
+ // When continueSplit is set, we expect chords followed by `]` at the start of the line
77
+ if (options.continueSplit) {
78
+ const closeChords = []
79
+ // scan for chords before `]`
80
+ while (i < line.length && line[i] !== ']') {
81
+ if (line[i] === ' ' || line[i] === '\t') { i++; continue }
82
+ const result = parseChordAt(line, i, DECORATOR_STOP_CHARS)
83
+ if (!result) return null // non-chord content before ] means not a valid continuation
84
+ closeChords.push(result.token)
85
+ i = result.end
86
+ }
87
+ if (i >= line.length || line[i] !== ']') return null // no ] found
88
+ if (closeChords.length === 0) return null // must have at least one chord
89
+ const column = closeChords[0].column
90
+ i++ // consume ]
91
+ // next char must be whitespace, end-of-line, or |
92
+ if (i < line.length && line[i] !== ' ' && line[i] !== '\t' && line[i] !== '|') {
93
+ return null
94
+ }
95
+ tokens.push({
96
+ type: 'SPLIT_CLOSE',
97
+ column,
98
+ chords: closeChords.map(c => {
99
+ const sc = { root: c.root, type: c.quality }
100
+ if (c.bass) sc.bass = c.bass
101
+ if (c.nashville) sc.nashville = true
102
+ return sc
103
+ }),
104
+ })
105
+ // continue scanning the rest of the line normally
106
+ }
107
+
72
108
  while (i < line.length) {
73
109
  // skip whitespace
74
110
  if (line[i] === ' ' || line[i] === '\t') {
@@ -125,6 +161,7 @@ export function scanChordLine(line) {
125
161
 
126
162
  // split measure: [chord chord ...]
127
163
  if (line[i] === '[') {
164
+ const openColumn = column
128
165
  i++ // consume [
129
166
  const chords = []
130
167
  while (i < line.length && line[i] !== ']') {
@@ -134,7 +171,27 @@ export function scanChordLine(line) {
134
171
  chords.push(result.token)
135
172
  i = result.end
136
173
  }
137
- if (i >= line.length || line[i] !== ']') return null
174
+ if (i >= line.length) {
175
+ // No ] found — this is a cross-line split open
176
+ if (chords.length < 1) return null // need at least 1 chord after [
177
+ const first = chords[0]
178
+ const token = {
179
+ type: 'SPLIT_OPEN',
180
+ column: openColumn,
181
+ root: first.root,
182
+ quality: first.quality,
183
+ chords: chords.map(c => {
184
+ const sc = { root: c.root, type: c.quality }
185
+ if (c.bass) sc.bass = c.bass
186
+ if (c.nashville) sc.nashville = true
187
+ return sc
188
+ }),
189
+ }
190
+ if (first.bass) token.bass = first.bass
191
+ if (first.nashville) token.nashville = true
192
+ tokens.push(token)
193
+ continue
194
+ }
138
195
  i++ // consume ]
139
196
  if (chords.length < 2) return null
140
197
  // next char must be whitespace, end-of-line, or |
@@ -145,7 +202,7 @@ export function scanChordLine(line) {
145
202
  const first = chords[0]
146
203
  const token = {
147
204
  type: 'CHORD',
148
- column,
205
+ column: openColumn,
149
206
  root: first.root,
150
207
  quality: first.quality,
151
208
  splitMeasure: chords.map(c => {
package/src/parser.js CHANGED
@@ -262,15 +262,111 @@ function parseChordLyricBlock(text) {
262
262
  const allLyrics = []
263
263
  let i = 0
264
264
  let lastChord = null
265
+ let pendingSplit = null // tracks cross-line split measure state
265
266
 
266
267
  while (i < rawLines.length) {
267
268
  const line = rawLines[i]
269
+
270
+ // If we have a pending split open, try to parse this line with continueSplit first
271
+ if (pendingSplit) {
272
+ const contTokens = scanChordLine(line, { continueSplit: true })
273
+ if (contTokens) {
274
+ // Found the SPLIT_CLOSE — merge with the open chords
275
+ const closeToken = contTokens.find(t => t.type === 'SPLIT_CLOSE')
276
+ if (closeToken) {
277
+ const allSplitChords = [...pendingSplit.openChords, ...closeToken.chords]
278
+ const splitMeasure = allSplitChords
279
+ // Update the chord on the earlier line with the complete splitMeasure
280
+ const earlierChord = pendingSplit.lineData.chords[pendingSplit.chordIndex]
281
+ earlierChord.splitMeasure = splitMeasure
282
+ // Also update the allChords entry
283
+ const allChordsEntry = allChords[pendingSplit.allChordsIndex]
284
+ if (allChordsEntry) {
285
+ allChordsEntry.splitMeasure = splitMeasure
286
+ }
287
+
288
+ // Now process the rest of the tokens on this line (chords/bars after the ] )
289
+ const restTokens = contTokens.filter(t => t.type !== 'SPLIT_CLOSE')
290
+ const restChords = restTokens.filter(t => t.type === 'CHORD').map(t => tokenToPositionedChord(t))
291
+
292
+ // Build barLines for this line
293
+ const sortedTokens = [...contTokens].sort((a, b) => a.column - b.column)
294
+ let currentChord = lastChord
295
+ const barLines = []
296
+ for (const tok of sortedTokens) {
297
+ if (tok.type === 'CHORD') {
298
+ currentChord = tokenToChord(tok)
299
+ } else if (tok.type === 'BAR_LINE') {
300
+ const bar = { column: tok.column }
301
+ if (currentChord) bar.chord = currentChord
302
+ barLines.push(bar)
303
+ }
304
+ // SPLIT_CLOSE doesn't update currentChord (those chords belong to the previous line)
305
+ }
306
+ if (currentChord) lastChord = currentChord
307
+
308
+ pendingSplit = null
309
+ i++
310
+
311
+ // Find the paired lyric line
312
+ let lyricLine = ''
313
+ if (i < rawLines.length) {
314
+ // Check if next line is a chord line (with or without continueSplit)
315
+ const nextIsChord = scanChordLine(rawLines[i])
316
+ if (!nextIsChord) {
317
+ lyricLine = rawLines[i]
318
+ i++
319
+ }
320
+ }
321
+
322
+ allChords.push(...restChords.map(c => {
323
+ const ch = { root: c.root, type: c.type }
324
+ if (c.bass) ch.bass = c.bass
325
+ if (c.nashville) ch.nashville = true
326
+ if (c.diamond) ch.diamond = true
327
+ if (c.push) ch.push = true
328
+ if (c.stop) ch.stop = true
329
+ if (c.splitMeasure) ch.splitMeasure = c.splitMeasure
330
+ return ch
331
+ }))
332
+ if (lyricLine) allLyrics.push(lyricLine)
333
+
334
+ lines.push({
335
+ chords: restChords,
336
+ barLines,
337
+ lyrics: lyricLine,
338
+ characters: buildCharacterAlignment(restTokens, lyricLine),
339
+ })
340
+ continue
341
+ }
342
+ }
343
+ // If continueSplit didn't match, this line is a lyric line between split lines
344
+ // (fall through to normal handling below)
345
+ }
346
+
268
347
  const tokens = scanChordLine(line)
269
348
 
270
349
  if (tokens) {
350
+ // Check for SPLIT_OPEN token (cross-line split)
351
+ const splitOpenToken = tokens.find(t => t.type === 'SPLIT_OPEN')
352
+
271
353
  // This is a chord line — next non-chord line is its paired lyric
272
- const chordTokens = tokens
273
- const chords = tokens.filter(t => t.type === 'CHORD').map(t => tokenToPositionedChord(t))
354
+ const chordTokens = tokens.filter(t => t.type !== 'SPLIT_OPEN')
355
+ // For SPLIT_OPEN, create a positioned chord from the first open chord at the [ column
356
+ const chords = []
357
+ for (const t of tokens) {
358
+ if (t.type === 'CHORD') {
359
+ chords.push(tokenToPositionedChord(t))
360
+ } else if (t.type === 'SPLIT_OPEN') {
361
+ // Add the first chord of the split as a positioned chord
362
+ const first = t.chords[0]
363
+ const chord = { root: first.root, type: first.type, column: t.column }
364
+ if (first.bass) chord.bass = first.bass
365
+ if (first.nashville) chord.nashville = true
366
+ // splitMeasure will be set later when we find the SPLIT_CLOSE
367
+ chords.push(chord)
368
+ }
369
+ }
274
370
 
275
371
  // Build barLines as objects with chord context
276
372
  // Sort all tokens by column, walk left-to-right tracking currentChord
@@ -278,8 +374,16 @@ function parseChordLyricBlock(text) {
278
374
  let currentChord = lastChord
279
375
  const barLines = []
280
376
  for (const tok of sortedTokens) {
281
- if (tok.type === 'CHORD') {
282
- currentChord = tokenToChord(tok)
377
+ if (tok.type === 'CHORD' || tok.type === 'SPLIT_OPEN') {
378
+ if (tok.type === 'CHORD') {
379
+ currentChord = tokenToChord(tok)
380
+ } else {
381
+ // Use first chord of split open
382
+ const first = tok.chords[0]
383
+ currentChord = { root: first.root, type: first.type }
384
+ if (first.bass) currentChord.bass = first.bass
385
+ if (first.nashville) currentChord.nashville = true
386
+ }
283
387
  } else if (tok.type === 'BAR_LINE') {
284
388
  const bar = { column: tok.column }
285
389
  if (currentChord) bar.chord = currentChord
@@ -292,11 +396,19 @@ function parseChordLyricBlock(text) {
292
396
  i++
293
397
  // Find the paired lyric line (next line that isn't a chord line)
294
398
  let lyricLine = ''
295
- if (i < rawLines.length && !isChordLine(rawLines[i])) {
296
- lyricLine = rawLines[i]
297
- i++
399
+ if (i < rawLines.length) {
400
+ // When pendingSplit would be set, also check continueSplit before treating as lyrics
401
+ const nextIsChord = scanChordLine(rawLines[i])
402
+ if (!nextIsChord) {
403
+ // Also check if it's a split continuation line
404
+ if (!splitOpenToken || !scanChordLine(rawLines[i], { continueSplit: true })) {
405
+ lyricLine = rawLines[i]
406
+ i++
407
+ }
408
+ }
298
409
  }
299
410
 
411
+ const allChordsStartIndex = allChords.length
300
412
  allChords.push(...chords.map(c => {
301
413
  const ch = { root: c.root, type: c.type }
302
414
  if (c.bass) ch.bass = c.bass
@@ -309,12 +421,24 @@ function parseChordLyricBlock(text) {
309
421
  }))
310
422
  if (lyricLine) allLyrics.push(lyricLine)
311
423
 
312
- lines.push({
424
+ const lineData = {
313
425
  chords,
314
426
  barLines,
315
427
  lyrics: lyricLine,
316
428
  characters: buildCharacterAlignment(chordTokens, lyricLine),
317
- })
429
+ }
430
+ lines.push(lineData)
431
+
432
+ // If we found a SPLIT_OPEN, set up pendingSplit for cross-line merging
433
+ if (splitOpenToken) {
434
+ const splitChordIndex = chords.length - 1 // the split chord is the last one we added
435
+ pendingSplit = {
436
+ lineData,
437
+ chordIndex: splitChordIndex,
438
+ allChordsIndex: allChordsStartIndex + splitChordIndex,
439
+ openChords: splitOpenToken.chords,
440
+ }
441
+ }
318
442
  } else {
319
443
  // Lyric-only line (no chord line above it)
320
444
  if (line.trim().length > 0) {
package/src/playback.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  function expandChord(chord, markerIndex) {
6
6
  if (chord.splitMeasure && chord.splitMeasure.length > 0) {
7
- return chord.splitMeasure.map(sc => {
7
+ return chord.splitMeasure.map((sc, index) => {
8
8
  const pc = { root: sc.root, type: sc.type }
9
9
  if (sc.bass) pc.bass = sc.bass
10
10
  if (sc.nashville) pc.nashville = true
@@ -12,6 +12,7 @@ function expandChord(chord, markerIndex) {
12
12
  if (sc.push) pc.push = true
13
13
  if (sc.stop) pc.stop = true
14
14
  if (markerIndex !== undefined) pc.markerIndex = markerIndex
15
+ pc.splitIndex = index
15
16
  return pc
16
17
  })
17
18
  }
@@ -130,6 +130,65 @@ describe('scanChordLine slash chords', () => {
130
130
  })
131
131
  })
132
132
 
133
+ describe('scanChordLine split measures', () => {
134
+ it('parses same-line split measure [G C]', () => {
135
+ const tokens = scanChordLine('[G C]')
136
+ expect(tokens).not.toBeNull()
137
+ expect(tokens.length).toBe(1)
138
+ expect(tokens[0].type).toBe('CHORD')
139
+ expect(tokens[0].splitMeasure).toEqual([
140
+ { root: 'G', type: '' },
141
+ { root: 'C', type: '' },
142
+ ])
143
+ })
144
+
145
+ it('parses cross-line split open: [F at end of line', () => {
146
+ const tokens = scanChordLine('G [F')
147
+ expect(tokens).not.toBeNull()
148
+ expect(tokens.length).toBe(2)
149
+ expect(tokens[0]).toMatchObject({ type: 'CHORD', root: 'G' })
150
+ expect(tokens[1].type).toBe('SPLIT_OPEN')
151
+ expect(tokens[1].chords).toEqual([{ root: 'F', type: '' }])
152
+ })
153
+
154
+ it('parses cross-line split close with continueSplit', () => {
155
+ const tokens = scanChordLine(' C] G', { continueSplit: true })
156
+ expect(tokens).not.toBeNull()
157
+ const closeToken = tokens.find(t => t.type === 'SPLIT_CLOSE')
158
+ expect(closeToken).toBeDefined()
159
+ expect(closeToken.chords).toEqual([{ root: 'C', type: '' }])
160
+ // Rest of line parsed normally
161
+ const chordTokens = tokens.filter(t => t.type === 'CHORD')
162
+ expect(chordTokens.length).toBe(1)
163
+ expect(chordTokens[0].root).toBe('G')
164
+ })
165
+
166
+ it('returns null for continueSplit when no ] found', () => {
167
+ const tokens = scanChordLine('C G', { continueSplit: true })
168
+ expect(tokens).toBeNull()
169
+ })
170
+
171
+ it('returns null for continueSplit on a lyric line', () => {
172
+ const tokens = scanChordLine('Blue mountain road', { continueSplit: true })
173
+ expect(tokens).toBeNull()
174
+ })
175
+
176
+ it('parses split open with multiple chords: [F C', () => {
177
+ const tokens = scanChordLine('[F C')
178
+ expect(tokens).not.toBeNull()
179
+ expect(tokens[0].type).toBe('SPLIT_OPEN')
180
+ expect(tokens[0].chords).toEqual([
181
+ { root: 'F', type: '' },
182
+ { root: 'C', type: '' },
183
+ ])
184
+ })
185
+
186
+ it('returns null for [ with no chords', () => {
187
+ const tokens = scanChordLine('[')
188
+ expect(tokens).toBeNull()
189
+ })
190
+ })
191
+
133
192
  describe('lexExpression', () => {
134
193
  it('tokenizes simple chord list', () => {
135
194
  const tokens = lexExpression('D G D A')
@@ -302,6 +302,63 @@ describe('time signature parsing', () => {
302
302
  })
303
303
  })
304
304
 
305
+ describe('cross-line split measure parsing', () => {
306
+ it('parses cross-line split [F ... C] across chord lines', () => {
307
+ const song = parse(
308
+ 'TEST - AUTHOR\n\n' +
309
+ 'G [F\n' +
310
+ ' Blue mountain road, North Carolina,\n' +
311
+ ' C] G\n' +
312
+ " I've been to Asheville once before"
313
+ )
314
+ const lines = song.structure[0].lines
315
+ // First line should have G and the split chord (F + C)
316
+ expect(lines[0].chords.length).toBe(2)
317
+ expect(lines[0].chords[0].root).toBe('G')
318
+ expect(lines[0].chords[1].root).toBe('F')
319
+ expect(lines[0].chords[1].splitMeasure).toEqual([
320
+ { root: 'F', type: '' },
321
+ { root: 'C', type: '' },
322
+ ])
323
+ // Second line should have G (after the ])
324
+ expect(lines[1].chords.length).toBe(1)
325
+ expect(lines[1].chords[0].root).toBe('G')
326
+ })
327
+
328
+ it('pairs lyrics correctly with cross-line split', () => {
329
+ const song = parse(
330
+ 'TEST - AUTHOR\n\n' +
331
+ 'G [F\n' +
332
+ ' Blue mountain road\n' +
333
+ ' C] G\n' +
334
+ ' Asheville town'
335
+ )
336
+ const lines = song.structure[0].lines
337
+ expect(lines[0].lyrics).toBe(' Blue mountain road')
338
+ expect(lines[1].lyrics).toBe(' Asheville town')
339
+ })
340
+
341
+ it('includes split measure chords in section allChords', () => {
342
+ const song = parse(
343
+ 'TEST - AUTHOR\n\n' +
344
+ 'G [F\n' +
345
+ ' Lyrics one\n' +
346
+ 'C] D\n' +
347
+ ' Lyrics two'
348
+ )
349
+ // allChords should include G, the split chord (F+C), and D
350
+ const chords = song.structure[0].chords
351
+ expect(chords.length).toBe(3)
352
+ expect(chords[0].root).toBe('G')
353
+ expect(chords[1].root).toBe('F')
354
+ expect(chords[1].splitMeasure).toEqual([
355
+ { root: 'F', type: '' },
356
+ { root: 'C', type: '' },
357
+ ])
358
+ expect(chords[2].root).toBe('D')
359
+ })
360
+ })
361
+
305
362
  describe('bar line parsing', () => {
306
363
  it('parses bar lines in chord lines', () => {
307
364
  const song = parse('TEST SONG - AUTHOR\n\n| G | C | D |\nSome lyrics here')