@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,1155 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
One question at a time, for any drill. What is drawn depends on the kind of
|
|
3
|
+
question; the answer area, the explanation and the verdict are shared.
|
|
4
|
+
-->
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
import { honmoon } from '../../store/honmoon.svelte';
|
|
7
|
+
import { STRINGS, lvl, vhi, placesFor, sameNoteFrets } from '../../music/bass';
|
|
8
|
+
import { SYSNAME, bothNames, keyName, midiName, noteName, pcOf, relOf } from '../../music/notes';
|
|
9
|
+
import { clefDef, multiClef } from '../../music/clefs';
|
|
10
|
+
import { chPool, ivDef } from '../../music/intervals';
|
|
11
|
+
import {
|
|
12
|
+
degPretty,
|
|
13
|
+
midFret,
|
|
14
|
+
noteText,
|
|
15
|
+
rootName,
|
|
16
|
+
scaleDef,
|
|
17
|
+
scaleName,
|
|
18
|
+
scaleNotes,
|
|
19
|
+
scaleNoun,
|
|
20
|
+
scaleShape,
|
|
21
|
+
} from '../../music/scales';
|
|
22
|
+
import {
|
|
23
|
+
rlev,
|
|
24
|
+
rhBpm,
|
|
25
|
+
rhFloor,
|
|
26
|
+
rhRec,
|
|
27
|
+
rhSetBpm,
|
|
28
|
+
TEMPO_MAX,
|
|
29
|
+
tsSentence,
|
|
30
|
+
type TimeSig,
|
|
31
|
+
} from '../../music/rhythm';
|
|
32
|
+
import { staffSVG, degStaffSVG } from '../../notation/staff';
|
|
33
|
+
import { rhOneSVG, rhythmSVG, tapTimeline } from '../../notation/rhythmsvg';
|
|
34
|
+
import {
|
|
35
|
+
holdNote,
|
|
36
|
+
holdStop,
|
|
37
|
+
piano,
|
|
38
|
+
pianoChord,
|
|
39
|
+
playInterval,
|
|
40
|
+
playScale,
|
|
41
|
+
playScaleEar,
|
|
42
|
+
pluck,
|
|
43
|
+
} from '../../audio/synth';
|
|
44
|
+
import { rhPlay } from '../../audio/rhythmplay';
|
|
45
|
+
import { micUsable } from '../../audio/mic';
|
|
46
|
+
import { anyDrillDef, isScaleDrill } from '../../drills/defs';
|
|
47
|
+
import { groupDef } from '../../drills/registry.svelte';
|
|
48
|
+
import type { Question } from '../../drills/questions';
|
|
49
|
+
import { verdictOf } from '../../drills/verdict';
|
|
50
|
+
import {
|
|
51
|
+
drill,
|
|
52
|
+
session,
|
|
53
|
+
chooseOption,
|
|
54
|
+
choosePc,
|
|
55
|
+
endSession,
|
|
56
|
+
handleFret,
|
|
57
|
+
listen,
|
|
58
|
+
nextQ,
|
|
59
|
+
rtapCancel,
|
|
60
|
+
rtapGo,
|
|
61
|
+
rtapHit,
|
|
62
|
+
selfMark,
|
|
63
|
+
skip,
|
|
64
|
+
tapInstead,
|
|
65
|
+
tapPad,
|
|
66
|
+
} from '../../drills/runtime.svelte';
|
|
67
|
+
import { go, hasRoute } from '../../nav.svelte';
|
|
68
|
+
import { anA } from '../../util/text';
|
|
69
|
+
import { fretFrac, hscroll } from '../actions';
|
|
70
|
+
import Fretboard from '../Fretboard.svelte';
|
|
71
|
+
import Keyboard from '../Keyboard.svelte';
|
|
72
|
+
import StaffRun from '../StaffRun.svelte';
|
|
73
|
+
import RhythmBar from '../RhythmBar.svelte';
|
|
74
|
+
import { boardTopFor, scaleBoard, stepsOnlyHTML } from '../scaleboard';
|
|
75
|
+
|
|
76
|
+
/** The question on screen. (Kept short: the markup below reads it hundreds of times.) */
|
|
77
|
+
const q = $derived(drill.q);
|
|
78
|
+
const drillDef = $derived(drill.id ? anyDrillDef(drill.id) : null);
|
|
79
|
+
const pref = $derived(honmoon.settings.pref);
|
|
80
|
+
const level = $derived(lvl());
|
|
81
|
+
const hints = $derived(honmoon.settings.hints !== false);
|
|
82
|
+
const current = $derived(session.current);
|
|
83
|
+
|
|
84
|
+
/** Kinds added after the original port, and the ear-training kinds: they get their own controls. */
|
|
85
|
+
const NEW_KINDS = ['mdo', 'deg2fb', 'singdeg', 'rvalue', 'rcount', 'rfill', 'rhear', 'rtap'];
|
|
86
|
+
const EAR_KINDS = ['ivhear', 'chhear', 'ivsee', 'ivbuild', 'singit', 'earscale'];
|
|
87
|
+
const isNew = (kind: string) => NEW_KINDS.includes(kind);
|
|
88
|
+
const isEar = (kind: string) => EAR_KINDS.includes(kind);
|
|
89
|
+
const isSc = (kind: string) => kind.startsWith('sc');
|
|
90
|
+
/** Which extra button the skip bar of a newer kind carries. */
|
|
91
|
+
function newKindExtra(kind: string): 'tonic' | 'rplay' | undefined {
|
|
92
|
+
if (kind === 'mdo' || kind === 'deg2fb') {
|
|
93
|
+
return 'tonic';
|
|
94
|
+
}
|
|
95
|
+
return kind === 'rcount' ? 'rplay' : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Written pitch is an octave above sounding pitch. */
|
|
99
|
+
const OCTAVE = 12;
|
|
100
|
+
/** The low E string as it sounds (E1): a bare pitch class is played from there up. */
|
|
101
|
+
const LOW_E_SOUNDING = 28;
|
|
102
|
+
const E_PC = 4;
|
|
103
|
+
/** Scale shapes start on the E string unless the question says otherwise. */
|
|
104
|
+
const DEFAULT_SHAPE_STRING = 3;
|
|
105
|
+
const TEMPO_NUDGE = 4;
|
|
106
|
+
/** A dot on the neck for a right or wrong answer. */
|
|
107
|
+
const answerDot = (cell: { s: number; f: number }, fill: string) => ({
|
|
108
|
+
s: cell.s,
|
|
109
|
+
f: cell.f,
|
|
110
|
+
fill,
|
|
111
|
+
r: 11,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/* ---- the bar across the top ---- */
|
|
115
|
+
const top = $derived.by(() => {
|
|
116
|
+
if (!current) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const entry = current.queue[current.i];
|
|
120
|
+
const isRedo = !!(entry && entry.retry);
|
|
121
|
+
let pendingRedos = 0;
|
|
122
|
+
for (let at = current.i + (isRedo ? 1 : 0); at < current.queue.length; at++) {
|
|
123
|
+
if (current.queue[at].retry) {
|
|
124
|
+
pendingRedos++;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
at: Math.min(current.plannedDone + (isRedo ? 0 : 1), current.planned),
|
|
129
|
+
isRedo,
|
|
130
|
+
pend: pendingRedos,
|
|
131
|
+
pct: ((100 * current.i) / Math.max(1, current.queue.length)).toFixed(1),
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
const back = $derived.by(() => {
|
|
135
|
+
const inScales =
|
|
136
|
+
(current && current.mode === 'scales') || (drill.id != null && isScaleDrill(drill.id));
|
|
137
|
+
if (inScales) {
|
|
138
|
+
return { id: 'backscales', n: 'Scales', to: () => go('scales') };
|
|
139
|
+
}
|
|
140
|
+
const group = drill.fam ? groupDef(drill.fam) : null;
|
|
141
|
+
if (group) {
|
|
142
|
+
return { id: 'backfam', n: group.n, to: () => go('fam', { fam: drill.fam }) };
|
|
143
|
+
}
|
|
144
|
+
return { id: 'back', n: 'Practice', to: () => go('home') };
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/* ---- sounds ---- */
|
|
148
|
+
/** The sounding pitch a note question is about, if it has one. */
|
|
149
|
+
function soundingMidi(question: Question): number | null {
|
|
150
|
+
if ('note' in question && question.note) {
|
|
151
|
+
return question.note.m - OCTAVE;
|
|
152
|
+
}
|
|
153
|
+
if ('pos' in question && question.pos) {
|
|
154
|
+
return question.pos.m - OCTAVE;
|
|
155
|
+
}
|
|
156
|
+
if ('target' in question && typeof question.target === 'number') {
|
|
157
|
+
return question.target;
|
|
158
|
+
}
|
|
159
|
+
if ('pc' in question && isFinite(question.pc)) {
|
|
160
|
+
return LOW_E_SOUNDING + pcOf(question.pc - E_PC);
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
function hearIt(question: Question) {
|
|
165
|
+
if ('sid' in question && question.sid != null) {
|
|
166
|
+
playScale(
|
|
167
|
+
question.sid,
|
|
168
|
+
question.root,
|
|
169
|
+
('start' in question && question.start) || DEFAULT_SHAPE_STRING,
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const midi = soundingMidi(question);
|
|
174
|
+
if (midi != null && isFinite(midi)) {
|
|
175
|
+
pluck(midi);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function earPlay() {
|
|
179
|
+
const question = drill.q;
|
|
180
|
+
if (!question) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (question.kind === 'earscale') {
|
|
184
|
+
playScaleEar(question.sid, question.root);
|
|
185
|
+
question.played = true;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (question.kind === 'chhear') {
|
|
189
|
+
pianoChord(question.notes, null, 2.2, 0.75);
|
|
190
|
+
question.played = true;
|
|
191
|
+
} else if (question.kind === 'singit') {
|
|
192
|
+
holdNote(question.root);
|
|
193
|
+
} else if (question.kind === 'ivhear') {
|
|
194
|
+
playInterval(question.root, question.semis, false);
|
|
195
|
+
question.played = true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Sing-the-interval: the starting note again, then the one you should have sung, held. */
|
|
199
|
+
function earAnswer() {
|
|
200
|
+
const question = drill.q;
|
|
201
|
+
if (!question || question.kind !== 'singit') {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const target = question.root + question.semis * (question.dir || 1);
|
|
205
|
+
holdStop();
|
|
206
|
+
piano(question.root, null, 1.1);
|
|
207
|
+
setTimeout(() => holdNote(target), 800);
|
|
208
|
+
}
|
|
209
|
+
/** C2: the tonic of a key is played from here. */
|
|
210
|
+
const TONIC_BASE = 36;
|
|
211
|
+
/** Major-triad offsets above the tonic. */
|
|
212
|
+
const MAJOR_TRIAD = [0, 4, 7];
|
|
213
|
+
/** The key: its triad, then the tonic held. */
|
|
214
|
+
function hearTonic() {
|
|
215
|
+
const question = drill.q;
|
|
216
|
+
if (!question || !('keyPc' in question)) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
holdStop();
|
|
220
|
+
const tonic = TONIC_BASE + OCTAVE + question.keyPc;
|
|
221
|
+
pianoChord(
|
|
222
|
+
MAJOR_TRIAD.map((step) => tonic + step),
|
|
223
|
+
null,
|
|
224
|
+
1.4,
|
|
225
|
+
0.6,
|
|
226
|
+
);
|
|
227
|
+
setTimeout(() => holdNote(tonic), 900);
|
|
228
|
+
}
|
|
229
|
+
/** Sing-the-degree: the tonic, then the degree asked for, held. */
|
|
230
|
+
function singDegAnswer() {
|
|
231
|
+
const question = drill.q;
|
|
232
|
+
if (!question || question.kind !== 'singdeg') {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
holdStop();
|
|
236
|
+
const tonic = TONIC_BASE + OCTAVE + question.keyPc;
|
|
237
|
+
const target = tonic + relOf(question.pc, question.keyPc);
|
|
238
|
+
piano(tonic, null, 0.9, 0.7);
|
|
239
|
+
setTimeout(() => holdNote(target), 700);
|
|
240
|
+
}
|
|
241
|
+
const COUNT_IN_BEATS = 4;
|
|
242
|
+
function playBar() {
|
|
243
|
+
const question = drill.q;
|
|
244
|
+
if (!question || !('bar' in question)) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
rhPlay(question.bar, rhBpm(), { countIn: COUNT_IN_BEATS, beats: true });
|
|
248
|
+
if (question.kind === 'rhear') {
|
|
249
|
+
question.played = true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function toCalibration() {
|
|
253
|
+
go('rcal', { from: 'drill' });
|
|
254
|
+
}
|
|
255
|
+
function toStaffMap() {
|
|
256
|
+
go('ref');
|
|
257
|
+
}
|
|
258
|
+
function keyLabel(pc: number, question: { sys?: string }) {
|
|
259
|
+
const system = question.sys === 'staff' ? 'en' : (question.sys as 'en' | 'solf');
|
|
260
|
+
return noteName(pc, system, pref);
|
|
261
|
+
}
|
|
262
|
+
const tsCap = (ts: TimeSig) => '<b>' + ts[0] + '/' + ts[1] + '</b> — ' + tsSentence(ts);
|
|
263
|
+
const hiOf = boardTopFor;
|
|
264
|
+
const fretsOf = (cells: { f: number }[]) => cells.map((cell) => cell.f);
|
|
265
|
+
|
|
266
|
+
const verdict = $derived(drill.answered && q ? verdictOf(q, drill) : null);
|
|
267
|
+
const tunerLabel = $derived(listen.midi ? bothNames(pcOf(Math.round(listen.midi)), pref) : '—');
|
|
268
|
+
const cents = $derived(
|
|
269
|
+
listen.midi ? Math.round((listen.midi - Math.round(listen.midi)) * 100) : 0,
|
|
270
|
+
);
|
|
271
|
+
const micLive = $derived(micUsable() && honmoon.settings.mic);
|
|
272
|
+
</script>
|
|
273
|
+
|
|
274
|
+
{#snippet tempoRow(showBest: boolean)}
|
|
275
|
+
{@const record = rhRec()}
|
|
276
|
+
{@const bpm = rhBpm()}
|
|
277
|
+
<div class="tempo">
|
|
278
|
+
<button
|
|
279
|
+
class="tbtn"
|
|
280
|
+
data-bpm="-4"
|
|
281
|
+
aria-label="slower"
|
|
282
|
+
disabled={bpm <= rhFloor()}
|
|
283
|
+
onclick={() => rhSetBpm(rhBpm() - TEMPO_NUDGE)}>−</button
|
|
284
|
+
>
|
|
285
|
+
<span class="bv"><b>{bpm}</b> bpm</span>
|
|
286
|
+
<button
|
|
287
|
+
class="tbtn"
|
|
288
|
+
data-bpm="4"
|
|
289
|
+
aria-label="faster"
|
|
290
|
+
disabled={bpm >= TEMPO_MAX}
|
|
291
|
+
onclick={() => rhSetBpm(rhBpm() + TEMPO_NUDGE)}>+</button
|
|
292
|
+
>
|
|
293
|
+
{#if showBest && record.best}<span class="spacer"></span><span class="best"
|
|
294
|
+
>best clean {record.best}</span
|
|
295
|
+
>{/if}
|
|
296
|
+
</div>
|
|
297
|
+
{/snippet}
|
|
298
|
+
|
|
299
|
+
{#snippet skipBar(extra?: 'hearscale' | 'tonic' | 'rplay' | 'hear')}
|
|
300
|
+
<div class="bar">
|
|
301
|
+
{#if extra === 'hearscale'}<button
|
|
302
|
+
class="ghost"
|
|
303
|
+
id="hearscaleq"
|
|
304
|
+
onclick={() =>
|
|
305
|
+
q &&
|
|
306
|
+
'sid' in q &&
|
|
307
|
+
playScale(q.sid, q.root, ('start' in q && q.start) || DEFAULT_SHAPE_STRING)}
|
|
308
|
+
>♪ Hear it</button
|
|
309
|
+
>{/if}
|
|
310
|
+
{#if extra === 'tonic'}<button class="ghost" id="heartonic" onclick={hearTonic}
|
|
311
|
+
>♪ Hear the key</button
|
|
312
|
+
>{/if}
|
|
313
|
+
{#if extra === 'rplay'}<button class="ghost" id="rplay" onclick={playBar}>♪ Hear it</button
|
|
314
|
+
>{/if}
|
|
315
|
+
{#if extra === 'hear'}
|
|
316
|
+
<button class="ghost" id="hear" onclick={() => q && hearIt(q)}>♪ Hear it</button>
|
|
317
|
+
{#if q?.kind === 'staff2name' && hasRoute('ref')}<button
|
|
318
|
+
class="ghost"
|
|
319
|
+
id="tomap"
|
|
320
|
+
onclick={toStaffMap}>Staff map</button
|
|
321
|
+
>{/if}
|
|
322
|
+
{/if}
|
|
323
|
+
<span class="spacer"></span><button class="ghost" id="skip" onclick={skip}>Skip</button>
|
|
324
|
+
</div>
|
|
325
|
+
{/snippet}
|
|
326
|
+
|
|
327
|
+
{#snippet selfPanel(fromStaff: boolean)}
|
|
328
|
+
<div class="card stack">
|
|
329
|
+
<p class="note">
|
|
330
|
+
{micUsable()
|
|
331
|
+
? 'Microphone grading is switched off in Setup.'
|
|
332
|
+
: 'This viewer does not hand the page a microphone, so playing cannot be graded automatically.'}
|
|
333
|
+
Play it, check yourself against the sound, then say how it went. It still counts towards your progress.
|
|
334
|
+
</p>
|
|
335
|
+
<div class="bar">
|
|
336
|
+
<button class="ghost" id="hear" onclick={() => q && hearIt(q)}>♪ Hear it</button>
|
|
337
|
+
{#if fromStaff && hasRoute('ref')}<button class="ghost" id="tomap" onclick={toStaffMap}
|
|
338
|
+
>Staff map</button
|
|
339
|
+
>{/if}
|
|
340
|
+
<span class="spacer"></span><button class="ghost" id="skip" onclick={skip}>Skip</button>
|
|
341
|
+
</div>
|
|
342
|
+
<div class="keys">
|
|
343
|
+
<button class="key v-ok" data-self="ok" onclick={() => selfMark(true)}>I played it</button
|
|
344
|
+
><button class="key v-no" data-self="no" onclick={() => selfMark(false)}>Missed it</button>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
{/snippet}
|
|
348
|
+
|
|
349
|
+
{#if !q}
|
|
350
|
+
<div class="card">
|
|
351
|
+
<p class="lead">
|
|
352
|
+
Nothing to drill at this range yet. Pick another range on the Practice screen.
|
|
353
|
+
</p>
|
|
354
|
+
</div>
|
|
355
|
+
{:else}
|
|
356
|
+
<!-- where you are -->
|
|
357
|
+
{#if current && top}
|
|
358
|
+
<div class="bar">
|
|
359
|
+
<button class="ghost" id="endsess" onclick={endSession}>End session</button>
|
|
360
|
+
<span class="tick">{top.at} / {current.planned}</span>
|
|
361
|
+
{#if top.isRedo}<span class="tag">redo</span>{/if}
|
|
362
|
+
{#if top.pend}<span class="tick">+{top.pend} to redo</span>{/if}
|
|
363
|
+
<span class="spacer"></span><span class="tick">{drillDef ? drillDef.nm : ''}</span>
|
|
364
|
+
</div>
|
|
365
|
+
<div class="prog"><i style="width:{top.pct}%"></i></div>
|
|
366
|
+
{:else}
|
|
367
|
+
<div class="bar">
|
|
368
|
+
<button class="ghost" id={back.id} onclick={back.to}>← {back.n}</button>
|
|
369
|
+
<span class="tick">{drill.session.c} / {drill.session.n} this session</span><span
|
|
370
|
+
class="spacer"
|
|
371
|
+
></span>
|
|
372
|
+
<span class="tick">{drillDef ? drillDef.nm : ''}</span>
|
|
373
|
+
</div>
|
|
374
|
+
{/if}
|
|
375
|
+
|
|
376
|
+
<!-- the question -->
|
|
377
|
+
{#if q.kind === 'fb2name'}
|
|
378
|
+
<div class="stage slim">
|
|
379
|
+
<div class="ask">Which note is marked? Answer in {SYSNAME[q.sys]}</div>
|
|
380
|
+
<div
|
|
381
|
+
class="fbwrap"
|
|
382
|
+
id="fbw"
|
|
383
|
+
use:hscroll={{ frac: fretFrac(q.pos.f, level.lo, vhi()), key: q }}
|
|
384
|
+
>
|
|
385
|
+
<Fretboard
|
|
386
|
+
lo={level.lo}
|
|
387
|
+
hi={vhi()}
|
|
388
|
+
cells={[{ s: q.pos.s, f: q.pos.f, fill: 'var(--accent)', r: 11 }]}
|
|
389
|
+
noLabels={!honmoon.settings.labels}
|
|
390
|
+
/>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
{/if}
|
|
394
|
+
{#if q.kind === 'name2fb'}
|
|
395
|
+
{@const cells = drill.answered
|
|
396
|
+
? [
|
|
397
|
+
answerDot(q.pos, 'var(--good)'),
|
|
398
|
+
...(drill.altCell ? [answerDot(drill.altCell, 'var(--good)')] : []),
|
|
399
|
+
...(drill.wrongCell ? [answerDot(drill.wrongCell, 'var(--bad)')] : []),
|
|
400
|
+
]
|
|
401
|
+
: []}
|
|
402
|
+
<div class="stage">
|
|
403
|
+
<div class="ask">Find this note on the {STRINGS[q.pos.s].n} string</div>
|
|
404
|
+
<div class="bignote">{noteName(q.pc, q.sys, pref)}</div>
|
|
405
|
+
</div>
|
|
406
|
+
<div
|
|
407
|
+
class="fbwrap"
|
|
408
|
+
id="fbw"
|
|
409
|
+
use:hscroll={{
|
|
410
|
+
frac: drill.answered ? fretFrac(q.pos.f, level.lo, vhi()) : null,
|
|
411
|
+
left: drill.answered ? null : drill.fbScroll,
|
|
412
|
+
key: drill.answered ? 'ans' : q,
|
|
413
|
+
}}
|
|
414
|
+
>
|
|
415
|
+
<Fretboard
|
|
416
|
+
lo={level.lo}
|
|
417
|
+
hi={vhi()}
|
|
418
|
+
{cells}
|
|
419
|
+
tap={!drill.answered}
|
|
420
|
+
onlyString={q.pos.s}
|
|
421
|
+
onTap={handleFret}
|
|
422
|
+
/>
|
|
423
|
+
</div>
|
|
424
|
+
{#if !drill.answered}
|
|
425
|
+
<div class="sub">
|
|
426
|
+
Tap the fret on the highlighted {STRINGS[q.pos.s].n} string. Fret 0 is the open string, left of
|
|
427
|
+
the nut{sameNoteFrets(q.pos.s, q.pc).length > 1
|
|
428
|
+
? '. This note is on the string twice, an octave apart — either will do'
|
|
429
|
+
: ''}.
|
|
430
|
+
</div>
|
|
431
|
+
{/if}
|
|
432
|
+
{#if drill.answered && drill.octaveOff}<div class="sub">
|
|
433
|
+
Both spots are that note; the one it had in mind was fret {q.pos.f}.
|
|
434
|
+
</div>{/if}
|
|
435
|
+
{/if}
|
|
436
|
+
{#if q.kind === 'staffkey'}
|
|
437
|
+
{@const keyCells = drill.answered
|
|
438
|
+
? [
|
|
439
|
+
{ pc: q.pc, fill: 'var(--good)', label: noteName(q.pc, 'en', pref) },
|
|
440
|
+
...(drill.wrongKey != null && pcOf(drill.wrongKey) !== q.pc
|
|
441
|
+
? [
|
|
442
|
+
{
|
|
443
|
+
midi: drill.wrongKey,
|
|
444
|
+
fill: 'var(--bad)',
|
|
445
|
+
label: noteName(pcOf(drill.wrongKey), 'en', pref),
|
|
446
|
+
},
|
|
447
|
+
]
|
|
448
|
+
: []),
|
|
449
|
+
]
|
|
450
|
+
: []}
|
|
451
|
+
<div class="stage slim"><div class="ask">Which key is this note?</div></div>
|
|
452
|
+
<div class="staffwrap one">{@html staffSVG(q.note.m, pref, q.clef)}</div>
|
|
453
|
+
<Keyboard lo={48} oct={1} tap={!drill.answered} cells={keyCells} />
|
|
454
|
+
{#if !drill.answered}<div class="sub">
|
|
455
|
+
Any octave — the board shows one. {clefDef(q.clef).n} clef.
|
|
456
|
+
</div>{/if}
|
|
457
|
+
{/if}
|
|
458
|
+
{#if q.kind === 'staff2name' || (q.kind === 'play' && q.sys === 'staff')}
|
|
459
|
+
<div class="stage">
|
|
460
|
+
<div class="ask">
|
|
461
|
+
{q.kind === 'play'
|
|
462
|
+
? 'Read it, then play it'
|
|
463
|
+
: 'Name this note in ' + SYSNAME[q.sys as 'en' | 'solf']}
|
|
464
|
+
</div>
|
|
465
|
+
<div class="staffwrap">{@html staffSVG(q.note!.m, pref, q.clef || 'bass')}</div>
|
|
466
|
+
{#if multiClef()}<div class="tick">{clefDef(q.clef || 'bass').n} clef</div>{/if}
|
|
467
|
+
</div>
|
|
468
|
+
{/if}
|
|
469
|
+
{#if q.kind === 'translate'}
|
|
470
|
+
<div class="stage">
|
|
471
|
+
<div class="ask">Say it in {SYSNAME[q.sys]}</div>
|
|
472
|
+
<div class="bignote">{noteName(q.pc, q.from, pref)}</div>
|
|
473
|
+
</div>
|
|
474
|
+
{/if}
|
|
475
|
+
{#if q.kind === 'play' && q.sys !== 'staff'}
|
|
476
|
+
<div class="stage">
|
|
477
|
+
<div class="ask">Play this note — any octave, anywhere on the neck</div>
|
|
478
|
+
<div class="bignote">{noteName(q.pc, q.sys, pref)}</div>
|
|
479
|
+
</div>
|
|
480
|
+
{/if}
|
|
481
|
+
|
|
482
|
+
<!-- movable do and degrees -->
|
|
483
|
+
{#if q.kind === 'mdo'}
|
|
484
|
+
<div class="stage">
|
|
485
|
+
<div class="keychip">Key of {keyName(q.keyPc)}</div>
|
|
486
|
+
<div class="ask">
|
|
487
|
+
{q.from === 'syll'
|
|
488
|
+
? 'Which note is that, in this key?'
|
|
489
|
+
: 'Which ' +
|
|
490
|
+
(q.mode === 'deg' ? 'scale degree' : 'movable-do syllable') +
|
|
491
|
+
' is this, in this key?'}
|
|
492
|
+
</div>
|
|
493
|
+
{#if q.from === 'staff'}<div class="staffwrap">{@html staffSVG(q.m!, pref, q.clef)}</div>
|
|
494
|
+
{:else if q.from === 'syll'}<div class="bignote">{q.ask}</div>
|
|
495
|
+
{:else}<div class="bignote">{noteName(q.pc, 'en', pref)}</div>{/if}
|
|
496
|
+
{#if q.from === 'staff' && multiClef()}<div class="tick">{clefDef(q.clef!).n} clef</div>{/if}
|
|
497
|
+
</div>
|
|
498
|
+
{/if}
|
|
499
|
+
{#if q.kind === 'deg2fb'}
|
|
500
|
+
{@const degreeCells = drill.answered
|
|
501
|
+
? [
|
|
502
|
+
answerDot(q.pos, 'var(--good)'),
|
|
503
|
+
...(drill.wrongCell ? [answerDot(drill.wrongCell, 'var(--bad)')] : []),
|
|
504
|
+
]
|
|
505
|
+
: []}
|
|
506
|
+
<div class="stage slim">
|
|
507
|
+
<div class="keychip">Key of {keyName(q.keyPc)}</div>
|
|
508
|
+
<div class="ask">Find the {q.ask} of this key on the neck</div>
|
|
509
|
+
<div class="tick">any octave, anywhere it lies</div>
|
|
510
|
+
</div>
|
|
511
|
+
<div class="fbwrap" id="fbw">
|
|
512
|
+
<Fretboard
|
|
513
|
+
lo={level.lo}
|
|
514
|
+
hi={vhi()}
|
|
515
|
+
cells={degreeCells}
|
|
516
|
+
tap={!drill.answered}
|
|
517
|
+
noLabels={!honmoon.settings.labels}
|
|
518
|
+
onTap={handleFret}
|
|
519
|
+
/>
|
|
520
|
+
</div>
|
|
521
|
+
{/if}
|
|
522
|
+
{#if q.kind === 'singdeg'}
|
|
523
|
+
<div class="stage">
|
|
524
|
+
<div class="keychip">Key of {keyName(q.keyPc)}</div>
|
|
525
|
+
<div class="ask">Hear the key, then sing the {q.ask}</div>
|
|
526
|
+
<button class="start" id="heartonic" style="width:auto" onclick={hearTonic}
|
|
527
|
+
>♪ Play the key</button
|
|
528
|
+
>
|
|
529
|
+
<button
|
|
530
|
+
class="mbtn"
|
|
531
|
+
id="hushbtn"
|
|
532
|
+
aria-label="Stop the sound"
|
|
533
|
+
title="Stop the sound"
|
|
534
|
+
onclick={holdStop}>■</button
|
|
535
|
+
>
|
|
536
|
+
</div>
|
|
537
|
+
<div class="card stack">
|
|
538
|
+
<p class="note">
|
|
539
|
+
Sing it, hold it, then check yourself — the answer plays the note you were after and shows
|
|
540
|
+
you where it sits on the neck. Nothing here can hear you, so this one is on your honour.
|
|
541
|
+
</p>
|
|
542
|
+
<div class="bar">
|
|
543
|
+
<button class="ghost" id="singdegans" onclick={singDegAnswer}>♪ Play the answer</button
|
|
544
|
+
><span class="spacer"></span><button class="ghost" id="skip" onclick={skip}>Skip</button>
|
|
545
|
+
</div>
|
|
546
|
+
{#if !drill.answered}<div class="keys">
|
|
547
|
+
<button class="key v-ok" data-self="ok" onclick={() => selfMark(true)}>I got it</button
|
|
548
|
+
><button class="key v-no" data-self="no" onclick={() => selfMark(false)}>Missed it</button
|
|
549
|
+
>
|
|
550
|
+
</div>{/if}
|
|
551
|
+
</div>
|
|
552
|
+
{#if drill.answered}
|
|
553
|
+
<div class="fbwrap">
|
|
554
|
+
<Fretboard
|
|
555
|
+
lo={level.lo}
|
|
556
|
+
hi={vhi()}
|
|
557
|
+
cells={placesFor(q.pc)
|
|
558
|
+
.slice(0, 4)
|
|
559
|
+
.map((place) => answerDot(place, 'var(--accent)'))}
|
|
560
|
+
noLabels={!honmoon.settings.labels}
|
|
561
|
+
/>
|
|
562
|
+
</div>
|
|
563
|
+
{/if}
|
|
564
|
+
{/if}
|
|
565
|
+
|
|
566
|
+
<!-- rhythm -->
|
|
567
|
+
{#if q.kind === 'rvalue'}
|
|
568
|
+
<div class="stage">
|
|
569
|
+
<div class="ask">How long is this {q.rest ? 'rest' : 'note'}? Counting in 4/4</div>
|
|
570
|
+
<div class="staffwrap one">{@html rhOneSVG(q.u, q.rest)}</div>
|
|
571
|
+
</div>
|
|
572
|
+
{/if}
|
|
573
|
+
{#if q.kind === 'rcount'}
|
|
574
|
+
<div class="stage slim"><div class="ask">Which counting matches this bar?</div></div>
|
|
575
|
+
<RhythmBar bar={q.bar} count={drill.answered} />
|
|
576
|
+
{#if hints}<div class="tscap">{@html tsCap(q.bar.ts)}</div>{/if}
|
|
577
|
+
{#if !drill.answered}{@render tempoRow(false)}{/if}
|
|
578
|
+
{/if}
|
|
579
|
+
{#if q.kind === 'rfill'}
|
|
580
|
+
<div class="stage slim">
|
|
581
|
+
<div class="ask">One value is missing. Which one fills the bar?</div>
|
|
582
|
+
<div class="tick">{q.bar.ts[0]}/{q.bar.ts[1]}</div>
|
|
583
|
+
</div>
|
|
584
|
+
<RhythmBar bar={q.bar} blank={drill.answered ? -1 : q.blank} />
|
|
585
|
+
{#if hints}<div class="tscap">{@html tsCap(q.bar.ts)}</div>{/if}
|
|
586
|
+
{/if}
|
|
587
|
+
{#if q.kind === 'rhear'}
|
|
588
|
+
<div class="stage">
|
|
589
|
+
<div class="ask">Which of these was played?</div>
|
|
590
|
+
<button class="start" id="rplay" style="width:auto" onclick={playBar}
|
|
591
|
+
>♪ Play {q.played ? 'again' : 'it'}</button
|
|
592
|
+
>
|
|
593
|
+
<div class="sub">Four clicks to count you in, then the rhythm.</div>
|
|
594
|
+
</div>
|
|
595
|
+
{@render tempoRow(false)}
|
|
596
|
+
{#if hints}<div class="tscap">{@html tsCap(q.bar.ts)}</div>{/if}
|
|
597
|
+
<div class="ropts">
|
|
598
|
+
{#each q.ropts as option, index (index)}
|
|
599
|
+
<button
|
|
600
|
+
class="ropt"
|
|
601
|
+
class:ok={drill.answered && option.ok}
|
|
602
|
+
class:no={drill.answered && !option.ok && index === drill.chosen}
|
|
603
|
+
data-oi={index}
|
|
604
|
+
disabled={drill.answered}
|
|
605
|
+
onclick={() => chooseOption(index)}
|
|
606
|
+
>
|
|
607
|
+
<span class="rn">{index + 1}</span>{@html rhythmSVG(option.b, { scale: 0.8 })}</button
|
|
608
|
+
>
|
|
609
|
+
{/each}
|
|
610
|
+
</div>
|
|
611
|
+
{/if}
|
|
612
|
+
{#if q.kind === 'rtap'}
|
|
613
|
+
<div class="stage slim">
|
|
614
|
+
<div class="ask">Read this bar, then tap it with the click</div>
|
|
615
|
+
<div class="tick">
|
|
616
|
+
{q.bar.ts[0]}/{q.bar.ts[1]} · {rlev().n.toLowerCase()}{honmoon.settings.rlat
|
|
617
|
+
? ' · ' + Math.round(honmoon.settings.rlat * 1000) + ' ms allowed for your screen'
|
|
618
|
+
: ''}
|
|
619
|
+
</div>
|
|
620
|
+
</div>
|
|
621
|
+
<RhythmBar bar={q.bar} count={true} />
|
|
622
|
+
{#if hints && q.phase !== 'run'}<div class="tscap">{@html tsCap(q.bar.ts)}</div>{/if}
|
|
623
|
+
{#if q.phase === 'ready'}
|
|
624
|
+
{@render tempoRow(true)}
|
|
625
|
+
<div class="sec">
|
|
626
|
+
<button class="start" id="rtapgo" onclick={rtapGo}>Count me in</button>
|
|
627
|
+
<div class="bar">
|
|
628
|
+
<button class="ghost" id="rplay" onclick={playBar}>♪ Hear it first</button>
|
|
629
|
+
<span class="spacer"></span>
|
|
630
|
+
{#if hasRoute('rcal')}<button class="ghost" id="rcalib" onclick={toCalibration}
|
|
631
|
+
>Calibrate the tap delay</button
|
|
632
|
+
>{/if}
|
|
633
|
+
</div>
|
|
634
|
+
</div>
|
|
635
|
+
{/if}
|
|
636
|
+
{#if q.phase === 'run'}
|
|
637
|
+
<button
|
|
638
|
+
class="tappad"
|
|
639
|
+
class:live={!q.counting}
|
|
640
|
+
class:flash={tapPad.flash}
|
|
641
|
+
id="tappad"
|
|
642
|
+
onpointerdown={rtapHit}><span>{q.counting ? 'Counting in…' : 'Tap'}</span></button
|
|
643
|
+
>
|
|
644
|
+
<div class="bar">
|
|
645
|
+
<button class="ghost" id="rtapcancel" onclick={rtapCancel}>Stop</button><span class="spacer"
|
|
646
|
+
></span><span class="tick" id="tapcount"
|
|
647
|
+
>{q.taps.length}{q.taps.length === 1 ? ' tap' : ' taps'}</span
|
|
648
|
+
>
|
|
649
|
+
</div>
|
|
650
|
+
{/if}
|
|
651
|
+
{#if drill.answered && q.grade}
|
|
652
|
+
{@const grade = q.grade}
|
|
653
|
+
<div class="card stack">
|
|
654
|
+
<div class="tick">Written above the line, what you played below</div>
|
|
655
|
+
{@html tapTimeline(grade, q.bar, q.gstart!, q.gspu!)}
|
|
656
|
+
<div class="legend">
|
|
657
|
+
<span><b style="background:var(--ink-3)"></b>written</span>
|
|
658
|
+
<span><b style="background:var(--good)"></b>on it</span>
|
|
659
|
+
<span><b style="background:var(--mid)"></b>close</span>
|
|
660
|
+
<span><b style="border:1.6px solid var(--bad);border-radius:50%"></b>nothing played</span>
|
|
661
|
+
<span><b style="background:var(--bad)"></b>tap with no note</span>
|
|
662
|
+
</div>
|
|
663
|
+
<p class="note">
|
|
664
|
+
Every tap you made is drawn, wherever it landed — {grade.taps} registered, {grade.got} matched
|
|
665
|
+
a written note{grade.extra
|
|
666
|
+
? ', ' + grade.extra + ' landed nowhere near one'
|
|
667
|
+
: ''}{grade.extra
|
|
668
|
+
? '. A tap parked at the edge under an arrow was too early or too late to fit on the picture.'
|
|
669
|
+
: '.'}
|
|
670
|
+
</p>
|
|
671
|
+
</div>
|
|
672
|
+
{/if}
|
|
673
|
+
{#if !drill.answered && q.grade === null && q.phase === 'ready'}{@render skipBar()}{/if}
|
|
674
|
+
{/if}
|
|
675
|
+
|
|
676
|
+
<!-- note-name answers -->
|
|
677
|
+
{#if 'opts' in q}
|
|
678
|
+
<div class="keys" id="keys">
|
|
679
|
+
{#each q.opts as pc (pc)}
|
|
680
|
+
<button
|
|
681
|
+
class="key"
|
|
682
|
+
class:ok={drill.answered && pc === q.pc}
|
|
683
|
+
class:no={drill.answered && pc !== q.pc && pc === drill.chosen}
|
|
684
|
+
data-pc={pc}
|
|
685
|
+
disabled={drill.answered}
|
|
686
|
+
onclick={() => choosePc(pc)}>{keyLabel(pc, q)}</button
|
|
687
|
+
>
|
|
688
|
+
{/each}
|
|
689
|
+
</div>
|
|
690
|
+
{/if}
|
|
691
|
+
|
|
692
|
+
<!-- ear -->
|
|
693
|
+
{#if q.kind === 'ivhear' || q.kind === 'chhear'}
|
|
694
|
+
<div class="stage">
|
|
695
|
+
<div class="ask">
|
|
696
|
+
{q.kind === 'chhear' ? 'What kind of chord is this?' : 'How far apart are these two notes?'}
|
|
697
|
+
</div>
|
|
698
|
+
<button class="start" id="earplay" style="width:auto" onclick={earPlay}
|
|
699
|
+
>♪ Play {q.played ? 'again' : 'it'}</button
|
|
700
|
+
>
|
|
701
|
+
<div class="sub">
|
|
702
|
+
{q.kind === 'chhear'
|
|
703
|
+
? 'All the notes together.' +
|
|
704
|
+
(chPool().length < 3
|
|
705
|
+
? ' Major against minor is the first thing to hear; widen the interval set in this family and seven types join in.'
|
|
706
|
+
: '')
|
|
707
|
+
: 'Played one after the other, low to high.'}
|
|
708
|
+
</div>
|
|
709
|
+
</div>
|
|
710
|
+
{/if}
|
|
711
|
+
{#if q.kind === 'earscale'}
|
|
712
|
+
<div class="stage">
|
|
713
|
+
<div class="ask">Which scale was that?</div>
|
|
714
|
+
<button class="start" id="earplay" style="width:auto" onclick={earPlay}
|
|
715
|
+
>♪ Play {q.played ? 'again' : 'it'}</button
|
|
716
|
+
>
|
|
717
|
+
<div class="sub">
|
|
718
|
+
Straight up from the root to the octave. Listen for where the small steps fall.
|
|
719
|
+
</div>
|
|
720
|
+
</div>
|
|
721
|
+
{/if}
|
|
722
|
+
{#if q.kind === 'ivsee'}
|
|
723
|
+
{@const lastFret = hiOf([q.a.f, q.b.f])}
|
|
724
|
+
<div class="stage slim"><div class="ask">From 1 to 2 — how far is that?</div></div>
|
|
725
|
+
<div
|
|
726
|
+
class="fbwrap"
|
|
727
|
+
id="fbw"
|
|
728
|
+
use:hscroll={{ frac: fretFrac(Math.round((q.a.f + q.b.f) / 2), 0, lastFret), key: q }}
|
|
729
|
+
>
|
|
730
|
+
<Fretboard
|
|
731
|
+
lo={0}
|
|
732
|
+
hi={lastFret}
|
|
733
|
+
cells={[
|
|
734
|
+
{
|
|
735
|
+
s: q.a.s,
|
|
736
|
+
f: q.a.f,
|
|
737
|
+
fill: 'var(--accent)',
|
|
738
|
+
r: 11,
|
|
739
|
+
label: '1',
|
|
740
|
+
fs: 11,
|
|
741
|
+
tc: 'var(--accent-ink)',
|
|
742
|
+
},
|
|
743
|
+
{ s: q.b.s, f: q.b.f, fill: 'var(--good)', r: 11, label: '2', fs: 11, tc: 'var(--bg)' },
|
|
744
|
+
]}
|
|
745
|
+
/>
|
|
746
|
+
</div>
|
|
747
|
+
{/if}
|
|
748
|
+
{#if q.kind === 'ivbuild'}
|
|
749
|
+
{@const buildCells = [
|
|
750
|
+
{
|
|
751
|
+
s: q.base.s,
|
|
752
|
+
f: q.base.f,
|
|
753
|
+
fill: 'var(--accent)',
|
|
754
|
+
r: 11,
|
|
755
|
+
label: 'R',
|
|
756
|
+
fs: 11,
|
|
757
|
+
tc: 'var(--accent-ink)',
|
|
758
|
+
},
|
|
759
|
+
...(drill.answered
|
|
760
|
+
? [
|
|
761
|
+
answerDot(q.target, 'var(--good)'),
|
|
762
|
+
...(drill.wrongCell ? [answerDot(drill.wrongCell, 'var(--bad)')] : []),
|
|
763
|
+
]
|
|
764
|
+
: []),
|
|
765
|
+
]}
|
|
766
|
+
<div class="stage slim">
|
|
767
|
+
<div class="ask">
|
|
768
|
+
Tap {anA(ivDef(q.semis).n.toLowerCase())} above the marked note, on the same string
|
|
769
|
+
</div>
|
|
770
|
+
<div class="tick">
|
|
771
|
+
{bothNames(pcOf(q.rootMidi), pref)} on the {STRINGS[q.base.s].n} string
|
|
772
|
+
</div>
|
|
773
|
+
</div>
|
|
774
|
+
<div class="fbwrap" id="fbw">
|
|
775
|
+
<Fretboard
|
|
776
|
+
lo={0}
|
|
777
|
+
hi={hiOf([q.target.f])}
|
|
778
|
+
cells={buildCells}
|
|
779
|
+
tap={!drill.answered}
|
|
780
|
+
onlyString={q.base.s}
|
|
781
|
+
onTap={handleFret}
|
|
782
|
+
/>
|
|
783
|
+
</div>
|
|
784
|
+
{/if}
|
|
785
|
+
{#if q.kind === 'singit'}
|
|
786
|
+
<div class="stage">
|
|
787
|
+
<div class="ask">
|
|
788
|
+
You will hear a note. Sing {anA(ivDef(q.semis).n.toLowerCase())}
|
|
789
|
+
{q.dir > 0 ? 'above' : 'below'} it
|
|
790
|
+
</div>
|
|
791
|
+
<button class="start" id="earplay" style="width:auto" onclick={earPlay}
|
|
792
|
+
>♪ Play and hold the note</button
|
|
793
|
+
>
|
|
794
|
+
<button
|
|
795
|
+
class="mbtn"
|
|
796
|
+
id="hushbtn"
|
|
797
|
+
aria-label="Stop the sound"
|
|
798
|
+
title="Stop the sound"
|
|
799
|
+
onclick={holdStop}>■</button
|
|
800
|
+
>
|
|
801
|
+
{#if drill.answered}<div class="sub">
|
|
802
|
+
{midiName(q.root)} → {midiName(q.root + q.semis * q.dir)}
|
|
803
|
+
</div>{/if}
|
|
804
|
+
</div>
|
|
805
|
+
{/if}
|
|
806
|
+
|
|
807
|
+
<!-- scales -->
|
|
808
|
+
{#if q.kind === 'scbuild'}
|
|
809
|
+
{@const shape = q.shape}
|
|
810
|
+
{@const scaleCells = [
|
|
811
|
+
...shape.pos.slice(0, q.idx).map((pos) => ({
|
|
812
|
+
s: pos.s,
|
|
813
|
+
f: pos.f,
|
|
814
|
+
fill: 'var(--good)',
|
|
815
|
+
r: 11,
|
|
816
|
+
label: degPretty(pos.deg.deg),
|
|
817
|
+
fs: 11,
|
|
818
|
+
tc: 'var(--bg)',
|
|
819
|
+
})),
|
|
820
|
+
...(q.wrong ? [answerDot(q.wrong, 'var(--bad)')] : []),
|
|
821
|
+
...(q.reveal && q.idx < shape.pos.length
|
|
822
|
+
? [
|
|
823
|
+
{
|
|
824
|
+
s: shape.pos[q.idx].s,
|
|
825
|
+
f: shape.pos[q.idx].f,
|
|
826
|
+
fill: 'none',
|
|
827
|
+
stroke: 'var(--accent)',
|
|
828
|
+
sw: 2.4,
|
|
829
|
+
r: 12,
|
|
830
|
+
},
|
|
831
|
+
]
|
|
832
|
+
: []),
|
|
833
|
+
...(drill.answered
|
|
834
|
+
? shape.pos.slice(q.idx).map((pos) => ({
|
|
835
|
+
s: pos.s,
|
|
836
|
+
f: pos.f,
|
|
837
|
+
fill: 'var(--accent)',
|
|
838
|
+
r: 11,
|
|
839
|
+
label: degPretty(pos.deg.deg),
|
|
840
|
+
fs: 11,
|
|
841
|
+
tc: 'var(--accent-ink)',
|
|
842
|
+
}))
|
|
843
|
+
: []),
|
|
844
|
+
]}
|
|
845
|
+
<div class="stage slim">
|
|
846
|
+
<div class="ask">
|
|
847
|
+
Tap {scaleName(q.sid, q.root)} in order, from the {STRINGS[q.start].n} string
|
|
848
|
+
</div>
|
|
849
|
+
<div class="tick">
|
|
850
|
+
{q.idx} / {shape.pos.length}{q.misses ? ' · ' + q.misses + ' wrong' : ''}
|
|
851
|
+
</div>
|
|
852
|
+
</div>
|
|
853
|
+
<div class="fbwrap" id="fbw">
|
|
854
|
+
<Fretboard
|
|
855
|
+
lo={0}
|
|
856
|
+
hi={hiOf(fretsOf(shape.pos))}
|
|
857
|
+
cells={scaleCells}
|
|
858
|
+
tap={!drill.answered}
|
|
859
|
+
onTap={handleFret}
|
|
860
|
+
/>
|
|
861
|
+
</div>
|
|
862
|
+
{#if !drill.answered}<div class="sub">
|
|
863
|
+
Ascending, starting on the root. Two wrong taps and it will show you the next note.
|
|
864
|
+
</div>{/if}
|
|
865
|
+
{/if}
|
|
866
|
+
{#if q.kind === 'scwrong'}
|
|
867
|
+
{@const wrongCells = [
|
|
868
|
+
...q.shown.map((pos, index) => ({
|
|
869
|
+
s: pos.s,
|
|
870
|
+
f: pos.f,
|
|
871
|
+
fill:
|
|
872
|
+
drill.answered && (index === q.bad || index === drill.chosenIdx)
|
|
873
|
+
? 'var(--bad)'
|
|
874
|
+
: 'var(--accent)',
|
|
875
|
+
r: 11,
|
|
876
|
+
label: drill.answered ? degPretty(pos.deg.deg) : '',
|
|
877
|
+
fs: 11,
|
|
878
|
+
tc: 'var(--accent-ink)',
|
|
879
|
+
})),
|
|
880
|
+
...(drill.answered
|
|
881
|
+
? [
|
|
882
|
+
{
|
|
883
|
+
s: q.shape.pos[q.bad].s,
|
|
884
|
+
f: q.shape.pos[q.bad].f,
|
|
885
|
+
fill: 'var(--good)',
|
|
886
|
+
r: 11,
|
|
887
|
+
label: degPretty(q.shape.pos[q.bad].deg.deg),
|
|
888
|
+
fs: 11,
|
|
889
|
+
tc: 'var(--bg)',
|
|
890
|
+
},
|
|
891
|
+
]
|
|
892
|
+
: []),
|
|
893
|
+
]}
|
|
894
|
+
{@const lastFret = hiOf(fretsOf(q.shown))}
|
|
895
|
+
<div class="stage slim">
|
|
896
|
+
<div class="ask">{scaleName(q.sid, q.root)} — one note has moved a fret. Tap it.</div>
|
|
897
|
+
</div>
|
|
898
|
+
<div
|
|
899
|
+
class="fbwrap"
|
|
900
|
+
id="fbw"
|
|
901
|
+
use:hscroll={{ frac: fretFrac(midFret(q.shown), 0, lastFret), key: q }}
|
|
902
|
+
>
|
|
903
|
+
<Fretboard
|
|
904
|
+
lo={0}
|
|
905
|
+
hi={lastFret}
|
|
906
|
+
cells={wrongCells}
|
|
907
|
+
tap={!drill.answered}
|
|
908
|
+
only={q.shown.map((pos) => ({ s: pos.s, f: pos.f }))}
|
|
909
|
+
onTap={handleFret}
|
|
910
|
+
/>
|
|
911
|
+
</div>
|
|
912
|
+
{/if}
|
|
913
|
+
{#if q.kind === 'scname'}
|
|
914
|
+
{@const lastFret = hiOf(fretsOf(q.shape.pos))}
|
|
915
|
+
<div class="stage slim">
|
|
916
|
+
<div class="ask">Root {rootName(q.root)} shown in accent. Which scale is this?</div>
|
|
917
|
+
</div>
|
|
918
|
+
<div
|
|
919
|
+
class="fbwrap"
|
|
920
|
+
id="fbw"
|
|
921
|
+
use:hscroll={{ frac: fretFrac(midFret(q.shape.pos), 0, lastFret), key: q }}
|
|
922
|
+
>
|
|
923
|
+
<Fretboard
|
|
924
|
+
lo={0}
|
|
925
|
+
hi={lastFret}
|
|
926
|
+
cells={q.shape.pos.map((pos) => ({
|
|
927
|
+
s: pos.s,
|
|
928
|
+
f: pos.f,
|
|
929
|
+
fill: pos.deg.semis % OCTAVE === 0 ? 'var(--accent)' : 'var(--good)',
|
|
930
|
+
r: 11,
|
|
931
|
+
}))}
|
|
932
|
+
/>
|
|
933
|
+
</div>
|
|
934
|
+
{/if}
|
|
935
|
+
{#if q.kind === 'scstaff'}
|
|
936
|
+
<div class="stage slim">
|
|
937
|
+
<div class="ask">
|
|
938
|
+
It starts on {rootName(q.root)}. Which {scaleNoun(q.sid)} is written here?
|
|
939
|
+
</div>
|
|
940
|
+
</div>
|
|
941
|
+
<div class="card">
|
|
942
|
+
{@html degStaffSVG(q.sid, q.root, null, null, { labels: drill.answered ? 'both' : 'none' })}
|
|
943
|
+
</div>
|
|
944
|
+
{#if !drill.answered}<div class="sub">
|
|
945
|
+
Bass clef, one octave up from the root. Read the sharps and flats against the major scale on {rootName(
|
|
946
|
+
q.root,
|
|
947
|
+
)}.
|
|
948
|
+
</div>{/if}
|
|
949
|
+
{/if}
|
|
950
|
+
{#if q.kind === 'scwrite'}
|
|
951
|
+
<div class="stage slim">
|
|
952
|
+
<div class="ask">Which staff shows {scaleName(q.sid, q.root)}?</div>
|
|
953
|
+
</div>
|
|
954
|
+
<div class="ropts">
|
|
955
|
+
{#each q.sopts as option, index (index)}
|
|
956
|
+
<button
|
|
957
|
+
class="ropt sopt"
|
|
958
|
+
class:ok={drill.answered && option.ok}
|
|
959
|
+
class:no={drill.answered && !option.ok && index === drill.chosen}
|
|
960
|
+
data-oi={index}
|
|
961
|
+
disabled={drill.answered}
|
|
962
|
+
onclick={() => chooseOption(index)}
|
|
963
|
+
>
|
|
964
|
+
<span class="rn">{index + 1}</span>
|
|
965
|
+
{@html degStaffSVG(option.sid, q.root, null, null, {
|
|
966
|
+
labels: drill.answered ? 'letters' : 'none',
|
|
967
|
+
alter: option.alter,
|
|
968
|
+
})}
|
|
969
|
+
{#if drill.answered}<span class="sname"
|
|
970
|
+
>{option.alter
|
|
971
|
+
? scaleName(option.sid, q.root) + ' with one note moved — not a real scale'
|
|
972
|
+
: scaleName(option.sid, q.root)}</span
|
|
973
|
+
>{/if}
|
|
974
|
+
</button>
|
|
975
|
+
{/each}
|
|
976
|
+
</div>
|
|
977
|
+
{/if}
|
|
978
|
+
{#if q.kind === 'scsteps'}
|
|
979
|
+
<div class="stage">
|
|
980
|
+
<div class="ask">
|
|
981
|
+
Which {scaleNoun(q.sid)} has {q.form === 'steps' ? 'these steps' : 'this formula'}?
|
|
982
|
+
</div>
|
|
983
|
+
{#if q.form === 'steps'}{@html stepsOnlyHTML(q.sid)}{:else}<div
|
|
984
|
+
class="dform"
|
|
985
|
+
style="align-items:center"
|
|
986
|
+
>
|
|
987
|
+
<span>{scaleDef(q.sid).dg.map(degPretty).join(' ')}</span>
|
|
988
|
+
</div>{/if}
|
|
989
|
+
<div class="sub">
|
|
990
|
+
{q.form === 'steps'
|
|
991
|
+
? 'Frets from each note to the next, starting on the root and ending on the octave.'
|
|
992
|
+
: 'Every note measured from the root. Plain numbers are where the major scale has them; ♭ is a fret lower.'}
|
|
993
|
+
</div>
|
|
994
|
+
</div>
|
|
995
|
+
{/if}
|
|
996
|
+
{#if q.kind === 'screcipe'}
|
|
997
|
+
<div class="stage">
|
|
998
|
+
<div class="ask">
|
|
999
|
+
{q.form === 'steps' ? 'What are the steps of' : 'What is the formula of'}
|
|
1000
|
+
</div>
|
|
1001
|
+
<div class="bignote" style="font-size:clamp(26px,8vw,38px)">{scaleDef(q.sid).n}</div>
|
|
1002
|
+
<div class="sub">
|
|
1003
|
+
{q.form === 'steps'
|
|
1004
|
+
? 'Frets from each note to the next, root to octave.'
|
|
1005
|
+
: 'Each note as a degree, counting the root as 1.'}
|
|
1006
|
+
</div>
|
|
1007
|
+
</div>
|
|
1008
|
+
{/if}
|
|
1009
|
+
{#if q.kind === 'scdeg'}
|
|
1010
|
+
<div class="stage">
|
|
1011
|
+
<div class="ask">In {scaleName(q.sid, q.root)}</div>
|
|
1012
|
+
<div class="bignote">{q.ask}</div>
|
|
1013
|
+
<div class="sub">
|
|
1014
|
+
{q.dir === 'note' ? 'which note is that degree?' : 'which degree is that note?'}
|
|
1015
|
+
</div>
|
|
1016
|
+
</div>
|
|
1017
|
+
{/if}
|
|
1018
|
+
{#if q.kind === 'scplay'}
|
|
1019
|
+
<div class="stage">
|
|
1020
|
+
<div class="ask">Play this scale, ascending</div>
|
|
1021
|
+
{#if q.show === 'name'}<div class="bignote" style="font-size:clamp(26px,8vw,40px)">
|
|
1022
|
+
{scaleName(q.sid, q.root)}
|
|
1023
|
+
</div>
|
|
1024
|
+
{:else if q.show === 'letters'}<div class="notesrow">
|
|
1025
|
+
{#each scaleNotes(q.sid, q.root) as note, i (i)}<span class="nchip"
|
|
1026
|
+
><b>{noteText(note)}</b></span
|
|
1027
|
+
>{/each}
|
|
1028
|
+
</div>
|
|
1029
|
+
{:else}<div class="fbwrap">
|
|
1030
|
+
<StaffRun
|
|
1031
|
+
midis={scaleShape(q.sid, q.root, q.start, 1).pos.map(
|
|
1032
|
+
(pos) => STRINGS[pos.s].open + pos.f,
|
|
1033
|
+
)}
|
|
1034
|
+
{pref}
|
|
1035
|
+
tappable={false}
|
|
1036
|
+
/>
|
|
1037
|
+
</div>{/if}
|
|
1038
|
+
</div>
|
|
1039
|
+
{#if drill.answered}
|
|
1040
|
+
{@const board = scaleBoard(q.sid, q.root, q.start, 'deg')}
|
|
1041
|
+
<div class="fbwrap" use:hscroll={{ frac: fretFrac(board.cf, board.lo, board.hi), key: q }}>
|
|
1042
|
+
<Fretboard lo={board.lo} hi={board.hi} cells={board.cells} />
|
|
1043
|
+
</div>
|
|
1044
|
+
{/if}
|
|
1045
|
+
{/if}
|
|
1046
|
+
|
|
1047
|
+
<!-- multiple choice -->
|
|
1048
|
+
{#if 'choices' in q}
|
|
1049
|
+
<div class="keys" class:countopts={q.kind === 'rcount' || q.kind === 'screcipe'} id="keys">
|
|
1050
|
+
{#each q.choices as choice, index (index)}
|
|
1051
|
+
<button
|
|
1052
|
+
class="key"
|
|
1053
|
+
class:ok={drill.answered && choice.ok}
|
|
1054
|
+
class:no={drill.answered && !choice.ok && index === drill.chosen}
|
|
1055
|
+
data-oi={index}
|
|
1056
|
+
disabled={drill.answered}
|
|
1057
|
+
onclick={() => chooseOption(index)}>{choice.t}</button
|
|
1058
|
+
>
|
|
1059
|
+
{/each}
|
|
1060
|
+
</div>
|
|
1061
|
+
{/if}
|
|
1062
|
+
{#if q.kind === 'singit'}
|
|
1063
|
+
<div class="card stack">
|
|
1064
|
+
<p class="note">
|
|
1065
|
+
Sing it, then check yourself — the answer plays the note you were aiming for. Nothing here
|
|
1066
|
+
can hear you, so this one is on your honour.
|
|
1067
|
+
</p>
|
|
1068
|
+
<div class="bar">
|
|
1069
|
+
<button class="ghost" id="earanswer" onclick={earAnswer}>♪ Play the answer</button><span
|
|
1070
|
+
class="spacer"
|
|
1071
|
+
></span><button class="ghost" id="skip" onclick={skip}>Skip</button>
|
|
1072
|
+
</div>
|
|
1073
|
+
{#if !drill.answered}<div class="keys">
|
|
1074
|
+
<button class="key v-ok" data-self="ok" onclick={() => selfMark(true)}>I got it</button
|
|
1075
|
+
><button class="key v-no" data-self="no" onclick={() => selfMark(false)}>Missed it</button
|
|
1076
|
+
>
|
|
1077
|
+
</div>{/if}
|
|
1078
|
+
</div>
|
|
1079
|
+
{/if}
|
|
1080
|
+
{#if isEar(q.kind) && q.kind !== 'singit' && !drill.answered}{@render skipBar()}{/if}
|
|
1081
|
+
{#if q.kind === 'scplay'}{@render selfPanel(false)}
|
|
1082
|
+
{:else if isSc(q.kind) && !drill.answered}{@render skipBar('hearscale')}{/if}
|
|
1083
|
+
|
|
1084
|
+
<!-- play along -->
|
|
1085
|
+
{#if q.kind === 'play'}
|
|
1086
|
+
{#if !micLive}{@render selfPanel(q.sys === 'staff')}
|
|
1087
|
+
{:else}
|
|
1088
|
+
<div class="card stack">
|
|
1089
|
+
{#if listen.reason}
|
|
1090
|
+
<div class="failbox">
|
|
1091
|
+
<div class="ft">{listen.reason.t}</div>
|
|
1092
|
+
<p class="note" style="margin:4px 0 0">{listen.reason.m}</p>
|
|
1093
|
+
</div>
|
|
1094
|
+
{:else}
|
|
1095
|
+
<div class="tuner">
|
|
1096
|
+
<span class="nt">{tunerLabel}</span><span class="hz"
|
|
1097
|
+
>{listen.freq ? listen.freq.toFixed(1) + ' Hz' : 'listening'}</span
|
|
1098
|
+
>
|
|
1099
|
+
</div>
|
|
1100
|
+
<div class="cents">
|
|
1101
|
+
<span class="mid"></span><span
|
|
1102
|
+
class="ndl"
|
|
1103
|
+
style="left:{50 + Math.max(-50, Math.min(50, cents)) * 0.9}%"
|
|
1104
|
+
></span>
|
|
1105
|
+
</div>
|
|
1106
|
+
<div class="meter">
|
|
1107
|
+
<i style="width:{Math.min(100, Math.round((listen.rms * 100) / 0.06))}%"></i>
|
|
1108
|
+
</div>
|
|
1109
|
+
<p class="note">
|
|
1110
|
+
{q.sys === 'staff'
|
|
1111
|
+
? 'Play it on any string — the octave is checked, the position is not.'
|
|
1112
|
+
: 'Any octave, anywhere on the neck.'} It needs about a second of clean note — let it ring.
|
|
1113
|
+
</p>
|
|
1114
|
+
{/if}
|
|
1115
|
+
<div class="bar">
|
|
1116
|
+
<button class="ghost" id="hear" onclick={() => hearIt(q)}>♪ Hear it</button>
|
|
1117
|
+
{#if q.sys === 'staff'}<button class="ghost" id="tapinstead" onclick={tapInstead}
|
|
1118
|
+
>Answer by tapping</button
|
|
1119
|
+
>{#if hasRoute('ref')}<button class="ghost" id="tomap" onclick={toStaffMap}
|
|
1120
|
+
>Staff map</button
|
|
1121
|
+
>{/if}{/if}
|
|
1122
|
+
<span class="spacer"></span><button class="ghost" id="skip" onclick={skip}>Skip</button>
|
|
1123
|
+
</div>
|
|
1124
|
+
</div>
|
|
1125
|
+
{/if}
|
|
1126
|
+
{/if}
|
|
1127
|
+
|
|
1128
|
+
<!-- the newer drills explain themselves -->
|
|
1129
|
+
{#if drillDef && drillDef.how && hints && !drill.answered}
|
|
1130
|
+
<div class="howbox">
|
|
1131
|
+
<span class="q">?</span>
|
|
1132
|
+
<div>
|
|
1133
|
+
<div class="ht">{drillDef.nm}</div>
|
|
1134
|
+
<p>{@html drillDef.how}</p>
|
|
1135
|
+
{#if drillDef.kind === 'rhythm' && hasRoute('rref')}<div class="bar" style="margin-top:8px">
|
|
1136
|
+
<button class="ghost" onclick={() => go('rref')}>How rhythm is written</button>
|
|
1137
|
+
</div>{/if}
|
|
1138
|
+
</div>
|
|
1139
|
+
</div>
|
|
1140
|
+
{/if}
|
|
1141
|
+
|
|
1142
|
+
<!-- the verdict, or the controls -->
|
|
1143
|
+
{#if verdict}
|
|
1144
|
+
<div class="verdict" class:ok={verdict.ok} class:no={!verdict.ok}>
|
|
1145
|
+
<span class="txt">{verdict.txt}</span><button class="next" id="next" onclick={nextQ}
|
|
1146
|
+
>{verdict.ok ? 'Next' : 'Got it'}</button
|
|
1147
|
+
>
|
|
1148
|
+
</div>
|
|
1149
|
+
{#each verdict.subs as line, i (i)}<div class="sub" style="text-align:left">{line}</div>{/each}
|
|
1150
|
+
{:else if isNew(q.kind) && q.kind !== 'rtap' && q.kind !== 'singdeg'}
|
|
1151
|
+
{@render skipBar(newKindExtra(q.kind))}
|
|
1152
|
+
{:else if q.kind !== 'play' && !isEar(q.kind) && !isNew(q.kind) && !isSc(q.kind)}
|
|
1153
|
+
{@render skipBar('hear')}
|
|
1154
|
+
{/if}
|
|
1155
|
+
{/if}
|