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 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 G
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 G
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
@@ -1,2 +1,3 @@
1
1
  export { parse } from './src/parser.js'
2
2
  export { transpose } from './src/transpose.js'
3
+ export { toNashville, toStandard } from './src/notation.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "songsheet",
3
- "version": "6.0.0",
3
+ "version": "6.2.0",
4
4
  "description": "A songsheet interpreter",
5
5
  "type": "module",
6
6
  "exports": "./index.js",
@@ -1,4 +1,5 @@
1
1
  SONG OF MYSELF - WILLIE AND WALT
2
+ (3/4 time)
2
3
 
3
4
  G Am
4
5
  I have heard what the talkers were talking...
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#+\/]/i
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
- // try to parse a chord: root + optional accidental + optional quality
35
- if (ROOTS.has(line[i])) {
36
- let root = line[i]
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
- // accidental
40
- if (i < line.length && ACCIDENTALS.has(line[i])) {
41
- root += line[i]
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
- // quality: consume word chars that are valid in chord names
46
- let quality = ''
47
- while (i < line.length && QUALITY_CHARS.test(line[i]) && line[i] !== ' ' && line[i] !== '|') {
48
- quality += line[i]
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({ type: 'CHORD', column, root, quality })
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
- // number
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+\/]/.test(text[i])) { word += text[i]; i++ }
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
- tokens.push({ type: ExprTokenTypes.CHORD, root, quality: rest })
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
  }
@@ -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
+ }