songsheet 7.5.0 → 7.7.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
@@ -7,6 +7,8 @@ export interface Chord {
7
7
  push?: boolean
8
8
  stop?: boolean
9
9
  splitMeasure?: Chord[]
10
+ splitOpen?: boolean
11
+ splitClose?: boolean
10
12
  }
11
13
 
12
14
  export interface PositionedChord extends Chord {
@@ -67,6 +69,7 @@ export interface PlaybackChord {
67
69
  push?: boolean
68
70
  stop?: boolean
69
71
  markerIndex?: number
72
+ splitIndex?: number
70
73
  beatStart: number
71
74
  durationInBeats: number
72
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "songsheet",
3
- "version": "7.5.0",
3
+ "version": "7.7.0",
4
4
  "description": "A songsheet interpreter",
5
5
  "type": "module",
6
6
  "exports": "./index.js",
package/src/lexer.js CHANGED
@@ -62,13 +62,50 @@ 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
+ rawTokens: closeChords,
99
+ chords: closeChords.map(c => {
100
+ const sc = { root: c.root, type: c.quality }
101
+ if (c.bass) sc.bass = c.bass
102
+ if (c.nashville) sc.nashville = true
103
+ return sc
104
+ }),
105
+ })
106
+ // continue scanning the rest of the line normally
107
+ }
108
+
72
109
  while (i < line.length) {
73
110
  // skip whitespace
74
111
  if (line[i] === ' ' || line[i] === '\t') {
@@ -125,6 +162,7 @@ export function scanChordLine(line) {
125
162
 
126
163
  // split measure: [chord chord ...]
127
164
  if (line[i] === '[') {
165
+ const openColumn = column
128
166
  i++ // consume [
129
167
  const chords = []
130
168
  while (i < line.length && line[i] !== ']') {
@@ -134,7 +172,27 @@ export function scanChordLine(line) {
134
172
  chords.push(result.token)
135
173
  i = result.end
136
174
  }
137
- if (i >= line.length || line[i] !== ']') return null
175
+ if (i >= line.length) {
176
+ // No ] found — this is a cross-line split open
177
+ if (chords.length < 1) return null // need at least 1 chord after [
178
+ const first = chords[0]
179
+ const token = {
180
+ type: 'SPLIT_OPEN',
181
+ column: openColumn,
182
+ root: first.root,
183
+ quality: first.quality,
184
+ chords: chords.map(c => {
185
+ const sc = { root: c.root, type: c.quality }
186
+ if (c.bass) sc.bass = c.bass
187
+ if (c.nashville) sc.nashville = true
188
+ return sc
189
+ }),
190
+ }
191
+ if (first.bass) token.bass = first.bass
192
+ if (first.nashville) token.nashville = true
193
+ tokens.push(token)
194
+ continue
195
+ }
138
196
  i++ // consume ]
139
197
  if (chords.length < 2) return null
140
198
  // next char must be whitespace, end-of-line, or |
@@ -145,7 +203,7 @@ export function scanChordLine(line) {
145
203
  const first = chords[0]
146
204
  const token = {
147
205
  type: 'CHORD',
148
- column,
206
+ column: openColumn,
149
207
  root: first.root,
150
208
  quality: first.quality,
151
209
  splitMeasure: chords.map(c => {
package/src/parser.js CHANGED
@@ -15,6 +15,8 @@ function tokenToChord(token) {
15
15
  if (token.push) chord.push = true
16
16
  if (token.stop) chord.stop = true
17
17
  if (token.splitMeasure) chord.splitMeasure = token.splitMeasure
18
+ if (token.splitOpen) chord.splitOpen = true
19
+ if (token.splitClose) chord.splitClose = true
18
20
  return chord
19
21
  }
20
22
 
@@ -262,41 +264,177 @@ function parseChordLyricBlock(text) {
262
264
  const allLyrics = []
263
265
  let i = 0
264
266
  let lastChord = null
267
+ let pendingSplit = null // tracks cross-line split measure state
265
268
 
266
269
  while (i < rawLines.length) {
267
270
  const line = rawLines[i]
271
+
272
+ // If we have a pending split open, try to parse this line with continueSplit first
273
+ if (pendingSplit) {
274
+ const contTokens = scanChordLine(line, { continueSplit: true })
275
+ if (contTokens) {
276
+ // Found the SPLIT_CLOSE — merge with the open chords
277
+ const closeToken = contTokens.find(t => t.type === 'SPLIT_CLOSE')
278
+ if (closeToken) {
279
+ const allSplitChords = [...pendingSplit.openChords, ...closeToken.chords]
280
+ // Update the chord on the earlier line: add splitMeasure + splitOpen flag
281
+ const earlierChord = pendingSplit.lineData.chords[pendingSplit.chordIndex]
282
+ earlierChord.splitMeasure = allSplitChords
283
+ earlierChord.splitOpen = true
284
+ // Also update the allChords entry
285
+ const allChordsEntry = allChords[pendingSplit.allChordsIndex]
286
+ if (allChordsEntry) {
287
+ allChordsEntry.splitMeasure = allSplitChords
288
+ allChordsEntry.splitOpen = true
289
+ }
290
+
291
+ // Build chords for this continuation line:
292
+ // - close chords get splitClose flag + the shared splitMeasure
293
+ // - remaining chords after ] are normal
294
+ const closeChordPositioned = []
295
+ for (const rawTok of closeToken.rawTokens) {
296
+ const chord = tokenToPositionedChord(rawTok)
297
+ chord.splitClose = true
298
+ chord.splitMeasure = allSplitChords
299
+ closeChordPositioned.push(chord)
300
+ }
301
+ const restChords = contTokens.filter(t => t.type === 'CHORD').map(t => tokenToPositionedChord(t))
302
+ const lineChords = [...closeChordPositioned, ...restChords]
303
+
304
+ // Build barLines for this line
305
+ const sortedTokens = [...contTokens].sort((a, b) => a.column - b.column)
306
+ let currentChord = lastChord
307
+ const barLines = []
308
+ for (const tok of sortedTokens) {
309
+ if (tok.type === 'CHORD') {
310
+ currentChord = tokenToChord(tok)
311
+ } else if (tok.type === 'SPLIT_CLOSE') {
312
+ // Update currentChord to the last close chord
313
+ const lastClose = closeToken.chords[closeToken.chords.length - 1]
314
+ currentChord = { root: lastClose.root, type: lastClose.type }
315
+ if (lastClose.bass) currentChord.bass = lastClose.bass
316
+ if (lastClose.nashville) currentChord.nashville = true
317
+ } else if (tok.type === 'BAR_LINE') {
318
+ const bar = { column: tok.column }
319
+ if (currentChord) bar.chord = currentChord
320
+ barLines.push(bar)
321
+ }
322
+ }
323
+ if (currentChord) lastChord = currentChord
324
+
325
+ pendingSplit = null
326
+ i++
327
+
328
+ // Find the paired lyric line
329
+ let lyricLine = ''
330
+ if (i < rawLines.length) {
331
+ const nextIsChord = scanChordLine(rawLines[i])
332
+ if (!nextIsChord) {
333
+ lyricLine = rawLines[i]
334
+ i++
335
+ }
336
+ }
337
+
338
+ // Add close chords + rest chords to allChords
339
+ allChords.push(...lineChords.map(c => {
340
+ const ch = { root: c.root, type: c.type }
341
+ if (c.bass) ch.bass = c.bass
342
+ if (c.nashville) ch.nashville = true
343
+ if (c.diamond) ch.diamond = true
344
+ if (c.push) ch.push = true
345
+ if (c.stop) ch.stop = true
346
+ if (c.splitMeasure) ch.splitMeasure = c.splitMeasure
347
+ if (c.splitClose) ch.splitClose = true
348
+ return ch
349
+ }))
350
+ if (lyricLine) allLyrics.push(lyricLine)
351
+
352
+ // Build alignment tokens from the close chord raw tokens + rest tokens
353
+ const alignTokens = [
354
+ ...closeToken.rawTokens,
355
+ ...contTokens.filter(t => t.type !== 'SPLIT_CLOSE'),
356
+ ]
357
+
358
+ lines.push({
359
+ chords: lineChords,
360
+ barLines,
361
+ lyrics: lyricLine,
362
+ characters: buildCharacterAlignment(alignTokens, lyricLine),
363
+ })
364
+ continue
365
+ }
366
+ }
367
+ // If continueSplit didn't match, this line is a lyric line between split lines
368
+ // (fall through to normal handling below)
369
+ }
370
+
268
371
  const tokens = scanChordLine(line)
269
372
 
270
373
  if (tokens) {
271
- // 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))
374
+ // Check for SPLIT_OPEN token (cross-line split)
375
+ const splitOpenToken = tokens.find(t => t.type === 'SPLIT_OPEN')
376
+
377
+ // This is a chord line
378
+ // For alignment, use all tokens except SPLIT_OPEN (which becomes a regular chord)
379
+ const chordTokens = []
380
+ const chords = []
381
+ for (const t of tokens) {
382
+ if (t.type === 'CHORD') {
383
+ chords.push(tokenToPositionedChord(t))
384
+ chordTokens.push(t)
385
+ } else if (t.type === 'SPLIT_OPEN') {
386
+ // Add the first chord of the split as a positioned chord at the [ column
387
+ const first = t.chords[0]
388
+ const chord = { root: first.root, type: first.type, column: t.column }
389
+ if (first.bass) chord.bass = first.bass
390
+ if (first.nashville) chord.nashville = true
391
+ // splitOpen flag — splitMeasure will be set when we find the SPLIT_CLOSE
392
+ chord.splitOpen = true
393
+ chords.push(chord)
394
+ // For alignment, create a synthetic CHORD token
395
+ chordTokens.push({ type: 'CHORD', column: t.column, root: first.root, quality: first.type })
396
+ } else {
397
+ chordTokens.push(t)
398
+ }
399
+ }
274
400
 
275
401
  // Build barLines as objects with chord context
276
- // Sort all tokens by column, walk left-to-right tracking currentChord
277
402
  const sortedTokens = [...tokens].sort((a, b) => a.column - b.column)
278
403
  let currentChord = lastChord
279
404
  const barLines = []
280
405
  for (const tok of sortedTokens) {
281
- if (tok.type === 'CHORD') {
282
- currentChord = tokenToChord(tok)
406
+ if (tok.type === 'CHORD' || tok.type === 'SPLIT_OPEN') {
407
+ if (tok.type === 'CHORD') {
408
+ currentChord = tokenToChord(tok)
409
+ } else {
410
+ const first = tok.chords[0]
411
+ currentChord = { root: first.root, type: first.type }
412
+ if (first.bass) currentChord.bass = first.bass
413
+ if (first.nashville) currentChord.nashville = true
414
+ }
283
415
  } else if (tok.type === 'BAR_LINE') {
284
416
  const bar = { column: tok.column }
285
417
  if (currentChord) bar.chord = currentChord
286
418
  barLines.push(bar)
287
419
  }
288
420
  }
289
- // Update lastChord for the next line
290
421
  if (currentChord) lastChord = currentChord
291
422
 
292
423
  i++
293
424
  // Find the paired lyric line (next line that isn't a chord line)
294
425
  let lyricLine = ''
295
- if (i < rawLines.length && !isChordLine(rawLines[i])) {
296
- lyricLine = rawLines[i]
297
- i++
426
+ if (i < rawLines.length) {
427
+ const nextIsChord = scanChordLine(rawLines[i])
428
+ if (!nextIsChord) {
429
+ // Also check if it's a split continuation line — don't consume as lyrics
430
+ if (!splitOpenToken || !scanChordLine(rawLines[i], { continueSplit: true })) {
431
+ lyricLine = rawLines[i]
432
+ i++
433
+ }
434
+ }
298
435
  }
299
436
 
437
+ const allChordsStartIndex = allChords.length
300
438
  allChords.push(...chords.map(c => {
301
439
  const ch = { root: c.root, type: c.type }
302
440
  if (c.bass) ch.bass = c.bass
@@ -305,16 +443,30 @@ function parseChordLyricBlock(text) {
305
443
  if (c.push) ch.push = true
306
444
  if (c.stop) ch.stop = true
307
445
  if (c.splitMeasure) ch.splitMeasure = c.splitMeasure
446
+ if (c.splitOpen) ch.splitOpen = true
447
+ if (c.splitClose) ch.splitClose = true
308
448
  return ch
309
449
  }))
310
450
  if (lyricLine) allLyrics.push(lyricLine)
311
451
 
312
- lines.push({
452
+ const lineData = {
313
453
  chords,
314
454
  barLines,
315
455
  lyrics: lyricLine,
316
456
  characters: buildCharacterAlignment(chordTokens, lyricLine),
317
- })
457
+ }
458
+ lines.push(lineData)
459
+
460
+ // If we found a SPLIT_OPEN, set up pendingSplit for cross-line merging
461
+ if (splitOpenToken) {
462
+ const splitChordIndex = chords.length - 1
463
+ pendingSplit = {
464
+ lineData,
465
+ chordIndex: splitChordIndex,
466
+ allChordsIndex: allChordsStartIndex + splitChordIndex,
467
+ openChords: splitOpenToken.chords,
468
+ }
469
+ }
318
470
  } else {
319
471
  // Lyric-only line (no chord line above it)
320
472
  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
  }
@@ -84,6 +85,8 @@ export function buildPlaybackTimeline(structure, timeSignature) {
84
85
  for (let mi = 0; mi < markers.length; mi++) {
85
86
  const m = markers[mi]
86
87
  if (m.type === 'chord') {
88
+ // splitClose chords are already accounted for by the open chord's splitMeasure
89
+ if (m.chord.splitClose) continue
87
90
  lastExpanded = expandChord(m.chord, mi)
88
91
  measures.push(buildMeasure(lastExpanded, measureIndex, si, li, ts))
89
92
  measureIndex++
@@ -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,76 @@ describe('time signature parsing', () => {
302
302
  })
303
303
  })
304
304
 
305
+ describe('cross-line split measure parsing', () => {
306
+ it('parses cross-line split with chords on separate lines at original columns', () => {
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
+ // Line 0: G at col 0, F at col 32 with splitOpen + splitMeasure
316
+ expect(lines[0].chords.length).toBe(2)
317
+ expect(lines[0].chords[0].root).toBe('G')
318
+ expect(lines[0].chords[0].column).toBe(0)
319
+ expect(lines[0].chords[1].root).toBe('F')
320
+ expect(lines[0].chords[1].column).toBe(32)
321
+ expect(lines[0].chords[1].splitOpen).toBe(true)
322
+ expect(lines[0].chords[1].splitMeasure).toEqual([
323
+ { root: 'F', type: '' },
324
+ { root: 'C', type: '' },
325
+ ])
326
+ expect(lines[0].lyrics).toBe(' Blue mountain road, North Carolina,')
327
+
328
+ // Line 1: C at col 15 with splitClose, G at col 33
329
+ expect(lines[1].chords.length).toBe(2)
330
+ expect(lines[1].chords[0].root).toBe('C')
331
+ expect(lines[1].chords[0].column).toBe(15)
332
+ expect(lines[1].chords[0].splitClose).toBe(true)
333
+ expect(lines[1].chords[1].root).toBe('G')
334
+ expect(lines[1].chords[1].column).toBe(33)
335
+ expect(lines[1].lyrics).toBe(" I've been to Asheville once before")
336
+ })
337
+
338
+ it('pairs lyrics correctly with cross-line split', () => {
339
+ const song = parse(
340
+ 'TEST - AUTHOR\n\n' +
341
+ 'G [F\n' +
342
+ ' Blue mountain road\n' +
343
+ ' C] G\n' +
344
+ ' Asheville town'
345
+ )
346
+ const lines = song.structure[0].lines
347
+ expect(lines[0].lyrics).toBe(' Blue mountain road')
348
+ expect(lines[1].lyrics).toBe(' Asheville town')
349
+ })
350
+
351
+ it('includes split chords in section allChords with flags', () => {
352
+ const song = parse(
353
+ 'TEST - AUTHOR\n\n' +
354
+ 'G [F\n' +
355
+ ' Lyrics one\n' +
356
+ 'C] D\n' +
357
+ ' Lyrics two'
358
+ )
359
+ const chords = song.structure[0].chords
360
+ // G, F (splitOpen), C (splitClose), D
361
+ expect(chords.length).toBe(4)
362
+ expect(chords[0].root).toBe('G')
363
+ expect(chords[1].root).toBe('F')
364
+ expect(chords[1].splitOpen).toBe(true)
365
+ expect(chords[1].splitMeasure).toEqual([
366
+ { root: 'F', type: '' },
367
+ { root: 'C', type: '' },
368
+ ])
369
+ expect(chords[2].root).toBe('C')
370
+ expect(chords[2].splitClose).toBe(true)
371
+ expect(chords[3].root).toBe('D')
372
+ })
373
+ })
374
+
305
375
  describe('bar line parsing', () => {
306
376
  it('parses bar lines in chord lines', () => {
307
377
  const song = parse('TEST SONG - AUTHOR\n\n| G | C | D |\nSome lyrics here')