@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.
Files changed (73) hide show
  1. package/dist/chord-sheet/codec.d.ts +1 -1
  2. package/dist/chord-sheet/codec.js +1 -1
  3. package/dist/chord-sheet/codec.js.map +1 -1
  4. package/dist/chord-sheet/defaults.d.ts +1 -1
  5. package/dist/chord-sheet/index.d.ts +3 -3
  6. package/dist/chord-sheet/index.js +3 -3
  7. package/dist/chord-sheet/index.js.map +1 -1
  8. package/dist/engine/auto-fingering.js +1 -1
  9. package/dist/engine/auto-fingering.js.map +1 -1
  10. package/dist/engine/highlight-mapper.d.ts +1 -1
  11. package/dist/engine/highlight-mapper.js +2 -2
  12. package/dist/engine/highlight-mapper.js.map +1 -1
  13. package/dist/engine/keyboard-layout.d.ts +1 -1
  14. package/dist/engine/keyboard-layout.js +1 -1
  15. package/dist/engine/keyboard-layout.js.map +1 -1
  16. package/dist/engine/mei-builder.d.ts +27 -0
  17. package/dist/engine/mei-builder.js +138 -0
  18. package/dist/engine/mei-builder.js.map +1 -0
  19. package/dist/engine/staff-glyphs.js +5 -10
  20. package/dist/engine/staff-glyphs.js.map +1 -1
  21. package/dist/engine/staff-layout.js +2 -2
  22. package/dist/engine/staff-layout.js.map +1 -1
  23. package/dist/index.d.ts +48 -42
  24. package/dist/index.js +32 -28
  25. package/dist/index.js.map +1 -1
  26. package/dist/parser/natural-language.d.ts +1 -1
  27. package/dist/parser/natural-language.js +432 -36
  28. package/dist/parser/natural-language.js.map +1 -1
  29. package/dist/parser/progression-parser.d.ts +1 -1
  30. package/dist/parser/progression-parser.js +1 -1
  31. package/dist/parser/progression-parser.js.map +1 -1
  32. package/dist/pipeline.d.ts +1 -1
  33. package/dist/pipeline.js +46 -25
  34. package/dist/pipeline.js.map +1 -1
  35. package/dist/progression/index.d.ts +5 -5
  36. package/dist/progression/index.js +3 -3
  37. package/dist/progression/index.js.map +1 -1
  38. package/dist/progression/progression-resolver.js +5 -5
  39. package/dist/progression/progression-resolver.js.map +1 -1
  40. package/dist/progression/roman-numeral.js +1 -1
  41. package/dist/progression/roman-numeral.js.map +1 -1
  42. package/dist/resolver/auto-layout.d.ts +1 -1
  43. package/dist/resolver/auto-layout.js +2 -2
  44. package/dist/resolver/auto-layout.js.map +1 -1
  45. package/dist/resolver/chord-resolver.d.ts +11 -1
  46. package/dist/resolver/chord-resolver.js +28 -2
  47. package/dist/resolver/chord-resolver.js.map +1 -1
  48. package/dist/resolver/scale-resolver.d.ts +5 -2
  49. package/dist/resolver/scale-resolver.js +28 -8
  50. package/dist/resolver/scale-resolver.js.map +1 -1
  51. package/dist/themes/boomwhacker.d.ts +1 -1
  52. package/dist/themes/boomwhacker.js +1 -1
  53. package/dist/themes/boomwhacker.js.map +1 -1
  54. package/dist/themes/crf.d.ts +1 -1
  55. package/dist/themes/crf.js +1 -1
  56. package/dist/themes/crf.js.map +1 -1
  57. package/dist/themes/index.d.ts +1 -1
  58. package/dist/themes/index.js +3 -3
  59. package/dist/themes/index.js.map +1 -1
  60. package/dist/themes/simple.d.ts +1 -1
  61. package/dist/themes/simple.js +1 -1
  62. package/dist/themes/simple.js.map +1 -1
  63. package/dist/theory/constrained-variants.d.ts +46 -0
  64. package/dist/theory/constrained-variants.js +117 -0
  65. package/dist/theory/constrained-variants.js.map +1 -0
  66. package/dist/theory/degree-labels.d.ts +39 -0
  67. package/dist/theory/degree-labels.js +102 -1
  68. package/dist/theory/degree-labels.js.map +1 -1
  69. package/dist/theory/hand-constraints.d.ts +104 -0
  70. package/dist/theory/hand-constraints.js +214 -0
  71. package/dist/theory/hand-constraints.js.map +1 -0
  72. package/dist/types.d.ts +26 -1
  73. package/package.json +4 -4
@@ -1,4 +1,45 @@
1
- const FILLER_WORDS = /\b(show\s+me|draw|display|render|please|a|an|the|with|that|this|me|of)\b/gi;
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+)?[""]([^""]+)[""](?:\s+(?:in\s+)?(base|lg|xl|2xl))?/i;
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", etc.
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
- const SCALE_UNAMBIGUOUS_RE = new RegExp(`([A-Ga-g][#b]?)\\s+(${UNAMBIGUOUS_SCALE_TYPES.join("|")})(?:\\s+scale)?`, "i");
75
- const SCALE_EXPLICIT_RE = /([A-Ga-g][#b]?)\s+(major|minor)\s+scale/i;
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
- // "with degrees" / "with degrees in xl" / "with note names and degrees in lg"
81
- const DEGREE_DISPLAY_RE = /\bwith\s+(?:note\s+names?\s+and\s+)?degrees?(?:\s+(?:in\s+)?(base|lg|xl|2xl))?\b/i;
82
- const DEGREE_ONLY_RE = /\bwith\s+degrees?(?:\s+(?:in\s+)?(base|lg|xl|2xl))?\b/i;
83
- const NAMES_AND_DEGREES_RE = /\bwith\s+note\s+names?\s+and\s+degrees?(?:\s+(?:in\s+)?(base|lg|xl|2xl))?\b/i;
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
- const CHORD_RE = /([A-Ga-g][#b]?)\s*(maj|min|m|aug|dim|sus|add|dom|M|°|ø|[0-9#b/]+)*/i;
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 = input.match(MIDI_NAMES_RE);
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 = input.match(NOTE_NAMES_RE);
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
- result.customFingering = customFingerMatch[2].split(/[,\-\s]+/).filter(Boolean);
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
- // Extract degree display mode (before scale detection, since it applies to both)
280
- const namesDegMatch = input.match(NAMES_AND_DEGREES_RE);
281
- const degOnlyMatch = input.match(DEGREE_ONLY_RE);
282
- if (namesDegMatch) {
283
- result.showNoteNames = true;
284
- result.noteNameMode = "pitch-class+degree";
285
- const sz = toTextSize(namesDegMatch[1]);
286
- if (sz)
287
- result.noteNameSize = sz;
288
- }
289
- else if (degOnlyMatch) {
290
- result.showNoteNames = true;
291
- result.noteNameMode = "degree";
292
- const sz = toTextSize(degOnlyMatch[1]);
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.noteNameSize = sz;
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
- let cleaned = input
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(NAMES_AND_DEGREES_RE, "")
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 fullMatch = chordMatch[0];
400
- const afterRoot = fullMatch.slice(chordMatch[1].length);
401
- result.chordName = root + afterRoot.trim();
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;