@greenblacksky/honmoon 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -2
- package/package.json +58 -4
- package/src/lib/audio/context.ts +29 -0
- package/src/lib/audio/metronome.svelte.ts +146 -0
- package/src/lib/audio/mic.ts +254 -0
- package/src/lib/audio/pitch.ts +133 -0
- package/src/lib/audio/rhythmplay.ts +152 -0
- package/src/lib/audio/synth.ts +409 -0
- package/src/lib/audio/voiceplay.ts +60 -0
- package/src/lib/drills/defs.ts +459 -0
- package/src/lib/drills/questions.ts +920 -0
- package/src/lib/drills/registry.svelte.ts +167 -0
- package/src/lib/drills/runtime.svelte.ts +629 -0
- package/src/lib/drills/verdict.ts +241 -0
- package/src/lib/index.ts +117 -0
- package/src/lib/learn/describe.ts +72 -0
- package/src/lib/learn/pools.ts +44 -0
- package/src/lib/learn/progress.ts +100 -0
- package/src/lib/learn/session.ts +304 -0
- package/src/lib/music/bass.ts +139 -0
- package/src/lib/music/clefs.ts +218 -0
- package/src/lib/music/intervals.ts +94 -0
- package/src/lib/music/notes.ts +133 -0
- package/src/lib/music/rhythm.ts +624 -0
- package/src/lib/music/scales.ts +560 -0
- package/src/lib/music/voice.ts +227 -0
- package/src/lib/music/warmups.ts +318 -0
- package/src/lib/nav.svelte.ts +53 -0
- package/src/lib/notation/fretboard.ts +187 -0
- package/src/lib/notation/glyphdata.ts +101 -0
- package/src/lib/notation/glyphs.ts +71 -0
- package/src/lib/notation/icons.ts +12 -0
- package/src/lib/notation/keyboard.ts +166 -0
- package/src/lib/notation/rhythmsvg.ts +341 -0
- package/src/lib/notation/staff.ts +288 -0
- package/src/lib/notation/svg.ts +37 -0
- package/src/lib/notation/tab.ts +80 -0
- package/src/lib/store/honmoon.svelte.ts +329 -0
- package/src/lib/store/normalize.ts +23 -0
- package/src/lib/store/sync.ts +43 -0
- package/src/lib/store/types.ts +101 -0
- package/src/lib/styles/honmoon.css +2064 -0
- package/src/lib/ui/AccRow.svelte +23 -0
- package/src/lib/ui/BackBar.svelte +23 -0
- package/src/lib/ui/BassRow.svelte +30 -0
- package/src/lib/ui/Chips.svelte +23 -0
- package/src/lib/ui/DataCard.svelte +66 -0
- package/src/lib/ui/Fretboard.svelte +71 -0
- package/src/lib/ui/Keyboard.svelte +54 -0
- package/src/lib/ui/MetronomePanel.svelte +101 -0
- package/src/lib/ui/RefBar.svelte +28 -0
- package/src/lib/ui/RhythmBar.svelte +9 -0
- package/src/lib/ui/Shell.svelte +96 -0
- package/src/lib/ui/StaffRun.svelte +46 -0
- package/src/lib/ui/Stat.svelte +13 -0
- package/src/lib/ui/actions.ts +36 -0
- package/src/lib/ui/keyfollow.svelte.ts +48 -0
- package/src/lib/ui/overlay.ts +15 -0
- package/src/lib/ui/refdata.ts +355 -0
- package/src/lib/ui/scaleboard.ts +420 -0
- package/src/lib/ui/screens/DegreesRef.svelte +405 -0
- package/src/lib/ui/screens/DrillScreen.svelte +1155 -0
- package/src/lib/ui/screens/Explorer.svelte +245 -0
- package/src/lib/ui/screens/FamilyScreen.svelte +280 -0
- package/src/lib/ui/screens/Glossary.svelte +65 -0
- package/src/lib/ui/screens/IntervalRef.svelte +37 -0
- package/src/lib/ui/screens/LearnIndex.svelte +44 -0
- package/src/lib/ui/screens/MetronomeScreen.svelte +17 -0
- package/src/lib/ui/screens/PracticeHome.svelte +159 -0
- package/src/lib/ui/screens/RhythmRef.svelte +239 -0
- package/src/lib/ui/screens/ScalesScreen.svelte +210 -0
- package/src/lib/ui/screens/SessionDone.svelte +119 -0
- package/src/lib/ui/screens/SetupScreen.svelte +548 -0
- package/src/lib/ui/screens/StaffMap.svelte +230 -0
- package/src/lib/ui/screens/StatsScreen.svelte +478 -0
- package/src/lib/ui/screens/TapCalibration.svelte +186 -0
- package/src/lib/ui/tapfix.ts +93 -0
- package/src/lib/ui/types.ts +40 -0
- package/src/lib/util/list.ts +4 -0
- package/src/lib/util/random.ts +23 -0
- package/src/lib/util/text.ts +50 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { honmoon } from '../store/honmoon.svelte';
|
|
2
|
+
import { LETTERS, NATPC, SEMITONES_PER_OCTAVE, pcOf } from './notes';
|
|
3
|
+
import { STRINGS } from './bass';
|
|
4
|
+
import { rnd, shuffle } from '../util/random';
|
|
5
|
+
|
|
6
|
+
export const ROOTS = [
|
|
7
|
+
{ pc: 0, l: 'C', a: 0 },
|
|
8
|
+
{ pc: 1, l: 'D', a: -1 },
|
|
9
|
+
{ pc: 2, l: 'D', a: 0 },
|
|
10
|
+
{ pc: 3, l: 'E', a: -1 },
|
|
11
|
+
{ pc: 4, l: 'E', a: 0 },
|
|
12
|
+
{ pc: 5, l: 'F', a: 0 },
|
|
13
|
+
{ pc: 6, l: 'F', a: 1 },
|
|
14
|
+
{ pc: 7, l: 'G', a: 0 },
|
|
15
|
+
{ pc: 8, l: 'A', a: -1 },
|
|
16
|
+
{ pc: 9, l: 'A', a: 0 },
|
|
17
|
+
{ pc: 10, l: 'B', a: -1 },
|
|
18
|
+
{ pc: 11, l: 'B', a: 0 },
|
|
19
|
+
];
|
|
20
|
+
const NATURAL_ROOTS = [0, 2, 4, 5, 7, 9, 11];
|
|
21
|
+
const ALL_ROOTS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
|
22
|
+
|
|
23
|
+
export interface ScaleDef {
|
|
24
|
+
id: string;
|
|
25
|
+
/** group heading it is listed under */
|
|
26
|
+
g: string;
|
|
27
|
+
/** name */
|
|
28
|
+
n: string;
|
|
29
|
+
/** semitones above the root, one per note */
|
|
30
|
+
iv: number[];
|
|
31
|
+
/** the degree of each note, written with b and # ("b3", "#4") */
|
|
32
|
+
dg: string[];
|
|
33
|
+
/** 1 when switched on by default */
|
|
34
|
+
on: 0 | 1;
|
|
35
|
+
/** one-line description */
|
|
36
|
+
d: string;
|
|
37
|
+
}
|
|
38
|
+
export const SCALES: ScaleDef[] = [
|
|
39
|
+
{
|
|
40
|
+
id: 'minpent',
|
|
41
|
+
g: 'Pentatonics',
|
|
42
|
+
n: 'Minor pentatonic',
|
|
43
|
+
iv: [0, 3, 5, 7, 10],
|
|
44
|
+
dg: ['1', 'b3', '4', '5', 'b7'],
|
|
45
|
+
on: 1,
|
|
46
|
+
d: 'Five notes and none of them sound wrong. The backbone of rock, blues and most bass fills.',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'majpent',
|
|
50
|
+
g: 'Pentatonics',
|
|
51
|
+
n: 'Major pentatonic',
|
|
52
|
+
iv: [0, 2, 4, 7, 9],
|
|
53
|
+
dg: ['1', '2', '3', '5', '6'],
|
|
54
|
+
on: 1,
|
|
55
|
+
d: 'The same shape as minor pentatonic three frets lower. Bright, country, pop, gospel.',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'blues',
|
|
59
|
+
g: 'Pentatonics',
|
|
60
|
+
n: 'Blues scale',
|
|
61
|
+
iv: [0, 3, 5, 6, 7, 10],
|
|
62
|
+
dg: ['1', 'b3', '4', 'b5', '5', 'b7'],
|
|
63
|
+
on: 1,
|
|
64
|
+
d: 'Minor pentatonic plus the flat five. One extra note and all the attitude.',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'major',
|
|
68
|
+
g: 'Major & minor',
|
|
69
|
+
n: 'Major',
|
|
70
|
+
iv: [0, 2, 4, 5, 7, 9, 11],
|
|
71
|
+
dg: ['1', '2', '3', '4', '5', '6', '7'],
|
|
72
|
+
on: 1,
|
|
73
|
+
d: 'The do-re-mi scale. Everything else in music is described by how it differs from this.',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'natmin',
|
|
77
|
+
g: 'Major & minor',
|
|
78
|
+
n: 'Natural minor',
|
|
79
|
+
iv: [0, 2, 3, 5, 7, 8, 10],
|
|
80
|
+
dg: ['1', '2', 'b3', '4', '5', 'b6', 'b7'],
|
|
81
|
+
on: 1,
|
|
82
|
+
d: 'The sad one, also called Aeolian. Exactly the notes of the major scale a minor third above.',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'harmmin',
|
|
86
|
+
g: 'Major & minor',
|
|
87
|
+
n: 'Harmonic minor',
|
|
88
|
+
iv: [0, 2, 3, 5, 7, 8, 11],
|
|
89
|
+
dg: ['1', '2', 'b3', '4', '5', 'b6', '7'],
|
|
90
|
+
on: 0,
|
|
91
|
+
d: 'Natural minor with the seventh raised, which opens a gap you can hear. Classical and neoclassical metal.',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'melmin',
|
|
95
|
+
g: 'Major & minor',
|
|
96
|
+
n: 'Melodic minor',
|
|
97
|
+
iv: [0, 2, 3, 5, 7, 9, 11],
|
|
98
|
+
dg: ['1', '2', 'b3', '4', '5', '6', '7'],
|
|
99
|
+
on: 0,
|
|
100
|
+
d: 'Minor underneath, major on top. Mostly a jazz scale.',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'dorian',
|
|
104
|
+
g: 'Modes',
|
|
105
|
+
n: 'Dorian',
|
|
106
|
+
iv: [0, 2, 3, 5, 7, 9, 10],
|
|
107
|
+
dg: ['1', '2', 'b3', '4', '5', '6', 'b7'],
|
|
108
|
+
on: 1,
|
|
109
|
+
d: 'Minor with a raised sixth. Funk, soul and most modal jamming lives here.',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: 'mixo',
|
|
113
|
+
g: 'Modes',
|
|
114
|
+
n: 'Mixolydian',
|
|
115
|
+
iv: [0, 2, 4, 5, 7, 9, 10],
|
|
116
|
+
dg: ['1', '2', '3', '4', '5', '6', 'b7'],
|
|
117
|
+
on: 1,
|
|
118
|
+
d: 'Major with a flat seventh — the sound of a dominant chord. Blues, rock, funk.',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'phryg',
|
|
122
|
+
g: 'Modes',
|
|
123
|
+
n: 'Phrygian',
|
|
124
|
+
iv: [0, 1, 3, 5, 7, 8, 10],
|
|
125
|
+
dg: ['1', 'b2', 'b3', '4', '5', 'b6', 'b7'],
|
|
126
|
+
on: 0,
|
|
127
|
+
d: 'Minor with a flat second. Spanish music and a great deal of metal.',
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: 'lydian',
|
|
131
|
+
g: 'Modes',
|
|
132
|
+
n: 'Lydian',
|
|
133
|
+
iv: [0, 2, 4, 6, 7, 9, 11],
|
|
134
|
+
dg: ['1', '2', '3', '#4', '5', '6', '7'],
|
|
135
|
+
on: 0,
|
|
136
|
+
d: 'Major with a raised fourth. Floating and unresolved — film scores love it.',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'locrian',
|
|
140
|
+
g: 'Modes',
|
|
141
|
+
n: 'Locrian',
|
|
142
|
+
iv: [0, 1, 3, 5, 6, 8, 10],
|
|
143
|
+
dg: ['1', 'b2', 'b3', '4', 'b5', 'b6', 'b7'],
|
|
144
|
+
on: 0,
|
|
145
|
+
d: 'Half-diminished and restless. Rare in practice, but it completes the set.',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: 'maj',
|
|
149
|
+
g: 'Arpeggios',
|
|
150
|
+
n: 'Major triad',
|
|
151
|
+
iv: [0, 4, 7],
|
|
152
|
+
dg: ['1', '3', '5'],
|
|
153
|
+
on: 1,
|
|
154
|
+
d: 'Three notes that spell a major chord. Most bass lines are made of these, not scales.',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: 'min',
|
|
158
|
+
g: 'Arpeggios',
|
|
159
|
+
n: 'Minor triad',
|
|
160
|
+
iv: [0, 3, 7],
|
|
161
|
+
dg: ['1', 'b3', '5'],
|
|
162
|
+
on: 1,
|
|
163
|
+
d: 'The minor chord, note for note.',
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: 'r5',
|
|
167
|
+
g: 'Arpeggios',
|
|
168
|
+
n: 'Root–fifth–octave',
|
|
169
|
+
iv: [0, 7, 12],
|
|
170
|
+
dg: ['1', '5', '8'],
|
|
171
|
+
on: 1,
|
|
172
|
+
d: 'The two-note bassline that works over almost any chord. Learn it in every key first.',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'dom7',
|
|
176
|
+
g: 'Arpeggios',
|
|
177
|
+
n: 'Dominant 7',
|
|
178
|
+
iv: [0, 4, 7, 10],
|
|
179
|
+
dg: ['1', '3', '5', 'b7'],
|
|
180
|
+
on: 0,
|
|
181
|
+
d: 'A major triad with a flat seventh on top. The engine of blues and funk.',
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: 'min7',
|
|
185
|
+
g: 'Arpeggios',
|
|
186
|
+
n: 'Minor 7',
|
|
187
|
+
iv: [0, 3, 7, 10],
|
|
188
|
+
dg: ['1', 'b3', '5', 'b7'],
|
|
189
|
+
on: 0,
|
|
190
|
+
d: 'The smooth one. Soul, neo-soul, half of all jazz.',
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: 'maj7',
|
|
194
|
+
g: 'Arpeggios',
|
|
195
|
+
n: 'Major 7',
|
|
196
|
+
iv: [0, 4, 7, 11],
|
|
197
|
+
dg: ['1', '3', '5', '7'],
|
|
198
|
+
on: 0,
|
|
199
|
+
d: 'Lush and slightly wistful. Bossa nova, ballads.',
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'dim',
|
|
203
|
+
g: 'Arpeggios',
|
|
204
|
+
n: 'Diminished triad',
|
|
205
|
+
iv: [0, 3, 6],
|
|
206
|
+
dg: ['1', 'b3', 'b5'],
|
|
207
|
+
on: 0,
|
|
208
|
+
d: 'Tense and unstable, built from two minor thirds.',
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: 'm7b5',
|
|
212
|
+
g: 'Arpeggios',
|
|
213
|
+
n: 'Half-diminished',
|
|
214
|
+
iv: [0, 3, 6, 10],
|
|
215
|
+
dg: ['1', 'b3', 'b5', 'b7'],
|
|
216
|
+
on: 0,
|
|
217
|
+
d: 'The chord before a minor resolution in jazz.',
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
id: 'aug',
|
|
221
|
+
g: 'Other',
|
|
222
|
+
n: 'Augmented triad',
|
|
223
|
+
iv: [0, 4, 8],
|
|
224
|
+
dg: ['1', '3', '#5'],
|
|
225
|
+
on: 0,
|
|
226
|
+
d: 'Two major thirds stacked. Symmetrical, so it has no home key.',
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: 'whole',
|
|
230
|
+
g: 'Other',
|
|
231
|
+
n: 'Whole tone',
|
|
232
|
+
iv: [0, 2, 4, 6, 8, 10],
|
|
233
|
+
dg: ['1', '2', '3', '#4', '#5', '#6'],
|
|
234
|
+
on: 0,
|
|
235
|
+
d: 'Every step the same size. Dreamlike and directionless.',
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
export const SGROUPS = ['Pentatonics', 'Major & minor', 'Modes', 'Arpeggios', 'Other'];
|
|
239
|
+
|
|
240
|
+
export function scaleDef(id: string): ScaleDef {
|
|
241
|
+
return SCALES.find((scale) => scale.id === id) ?? (null as unknown as ScaleDef);
|
|
242
|
+
}
|
|
243
|
+
export function rootDef(pc: number) {
|
|
244
|
+
return ROOTS[pcOf(pc)];
|
|
245
|
+
}
|
|
246
|
+
const ACCIDENTAL_GLYPHS: Record<number, string> = { 1: '♯', 2: '♯♯', [-1]: '♭', [-2]: '♭♭' };
|
|
247
|
+
export function accG(alteration: number): string {
|
|
248
|
+
return ACCIDENTAL_GLYPHS[alteration] ?? '';
|
|
249
|
+
}
|
|
250
|
+
export function rootName(pc: number): string {
|
|
251
|
+
const root = rootDef(pc);
|
|
252
|
+
return root.l + accG(root.a);
|
|
253
|
+
}
|
|
254
|
+
export function degPretty(degree: string): string {
|
|
255
|
+
return degree.replace(/b/g, '♭').replace(/#/g, '♯');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** One note of a scale in a key: semitones above the root, pitch class, its
|
|
259
|
+
degree ("b3"), and how it is spelled (letter `l`, alteration `a`). */
|
|
260
|
+
export interface ScaleNote {
|
|
261
|
+
semis: number;
|
|
262
|
+
pc: number;
|
|
263
|
+
deg: string;
|
|
264
|
+
l: string;
|
|
265
|
+
a: number;
|
|
266
|
+
moved?: boolean;
|
|
267
|
+
}
|
|
268
|
+
const LETTERS_PER_OCTAVE = 7;
|
|
269
|
+
/** Difference between two pitch classes folded into -6 … +5: how far a
|
|
270
|
+
letter's natural note has to be raised (+) or lowered (-) to reach a pitch. */
|
|
271
|
+
function alterationBetween(naturalPc: number, targetPc: number): number {
|
|
272
|
+
return ((targetPc - naturalPc + 18) % SEMITONES_PER_OCTAVE) - 6;
|
|
273
|
+
}
|
|
274
|
+
/** Spell each degree by letter, so F major gets B♭ and B major gets D♯.
|
|
275
|
+
The degree number fixes the letter; the accidental is whatever makes that
|
|
276
|
+
letter land on the right pitch. */
|
|
277
|
+
export function scaleNotes(id: string, rootPc: number): ScaleNote[] {
|
|
278
|
+
const scale = scaleDef(id);
|
|
279
|
+
const rootLetterIndex = LETTERS.indexOf(rootDef(rootPc).l);
|
|
280
|
+
return scale.iv.map((semis, i) => {
|
|
281
|
+
const pc = pcOf(rootPc + semis);
|
|
282
|
+
const degreeNumber = +scale.dg[i].match(/^([b#]*)(\d+)$/)![2];
|
|
283
|
+
const letterIndex = (rootLetterIndex + degreeNumber - 1) % LETTERS_PER_OCTAVE;
|
|
284
|
+
return {
|
|
285
|
+
semis,
|
|
286
|
+
pc,
|
|
287
|
+
deg: scale.dg[i],
|
|
288
|
+
l: LETTERS[letterIndex],
|
|
289
|
+
a: alterationBetween(NATPC[letterIndex], pc),
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
export function noteText(note: { l: string | null; a: number | null }): string {
|
|
294
|
+
return note.l === null ? '' : note.l + accG(note.a || 0);
|
|
295
|
+
}
|
|
296
|
+
export function scaleName(id: string, rootPc: number): string {
|
|
297
|
+
return rootName(rootPc) + ' ' + scaleDef(id).n.toLowerCase();
|
|
298
|
+
}
|
|
299
|
+
export function scaleOn(id: string): boolean {
|
|
300
|
+
return (honmoon.settings.sen || []).includes(id);
|
|
301
|
+
}
|
|
302
|
+
export function enabledScales(): ScaleDef[] {
|
|
303
|
+
return SCALES.filter((scale) => scaleOn(scale.id));
|
|
304
|
+
}
|
|
305
|
+
export function rootPool(): number[] {
|
|
306
|
+
return honmoon.settings.sroots === 'all' ? [...ALL_ROOTS] : [...NATURAL_ROOTS];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** String indices (0 = G, top row) of the strings a scale can start on. */
|
|
310
|
+
const E_STRING = 3;
|
|
311
|
+
const A_STRING = 2;
|
|
312
|
+
function startPool(): number[] {
|
|
313
|
+
const startSetting = honmoon.settings.sstart;
|
|
314
|
+
if (startSetting === 'E') {
|
|
315
|
+
return [E_STRING];
|
|
316
|
+
}
|
|
317
|
+
if (startSetting === 'A') {
|
|
318
|
+
return [A_STRING];
|
|
319
|
+
}
|
|
320
|
+
return [E_STRING, A_STRING];
|
|
321
|
+
}
|
|
322
|
+
export function randStart(): number {
|
|
323
|
+
const strings = startPool();
|
|
324
|
+
return strings[rnd(strings.length)];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** One note of a fingered shape: string `s`, fret `f`, finger `fin` (0 = open),
|
|
328
|
+
the scale note it plays, and whether the hand shifted to reach it. */
|
|
329
|
+
export interface ShapePos {
|
|
330
|
+
s: number;
|
|
331
|
+
f: number;
|
|
332
|
+
fin: number;
|
|
333
|
+
deg: ScaleNote;
|
|
334
|
+
shift: boolean;
|
|
335
|
+
}
|
|
336
|
+
export interface Shape {
|
|
337
|
+
anchor: number;
|
|
338
|
+
pos: ShapePos[];
|
|
339
|
+
clean: boolean;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The hand covers four frets: the anchor fret and the three above it. */
|
|
343
|
+
const HAND_SPAN = 3;
|
|
344
|
+
/** Nothing in a shape is placed above this fret. */
|
|
345
|
+
const HIGHEST_SHAPE_FRET = 17;
|
|
346
|
+
|
|
347
|
+
/** The semitone offsets from the root (over the given number of octaves,
|
|
348
|
+
ending on the top octave) and the scale note each one plays. */
|
|
349
|
+
function shapeSteps(id: string, rootPc: number, octaves: number) {
|
|
350
|
+
const scale = scaleDef(id);
|
|
351
|
+
const notes = scaleNotes(id, rootPc);
|
|
352
|
+
const offsets: number[] = [];
|
|
353
|
+
const degrees: ScaleNote[] = [];
|
|
354
|
+
for (let octave = 0; octave < octaves; octave++) {
|
|
355
|
+
for (let i = 0; i < scale.iv.length; i++) {
|
|
356
|
+
/* a scale whose list ends on 12 already carries its octave */
|
|
357
|
+
if (scale.iv[i] === 12 && octave < octaves - 1) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
offsets.push(scale.iv[i] + 12 * octave);
|
|
361
|
+
degrees.push(notes[i]);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (offsets[offsets.length - 1] !== 12 * octaves) {
|
|
365
|
+
offsets.push(12 * octaves);
|
|
366
|
+
degrees.push(notes[0]);
|
|
367
|
+
}
|
|
368
|
+
return { offsets, degrees };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** A playable box: anchor the hand, stay on a string until the next note
|
|
372
|
+
leaves the four-fret window, then cross. Try the anchors a bass player
|
|
373
|
+
would actually try, nearest first. */
|
|
374
|
+
export function scaleShape(id: string, rootPc: number, startString: number, octaves = 1): Shape {
|
|
375
|
+
const { offsets, degrees } = shapeSteps(id, rootPc, octaves);
|
|
376
|
+
const startOpen = STRINGS[startString].open;
|
|
377
|
+
const rootFret = pcOf(rootPc - pcOf(startOpen));
|
|
378
|
+
/** fret on `string` that plays the note `offset` semitones above the root */
|
|
379
|
+
const fretFor = (string: number, offset: number) =>
|
|
380
|
+
rootFret + offset - (STRINGS[string].open - startOpen);
|
|
381
|
+
|
|
382
|
+
/* An open string is free: it needs no finger, so it is playable from any
|
|
383
|
+
hand position. That is why a scale rooted on an open string can sit in a
|
|
384
|
+
window further up the neck than its own root. */
|
|
385
|
+
const inWindow = (fret: number, anchor: number) =>
|
|
386
|
+
fret === 0 || (fret >= anchor && fret <= anchor + HAND_SPAN);
|
|
387
|
+
|
|
388
|
+
function placeFrom(anchor: number, allowShift: boolean): Shape | null {
|
|
389
|
+
let string = startString;
|
|
390
|
+
let shiftedAtAll = false;
|
|
391
|
+
const out: ShapePos[] = [];
|
|
392
|
+
for (let k = 0; k < offsets.length; k++) {
|
|
393
|
+
let fret = fretFor(string, offsets[k]);
|
|
394
|
+
// move to a higher string while the note is past the hand and reachable there
|
|
395
|
+
while (fret > anchor + HAND_SPAN && string > 0 && fretFor(string - 1, offsets[k]) >= 0) {
|
|
396
|
+
string--;
|
|
397
|
+
fret = fretFor(string, offsets[k]);
|
|
398
|
+
}
|
|
399
|
+
let shiftedHere = false;
|
|
400
|
+
if (!inWindow(fret, anchor)) {
|
|
401
|
+
if (!allowShift) {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
anchor = Math.max(0, fret - HAND_SPAN);
|
|
405
|
+
shiftedHere = true;
|
|
406
|
+
shiftedAtAll = true;
|
|
407
|
+
if (!inWindow(fret, anchor)) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (fret < 0 || fret > HIGHEST_SHAPE_FRET) {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
const finger = fret === 0 ? 0 : fret - anchor + 1;
|
|
415
|
+
out.push({ s: string, f: fret, fin: finger, deg: degrees[k], shift: shiftedHere });
|
|
416
|
+
}
|
|
417
|
+
return { anchor, pos: out, clean: !shiftedAtAll };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* the root under the first finger, then the hand a fret or three lower,
|
|
421
|
+
then a fret or two higher */
|
|
422
|
+
const anchors = [
|
|
423
|
+
rootFret,
|
|
424
|
+
rootFret - 1,
|
|
425
|
+
rootFret - 2,
|
|
426
|
+
rootFret - 3,
|
|
427
|
+
rootFret + 1,
|
|
428
|
+
rootFret + 2,
|
|
429
|
+
].filter((anchor, i, all) => anchor >= 0 && all.indexOf(anchor) === i);
|
|
430
|
+
for (const anchor of anchors) {
|
|
431
|
+
const shape = placeFrom(anchor, false);
|
|
432
|
+
if (shape) {
|
|
433
|
+
return shape;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
for (const anchor of anchors) {
|
|
437
|
+
const shape = placeFrom(anchor, true);
|
|
438
|
+
if (shape) {
|
|
439
|
+
return shape;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return placeFrom(Math.max(0, rootFret), true) || { anchor: rootFret, pos: [], clean: false };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** The gaps between consecutive notes of a scale, in semitones, closing the
|
|
446
|
+
loop back to the octave. They always add up to twelve. */
|
|
447
|
+
export function scaleSteps(scaleId: string): number[] {
|
|
448
|
+
const intervals = scaleDef(scaleId).iv;
|
|
449
|
+
return intervals.map((semis, i) => (i + 1 < intervals.length ? intervals[i + 1] : 12) - semis);
|
|
450
|
+
}
|
|
451
|
+
/** Named in whole steps, the vocabulary the explorer teaches, rather than in
|
|
452
|
+
semitones — "7 semitones" was both longer than its slot and in the wrong
|
|
453
|
+
unit for what is being explained. */
|
|
454
|
+
const STEP_WORDS: Record<number, string> = {
|
|
455
|
+
0: '',
|
|
456
|
+
1: 'half',
|
|
457
|
+
2: 'whole',
|
|
458
|
+
3: '1½',
|
|
459
|
+
4: 'two',
|
|
460
|
+
5: '2½',
|
|
461
|
+
6: 'three',
|
|
462
|
+
7: '3½',
|
|
463
|
+
8: 'four',
|
|
464
|
+
};
|
|
465
|
+
export function stepWord(semitones: number): string {
|
|
466
|
+
return STEP_WORDS[semitones] ?? semitones / 2 + ' steps';
|
|
467
|
+
}
|
|
468
|
+
/** Four notes or fewer is a chord played one note at a time, and asking
|
|
469
|
+
"which scale" of a triad reads like a trick question. */
|
|
470
|
+
export function scaleNoun(scaleId: string): 'arpeggio' | 'scale' {
|
|
471
|
+
return scaleDef(scaleId).iv.length <= 4 ? 'arpeggio' : 'scale';
|
|
472
|
+
}
|
|
473
|
+
/** The fret halfway across the fretted (non-open) notes of a shape. */
|
|
474
|
+
export function midFret(positions: { f: number }[]): number {
|
|
475
|
+
const frets = positions.filter((pos) => pos.f > 0).map((pos) => pos.f);
|
|
476
|
+
if (!frets.length) {
|
|
477
|
+
return 0;
|
|
478
|
+
}
|
|
479
|
+
return Math.round((Math.min(...frets) + Math.max(...frets)) / 2);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/* ---- scale cards: one per scale in one key ---- */
|
|
483
|
+
export function scaleCards(): string[] {
|
|
484
|
+
const roots = rootPool();
|
|
485
|
+
return enabledScales().flatMap((scale) => roots.map((root) => 'sc:' + scale.id + ':' + root));
|
|
486
|
+
}
|
|
487
|
+
export function parseSC(id: string): { sid: string; root: number } {
|
|
488
|
+
const parts = id.split(':');
|
|
489
|
+
return { sid: parts[1], root: +parts[2] };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Whether a note may be moved by `direction` (+1 or -1) without giving a
|
|
493
|
+
spelling no one would write (E♯, B♯, C♭, F♭, or a double accidental). */
|
|
494
|
+
function spellableMove(note: ScaleNote, direction: number): boolean {
|
|
495
|
+
const alteration = (note.a || 0) + direction;
|
|
496
|
+
if (Math.abs(alteration) > 1) {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
if (alteration === 1 && (note.l === 'E' || note.l === 'B')) {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
if (alteration === -1 && (note.l === 'C' || note.l === 'F')) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
/** One note of a scale, other than the root, moved a fret up or down on its own
|
|
508
|
+
letter: B♭ to B, C to C♯. Never onto E♯, B♯, C♭ or F♭, and never a double
|
|
509
|
+
sharp or flat — a wrong answer should be wrong in its notes, not in its
|
|
510
|
+
spelling. */
|
|
511
|
+
export function alterOne(sid: string, root: number): { k: number; dir: number } | null {
|
|
512
|
+
const notes = scaleNotes(sid, root);
|
|
513
|
+
const candidates: { k: number; dir: number }[] = [];
|
|
514
|
+
for (let k = 1; k < notes.length; k++) {
|
|
515
|
+
if (notes[k].semis >= 12) {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
for (const dir of [1, -1]) {
|
|
519
|
+
if (!spellableMove(notes[k], dir)) {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const semis = notes[k].semis + dir;
|
|
523
|
+
const collides = notes.some((other, j) => j !== k && pcOf(other.semis - semis) === 0);
|
|
524
|
+
if (collides || semis <= 0 || semis >= 12) {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
candidates.push({ k, dir });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return candidates.length ? candidates[rnd(candidates.length)] : null;
|
|
531
|
+
}
|
|
532
|
+
/** A scale's spelling as "letter+alteration" pairs ("B-1 C0 …"), with an
|
|
533
|
+
optional altered note — two scales that sound alike compare equal only if
|
|
534
|
+
they are also written alike. */
|
|
535
|
+
export function staffSig(
|
|
536
|
+
scale: { sid: string; alter?: { k: number; dir: number } | null },
|
|
537
|
+
root: number,
|
|
538
|
+
): string {
|
|
539
|
+
return scaleNotes(scale.sid, root)
|
|
540
|
+
.map((note, k) => {
|
|
541
|
+
let alteration = note.a || 0;
|
|
542
|
+
if (scale.alter && scale.alter.k === k) {
|
|
543
|
+
alteration += scale.alter.dir;
|
|
544
|
+
}
|
|
545
|
+
return note.l + alteration;
|
|
546
|
+
})
|
|
547
|
+
.join(' ');
|
|
548
|
+
}
|
|
549
|
+
/** Other scales to offer as wrong answers, best first: the same number of notes
|
|
550
|
+
(so the answer cannot be had by counting), and among those the ones you have
|
|
551
|
+
switched on (so you are tested on what you are learning), then the rest. */
|
|
552
|
+
export function scaleAlts(sid: string): ScaleDef[] {
|
|
553
|
+
const noteCount = scaleDef(sid).iv.length;
|
|
554
|
+
const offRanksLast = (scale: ScaleDef) => (scaleOn(scale.id) ? 0 : 1);
|
|
555
|
+
const sameSize = shuffle(SCALES.filter((x) => x.id !== sid && x.iv.length === noteCount));
|
|
556
|
+
const otherSize = shuffle(SCALES.filter((x) => x.id !== sid && x.iv.length !== noteCount));
|
|
557
|
+
sameSize.sort((a, b) => offRanksLast(a) - offRanksLast(b));
|
|
558
|
+
otherSize.sort((a, b) => offRanksLast(a) - offRanksLast(b));
|
|
559
|
+
return sameSize.concat(otherSize);
|
|
560
|
+
}
|