note-mapper 1.0.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/index.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * index.js
3
+ * Main entry point for note-mapper.
4
+ * Re-exports everything from all modules so consumers
5
+ * only need one import.
6
+ *
7
+ * @example
8
+ * import { SCALES, createMapper, ACTIONS_5 } from "note-mapper"
9
+ */
10
+
11
+ // scales
12
+ export {
13
+ SCALES,
14
+ KEYS,
15
+ MAJOR,
16
+ NATURAL_MINOR,
17
+ PENTATONIC_MINOR,
18
+ PENTATONIC_MAJOR,
19
+ BLUES,
20
+ DORIAN,
21
+ PHRYGIAN,
22
+ LYDIAN,
23
+ MIXOLYDIAN,
24
+ LOCRIAN,
25
+ HARMONIC_MINOR,
26
+ MELODIC_MINOR,
27
+ PHRYGIAN_DOMINANT,
28
+ WHOLE_TONE,
29
+ DIMINISHED,
30
+ } from "./scales.js"
31
+
32
+ // degrees
33
+ export {
34
+ getDegree,
35
+ getDegreeWithTolerance,
36
+ degreeToNote,
37
+ isInScale,
38
+ pitchClass,
39
+ } from "./degrees.js"
40
+
41
+ // intervals
42
+ export {
43
+ INTERVALS,
44
+ getInterval,
45
+ getIntervalInfo,
46
+ detectInterval,
47
+ isInterval,
48
+ } from "./intervals.js"
49
+
50
+ // mapper
51
+ export {
52
+ createMapper,
53
+ ACTIONS_5,
54
+ ACTIONS_6,
55
+ ACTIONS_7,
56
+ ACTIONS_INTERVAL,
57
+ } from "./mapper.js"
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "note-mapper",
3
+ "version": "1.0.0",
4
+ "description": "Map guitar notes from note-listener to scale degrees, intervals and game actions",
5
+ "main": "index.js",
6
+ "exports": {
7
+ ".": "./src/index.js"
8
+ },
9
+ "scripts": {
10
+ "test": "echo \"Error: no test specified\" && exit 1"
11
+ },
12
+ "keywords": ["guitar", "music", "pitch", "scale", "interval", "game-controller"],
13
+ "author": "Donald Edwin",
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "peerDependencies": {
17
+ "note-listener": "*"
18
+ }
19
+ }
package/src/degrees.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * degrees.js
3
+ * Resolves a note from note-listener into a scale degree.
4
+ * A degree is just the position of the note in the scale — 0 is the root.
5
+ */
6
+
7
+ // ─── Enharmonic equivalents ──────────────────────────────────────────────────
8
+ // Some notes have two names (C# = Db, F# = Gb etc).
9
+ // This map normalizes them so lookups always work regardless of which
10
+ // name the scale uses vs which name note-listener detected.
11
+
12
+ const ENHARMONIC = {
13
+ "Db": "C#",
14
+ "D#": "Eb",
15
+ "Gb": "F#",
16
+ "G#": "Ab",
17
+ "A#": "Bb",
18
+ // and the reverse
19
+ "C#": "Db",
20
+ "Eb": "D#",
21
+ "F#": "Gb",
22
+ "Ab": "G#",
23
+ "Bb": "A#",
24
+ }
25
+
26
+ // ─── Utilities ───────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Strips the octave number from a note string.
30
+ * "A2" → "A", "C#3" → "C#", "Bb4" → "Bb"
31
+ */
32
+ export function pitchClass(note) {
33
+ return note.replace(/\d/g, "")
34
+ }
35
+
36
+ /**
37
+ * Looks up a pitch class in a scale, trying both its name
38
+ * and its enharmonic equivalent.
39
+ * Returns the index (degree) or -1 if not found.
40
+ */
41
+ function findInScale(pc, scale) {
42
+ const direct = scale.indexOf(pc)
43
+ if (direct !== -1) return direct
44
+
45
+ const alt = ENHARMONIC[pc]
46
+ if (alt) return scale.indexOf(alt)
47
+
48
+ return -1
49
+ }
50
+
51
+ // ─── Main exports ────────────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Returns the scale degree (0-based) of a note within a scale.
55
+ * Returns null if the note is not in the scale.
56
+ *
57
+ * @param {string} note - note string from note-listener e.g. "A2"
58
+ * @param {string[]} scale - scale array from scales.js e.g. ["A","C","D","E","G"]
59
+ * @returns {number|null}
60
+ *
61
+ * @example
62
+ * getDegree("A2", ["A","C","D","E","G"]) // → 0 (root)
63
+ * getDegree("E3", ["A","C","D","E","G"]) // → 3
64
+ * getDegree("F#2",["A","C","D","E","G"]) // → null (not in scale)
65
+ */
66
+ export function getDegree(note, scale) {
67
+ const pc = pitchClass(note)
68
+ const degree = findInScale(pc, scale)
69
+ return degree === -1 ? null : degree
70
+ }
71
+
72
+ /**
73
+ * Same as getDegree but with cents tolerance.
74
+ * Useful when the player is slightly out of tune —
75
+ * accepts the note if it's within centsTolerance cents of a scale degree.
76
+ *
77
+ * @param {string} note - note string from note-listener e.g. "A2"
78
+ * @param {number} freq - frequency in Hz from note-listener
79
+ * @param {string[]} scale - scale array from scales.js
80
+ * @param {number} centsTolerance - how many cents off to still accept (default 40)
81
+ * @returns {number|null}
82
+ */
83
+ export function getDegreeWithTolerance(note, freq, scale, centsTolerance = 40) {
84
+ // first try exact match
85
+ const exact = getDegree(note, scale)
86
+ if (exact !== null) return exact
87
+
88
+ // if no exact match, check if freq is close enough to any scale degree
89
+ for (let i = 0; i < scale.length; i++) {
90
+ const targetFreq = noteNameToFreq(scale[i])
91
+ if (!targetFreq) continue
92
+
93
+ // check across multiple octaves (2 through 6)
94
+ for (let octave = 2; octave <= 6; octave++) {
95
+ const octaveFreq = targetFreq * Math.pow(2, octave - 4)
96
+ const cents = Math.abs(1200 * Math.log2(freq / octaveFreq))
97
+ if (cents <= centsTolerance) return i
98
+ }
99
+ }
100
+
101
+ return null
102
+ }
103
+
104
+ /**
105
+ * Converts a pitch class name to its frequency in octave 4.
106
+ * Used internally by getDegreeWithTolerance.
107
+ */
108
+ function noteNameToFreq(pc) {
109
+ const A4 = 440
110
+ const NOTE_NAMES = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"]
111
+
112
+ // normalize enharmonics to sharps for lookup
113
+ const normalized = ENHARMONIC[pc] && NOTE_NAMES.includes(ENHARMONIC[pc])
114
+ ? ENHARMONIC[pc]
115
+ : pc
116
+
117
+ const semitone = NOTE_NAMES.indexOf(normalized)
118
+ if (semitone === -1) return null
119
+
120
+ // semitones from A4 (midi 69) → freq
121
+ const midi = semitone + 60 // C4 = midi 60
122
+ return A4 * Math.pow(2, (midi - 69) / 12)
123
+ }
124
+
125
+ /**
126
+ * Returns the note name for a given degree in a scale.
127
+ * The reverse of getDegree — useful for displaying what note to play next.
128
+ *
129
+ * @param {number} degree - 0-based degree index
130
+ * @param {string[]} scale - scale array from scales.js
131
+ * @returns {string|null}
132
+ *
133
+ * @example
134
+ * degreeToNote(0, ["A","C","D","E","G"]) // → "A"
135
+ * degreeToNote(3, ["A","C","D","E","G"]) // → "E"
136
+ */
137
+ export function degreeToNote(degree, scale) {
138
+ return scale[degree] ?? null
139
+ }
140
+
141
+ /**
142
+ * Returns true if a note is in the scale, false if not.
143
+ * Useful for filtering out notes that don't belong before mapping to actions.
144
+ */
145
+ export function isInScale(note, scale) {
146
+ return getDegree(note, scale) !== null
147
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * intervals.js
3
+ * Detects and names the interval between two simultaneously detected notes.
4
+ * Works with the notes array from note-listener's onNotes callback.
5
+ */
6
+
7
+ // ─── Interval definitions ────────────────────────────────────────────────────
8
+
9
+ export const INTERVALS = {
10
+ 0: { name: "Unison", short: "P1", character: "same note" },
11
+ 1: { name: "Minor 2nd", short: "m2", character: "tense, dissonant" },
12
+ 2: { name: "Major 2nd", short: "M2", character: "stepwise, mild" },
13
+ 3: { name: "Minor 3rd", short: "m3", character: "minor, sad" },
14
+ 4: { name: "Major 3rd", short: "M3", character: "major, bright" },
15
+ 5: { name: "Perfect 4th", short: "P4", character: "open, suspended" },
16
+ 6: { name: "Tritone", short: "TT", character: "maximum tension" },
17
+ 7: { name: "Perfect 5th", short: "P5", character: "power chord, strong" },
18
+ 8: { name: "Minor 6th", short: "m6", character: "melancholic" },
19
+ 9: { name: "Major 6th", short: "M6", character: "warm, resolved" },
20
+ 10: { name: "Minor 7th", short: "m7", character: "bluesy, unresolved" },
21
+ 11: { name: "Major 7th", short: "M7", character: "jazzy, tense" },
22
+ 12: { name: "Octave", short: "P8", character: "same note reinforced" },
23
+ }
24
+
25
+ // ─── Utilities ───────────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Converts a frequency to a MIDI note number (not rounded — keeps decimals).
29
+ * Used for interval calculation so we work in semitone space.
30
+ */
31
+ function freqToMidi(freq) {
32
+ return 12 * Math.log2(freq / 440) + 69
33
+ }
34
+
35
+ // ─── Main exports ────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Returns the interval in semitones between two frequencies.
39
+ * Always returns a positive number between 0 and 12 (octave-reduced).
40
+ *
41
+ * @param {number} freqA
42
+ * @param {number} freqB
43
+ * @returns {number} semitones 0–12
44
+ *
45
+ * @example
46
+ * getInterval(440, 660) // → 7 (perfect 5th, A4 to E5)
47
+ * getInterval(440, 880) // → 12 (octave)
48
+ */
49
+ export function getInterval(freqA, freqB) {
50
+ const semitones = Math.abs(freqToMidi(freqA) - freqToMidi(freqB))
51
+ return Math.round(semitones) % 12 || 12
52
+ // the || 12 means: if mod 12 gives 0 (an octave), return 12 not 0
53
+ // so unison (same note) = 0, octave = 12, everything else 1–11
54
+ }
55
+
56
+ /**
57
+ * Returns the full interval info object for two frequencies.
58
+ *
59
+ * @param {number} freqA
60
+ * @param {number} freqB
61
+ * @returns {{ semitones, name, short, character }}
62
+ *
63
+ * @example
64
+ * getIntervalInfo(440, 660)
65
+ * // → { semitones: 7, name: "Perfect 5th", short: "P5", character: "power chord, strong" }
66
+ */
67
+ export function getIntervalInfo(freqA, freqB) {
68
+ const semitones = getInterval(freqA, freqB)
69
+ return { semitones, ...INTERVALS[semitones] }
70
+ }
71
+
72
+ /**
73
+ * Detects an interval from the notes array given by note-listener's onNotes.
74
+ * Only looks at notes with isOnset: true so held notes don't re-trigger.
75
+ *
76
+ * Uses a timing window — notes don't have to arrive in the exact same frame,
77
+ * just within windowMs milliseconds of each other.
78
+ *
79
+ * @param {Array} notes - the notes array from onNotes
80
+ * @param {Array} history - a mutable array you maintain across calls (pass [] initially)
81
+ * @param {number} windowMs - how long to wait for a second note (default 150ms)
82
+ * @returns {{ semitones, name, short, character, notes } | null}
83
+ *
84
+ * @example
85
+ * const history = []
86
+ *
87
+ * onNotes(notes) {
88
+ * const interval = detectInterval(notes, history)
89
+ * if (interval) console.log(interval.name) // "Perfect 5th"
90
+ * }
91
+ */
92
+ export function detectInterval(notes, history, windowMs = 150) {
93
+ const now = Date.now()
94
+
95
+ // add any new onsets to the history buffer
96
+ for (const n of notes) {
97
+ if (n.isOnset) {
98
+ history.push({ freq: n.freq, note: n.note, time: now })
99
+ }
100
+ }
101
+
102
+ // remove entries older than the window
103
+ while (history.length && now - history[0].time > windowMs) {
104
+ history.shift()
105
+ }
106
+
107
+ // if we have at least two notes in the window, we have an interval
108
+ if (history.length >= 2) {
109
+ const a = history[0]
110
+ const b = history[1]
111
+ const info = getIntervalInfo(a.freq, b.freq)
112
+
113
+ // clear history so we don't keep re-firing the same interval
114
+ history.length = 0
115
+
116
+ return {
117
+ ...info,
118
+ notes: [a.note, b.note],
119
+ }
120
+ }
121
+
122
+ return null
123
+ }
124
+
125
+ /**
126
+ * Checks if a detected interval matches a named interval.
127
+ * Useful for if/else game logic without dealing with semitone numbers.
128
+ *
129
+ * @param {number} semitones - from getInterval()
130
+ * @param {string} name - interval short name e.g. "P5", "m3", "TT"
131
+ * @returns {boolean}
132
+ *
133
+ * @example
134
+ * if (isInterval(semitones, "P5")) triggerAction("power_attack")
135
+ */
136
+ export function isInterval(semitones, name) {
137
+ return INTERVALS[semitones]?.short === name
138
+ }
package/src/mapper.js ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * mapper.js
3
+ * Maps scale degrees and intervals to game actions.
4
+ * This is the final layer — it takes what degrees.js and intervals.js
5
+ * give you and turns them into something your game can act on.
6
+ */
7
+
8
+ // ─── Default action maps ─────────────────────────────────────────────────────
9
+
10
+ // 5 note pentatonic — good for simple games
11
+ export const ACTIONS_5 = {
12
+ 0: "jump",
13
+ 1: "move_left",
14
+ 2: "move_right",
15
+ 3: "attack",
16
+ 4: "shield",
17
+ }
18
+
19
+ // 6 note blues — adds one extra action
20
+ export const ACTIONS_6 = {
21
+ 0: "jump",
22
+ 1: "move_left",
23
+ 2: "move_right",
24
+ 3: "attack",
25
+ 4: "shield",
26
+ 5: "special",
27
+ }
28
+
29
+ // 7 note diatonic — full action set
30
+ export const ACTIONS_7 = {
31
+ 0: "jump",
32
+ 1: "move_left",
33
+ 2: "move_right",
34
+ 3: "attack",
35
+ 4: "shield",
36
+ 5: "special",
37
+ 6: "ultimate",
38
+ }
39
+
40
+ // interval-based actions — two notes played together
41
+ export const ACTIONS_INTERVAL = {
42
+ 7: "power_attack", // perfect 5th — power chord
43
+ 3: "block", // minor 3rd
44
+ 4: "dodge", // major 3rd
45
+ 12: "ultimate", // octave
46
+ 5: "special", // perfect 4th
47
+ 6: "taunt", // tritone — maximum tension
48
+ 10: "roll", // minor 7th
49
+ 9: "heal", // major 6th
50
+ }
51
+
52
+ // ─── Main exports ────────────────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Creates a mapper instance that connects note-listener output
56
+ * to game actions. This is the main thing you use in your app.
57
+ *
58
+ * @param {object} opts
59
+ * @param {string[]} opts.scale - scale array from scales.js
60
+ * @param {object} [opts.degreeActions] - degree → action map (default ACTIONS_7)
61
+ * @param {object} [opts.intervalActions] - semitones → action map (default ACTIONS_INTERVAL)
62
+ * @param {number} [opts.centsTolerance] - how many cents off to still accept (default 40)
63
+ * @param {number} [opts.intervalWindow] - ms window for interval detection (default 150)
64
+ * @param {function} opts.onAction - fires when any action is triggered
65
+ *
66
+ * @returns {{ process, setScale, setDegreeActions, setIntervalActions }}
67
+ *
68
+ * @example
69
+ * const mapper = createMapper({
70
+ * scale: SCALES["natural_minor"]["A"],
71
+ * onAction(action, meta) {
72
+ * console.log(action) // "jump", "power_attack" etc
73
+ * console.log(meta) // { note, freq, degree, interval, isOnset }
74
+ * }
75
+ * })
76
+ *
77
+ * // inside note-listener's onNotes:
78
+ * onNotes(notes) {
79
+ * mapper.process(notes)
80
+ * }
81
+ */
82
+ export function createMapper({
83
+ scale,
84
+ degreeActions = ACTIONS_7,
85
+ intervalActions = ACTIONS_INTERVAL,
86
+ centsTolerance = 40,
87
+ intervalWindow = 150,
88
+ onAction,
89
+ } = {}) {
90
+ let _scale = scale
91
+ let _degreeActions = degreeActions
92
+ let _intervalActions = intervalActions
93
+ let _intervalHistory = []
94
+
95
+ // ── onset gate ────────────────────────────────────────────────────────────
96
+ // tracks which notes are currently held so we only fire on the attack,
97
+ // not continuously while the note rings
98
+ const _heldNotes = new Set()
99
+
100
+ function processOnsets(notes) {
101
+ const onsets = []
102
+
103
+ for (const n of notes) {
104
+ if (n.isOnset && !_heldNotes.has(n.note)) {
105
+ _heldNotes.add(n.note)
106
+ onsets.push(n)
107
+ }
108
+ // if a note is no longer in the detected list, release it
109
+ }
110
+
111
+ // release notes that stopped sounding
112
+ const currentNotes = new Set(notes.map(n => n.note))
113
+ for (const held of _heldNotes) {
114
+ if (!currentNotes.has(held)) _heldNotes.delete(held)
115
+ }
116
+
117
+ return onsets
118
+ }
119
+
120
+ // ── interval detection ────────────────────────────────────────────────────
121
+ function processInterval(notes) {
122
+ const now = Date.now()
123
+
124
+ for (const n of notes) {
125
+ if (n.isOnset) {
126
+ _intervalHistory.push({ freq: n.freq, note: n.note, time: now })
127
+ }
128
+ }
129
+
130
+ // remove old entries outside the window
131
+ while (_intervalHistory.length && now - _intervalHistory[0].time > intervalWindow) {
132
+ _intervalHistory.shift()
133
+ }
134
+
135
+ if (_intervalHistory.length >= 2) {
136
+ const a = _intervalHistory[0]
137
+ const b = _intervalHistory[1]
138
+
139
+ const semitones = Math.round(
140
+ Math.abs(12 * Math.log2(a.freq / b.freq))
141
+ ) % 12 || 12
142
+
143
+ _intervalHistory.length = 0
144
+ return { semitones, notes: [a.note, b.note] }
145
+ }
146
+
147
+ return null
148
+ }
149
+
150
+ // ── degree detection ──────────────────────────────────────────────────────
151
+ function processDegree(note, freq) {
152
+ const pc = note.replace(/\d/g, "")
153
+
154
+ // try direct match first
155
+ let degree = _scale.indexOf(pc)
156
+
157
+ // try enharmonic equivalent
158
+ if (degree === -1) {
159
+ const ENHARMONIC = {
160
+ "Db":"C#","D#":"Eb","Gb":"F#","G#":"Ab","A#":"Bb",
161
+ "C#":"Db","Eb":"D#","F#":"Gb","Ab":"G#","Bb":"A#",
162
+ }
163
+ const alt = ENHARMONIC[pc]
164
+ if (alt) degree = _scale.indexOf(alt)
165
+ }
166
+
167
+ // try cents tolerance if still not found
168
+ if (degree === -1 && freq) {
169
+ for (let i = 0; i < _scale.length; i++) {
170
+ for (let octave = 2; octave <= 6; octave++) {
171
+ const midi = (_scale.indexOf(_scale[i]) + 60)
172
+ const tFreq = 440 * Math.pow(2, (midi - 69) / 12) * Math.pow(2, octave - 4)
173
+ const cents = Math.abs(1200 * Math.log2(freq / tFreq))
174
+ if (cents <= centsTolerance) { degree = i; break }
175
+ }
176
+ if (degree !== -1) break
177
+ }
178
+ }
179
+
180
+ return degree === -1 ? null : degree
181
+ }
182
+
183
+ // ── main process function ─────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Call this inside note-listener's onNotes with the notes array.
187
+ * It will fire onAction for every detected action.
188
+ *
189
+ * @param {Array} notes - the notes array from note-listener's onNotes
190
+ */
191
+ function process(notes) {
192
+ if (!notes?.length) return
193
+
194
+ // --- interval check (two notes together) ---
195
+ const interval = processInterval(notes)
196
+ if (interval) {
197
+ const action = _intervalActions[interval.semitones]
198
+ if (action && onAction) {
199
+ onAction(action, {
200
+ type: "interval",
201
+ interval: interval.semitones,
202
+ notes: interval.notes,
203
+ })
204
+ }
205
+ }
206
+
207
+ // --- single note degree check ---
208
+ const onsets = processOnsets(notes)
209
+ for (const n of onsets) {
210
+ const degree = processDegree(n.note, n.freq)
211
+ if (degree === null) continue
212
+
213
+ const action = _degreeActions[degree]
214
+ if (action && onAction) {
215
+ onAction(action, {
216
+ type: "degree",
217
+ degree,
218
+ note: n.note,
219
+ freq: n.freq,
220
+ cents: n.cents,
221
+ energy: n.energy,
222
+ })
223
+ }
224
+ }
225
+ }
226
+
227
+ // ── public API ────────────────────────────────────────────────────────────
228
+
229
+ return {
230
+ process,
231
+
232
+ /** swap the scale at runtime e.g. when player changes key */
233
+ setScale(newScale) {
234
+ _scale = newScale
235
+ _heldNotes.clear()
236
+ _intervalHistory.length = 0
237
+ },
238
+
239
+ /** swap the degree action map at runtime */
240
+ setDegreeActions(newActions) {
241
+ _degreeActions = newActions
242
+ },
243
+
244
+ /** swap the interval action map at runtime */
245
+ setIntervalActions(newActions) {
246
+ _intervalActions = newActions
247
+ },
248
+
249
+ /** returns which notes are currently being held */
250
+ getHeldNotes() {
251
+ return [..._heldNotes]
252
+ },
253
+ }
254
+ }
package/src/scales.js ADDED
@@ -0,0 +1,301 @@
1
+ /**
2
+ * scales.js
3
+ * Complete scale definitions for all 12 keys.
4
+ * Each scale is an ordered array of pitch classes (no octave numbers).
5
+ * The order defines the degree — index 0 is always the root.
6
+ *
7
+ * Keys: C, C#, D, Eb, E, F, F#, G, Ab, A, Bb, B
8
+ */
9
+
10
+ // ─── Pentatonic Minor (5 notes) ──────────────────────────────────────────────
11
+ // Formula: 1 b3 4 5 b7
12
+
13
+ export const PENTATONIC_MINOR = {
14
+ "C": ["C", "Eb", "F", "G", "Bb"],
15
+ "C#": ["C#", "E", "F#", "G#", "B"],
16
+ "D": ["D", "F", "G", "A", "C"],
17
+ "Eb": ["Eb", "Gb", "Ab", "Bb", "Db"],
18
+ "E": ["E", "G", "A", "B", "D"],
19
+ "F": ["F", "Ab", "Bb", "C", "Eb"],
20
+ "F#": ["F#", "A", "B", "C#", "E"],
21
+ "G": ["G", "Bb", "C", "D", "F"],
22
+ "Ab": ["Ab", "B", "Db", "Eb", "Gb"],
23
+ "A": ["A", "C", "D", "E", "G"],
24
+ "Bb": ["Bb", "Db", "Eb", "F", "Ab"],
25
+ "B": ["B", "D", "E", "F#", "A"],
26
+ }
27
+
28
+ // ─── Pentatonic Major (5 notes) ──────────────────────────────────────────────
29
+ // Formula: 1 2 3 5 6
30
+
31
+ export const PENTATONIC_MAJOR = {
32
+ "C": ["C", "D", "E", "G", "A"],
33
+ "C#": ["C#", "D#", "F", "G#", "A#"],
34
+ "D": ["D", "E", "F#", "A", "B"],
35
+ "Eb": ["Eb", "F", "G", "Bb", "C"],
36
+ "E": ["E", "F#", "G#", "B", "C#"],
37
+ "F": ["F", "G", "A", "C", "D"],
38
+ "F#": ["F#", "G#", "A#", "C#", "D#"],
39
+ "G": ["G", "A", "B", "D", "E"],
40
+ "Ab": ["Ab", "Bb", "C", "Eb", "F"],
41
+ "A": ["A", "B", "C#", "E", "F#"],
42
+ "Bb": ["Bb", "C", "D", "F", "G"],
43
+ "B": ["B", "C#", "D#", "F#", "G#"],
44
+ }
45
+
46
+ // ─── Blues (6 notes) ─────────────────────────────────────────────────────────
47
+ // Formula: 1 b3 4 b5 5 b7
48
+
49
+ export const BLUES = {
50
+ "C": ["C", "Eb", "F", "Gb", "G", "Bb"],
51
+ "C#": ["C#", "E", "F#", "G", "G#", "B"],
52
+ "D": ["D", "F", "G", "Ab", "A", "C"],
53
+ "Eb": ["Eb", "Gb", "Ab", "A", "Bb", "Db"],
54
+ "E": ["E", "G", "A", "Bb", "B", "D"],
55
+ "F": ["F", "Ab", "Bb", "B", "C", "Eb"],
56
+ "F#": ["F#", "A", "B", "C", "C#", "E"],
57
+ "G": ["G", "Bb", "C", "Db", "D", "F"],
58
+ "Ab": ["Ab", "B", "Db", "D", "Eb", "Gb"],
59
+ "A": ["A", "C", "D", "Eb", "E", "G"],
60
+ "Bb": ["Bb", "Db", "Eb", "E", "F", "Ab"],
61
+ "B": ["B", "D", "E", "F", "F#", "A"],
62
+ }
63
+
64
+ // ─── Major / Ionian (7 notes) ────────────────────────────────────────────────
65
+ // Formula: 1 2 3 4 5 6 7
66
+
67
+ export const MAJOR = {
68
+ "C": ["C", "D", "E", "F", "G", "A", "B"],
69
+ "C#": ["C#", "D#", "F", "F#", "G#", "A#", "C"],
70
+ "D": ["D", "E", "F#", "G", "A", "B", "C#"],
71
+ "Eb": ["Eb", "F", "G", "Ab", "Bb", "C", "D"],
72
+ "E": ["E", "F#", "G#", "A", "B", "C#", "D#"],
73
+ "F": ["F", "G", "A", "Bb", "C", "D", "E"],
74
+ "F#": ["F#", "G#", "A#", "B", "C#", "D#", "F"],
75
+ "G": ["G", "A", "B", "C", "D", "E", "F#"],
76
+ "Ab": ["Ab", "Bb", "C", "Db", "Eb", "F", "G"],
77
+ "A": ["A", "B", "C#", "D", "E", "F#", "G#"],
78
+ "Bb": ["Bb", "C", "D", "Eb", "F", "G", "A"],
79
+ "B": ["B", "C#", "D#", "E", "F#", "G#", "A#"],
80
+ }
81
+
82
+ // ─── Natural Minor / Aeolian (7 notes) ───────────────────────────────────────
83
+ // Formula: 1 2 b3 4 5 b6 b7
84
+
85
+ export const NATURAL_MINOR = {
86
+ "C": ["C", "D", "Eb", "F", "G", "Ab", "Bb"],
87
+ "C#": ["C#", "D#", "E", "F#", "G#", "A", "B"],
88
+ "D": ["D", "E", "F", "G", "A", "Bb", "C"],
89
+ "Eb": ["Eb", "F", "Gb", "Ab", "Bb", "B", "Db"],
90
+ "E": ["E", "F#", "G", "A", "B", "C", "D"],
91
+ "F": ["F", "G", "Ab", "Bb", "C", "Db", "Eb"],
92
+ "F#": ["F#", "G#", "A", "B", "C#", "D", "E"],
93
+ "G": ["G", "A", "Bb", "C", "D", "Eb", "F"],
94
+ "Ab": ["Ab", "Bb", "B", "Db", "Eb", "E", "Gb"],
95
+ "A": ["A", "B", "C", "D", "E", "F", "G"],
96
+ "Bb": ["Bb", "C", "Db", "Eb", "F", "Gb", "Ab"],
97
+ "B": ["B", "C#", "D", "E", "F#", "G", "A"],
98
+ }
99
+
100
+ // ─── Dorian (7 notes) ────────────────────────────────────────────────────────
101
+ // Formula: 1 2 b3 4 5 6 b7
102
+
103
+ export const DORIAN = {
104
+ "C": ["C", "D", "Eb", "F", "G", "A", "Bb"],
105
+ "C#": ["C#", "D#", "E", "F#", "G#", "A#", "B"],
106
+ "D": ["D", "E", "F", "G", "A", "B", "C"],
107
+ "Eb": ["Eb", "F", "Gb", "Ab", "Bb", "C", "Db"],
108
+ "E": ["E", "F#", "G", "A", "B", "C#", "D"],
109
+ "F": ["F", "G", "Ab", "Bb", "C", "D", "Eb"],
110
+ "F#": ["F#", "G#", "A", "B", "C#", "D#", "E"],
111
+ "G": ["G", "A", "Bb", "C", "D", "E", "F"],
112
+ "Ab": ["Ab", "Bb", "B", "Db", "Eb", "F", "Gb"],
113
+ "A": ["A", "B", "C", "D", "E", "F#", "G"],
114
+ "Bb": ["Bb", "C", "Db", "Eb", "F", "G", "Ab"],
115
+ "B": ["B", "C#", "D", "E", "F#", "G#", "A"],
116
+ }
117
+
118
+ // ─── Phrygian (7 notes) ──────────────────────────────────────────────────────
119
+ // Formula: 1 b2 b3 4 5 b6 b7
120
+
121
+ export const PHRYGIAN = {
122
+ "C": ["C", "Db", "Eb", "F", "G", "Ab", "Bb"],
123
+ "C#": ["C#", "D", "E", "F#", "G#", "A", "B"],
124
+ "D": ["D", "Eb", "F", "G", "A", "Bb", "C"],
125
+ "Eb": ["Eb", "E", "Gb", "Ab", "Bb", "B", "Db"],
126
+ "E": ["E", "F", "G", "A", "B", "C", "D"],
127
+ "F": ["F", "Gb", "Ab", "Bb", "C", "Db", "Eb"],
128
+ "F#": ["F#", "G", "A", "B", "C#", "D", "E"],
129
+ "G": ["G", "Ab", "Bb", "C", "D", "Eb", "F"],
130
+ "Ab": ["Ab", "A", "B", "Db", "Eb", "E", "Gb"],
131
+ "A": ["A", "Bb", "C", "D", "E", "F", "G"],
132
+ "Bb": ["Bb", "B", "Db", "Eb", "F", "Gb", "Ab"],
133
+ "B": ["B", "C", "D", "E", "F#", "G", "A"],
134
+ }
135
+
136
+ // ─── Lydian (7 notes) ────────────────────────────────────────────────────────
137
+ // Formula: 1 2 3 #4 5 6 7
138
+
139
+ export const LYDIAN = {
140
+ "C": ["C", "D", "E", "F#", "G", "A", "B"],
141
+ "C#": ["C#", "D#", "F", "G", "G#", "A#", "C"],
142
+ "D": ["D", "E", "F#", "G#", "A", "B", "C#"],
143
+ "Eb": ["Eb", "F", "G", "A", "Bb", "C", "D"],
144
+ "E": ["E", "F#", "G#", "A#", "B", "C#", "D#"],
145
+ "F": ["F", "G", "A", "B", "C", "D", "E"],
146
+ "F#": ["F#", "G#", "A#", "C", "C#", "D#", "F"],
147
+ "G": ["G", "A", "B", "C#", "D", "E", "F#"],
148
+ "Ab": ["Ab", "Bb", "C", "D", "Eb", "F", "G"],
149
+ "A": ["A", "B", "C#", "D#", "E", "F#", "G#"],
150
+ "Bb": ["Bb", "C", "D", "E", "F", "G", "A"],
151
+ "B": ["B", "C#", "D#", "F", "F#", "G#", "A#"],
152
+ }
153
+
154
+ // ─── Mixolydian (7 notes) ────────────────────────────────────────────────────
155
+ // Formula: 1 2 3 4 5 6 b7
156
+
157
+ export const MIXOLYDIAN = {
158
+ "C": ["C", "D", "E", "F", "G", "A", "Bb"],
159
+ "C#": ["C#", "D#", "F", "F#", "G#", "A#", "B"],
160
+ "D": ["D", "E", "F#", "G", "A", "B", "C"],
161
+ "Eb": ["Eb", "F", "G", "Ab", "Bb", "C", "Db"],
162
+ "E": ["E", "F#", "G#", "A", "B", "C#", "D"],
163
+ "F": ["F", "G", "A", "Bb", "C", "D", "Eb"],
164
+ "F#": ["F#", "G#", "A#", "B", "C#", "D#", "E"],
165
+ "G": ["G", "A", "B", "C", "D", "E", "F"],
166
+ "Ab": ["Ab", "Bb", "C", "Db", "Eb", "F", "Gb"],
167
+ "A": ["A", "B", "C#", "D", "E", "F#", "G"],
168
+ "Bb": ["Bb", "C", "D", "Eb", "F", "G", "Ab"],
169
+ "B": ["B", "C#", "D#", "E", "F#", "G#", "A"],
170
+ }
171
+
172
+ // ─── Locrian (7 notes) ───────────────────────────────────────────────────────
173
+ // Formula: 1 b2 b3 4 b5 b6 b7
174
+
175
+ export const LOCRIAN = {
176
+ "C": ["C", "Db", "Eb", "F", "Gb", "Ab", "Bb"],
177
+ "C#": ["C#", "D", "E", "F#", "G", "A", "B"],
178
+ "D": ["D", "Eb", "F", "G", "Ab", "Bb", "C"],
179
+ "Eb": ["Eb", "E", "Gb", "Ab", "A", "B", "Db"],
180
+ "E": ["E", "F", "G", "A", "Bb", "C", "D"],
181
+ "F": ["F", "Gb", "Ab", "Bb", "B", "Db", "Eb"],
182
+ "F#": ["F#", "G", "A", "B", "C", "D", "E"],
183
+ "G": ["G", "Ab", "Bb", "C", "Db", "Eb", "F"],
184
+ "Ab": ["Ab", "A", "B", "Db", "D", "E", "Gb"],
185
+ "A": ["A", "Bb", "C", "D", "Eb", "F", "G"],
186
+ "Bb": ["Bb", "B", "Db", "Eb", "E", "Gb", "Ab"],
187
+ "B": ["B", "C", "D", "E", "F", "G", "A"],
188
+ }
189
+
190
+ // ─── Harmonic Minor (7 notes) ────────────────────────────────────────────────
191
+ // Formula: 1 2 b3 4 5 b6 7
192
+
193
+ export const HARMONIC_MINOR = {
194
+ "C": ["C", "D", "Eb", "F", "G", "Ab", "B"],
195
+ "C#": ["C#", "D#", "E", "F#", "G#", "A", "C"],
196
+ "D": ["D", "E", "F", "G", "A", "Bb", "C#"],
197
+ "Eb": ["Eb", "F", "Gb", "Ab", "Bb", "B", "D"],
198
+ "E": ["E", "F#", "G", "A", "B", "C", "D#"],
199
+ "F": ["F", "G", "Ab", "Bb", "C", "Db", "E"],
200
+ "F#": ["F#", "G#", "A", "B", "C#", "D", "F"],
201
+ "G": ["G", "A", "Bb", "C", "D", "Eb", "F#"],
202
+ "Ab": ["Ab", "Bb", "B", "Db", "Eb", "E", "G"],
203
+ "A": ["A", "B", "C", "D", "E", "F", "G#"],
204
+ "Bb": ["Bb", "C", "Db", "Eb", "F", "Gb", "A"],
205
+ "B": ["B", "C#", "D", "E", "F#", "G", "A#"],
206
+ }
207
+
208
+ // ─── Melodic Minor (7 notes) ─────────────────────────────────────────────────
209
+ // Formula: 1 2 b3 4 5 6 7
210
+
211
+ export const MELODIC_MINOR = {
212
+ "C": ["C", "D", "Eb", "F", "G", "A", "B"],
213
+ "C#": ["C#", "D#", "E", "F#", "G#", "A#", "C"],
214
+ "D": ["D", "E", "F", "G", "A", "B", "C#"],
215
+ "Eb": ["Eb", "F", "Gb", "Ab", "Bb", "C", "D"],
216
+ "E": ["E", "F#", "G", "A", "B", "C#", "D#"],
217
+ "F": ["F", "G", "Ab", "Bb", "C", "D", "E"],
218
+ "F#": ["F#", "G#", "A", "B", "C#", "D#", "F"],
219
+ "G": ["G", "A", "Bb", "C", "D", "E", "F#"],
220
+ "Ab": ["Ab", "Bb", "B", "Db", "Eb", "F", "G"],
221
+ "A": ["A", "B", "C", "D", "E", "F#", "G#"],
222
+ "Bb": ["Bb", "C", "Db", "Eb", "F", "G", "A"],
223
+ "B": ["B", "C#", "D", "E", "F#", "G#", "A#"],
224
+ }
225
+
226
+ // ─── Phrygian Dominant (7 notes) ─────────────────────────────────────────────
227
+ // Formula: 1 b2 3 4 5 b6 b7 (Spanish/metal feel)
228
+
229
+ export const PHRYGIAN_DOMINANT = {
230
+ "C": ["C", "Db", "E", "F", "G", "Ab", "Bb"],
231
+ "C#": ["C#", "D", "F", "F#", "G#", "A", "B"],
232
+ "D": ["D", "Eb", "F#", "G", "A", "Bb", "C"],
233
+ "Eb": ["Eb", "E", "G", "Ab", "Bb", "B", "Db"],
234
+ "E": ["E", "F", "G#", "A", "B", "C", "D"],
235
+ "F": ["F", "Gb", "A", "Bb", "C", "Db", "Eb"],
236
+ "F#": ["F#", "G", "A#", "B", "C#", "D", "E"],
237
+ "G": ["G", "Ab", "B", "C", "D", "Eb", "F"],
238
+ "Ab": ["Ab", "A", "C", "Db", "Eb", "E", "Gb"],
239
+ "A": ["A", "Bb", "C#", "D", "E", "F", "G"],
240
+ "Bb": ["Bb", "B", "D", "Eb", "F", "Gb", "Ab"],
241
+ "B": ["B", "C", "D#", "E", "F#", "G", "A"],
242
+ }
243
+
244
+ // ─── Whole Tone (6 notes) ────────────────────────────────────────────────────
245
+ // Formula: all whole steps — only 2 unique versions exist, rest are modes of same scale
246
+
247
+ export const WHOLE_TONE = {
248
+ "C": ["C", "D", "E", "F#", "G#", "A#"],
249
+ "C#": ["C#", "D#", "F", "G", "A", "B"],
250
+ "D": ["D", "E", "F#", "G#", "A#", "C"],
251
+ "Eb": ["Eb", "F", "G", "A", "B", "C#"],
252
+ "E": ["E", "F#", "G#", "A#", "C", "D"],
253
+ "F": ["F", "G", "A", "B", "C#", "D#"],
254
+ "F#": ["F#", "G#", "A#", "C", "D", "E"],
255
+ "G": ["G", "A", "B", "C#", "D#", "F"],
256
+ "Ab": ["Ab", "Bb", "C", "D", "E", "F#"],
257
+ "A": ["A", "B", "C#", "D#", "F", "G"],
258
+ "Bb": ["Bb", "C", "D", "E", "F#", "G#"],
259
+ "B": ["B", "C#", "D#", "F", "G", "A"],
260
+ }
261
+
262
+ // ─── Diminished (8 notes) ────────────────────────────────────────────────────
263
+ // Formula: alternating whole and half steps
264
+
265
+ export const DIMINISHED = {
266
+ "C": ["C", "D", "Eb", "F", "Gb", "Ab", "A", "B"],
267
+ "C#": ["C#", "D#", "E", "F#", "G", "A", "Bb", "C"],
268
+ "D": ["D", "E", "F", "G", "Ab", "Bb", "B", "C#"],
269
+ "Eb": ["Eb", "F", "Gb", "Ab", "A", "B", "C", "D"],
270
+ "E": ["E", "F#", "G", "A", "Bb", "C", "Db", "D#"],
271
+ "F": ["F", "G", "Ab", "Bb", "B", "Db", "D", "E"],
272
+ "F#": ["F#", "G#", "A", "B", "C", "D", "Eb", "F"],
273
+ "G": ["G", "A", "Bb", "C", "Db", "Eb", "E", "F#"],
274
+ "Ab": ["Ab", "Bb", "B", "Db", "D", "E", "F", "G"],
275
+ "A": ["A", "B", "C", "D", "Eb", "F", "F#", "G#"],
276
+ "Bb": ["Bb", "C", "Db", "Eb", "E", "F#", "G", "A"],
277
+ "B": ["B", "C#", "D", "E", "F", "G", "Ab", "A#"],
278
+ }
279
+
280
+ // ─── Convenience lookup ───────────────────────────────────────────────────────
281
+
282
+ export const SCALES = {
283
+ "major": MAJOR,
284
+ "natural_minor": NATURAL_MINOR,
285
+ "pentatonic_minor": PENTATONIC_MINOR,
286
+ "pentatonic_major": PENTATONIC_MAJOR,
287
+ "blues": BLUES,
288
+ "dorian": DORIAN,
289
+ "phrygian": PHRYGIAN,
290
+ "lydian": LYDIAN,
291
+ "mixolydian": MIXOLYDIAN,
292
+ "locrian": LOCRIAN,
293
+ "harmonic_minor": HARMONIC_MINOR,
294
+ "melodic_minor": MELODIC_MINOR,
295
+ "phrygian_dominant": PHRYGIAN_DOMINANT,
296
+ "whole_tone": WHOLE_TONE,
297
+ "diminished": DIMINISHED,
298
+ }
299
+
300
+ // list of all valid root keys
301
+ export const KEYS = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"]