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 +3 -0
- package/package.json +1 -1
- package/src/lexer.js +61 -3
- package/src/parser.js +164 -12
- package/src/playback.js +4 -1
- package/test/lexer.test.js +59 -0
- package/test/parser.test.js +70 -0
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
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
|
|
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
|
-
//
|
|
272
|
-
const
|
|
273
|
-
|
|
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
|
-
|
|
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
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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++
|
package/test/lexer.test.js
CHANGED
|
@@ -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')
|
package/test/parser.test.js
CHANGED
|
@@ -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')
|