@pepperhorn/chordl-core 0.3.1 → 0.4.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/dist/chord-sheet/codec.d.ts +1 -1
- package/dist/chord-sheet/codec.js +1 -1
- package/dist/chord-sheet/codec.js.map +1 -1
- package/dist/chord-sheet/defaults.d.ts +1 -1
- package/dist/chord-sheet/index.d.ts +3 -3
- package/dist/chord-sheet/index.js +3 -3
- package/dist/chord-sheet/index.js.map +1 -1
- package/dist/engine/auto-fingering.js +1 -1
- package/dist/engine/auto-fingering.js.map +1 -1
- package/dist/engine/highlight-mapper.d.ts +1 -1
- package/dist/engine/highlight-mapper.js +2 -2
- package/dist/engine/highlight-mapper.js.map +1 -1
- package/dist/engine/keyboard-layout.d.ts +1 -1
- package/dist/engine/keyboard-layout.js +1 -1
- package/dist/engine/keyboard-layout.js.map +1 -1
- package/dist/engine/mei-builder.d.ts +27 -0
- package/dist/engine/mei-builder.js +138 -0
- package/dist/engine/mei-builder.js.map +1 -0
- package/dist/engine/staff-glyphs.js +5 -10
- package/dist/engine/staff-glyphs.js.map +1 -1
- package/dist/engine/staff-layout.js +2 -2
- package/dist/engine/staff-layout.js.map +1 -1
- package/dist/index.d.ts +48 -42
- package/dist/index.js +32 -28
- package/dist/index.js.map +1 -1
- package/dist/parser/natural-language.d.ts +1 -1
- package/dist/parser/natural-language.js +432 -36
- package/dist/parser/natural-language.js.map +1 -1
- package/dist/parser/progression-parser.d.ts +1 -1
- package/dist/parser/progression-parser.js +1 -1
- package/dist/parser/progression-parser.js.map +1 -1
- package/dist/pipeline.d.ts +1 -1
- package/dist/pipeline.js +46 -25
- package/dist/pipeline.js.map +1 -1
- package/dist/progression/index.d.ts +5 -5
- package/dist/progression/index.js +3 -3
- package/dist/progression/index.js.map +1 -1
- package/dist/progression/progression-resolver.js +5 -5
- package/dist/progression/progression-resolver.js.map +1 -1
- package/dist/progression/roman-numeral.js +1 -1
- package/dist/progression/roman-numeral.js.map +1 -1
- package/dist/resolver/auto-layout.d.ts +1 -1
- package/dist/resolver/auto-layout.js +2 -2
- package/dist/resolver/auto-layout.js.map +1 -1
- package/dist/resolver/chord-resolver.d.ts +11 -1
- package/dist/resolver/chord-resolver.js +28 -2
- package/dist/resolver/chord-resolver.js.map +1 -1
- package/dist/resolver/scale-resolver.d.ts +5 -2
- package/dist/resolver/scale-resolver.js +28 -8
- package/dist/resolver/scale-resolver.js.map +1 -1
- package/dist/themes/boomwhacker.d.ts +1 -1
- package/dist/themes/boomwhacker.js +1 -1
- package/dist/themes/boomwhacker.js.map +1 -1
- package/dist/themes/crf.d.ts +1 -1
- package/dist/themes/crf.js +1 -1
- package/dist/themes/crf.js.map +1 -1
- package/dist/themes/index.d.ts +1 -1
- package/dist/themes/index.js +3 -3
- package/dist/themes/index.js.map +1 -1
- package/dist/themes/simple.d.ts +1 -1
- package/dist/themes/simple.js +1 -1
- package/dist/themes/simple.js.map +1 -1
- package/dist/theory/constrained-variants.d.ts +46 -0
- package/dist/theory/constrained-variants.js +117 -0
- package/dist/theory/constrained-variants.js.map +1 -0
- package/dist/theory/degree-labels.d.ts +39 -0
- package/dist/theory/degree-labels.js +102 -1
- package/dist/theory/degree-labels.js.map +1 -1
- package/dist/theory/hand-constraints.d.ts +104 -0
- package/dist/theory/hand-constraints.js +214 -0
- package/dist/theory/hand-constraints.js.map +1 -0
- package/dist/types.d.ts +26 -1
- package/package.json +4 -4
|
@@ -1,4 +1,45 @@
|
|
|
1
|
-
|
|
1
|
+
import { resolveChord } from "../resolver/chord-resolver.js";
|
|
2
|
+
// Filler runs in two passes, and the order is load-bearing.
|
|
3
|
+
//
|
|
4
|
+
// The articles "a"/"an" are also the note A, and by the time filler is stripped
|
|
5
|
+
// every modifier has already been cut out — so "A with note names" arrives as
|
|
6
|
+
// "A ", and so does "a compact". The residual cannot tell them apart, because
|
|
7
|
+
// the article's own noun is one of the things that was deleted.
|
|
8
|
+
//
|
|
9
|
+
// So the article pass decides on evidence the stripping cannot destroy: the
|
|
10
|
+
// character after it, the capital letter the user typed, and where the token
|
|
11
|
+
// sat in the original input. See `stripArticles`.
|
|
12
|
+
const FILLER_WORDS = /\b(show\s+me|draw|display|render|please|the|with|that|this|me|of)\b/gi;
|
|
13
|
+
const ARTICLE_RE = /\b(an?)\b/gi;
|
|
14
|
+
/** An article at the very end of the *original* input is the note A: "a", "an A". */
|
|
15
|
+
const ARTICLE_AT_INPUT_END = /\ban?(?=[#/]|\s*$)/i;
|
|
16
|
+
/**
|
|
17
|
+
* Remove English articles without eating the note A.
|
|
18
|
+
*
|
|
19
|
+
* Three things say "this is the note, not an article", none of which an earlier
|
|
20
|
+
* `.replace` can have invented:
|
|
21
|
+
*
|
|
22
|
+
* - what follows is `#` or `/` — only a root can be ("A#dim", "A/C#");
|
|
23
|
+
* - the token is a capital `A`, which is how a chord is written and not how an
|
|
24
|
+
* article is ("A minor", "A with note names", "A in second inversion");
|
|
25
|
+
* - the article ends the original input ("a", "show me an A").
|
|
26
|
+
*
|
|
27
|
+
* Anything else is an article, including one left trailing because its noun was
|
|
28
|
+
* stripped — "in a compact layout" must not resolve to the chord A.
|
|
29
|
+
*/
|
|
30
|
+
function stripArticles(text, input) {
|
|
31
|
+
const bareArticleAtEnd = ARTICLE_AT_INPUT_END.test(input);
|
|
32
|
+
return text.replace(ARTICLE_RE, (token, _g, offset, whole) => {
|
|
33
|
+
const rest = whole.slice(offset + token.length);
|
|
34
|
+
if (/^[#/]/.test(rest))
|
|
35
|
+
return token;
|
|
36
|
+
if (token === "A")
|
|
37
|
+
return token;
|
|
38
|
+
if (/^\s*$/.test(rest) && bareArticleAtEnd)
|
|
39
|
+
return token;
|
|
40
|
+
return "";
|
|
41
|
+
});
|
|
42
|
+
}
|
|
2
43
|
const FORMAT_RE = /\b(compact|exact|full)\b/i;
|
|
3
44
|
const FORMAT_FULL_RE = /\bfull\s+(?:layout|height|size|keys?)\b/i;
|
|
4
45
|
// "size 80" / "size 80%" / "size 0.8" / "size xl" / "keys xl"
|
|
@@ -51,7 +92,7 @@ const FINGERING_RE = /(?:with\s+)?finger(?:ing|s)?\s+([\dxX\-](?:[,\s\-]*[\dxX\-
|
|
|
51
92
|
const VALID_FINGERING = new Set(["0", "1", "2", "3", "4", "5", "-", "x"]);
|
|
52
93
|
// "custom fingering "A0,A1,A2"" / "fingering "G3-G0-E1"" / "fingering xl "1-2-3"" / "fingering "1-2-3" 2xl"
|
|
53
94
|
// Quoted strings after "fingering" or "custom fingering" are treated as free-form labels.
|
|
54
|
-
const CUSTOM_FINGERING_RE = /(?:with\s+)?(?:custom\s+)?finger(?:ing|s)?\s+(?:(?:in\s+)?(base|lg|xl|2xl)\s+)?["
|
|
95
|
+
const CUSTOM_FINGERING_RE = /(?:with\s+)?(?:custom\s+)?finger(?:ing|s)?\s+(?:(?:in\s+)?(base|lg|xl|2xl)\s+)?["“”']([^"“”']+)["“”'](?:\s+(?:in\s+)?(base|lg|xl|2xl))?/i;
|
|
55
96
|
// "with fingerings" / "show fingering" / "with fingering in xl" / "fingering 2xl" (no explicit numbers → auto)
|
|
56
97
|
// Negative lookahead: don't match when followed by digits (explicit fingering like "1 2 3 5")
|
|
57
98
|
const AUTO_FINGERING_RE = /(?:with\s+)?(?:show\s+)?finger(?:ings?|s)?(?:\s+(?:in\s+)?(base|lg|xl|2xl)|\s+(base|lg|xl|2xl))?(?!\s+\d)(?:\s|$)/i;
|
|
@@ -69,20 +110,134 @@ const UNAMBIGUOUS_SCALE_TYPES = [
|
|
|
69
110
|
"major\\s+pentatonic", "minor\\s+pentatonic", "pentatonic",
|
|
70
111
|
"blues", "whole\\s+tone", "bebop", "diminished",
|
|
71
112
|
];
|
|
72
|
-
// Scale patterns: "D major scale", "C blues", "A dorian",
|
|
113
|
+
// Scale patterns: "D major scale", "C blues", "A dorian", "Dm harmonic minor".
|
|
73
114
|
// For "major"/"minor" alone, require the trailing word "scale" to avoid chord collision.
|
|
74
|
-
|
|
75
|
-
|
|
115
|
+
//
|
|
116
|
+
// The root must not be preceded by a letter, or the trailing "c" of
|
|
117
|
+
// "harmonic"/"melodic" is read as a root note and "dm harmonic minor" resolves
|
|
118
|
+
// to C minor. An optional chord-quality marker may sit between the root and the
|
|
119
|
+
// scale type, so shorthand roots ("dm", "f#m", "dmaj") work the same as bare
|
|
120
|
+
// ones — the named scale type wins, the marker is only there to be absorbed.
|
|
121
|
+
const ROOT = "(?<![A-Za-z#b])([A-Ga-g][#b]?)";
|
|
122
|
+
const QUALITY_MARKER = "(?:\\s*(?:maj|min|m|M)\\b)?";
|
|
123
|
+
const SCALE_UNAMBIGUOUS_RE = new RegExp(`${ROOT}${QUALITY_MARKER}\\s+(${UNAMBIGUOUS_SCALE_TYPES.join("|")})(?:\\s+scale)?`, "i");
|
|
124
|
+
const SCALE_EXPLICIT_RE = new RegExp(`${ROOT}${QUALITY_MARKER}\\s+(major|minor)\\s+scale`, "i");
|
|
125
|
+
// Chord-shorthand scales: "dm scale", "d maj scale", "dmaj scale", "d scale".
|
|
126
|
+
// Only the major/minor markers — anything richer stays a chord. The word
|
|
127
|
+
// "scale" is required, so a bare "Dm" is still a chord.
|
|
128
|
+
// The quality marker is matched case-insensitively but read case-sensitively,
|
|
129
|
+
// since "M" means major and "m" means minor.
|
|
130
|
+
const SCALE_SHORTHAND_RE = /(?<![A-Za-z#b])([A-Ga-g][#b]?)\s*(major|maj|minor|min|M|m)?\s+scale\b/i;
|
|
131
|
+
/** Resolve a shorthand quality marker to a scale type. Absent marker = major. */
|
|
132
|
+
function shorthandScaleType(marker) {
|
|
133
|
+
if (!marker)
|
|
134
|
+
return "major";
|
|
135
|
+
if (marker === "M")
|
|
136
|
+
return "major";
|
|
137
|
+
if (marker === "m")
|
|
138
|
+
return "minor";
|
|
139
|
+
return marker.toLowerCase().startsWith("min") ? "minor" : "major";
|
|
140
|
+
}
|
|
76
141
|
// "ascending" / "descending" for melodic minor
|
|
77
142
|
const DIRECTION_RE = /\b(ascending|descending)\b/i;
|
|
78
143
|
// "in N octaves" / "N octaves" — works for both scales and arpeggios
|
|
79
144
|
const OCTAVES_RE = /\b(?:in\s+)?(\d+)\s+octaves?\b/i;
|
|
80
|
-
// "
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
145
|
+
// "degrees" anywhere — combined with note names if present, standalone otherwise.
|
|
146
|
+
// The literal word "degree(s)" isn't used by any other parser rule
|
|
147
|
+
// (bass-degree / starting-degree use "5th", "9th", etc.), so a broad match is safe.
|
|
148
|
+
/*
|
|
149
|
+
* Two forms, because only one of them is unambiguous.
|
|
150
|
+
*
|
|
151
|
+
* "degrees in lg" names the degrees' own size and always keeps it. "degrees lg"
|
|
152
|
+
* is a bare size between two keywords, and NOTE_NAMES_RE accepts exactly that
|
|
153
|
+
* shape in front of its own keyword ("lg note names") — so in "degrees 2xl note
|
|
154
|
+
* names" the size belongs to what follows it, not to what precedes it. Without
|
|
155
|
+
* the lookahead the degrees clause swallowed it and the note names silently
|
|
156
|
+
* fell back to the default.
|
|
157
|
+
*/
|
|
158
|
+
const DEGREES_KEYWORD_RE = /\bdegrees?(?:\s+(?:in\s+(base|lg|xl|2xl)|(base|lg|xl|2xl)(?!\s+(?:midi\s+)?note\s*names?)))?\b/i;
|
|
84
159
|
// "with heading" / "with a heading" / "show heading"
|
|
85
160
|
const HEADING_RE = /\b(?:with\s+)?(?:a\s+)?(?:show\s+)?heading\b/i;
|
|
161
|
+
// All recognized hand/clef phrases — long forms before shorthand to ensure
|
|
162
|
+
// the longest alternative is matched first under JS regex alternation.
|
|
163
|
+
const HAND_CLEF_PATTERN = "left\\s+hand|right\\s+hand|bottom\\s+hand|top\\s+hand|bass\\s+clef|treble\\s+clef|l\\.?h\\.?|r\\.?h\\.?|lh|rh|left|right|bottom|top|bass|treble";
|
|
164
|
+
// 2+ note tokens separated by spaces/commas/dashes; "and" allowed between tokens.
|
|
165
|
+
// Single-letter notes (A-G) plus optional accidental (#/b) and optional single-digit octave.
|
|
166
|
+
const NOTE_LIST_PATTERN = "[A-Ga-g][#b]?\\d?(?:[\\s,\\-]+(?:and\\s+)?[A-Ga-g][#b]?\\d?)+";
|
|
167
|
+
// After the explicit "notes" keyword, a token may be a run-together sequence
|
|
168
|
+
// of notes with no separators ("cdefgabc", "EbGbBb", "E4G4C5"). The trailing
|
|
169
|
+
// lookahead stops the run at any non-note word ("and", "in", "with", ...)
|
|
170
|
+
// while still allowing a run to end in an accidental ("C#").
|
|
171
|
+
const NOTE_RUN_PATTERN = "(?:[A-Ga-g][#b]?\\d?)+(?![A-Za-z0-9])";
|
|
172
|
+
// "with notes C E G" / "notes E4 G4 C5" / "notes cdefgabc" — optional
|
|
173
|
+
// trailing "in <hand|clef>".
|
|
174
|
+
const NOTES_GROUP_RE = new RegExp(`(?:with\\s+|and\\s+)?notes\\s+(${NOTE_RUN_PATTERN}(?:[\\s,\\-]+${NOTE_RUN_PATTERN})*)(?:\\s+in\\s+(?:the\\s+)?(${HAND_CLEF_PATTERN}))?`, "gi");
|
|
175
|
+
// "notes in lh Eb Gb Bb" / "notes in the right hand D F A" — prefix form.
|
|
176
|
+
const NOTES_GROUP_PREFIX_RE = new RegExp(`(?:with\\s+|and\\s+)?notes\\s+in\\s+(?:the\\s+)?(${HAND_CLEF_PATTERN})\\s+(${NOTE_RUN_PATTERN}(?:[\\s,\\-]+${NOTE_RUN_PATTERN})*)`, "gi");
|
|
177
|
+
// "lh Eb Gb Bb" / "lh: Eb Gb Bb" / "left Eb Gb Bb" / "bass clef C E G"
|
|
178
|
+
// Hand keyword first, no "notes" word. Optional colon between hand and notes.
|
|
179
|
+
const HAND_PREFIX_BARE_RE = new RegExp(`\\b(${HAND_CLEF_PATTERN})\\s*(?::\\s*|\\s+)(${NOTE_LIST_PATTERN})`, "gi");
|
|
180
|
+
// "Eb Gb Bb in lh" / "Eb Gb Bb lh" / "(Eb Gb Bb) lh" — notes first, hand after.
|
|
181
|
+
// "in" is optional. Surrounding parens optional. Requires 2+ notes to avoid
|
|
182
|
+
// swallowing chord symbols like "G7 in lh".
|
|
183
|
+
const NOTES_BARE_HAND_RE = new RegExp(`\\(?\\b(${NOTE_LIST_PATTERN})\\b\\)?\\s*(?:in\\s+(?:the\\s+)?)?(${HAND_CLEF_PATTERN})\\b`, "gi");
|
|
184
|
+
// Single bare note + hand variants. Restricted to [A-G][#b]? with no octave
|
|
185
|
+
// digit and no chord-quality suffix — that way "G7 in lh" / "Bb7 in lh" stay
|
|
186
|
+
// chord symbols (no \b between letter and digit), but "Bb in lh" works.
|
|
187
|
+
const SINGLE_NOTE_PATTERN = "[A-Ga-g][#b]?";
|
|
188
|
+
// "lh Bb" / "lh: Bb" / "left Bb" / "bass clef C" — hand keyword + single note.
|
|
189
|
+
// Lookahead requires the note to be followed by whitespace, comma, semicolon,
|
|
190
|
+
// or end-of-string so "lh G7" doesn't absorb G as a note.
|
|
191
|
+
const HAND_PREFIX_SINGLE_RE = new RegExp(`\\b(${HAND_CLEF_PATTERN})\\s*(?::\\s*|\\s+)(${SINGLE_NOTE_PATTERN})(?=[\\s,;]|$)`, "gi");
|
|
192
|
+
// "Bb in lh" / "Bb lh" / "(Bb) lh" — single bare note + hand.
|
|
193
|
+
const NOTE_BARE_HAND_SINGLE_RE = new RegExp(`\\(?\\b(${SINGLE_NOTE_PATTERN})\\b\\)?\\s*(?:in\\s+(?:the\\s+)?)?(${HAND_CLEF_PATTERN})\\b`, "gi");
|
|
194
|
+
// A chord symbol following a hand keyword: "rh Cmaj7", "lh: Bbm7b5",
|
|
195
|
+
// "left hand Am", "bass clef G7/B". Deliberately permissive — the token is
|
|
196
|
+
// validated by actually resolving it as a chord, so junk never becomes a group.
|
|
197
|
+
const CHORD_TOKEN_PATTERN = "[A-Ga-g][#b]?[A-Za-z0-9#b\\u00b0\\u00f8+\\-/]*";
|
|
198
|
+
// "rh Cmaj7 lh Dm7" — hand keyword + chord symbol(s). The trailing group
|
|
199
|
+
// captures any further chord tokens so "rh Cmaj7 Dm7" can drop the second one:
|
|
200
|
+
// only the first chord after a hand keyword is used.
|
|
201
|
+
const HAND_PREFIX_CHORD_RE = new RegExp(`\\b(${HAND_CLEF_PATTERN})\\s*(?::\\s*|\\s+)(${CHORD_TOKEN_PATTERN})((?:\\s+${CHORD_TOKEN_PATTERN})*)`, "gi");
|
|
202
|
+
// A plain pitch class ("C", "Eb") — the single-note pass already claims those.
|
|
203
|
+
// Tokens carrying a digit ("G7", "C9") read as chords here, matching how
|
|
204
|
+
// "G7 in lh" / "Bb7 in lh" are treated elsewhere in this parser; an
|
|
205
|
+
// octave-qualified note needs the explicit "notes" keyword ("notes G7 in lh").
|
|
206
|
+
const BARE_NOTE_TOKEN_RE = /^[A-Ga-g][#b]?$/;
|
|
207
|
+
/**
|
|
208
|
+
* Resolve a token as a chord symbol, or undefined when it isn't one.
|
|
209
|
+
* Bare pitches are rejected so "lh Bb" stays a single-note assignment.
|
|
210
|
+
*/
|
|
211
|
+
function chordTokenNotes(token) {
|
|
212
|
+
if (BARE_NOTE_TOKEN_RE.test(token))
|
|
213
|
+
return undefined;
|
|
214
|
+
try {
|
|
215
|
+
const { notes } = resolveChord(token);
|
|
216
|
+
return notes.length > 0 ? notes : undefined;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// "Eb Gb Bb // Db Eb F Gb" — polychord-style "top over bottom" (rh then lh).
|
|
223
|
+
const POLYCHORD_SLASH_RE = new RegExp(`(${NOTE_LIST_PATTERN})\\s*\\/\\/\\s*(${NOTE_LIST_PATTERN})`, "gi");
|
|
224
|
+
// "Eb Gb Bb; Db Eb F Gb" — semicolon separator; reading order (lh then rh).
|
|
225
|
+
const SEMI_SEP_RE = new RegExp(`(${NOTE_LIST_PATTERN})\\s*;\\s*(${NOTE_LIST_PATTERN})`, "gi");
|
|
226
|
+
/** Normalize a captured hand/clef phrase to (hand, clef) tuple. */
|
|
227
|
+
function parseHandOrClef(raw) {
|
|
228
|
+
if (!raw)
|
|
229
|
+
return {};
|
|
230
|
+
const h = raw.toLowerCase().replace(/[\s.:]/g, "");
|
|
231
|
+
if (h === "bassclef" || h === "bass")
|
|
232
|
+
return { clef: "bass", hand: "lh" };
|
|
233
|
+
if (h === "trebleclef" || h === "treble")
|
|
234
|
+
return { clef: "treble", hand: "rh" };
|
|
235
|
+
if (h === "lefthand" || h === "bottomhand" || h === "lh" || h === "left" || h === "bottom")
|
|
236
|
+
return { hand: "lh" };
|
|
237
|
+
if (h === "righthand" || h === "tophand" || h === "rh" || h === "right" || h === "top")
|
|
238
|
+
return { hand: "rh" };
|
|
239
|
+
return {};
|
|
240
|
+
}
|
|
86
241
|
// "boomwhacker" / "with boomwhacker theme" / "crf theme" / "rainbow"
|
|
87
242
|
const THEME_RE = /\b(?:(?:with\s+)?(?:the\s+)?)?(?:(boomwhacker|boomwhackers|crf|rainbow)\s*(?:theme|colors?|colours?)?)\b/i;
|
|
88
243
|
const THEME_MAP = {
|
|
@@ -105,7 +260,12 @@ const QUALITY_WORDS = {
|
|
|
105
260
|
eleventh: "11",
|
|
106
261
|
thirteenth: "13",
|
|
107
262
|
};
|
|
108
|
-
|
|
263
|
+
// Slash-bass is captured as its own group (any note letter, not just B) rather
|
|
264
|
+
// than folded into the quality-suffix class — the old catch-all `[0-9#b/]+`
|
|
265
|
+
// let '/' bleed into the flat-symbol 'b', so only B/Bb bass notes round-tripped.
|
|
266
|
+
// `\/[0-9]+` stays in the quality group so "6/9"-style suffixes (slash
|
|
267
|
+
// followed by digits, not a bass letter) aren't mistaken for a slash bass.
|
|
268
|
+
const CHORD_RE = /([A-Ga-g][#b]?)\s*(maj|min|m|aug|dim|sus|add|dom|M|°|ø|\/[0-9]+|[0-9#b]+)*(\/[A-Ga-g][#b]?)?/i;
|
|
109
269
|
function capitalizeNote(note) {
|
|
110
270
|
return note.charAt(0).toUpperCase() + note.slice(1);
|
|
111
271
|
}
|
|
@@ -182,8 +342,16 @@ export function parseChordDescription(input) {
|
|
|
182
342
|
const isDown = direction === "down" || direction === "lower";
|
|
183
343
|
result.bassOctaveShift = isDown ? -count : count;
|
|
184
344
|
}
|
|
345
|
+
/*
|
|
346
|
+
* Note-name sizes are read from the input with the degrees clause taken out.
|
|
347
|
+
* NOTE_NAMES_RE accepts a size *before* the keyword ("xl note names"), so in
|
|
348
|
+
* "with degrees in base note names in 2xl" it read the degrees' own "base" as
|
|
349
|
+
* the note-name size. Two clauses that each carry a size must not be able to
|
|
350
|
+
* read each other's.
|
|
351
|
+
*/
|
|
352
|
+
const sizeSource = input.replace(DEGREES_KEYWORD_RE, " ");
|
|
185
353
|
// Extract "midi note names" / "with midi names" (check before regular note names)
|
|
186
|
-
const midiNamesMatch =
|
|
354
|
+
const midiNamesMatch = sizeSource.match(MIDI_NAMES_RE);
|
|
187
355
|
if (midiNamesMatch) {
|
|
188
356
|
result.showNoteNames = true;
|
|
189
357
|
result.noteNameMode = "midi";
|
|
@@ -191,7 +359,7 @@ export function parseChordDescription(input) {
|
|
|
191
359
|
}
|
|
192
360
|
// Extract "with note names" / "note names in xl" / "xl note names"
|
|
193
361
|
if (!midiNamesMatch) {
|
|
194
|
-
const noteNamesMatch =
|
|
362
|
+
const noteNamesMatch = sizeSource.match(NOTE_NAMES_RE);
|
|
195
363
|
if (noteNamesMatch) {
|
|
196
364
|
result.showNoteNames = true;
|
|
197
365
|
// Size can be in group 1 (before "note names"), 2 (after), or 3 ("name the notes in xl")
|
|
@@ -206,7 +374,13 @@ export function parseChordDescription(input) {
|
|
|
206
374
|
// Check custom fingering FIRST ("custom fingering A0,A1,A2")
|
|
207
375
|
const customFingerMatch = input.match(CUSTOM_FINGERING_RE);
|
|
208
376
|
if (customFingerMatch) {
|
|
209
|
-
|
|
377
|
+
// Comma-separated lists split ONLY on commas/spaces so "-" placeholders
|
|
378
|
+
// survive as positional blanks ("1,-,3"); dash-separated lists keep the
|
|
379
|
+
// documented "D2-D1-D0-D1" behavior.
|
|
380
|
+
const raw = customFingerMatch[2];
|
|
381
|
+
result.customFingering = (raw.includes(",")
|
|
382
|
+
? raw.split(/[,\s]+/)
|
|
383
|
+
: raw.split(/[\-\s]+/)).filter(Boolean);
|
|
210
384
|
result.fingeringSize = toTextSize(customFingerMatch[1]) ?? toTextSize(customFingerMatch[3]);
|
|
211
385
|
}
|
|
212
386
|
// Check auto fingering ("with fingering", "fingering 2xl", "fingering in xl")
|
|
@@ -271,27 +445,217 @@ export function parseChordDescription(input) {
|
|
|
271
445
|
if (HEADING_RE.test(input)) {
|
|
272
446
|
result.showHeading = true;
|
|
273
447
|
}
|
|
448
|
+
// Extract explicit notes group(s) — supports one or more "with notes ... [in <hand/clef>]"
|
|
449
|
+
// segments separated by "and" / "with". Paired example:
|
|
450
|
+
// "notes C E G in bass clef and notes B D F in treble clef"
|
|
451
|
+
const groups = [];
|
|
452
|
+
// Explode a run-together token ("cdefgabc", "EbGbBb", "E4G4C5") into
|
|
453
|
+
// individual notes. In runs, a lowercase 'b' binds as a flat only after
|
|
454
|
+
// an UPPERCASE letter ("EbGbBb") — in lowercase runs like "cdefgabc"
|
|
455
|
+
// every letter is its own note, so 'b' stays the note B.
|
|
456
|
+
const explodeNoteRun = (token) => {
|
|
457
|
+
const out = [];
|
|
458
|
+
let i = 0;
|
|
459
|
+
while (i < token.length) {
|
|
460
|
+
const letter = token[i];
|
|
461
|
+
if (!/[A-Ga-g]/.test(letter))
|
|
462
|
+
return [token]; // not a pure note run
|
|
463
|
+
i++;
|
|
464
|
+
let accidental = "";
|
|
465
|
+
if (token[i] === "#") {
|
|
466
|
+
accidental = "#";
|
|
467
|
+
i++;
|
|
468
|
+
}
|
|
469
|
+
else if (token[i] === "b" && letter === letter.toUpperCase()) {
|
|
470
|
+
accidental = "b";
|
|
471
|
+
i++;
|
|
472
|
+
}
|
|
473
|
+
let octave = "";
|
|
474
|
+
if (/\d/.test(token[i] ?? "")) {
|
|
475
|
+
octave = token[i];
|
|
476
|
+
i++;
|
|
477
|
+
}
|
|
478
|
+
out.push(letter.toUpperCase() + accidental + octave);
|
|
479
|
+
}
|
|
480
|
+
return out;
|
|
481
|
+
};
|
|
482
|
+
const normalizeTokens = (raw) => raw
|
|
483
|
+
.split(/[\s,\-]+/)
|
|
484
|
+
.filter(Boolean)
|
|
485
|
+
.flatMap((t) => {
|
|
486
|
+
// Single-note tokens keep their exact old handling ("eb5" → Eb5,
|
|
487
|
+
// "bb" → Bb) — run explosion applies only to longer sequences.
|
|
488
|
+
const nm = t.match(/^([A-Ga-g])([#b])?(\d)?$/);
|
|
489
|
+
if (nm)
|
|
490
|
+
return [nm[1].toUpperCase() + (nm[2] ?? "") + (nm[3] ?? "")];
|
|
491
|
+
return explodeNoteRun(t);
|
|
492
|
+
});
|
|
493
|
+
// Note-group extraction runs in priority order. Each pass strips its matches
|
|
494
|
+
// from `residual` so later (looser) passes can't re-consume the same notes.
|
|
495
|
+
let residual = input;
|
|
496
|
+
// Hand-prefixed chord phrases actually consumed by pass 4d, so the chord-name
|
|
497
|
+
// pass below strips exactly those (and not look-alikes it rejected).
|
|
498
|
+
const handChordMatches = [];
|
|
499
|
+
const stripFromResidual = (match) => {
|
|
500
|
+
residual = residual.replace(match, " ");
|
|
501
|
+
};
|
|
502
|
+
const pushGroup = (tokens, hand, clef, chord) => {
|
|
503
|
+
const g = { notes: tokens };
|
|
504
|
+
if (hand)
|
|
505
|
+
g.hand = hand;
|
|
506
|
+
if (clef)
|
|
507
|
+
g.clef = clef;
|
|
508
|
+
if (chord)
|
|
509
|
+
g.chord = chord;
|
|
510
|
+
groups.push(g);
|
|
511
|
+
};
|
|
512
|
+
// 1. "notes in <hand> <notes>" (prefix with explicit "notes" keyword)
|
|
513
|
+
NOTES_GROUP_PREFIX_RE.lastIndex = 0;
|
|
514
|
+
for (const m of [...residual.matchAll(NOTES_GROUP_PREFIX_RE)]) {
|
|
515
|
+
const tokens = normalizeTokens(m[2]);
|
|
516
|
+
if (tokens.length === 0)
|
|
517
|
+
continue;
|
|
518
|
+
const { hand, clef } = parseHandOrClef(m[1]);
|
|
519
|
+
pushGroup(tokens, hand, clef);
|
|
520
|
+
stripFromResidual(m[0]);
|
|
521
|
+
}
|
|
522
|
+
// 2. "notes <notes> [in <hand>]" (suffix with explicit "notes" keyword)
|
|
523
|
+
NOTES_GROUP_RE.lastIndex = 0;
|
|
524
|
+
for (const m of [...residual.matchAll(NOTES_GROUP_RE)]) {
|
|
525
|
+
const tokens = normalizeTokens(m[1]);
|
|
526
|
+
if (tokens.length === 0)
|
|
527
|
+
continue;
|
|
528
|
+
const { hand, clef } = parseHandOrClef(m[2]);
|
|
529
|
+
pushGroup(tokens, hand, clef);
|
|
530
|
+
stripFromResidual(m[0]);
|
|
531
|
+
}
|
|
532
|
+
// 3. "lh Eb Gb Bb" / "lh: Eb Gb Bb" / "left Eb Gb Bb" / "bass clef C E G"
|
|
533
|
+
// Hand keyword first, no "notes" word required.
|
|
534
|
+
HAND_PREFIX_BARE_RE.lastIndex = 0;
|
|
535
|
+
for (const m of [...residual.matchAll(HAND_PREFIX_BARE_RE)]) {
|
|
536
|
+
const tokens = normalizeTokens(m[2].replace(/\band\b/gi, " "));
|
|
537
|
+
if (tokens.length < 2)
|
|
538
|
+
continue;
|
|
539
|
+
const { hand, clef } = parseHandOrClef(m[1]);
|
|
540
|
+
if (!hand && !clef)
|
|
541
|
+
continue;
|
|
542
|
+
pushGroup(tokens, hand, clef);
|
|
543
|
+
stripFromResidual(m[0]);
|
|
544
|
+
}
|
|
545
|
+
// 4. "Eb Gb Bb in lh" / "Eb Gb Bb lh" / "(Eb Gb Bb) lh" — bare notes + hand.
|
|
546
|
+
NOTES_BARE_HAND_RE.lastIndex = 0;
|
|
547
|
+
for (const m of [...residual.matchAll(NOTES_BARE_HAND_RE)]) {
|
|
548
|
+
const tokens = normalizeTokens(m[1].replace(/\band\b/gi, " "));
|
|
549
|
+
if (tokens.length < 2)
|
|
550
|
+
continue;
|
|
551
|
+
const { hand, clef } = parseHandOrClef(m[2]);
|
|
552
|
+
if (!hand && !clef)
|
|
553
|
+
continue;
|
|
554
|
+
pushGroup(tokens, hand, clef);
|
|
555
|
+
stripFromResidual(m[0]);
|
|
556
|
+
}
|
|
557
|
+
// 4b. "lh Bb" / "lh: Bb" / "left Bb" — single bare note after hand keyword.
|
|
558
|
+
// Runs AFTER the multi-note passes so a 3-note group is preferred where
|
|
559
|
+
// both could match. Anything left here is a genuine single-note assignment.
|
|
560
|
+
HAND_PREFIX_SINGLE_RE.lastIndex = 0;
|
|
561
|
+
for (const m of [...residual.matchAll(HAND_PREFIX_SINGLE_RE)]) {
|
|
562
|
+
const tokens = normalizeTokens(m[2]);
|
|
563
|
+
if (tokens.length === 0)
|
|
564
|
+
continue;
|
|
565
|
+
const { hand, clef } = parseHandOrClef(m[1]);
|
|
566
|
+
if (!hand && !clef)
|
|
567
|
+
continue;
|
|
568
|
+
pushGroup(tokens, hand, clef);
|
|
569
|
+
stripFromResidual(m[0]);
|
|
570
|
+
}
|
|
571
|
+
// 4c. "Bb in lh" / "Bb lh" / "(Bb) lh" — single bare note before hand keyword.
|
|
572
|
+
NOTE_BARE_HAND_SINGLE_RE.lastIndex = 0;
|
|
573
|
+
for (const m of [...residual.matchAll(NOTE_BARE_HAND_SINGLE_RE)]) {
|
|
574
|
+
const tokens = normalizeTokens(m[1]);
|
|
575
|
+
if (tokens.length === 0)
|
|
576
|
+
continue;
|
|
577
|
+
const { hand, clef } = parseHandOrClef(m[2]);
|
|
578
|
+
if (!hand && !clef)
|
|
579
|
+
continue;
|
|
580
|
+
pushGroup(tokens, hand, clef);
|
|
581
|
+
stripFromResidual(m[0]);
|
|
582
|
+
}
|
|
583
|
+
// 4d. "rh Cmaj7 lh Dm7" — hand keyword + chord symbol. Runs after every
|
|
584
|
+
// bare-note pass so note lists still win; whatever is left here is a chord.
|
|
585
|
+
// Only the first chord after a hand keyword is used — trailing chords are
|
|
586
|
+
// dropped (and removed from the residual so they don't become the chord name).
|
|
587
|
+
HAND_PREFIX_CHORD_RE.lastIndex = 0;
|
|
588
|
+
for (const m of [...residual.matchAll(HAND_PREFIX_CHORD_RE)]) {
|
|
589
|
+
const { hand, clef } = parseHandOrClef(m[1]);
|
|
590
|
+
if (!hand && !clef)
|
|
591
|
+
continue;
|
|
592
|
+
const notes = chordTokenNotes(m[2]);
|
|
593
|
+
if (!notes)
|
|
594
|
+
continue;
|
|
595
|
+
pushGroup(notes, hand, clef, m[2]);
|
|
596
|
+
// Drop the run of extra chord tokens that directly follows, stopping at
|
|
597
|
+
// the first token that isn't a chord so unrelated text survives.
|
|
598
|
+
let extras = m[3] ?? "";
|
|
599
|
+
while (extras.length > 0) {
|
|
600
|
+
const next = extras.match(/^\s+(\S+)/);
|
|
601
|
+
if (!next || !chordTokenNotes(next[1]))
|
|
602
|
+
break;
|
|
603
|
+
extras = extras.slice(next[0].length);
|
|
604
|
+
}
|
|
605
|
+
const consumed = m[0].slice(0, m[0].length - extras.length);
|
|
606
|
+
handChordMatches.push(consumed);
|
|
607
|
+
stripFromResidual(consumed);
|
|
608
|
+
}
|
|
609
|
+
// 5. "Eb Gb Bb // Db Eb F Gb" — polychord-style "top over bottom" (rh, lh).
|
|
610
|
+
POLYCHORD_SLASH_RE.lastIndex = 0;
|
|
611
|
+
for (const m of [...residual.matchAll(POLYCHORD_SLASH_RE)]) {
|
|
612
|
+
const top = normalizeTokens(m[1].replace(/\band\b/gi, " "));
|
|
613
|
+
const bottom = normalizeTokens(m[2].replace(/\band\b/gi, " "));
|
|
614
|
+
if (top.length < 2 || bottom.length < 2)
|
|
615
|
+
continue;
|
|
616
|
+
pushGroup(top, "rh");
|
|
617
|
+
pushGroup(bottom, "lh");
|
|
618
|
+
stripFromResidual(m[0]);
|
|
619
|
+
}
|
|
620
|
+
// 6. "Eb Gb Bb; Db Eb F Gb" — semicolon separator; reading order (lh, rh).
|
|
621
|
+
SEMI_SEP_RE.lastIndex = 0;
|
|
622
|
+
for (const m of [...residual.matchAll(SEMI_SEP_RE)]) {
|
|
623
|
+
const first = normalizeTokens(m[1].replace(/\band\b/gi, " "));
|
|
624
|
+
const second = normalizeTokens(m[2].replace(/\band\b/gi, " "));
|
|
625
|
+
if (first.length < 2 || second.length < 2)
|
|
626
|
+
continue;
|
|
627
|
+
pushGroup(first, "lh");
|
|
628
|
+
pushGroup(second, "rh");
|
|
629
|
+
stripFromResidual(m[0]);
|
|
630
|
+
}
|
|
631
|
+
if (groups.length > 0) {
|
|
632
|
+
result.notesGroups = groups;
|
|
633
|
+
}
|
|
274
634
|
// Extract color theme
|
|
275
635
|
const themeMatch = input.match(THEME_RE);
|
|
276
636
|
if (themeMatch) {
|
|
277
637
|
result.colorTheme = THEME_MAP[themeMatch[1].toLowerCase()] ?? themeMatch[1].toLowerCase();
|
|
278
638
|
}
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (
|
|
287
|
-
result.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
639
|
+
// Degrees keyword is independent — combines with whichever note names are
|
|
640
|
+
// already detected, otherwise stands alone as degree-only mode. Midi names
|
|
641
|
+
// combine too: "midi note names with degrees" is a real request for two rows,
|
|
642
|
+
// and skipping the clause there dropped both the mode and the degree size, so
|
|
643
|
+
// the degrees the user asked for were silently never drawn.
|
|
644
|
+
const degMatch = input.match(DEGREES_KEYWORD_RE);
|
|
645
|
+
if (degMatch) {
|
|
646
|
+
if (result.showNoteNames) {
|
|
647
|
+
result.noteNameMode = result.noteNameMode === "midi" ? "midi+degree" : "pitch-class+degree";
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
result.showNoteNames = true;
|
|
651
|
+
result.noteNameMode = "degree";
|
|
652
|
+
}
|
|
653
|
+
// Its own field. Writing it to noteNameSize meant the second size in
|
|
654
|
+
// "note names in xl with degrees in lg" overwrote the first, and both rows
|
|
655
|
+
// came out at lg — the note names silently demoted to the degrees' size.
|
|
656
|
+
const sz = toTextSize(degMatch[1]) ?? toTextSize(degMatch[2]);
|
|
293
657
|
if (sz)
|
|
294
|
-
result.
|
|
658
|
+
result.degreeSize = sz;
|
|
295
659
|
}
|
|
296
660
|
// Extract scale direction
|
|
297
661
|
const dirMatch = input.match(DIRECTION_RE);
|
|
@@ -301,10 +665,12 @@ export function parseChordDescription(input) {
|
|
|
301
665
|
// Extract octave count (used by both scales and arpeggios)
|
|
302
666
|
const octavesMatch = input.match(OCTAVES_RE);
|
|
303
667
|
const octaveCount = octavesMatch ? parseInt(octavesMatch[1], 10) : undefined;
|
|
304
|
-
// Detect scales — check BEFORE chord extraction
|
|
668
|
+
// Detect scales — check BEFORE chord extraction. Spelled-out forms first,
|
|
669
|
+
// then chord shorthand ("dm scale"), so "d minor scale" keeps its own path.
|
|
305
670
|
const scaleUnambig = input.match(SCALE_UNAMBIGUOUS_RE);
|
|
306
671
|
const scaleExplicit = input.match(SCALE_EXPLICIT_RE);
|
|
307
672
|
const scaleMatch = scaleUnambig || scaleExplicit;
|
|
673
|
+
const scaleShorthand = scaleMatch ? null : input.match(SCALE_SHORTHAND_RE);
|
|
308
674
|
if (scaleMatch) {
|
|
309
675
|
const scaleRoot = capitalizeNote(scaleMatch[1]);
|
|
310
676
|
const scaleType = scaleMatch[2].toLowerCase().replace(/\s+/g, " ").trim();
|
|
@@ -312,6 +678,12 @@ export function parseChordDescription(input) {
|
|
|
312
678
|
result.scaleName = `${scaleRoot} ${scaleType}`;
|
|
313
679
|
result.scaleOctaves = octaveCount ?? 1;
|
|
314
680
|
}
|
|
681
|
+
else if (scaleShorthand) {
|
|
682
|
+
const scaleRoot = capitalizeNote(scaleShorthand[1]);
|
|
683
|
+
result.isScale = true;
|
|
684
|
+
result.scaleName = `${scaleRoot} ${shorthandScaleType(scaleShorthand[2])}`;
|
|
685
|
+
result.scaleOctaves = octaveCount ?? 1;
|
|
686
|
+
}
|
|
315
687
|
else if (octaveCount) {
|
|
316
688
|
// Not a scale but has "N octaves" → arpeggio mode for chord
|
|
317
689
|
result.chordOctaves = octaveCount;
|
|
@@ -325,8 +697,11 @@ export function parseChordDescription(input) {
|
|
|
325
697
|
else if (startMatch) {
|
|
326
698
|
result.startingNote = capitalizeNote(startMatch[1]);
|
|
327
699
|
}
|
|
328
|
-
// Strip extracted patterns and filler for chord name extraction
|
|
329
|
-
|
|
700
|
+
// Strip extracted patterns and filler for chord name extraction. Chord
|
|
701
|
+
// phrases already claimed by a hand go first — they're plain substrings of
|
|
702
|
+
// the input, so removing them here keeps them out of the chord name.
|
|
703
|
+
let cleaned = handChordMatches
|
|
704
|
+
.reduce((text, phrase) => text.replace(phrase, " "), input)
|
|
330
705
|
.replace(FORMAT_FULL_RE, "")
|
|
331
706
|
.replace(FORMAT_RE, "")
|
|
332
707
|
.replace(SIZE_NAME_RE, "")
|
|
@@ -339,6 +714,11 @@ export function parseChordDescription(input) {
|
|
|
339
714
|
.replace(PADDING_RE, "")
|
|
340
715
|
.replace(CHORD_OCTAVE_RE, "")
|
|
341
716
|
.replace(BASS_OCTAVE_RE, "")
|
|
717
|
+
// Before NOTE_NAMES_RE, which matches the "note names" half on its own and
|
|
718
|
+
// would leave "midi" behind. The leftover word is not inert: CHORD_RE's
|
|
719
|
+
// quality class contains `m`, so "C midi" parsed as Cm, and an A root lost
|
|
720
|
+
// to FILLER_WORDS let the extractor find its root in the `d` of "midi".
|
|
721
|
+
.replace(MIDI_NAMES_RE, "")
|
|
342
722
|
.replace(NOTE_NAMES_RE, "")
|
|
343
723
|
.replace(CUSTOM_FINGERING_RE, "")
|
|
344
724
|
.replace(FINGERING_RE, "")
|
|
@@ -350,14 +730,25 @@ export function parseChordDescription(input) {
|
|
|
350
730
|
.replace(OVER_BASS_NOTE_RE, "")
|
|
351
731
|
.replace(STARTING_DEGREE_RE, "")
|
|
352
732
|
.replace(STARTING_NOTE_RE, "")
|
|
353
|
-
.replace(
|
|
354
|
-
.replace(DEGREE_DISPLAY_RE, "")
|
|
733
|
+
.replace(DEGREES_KEYWORD_RE, "")
|
|
355
734
|
.replace(DIRECTION_RE, "")
|
|
356
735
|
.replace(OCTAVES_RE, "")
|
|
357
736
|
.replace(SCALE_UNAMBIGUOUS_RE, "")
|
|
358
737
|
.replace(SCALE_EXPLICIT_RE, "")
|
|
738
|
+
.replace(SCALE_SHORTHAND_RE, "")
|
|
739
|
+
// A loose "midi" is itself the request for midi note names (see the
|
|
740
|
+
// fallback above), so it has to leave with the rest of the request.
|
|
741
|
+
.replace(/\bmidi\b/gi, "")
|
|
359
742
|
.replace(/\bscale\b/gi, "")
|
|
360
743
|
.replace(HEADING_RE, "")
|
|
744
|
+
.replace(NOTES_GROUP_PREFIX_RE, "")
|
|
745
|
+
.replace(NOTES_GROUP_RE, "")
|
|
746
|
+
.replace(HAND_PREFIX_BARE_RE, "")
|
|
747
|
+
.replace(NOTES_BARE_HAND_RE, "")
|
|
748
|
+
.replace(HAND_PREFIX_SINGLE_RE, "")
|
|
749
|
+
.replace(NOTE_BARE_HAND_SINGLE_RE, "")
|
|
750
|
+
.replace(POLYCHORD_SLASH_RE, "")
|
|
751
|
+
.replace(SEMI_SEP_RE, "")
|
|
361
752
|
.replace(THEME_RE, "")
|
|
362
753
|
.replace(/\btheme\b/gi, "")
|
|
363
754
|
.replace(/\bcolou?rs?\b/gi, "")
|
|
@@ -373,6 +764,9 @@ export function parseChordDescription(input) {
|
|
|
373
764
|
.replace(/\bin\b/gi, "")
|
|
374
765
|
.replace(/\s+/g, " ")
|
|
375
766
|
.trim();
|
|
767
|
+
// Last, so the article test is asked of text that is finished — and of the
|
|
768
|
+
// original input, which still knows what the article was standing in front of.
|
|
769
|
+
cleaned = stripArticles(cleaned, input).replace(/\s+/g, " ").trim();
|
|
376
770
|
// Replace word-based accidentals before chord extraction
|
|
377
771
|
cleaned = cleaned
|
|
378
772
|
.replace(/\bsharp\b/gi, "#")
|
|
@@ -396,9 +790,11 @@ export function parseChordDescription(input) {
|
|
|
396
790
|
const chordMatch = cleaned.match(CHORD_RE);
|
|
397
791
|
if (chordMatch) {
|
|
398
792
|
const root = capitalizeNote(chordMatch[1]);
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
793
|
+
const afterRoot = chordMatch[0]
|
|
794
|
+
.slice(chordMatch[1].length)
|
|
795
|
+
.trim()
|
|
796
|
+
.replace(/\/([A-Ga-g])/, (_m, letter) => `/${letter.toUpperCase()}`);
|
|
797
|
+
result.chordName = root + afterRoot;
|
|
402
798
|
}
|
|
403
799
|
}
|
|
404
800
|
return result;
|