@pepperhorn/chordl-core 0.3.4 → 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 +241 -31
  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 +9 -1
  73. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -1,37 +1,41 @@
1
- export { computeKeyboard, computeSvgDimensions } from "./engine/keyboard-layout";
2
- export { mapHighlights, normalizeNote } from "./engine/highlight-mapper";
3
- export { FLAT_TO_SHARP, SHARP_TO_FLAT, FLAT_KEYS, normalizeToSharps, spellForKey, spellWithPreference, PC_SEMITONES, MIDI_A3, assignAscendingOctaves, } from "./engine/note-spelling";
4
- export { autoFingering, assignFingering } from "./engine/auto-fingering";
5
- export { scaleAutoFingering } from "./engine/scale-fingering";
6
- export { computeStaffLayout } from "./engine/staff-layout";
7
- export { getDefaultGlyphs, setDefaultGlyphs, BRAVURA_GLYPHS, PETALUMA_GLYPHS } from "./engine/staff-glyphs";
8
- export { STAFF_LINE_SPACING, HALF_STAFF_SPACING, STAFF_WIDTH, CLEF_AREA_WIDTH, NOTE_COLUMN_X, NOTE_HEAD_RX, ACCIDENTAL_OFFSET, LEDGER_LINE_EXTEND, LEDGER_LINE_STROKE, STAFF_GAP, STAFF_TOP_MARGIN, STAFF_BOTTOM_MARGIN, STAFF_LINE_STROKE, BRACE_WIDTH, SECOND_OFFSET, ACCIDENTAL_COL_WIDTH, } from "./engine/staff-constants";
9
- export { WHITE_KEY_WIDTH, WHITE_KEY_WIDTH_EXACT, WHITE_KEY_HEIGHT_COMPACT, WHITE_KEY_HEIGHT_EXACT, WHITE_KEY_RY, BLACK_KEY_WIDTH, BLACK_KEY_WIDTH_EXACT, BLACK_KEY_HEIGHT_COMPACT, BLACK_KEY_HEIGHT_EXACT, BLACK_KEY_RY, BLACK_KEY_OFFSETS, BLACK_KEY_OFFSETS_EXACT, WHITE_NOTES_WITH_SHARPS, WHITE_NOTE_ORDER, DEFAULT_WHITE_FILL, DEFAULT_BLACK_FILL, DEFAULT_STROKE, DEFAULT_STROKE_WIDTH, } from "./engine/svg-constants";
10
- export { parseChordDescription } from "./parser/natural-language";
11
- export { isProgressionRequest, parseProgressionRequest } from "./parser/progression-parser";
12
- export { resolveChord } from "./resolver/chord-resolver";
13
- export { calculateLayout, whiteIdxHasSharp } from "./resolver/auto-layout";
14
- export { MAX_EXAMPLES, ENABLE_CHORD_LOGGING } from "./config";
1
+ export { computeKeyboard, computeSvgDimensions } from "./engine/keyboard-layout.js";
2
+ export { mapHighlights, normalizeNote } from "./engine/highlight-mapper.js";
3
+ export { FLAT_TO_SHARP, SHARP_TO_FLAT, FLAT_KEYS, normalizeToSharps, spellForKey, spellWithPreference, PC_SEMITONES, MIDI_A3, assignAscendingOctaves, } from "./engine/note-spelling.js";
4
+ export { autoFingering, assignFingering } from "./engine/auto-fingering.js";
5
+ export { scaleAutoFingering } from "./engine/scale-fingering.js";
6
+ export { computeStaffLayout } from "./engine/staff-layout.js";
7
+ export { buildMei } from "./engine/mei-builder.js";
8
+ export { getDefaultGlyphs, setDefaultGlyphs, BRAVURA_GLYPHS, PETALUMA_GLYPHS } from "./engine/staff-glyphs.js";
9
+ export { STAFF_LINE_SPACING, HALF_STAFF_SPACING, STAFF_WIDTH, CLEF_AREA_WIDTH, NOTE_COLUMN_X, NOTE_HEAD_RX, ACCIDENTAL_OFFSET, LEDGER_LINE_EXTEND, LEDGER_LINE_STROKE, STAFF_GAP, STAFF_TOP_MARGIN, STAFF_BOTTOM_MARGIN, STAFF_LINE_STROKE, BRACE_WIDTH, SECOND_OFFSET, ACCIDENTAL_COL_WIDTH, } from "./engine/staff-constants.js";
10
+ export { WHITE_KEY_WIDTH, WHITE_KEY_WIDTH_EXACT, WHITE_KEY_HEIGHT_COMPACT, WHITE_KEY_HEIGHT_EXACT, WHITE_KEY_RY, BLACK_KEY_WIDTH, BLACK_KEY_WIDTH_EXACT, BLACK_KEY_HEIGHT_COMPACT, BLACK_KEY_HEIGHT_EXACT, BLACK_KEY_RY, BLACK_KEY_OFFSETS, BLACK_KEY_OFFSETS_EXACT, WHITE_NOTES_WITH_SHARPS, WHITE_NOTE_ORDER, DEFAULT_WHITE_FILL, DEFAULT_BLACK_FILL, DEFAULT_STROKE, DEFAULT_STROKE_WIDTH, } from "./engine/svg-constants.js";
11
+ export { parseChordDescription } from "./parser/natural-language.js";
12
+ export { isProgressionRequest, parseProgressionRequest } from "./parser/progression-parser.js";
13
+ export { resolveChord } from "./resolver/chord-resolver.js";
14
+ export { calculateLayout, whiteIdxHasSharp } from "./resolver/auto-layout.js";
15
+ export { MAX_EXAMPLES, ENABLE_CHORD_LOGGING } from "./config.js";
15
16
  // Progression
16
- export { resolveProgression, tokenizeProgression } from "./progression/roman-numeral";
17
- export { FORM_TEMPLATES, findTemplate } from "./progression/form-templates";
18
- export { resolveProgressionRequest } from "./progression/progression-resolver";
17
+ export { resolveProgression, tokenizeProgression } from "./progression/roman-numeral.js";
18
+ export { FORM_TEMPLATES, findTemplate } from "./progression/form-templates.js";
19
+ export { resolveProgressionRequest } from "./progression/progression-resolver.js";
19
20
  // Audio
20
- export { generateMidiFile, downloadMidi } from "./audio/midi-export";
21
+ export { generateMidiFile, downloadMidi } from "./audio/midi-export.js";
21
22
  // Logging
22
- export { logChordRequest, LOG_SCHEMA_VERSION } from "./logging";
23
+ export { logChordRequest, LOG_SCHEMA_VERSION } from "./logging.js";
23
24
  // Theory
24
- export { classifyTones, parseInterval, minimalVoicing, dropOrder } from "./theory/chord-tones";
25
- export { intervalToDegreeLabel, degreesForIntervals } from "./theory/degree-labels";
25
+ export { classifyTones, parseInterval, minimalVoicing, dropOrder } from "./theory/chord-tones.js";
26
+ export { intervalToDegreeLabel, degreesForIntervals, degreeLabelsForNotes } from "./theory/degree-labels.js";
26
27
  // Scale resolver
27
- export { resolveScale } from "./resolver/scale-resolver";
28
+ export { resolveScale } from "./resolver/scale-resolver.js";
28
29
  // Themes
29
- export { getTheme, resolveTheme } from "./themes";
30
- export { CRF_PITCH_PALETTE, formatCmyk } from "./themes/crf";
30
+ export { getTheme, resolveTheme } from "./themes/index.js";
31
+ export { CRF_PITCH_PALETTE, formatCmyk } from "./themes/crf.js";
31
32
  // Pipeline
32
- export { processChordRequest } from "./pipeline";
33
+ export { processChordRequest } from "./pipeline.js";
33
34
  // ChordSheet
34
- export { resolveDefaults, chordRef, SYSTEM_DEFAULTS } from "./chord-sheet/defaults";
35
- export { CHORD_SHEET_SCHEMA_VERSION, validateVersion } from "./chord-sheet/schema";
36
- export { encodeChordSheet, decodeChordSheet } from "./chord-sheet/codec";
35
+ export { resolveDefaults, chordRef, SYSTEM_DEFAULTS } from "./chord-sheet/defaults.js";
36
+ export { CHORD_SHEET_SCHEMA_VERSION, validateVersion } from "./chord-sheet/schema.js";
37
+ export { encodeChordSheet, decodeChordSheet } from "./chord-sheet/codec.js";
38
+ // Hand Constraints
39
+ export { midiOf, placeAscending, spanOf, constrainVoicing } from "./theory/hand-constraints.js";
40
+ export { generateConstrainedVariants } from "./theory/constrained-variants.js";
37
41
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import type { ParsedChordRequest } from "../types";
1
+ import type { ParsedChordRequest } from "../types.js";
2
2
  /**
3
3
  * Extract modifiers first (format, inversion, bass, style, etc.) using
4
4
  * dedicated regexes, then strip ALL matched patterns plus filler words
@@ -1,8 +1,45 @@
1
- // "a"/"an" are excluded when immediately followed by '#', '/', or end-of-string —
2
- // those are the only non-word-char continuations a real chord root can have
3
- // (e.g. bare "A", "A#dim", "A/C#"); any word-char continuation (e.g. "Adim",
4
- // "Am7") already fails the trailing \b below, so this doesn't affect them.
5
- 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
+ }
6
43
  const FORMAT_RE = /\b(compact|exact|full)\b/i;
7
44
  const FORMAT_FULL_RE = /\bfull\s+(?:layout|height|size|keys?)\b/i;
8
45
  // "size 80" / "size 80%" / "size 0.8" / "size xl" / "keys xl"
@@ -55,7 +92,7 @@ const FINGERING_RE = /(?:with\s+)?finger(?:ing|s)?\s+([\dxX\-](?:[,\s\-]*[\dxX\-
55
92
  const VALID_FINGERING = new Set(["0", "1", "2", "3", "4", "5", "-", "x"]);
56
93
  // "custom fingering "A0,A1,A2"" / "fingering "G3-G0-E1"" / "fingering xl "1-2-3"" / "fingering "1-2-3" 2xl"
57
94
  // Quoted strings after "fingering" or "custom fingering" are treated as free-form labels.
58
- 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;
59
96
  // "with fingerings" / "show fingering" / "with fingering in xl" / "fingering 2xl" (no explicit numbers → auto)
60
97
  // Negative lookahead: don't match when followed by digits (explicit fingering like "1 2 3 5")
61
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;
@@ -73,10 +110,34 @@ const UNAMBIGUOUS_SCALE_TYPES = [
73
110
  "major\\s+pentatonic", "minor\\s+pentatonic", "pentatonic",
74
111
  "blues", "whole\\s+tone", "bebop", "diminished",
75
112
  ];
76
- // Scale patterns: "D major scale", "C blues", "A dorian", etc.
113
+ // Scale patterns: "D major scale", "C blues", "A dorian", "Dm harmonic minor".
77
114
  // For "major"/"minor" alone, require the trailing word "scale" to avoid chord collision.
78
- const SCALE_UNAMBIGUOUS_RE = new RegExp(`([A-Ga-g][#b]?)\\s+(${UNAMBIGUOUS_SCALE_TYPES.join("|")})(?:\\s+scale)?`, "i");
79
- 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
+ }
80
141
  // "ascending" / "descending" for melodic minor
81
142
  const DIRECTION_RE = /\b(ascending|descending)\b/i;
82
143
  // "in N octaves" / "N octaves" — works for both scales and arpeggios
@@ -84,7 +145,17 @@ const OCTAVES_RE = /\b(?:in\s+)?(\d+)\s+octaves?\b/i;
84
145
  // "degrees" anywhere — combined with note names if present, standalone otherwise.
85
146
  // The literal word "degree(s)" isn't used by any other parser rule
86
147
  // (bass-degree / starting-degree use "5th", "9th", etc.), so a broad match is safe.
87
- const DEGREES_KEYWORD_RE = /\bdegrees?(?:\s+(?:in\s+)?(base|lg|xl|2xl))?\b/i;
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;
88
159
  // "with heading" / "with a heading" / "show heading"
89
160
  const HEADING_RE = /\b(?:with\s+)?(?:a\s+)?(?:show\s+)?heading\b/i;
90
161
  // All recognized hand/clef phrases — long forms before shorthand to ensure
@@ -93,10 +164,16 @@ const HAND_CLEF_PATTERN = "left\\s+hand|right\\s+hand|bottom\\s+hand|top\\s+hand
93
164
  // 2+ note tokens separated by spaces/commas/dashes; "and" allowed between tokens.
94
165
  // Single-letter notes (A-G) plus optional accidental (#/b) and optional single-digit octave.
95
166
  const NOTE_LIST_PATTERN = "[A-Ga-g][#b]?\\d?(?:[\\s,\\-]+(?:and\\s+)?[A-Ga-g][#b]?\\d?)+";
96
- // "with notes C E G" / "notes E4 G4 C5" — optional trailing "in <hand|clef>".
97
- const NOTES_GROUP_RE = new RegExp(`(?:with\\s+|and\\s+)?notes\\s+([A-Ga-g][#b]?\\d?\\b(?:[\\s,\\-]+[A-Ga-g][#b]?\\d?\\b)*)(?:\\s+in\\s+(?:the\\s+)?(${HAND_CLEF_PATTERN}))?`, "gi");
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");
98
175
  // "notes in lh Eb Gb Bb" / "notes in the right hand D F A" — prefix form.
99
- const NOTES_GROUP_PREFIX_RE = new RegExp(`(?:with\\s+|and\\s+)?notes\\s+in\\s+(?:the\\s+)?(${HAND_CLEF_PATTERN})\\s+([A-Ga-g][#b]?\\d?\\b(?:[\\s,\\-]+[A-Ga-g][#b]?\\d?\\b)*)`, "gi");
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");
100
177
  // "lh Eb Gb Bb" / "lh: Eb Gb Bb" / "left Eb Gb Bb" / "bass clef C E G"
101
178
  // Hand keyword first, no "notes" word. Optional colon between hand and notes.
102
179
  const HAND_PREFIX_BARE_RE = new RegExp(`\\b(${HAND_CLEF_PATTERN})\\s*(?::\\s*|\\s+)(${NOTE_LIST_PATTERN})`, "gi");
@@ -114,6 +191,34 @@ const SINGLE_NOTE_PATTERN = "[A-Ga-g][#b]?";
114
191
  const HAND_PREFIX_SINGLE_RE = new RegExp(`\\b(${HAND_CLEF_PATTERN})\\s*(?::\\s*|\\s+)(${SINGLE_NOTE_PATTERN})(?=[\\s,;]|$)`, "gi");
115
192
  // "Bb in lh" / "Bb lh" / "(Bb) lh" — single bare note + hand.
116
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
+ }
117
222
  // "Eb Gb Bb // Db Eb F Gb" — polychord-style "top over bottom" (rh then lh).
118
223
  const POLYCHORD_SLASH_RE = new RegExp(`(${NOTE_LIST_PATTERN})\\s*\\/\\/\\s*(${NOTE_LIST_PATTERN})`, "gi");
119
224
  // "Eb Gb Bb; Db Eb F Gb" — semicolon separator; reading order (lh then rh).
@@ -237,8 +342,16 @@ export function parseChordDescription(input) {
237
342
  const isDown = direction === "down" || direction === "lower";
238
343
  result.bassOctaveShift = isDown ? -count : count;
239
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, " ");
240
353
  // Extract "midi note names" / "with midi names" (check before regular note names)
241
- const midiNamesMatch = input.match(MIDI_NAMES_RE);
354
+ const midiNamesMatch = sizeSource.match(MIDI_NAMES_RE);
242
355
  if (midiNamesMatch) {
243
356
  result.showNoteNames = true;
244
357
  result.noteNameMode = "midi";
@@ -246,7 +359,7 @@ export function parseChordDescription(input) {
246
359
  }
247
360
  // Extract "with note names" / "note names in xl" / "xl note names"
248
361
  if (!midiNamesMatch) {
249
- const noteNamesMatch = input.match(NOTE_NAMES_RE);
362
+ const noteNamesMatch = sizeSource.match(NOTE_NAMES_RE);
250
363
  if (noteNamesMatch) {
251
364
  result.showNoteNames = true;
252
365
  // Size can be in group 1 (before "note names"), 2 (after), or 3 ("name the notes in xl")
@@ -261,7 +374,13 @@ export function parseChordDescription(input) {
261
374
  // Check custom fingering FIRST ("custom fingering A0,A1,A2")
262
375
  const customFingerMatch = input.match(CUSTOM_FINGERING_RE);
263
376
  if (customFingerMatch) {
264
- 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);
265
384
  result.fingeringSize = toTextSize(customFingerMatch[1]) ?? toTextSize(customFingerMatch[3]);
266
385
  }
267
386
  // Check auto fingering ("with fingering", "fingering 2xl", "fingering in xl")
@@ -330,27 +449,64 @@ export function parseChordDescription(input) {
330
449
  // segments separated by "and" / "with". Paired example:
331
450
  // "notes C E G in bass clef and notes B D F in treble clef"
332
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
+ };
333
482
  const normalizeTokens = (raw) => raw
334
483
  .split(/[\s,\-]+/)
335
484
  .filter(Boolean)
336
- .map((t) => {
485
+ .flatMap((t) => {
486
+ // Single-note tokens keep their exact old handling ("eb5" → Eb5,
487
+ // "bb" → Bb) — run explosion applies only to longer sequences.
337
488
  const nm = t.match(/^([A-Ga-g])([#b])?(\d)?$/);
338
- if (!nm)
339
- return t;
340
- return nm[1].toUpperCase() + (nm[2] ?? "") + (nm[3] ?? "");
489
+ if (nm)
490
+ return [nm[1].toUpperCase() + (nm[2] ?? "") + (nm[3] ?? "")];
491
+ return explodeNoteRun(t);
341
492
  });
342
493
  // Note-group extraction runs in priority order. Each pass strips its matches
343
494
  // from `residual` so later (looser) passes can't re-consume the same notes.
344
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 = [];
345
499
  const stripFromResidual = (match) => {
346
500
  residual = residual.replace(match, " ");
347
501
  };
348
- const pushGroup = (tokens, hand, clef) => {
502
+ const pushGroup = (tokens, hand, clef, chord) => {
349
503
  const g = { notes: tokens };
350
504
  if (hand)
351
505
  g.hand = hand;
352
506
  if (clef)
353
507
  g.clef = clef;
508
+ if (chord)
509
+ g.chord = chord;
354
510
  groups.push(g);
355
511
  };
356
512
  // 1. "notes in <hand> <notes>" (prefix with explicit "notes" keyword)
@@ -424,6 +580,32 @@ export function parseChordDescription(input) {
424
580
  pushGroup(tokens, hand, clef);
425
581
  stripFromResidual(m[0]);
426
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
+ }
427
609
  // 5. "Eb Gb Bb // Db Eb F Gb" — polychord-style "top over bottom" (rh, lh).
428
610
  POLYCHORD_SLASH_RE.lastIndex = 0;
429
611
  for (const m of [...residual.matchAll(POLYCHORD_SLASH_RE)]) {
@@ -454,21 +636,26 @@ export function parseChordDescription(input) {
454
636
  if (themeMatch) {
455
637
  result.colorTheme = THEME_MAP[themeMatch[1].toLowerCase()] ?? themeMatch[1].toLowerCase();
456
638
  }
457
- // Degrees keyword is independent — combines with note names if already detected,
458
- // otherwise stands alone as degree-only mode. Skipped if MIDI names are active
459
- // since "midi+degree" isn't a supported display mode.
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.
460
644
  const degMatch = input.match(DEGREES_KEYWORD_RE);
461
- if (degMatch && result.noteNameMode !== "midi") {
645
+ if (degMatch) {
462
646
  if (result.showNoteNames) {
463
- result.noteNameMode = "pitch-class+degree";
647
+ result.noteNameMode = result.noteNameMode === "midi" ? "midi+degree" : "pitch-class+degree";
464
648
  }
465
649
  else {
466
650
  result.showNoteNames = true;
467
651
  result.noteNameMode = "degree";
468
652
  }
469
- const sz = toTextSize(degMatch[1]);
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]);
470
657
  if (sz)
471
- result.noteNameSize = sz;
658
+ result.degreeSize = sz;
472
659
  }
473
660
  // Extract scale direction
474
661
  const dirMatch = input.match(DIRECTION_RE);
@@ -478,10 +665,12 @@ export function parseChordDescription(input) {
478
665
  // Extract octave count (used by both scales and arpeggios)
479
666
  const octavesMatch = input.match(OCTAVES_RE);
480
667
  const octaveCount = octavesMatch ? parseInt(octavesMatch[1], 10) : undefined;
481
- // 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.
482
670
  const scaleUnambig = input.match(SCALE_UNAMBIGUOUS_RE);
483
671
  const scaleExplicit = input.match(SCALE_EXPLICIT_RE);
484
672
  const scaleMatch = scaleUnambig || scaleExplicit;
673
+ const scaleShorthand = scaleMatch ? null : input.match(SCALE_SHORTHAND_RE);
485
674
  if (scaleMatch) {
486
675
  const scaleRoot = capitalizeNote(scaleMatch[1]);
487
676
  const scaleType = scaleMatch[2].toLowerCase().replace(/\s+/g, " ").trim();
@@ -489,6 +678,12 @@ export function parseChordDescription(input) {
489
678
  result.scaleName = `${scaleRoot} ${scaleType}`;
490
679
  result.scaleOctaves = octaveCount ?? 1;
491
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
+ }
492
687
  else if (octaveCount) {
493
688
  // Not a scale but has "N octaves" → arpeggio mode for chord
494
689
  result.chordOctaves = octaveCount;
@@ -502,8 +697,11 @@ export function parseChordDescription(input) {
502
697
  else if (startMatch) {
503
698
  result.startingNote = capitalizeNote(startMatch[1]);
504
699
  }
505
- // Strip extracted patterns and filler for chord name extraction
506
- 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)
507
705
  .replace(FORMAT_FULL_RE, "")
508
706
  .replace(FORMAT_RE, "")
509
707
  .replace(SIZE_NAME_RE, "")
@@ -516,6 +714,11 @@ export function parseChordDescription(input) {
516
714
  .replace(PADDING_RE, "")
517
715
  .replace(CHORD_OCTAVE_RE, "")
518
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, "")
519
722
  .replace(NOTE_NAMES_RE, "")
520
723
  .replace(CUSTOM_FINGERING_RE, "")
521
724
  .replace(FINGERING_RE, "")
@@ -532,6 +735,10 @@ export function parseChordDescription(input) {
532
735
  .replace(OCTAVES_RE, "")
533
736
  .replace(SCALE_UNAMBIGUOUS_RE, "")
534
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, "")
535
742
  .replace(/\bscale\b/gi, "")
536
743
  .replace(HEADING_RE, "")
537
744
  .replace(NOTES_GROUP_PREFIX_RE, "")
@@ -557,6 +764,9 @@ export function parseChordDescription(input) {
557
764
  .replace(/\bin\b/gi, "")
558
765
  .replace(/\s+/g, " ")
559
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();
560
770
  // Replace word-based accidentals before chord extraction
561
771
  cleaned = cleaned
562
772
  .replace(/\bsharp\b/gi, "#")