songsheet 7.6.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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "songsheet",
3
- "version": "7.6.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
@@ -95,6 +95,7 @@ export function scanChordLine(line, options = {}) {
95
95
  tokens.push({
96
96
  type: 'SPLIT_CLOSE',
97
97
  column,
98
+ rawTokens: closeChords,
98
99
  chords: closeChords.map(c => {
99
100
  const sc = { root: c.root, type: c.quality }
100
101
  if (c.bass) sc.bass = c.bass
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
 
@@ -275,19 +277,29 @@ function parseChordLyricBlock(text) {
275
277
  const closeToken = contTokens.find(t => t.type === 'SPLIT_CLOSE')
276
278
  if (closeToken) {
277
279
  const allSplitChords = [...pendingSplit.openChords, ...closeToken.chords]
278
- const splitMeasure = allSplitChords
279
- // Update the chord on the earlier line with the complete splitMeasure
280
+ // Update the chord on the earlier line: add splitMeasure + splitOpen flag
280
281
  const earlierChord = pendingSplit.lineData.chords[pendingSplit.chordIndex]
281
- earlierChord.splitMeasure = splitMeasure
282
+ earlierChord.splitMeasure = allSplitChords
283
+ earlierChord.splitOpen = true
282
284
  // Also update the allChords entry
283
285
  const allChordsEntry = allChords[pendingSplit.allChordsIndex]
284
286
  if (allChordsEntry) {
285
- allChordsEntry.splitMeasure = splitMeasure
287
+ allChordsEntry.splitMeasure = allSplitChords
288
+ allChordsEntry.splitOpen = true
286
289
  }
287
290
 
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
+ // 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]
291
303
 
292
304
  // Build barLines for this line
293
305
  const sortedTokens = [...contTokens].sort((a, b) => a.column - b.column)
@@ -296,12 +308,17 @@ function parseChordLyricBlock(text) {
296
308
  for (const tok of sortedTokens) {
297
309
  if (tok.type === 'CHORD') {
298
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
299
317
  } else if (tok.type === 'BAR_LINE') {
300
318
  const bar = { column: tok.column }
301
319
  if (currentChord) bar.chord = currentChord
302
320
  barLines.push(bar)
303
321
  }
304
- // SPLIT_CLOSE doesn't update currentChord (those chords belong to the previous line)
305
322
  }
306
323
  if (currentChord) lastChord = currentChord
307
324
 
@@ -311,7 +328,6 @@ function parseChordLyricBlock(text) {
311
328
  // Find the paired lyric line
312
329
  let lyricLine = ''
313
330
  if (i < rawLines.length) {
314
- // Check if next line is a chord line (with or without continueSplit)
315
331
  const nextIsChord = scanChordLine(rawLines[i])
316
332
  if (!nextIsChord) {
317
333
  lyricLine = rawLines[i]
@@ -319,7 +335,8 @@ function parseChordLyricBlock(text) {
319
335
  }
320
336
  }
321
337
 
322
- allChords.push(...restChords.map(c => {
338
+ // Add close chords + rest chords to allChords
339
+ allChords.push(...lineChords.map(c => {
323
340
  const ch = { root: c.root, type: c.type }
324
341
  if (c.bass) ch.bass = c.bass
325
342
  if (c.nashville) ch.nashville = true
@@ -327,15 +344,22 @@ function parseChordLyricBlock(text) {
327
344
  if (c.push) ch.push = true
328
345
  if (c.stop) ch.stop = true
329
346
  if (c.splitMeasure) ch.splitMeasure = c.splitMeasure
347
+ if (c.splitClose) ch.splitClose = true
330
348
  return ch
331
349
  }))
332
350
  if (lyricLine) allLyrics.push(lyricLine)
333
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
+
334
358
  lines.push({
335
- chords: restChords,
359
+ chords: lineChords,
336
360
  barLines,
337
361
  lyrics: lyricLine,
338
- characters: buildCharacterAlignment(restTokens, lyricLine),
362
+ characters: buildCharacterAlignment(alignTokens, lyricLine),
339
363
  })
340
364
  continue
341
365
  }
@@ -350,26 +374,31 @@ function parseChordLyricBlock(text) {
350
374
  // Check for SPLIT_OPEN token (cross-line split)
351
375
  const splitOpenToken = tokens.find(t => t.type === 'SPLIT_OPEN')
352
376
 
353
- // This is a chord line — next non-chord line is its paired lyric
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
377
+ // This is a chord line
378
+ // For alignment, use all tokens except SPLIT_OPEN (which becomes a regular chord)
379
+ const chordTokens = []
356
380
  const chords = []
357
381
  for (const t of tokens) {
358
382
  if (t.type === 'CHORD') {
359
383
  chords.push(tokenToPositionedChord(t))
384
+ chordTokens.push(t)
360
385
  } else if (t.type === 'SPLIT_OPEN') {
361
- // Add the first chord of the split as a positioned chord
386
+ // Add the first chord of the split as a positioned chord at the [ column
362
387
  const first = t.chords[0]
363
388
  const chord = { root: first.root, type: first.type, column: t.column }
364
389
  if (first.bass) chord.bass = first.bass
365
390
  if (first.nashville) chord.nashville = true
366
- // splitMeasure will be set later when we find the SPLIT_CLOSE
391
+ // splitOpen flag — splitMeasure will be set when we find the SPLIT_CLOSE
392
+ chord.splitOpen = true
367
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)
368
398
  }
369
399
  }
370
400
 
371
401
  // Build barLines as objects with chord context
372
- // Sort all tokens by column, walk left-to-right tracking currentChord
373
402
  const sortedTokens = [...tokens].sort((a, b) => a.column - b.column)
374
403
  let currentChord = lastChord
375
404
  const barLines = []
@@ -378,7 +407,6 @@ function parseChordLyricBlock(text) {
378
407
  if (tok.type === 'CHORD') {
379
408
  currentChord = tokenToChord(tok)
380
409
  } else {
381
- // Use first chord of split open
382
410
  const first = tok.chords[0]
383
411
  currentChord = { root: first.root, type: first.type }
384
412
  if (first.bass) currentChord.bass = first.bass
@@ -390,17 +418,15 @@ function parseChordLyricBlock(text) {
390
418
  barLines.push(bar)
391
419
  }
392
420
  }
393
- // Update lastChord for the next line
394
421
  if (currentChord) lastChord = currentChord
395
422
 
396
423
  i++
397
424
  // Find the paired lyric line (next line that isn't a chord line)
398
425
  let lyricLine = ''
399
426
  if (i < rawLines.length) {
400
- // When pendingSplit would be set, also check continueSplit before treating as lyrics
401
427
  const nextIsChord = scanChordLine(rawLines[i])
402
428
  if (!nextIsChord) {
403
- // Also check if it's a split continuation line
429
+ // Also check if it's a split continuation line — don't consume as lyrics
404
430
  if (!splitOpenToken || !scanChordLine(rawLines[i], { continueSplit: true })) {
405
431
  lyricLine = rawLines[i]
406
432
  i++
@@ -417,6 +443,8 @@ function parseChordLyricBlock(text) {
417
443
  if (c.push) ch.push = true
418
444
  if (c.stop) ch.stop = true
419
445
  if (c.splitMeasure) ch.splitMeasure = c.splitMeasure
446
+ if (c.splitOpen) ch.splitOpen = true
447
+ if (c.splitClose) ch.splitClose = true
420
448
  return ch
421
449
  }))
422
450
  if (lyricLine) allLyrics.push(lyricLine)
@@ -431,7 +459,7 @@ function parseChordLyricBlock(text) {
431
459
 
432
460
  // If we found a SPLIT_OPEN, set up pendingSplit for cross-line merging
433
461
  if (splitOpenToken) {
434
- const splitChordIndex = chords.length - 1 // the split chord is the last one we added
462
+ const splitChordIndex = chords.length - 1
435
463
  pendingSplit = {
436
464
  lineData,
437
465
  chordIndex: splitChordIndex,
package/src/playback.js CHANGED
@@ -85,6 +85,8 @@ export function buildPlaybackTimeline(structure, timeSignature) {
85
85
  for (let mi = 0; mi < markers.length; mi++) {
86
86
  const m = markers[mi]
87
87
  if (m.type === 'chord') {
88
+ // splitClose chords are already accounted for by the open chord's splitMeasure
89
+ if (m.chord.splitClose) continue
88
90
  lastExpanded = expandChord(m.chord, mi)
89
91
  measures.push(buildMeasure(lastExpanded, measureIndex, si, li, ts))
90
92
  measureIndex++
@@ -303,7 +303,7 @@ describe('time signature parsing', () => {
303
303
  })
304
304
 
305
305
  describe('cross-line split measure parsing', () => {
306
- it('parses cross-line split [F ... C] across chord lines', () => {
306
+ it('parses cross-line split with chords on separate lines at original columns', () => {
307
307
  const song = parse(
308
308
  'TEST - AUTHOR\n\n' +
309
309
  'G [F\n' +
@@ -312,17 +312,27 @@ describe('cross-line split measure parsing', () => {
312
312
  " I've been to Asheville once before"
313
313
  )
314
314
  const lines = song.structure[0].lines
315
- // First line should have G and the split chord (F + C)
315
+ // Line 0: G at col 0, F at col 32 with splitOpen + splitMeasure
316
316
  expect(lines[0].chords.length).toBe(2)
317
317
  expect(lines[0].chords[0].root).toBe('G')
318
+ expect(lines[0].chords[0].column).toBe(0)
318
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)
319
322
  expect(lines[0].chords[1].splitMeasure).toEqual([
320
323
  { root: 'F', type: '' },
321
324
  { root: 'C', type: '' },
322
325
  ])
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
+ 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")
326
336
  })
327
337
 
328
338
  it('pairs lyrics correctly with cross-line split', () => {
@@ -338,7 +348,7 @@ describe('cross-line split measure parsing', () => {
338
348
  expect(lines[1].lyrics).toBe(' Asheville town')
339
349
  })
340
350
 
341
- it('includes split measure chords in section allChords', () => {
351
+ it('includes split chords in section allChords with flags', () => {
342
352
  const song = parse(
343
353
  'TEST - AUTHOR\n\n' +
344
354
  'G [F\n' +
@@ -346,16 +356,19 @@ describe('cross-line split measure parsing', () => {
346
356
  'C] D\n' +
347
357
  ' Lyrics two'
348
358
  )
349
- // allChords should include G, the split chord (F+C), and D
350
359
  const chords = song.structure[0].chords
351
- expect(chords.length).toBe(3)
360
+ // G, F (splitOpen), C (splitClose), D
361
+ expect(chords.length).toBe(4)
352
362
  expect(chords[0].root).toBe('G')
353
363
  expect(chords[1].root).toBe('F')
364
+ expect(chords[1].splitOpen).toBe(true)
354
365
  expect(chords[1].splitMeasure).toEqual([
355
366
  { root: 'F', type: '' },
356
367
  { root: 'C', type: '' },
357
368
  ])
358
- expect(chords[2].root).toBe('D')
369
+ expect(chords[2].root).toBe('C')
370
+ expect(chords[2].splitClose).toBe(true)
371
+ expect(chords[3].root).toBe('D')
359
372
  })
360
373
  })
361
374