@pepperhorn/chordl-core 0.4.0 → 0.5.1

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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * "Starting on X" — the rule that decides which chord tone sits at the bottom.
3
+ *
4
+ * This lives in the theory layer rather than in a renderer because more than
5
+ * one view of a chord has to agree about it. `PianoChord` rotates the voicing
6
+ * it draws; the board's player rotates the voicing it *sounds*. A second copy
7
+ * of the rule in the second caller is the one kind of drift nothing on screen
8
+ * would reveal — the card would draw a first inversion and play root position,
9
+ * silently, so both call this.
10
+ *
11
+ * Degree resolution ("starting on the 3rd") lives here for the same reason: it
12
+ * is the first half of the same clause, and `bassDegree` ("over the 3rd") uses
13
+ * the identical lookup.
14
+ */
15
+ import { Note } from "tonal";
16
+ import { FLAT_TO_SHARP } from "../engine/note-spelling.js";
17
+ /**
18
+ * Map semitones (mod 12) from root to a scale degree number.
19
+ * Handles both major and minor variants of each degree.
20
+ * Note: does not distinguish quality (e.g. b9 vs M9 both map to degree 2).
21
+ * This means degree-based lookups may match the wrong quality in altered chords.
22
+ */
23
+ function semitonesToDegree(semitones) {
24
+ const s = ((semitones % 12) + 12) % 12;
25
+ if (s === 0)
26
+ return 1;
27
+ if (s <= 2)
28
+ return 2; // m2=1, M2=2 (also 9th)
29
+ if (s <= 4)
30
+ return 3; // m3=3, M3=4
31
+ if (s === 5)
32
+ return 4; // P4=5 (also 11th)
33
+ if (s <= 7)
34
+ return 5; // d5=6, P5=7
35
+ if (s <= 9)
36
+ return 6; // m6=8, M6=9 (also 13th)
37
+ return 7; // m7=10, M7=11
38
+ }
39
+ /** Ordinal names for the degrees a "starting on"/"over" clause can name. */
40
+ export const DEGREE_NAMES = {
41
+ 1: "root", 2: "2nd", 3: "3rd", 4: "4th", 5: "5th", 6: "6th", 7: "7th",
42
+ 9: "9th", 11: "11th", 13: "13th",
43
+ };
44
+ /**
45
+ * Find the chord tone matching a musical degree, regardless of quality.
46
+ * e.g. degree 3 finds Eb in Cm7 and E in Cmaj7.
47
+ */
48
+ export function degreeToNote(root, degree, notes) {
49
+ const rootMidi = Note.midi(`${root}4`);
50
+ if (rootMidi == null)
51
+ return undefined;
52
+ // Normalize compound degrees: 9→2, 11→4, 13→6
53
+ const simpleDegree = degree > 7 ? degree - 7 : degree;
54
+ for (const note of notes) {
55
+ const noteMidi = Note.midi(`${note}4`);
56
+ if (noteMidi == null)
57
+ continue;
58
+ const semitones = ((noteMidi - rootMidi) % 12 + 12) % 12;
59
+ if (semitonesToDegree(semitones) === simpleDegree)
60
+ return note;
61
+ }
62
+ return undefined;
63
+ }
64
+ /** Format the available degrees for error messages */
65
+ export function describeAvailableDegrees(root, notes) {
66
+ const rootMidi = Note.midi(`${root}4`);
67
+ if (rootMidi == null)
68
+ return notes.join(", ");
69
+ const degrees = [];
70
+ for (const note of notes) {
71
+ const noteMidi = Note.midi(`${note}4`);
72
+ if (noteMidi == null)
73
+ continue;
74
+ const semitones = ((noteMidi - rootMidi) % 12 + 12) % 12;
75
+ const deg = semitonesToDegree(semitones);
76
+ const name = DEGREE_NAMES[deg] ?? `${deg}th`;
77
+ degrees.push(`${name} (${note})`);
78
+ }
79
+ return degrees.join(", ");
80
+ }
81
+ /**
82
+ * Rotate a voicing so `startingNote` is its lowest note.
83
+ *
84
+ * Both sides are normalised before matching. `notes` keeps the chord's own
85
+ * spelling — Bbm is ["Bb", "Db", "F"] — so normalising only the input turned
86
+ * "Bbm starting on Db" into a search for "C#" and reported Db missing from a
87
+ * chord whose own error message listed it as the 3rd; matching literally is
88
+ * the mirror bug, and drops the rotation for "Bbm starting on C#".
89
+ */
90
+ export function rotateToStartingNote(notes, startingNote) {
91
+ const norm = FLAT_TO_SHARP[startingNote] ?? startingNote;
92
+ const index = notes.findIndex((n) => (FLAT_TO_SHARP[n] ?? n) === norm);
93
+ if (index <= 0)
94
+ return { index, notes };
95
+ return { index, notes: [...notes.slice(index), ...notes.slice(0, index)] };
96
+ }
97
+ //# sourceMappingURL=starting-note.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"starting-note.js","sourceRoot":"","sources":["../../src/theory/starting-note.ts"],"names":[],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -5,6 +5,7 @@ export type TextSize = "base" | "lg" | "xl" | "2xl";
5
5
  export type NoteNameMode = "pitch-class" | "midi" | "degree" | "pitch-class+degree" | "midi+degree";
6
6
  export type WhiteNote = "C" | "D" | "E" | "F" | "G" | "A" | "B";
7
7
  export type NoteName = string;
8
+ export type PlaybackInstrument = "acoustic_grand_piano" | "electric_guitar_clean" | "ukulele";
8
9
  export interface ColorTheme {
9
10
  name: string;
10
11
  whiteKey: (note: string, highlighted: boolean) => string;
@@ -113,6 +114,10 @@ export interface DisplayDefaults {
113
114
  noteNameMode?: NoteNameMode;
114
115
  showFingering?: boolean;
115
116
  fingeringSize?: TextSize;
117
+ /** Arpeggio tempo on a sixteenth-note grid. */
118
+ arpeggioBpm?: number;
119
+ /** Transient colour used to identify the note currently being attacked. */
120
+ playbackHighlightColor?: string;
116
121
  }
117
122
  export interface ChordData {
118
123
  /** NL string — source of truth for chord resolution. */
@@ -127,6 +132,11 @@ export interface ChordData {
127
132
  theme?: ColorTheme | string;
128
133
  highlightColor?: string;
129
134
  padding?: number;
135
+ arpeggioBpm?: number;
136
+ playbackHighlightColor?: string;
137
+ /** Ordered MIDI pitches for the exact rendered voicing. */
138
+ playbackNotes?: number[];
139
+ playbackInstrument?: PlaybackInstrument;
130
140
  }
131
141
  export interface SectionData {
132
142
  /** Section letter ID (auto-assigned "A","B",... if omitted). */
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@pepperhorn/chordl-core",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Pure TypeScript chord parsing, resolving, layout, and export engine",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/pepperhorn/chordl.git",
8
+ "directory": "packages/chordl-core"
9
+ },
5
10
  "type": "module",
6
11
  "main": "./dist/index.js",
7
12
  "module": "./dist/index.js",
@@ -17,8 +22,8 @@
17
22
  "dist"
18
23
  ],
19
24
  "dependencies": {
20
- "tonal": "^6.3.0",
21
- "@pepperhorn/chordl-voicings": "0.4.0"
25
+ "tonal": "~6.4.3",
26
+ "@pepperhorn/chordl-voicings": "0.4.1"
22
27
  },
23
28
  "devDependencies": {
24
29
  "typescript": "^5.7.0",