@greenblacksky/honmoon 0.0.0-stage → 0.4.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.
- package/LICENSE +21 -0
- package/README.md +85 -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,920 @@
|
|
|
1
|
+
import { honmoon } from '../store/honmoon.svelte';
|
|
2
|
+
import type { ClefId, Sys } from '../store/types';
|
|
3
|
+
import { degName, mdoName, noteName, otherSys, pcOf, relOf } from '../music/notes';
|
|
4
|
+
import { STRINGS, findPos, placesFor, positions, type FretPos } from '../music/bass';
|
|
5
|
+
import {
|
|
6
|
+
enabledClefs,
|
|
7
|
+
midiForPcInClef,
|
|
8
|
+
staffNotes,
|
|
9
|
+
staffNotesFor,
|
|
10
|
+
type StaffNote,
|
|
11
|
+
} from '../music/clefs';
|
|
12
|
+
import { chDef, chPool, ivDef, ivPool } from '../music/intervals';
|
|
13
|
+
import { vRange } from '../music/voice';
|
|
14
|
+
import {
|
|
15
|
+
barCounting,
|
|
16
|
+
barGridStep,
|
|
17
|
+
beatsText,
|
|
18
|
+
gcd2,
|
|
19
|
+
restName,
|
|
20
|
+
rhCards,
|
|
21
|
+
rhMakeBar,
|
|
22
|
+
rhValues,
|
|
23
|
+
rhVariants,
|
|
24
|
+
rlevN,
|
|
25
|
+
rvalDef,
|
|
26
|
+
type Bar,
|
|
27
|
+
type RhEvent,
|
|
28
|
+
type TapGrade,
|
|
29
|
+
} from '../music/rhythm';
|
|
30
|
+
import {
|
|
31
|
+
SCALES,
|
|
32
|
+
alterOne,
|
|
33
|
+
degPretty,
|
|
34
|
+
enabledScales,
|
|
35
|
+
noteText,
|
|
36
|
+
parseSC,
|
|
37
|
+
randStart,
|
|
38
|
+
scaleAlts,
|
|
39
|
+
scaleCards,
|
|
40
|
+
scaleDef,
|
|
41
|
+
scaleName,
|
|
42
|
+
scaleNotes,
|
|
43
|
+
scaleOn,
|
|
44
|
+
scaleShape,
|
|
45
|
+
scaleSteps,
|
|
46
|
+
staffSig,
|
|
47
|
+
type Shape,
|
|
48
|
+
type ShapePos,
|
|
49
|
+
} from '../music/scales';
|
|
50
|
+
import { pickOf, rnd, shuffle } from '../util/random';
|
|
51
|
+
import type { TempoVerdict } from '../store/types';
|
|
52
|
+
import { altOptions, distractors, options, pick, type Choice } from '../learn/session';
|
|
53
|
+
import { mdoCards, mdoRels, parseMD, pcs, playPcs } from '../learn/pools';
|
|
54
|
+
import { anyDrillDef, isScaleDrill, type DrillDef } from './defs';
|
|
55
|
+
|
|
56
|
+
/** A place on the neck: string index (0 = G) and fret. */
|
|
57
|
+
type Cell = { s: number; f: number };
|
|
58
|
+
|
|
59
|
+
/* ---------------- the questions ----------------
|
|
60
|
+
One type per way of asking. The drill screen switches on `kind`, so a new
|
|
61
|
+
kind that is not handled there is a compile error rather than a blank
|
|
62
|
+
screen. */
|
|
63
|
+
export type Question =
|
|
64
|
+
| { kind: 'fb2name'; id: string; sys: Sys; pos: FretPos; pc: number; opts: number[] }
|
|
65
|
+
| { kind: 'name2fb'; id: string; sys: Sys; pos: FretPos; pc: number }
|
|
66
|
+
| { kind: 'staffkey'; id: string; note: StaffNote; clef: ClefId; pc: number }
|
|
67
|
+
| {
|
|
68
|
+
kind: 'staff2name';
|
|
69
|
+
id: string;
|
|
70
|
+
sys: Sys;
|
|
71
|
+
note: StaffNote;
|
|
72
|
+
clef: ClefId;
|
|
73
|
+
pc: number;
|
|
74
|
+
opts: number[];
|
|
75
|
+
}
|
|
76
|
+
| { kind: 'translate'; id: string; sys: Sys; from: Sys; pc: number; opts: number[] }
|
|
77
|
+
| {
|
|
78
|
+
kind: 'play';
|
|
79
|
+
id: string;
|
|
80
|
+
sys: Sys | 'staff';
|
|
81
|
+
note?: StaffNote;
|
|
82
|
+
clef?: ClefId;
|
|
83
|
+
pc: number;
|
|
84
|
+
target: number | null;
|
|
85
|
+
exact: boolean;
|
|
86
|
+
}
|
|
87
|
+
| {
|
|
88
|
+
kind: 'chhear';
|
|
89
|
+
id: string;
|
|
90
|
+
cid: string;
|
|
91
|
+
root: number;
|
|
92
|
+
notes: number[];
|
|
93
|
+
choices: Choice[];
|
|
94
|
+
played?: boolean;
|
|
95
|
+
}
|
|
96
|
+
| { kind: 'ivhear'; id: string; semis: number; root: number; choices: Choice[]; played?: boolean }
|
|
97
|
+
| { kind: 'ivsee'; id: string; semis: number; a: Cell; b: Cell; choices: Choice[] }
|
|
98
|
+
| { kind: 'ivbuild'; id: string; semis: number; base: Cell; rootMidi: number; target: Cell }
|
|
99
|
+
| { kind: 'singit'; id: string; semis: number; root: number; dir: 1 | -1 }
|
|
100
|
+
| { kind: 'earscale'; id: string; sid: string; root: number; choices: Choice[]; played: boolean }
|
|
101
|
+
| {
|
|
102
|
+
kind: 'singdeg';
|
|
103
|
+
id: string;
|
|
104
|
+
keyPc: number;
|
|
105
|
+
pc: number;
|
|
106
|
+
mode: 'solf' | 'deg' | 'sing';
|
|
107
|
+
ask: string;
|
|
108
|
+
tonic: number;
|
|
109
|
+
target: number;
|
|
110
|
+
}
|
|
111
|
+
| {
|
|
112
|
+
kind: 'deg2fb';
|
|
113
|
+
id: string;
|
|
114
|
+
keyPc: number;
|
|
115
|
+
pc: number;
|
|
116
|
+
mode: 'solf' | 'deg' | 'sing';
|
|
117
|
+
ask: string;
|
|
118
|
+
pos: FretPos;
|
|
119
|
+
}
|
|
120
|
+
| {
|
|
121
|
+
kind: 'mdo';
|
|
122
|
+
id: string;
|
|
123
|
+
keyPc: number;
|
|
124
|
+
pc: number;
|
|
125
|
+
mode: 'solf' | 'deg' | 'sing';
|
|
126
|
+
from: 'staff' | 'name' | 'syll';
|
|
127
|
+
ask: string;
|
|
128
|
+
choices: Choice[];
|
|
129
|
+
clef?: ClefId;
|
|
130
|
+
m?: number;
|
|
131
|
+
}
|
|
132
|
+
| { kind: 'rvalue'; id: string; u: number; rest: boolean; choices: Choice[] }
|
|
133
|
+
| { kind: 'rcount'; id: string; bar: Bar; step: number; choices: Choice[] }
|
|
134
|
+
| { kind: 'rfill'; id: string; bar: Bar; blank: number; miss: RhEvent; choices: Choice[] }
|
|
135
|
+
| { kind: 'rhear'; id: string; bar: Bar; ropts: { b: Bar; ok: boolean }[]; played: boolean }
|
|
136
|
+
| RtapQuestion
|
|
137
|
+
| {
|
|
138
|
+
kind: 'scbuild';
|
|
139
|
+
id: string;
|
|
140
|
+
sid: string;
|
|
141
|
+
root: number;
|
|
142
|
+
start: number;
|
|
143
|
+
shape: Shape;
|
|
144
|
+
idx: number;
|
|
145
|
+
misses: number;
|
|
146
|
+
wrong: Cell | null;
|
|
147
|
+
reveal: boolean;
|
|
148
|
+
scroll?: number;
|
|
149
|
+
}
|
|
150
|
+
| {
|
|
151
|
+
kind: 'scdeg';
|
|
152
|
+
id: string;
|
|
153
|
+
sid: string;
|
|
154
|
+
root: number;
|
|
155
|
+
dir: 'note' | 'deg';
|
|
156
|
+
ask: string;
|
|
157
|
+
choices: Choice[];
|
|
158
|
+
}
|
|
159
|
+
| {
|
|
160
|
+
kind: 'scwrong';
|
|
161
|
+
id: string;
|
|
162
|
+
sid: string;
|
|
163
|
+
root: number;
|
|
164
|
+
start: number;
|
|
165
|
+
shape: Shape;
|
|
166
|
+
shown: ShapePos[];
|
|
167
|
+
bad: number;
|
|
168
|
+
}
|
|
169
|
+
| {
|
|
170
|
+
kind: 'scname';
|
|
171
|
+
id: string;
|
|
172
|
+
sid: string;
|
|
173
|
+
root: number;
|
|
174
|
+
start: number;
|
|
175
|
+
shape: Shape;
|
|
176
|
+
choices: Choice[];
|
|
177
|
+
}
|
|
178
|
+
| { kind: 'scstaff'; id: string; sid: string; root: number; start: number; choices: Choice[] }
|
|
179
|
+
| {
|
|
180
|
+
kind: 'scwrite';
|
|
181
|
+
id: string;
|
|
182
|
+
sid: string;
|
|
183
|
+
root: number;
|
|
184
|
+
start: number;
|
|
185
|
+
sopts: ScaleStaffOpt[];
|
|
186
|
+
}
|
|
187
|
+
| {
|
|
188
|
+
kind: 'scsteps' | 'screcipe';
|
|
189
|
+
id: string;
|
|
190
|
+
sid: string;
|
|
191
|
+
root: number;
|
|
192
|
+
start: number;
|
|
193
|
+
form: 'steps' | 'formula';
|
|
194
|
+
choices: Choice[];
|
|
195
|
+
}
|
|
196
|
+
| {
|
|
197
|
+
kind: 'scplay';
|
|
198
|
+
id: string;
|
|
199
|
+
sid: string;
|
|
200
|
+
root: number;
|
|
201
|
+
start: number;
|
|
202
|
+
show: 'name' | 'letters' | 'staff';
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export interface RtapQuestion {
|
|
206
|
+
kind: 'rtap';
|
|
207
|
+
id: string;
|
|
208
|
+
bar: Bar;
|
|
209
|
+
phase: 'ready' | 'run' | 'done';
|
|
210
|
+
taps: number[];
|
|
211
|
+
grade: TapGrade | null;
|
|
212
|
+
counting?: boolean;
|
|
213
|
+
playedAt?: number;
|
|
214
|
+
gstart?: number;
|
|
215
|
+
gspu?: number;
|
|
216
|
+
tv?: TempoVerdict;
|
|
217
|
+
newBest?: boolean;
|
|
218
|
+
nextBpm?: number;
|
|
219
|
+
}
|
|
220
|
+
export interface ScaleStaffOpt {
|
|
221
|
+
sid: string;
|
|
222
|
+
ok: boolean;
|
|
223
|
+
alter?: { k: number; dir: number };
|
|
224
|
+
}
|
|
225
|
+
export type QuestionKind = Question['kind'];
|
|
226
|
+
export type Q<K extends QuestionKind> = Extract<Question, { kind: K }>;
|
|
227
|
+
|
|
228
|
+
/** A forced card, as cardToItem() describes it. Loosely typed on purpose: each
|
|
229
|
+
maker reads only the fields its own card prefix carries. */
|
|
230
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
231
|
+
type Forced = any;
|
|
232
|
+
|
|
233
|
+
/* ---------------- shared helpers ---------------- */
|
|
234
|
+
/** Multiple choice is offered with up to four options. */
|
|
235
|
+
const MAX_CHOICES = 4;
|
|
236
|
+
/** Add a wrong answer unless the list is full or the label is already there.
|
|
237
|
+
Two options that read the same make a question unanswerable. */
|
|
238
|
+
function addDistinct(choices: Choice[], used: Set<string>, label: string): void {
|
|
239
|
+
if (choices.length >= MAX_CHOICES || used.has(label)) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
used.add(label);
|
|
243
|
+
choices.push({ t: label, ok: false });
|
|
244
|
+
}
|
|
245
|
+
/** The right answer, then wrong ones from `wrongLabels` in order until full. */
|
|
246
|
+
function choicesFrom(correct: string, wrongLabels: string[]): Choice[] {
|
|
247
|
+
const choices: Choice[] = [{ t: correct, ok: true }];
|
|
248
|
+
const used = new Set([correct]);
|
|
249
|
+
wrongLabels.forEach((label) => addDistinct(choices, used, label));
|
|
250
|
+
return choices;
|
|
251
|
+
}
|
|
252
|
+
/** The right pitch class among three others near it, shuffled. */
|
|
253
|
+
function pcOptions(pc: number): number[] {
|
|
254
|
+
return shuffle([pc].concat(distractors(pc, 3)));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* ---------------- interval and chord questions ---------------- */
|
|
258
|
+
/** Ear questions on a bass sound in E2–E3: a comfortable middle register. */
|
|
259
|
+
const EAR_LOW = 40;
|
|
260
|
+
const EAR_SPAN = 13;
|
|
261
|
+
/** A comfortable place to hear an interval: middle of the bass range, or the
|
|
262
|
+
singer's range for the sing-it drill. */
|
|
263
|
+
function earRoot(forVoice: boolean): number {
|
|
264
|
+
if (forVoice) {
|
|
265
|
+
const range = vRange();
|
|
266
|
+
return range.lo + rnd(Math.max(1, range.hi - range.lo - 12));
|
|
267
|
+
}
|
|
268
|
+
return EAR_LOW + rnd(EAR_SPAN);
|
|
269
|
+
}
|
|
270
|
+
function makeChordQ(chordId: string | null): Question {
|
|
271
|
+
const cid = chordId ?? pickOf(chPool()).id;
|
|
272
|
+
const chord = chDef(cid);
|
|
273
|
+
const root = EAR_LOW + rnd(EAR_SPAN);
|
|
274
|
+
const otherNames = chPool()
|
|
275
|
+
.filter((other) => other.id !== cid)
|
|
276
|
+
.map((other) => other.n);
|
|
277
|
+
return {
|
|
278
|
+
kind: 'chhear',
|
|
279
|
+
id: 'ch:' + cid,
|
|
280
|
+
cid,
|
|
281
|
+
root,
|
|
282
|
+
notes: chord.iv.map((semis) => root + semis),
|
|
283
|
+
choices: options(chord.n, otherNames),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/** Two notes an interval apart on the neck, the second found from the first's
|
|
287
|
+
string up; null if no such pair turns up in a few tries. */
|
|
288
|
+
function intervalOnNeck(semis: number): { a: Cell; b: Cell } | null {
|
|
289
|
+
let first: Cell | null = null;
|
|
290
|
+
let second: Cell | null = null;
|
|
291
|
+
for (let tries = 0; tries < 40 && (!first || !second); tries++) {
|
|
292
|
+
first = findPos(EAR_LOW + rnd(16), rnd(4));
|
|
293
|
+
second = first ? findPos(STRINGS[first.s].open + first.f + semis, first.s) : null;
|
|
294
|
+
}
|
|
295
|
+
return first && second ? { a: first, b: second } : null;
|
|
296
|
+
}
|
|
297
|
+
/** A root on one string with room for the interval above it on the same string. */
|
|
298
|
+
function intervalBase(semis: number): Cell {
|
|
299
|
+
const highestFret = 15;
|
|
300
|
+
let base: Cell;
|
|
301
|
+
let tries = 0;
|
|
302
|
+
do {
|
|
303
|
+
base = { s: rnd(4), f: rnd(Math.max(1, 13 - semis)) };
|
|
304
|
+
tries++;
|
|
305
|
+
} while (base.f + semis > highestFret && tries < 30);
|
|
306
|
+
return base;
|
|
307
|
+
}
|
|
308
|
+
function makeEarQ(drill: { id: string }, forced?: Forced): Question | null {
|
|
309
|
+
const wantsChord = drill.id === 'chhear';
|
|
310
|
+
let card = forced;
|
|
311
|
+
if (!card) {
|
|
312
|
+
/* pick from the pool this drill actually uses, or a chord card lands in
|
|
313
|
+
the middle of an interval drill */
|
|
314
|
+
const ids = wantsChord
|
|
315
|
+
? chPool().map((chord) => 'ch:' + chord.id)
|
|
316
|
+
: ivPool().map((semis) => 'iv:' + semis);
|
|
317
|
+
card = pick(ids.map((id) => ({ id })));
|
|
318
|
+
}
|
|
319
|
+
if (!card) {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
const id: string = card.id;
|
|
323
|
+
const isChordCard = id.startsWith('ch:');
|
|
324
|
+
if (wantsChord || isChordCard) {
|
|
325
|
+
return makeChordQ(isChordCard ? id.slice(3) : null);
|
|
326
|
+
}
|
|
327
|
+
const semis = +id.slice(3);
|
|
328
|
+
const intervalChoices = () =>
|
|
329
|
+
options(
|
|
330
|
+
ivDef(semis).n,
|
|
331
|
+
ivPool()
|
|
332
|
+
.filter((other) => other !== semis)
|
|
333
|
+
.map((other) => ivDef(other).n),
|
|
334
|
+
);
|
|
335
|
+
if (drill.id === 'ivhear') {
|
|
336
|
+
return { kind: 'ivhear', id, semis, root: earRoot(false), choices: intervalChoices() };
|
|
337
|
+
}
|
|
338
|
+
if (drill.id === 'ivsee') {
|
|
339
|
+
const pair = intervalOnNeck(semis);
|
|
340
|
+
if (!pair) {
|
|
341
|
+
return makeEarQ({ id: 'ivhear' }, card);
|
|
342
|
+
}
|
|
343
|
+
return { kind: 'ivsee', id, semis, a: pair.a, b: pair.b, choices: intervalChoices() };
|
|
344
|
+
}
|
|
345
|
+
if (drill.id === 'ivbuild') {
|
|
346
|
+
const base = intervalBase(semis);
|
|
347
|
+
const rootMidi = STRINGS[base.s].open + base.f;
|
|
348
|
+
return { kind: 'ivbuild', id, semis, base, rootMidi, target: { s: base.s, f: base.f + semis } };
|
|
349
|
+
}
|
|
350
|
+
/* sing it: mostly upwards */
|
|
351
|
+
const root = earRoot(true);
|
|
352
|
+
return { kind: 'singit', id, semis, root, dir: Math.random() < 0.75 ? 1 : -1 };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* ---------------- a scale by ear ---------------- */
|
|
356
|
+
function makeScalearQ(forced?: Forced): Question | null {
|
|
357
|
+
const pool = enabledScales();
|
|
358
|
+
if (pool.length < 2) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
const sid = forced && forced.id ? forced.id.slice(3) : pickOf(pool).id;
|
|
362
|
+
const scale = scaleDef(sid);
|
|
363
|
+
if (!scale || !scaleOn(sid)) {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
/* Distractors with the same number of notes first: a five-note scale set
|
|
367
|
+
against seven-note ones gives the answer away on length alone. */
|
|
368
|
+
const others = pool.filter((other) => other.id !== sid);
|
|
369
|
+
const sameSize = shuffle(others.filter((other) => other.iv.length === scale.iv.length));
|
|
370
|
+
const otherSize = shuffle(others.filter((other) => other.iv.length !== scale.iv.length));
|
|
371
|
+
/* options() shuffles whatever it is handed, so the same-length ones have to
|
|
372
|
+
be chosen here rather than merely listed first. */
|
|
373
|
+
const wrongNames = sameSize
|
|
374
|
+
.concat(otherSize)
|
|
375
|
+
.map((other) => other.n)
|
|
376
|
+
.slice(0, 3);
|
|
377
|
+
const choices = options(scale.n, wrongNames);
|
|
378
|
+
return {
|
|
379
|
+
kind: 'earscale',
|
|
380
|
+
id: 'se:' + sid,
|
|
381
|
+
sid,
|
|
382
|
+
root: EAR_LOW + rnd(10),
|
|
383
|
+
choices,
|
|
384
|
+
played: false,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/* ---------------- movable do and degrees ---------------- */
|
|
389
|
+
type Pref = 'sharp' | 'flat';
|
|
390
|
+
function mdoLabel(pc: number, keyPc: number, mode: string, pref: Pref): string {
|
|
391
|
+
return mode === 'deg' ? degName(pc, keyPc, pref) : mdoName(pc, keyPc, pref);
|
|
392
|
+
}
|
|
393
|
+
/** The other degrees in rotation, shuffled — the source of wrong answers. */
|
|
394
|
+
function otherDegrees(pc: number, keyPc: number): number[] {
|
|
395
|
+
const mine = relOf(pc, keyPc);
|
|
396
|
+
return shuffle(mdoRels().filter((degree) => degree !== mine));
|
|
397
|
+
}
|
|
398
|
+
/** The wrong answers are other degrees of the same key, never random
|
|
399
|
+
syllables: confusing Mi with Fa is the mistake worth drilling. */
|
|
400
|
+
function mdoDistract(pc: number, keyPc: number, mode: string, pref: Pref, count: number) {
|
|
401
|
+
const out: { pc: number; t: string }[] = [];
|
|
402
|
+
const used = new Set([mdoLabel(pc, keyPc, mode, pref)]);
|
|
403
|
+
for (const degree of otherDegrees(pc, keyPc)) {
|
|
404
|
+
if (out.length >= count) {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const otherPc = pcOf(keyPc + degree);
|
|
408
|
+
const label = mdoLabel(otherPc, keyPc, mode, pref);
|
|
409
|
+
if (used.has(label)) {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
used.add(label);
|
|
413
|
+
out.push({ pc: otherPc, t: label });
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
function noteDistract(pc: number, keyPc: number, pref: Pref, count: number) {
|
|
418
|
+
return otherDegrees(pc, keyPc)
|
|
419
|
+
.slice(0, count)
|
|
420
|
+
.map((degree) => {
|
|
421
|
+
const otherPc = pcOf(keyPc + degree);
|
|
422
|
+
return { pc: otherPc, t: noteName(otherPc, 'en', pref) };
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
/** The movable-do card to ask: the forced one, or one picked by need (for the
|
|
426
|
+
neck drill, only notes the current range reaches). */
|
|
427
|
+
function chooseMdoCard(drill: DrillDef, forced?: Forced) {
|
|
428
|
+
if (forced && forced.id && forced.id.startsWith('md:')) {
|
|
429
|
+
return { keyPc: forced.keyPc as number, pc: forced.pc as number, id: forced.id as string };
|
|
430
|
+
}
|
|
431
|
+
let ids = mdoCards();
|
|
432
|
+
if (drill.from === 'fb') {
|
|
433
|
+
ids = ids.filter((id) => placesFor(parseMD(id).pc).length > 0);
|
|
434
|
+
}
|
|
435
|
+
const chosen = pick(ids.map((id) => ({ id })));
|
|
436
|
+
if (!chosen) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
return { ...parseMD(chosen.id), id: chosen.id };
|
|
440
|
+
}
|
|
441
|
+
/** Sung degrees are pitched around C3, in the key's own octave. */
|
|
442
|
+
const SING_TONIC_BASE = 48;
|
|
443
|
+
function makeMdoQ(drill: DrillDef, forced?: Forced): Question | null {
|
|
444
|
+
const pref = honmoon.settings.pref;
|
|
445
|
+
const card = chooseMdoCard(drill, forced);
|
|
446
|
+
if (!card) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
const { keyPc, pc, id } = card;
|
|
450
|
+
const mode = drill.mode!;
|
|
451
|
+
const correct = mdoLabel(pc, keyPc, mode, pref);
|
|
452
|
+
|
|
453
|
+
if (drill.from === 'sing') {
|
|
454
|
+
const tonic = SING_TONIC_BASE + (keyPc % 12);
|
|
455
|
+
return {
|
|
456
|
+
kind: 'singdeg',
|
|
457
|
+
id,
|
|
458
|
+
keyPc,
|
|
459
|
+
pc,
|
|
460
|
+
mode,
|
|
461
|
+
ask: correct,
|
|
462
|
+
tonic,
|
|
463
|
+
target: tonic + relOf(pc, keyPc),
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (drill.from === 'fb') {
|
|
467
|
+
const where = placesFor(pc);
|
|
468
|
+
if (!where.length) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
const pos = where[rnd(where.length)];
|
|
472
|
+
return { kind: 'deg2fb', id, keyPc, pc, mode, ask: correct, pos };
|
|
473
|
+
}
|
|
474
|
+
if (drill.from === 'syll') {
|
|
475
|
+
const wrongNotes = noteDistract(pc, keyPc, pref, 5).map((item) => item.t);
|
|
476
|
+
return {
|
|
477
|
+
kind: 'mdo',
|
|
478
|
+
id,
|
|
479
|
+
keyPc,
|
|
480
|
+
pc,
|
|
481
|
+
mode,
|
|
482
|
+
from: 'syll',
|
|
483
|
+
ask: correct,
|
|
484
|
+
choices: options(noteName(pc, 'en', pref), wrongNotes),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
const wrongLabels = mdoDistract(pc, keyPc, mode, pref, 5).map((item) => item.t);
|
|
488
|
+
const question: Q<'mdo'> = {
|
|
489
|
+
kind: 'mdo',
|
|
490
|
+
id,
|
|
491
|
+
keyPc,
|
|
492
|
+
pc,
|
|
493
|
+
mode,
|
|
494
|
+
from: drill.from as 'staff' | 'name',
|
|
495
|
+
ask: correct,
|
|
496
|
+
choices: options(correct, wrongLabels),
|
|
497
|
+
};
|
|
498
|
+
if (drill.from === 'staff') {
|
|
499
|
+
const clef = enabledClefs()[rnd(enabledClefs().length)];
|
|
500
|
+
question.clef = clef;
|
|
501
|
+
question.m = midiForPcInClef(pc, clef);
|
|
502
|
+
}
|
|
503
|
+
return question;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/* ---------------- rhythm questions ---------------- */
|
|
507
|
+
/** A note-value card: forced, or picked by need from the value and rest cards. */
|
|
508
|
+
function chooseValueCard(forcedId: string | null): { id: string; u: number; rest: boolean } | null {
|
|
509
|
+
let id = forcedId;
|
|
510
|
+
if (!id) {
|
|
511
|
+
const kindOf = (cardId: string) => cardId.split(':')[1];
|
|
512
|
+
const pool = rhCards()
|
|
513
|
+
.filter((cardId) => kindOf(cardId) === 'v' || kindOf(cardId) === 'r')
|
|
514
|
+
.map((cardId) => ({ id: cardId }));
|
|
515
|
+
const chosen = pick(pool);
|
|
516
|
+
if (!chosen) {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
id = chosen.id;
|
|
520
|
+
}
|
|
521
|
+
const [, kind, value] = id.split(':');
|
|
522
|
+
return { id, u: +value, rest: kind === 'r' };
|
|
523
|
+
}
|
|
524
|
+
function makeValueQ(forcedId: string | null, level: number): Question | null {
|
|
525
|
+
const card = chooseValueCard(forcedId);
|
|
526
|
+
if (!card) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
const { u, rest } = card;
|
|
530
|
+
const others = shuffle(rhValues(level).filter((value) => value !== u));
|
|
531
|
+
const choices = choicesFrom(
|
|
532
|
+
beatsText(u),
|
|
533
|
+
others.map((value) => beatsText(value)),
|
|
534
|
+
);
|
|
535
|
+
return { kind: 'rvalue', id: card.id, u, rest, choices: shuffle(choices) };
|
|
536
|
+
}
|
|
537
|
+
const PADDING_TRIES = 30;
|
|
538
|
+
/** "Which counting is this bar?": the real counting against those of similar
|
|
539
|
+
bars, all written on one grid fine enough for every one of them. */
|
|
540
|
+
function makeCountQ(bar: Bar, id: string, level: number): Question {
|
|
541
|
+
const variants = rhVariants(bar, 3).filter((variant) => variant.u === bar.u);
|
|
542
|
+
let step = barGridStep(bar);
|
|
543
|
+
variants.forEach((variant) => {
|
|
544
|
+
step = gcd2(step, barGridStep(variant));
|
|
545
|
+
});
|
|
546
|
+
step = Math.max(1, step);
|
|
547
|
+
const choices = choicesFrom(
|
|
548
|
+
barCounting(bar, step),
|
|
549
|
+
variants.map((variant) => barCounting(variant, step)),
|
|
550
|
+
);
|
|
551
|
+
const used = new Set(choices.map((choice) => choice.t));
|
|
552
|
+
for (let tries = 0; choices.length < MAX_CHOICES && tries < PADDING_TRIES; tries++) {
|
|
553
|
+
const extra = rhMakeBar(level);
|
|
554
|
+
if (extra.u === bar.u) {
|
|
555
|
+
addDistinct(choices, used, barCounting(extra, step));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return { kind: 'rcount', id, bar, step, choices: shuffle(choices) };
|
|
559
|
+
}
|
|
560
|
+
/** Blank out one event and ask which value fills the bar. */
|
|
561
|
+
function makeFillQ(bar: Bar, id: string, level: number): Question {
|
|
562
|
+
const blank = rnd(bar.ev.length);
|
|
563
|
+
const missing = bar.ev[blank];
|
|
564
|
+
const nameOf = (value: number) => (missing.r ? restName(value) : rvalDef(value).n);
|
|
565
|
+
const others = shuffle(rhValues(level).filter((value) => value !== missing.u));
|
|
566
|
+
const choices = choicesFrom(nameOf(missing.u), others.map(nameOf));
|
|
567
|
+
return { kind: 'rfill', id, bar, blank, miss: missing, choices: shuffle(choices) };
|
|
568
|
+
}
|
|
569
|
+
function makeRhythmQ(drill: DrillDef, forced?: Forced): Question | null {
|
|
570
|
+
const forcedId: string | null = (forced && forced.id) || null;
|
|
571
|
+
const level = rlevN();
|
|
572
|
+
if (drill.rk === 'value') {
|
|
573
|
+
return makeValueQ(forcedId, level);
|
|
574
|
+
}
|
|
575
|
+
const bar = rhMakeBar(level);
|
|
576
|
+
const cardId = (kind: string) => forcedId || 'rh:' + kind + ':' + level;
|
|
577
|
+
if (drill.rk === 'count') {
|
|
578
|
+
return makeCountQ(bar, cardId('count'), level);
|
|
579
|
+
}
|
|
580
|
+
if (drill.rk === 'fill') {
|
|
581
|
+
return makeFillQ(bar, cardId('fill'), level);
|
|
582
|
+
}
|
|
583
|
+
if (drill.rk === 'hear') {
|
|
584
|
+
const soundAlikes = rhVariants(bar, 2, true);
|
|
585
|
+
if (soundAlikes.length < 2) {
|
|
586
|
+
return null; /* rather no question than a guess */
|
|
587
|
+
}
|
|
588
|
+
const order = shuffle([bar, ...soundAlikes].map((option, i) => ({ b: option, ok: i === 0 })));
|
|
589
|
+
return { kind: 'rhear', id: cardId('hear'), bar, ropts: order, played: false };
|
|
590
|
+
}
|
|
591
|
+
return { kind: 'rtap', id: cardId('tap'), bar, phase: 'ready', taps: [], grade: null };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/* ---------------- scale questions ---------------- */
|
|
595
|
+
/** Every degree a wrong answer can name. */
|
|
596
|
+
const ALL_DEGREES = ['1', '2', 'b2', 'b3', '3', '4', '#4', 'b5', '5', 'b6', '6', 'b7', '7', '8'];
|
|
597
|
+
/** Nothing in a shape is placed above this fret. */
|
|
598
|
+
const HIGHEST_SHAPE_FRET = 17;
|
|
599
|
+
|
|
600
|
+
function makeDegreeQ(id: string, sid: string, root: number): Question {
|
|
601
|
+
const notes = scaleNotes(sid, root);
|
|
602
|
+
const toNote = Math.random() < 0.5;
|
|
603
|
+
const index = 1 + rnd(notes.length - 1);
|
|
604
|
+
const target = notes[index];
|
|
605
|
+
if (toNote) {
|
|
606
|
+
/* distractors are the scale's own other notes — being wrong by a degree
|
|
607
|
+
is the mistake worth catching, not being wrong by a random letter */
|
|
608
|
+
const wrongNotes = notes.filter((_, i) => i !== index).map(noteText);
|
|
609
|
+
const choices = options(noteText(target), wrongNotes);
|
|
610
|
+
return { kind: 'scdeg', id, sid, root, dir: 'note', ask: degPretty(target.deg), choices };
|
|
611
|
+
}
|
|
612
|
+
const wrongDegrees = ALL_DEGREES.filter((degree) => degree !== target.deg).map(degPretty);
|
|
613
|
+
const choices = options(degPretty(target.deg), wrongDegrees);
|
|
614
|
+
return { kind: 'scdeg', id, sid, root, dir: 'deg', ask: noteText(target), choices };
|
|
615
|
+
}
|
|
616
|
+
/** Every note of a shape (but the first) that can be moved a fret up or down
|
|
617
|
+
without leaving the neck or landing on another note of the shape. */
|
|
618
|
+
function movableNotes(shape: Shape): { i: number; d: number }[] {
|
|
619
|
+
const moves: { i: number; d: number }[] = [];
|
|
620
|
+
for (let i = 1; i < shape.pos.length; i++) {
|
|
621
|
+
for (const direction of [1, -1]) {
|
|
622
|
+
const note = shape.pos[i];
|
|
623
|
+
const fret = note.f + direction;
|
|
624
|
+
if (fret < 0 || fret > HIGHEST_SHAPE_FRET) {
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const occupied = shape.pos.some(
|
|
628
|
+
(other, j) => j !== i && other.s === note.s && other.f === fret,
|
|
629
|
+
);
|
|
630
|
+
if (!occupied) {
|
|
631
|
+
moves.push({ i, d: direction });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return moves;
|
|
636
|
+
}
|
|
637
|
+
function makeWrongNoteQ(card: Forced, id: string, sid: string, root: number): Question | null {
|
|
638
|
+
const start = randStart();
|
|
639
|
+
const shape = scaleShape(sid, root, start, 1);
|
|
640
|
+
const moves = movableNotes(shape);
|
|
641
|
+
if (!moves.length) {
|
|
642
|
+
return makeScaleQ({ id: 'scbuild' }, card);
|
|
643
|
+
}
|
|
644
|
+
const move = moves[rnd(moves.length)];
|
|
645
|
+
const shown: ShapePos[] = shape.pos.map((pos) => ({
|
|
646
|
+
s: pos.s,
|
|
647
|
+
f: pos.f,
|
|
648
|
+
fin: pos.fin,
|
|
649
|
+
deg: pos.deg,
|
|
650
|
+
shift: false,
|
|
651
|
+
}));
|
|
652
|
+
shown[move.i] = { ...shown[move.i], f: shape.pos[move.i].f + move.d };
|
|
653
|
+
return { kind: 'scwrong', id, sid, root, start, shape, shown, bad: move.i };
|
|
654
|
+
}
|
|
655
|
+
/** Three staves on the same root: the scale asked for, and two others with
|
|
656
|
+
the same number of notes, so the difference is an accidental or two and has
|
|
657
|
+
to be read rather than counted. */
|
|
658
|
+
function makeWriteQ(id: string, sid: string, root: number): Question {
|
|
659
|
+
const scale = scaleDef(sid);
|
|
660
|
+
const sameSize = scaleAlts(sid).filter((other) => other.iv.length === scale.iv.length);
|
|
661
|
+
const staves: ScaleStaffOpt[] = [{ sid, ok: true }].concat(
|
|
662
|
+
sameSize.slice(0, 2).map((other) => ({ sid: other.id, ok: false })),
|
|
663
|
+
);
|
|
664
|
+
/* Pentatonics and the six-note scales have only one relative of the same
|
|
665
|
+
size. Rather than pad with a scale you could rule out by counting, the
|
|
666
|
+
missing staff is the right scale with one note moved a fret — the most
|
|
667
|
+
useful wrong answer there is, because it is the one you would write by
|
|
668
|
+
mistake. */
|
|
669
|
+
const written = new Set(staves.map((staff) => staffSig(staff, root)));
|
|
670
|
+
for (let tries = 0; staves.length < 3 && tries < PADDING_TRIES; tries++) {
|
|
671
|
+
const alter = alterOne(sid, root);
|
|
672
|
+
if (!alter) {
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
const candidate: ScaleStaffOpt = { sid, ok: false, alter };
|
|
676
|
+
const signature = staffSig(candidate, root);
|
|
677
|
+
if (written.has(signature)) {
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
written.add(signature);
|
|
681
|
+
staves.push(candidate);
|
|
682
|
+
}
|
|
683
|
+
return { kind: 'scwrite', id, sid, root, start: randStart(), sopts: shuffle(staves) };
|
|
684
|
+
}
|
|
685
|
+
/** Name the scale from its steps, or its steps/formula from the name. */
|
|
686
|
+
function makeRecipeQ(
|
|
687
|
+
kind: 'scsteps' | 'screcipe',
|
|
688
|
+
id: string,
|
|
689
|
+
sid: string,
|
|
690
|
+
root: number,
|
|
691
|
+
): Question {
|
|
692
|
+
const form = Math.random() < 0.5 ? 'steps' : 'formula';
|
|
693
|
+
const recipe = (scaleId: string) =>
|
|
694
|
+
form === 'steps'
|
|
695
|
+
? scaleSteps(scaleId)
|
|
696
|
+
.filter((step) => step > 0)
|
|
697
|
+
.join(' ')
|
|
698
|
+
: scaleDef(scaleId).dg.map(degPretty).join(' ');
|
|
699
|
+
const alternatives = scaleAlts(sid);
|
|
700
|
+
// (randomness is drawn in a fixed order: the start string, then the options)
|
|
701
|
+
const start = randStart();
|
|
702
|
+
if (kind === 'scsteps') {
|
|
703
|
+
const choices = altOptions(
|
|
704
|
+
scaleDef(sid).n,
|
|
705
|
+
alternatives.map((other) => other.n),
|
|
706
|
+
);
|
|
707
|
+
return { kind, id, sid, root, start, form, choices };
|
|
708
|
+
}
|
|
709
|
+
const choices = altOptions(
|
|
710
|
+
recipe(sid),
|
|
711
|
+
alternatives.map((other) => recipe(other.id)),
|
|
712
|
+
);
|
|
713
|
+
return { kind, id, sid, root, start, form, choices };
|
|
714
|
+
}
|
|
715
|
+
function makeScaleQ(drill: { id: string }, forced?: Forced): Question | null {
|
|
716
|
+
const card = forced || pick(scaleCards().map((id) => ({ id })));
|
|
717
|
+
if (!card) {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
const info = card.sid ? card : parseSC(card.id);
|
|
721
|
+
const sid: string = info.sid;
|
|
722
|
+
const root: number = info.root;
|
|
723
|
+
const id: string = card.id || 'sc:' + sid + ':' + root;
|
|
724
|
+
const scale = scaleDef(sid);
|
|
725
|
+
switch (drill.id) {
|
|
726
|
+
case 'scbuild': {
|
|
727
|
+
const start = randStart();
|
|
728
|
+
const shape = scaleShape(sid, root, start, 1);
|
|
729
|
+
return {
|
|
730
|
+
kind: 'scbuild',
|
|
731
|
+
id,
|
|
732
|
+
sid,
|
|
733
|
+
root,
|
|
734
|
+
start,
|
|
735
|
+
shape,
|
|
736
|
+
idx: 0,
|
|
737
|
+
misses: 0,
|
|
738
|
+
wrong: null,
|
|
739
|
+
reveal: false,
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
case 'scdeg':
|
|
743
|
+
return makeDegreeQ(id, sid, root);
|
|
744
|
+
case 'scwrong':
|
|
745
|
+
return makeWrongNoteQ(card, id, sid, root);
|
|
746
|
+
case 'scname': {
|
|
747
|
+
const start = randStart();
|
|
748
|
+
const shape = scaleShape(sid, root, start, 1);
|
|
749
|
+
const sameSize = shuffle(
|
|
750
|
+
SCALES.filter((other) => other.id !== sid && other.iv.length === scale.iv.length),
|
|
751
|
+
);
|
|
752
|
+
const otherSize = shuffle(
|
|
753
|
+
SCALES.filter((other) => other.id !== sid && other.iv.length !== scale.iv.length),
|
|
754
|
+
);
|
|
755
|
+
const choices = options(
|
|
756
|
+
scale.n,
|
|
757
|
+
sameSize.concat(otherSize).map((other) => other.n),
|
|
758
|
+
);
|
|
759
|
+
return { kind: 'scname', id, sid, root, start, shape, choices };
|
|
760
|
+
}
|
|
761
|
+
case 'scstaff': {
|
|
762
|
+
const alternatives = scaleAlts(sid);
|
|
763
|
+
// (randomness is drawn in a fixed order: the start string, then the options)
|
|
764
|
+
const start = randStart();
|
|
765
|
+
const choices = altOptions(
|
|
766
|
+
scaleName(sid, root),
|
|
767
|
+
alternatives.map((other) => scaleName(other.id, root)),
|
|
768
|
+
);
|
|
769
|
+
return { kind: 'scstaff', id, sid, root, start, choices };
|
|
770
|
+
}
|
|
771
|
+
case 'scwrite':
|
|
772
|
+
return makeWriteQ(id, sid, root);
|
|
773
|
+
case 'scsteps':
|
|
774
|
+
case 'screcipe':
|
|
775
|
+
return makeRecipeQ(drill.id, id, sid, root);
|
|
776
|
+
default: {
|
|
777
|
+
const show = (['name', 'letters', 'staff'] as const)[rnd(3)];
|
|
778
|
+
return { kind: 'scplay', id, sid, root, start: randStart(), show };
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/* ---------------- the simple note questions ---------------- */
|
|
784
|
+
function makeFretToNameQ(drill: DrillDef, forced?: Forced): Question | null {
|
|
785
|
+
const pos = forced || pick(positions());
|
|
786
|
+
if (!pos) {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
const pc = pcOf(pos.m);
|
|
790
|
+
return { kind: 'fb2name', sys: drill.sys as Sys, pos, id: pos.id, pc, opts: pcOptions(pc) };
|
|
791
|
+
}
|
|
792
|
+
function makeNameToFretQ(drill: DrillDef, forced?: Forced): Question | null {
|
|
793
|
+
const pos = forced || pick(positions());
|
|
794
|
+
if (!pos) {
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
return { kind: 'name2fb', sys: drill.sys as Sys, pos, id: pos.id, pc: pcOf(pos.m) };
|
|
798
|
+
}
|
|
799
|
+
function makeStaffKeyQ(forced?: Forced): Question | null {
|
|
800
|
+
const note = forced || pick(staffNotes());
|
|
801
|
+
if (!note) {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
return { kind: 'staffkey', note, clef: note.clef || 'bass', id: note.id, pc: pcOf(note.m) };
|
|
805
|
+
}
|
|
806
|
+
function makeStaffToNameQ(drill: DrillDef, forced?: Forced): Question | null {
|
|
807
|
+
const note = forced || pick(staffNotes());
|
|
808
|
+
if (!note) {
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
const pc = pcOf(note.m);
|
|
812
|
+
return {
|
|
813
|
+
kind: 'staff2name',
|
|
814
|
+
sys: drill.sys as Sys,
|
|
815
|
+
note,
|
|
816
|
+
clef: note.clef || 'bass',
|
|
817
|
+
id: note.id,
|
|
818
|
+
pc,
|
|
819
|
+
opts: pcOptions(pc),
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
function makeTranslateQ(drill: DrillDef, forced?: Forced): Question | null {
|
|
823
|
+
const item = forced || pick(pcs());
|
|
824
|
+
if (!item) {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
return {
|
|
828
|
+
kind: 'translate',
|
|
829
|
+
sys: drill.sys as Sys,
|
|
830
|
+
from: otherSys(drill.sys as Sys),
|
|
831
|
+
id: item.id,
|
|
832
|
+
pc: item.pc,
|
|
833
|
+
opts: pcOptions(item.pc),
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
/** Play the note on the bass. Written pitch is an octave above sounding, so
|
|
837
|
+
the pitch to listen for is twelve below. */
|
|
838
|
+
function makePlayQ(drill: DrillDef, forced?: Forced): Question | null {
|
|
839
|
+
if (drill.sys === 'staff') {
|
|
840
|
+
const note = forced || pick(staffNotesFor('bass'));
|
|
841
|
+
if (!note) {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
return {
|
|
845
|
+
kind: 'play',
|
|
846
|
+
sys: 'staff',
|
|
847
|
+
note,
|
|
848
|
+
clef: 'bass',
|
|
849
|
+
id: note.id,
|
|
850
|
+
pc: pcOf(note.m),
|
|
851
|
+
target: note.m - 12,
|
|
852
|
+
exact: true,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
const item = forced || pick(playPcs());
|
|
856
|
+
if (!item) {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
const where = placesFor(item.pc);
|
|
860
|
+
return {
|
|
861
|
+
kind: 'play',
|
|
862
|
+
sys: drill.sys as Sys,
|
|
863
|
+
id: item.id,
|
|
864
|
+
pc: item.pc,
|
|
865
|
+
target: where.length ? where[0].m - 12 : null,
|
|
866
|
+
exact: false,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/* ---------------- one question for a drill ----------------
|
|
871
|
+
`forced` is a card the session wants asked; without one the drill picks its
|
|
872
|
+
own, weighted by need. */
|
|
873
|
+
export function makeQ(id: string, forced?: Forced): Question | null {
|
|
874
|
+
const drill = anyDrillDef(id);
|
|
875
|
+
if (!drill) {
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
switch (drill.kind) {
|
|
879
|
+
case 'earscale':
|
|
880
|
+
return makeScalearQ(forced);
|
|
881
|
+
case 'fb2name':
|
|
882
|
+
return makeFretToNameQ(drill, forced);
|
|
883
|
+
case 'name2fb':
|
|
884
|
+
return makeNameToFretQ(drill, forced);
|
|
885
|
+
case 'staffkey':
|
|
886
|
+
return makeStaffKeyQ(forced);
|
|
887
|
+
case 'staff2name':
|
|
888
|
+
return makeStaffToNameQ(drill, forced);
|
|
889
|
+
case 'translate':
|
|
890
|
+
return makeTranslateQ(drill, forced);
|
|
891
|
+
case 'ear':
|
|
892
|
+
return makeEarQ(drill, forced);
|
|
893
|
+
case 'mdo':
|
|
894
|
+
return makeMdoQ(drill, forced);
|
|
895
|
+
case 'rhythm':
|
|
896
|
+
return makeRhythmQ(drill, forced);
|
|
897
|
+
}
|
|
898
|
+
if (isScaleDrill(drill.id)) {
|
|
899
|
+
return makeScaleQ(drill, forced);
|
|
900
|
+
}
|
|
901
|
+
if (drill.kind === 'play') {
|
|
902
|
+
return makePlayQ(drill, forced);
|
|
903
|
+
}
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** "Answer by tapping" turns a play-along staff question into a naming one. */
|
|
908
|
+
export function playToNaming(question: Q<'play'>): Question {
|
|
909
|
+
return {
|
|
910
|
+
kind: 'staff2name',
|
|
911
|
+
id: question.id,
|
|
912
|
+
sys: 'en',
|
|
913
|
+
note: question.note!,
|
|
914
|
+
clef: question.clef || 'bass',
|
|
915
|
+
pc: question.pc,
|
|
916
|
+
opts: pcOptions(question.pc),
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
export { anyDrillDef };
|