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 +2 -0
- package/package.json +1 -1
- package/src/lexer.js +1 -0
- package/src/parser.js +51 -23
- package/src/playback.js +2 -0
- package/test/parser.test.js +22 -9
package/index.d.ts
CHANGED
package/package.json
CHANGED
package/src/lexer.js
CHANGED
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
|
-
|
|
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 =
|
|
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 =
|
|
287
|
+
allChordsEntry.splitMeasure = allSplitChords
|
|
288
|
+
allChordsEntry.splitOpen = true
|
|
286
289
|
}
|
|
287
290
|
|
|
288
|
-
//
|
|
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]
|
|
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
|
-
|
|
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:
|
|
359
|
+
chords: lineChords,
|
|
336
360
|
barLines,
|
|
337
361
|
lyrics: lyricLine,
|
|
338
|
-
characters: buildCharacterAlignment(
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
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
|
|
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++
|
package/test/parser.test.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
324
|
-
|
|
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")
|
|
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
|
|
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
|
-
|
|
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('
|
|
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
|
|