songsheet 5.0.0 → 6.0.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/.claude/settings.local.json +18 -0
- package/CLAUDE.md +180 -0
- package/README.md +136 -1
- package/index.d.ts +54 -0
- package/index.js +2 -214
- package/package.json +5 -10
- package/spent-some-time-in-buffalo.txt +10 -6
- package/src/lexer.js +147 -0
- package/src/parser.js +503 -0
- package/src/transpose.js +107 -0
- package/test/expression.test.js +86 -0
- package/test/lexer.test.js +109 -0
- package/test/parser.test.js +255 -0
- package/test/transpose.test.js +107 -0
- package/vitest.config.js +7 -0
- package/.npmignore +0 -33
- package/test.js +0 -183
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(/usr/bin/git -C /Users/administrator/Projects/songsheet log --oneline --all)",
|
|
5
|
+
"Bash(/usr/bin/git -C /Users/administrator/Projects/songsheet diff v4.0.0..v5.0.0 -- index.js)",
|
|
6
|
+
"Bash(npm install:*)",
|
|
7
|
+
"Bash(npx vitest run:*)",
|
|
8
|
+
"Bash(node -e:*)",
|
|
9
|
+
"WebSearch",
|
|
10
|
+
"WebFetch(domain:tonejs.github.io)",
|
|
11
|
+
"WebFetch(domain:github.com)",
|
|
12
|
+
"WebFetch(domain:www.andronio.me)",
|
|
13
|
+
"WebFetch(domain:www.guitarland.com)",
|
|
14
|
+
"Bash(npm test:*)",
|
|
15
|
+
"Bash(/usr/bin/git diff HEAD~2..HEAD -- package.json)"
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
}
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Songsheet
|
|
2
|
+
|
|
3
|
+
A zero-dependency songsheet parser and transposer. Parses plaintext songsheet files into a structured AST with chord-lyric character alignment.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { parse, transpose } from 'songsheet'
|
|
9
|
+
|
|
10
|
+
const song = parse(rawText) // synchronous, returns plain object
|
|
11
|
+
const songInA = transpose(song, 2) // up 2 semitones
|
|
12
|
+
const songInBb = transpose(song, 3, { preferFlats: true })
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
index.js — Public API: re-exports parse + transpose
|
|
19
|
+
src/
|
|
20
|
+
lexer.js — scanChordLine(), isChordLine(), lexExpression()
|
|
21
|
+
parser.js — parse(), expression parser, section assembly
|
|
22
|
+
transpose.js — transpose(), note math
|
|
23
|
+
test/
|
|
24
|
+
lexer.test.js — Chord line detection, bar lines, edge cases
|
|
25
|
+
parser.test.js — All 4 song fixture tests + bar line tests
|
|
26
|
+
expression.test.js — Expression parsing and resolution
|
|
27
|
+
transpose.test.js — Semitone math, round-trips, flat/sharp preference
|
|
28
|
+
*.txt — Song fixtures (do not modify)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Module Format
|
|
32
|
+
|
|
33
|
+
ESM only (`"type": "module"` in package.json). No CommonJS.
|
|
34
|
+
|
|
35
|
+
## Dev Commands
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm test # vitest run — all tests
|
|
39
|
+
npx vitest # watch mode
|
|
40
|
+
npx vitest run test/parser.test.js # single file
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## AST Shape
|
|
44
|
+
|
|
45
|
+
`parse()` returns:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
{
|
|
49
|
+
title: 'SONG TITLE',
|
|
50
|
+
author: 'AUTHOR NAME',
|
|
51
|
+
sections: {
|
|
52
|
+
verse: {
|
|
53
|
+
count: 4,
|
|
54
|
+
chords: [{ root: 'G', type: '' }, { root: 'F', type: '' }, ...],
|
|
55
|
+
lyrics: ['lyric line 1', ...],
|
|
56
|
+
lines: [
|
|
57
|
+
{
|
|
58
|
+
chords: [{ root: 'G', type: '', column: 0 }, ...],
|
|
59
|
+
barLines: [], // column positions of | markers
|
|
60
|
+
lyrics: 'lyric line 1',
|
|
61
|
+
characters: [
|
|
62
|
+
{ character: 'B', chord: { root: 'G', type: '' } },
|
|
63
|
+
{ character: 'l' },
|
|
64
|
+
...
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
chorus: { ... },
|
|
70
|
+
},
|
|
71
|
+
structure: [
|
|
72
|
+
{
|
|
73
|
+
sectionType: 'verse',
|
|
74
|
+
sectionIndex: 0,
|
|
75
|
+
chords: [...],
|
|
76
|
+
lyrics: [...],
|
|
77
|
+
lines: [...],
|
|
78
|
+
expression: null, // non-null on directive entries
|
|
79
|
+
},
|
|
80
|
+
...
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Key Design Decisions
|
|
86
|
+
|
|
87
|
+
- **Chord line detection**: Exhaustive left-to-right scan — every non-whitespace token must parse as a valid chord or `|`, otherwise the line is lyrics
|
|
88
|
+
- **root includes accidental**: `root: 'Bb'` not `root: 'B', accidental: 'b'`
|
|
89
|
+
- **Synchronous parse**: No Promise wrapper, plain objects (no Immutable.js)
|
|
90
|
+
- **Expression AST preserved**: `(VERSE, CHORUS*2)` stored as tree AND resolved to flat chords
|
|
91
|
+
- **Character alignment includes barLines**: `{ character: 'r', barLine: true }` at `|` column positions
|
|
92
|
+
- **Column preservation**: Chord lines are never trimmed — column positions match the original file
|
|
93
|
+
|
|
94
|
+
## Songsheet Format
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
SONG TITLE - AUTHOR NAME
|
|
98
|
+
|
|
99
|
+
G F
|
|
100
|
+
Lyrics aligned under chords...
|
|
101
|
+
C G
|
|
102
|
+
More lyrics here
|
|
103
|
+
|
|
104
|
+
F C D
|
|
105
|
+
Chorus lyrics...
|
|
106
|
+
|
|
107
|
+
Verse lyrics without chords (inherits first verse's chord pattern)
|
|
108
|
+
|
|
109
|
+
CHORUS
|
|
110
|
+
CHORUS*2
|
|
111
|
+
|
|
112
|
+
PRECHORUS:
|
|
113
|
+
D
|
|
114
|
+
Labeled section with chords...
|
|
115
|
+
|
|
116
|
+
INSTRUMENTAL: (VERSE, CHORUS*2)
|
|
117
|
+
FILL: D G D A D
|
|
118
|
+
|
|
119
|
+
BRIDGE
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Section Type Inference
|
|
123
|
+
|
|
124
|
+
1. 1st block with chords+lyrics → `verse`
|
|
125
|
+
2. 2nd block with chords+lyrics → `chorus`
|
|
126
|
+
3. 3rd block with chords+lyrics → `bridge`
|
|
127
|
+
4. Subsequent lyric-only blocks → `verse` (inherits first verse's chords)
|
|
128
|
+
5. `LABEL:` with body → named section (e.g., `prechorus`)
|
|
129
|
+
6. `LABEL: expression` → directive (e.g., `instrumental`, `fill`)
|
|
130
|
+
7. `LABEL` or `LABEL*N` → section reference / repeat
|
|
131
|
+
|
|
132
|
+
### Expression Grammar
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
Expression = Sequence
|
|
136
|
+
Sequence = Item (',' Item)*
|
|
137
|
+
Item = Atom ('*' Number)?
|
|
138
|
+
Atom = SectionRef | ChordList | '(' Sequence ')'
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Examples: `(VERSE, CHORUS*2)`, `(D G D A)*4`, `D G D A D`
|
|
142
|
+
|
|
143
|
+
## Future Features
|
|
144
|
+
|
|
145
|
+
### Percentage-based timing (`%`)
|
|
146
|
+
Support `%` notation for specifying timing or duration within a measure:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
G%50 C%50 — split measure: 50% G, 50% C
|
|
150
|
+
D%75 G%25 — 3/4 D, 1/4 G
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
This would add a `duration` or `percent` field to chord objects:
|
|
154
|
+
```js
|
|
155
|
+
{ root: 'G', type: '', percent: 50 }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Slash chords
|
|
159
|
+
Parse slash chords like `G/B`, `Am/E`:
|
|
160
|
+
```js
|
|
161
|
+
{ root: 'G', type: '', bass: 'B' }
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Time signature support
|
|
165
|
+
```
|
|
166
|
+
TIME: 3/4
|
|
167
|
+
```
|
|
168
|
+
Stored as `song.timeSignature = { beats: 3, value: 4 }`.
|
|
169
|
+
|
|
170
|
+
### Key detection
|
|
171
|
+
Auto-detect the song's key from its chord progression. Useful for intelligent transposition suggestions.
|
|
172
|
+
|
|
173
|
+
### Measure/bar grouping
|
|
174
|
+
Currently `|` markers are tracked by column position. Future: group chords into explicit measures with bar lines as structural delimiters.
|
|
175
|
+
|
|
176
|
+
### Multi-voice / harmony lines
|
|
177
|
+
Support for parallel harmony notation — multiple chord lines mapped to the same lyric line.
|
|
178
|
+
|
|
179
|
+
### Nashville number system output
|
|
180
|
+
Convert chord roots to Nashville numbers (1, 2, 3...) relative to the detected or specified key.
|
package/README.md
CHANGED
|
@@ -1,2 +1,137 @@
|
|
|
1
1
|
# songsheet
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
A zero-dependency songsheet parser and transposer. Parses plaintext songsheet files into a structured AST with chord-lyric character alignment.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install songsheet
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { parse, transpose } from 'songsheet'
|
|
15
|
+
|
|
16
|
+
const song = parse(rawText) // synchronous, returns plain object
|
|
17
|
+
const songInA = transpose(song, 2) // up 2 semitones
|
|
18
|
+
const songInF = transpose(song, -2) // down 2 semitones
|
|
19
|
+
const songInBb = transpose(song, 3, { preferFlats: true })
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Songsheet Format
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
SONG TITLE - AUTHOR NAME
|
|
26
|
+
|
|
27
|
+
G F
|
|
28
|
+
Lyrics aligned under chords...
|
|
29
|
+
C G
|
|
30
|
+
More lyrics here
|
|
31
|
+
|
|
32
|
+
F C D
|
|
33
|
+
Chorus lyrics...
|
|
34
|
+
|
|
35
|
+
Verse lyrics without chords (inherits first verse's chord pattern)
|
|
36
|
+
|
|
37
|
+
CHORUS
|
|
38
|
+
CHORUS*2
|
|
39
|
+
|
|
40
|
+
PRECHORUS:
|
|
41
|
+
D
|
|
42
|
+
Labeled section with chords...
|
|
43
|
+
|
|
44
|
+
INSTRUMENTAL: (VERSE, CHORUS*2)
|
|
45
|
+
FILL: D G D A D
|
|
46
|
+
|
|
47
|
+
BRIDGE
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Section Type Inference
|
|
51
|
+
|
|
52
|
+
1. 1st block with chords+lyrics → `verse`
|
|
53
|
+
2. 2nd block with chords+lyrics → `chorus`
|
|
54
|
+
3. 3rd block with chords+lyrics → `bridge`
|
|
55
|
+
4. Subsequent lyric-only blocks → `verse` (inherits first verse's chords)
|
|
56
|
+
5. `LABEL:` with body → named section (e.g., `prechorus`)
|
|
57
|
+
6. `LABEL: expression` → directive (e.g., `instrumental`, `fill`)
|
|
58
|
+
7. `LABEL` or `LABEL*N` → section reference / repeat
|
|
59
|
+
|
|
60
|
+
### Expression Grammar
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
Expression = Sequence
|
|
64
|
+
Sequence = Item (',' Item)*
|
|
65
|
+
Item = Atom ('*' Number)?
|
|
66
|
+
Atom = SectionRef | ChordList | '(' Sequence ')'
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Examples: `(VERSE, CHORUS*2)`, `(D G D A)*4`, `D G D A D`
|
|
70
|
+
|
|
71
|
+
## AST Shape
|
|
72
|
+
|
|
73
|
+
`parse()` returns:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
{
|
|
77
|
+
title: 'SONG TITLE',
|
|
78
|
+
author: 'AUTHOR NAME',
|
|
79
|
+
|
|
80
|
+
// Unique section definitions
|
|
81
|
+
sections: {
|
|
82
|
+
verse: {
|
|
83
|
+
count: 4,
|
|
84
|
+
chords: [{ root: 'G', type: '' }, { root: 'F', type: '' }, ...],
|
|
85
|
+
lyrics: ['lyric line 1', ...],
|
|
86
|
+
lines: [
|
|
87
|
+
{
|
|
88
|
+
chords: [{ root: 'G', type: '', column: 0 }, ...],
|
|
89
|
+
barLines: [], // column positions of | markers
|
|
90
|
+
lyrics: 'lyric line 1',
|
|
91
|
+
characters: [
|
|
92
|
+
{ character: 'B', chord: { root: 'G', type: '' } },
|
|
93
|
+
{ character: 'l' },
|
|
94
|
+
...
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
chorus: { ... },
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
// Ordered playback structure
|
|
103
|
+
structure: [
|
|
104
|
+
{
|
|
105
|
+
sectionType: 'verse',
|
|
106
|
+
sectionIndex: 0,
|
|
107
|
+
chords: [...],
|
|
108
|
+
lyrics: [...],
|
|
109
|
+
lines: [...],
|
|
110
|
+
expression: null, // non-null on directive entries
|
|
111
|
+
},
|
|
112
|
+
...
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Transposition
|
|
118
|
+
|
|
119
|
+
`transpose()` deep-walks the AST and replaces every chord root. It auto-detects whether the song uses flats or sharps, or you can override with `{ preferFlats: true }`.
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
const song = parse(rawText)
|
|
123
|
+
const up2 = transpose(song, 2) // G → A
|
|
124
|
+
const down3 = transpose(song, -3, { preferFlats: true }) // G → Eb
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npm test # vitest run — all tests
|
|
131
|
+
npx vitest # watch mode
|
|
132
|
+
npx vitest run test/parser.test.js # single file
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
ISC
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface Chord {
|
|
2
|
+
root: string
|
|
3
|
+
type: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface PositionedChord extends Chord {
|
|
7
|
+
column: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Character {
|
|
11
|
+
character: string
|
|
12
|
+
chord?: Chord
|
|
13
|
+
barLine?: true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Line {
|
|
17
|
+
chords: PositionedChord[]
|
|
18
|
+
barLines: number[]
|
|
19
|
+
lyrics: string
|
|
20
|
+
characters: Character[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Expression =
|
|
24
|
+
| { type: 'section_ref'; name: string }
|
|
25
|
+
| { type: 'chord_list'; chords: Chord[] }
|
|
26
|
+
| { type: 'sequence'; items: Expression[] }
|
|
27
|
+
| { type: 'repeat'; body: Expression; count: number }
|
|
28
|
+
|
|
29
|
+
export interface Section {
|
|
30
|
+
count: number
|
|
31
|
+
chords: Chord[]
|
|
32
|
+
lyrics: string[]
|
|
33
|
+
lines: Line[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface StructureEntry {
|
|
37
|
+
sectionType: string
|
|
38
|
+
sectionIndex: number
|
|
39
|
+
chords: Chord[]
|
|
40
|
+
lyrics: string[]
|
|
41
|
+
lines: Line[]
|
|
42
|
+
expression: Expression | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Song {
|
|
46
|
+
title: string
|
|
47
|
+
author: string
|
|
48
|
+
bpm: number | null
|
|
49
|
+
sections: Record<string, Section>
|
|
50
|
+
structure: StructureEntry[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parse(raw: string): Song
|
|
54
|
+
export function transpose(song: Song, semitones: number, options?: { preferFlats?: boolean }): Song
|
package/index.js
CHANGED
|
@@ -1,214 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const isCaps = string => string.toUpperCase() === string
|
|
4
|
-
|
|
5
|
-
const chordRegExp = /(([A-G]{1}[b#]{0,1})([\w+]*))/g // http://regexr.com/3eesm
|
|
6
|
-
const wordRegExp = /([A-Za-z#\+7])+/g // http://regexr.com/3eep7
|
|
7
|
-
|
|
8
|
-
const isChordLine = string => string.match(chordRegExp) && string.match(wordRegExp)
|
|
9
|
-
? string.match(chordRegExp).length === string.match(wordRegExp).length && !string.match(/:/)
|
|
10
|
-
: false
|
|
11
|
-
|
|
12
|
-
const multiplyRegExp = /([A-Z]*|\((.*)\))\*([0-9])/ // http://regexr.com/3eero
|
|
13
|
-
|
|
14
|
-
const multiplyExpand = line => {
|
|
15
|
-
const multiplyExecResults = multiplyRegExp.exec(line)
|
|
16
|
-
return multiplyExecResults
|
|
17
|
-
? line.replace(multiplyRegExp,
|
|
18
|
-
`${multiplyExecResults[2] || multiplyExecResults[1]} `
|
|
19
|
-
.repeat(multiplyRegExp.exec(line)[3]))
|
|
20
|
-
.replace(/[\(\)]/g, '')
|
|
21
|
-
.slice(0, -1)
|
|
22
|
-
: line.replace(/[\(\)]/g, '')
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const notSectionHeader = (lines) => !(lines.length === 1 && isCaps(lines[0]))
|
|
26
|
-
|
|
27
|
-
const getLyrics = ({ rawSection }) => {
|
|
28
|
-
const lines = rawSection.split('\n')
|
|
29
|
-
|
|
30
|
-
return notSectionHeader(lines)
|
|
31
|
-
? lines.reduce((lyrics, line) => {
|
|
32
|
-
if (!isChordLine(line) && !isCaps(line)) {
|
|
33
|
-
lyrics.push(line)
|
|
34
|
-
}
|
|
35
|
-
return lyrics
|
|
36
|
-
}, [])
|
|
37
|
-
: []
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const getChordLines = ({ rawSection }) => {
|
|
41
|
-
const lines = rawSection.split('\n')
|
|
42
|
-
|
|
43
|
-
return notSectionHeader(lines)
|
|
44
|
-
? lines.reduce((chords, line) => {
|
|
45
|
-
if (isChordLine(line)) {
|
|
46
|
-
chords.push(line)
|
|
47
|
-
}
|
|
48
|
-
return chords
|
|
49
|
-
}, [])
|
|
50
|
-
: []
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const getChords = ({ rawSection, song }) => {
|
|
54
|
-
const rawSectionLine = /.*[A-Z]:/.test(rawSection)
|
|
55
|
-
? rawSection.match(/.*[A-Z]:(.*)/)[1]
|
|
56
|
-
: false
|
|
57
|
-
|
|
58
|
-
return rawSectionLine
|
|
59
|
-
? multiplyExpand(rawSectionLine)
|
|
60
|
-
.match(wordRegExp)
|
|
61
|
-
.map(word => {
|
|
62
|
-
const section = song.get('sections').get(word.toLowerCase())
|
|
63
|
-
return section
|
|
64
|
-
? section.get('chords').toArray()
|
|
65
|
-
: word
|
|
66
|
-
})
|
|
67
|
-
.reduce((a, b) => a.concat(b), []) // flatten the array
|
|
68
|
-
: getChordLines({ rawSection }).reduce((chords, line) => {
|
|
69
|
-
let match
|
|
70
|
-
while ( ( match = chordRegExp.exec(line) ) != null ) {
|
|
71
|
-
const [,, root, type] = match
|
|
72
|
-
chords.push({ root, type })
|
|
73
|
-
}
|
|
74
|
-
return chords
|
|
75
|
-
}, [])
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const eachCharacterWithChordLine = ({ chordLine }) => (character, index) => {
|
|
79
|
-
const chordCheck = chordLine.charAt(index).match(/[A-G]/)
|
|
80
|
-
|
|
81
|
-
const chord = chordCheck
|
|
82
|
-
? chordLine.slice(index).match(/[^ ]*/)[0]
|
|
83
|
-
: ''
|
|
84
|
-
|
|
85
|
-
return chordCheck
|
|
86
|
-
? { chord, character }
|
|
87
|
-
: { character }
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const eachLyricLineWithChords = ({ chordLines }) => (lyricLine, i) => {
|
|
91
|
-
const chordLine = chordLines[i]
|
|
92
|
-
|
|
93
|
-
return chordLine
|
|
94
|
-
? lyricLine
|
|
95
|
-
.match(/(.)/g)
|
|
96
|
-
.map(eachCharacterWithChordLine({ chordLine }))
|
|
97
|
-
: []
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const getLyricLineCharacters = ({ rawSection }) => {
|
|
101
|
-
const lyrics = getLyrics({ rawSection })
|
|
102
|
-
const chordLines = getChordLines({ rawSection })
|
|
103
|
-
return lyrics && chordLines
|
|
104
|
-
? lyrics.map(eachLyricLineWithChords({ chordLines }))
|
|
105
|
-
: []
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const getSectionType = ({ rawSection, lyrics, chords, song }) => {
|
|
109
|
-
// (1st, 2nd, 3rd, and all remaining ordering from cascading logic of !chorus, !bridge, verses.length >= 1)
|
|
110
|
-
|
|
111
|
-
const sections = song.get('sections')
|
|
112
|
-
const verse = sections.get('verse')
|
|
113
|
-
const chorus = sections.get('chorus')
|
|
114
|
-
const bridge = sections.get('bridge')
|
|
115
|
-
|
|
116
|
-
// consider the 1st section with chords to be the verse
|
|
117
|
-
if (!verse) {
|
|
118
|
-
if (lyrics && chords.length > 0) {
|
|
119
|
-
return ['verse']
|
|
120
|
-
}
|
|
121
|
-
// consider the 2nd section with chords to be the chorus
|
|
122
|
-
} else if (verse.get('count') === 1 && lyrics.length > 0 && chords.length > 0 && !chorus) {
|
|
123
|
-
return ['chorus']
|
|
124
|
-
// consider the 3rd section with chords to be a bridge
|
|
125
|
-
} else if (verse.get('count') >= 1 && lyrics.length > 0 && chords.length > 0 && chorus && !bridge) {
|
|
126
|
-
return ['bridge']
|
|
127
|
-
// consider all remaining sections with lyrics and no chords to be verses
|
|
128
|
-
} else if (verse.get('count') >= 1 && lyrics.length > 0 && chords.length === 0 && chorus) {
|
|
129
|
-
return ['verse']
|
|
130
|
-
} else {
|
|
131
|
-
// if LABEL: - defines and gives a section a label
|
|
132
|
-
if (/.*[A-Z]:/.test(rawSection)) {
|
|
133
|
-
const [, sectionTitle] = rawSection.match(/(.*[A-Z]):/)
|
|
134
|
-
return [sectionTitle.toLowerCase()]
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// CHORUS, CHORUS*N
|
|
138
|
-
if (rawSection.substr(0, 6) === 'CHORUS') {
|
|
139
|
-
if (rawSection.length === 6) return ['chorus']
|
|
140
|
-
if (rawSection[6] === '*') {
|
|
141
|
-
const repeat = rawSection.split('*')[1]
|
|
142
|
-
|
|
143
|
-
const sectionTypes = []
|
|
144
|
-
let i = 0
|
|
145
|
-
while (i < repeat) {
|
|
146
|
-
sectionTypes.push('chorus')
|
|
147
|
-
i++
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return sectionTypes
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// bridge is built in
|
|
155
|
-
if (rawSection === 'BRIDGE') return ['bridge']
|
|
156
|
-
|
|
157
|
-
// TODO: INSTRUMENTAL (BRIDGE, CHORUS*2)
|
|
158
|
-
if (rawSection.split(' ')[0] === 'INSTRUMENTAL') return ['instrumental']
|
|
159
|
-
|
|
160
|
-
// if LABEL matches on previously defined LABEL:
|
|
161
|
-
if (rawSection.toLowerCase() && sections.get(rawSection.toLowerCase())) return [rawSection.toLowerCase()]
|
|
162
|
-
|
|
163
|
-
// otherwise it's a verse
|
|
164
|
-
if (lyrics && chords.length === 0) return ['verse']
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const parse = (rawSongsheet) => new Promise((resolve, reject) => {
|
|
169
|
-
const rawSections = rawSongsheet.replace('\r\n', '\n').replace(/^\s*\n/gm, '\n').split('\n\n')
|
|
170
|
-
resolve(rawSections.reduce((song, rawSection, structureIndex) => {
|
|
171
|
-
const presentLyrics = getLyrics({ rawSection })
|
|
172
|
-
const presentChords = getChords({ rawSection, song })
|
|
173
|
-
const presentLyricLineCharacters = getLyricLineCharacters({ rawSection })
|
|
174
|
-
|
|
175
|
-
const sectionTypes = getSectionType({ rawSection, lyrics: presentLyrics, chords: presentChords, song }) || []
|
|
176
|
-
|
|
177
|
-
return sectionTypes.reduce((song, sectionType) => {
|
|
178
|
-
const section = song.get('sections').get(sectionType)
|
|
179
|
-
|
|
180
|
-
const lyrics = presentLyrics.length === 0 && section && section.get('lyrics')
|
|
181
|
-
? section.get('lyrics')
|
|
182
|
-
: fromJS(presentLyrics)
|
|
183
|
-
|
|
184
|
-
const chords = presentChords.length === 0 && section && section.get('chords')
|
|
185
|
-
? section.get('chords')
|
|
186
|
-
: fromJS(presentChords)
|
|
187
|
-
|
|
188
|
-
const lyricLineCharacters = presentLyrics.length === 0 && section && section.get('lyricLineCharacters')
|
|
189
|
-
? section.get('lyricLineCharacters')
|
|
190
|
-
: fromJS(presentLyricLineCharacters)
|
|
191
|
-
|
|
192
|
-
return (!section
|
|
193
|
-
? song.mergeDeepIn(['sections', sectionType], { lyrics, chords, lyricLineCharacters, count: 0 })
|
|
194
|
-
: song)
|
|
195
|
-
.updateIn(['sections', sectionType, 'count'], count => count + 1)
|
|
196
|
-
.updateIn(['structure'], structure => structure.push(Map({ sectionType, lyrics, chords, lyricLineCharacters, sectionIndex: song.getIn(['sections', sectionType, 'count']) || 0 })))
|
|
197
|
-
}, song)
|
|
198
|
-
}, Map({
|
|
199
|
-
title: rawSections[0].split(' - ')[0],
|
|
200
|
-
author: rawSections[0].split(' - ')[1],
|
|
201
|
-
structure: List(),
|
|
202
|
-
sections: Map({}),
|
|
203
|
-
rawSongsheet
|
|
204
|
-
})))
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
module.exports = () => ({
|
|
208
|
-
parse,
|
|
209
|
-
chordRegExp,
|
|
210
|
-
wordRegExp,
|
|
211
|
-
isChordLine,
|
|
212
|
-
multiplyRegExp,
|
|
213
|
-
multiplyExpand
|
|
214
|
-
})
|
|
1
|
+
export { parse } from './src/parser.js'
|
|
2
|
+
export { transpose } from './src/transpose.js'
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "songsheet",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0",
|
|
4
4
|
"description": "A songsheet interpreter",
|
|
5
|
-
"
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": "./index.js",
|
|
6
7
|
"scripts": {
|
|
7
|
-
"test": "
|
|
8
|
+
"test": "vitest run"
|
|
8
9
|
},
|
|
9
10
|
"repository": {
|
|
10
11
|
"type": "git",
|
|
@@ -24,12 +25,6 @@
|
|
|
24
25
|
},
|
|
25
26
|
"homepage": "https://github.com/williamcotton/songsheet#readme",
|
|
26
27
|
"devDependencies": {
|
|
27
|
-
"
|
|
28
|
-
"fs-promise": "^0.5.0"
|
|
29
|
-
},
|
|
30
|
-
"dependencies": {
|
|
31
|
-
"immutable": "^3.8.1",
|
|
32
|
-
"react-addons-pure-render-mixin": "^15.3.2",
|
|
33
|
-
"tokenizer": "^1.1.2"
|
|
28
|
+
"vitest": "^3.0.0"
|
|
34
29
|
}
|
|
35
30
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
SPENT SOME TIME IN BUFFALO - WILLIE COTTON
|
|
2
|
+
(100 BPM)
|
|
2
3
|
|
|
3
|
-
D
|
|
4
|
+
D |
|
|
4
5
|
Sweating bullets in the morning
|
|
5
|
-
G
|
|
6
|
+
G |
|
|
6
7
|
The same old rhyme, the same old reason
|
|
7
|
-
A C D
|
|
8
|
+
A C D |
|
|
8
9
|
Had to get our fill of drinking up the last warm night of this here summer season
|
|
9
10
|
|
|
10
11
|
Riding shotgun down to Dansville
|
|
@@ -12,16 +13,19 @@ G
|
|
|
12
13
|
Pretty soon this truck'll be kicking up rock salt in everybody's front lawn
|
|
13
14
|
|
|
14
15
|
PRECHORUS:
|
|
15
|
-
D
|
|
16
|
+
D |
|
|
16
17
|
Spent some time in Buffalo
|
|
18
|
+
D |
|
|
17
19
|
Dug my life out of the snow
|
|
20
|
+
D |
|
|
18
21
|
And when those lake winds start to blow
|
|
22
|
+
D |
|
|
19
23
|
I've had my fill it's time to go
|
|
20
24
|
|
|
21
25
|
CHORUS:
|
|
22
|
-
C G D
|
|
26
|
+
C G D |
|
|
23
27
|
Head out west to find the good life but I'm thinking of you now
|
|
24
|
-
C
|
|
28
|
+
C G D |
|
|
25
29
|
Can't pay the rent, packing up my tent, picking up my money, I'm headed home
|
|
26
30
|
|
|
27
31
|
Just like Jimmie nobody wants me
|