songsheet 6.0.0 → 6.2.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.md +16 -18
- package/README.md +28 -3
- package/index.d.ts +15 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/song-of-myself.txt +1 -0
- package/src/lexer.js +194 -21
- package/src/notation.js +189 -0
- package/src/parser.js +75 -12
- package/src/transpose.js +10 -1
- package/test/lexer.test.js +66 -0
- package/test/nns.test.js +413 -0
- package/test/parser.test.js +64 -0
- package/test/transpose.test.js +13 -0
package/CLAUDE.md
CHANGED
|
@@ -16,15 +16,16 @@ const songInBb = transpose(song, 3, { preferFlats: true })
|
|
|
16
16
|
|
|
17
17
|
```
|
|
18
18
|
index.js — Public API: re-exports parse + transpose
|
|
19
|
+
index.d.ts — TypeScript type definitions (adjacent to index.js)
|
|
19
20
|
src/
|
|
20
21
|
lexer.js — scanChordLine(), isChordLine(), lexExpression()
|
|
21
22
|
parser.js — parse(), expression parser, section assembly
|
|
22
23
|
transpose.js — transpose(), note math
|
|
23
24
|
test/
|
|
24
|
-
lexer.test.js — Chord line detection, bar lines, edge cases
|
|
25
|
-
parser.test.js — All 4 song fixture tests + bar line tests
|
|
25
|
+
lexer.test.js — Chord line detection, bar lines, slash chords, edge cases
|
|
26
|
+
parser.test.js — All 4 song fixture tests + bar line + time signature tests
|
|
26
27
|
expression.test.js — Expression parsing and resolution
|
|
27
|
-
transpose.test.js — Semitone math, round-trips, flat/sharp preference
|
|
28
|
+
transpose.test.js — Semitone math, round-trips, flat/sharp preference, slash chords
|
|
28
29
|
*.txt — Song fixtures (do not modify)
|
|
29
30
|
```
|
|
30
31
|
|
|
@@ -48,10 +49,15 @@ npx vitest run test/parser.test.js # single file
|
|
|
48
49
|
{
|
|
49
50
|
title: 'SONG TITLE',
|
|
50
51
|
author: 'AUTHOR NAME',
|
|
52
|
+
bpm: 120, // number | null
|
|
53
|
+
timeSignature: { // { beats, value } | null
|
|
54
|
+
beats: 3,
|
|
55
|
+
value: 4,
|
|
56
|
+
},
|
|
51
57
|
sections: {
|
|
52
58
|
verse: {
|
|
53
59
|
count: 4,
|
|
54
|
-
chords: [{ root: 'G', type: '' }, { root: 'F', type: '' }, ...],
|
|
60
|
+
chords: [{ root: 'G', type: '' }, { root: 'F', type: '', bass: 'B' }, ...],
|
|
55
61
|
lyrics: ['lyric line 1', ...],
|
|
56
62
|
lines: [
|
|
57
63
|
{
|
|
@@ -84,21 +90,25 @@ npx vitest run test/parser.test.js # single file
|
|
|
84
90
|
|
|
85
91
|
## Key Design Decisions
|
|
86
92
|
|
|
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
|
|
93
|
+
- **Chord line detection**: Exhaustive left-to-right scan — every non-whitespace token must parse as a valid chord (including slash chords) or `|`, otherwise the line is lyrics
|
|
88
94
|
- **root includes accidental**: `root: 'Bb'` not `root: 'B', accidental: 'b'`
|
|
95
|
+
- **Slash chords**: `G/B` → `{ root: 'G', type: '', bass: 'B' }`. Bass note is optional — only present on slash chords
|
|
89
96
|
- **Synchronous parse**: No Promise wrapper, plain objects (no Immutable.js)
|
|
90
97
|
- **Expression AST preserved**: `(VERSE, CHORUS*2)` stored as tree AND resolved to flat chords
|
|
91
98
|
- **Character alignment includes barLines**: `{ character: 'r', barLine: true }` at `|` column positions
|
|
92
99
|
- **Column preservation**: Chord lines are never trimmed — column positions match the original file
|
|
100
|
+
- **Title metadata**: BPM and time signature parsed from `(120 BPM, 3/4 time)` in the title block
|
|
101
|
+
- **TypeScript types**: `index.d.ts` adjacent to `index.js` — consumers get types automatically with bundler module resolution
|
|
93
102
|
|
|
94
103
|
## Songsheet Format
|
|
95
104
|
|
|
96
105
|
```
|
|
97
106
|
SONG TITLE - AUTHOR NAME
|
|
107
|
+
(120 BPM, 3/4 time)
|
|
98
108
|
|
|
99
109
|
G F
|
|
100
110
|
Lyrics aligned under chords...
|
|
101
|
-
C
|
|
111
|
+
C/E G/B
|
|
102
112
|
More lyrics here
|
|
103
113
|
|
|
104
114
|
F C D
|
|
@@ -155,18 +165,6 @@ This would add a `duration` or `percent` field to chord objects:
|
|
|
155
165
|
{ root: 'G', type: '', percent: 50 }
|
|
156
166
|
```
|
|
157
167
|
|
|
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
168
|
### Key detection
|
|
171
169
|
Auto-detect the song's key from its chord progression. Useful for intelligent transposition suggestions.
|
|
172
170
|
|
package/README.md
CHANGED
|
@@ -23,10 +23,11 @@ const songInBb = transpose(song, 3, { preferFlats: true })
|
|
|
23
23
|
|
|
24
24
|
```
|
|
25
25
|
SONG TITLE - AUTHOR NAME
|
|
26
|
+
(120 BPM, 3/4 time)
|
|
26
27
|
|
|
27
28
|
G F
|
|
28
29
|
Lyrics aligned under chords...
|
|
29
|
-
C
|
|
30
|
+
C/E G/B
|
|
30
31
|
More lyrics here
|
|
31
32
|
|
|
32
33
|
F C D
|
|
@@ -47,6 +48,25 @@ FILL: D G D A D
|
|
|
47
48
|
BRIDGE
|
|
48
49
|
```
|
|
49
50
|
|
|
51
|
+
### Metadata
|
|
52
|
+
|
|
53
|
+
BPM and time signature can be specified in parentheses after the title line:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
SONG TITLE - AUTHOR NAME
|
|
57
|
+
(120 BPM)
|
|
58
|
+
(3/4 time)
|
|
59
|
+
(120 BPM, 3/4 time)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Slash Chords
|
|
63
|
+
|
|
64
|
+
Slash chords like `G/B`, `Am7/E`, `F#m/C#` are supported in both chord lines and expressions. The bass note is stored separately:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
{ root: 'G', type: '', bass: 'B' }
|
|
68
|
+
```
|
|
69
|
+
|
|
50
70
|
### Section Type Inference
|
|
51
71
|
|
|
52
72
|
1. 1st block with chords+lyrics → `verse`
|
|
@@ -76,12 +96,17 @@ Examples: `(VERSE, CHORUS*2)`, `(D G D A)*4`, `D G D A D`
|
|
|
76
96
|
{
|
|
77
97
|
title: 'SONG TITLE',
|
|
78
98
|
author: 'AUTHOR NAME',
|
|
99
|
+
bpm: 120, // number | null
|
|
100
|
+
timeSignature: { // { beats, value } | null
|
|
101
|
+
beats: 3,
|
|
102
|
+
value: 4,
|
|
103
|
+
},
|
|
79
104
|
|
|
80
105
|
// Unique section definitions
|
|
81
106
|
sections: {
|
|
82
107
|
verse: {
|
|
83
108
|
count: 4,
|
|
84
|
-
chords: [{ root: 'G', type: '' }, { root: 'F', type: '' }, ...],
|
|
109
|
+
chords: [{ root: 'G', type: '' }, { root: 'F', type: '', bass: 'B' }, ...],
|
|
85
110
|
lyrics: ['lyric line 1', ...],
|
|
86
111
|
lines: [
|
|
87
112
|
{
|
|
@@ -116,7 +141,7 @@ Examples: `(VERSE, CHORUS*2)`, `(D G D A)*4`, `D G D A D`
|
|
|
116
141
|
|
|
117
142
|
## Transposition
|
|
118
143
|
|
|
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 }`.
|
|
144
|
+
`transpose()` deep-walks the AST and replaces every chord root (and bass note on slash chords). It auto-detects whether the song uses flats or sharps, or you can override with `{ preferFlats: true }`.
|
|
120
145
|
|
|
121
146
|
```js
|
|
122
147
|
const song = parse(rawText)
|
package/index.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
export interface Chord {
|
|
2
2
|
root: string
|
|
3
3
|
type: string
|
|
4
|
+
bass?: string
|
|
5
|
+
nashville?: boolean
|
|
6
|
+
diamond?: boolean
|
|
7
|
+
push?: boolean
|
|
8
|
+
stop?: boolean
|
|
9
|
+
splitMeasure?: Chord[]
|
|
4
10
|
}
|
|
5
11
|
|
|
6
12
|
export interface PositionedChord extends Chord {
|
|
@@ -42,13 +48,22 @@ export interface StructureEntry {
|
|
|
42
48
|
expression: Expression | null
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
export interface TimeSignature {
|
|
52
|
+
beats: number
|
|
53
|
+
value: number
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
export interface Song {
|
|
46
57
|
title: string
|
|
47
58
|
author: string
|
|
48
59
|
bpm: number | null
|
|
60
|
+
timeSignature: TimeSignature | null
|
|
61
|
+
key: string | null
|
|
49
62
|
sections: Record<string, Section>
|
|
50
63
|
structure: StructureEntry[]
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
export function parse(raw: string): Song
|
|
54
67
|
export function transpose(song: Song, semitones: number, options?: { preferFlats?: boolean }): Song
|
|
68
|
+
export function toNashville(song: Song, key: string): Song
|
|
69
|
+
export function toStandard(song: Song, key: string): Song
|
package/index.js
CHANGED
package/package.json
CHANGED
package/song-of-myself.txt
CHANGED
package/src/lexer.js
CHANGED
|
@@ -1,13 +1,67 @@
|
|
|
1
1
|
const ROOTS = new Set(['A', 'B', 'C', 'D', 'E', 'F', 'G'])
|
|
2
|
+
const NNS_ROOTS = new Set(['1', '2', '3', '4', '5', '6', '7'])
|
|
2
3
|
const ACCIDENTALS = new Set(['#', 'b'])
|
|
3
|
-
const QUALITY_CHARS = /[a-z0-9
|
|
4
|
+
const QUALITY_CHARS = /[a-z0-9#+]/i
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Try to parse a chord starting at position i in line.
|
|
8
|
+
* Accepts both letter roots (A-G) and NNS roots (1-7).
|
|
9
|
+
* Returns { token, end } or null.
|
|
10
|
+
*/
|
|
11
|
+
function parseChordAt(line, i, stopChars) {
|
|
12
|
+
const ch = line[i]
|
|
13
|
+
const isNNS = NNS_ROOTS.has(ch)
|
|
14
|
+
const isLetter = ROOTS.has(ch)
|
|
15
|
+
if (!isNNS && !isLetter) return null
|
|
16
|
+
|
|
17
|
+
let root = ch
|
|
18
|
+
let j = i + 1
|
|
19
|
+
|
|
20
|
+
// accidental (only for letter roots)
|
|
21
|
+
if (!isNNS && j < line.length && ACCIDENTALS.has(line[j])) {
|
|
22
|
+
root += line[j]
|
|
23
|
+
j++
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// quality: consume word chars that are valid in chord names
|
|
27
|
+
let quality = ''
|
|
28
|
+
while (j < line.length && QUALITY_CHARS.test(line[j]) && line[j] !== ' ' && line[j] !== '|') {
|
|
29
|
+
if (stopChars && stopChars.has(line[j])) break
|
|
30
|
+
quality += line[j]
|
|
31
|
+
j++
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// slash chord: /Bass
|
|
35
|
+
let bass = ''
|
|
36
|
+
if (j < line.length && line[j] === '/') {
|
|
37
|
+
j++ // consume /
|
|
38
|
+
if (j < line.length && (ROOTS.has(line[j]) || NNS_ROOTS.has(line[j]))) {
|
|
39
|
+
const bassIsNNS = NNS_ROOTS.has(line[j])
|
|
40
|
+
bass = line[j]
|
|
41
|
+
j++
|
|
42
|
+
if (!bassIsNNS && j < line.length && ACCIDENTALS.has(line[j])) {
|
|
43
|
+
bass += line[j]
|
|
44
|
+
j++
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
return null // / not followed by a valid note
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const token = { type: 'CHORD', column: i, root, quality }
|
|
52
|
+
if (bass) token.bass = bass
|
|
53
|
+
if (isNNS) token.nashville = true
|
|
54
|
+
return { token, end: j }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DECORATOR_STOP_CHARS = new Set(['>', ']', '!'])
|
|
4
58
|
|
|
5
59
|
/**
|
|
6
60
|
* Scan a line left-to-right and return an array of chord/bar-line tokens,
|
|
7
61
|
* or null if the line is not a valid chord line.
|
|
8
62
|
*
|
|
9
63
|
* A chord line must contain at least one chord, and every non-whitespace
|
|
10
|
-
* token must parse as a valid chord or `|`.
|
|
64
|
+
* token must parse as a valid chord, decorator, or `|`.
|
|
11
65
|
*/
|
|
12
66
|
export function scanChordLine(line) {
|
|
13
67
|
if (!line || line.trim().length === 0) return null
|
|
@@ -31,21 +85,91 @@ export function scanChordLine(line) {
|
|
|
31
85
|
continue
|
|
32
86
|
}
|
|
33
87
|
|
|
34
|
-
//
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
i
|
|
88
|
+
// diamond: <chord>
|
|
89
|
+
if (line[i] === '<') {
|
|
90
|
+
i++ // consume <
|
|
91
|
+
const result = parseChordAt(line, i, DECORATOR_STOP_CHARS)
|
|
92
|
+
if (!result) return null
|
|
93
|
+
if (result.end >= line.length || line[result.end] !== '>') return null
|
|
94
|
+
i = result.end + 1 // consume >
|
|
95
|
+
const token = { ...result.token, column, diamond: true }
|
|
96
|
+
// next char must be whitespace, end-of-line, |, or !
|
|
97
|
+
if (i < line.length && line[i] === '!') {
|
|
98
|
+
token.stop = true
|
|
99
|
+
i++
|
|
100
|
+
}
|
|
101
|
+
if (i < line.length && line[i] !== ' ' && line[i] !== '\t' && line[i] !== '|') {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
tokens.push(token)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
38
107
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
108
|
+
// push: ^chord
|
|
109
|
+
if (line[i] === '^') {
|
|
110
|
+
i++ // consume ^
|
|
111
|
+
const result = parseChordAt(line, i, DECORATOR_STOP_CHARS)
|
|
112
|
+
if (!result) return null
|
|
113
|
+
i = result.end
|
|
114
|
+
const token = { ...result.token, column, push: true }
|
|
115
|
+
if (i < line.length && line[i] === '!') {
|
|
116
|
+
token.stop = true
|
|
42
117
|
i++
|
|
43
118
|
}
|
|
119
|
+
if (i < line.length && line[i] !== ' ' && line[i] !== '\t' && line[i] !== '|') {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
tokens.push(token)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
44
125
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
126
|
+
// split measure: [chord chord ...]
|
|
127
|
+
if (line[i] === '[') {
|
|
128
|
+
i++ // consume [
|
|
129
|
+
const chords = []
|
|
130
|
+
while (i < line.length && line[i] !== ']') {
|
|
131
|
+
if (line[i] === ' ' || line[i] === '\t') { i++; continue }
|
|
132
|
+
const result = parseChordAt(line, i, DECORATOR_STOP_CHARS)
|
|
133
|
+
if (!result) return null
|
|
134
|
+
chords.push(result.token)
|
|
135
|
+
i = result.end
|
|
136
|
+
}
|
|
137
|
+
if (i >= line.length || line[i] !== ']') return null
|
|
138
|
+
i++ // consume ]
|
|
139
|
+
if (chords.length < 2) return null
|
|
140
|
+
// next char must be whitespace, end-of-line, or |
|
|
141
|
+
if (i < line.length && line[i] !== ' ' && line[i] !== '\t' && line[i] !== '|') {
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
// Use the first chord as the main token, attach all chords as splitMeasure
|
|
145
|
+
const first = chords[0]
|
|
146
|
+
const token = {
|
|
147
|
+
type: 'CHORD',
|
|
148
|
+
column,
|
|
149
|
+
root: first.root,
|
|
150
|
+
quality: first.quality,
|
|
151
|
+
splitMeasure: chords.map(c => {
|
|
152
|
+
const sc = { root: c.root, type: c.quality }
|
|
153
|
+
if (c.bass) sc.bass = c.bass
|
|
154
|
+
if (c.nashville) sc.nashville = true
|
|
155
|
+
return sc
|
|
156
|
+
}),
|
|
157
|
+
}
|
|
158
|
+
if (first.bass) token.bass = first.bass
|
|
159
|
+
if (first.nashville) token.nashville = true
|
|
160
|
+
tokens.push(token)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// try to parse a chord: letter root or NNS root
|
|
165
|
+
if (ROOTS.has(line[i]) || NNS_ROOTS.has(line[i])) {
|
|
166
|
+
const result = parseChordAt(line, i, DECORATOR_STOP_CHARS)
|
|
167
|
+
if (!result) return null
|
|
168
|
+
i = result.end
|
|
169
|
+
|
|
170
|
+
// check for stop suffix
|
|
171
|
+
if (i < line.length && line[i] === '!') {
|
|
172
|
+
result.token.stop = true
|
|
49
173
|
i++
|
|
50
174
|
}
|
|
51
175
|
|
|
@@ -54,7 +178,7 @@ export function scanChordLine(line) {
|
|
|
54
178
|
return null // not a chord line
|
|
55
179
|
}
|
|
56
180
|
|
|
57
|
-
tokens.push(
|
|
181
|
+
tokens.push(result.token)
|
|
58
182
|
continue
|
|
59
183
|
}
|
|
60
184
|
|
|
@@ -94,26 +218,58 @@ const ExprTokenTypes = {
|
|
|
94
218
|
export function lexExpression(text) {
|
|
95
219
|
const tokens = []
|
|
96
220
|
let i = 0
|
|
221
|
+
let lastType = null
|
|
97
222
|
|
|
98
223
|
while (i < text.length) {
|
|
99
224
|
if (text[i] === ' ' || text[i] === '\t') { i++; continue }
|
|
100
|
-
if (text[i] === '(') { tokens.push({ type: ExprTokenTypes.LPAREN }); i++; continue }
|
|
101
|
-
if (text[i] === ')') { tokens.push({ type: ExprTokenTypes.RPAREN }); i++; continue }
|
|
102
|
-
if (text[i] === ',') { tokens.push({ type: ExprTokenTypes.COMMA }); i++; continue }
|
|
103
|
-
if (text[i] === '*') { tokens.push({ type: ExprTokenTypes.STAR }); i++; continue }
|
|
225
|
+
if (text[i] === '(') { tokens.push({ type: ExprTokenTypes.LPAREN }); lastType = ExprTokenTypes.LPAREN; i++; continue }
|
|
226
|
+
if (text[i] === ')') { tokens.push({ type: ExprTokenTypes.RPAREN }); lastType = ExprTokenTypes.RPAREN; i++; continue }
|
|
227
|
+
if (text[i] === ',') { tokens.push({ type: ExprTokenTypes.COMMA }); lastType = ExprTokenTypes.COMMA; i++; continue }
|
|
228
|
+
if (text[i] === '*') { tokens.push({ type: ExprTokenTypes.STAR }); lastType = ExprTokenTypes.STAR; i++; continue }
|
|
104
229
|
|
|
105
|
-
//
|
|
230
|
+
// NNS chord: digit 1-7 when previous token is not STAR
|
|
231
|
+
if (NNS_ROOTS.has(text[i]) && lastType !== ExprTokenTypes.STAR) {
|
|
232
|
+
let root = text[i]
|
|
233
|
+
i++
|
|
234
|
+
// quality
|
|
235
|
+
let quality = ''
|
|
236
|
+
while (i < text.length && QUALITY_CHARS.test(text[i]) && text[i] !== ' ' && text[i] !== ')' && text[i] !== ',' && text[i] !== '*') {
|
|
237
|
+
quality += text[i]
|
|
238
|
+
i++
|
|
239
|
+
}
|
|
240
|
+
// slash bass
|
|
241
|
+
let bass = ''
|
|
242
|
+
if (i < text.length && text[i] === '/') {
|
|
243
|
+
i++
|
|
244
|
+
if (i < text.length && (ROOTS.has(text[i]) || NNS_ROOTS.has(text[i]))) {
|
|
245
|
+
bass = text[i]
|
|
246
|
+
i++
|
|
247
|
+
if (!NNS_ROOTS.has(bass) && i < text.length && ACCIDENTALS.has(text[i])) {
|
|
248
|
+
bass += text[i]
|
|
249
|
+
i++
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const token = { type: ExprTokenTypes.CHORD, root, quality, nashville: true }
|
|
254
|
+
if (bass) token.bass = bass
|
|
255
|
+
tokens.push(token)
|
|
256
|
+
lastType = ExprTokenTypes.CHORD
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// number (including NNS digits when after STAR)
|
|
106
261
|
if (/[0-9]/.test(text[i])) {
|
|
107
262
|
let num = ''
|
|
108
263
|
while (i < text.length && /[0-9]/.test(text[i])) { num += text[i]; i++ }
|
|
109
264
|
tokens.push({ type: ExprTokenTypes.NUMBER, value: parseInt(num, 10) })
|
|
265
|
+
lastType = ExprTokenTypes.NUMBER
|
|
110
266
|
continue
|
|
111
267
|
}
|
|
112
268
|
|
|
113
269
|
// word or chord: starts with a letter
|
|
114
270
|
if (/[A-Za-z]/.test(text[i])) {
|
|
115
271
|
let word = ''
|
|
116
|
-
while (i < text.length && /[A-Za-z0-9#b
|
|
272
|
+
while (i < text.length && /[A-Za-z0-9#b+]/.test(text[i])) { word += text[i]; i++ }
|
|
117
273
|
|
|
118
274
|
// Determine if this is a chord (starts with A-G, followed by optional accidental + quality)
|
|
119
275
|
// or a section reference word (all caps like VERSE, CHORUS)
|
|
@@ -125,9 +281,26 @@ export function lexExpression(text) {
|
|
|
125
281
|
root += rest[0]
|
|
126
282
|
rest = rest.slice(1)
|
|
127
283
|
}
|
|
128
|
-
|
|
284
|
+
// Check for slash bass note
|
|
285
|
+
let bass = ''
|
|
286
|
+
if (i < text.length && text[i] === '/') {
|
|
287
|
+
i++ // consume /
|
|
288
|
+
if (i < text.length && ROOTS.has(text[i])) {
|
|
289
|
+
bass = text[i]
|
|
290
|
+
i++
|
|
291
|
+
if (i < text.length && ACCIDENTALS.has(text[i])) {
|
|
292
|
+
bass += text[i]
|
|
293
|
+
i++
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const token = { type: ExprTokenTypes.CHORD, root, quality: rest }
|
|
298
|
+
if (bass) token.bass = bass
|
|
299
|
+
tokens.push(token)
|
|
300
|
+
lastType = ExprTokenTypes.CHORD
|
|
129
301
|
} else {
|
|
130
302
|
tokens.push({ type: ExprTokenTypes.WORD, value: word })
|
|
303
|
+
lastType = ExprTokenTypes.WORD
|
|
131
304
|
}
|
|
132
305
|
continue
|
|
133
306
|
}
|
package/src/notation.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { noteToSemitone, semitoneToNote } from './transpose.js'
|
|
2
|
+
|
|
3
|
+
const MAJOR_SCALE = [0, 2, 4, 5, 7, 9, 11] // semitones for scale degrees 1-7
|
|
4
|
+
const NNS_DIGITS = ['1', '2', '3', '4', '5', '6', '7']
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Convert a chord's root to a Nashville number relative to the given key.
|
|
8
|
+
* Returns { root, prefixAccidental } where root is '1'-'7' and prefixAccidental
|
|
9
|
+
* is '' (exact match), 'b' (flat of a scale degree), or '#' (sharp of a scale degree).
|
|
10
|
+
*/
|
|
11
|
+
function rootToNashville(root, keySemitone) {
|
|
12
|
+
const chordSemitone = noteToSemitone(root)
|
|
13
|
+
const interval = ((chordSemitone - keySemitone) + 12) % 12
|
|
14
|
+
|
|
15
|
+
// Exact scale degree match
|
|
16
|
+
const exactIdx = MAJOR_SCALE.indexOf(interval)
|
|
17
|
+
if (exactIdx !== -1) {
|
|
18
|
+
return { root: NNS_DIGITS[exactIdx], prefixAccidental: '' }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Flat of the next scale degree
|
|
22
|
+
const sharpIdx = MAJOR_SCALE.indexOf((interval + 1) % 12)
|
|
23
|
+
if (sharpIdx !== -1) {
|
|
24
|
+
return { root: NNS_DIGITS[sharpIdx], prefixAccidental: 'b' }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Sharp of the previous scale degree
|
|
28
|
+
const flatIdx = MAJOR_SCALE.indexOf((interval + 11) % 12)
|
|
29
|
+
if (flatIdx !== -1) {
|
|
30
|
+
return { root: NNS_DIGITS[flatIdx], prefixAccidental: '#' }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Shouldn't reach here, but fallback
|
|
34
|
+
return { root: root, prefixAccidental: '' }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Convert a Nashville number root back to a letter note.
|
|
39
|
+
*/
|
|
40
|
+
function nashvilleToRoot(root, keySemitone, prefixAccidental, preferFlats) {
|
|
41
|
+
const digit = parseInt(root, 10)
|
|
42
|
+
if (digit < 1 || digit > 7) return root
|
|
43
|
+
|
|
44
|
+
let semitone = (MAJOR_SCALE[digit - 1] + keySemitone) % 12
|
|
45
|
+
if (prefixAccidental === 'b') semitone = (semitone + 11) % 12
|
|
46
|
+
else if (prefixAccidental === '#') semitone = (semitone + 1) % 12
|
|
47
|
+
|
|
48
|
+
return semitoneToNote(semitone, preferFlats)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function convertChord(chord, keySemitone, toNNS, preferFlats) {
|
|
52
|
+
if (!chord || !chord.root) return chord
|
|
53
|
+
|
|
54
|
+
if (toNNS) {
|
|
55
|
+
// Already nashville — leave as-is
|
|
56
|
+
if (chord.nashville) return chord
|
|
57
|
+
|
|
58
|
+
const { root: nnsRoot, prefixAccidental } = rootToNashville(chord.root, keySemitone)
|
|
59
|
+
const result = { ...chord, root: nnsRoot, nashville: true }
|
|
60
|
+
if (prefixAccidental) {
|
|
61
|
+
result.type = prefixAccidental + chord.type
|
|
62
|
+
}
|
|
63
|
+
// Convert bass note too
|
|
64
|
+
if (chord.bass) {
|
|
65
|
+
const { root: nnsBass, prefixAccidental: bassAcc } = rootToNashville(chord.bass, keySemitone)
|
|
66
|
+
result.bass = (bassAcc || '') + nnsBass
|
|
67
|
+
}
|
|
68
|
+
if (chord.splitMeasure) {
|
|
69
|
+
result.splitMeasure = chord.splitMeasure.map(c => convertChord(c, keySemitone, true, preferFlats))
|
|
70
|
+
}
|
|
71
|
+
return result
|
|
72
|
+
} else {
|
|
73
|
+
// To standard — only convert nashville chords
|
|
74
|
+
if (!chord.nashville) return chord
|
|
75
|
+
|
|
76
|
+
// Extract prefix accidental from type if present
|
|
77
|
+
let prefixAccidental = ''
|
|
78
|
+
let type = chord.type
|
|
79
|
+
if (type.startsWith('b') || type.startsWith('#')) {
|
|
80
|
+
prefixAccidental = type[0]
|
|
81
|
+
type = type.slice(1)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const newRoot = nashvilleToRoot(chord.root, keySemitone, prefixAccidental, preferFlats)
|
|
85
|
+
const result = { ...chord, root: newRoot, type }
|
|
86
|
+
delete result.nashville
|
|
87
|
+
|
|
88
|
+
if (chord.bass) {
|
|
89
|
+
// Bass could have prefix accidental too (e.g., 'b3')
|
|
90
|
+
let bassPre = ''
|
|
91
|
+
let bassDigit = chord.bass
|
|
92
|
+
if (bassDigit.startsWith('b') || bassDigit.startsWith('#')) {
|
|
93
|
+
bassPre = bassDigit[0]
|
|
94
|
+
bassDigit = bassDigit.slice(1)
|
|
95
|
+
}
|
|
96
|
+
const digit = parseInt(bassDigit, 10)
|
|
97
|
+
if (digit >= 1 && digit <= 7) {
|
|
98
|
+
result.bass = nashvilleToRoot(bassDigit, keySemitone, bassPre, preferFlats)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (chord.splitMeasure) {
|
|
103
|
+
result.splitMeasure = chord.splitMeasure.map(c => convertChord(c, keySemitone, false, preferFlats))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return result
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function convertLines(lines, keySemitone, toNNS, preferFlats) {
|
|
111
|
+
return lines.map(line => ({
|
|
112
|
+
...line,
|
|
113
|
+
chords: line.chords.map(c => ({
|
|
114
|
+
...convertChord(c, keySemitone, toNNS, preferFlats),
|
|
115
|
+
column: c.column,
|
|
116
|
+
})),
|
|
117
|
+
characters: line.characters.map(ch => {
|
|
118
|
+
if (ch.chord) {
|
|
119
|
+
return { ...ch, chord: convertChord(ch.chord, keySemitone, toNNS, preferFlats) }
|
|
120
|
+
}
|
|
121
|
+
return ch
|
|
122
|
+
}),
|
|
123
|
+
}))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function convertExpressionNode(node, keySemitone, toNNS, preferFlats) {
|
|
127
|
+
if (!node) return node
|
|
128
|
+
switch (node.type) {
|
|
129
|
+
case 'chord_list':
|
|
130
|
+
return { ...node, chords: node.chords.map(c => convertChord(c, keySemitone, toNNS, preferFlats)) }
|
|
131
|
+
case 'sequence':
|
|
132
|
+
return { ...node, items: node.items.map(item => convertExpressionNode(item, keySemitone, toNNS, preferFlats)) }
|
|
133
|
+
case 'repeat':
|
|
134
|
+
return { ...node, body: convertExpressionNode(node.body, keySemitone, toNNS, preferFlats) }
|
|
135
|
+
default:
|
|
136
|
+
return node
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function detectPreferFlats(song) {
|
|
141
|
+
for (const key in song.sections) {
|
|
142
|
+
for (const chord of song.sections[key].chords) {
|
|
143
|
+
if (chord.root && chord.root.length === 2 && chord.root[1] === 'b') return true
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function convertSong(song, key, toNNS) {
|
|
150
|
+
const keySemitone = noteToSemitone(key)
|
|
151
|
+
const preferFlats = detectPreferFlats(song)
|
|
152
|
+
|
|
153
|
+
const newSections = {}
|
|
154
|
+
for (const sKey in song.sections) {
|
|
155
|
+
const section = song.sections[sKey]
|
|
156
|
+
newSections[sKey] = {
|
|
157
|
+
...section,
|
|
158
|
+
chords: section.chords.map(c => convertChord(c, keySemitone, toNNS, preferFlats)),
|
|
159
|
+
lines: convertLines(section.lines, keySemitone, toNNS, preferFlats),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const newStructure = song.structure.map(entry => ({
|
|
164
|
+
...entry,
|
|
165
|
+
chords: entry.chords.map(c => convertChord(c, keySemitone, toNNS, preferFlats)),
|
|
166
|
+
lines: convertLines(entry.lines, keySemitone, toNNS, preferFlats),
|
|
167
|
+
expression: convertExpressionNode(entry.expression, keySemitone, toNNS, preferFlats),
|
|
168
|
+
}))
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
...song,
|
|
172
|
+
sections: newSections,
|
|
173
|
+
structure: newStructure,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Convert a song with letter-root chords to Nashville Number System notation.
|
|
179
|
+
*/
|
|
180
|
+
export function toNashville(song, key) {
|
|
181
|
+
return convertSong(song, key, true)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Convert a song with Nashville Number System chords back to standard letter roots.
|
|
186
|
+
*/
|
|
187
|
+
export function toStandard(song, key) {
|
|
188
|
+
return convertSong(song, key, false)
|
|
189
|
+
}
|