@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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +85 -2
  3. package/package.json +58 -4
  4. package/src/lib/audio/context.ts +29 -0
  5. package/src/lib/audio/metronome.svelte.ts +146 -0
  6. package/src/lib/audio/mic.ts +254 -0
  7. package/src/lib/audio/pitch.ts +133 -0
  8. package/src/lib/audio/rhythmplay.ts +152 -0
  9. package/src/lib/audio/synth.ts +409 -0
  10. package/src/lib/audio/voiceplay.ts +60 -0
  11. package/src/lib/drills/defs.ts +459 -0
  12. package/src/lib/drills/questions.ts +920 -0
  13. package/src/lib/drills/registry.svelte.ts +167 -0
  14. package/src/lib/drills/runtime.svelte.ts +629 -0
  15. package/src/lib/drills/verdict.ts +241 -0
  16. package/src/lib/index.ts +117 -0
  17. package/src/lib/learn/describe.ts +72 -0
  18. package/src/lib/learn/pools.ts +44 -0
  19. package/src/lib/learn/progress.ts +100 -0
  20. package/src/lib/learn/session.ts +304 -0
  21. package/src/lib/music/bass.ts +139 -0
  22. package/src/lib/music/clefs.ts +218 -0
  23. package/src/lib/music/intervals.ts +94 -0
  24. package/src/lib/music/notes.ts +133 -0
  25. package/src/lib/music/rhythm.ts +624 -0
  26. package/src/lib/music/scales.ts +560 -0
  27. package/src/lib/music/voice.ts +227 -0
  28. package/src/lib/music/warmups.ts +318 -0
  29. package/src/lib/nav.svelte.ts +53 -0
  30. package/src/lib/notation/fretboard.ts +187 -0
  31. package/src/lib/notation/glyphdata.ts +101 -0
  32. package/src/lib/notation/glyphs.ts +71 -0
  33. package/src/lib/notation/icons.ts +12 -0
  34. package/src/lib/notation/keyboard.ts +166 -0
  35. package/src/lib/notation/rhythmsvg.ts +341 -0
  36. package/src/lib/notation/staff.ts +288 -0
  37. package/src/lib/notation/svg.ts +37 -0
  38. package/src/lib/notation/tab.ts +80 -0
  39. package/src/lib/store/honmoon.svelte.ts +329 -0
  40. package/src/lib/store/normalize.ts +23 -0
  41. package/src/lib/store/sync.ts +43 -0
  42. package/src/lib/store/types.ts +101 -0
  43. package/src/lib/styles/honmoon.css +2064 -0
  44. package/src/lib/ui/AccRow.svelte +23 -0
  45. package/src/lib/ui/BackBar.svelte +23 -0
  46. package/src/lib/ui/BassRow.svelte +30 -0
  47. package/src/lib/ui/Chips.svelte +23 -0
  48. package/src/lib/ui/DataCard.svelte +66 -0
  49. package/src/lib/ui/Fretboard.svelte +71 -0
  50. package/src/lib/ui/Keyboard.svelte +54 -0
  51. package/src/lib/ui/MetronomePanel.svelte +101 -0
  52. package/src/lib/ui/RefBar.svelte +28 -0
  53. package/src/lib/ui/RhythmBar.svelte +9 -0
  54. package/src/lib/ui/Shell.svelte +96 -0
  55. package/src/lib/ui/StaffRun.svelte +46 -0
  56. package/src/lib/ui/Stat.svelte +13 -0
  57. package/src/lib/ui/actions.ts +36 -0
  58. package/src/lib/ui/keyfollow.svelte.ts +48 -0
  59. package/src/lib/ui/overlay.ts +15 -0
  60. package/src/lib/ui/refdata.ts +355 -0
  61. package/src/lib/ui/scaleboard.ts +420 -0
  62. package/src/lib/ui/screens/DegreesRef.svelte +405 -0
  63. package/src/lib/ui/screens/DrillScreen.svelte +1155 -0
  64. package/src/lib/ui/screens/Explorer.svelte +245 -0
  65. package/src/lib/ui/screens/FamilyScreen.svelte +280 -0
  66. package/src/lib/ui/screens/Glossary.svelte +65 -0
  67. package/src/lib/ui/screens/IntervalRef.svelte +37 -0
  68. package/src/lib/ui/screens/LearnIndex.svelte +44 -0
  69. package/src/lib/ui/screens/MetronomeScreen.svelte +17 -0
  70. package/src/lib/ui/screens/PracticeHome.svelte +159 -0
  71. package/src/lib/ui/screens/RhythmRef.svelte +239 -0
  72. package/src/lib/ui/screens/ScalesScreen.svelte +210 -0
  73. package/src/lib/ui/screens/SessionDone.svelte +119 -0
  74. package/src/lib/ui/screens/SetupScreen.svelte +548 -0
  75. package/src/lib/ui/screens/StaffMap.svelte +230 -0
  76. package/src/lib/ui/screens/StatsScreen.svelte +478 -0
  77. package/src/lib/ui/screens/TapCalibration.svelte +186 -0
  78. package/src/lib/ui/tapfix.ts +93 -0
  79. package/src/lib/ui/types.ts +40 -0
  80. package/src/lib/util/list.ts +4 -0
  81. package/src/lib/util/random.ts +23 -0
  82. 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}