@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,119 @@
1
+ <!-- The summary after a session: how it went, what it taught you, what is still shaky. -->
2
+ <script lang="ts">
3
+ import { minsSecs } from '../../util/text';
4
+ import { describeCard } from '../../learn/describe';
5
+ import { streakDays } from '../../learn/progress';
6
+ import {
7
+ session,
8
+ startMissSession,
9
+ startSession,
10
+ stillMissed,
11
+ } from '../../drills/runtime.svelte';
12
+ import { go } from '../../nav.svelte';
13
+ import Stat from '../Stat.svelte';
14
+
15
+ const current = $derived(session.current);
16
+ const summary = $derived.by(() => {
17
+ if (!current) {
18
+ return null;
19
+ }
20
+ const total = current.results.length;
21
+ const right = current.right;
22
+ const pct = total ? Math.round((100 * right) / total) : 0;
23
+ /* A card counts as shaky if it was missed and never got a clean pass after. */
24
+ const last: Record<string, boolean> = {};
25
+ for (const result of current.results) {
26
+ last[result.card] = result.ok;
27
+ }
28
+ const missed: string[] = [];
29
+ const fixed: string[] = [];
30
+ const seen: Record<string, 1> = {};
31
+ for (const result of current.results) {
32
+ if (result.ok || seen[result.card]) {
33
+ continue;
34
+ }
35
+ seen[result.card] = 1;
36
+ (last[result.card] ? fixed : missed).push(result.card);
37
+ }
38
+ return { total, right, pct, missed, fixed };
39
+ });
40
+ </script>
41
+
42
+ {#if current && summary}
43
+ <div class="sec">
44
+ <h2>Session done</h2>
45
+ <Stat
46
+ items={[
47
+ { v: summary.right + '/' + summary.total, k: 'Correct' },
48
+ { v: summary.pct + '%', k: 'Accuracy' },
49
+ { v: minsSecs(current.secs || 0), k: 'Time' },
50
+ { v: streakDays(), k: 'Day streak' },
51
+ ]}
52
+ />
53
+ <p class="note">
54
+ {current.planned} cards planned{current.redos
55
+ ? ', ' +
56
+ current.redos +
57
+ ' of them missed and sent round a second time — ' +
58
+ summary.total +
59
+ ' questions in all'
60
+ : ''}.
61
+ </p>
62
+ </div>
63
+ {#if summary.fixed.length}
64
+ <div class="sec">
65
+ <h2>Missed, then got right</h2>
66
+ <div class="card stack">
67
+ {#each summary.fixed.slice(0, 8) as card (card)}<div class="setrow">
68
+ <span class="lbl">{describeCard(card)}</span><span
69
+ class="tick"
70
+ style="color:var(--good)">fixed</span
71
+ >
72
+ </div>{/each}
73
+ </div>
74
+ <p class="note">
75
+ These are the ones the session actually taught you — you saw them again a few questions
76
+ later and got them.
77
+ </p>
78
+ </div>
79
+ {/if}
80
+ {#if summary.missed.length}
81
+ <div class="sec">
82
+ <h2>Still shaky</h2>
83
+ <div class="card stack">
84
+ {#each summary.missed.slice(0, 10) as card (card)}<div class="setrow">
85
+ <span class="lbl">{describeCard(card)}</span><span class="tick" style="color:var(--bad)"
86
+ >missed</span
87
+ >
88
+ </div>{/each}
89
+ </div>
90
+ </div>
91
+ {/if}
92
+ {#if !summary.missed.length && !summary.fixed.length}
93
+ <div class="sec">
94
+ <div class="card">
95
+ <p class="lead">Clean run — nothing missed. If that happens twice more, move up a range.</p>
96
+ </div>
97
+ </div>
98
+ {/if}
99
+ <div class="sec">
100
+ <div class="bar">
101
+ {#if summary.missed.length}<button
102
+ class="start"
103
+ id="redomiss"
104
+ style="width:auto"
105
+ onclick={() => startMissSession(stillMissed(current))}
106
+ >Drill the {summary.missed.length} misses</button
107
+ >{/if}
108
+ <button class="ghost" id="againsess" onclick={() => startSession(current.mode)}
109
+ >Another session</button
110
+ >
111
+ <span class="spacer"></span>
112
+ <button
113
+ class="ghost"
114
+ id={current.mode === 'scales' ? 'backscales' : 'back'}
115
+ onclick={() => go(current.mode === 'scales' ? 'scales' : 'home')}>Done</button
116
+ >
117
+ </div>
118
+ </div>
119
+ {/if}
@@ -0,0 +1,548 @@
1
+ <!-- Setup: every setting the original had, grouped as it was; each app picks the rows that apply to it. -->
2
+ <script lang="ts" module>
3
+ export const MDO_ADDS =
4
+ 'Turning this on adds seven drills in their own group: reading a note as a syllable in a given key, naming its scale degree, finding a degree on the neck, and singing one. It is the thing that makes a melody transposable in your head, and the thing that makes singing a written line possible without a reference note for every phrase. It is also a real second language, so leave it off until the fixed-do and staff drills feel easy.';
5
+ </script>
6
+
7
+ <script lang="ts">
8
+ import { onDestroy } from 'svelte';
9
+ import { honmoon, setSetting, saveLocal, queueSave } from '../../store/honmoon.svelte';
10
+ import type { ClefId, Settings } from '../../store/types';
11
+ import { LEVELS, STRINGS, bassHere, lvl } from '../../music/bass';
12
+ import { CLEFS, clefOn, enabledClefs } from '../../music/clefs';
13
+ import { bothNames, freqOfSounding, pcOf } from '../../music/notes';
14
+ import { RLEVELS, rhFloor, rlev } from '../../music/rhythm';
15
+ import { clefSVG } from '../../notation/staff';
16
+ import {
17
+ micEnv,
18
+ micListen,
19
+ micOn,
20
+ micReason,
21
+ micStart,
22
+ micStop,
23
+ micUsable,
24
+ type MicReading,
25
+ type MicReason,
26
+ } from '../../audio/mic';
27
+ import { go, hasRoute } from '../../nav.svelte';
28
+ import { toggledIn } from '../../util/list';
29
+ import AccRow from '../AccRow.svelte';
30
+ import DataCard from '../DataCard.svelte';
31
+ import type { SetupRow } from '../types';
32
+
33
+ interface Props {
34
+ rows: SetupRow[];
35
+ /** which paragraphs "How this works" carries */
36
+ how?: ('written' | 'position' | 'repetition')[];
37
+ /** how many drills the movable-do switch adds in this app, and what they are */
38
+ mdoAdds?: string;
39
+ appId?: string;
40
+ }
41
+ const {
42
+ rows,
43
+ how = ['written', 'position', 'repetition'],
44
+ mdoAdds = MDO_ADDS,
45
+ appId = 'honmoon',
46
+ }: Props = $props();
47
+ const has = (row: SetupRow) => rows.includes(row);
48
+ const settings = $derived(honmoon.settings);
49
+ /** Choices offered for the rhythm drills' starting tempo and their lowest tempo. */
50
+ const RHYTHM_TEMPOS = [60, 70, 80, 92, 104];
51
+ const RHYTHM_FLOORS = [40, 45, 50, 56, 63];
52
+ /** The written pitch is an octave above the sounding one: the mic hears the latter. */
53
+ const OCTAVE = 12;
54
+ /** Input level that fills the meter. */
55
+ const FULL_METER_RMS = 0.06;
56
+
57
+ function change<K extends keyof Settings>(key: K, value: Settings[K]) {
58
+ setSetting(key, value);
59
+ if (key === 'mic' && !value) {
60
+ stopCalib();
61
+ }
62
+ /* Raising the floor above where you are now has to carry the tempo with
63
+ it, or the drill would sit below its own limit until you nudged it. */
64
+ if (key === 'rfloor' && (honmoon.settings.rtempo || 72) < rhFloor()) {
65
+ setSetting('rtempo', rhFloor());
66
+ }
67
+ }
68
+ /** Switch a clef on or off — but never the last one. */
69
+ function toggleClef(clef: ClefId) {
70
+ const on = toggledIn(honmoon.settings.clefs || ['bass'], clef);
71
+ if (!on.length) {
72
+ return;
73
+ }
74
+ honmoon.settings.clefs = CLEFS.filter((def) => on.includes(def.id)).map((def) => def.id);
75
+ saveLocal();
76
+ queueSave();
77
+ }
78
+
79
+ /* ---- checking the mic against the open strings ---- */
80
+ const calib = $state({
81
+ open: STRINGS.map(() => false),
82
+ live: null as MicReading | null,
83
+ err: null as MicReason | null,
84
+ on: false,
85
+ });
86
+ function stopCalib() {
87
+ if (micOn()) {
88
+ micStop();
89
+ }
90
+ calib.on = false;
91
+ calib.live = null;
92
+ }
93
+ /** Tick off every open string the reading matches. */
94
+ function markOpenString(reading: MicReading) {
95
+ calib.live = reading;
96
+ if (!reading.stable || !reading.midi) {
97
+ return;
98
+ }
99
+ const heard = Math.round(reading.midi);
100
+ STRINGS.forEach((string, i) => {
101
+ if (heard === string.open - OCTAVE) {
102
+ calib.open[i] = true;
103
+ }
104
+ });
105
+ }
106
+ function toggleCalib() {
107
+ if (calib.on) {
108
+ stopCalib();
109
+ calib.err = null;
110
+ return;
111
+ }
112
+ calib.err = null;
113
+ micStart()
114
+ .then(() => {
115
+ calib.on = true;
116
+ micListen(markOpenString);
117
+ })
118
+ .catch((error) => {
119
+ calib.err = micReason(error);
120
+ calib.live = null;
121
+ });
122
+ }
123
+ onDestroy(() => {
124
+ if (calib.on) {
125
+ stopCalib();
126
+ }
127
+ });
128
+ const env = micEnv();
129
+ const usable = micUsable();
130
+ const heardMidi = $derived(calib.live && calib.live.midi ? Math.round(calib.live.midi) : 0);
131
+ /** Why no microphone can be had here, in a sentence. */
132
+ function whyNoMic(): string {
133
+ if (env.policy === false) {
134
+ return 'The page this one is embedded in does not pass microphone access through to it, and no browser setting overrides that.';
135
+ }
136
+ if (!env.secure) {
137
+ return 'The connection is not a secure one, which browsers require before handing over a microphone.';
138
+ }
139
+ return 'This browser exposes no capture API in this context.';
140
+ }
141
+ const yesNo = (value: boolean | null | undefined) => (value ? 'yes' : 'no');
142
+ const policyText = $derived(env.policy === null ? 'unknown' : yesNo(env.policy));
143
+ </script>
144
+
145
+ {#if has('range') || has('acc') || has('pref')}
146
+ <div class="sec">
147
+ <h2>Notes in rotation</h2>
148
+ <div class="card">
149
+ {#if has('range')}
150
+ <div class="setrow">
151
+ <span class="lbl">Range</span><span class="levels">
152
+ {#each LEVELS as level (level.id)}<button
153
+ class="chip sm"
154
+ data-lvl={level.id}
155
+ aria-pressed={level.id === lvl().id}
156
+ onclick={() => change('level', level.id)}>{level.name}</button
157
+ >{/each}
158
+ </span><span class="hint"
159
+ >{lvl().desc}. This is the same control as the one on the neck screen, and it sets how
160
+ far the reading drills range too.</span
161
+ >
162
+ </div>
163
+ {/if}
164
+ {#if has('acc')}<AccRow />{/if}
165
+ {#if has('pref')}
166
+ <div class="setrow">
167
+ <span class="lbl">Sharps or flats</span><span class="levels">
168
+ <button
169
+ class="chip sm"
170
+ data-set="pref"
171
+ data-val="sharp"
172
+ aria-pressed={settings.pref === 'sharp'}
173
+ onclick={() => change('pref', 'sharp')}>♯ sharps</button
174
+ >
175
+ <button
176
+ class="chip sm"
177
+ data-set="pref"
178
+ data-val="flat"
179
+ aria-pressed={settings.pref === 'flat'}
180
+ onclick={() => change('pref', 'flat')}>♭ flats</button
181
+ ></span
182
+ >
183
+ <span class="hint"
184
+ >Which name a black note goes by. The same key is F♯ or G♭ depending on where you are
185
+ coming from; this picks the one the app uses when it has a free choice.</span
186
+ >
187
+ </div>
188
+ {/if}
189
+ </div>
190
+ </div>
191
+ {/if}
192
+
193
+ {#if has('appearance') || has('bass') || has('hints') || has('labels')}
194
+ <div class="sec">
195
+ <h2>The board</h2>
196
+ <div class="card">
197
+ {#if has('appearance')}
198
+ <div class="setrow">
199
+ <span class="lbl">Appearance</span><span class="levels">
200
+ <button
201
+ class="chip sm"
202
+ data-set="theme"
203
+ data-val="auto"
204
+ aria-pressed={(settings.theme || 'auto') === 'auto'}
205
+ onclick={() => change('theme', 'auto')}>Match device</button
206
+ >
207
+ <button
208
+ class="chip sm"
209
+ data-set="theme"
210
+ data-val="light"
211
+ aria-pressed={settings.theme === 'light'}
212
+ onclick={() => change('theme', 'light')}>Light</button
213
+ >
214
+ <button
215
+ class="chip sm"
216
+ data-set="theme"
217
+ data-val="dark"
218
+ aria-pressed={settings.theme === 'dark'}
219
+ onclick={() => change('theme', 'dark')}>Dark</button
220
+ >
221
+ </span><span class="hint"
222
+ >The staff is drawn in ink on the page in one and chalk on a board in the other — the
223
+ lines sit a step back from the notes either way, so the notes read first.</span
224
+ >
225
+ </div>
226
+ {/if}
227
+ {#if has('bass')}
228
+ <div class="setrow">
229
+ <span class="lbl">Bass with me</span>
230
+ <button
231
+ class="chip sm"
232
+ data-set="bass"
233
+ data-val={String(!bassHere())}
234
+ aria-pressed={bassHere()}
235
+ onclick={() => change('bass', !bassHere())}
236
+ >{bassHere() ? 'Yes' : 'Left at home'}</button
237
+ >
238
+ <span class="hint"
239
+ >Say no and the four play-along drills step out of the sessions until you say otherwise.
240
+ The same switch sits on the Practice and Scales screens, which is where you will
241
+ actually want it.</span
242
+ >
243
+ </div>
244
+ {/if}
245
+ {#if has('hints')}
246
+ <div class="setrow">
247
+ <span class="lbl">Explain the drill on screen</span>
248
+ <button
249
+ class="chip sm"
250
+ data-set="hints"
251
+ data-val={String(settings.hints === false)}
252
+ aria-pressed={settings.hints !== false}
253
+ onclick={() => change('hints', settings.hints === false)}
254
+ >{settings.hints !== false ? 'On' : 'Off'}</button
255
+ >
256
+ <span class="hint"
257
+ >A short note under the newer drills saying what is being asked and how to work it out.
258
+ Turn it off once they are familiar — nothing else changes.</span
259
+ >
260
+ </div>
261
+ {/if}
262
+ {#if has('labels')}
263
+ <div class="setrow">
264
+ <span class="lbl">String names on the board</span>
265
+ <button
266
+ class="chip sm"
267
+ data-set="labels"
268
+ data-val={String(!settings.labels)}
269
+ aria-pressed={settings.labels}
270
+ onclick={() => change('labels', !settings.labels)}
271
+ >{settings.labels ? 'Shown' : 'Hidden'}</button
272
+ >
273
+ <span class="hint"
274
+ >Only affects the two Fret → name drills, where the G D A E labels let you count up from
275
+ a known string instead of recognising the note. Turn them off once that drill feels easy
276
+ — knowing the string order by heart is four facts, and it is worth having.</span
277
+ >
278
+ </div>
279
+ {/if}
280
+ </div>
281
+ </div>
282
+ {/if}
283
+
284
+ {#if has('clefs')}
285
+ <div class="sec">
286
+ <h2>Clefs</h2>
287
+ <div class="card stack">
288
+ <p class="note">
289
+ Bass clef is the one your instrument is written in, so it starts on its own. Add the others
290
+ when you are ready and they join the reading and movable-do drills, mixed in at random.
291
+ Turning one off does not lose what you have learned on it.
292
+ </p>
293
+ <div class="clefgrid">
294
+ {#each CLEFS as clef (clef.id)}
295
+ {@const on = clefOn(clef.id)}
296
+ {@const only = on && enabledClefs().length === 1}
297
+ <div class="clefrow">
298
+ <svg viewBox="-2 -22 40 84" aria-hidden="true">
299
+ <g stroke="var(--line)" stroke-width="1.1"
300
+ ><line x1="0" y1="0" x2="38" y2="0" /><line x1="0" y1="10" x2="38" y2="10" /><line
301
+ x1="0"
302
+ y1="20"
303
+ x2="38"
304
+ y2="20"
305
+ /><line x1="0" y1="30" x2="38" y2="30" /><line x1="0" y1="40" x2="38" y2="40" /></g
306
+ >
307
+ {@html clefSVG(clef.id)}</svg
308
+ >
309
+ <div style="flex:1;min-width:0">
310
+ <div class="setrow" style="border:none;padding:0">
311
+ <span class="lbl">{clef.n} clef</span>
312
+ <button
313
+ class="chip sm"
314
+ data-clef={clef.id}
315
+ aria-pressed={on}
316
+ disabled={only}
317
+ onclick={() => toggleClef(clef.id)}>{on ? 'On' : 'Off'}</button
318
+ >
319
+ </div>
320
+ <p class="note" style="margin:4px 0 0">{clef.sb}</p>
321
+ </div>
322
+ </div>
323
+ {/each}
324
+ </div>
325
+ </div>
326
+ </div>
327
+ {/if}
328
+
329
+ {#if has('mdo')}
330
+ <div class="sec">
331
+ <h2>Movable do and degrees</h2>
332
+ <div class="card stack">
333
+ <div class="setrow">
334
+ <span class="lbl">Key & degrees drills</span>
335
+ <button
336
+ class="chip sm"
337
+ data-set="mdo"
338
+ data-val={String(!settings.mdo)}
339
+ aria-pressed={!!settings.mdo}
340
+ onclick={() => change('mdo', !settings.mdo)}>{settings.mdo ? 'On' : 'Off'}</button
341
+ >
342
+ <span class="hint">Off by default, and nothing else changes while it is off.</span>
343
+ </div>
344
+ <p class="note">
345
+ <b>Fixed do</b> is what the app uses everywhere else: Do is C, always, in every key. It is a
346
+ second name for a letter. <b>Movable do</b> ties the syllables to the key instead — Do is
347
+ whichever note the key is built on, so Do Re Mi sounds the same shape in every key and Sol
348
+ always pulls home the same way. Movable do says <i>Ti</i> rather than Si, because Si is the raised
349
+ fifth.
350
+ </p>
351
+ {#if mdoAdds}<p class="note">{@html mdoAdds}</p>{/if}
352
+ </div>
353
+ </div>
354
+ {/if}
355
+
356
+ {#if has('rhythm')}
357
+ <div class="sec">
358
+ <h2>Rhythm</h2>
359
+ <div class="card stack">
360
+ <div class="setrow">
361
+ <span class="lbl">How much notation</span><span class="levels">
362
+ {#each RLEVELS as level (level.id)}<button
363
+ class="chip sm"
364
+ data-set="rlev"
365
+ data-val={level.id}
366
+ aria-pressed={(settings.rlev || 'r1') === level.id}
367
+ onclick={() => change('rlev', level.id)}>{level.n}</button
368
+ >{/each}
369
+ </span><span class="hint">{rlev().d}</span>
370
+ </div>
371
+ <div class="setrow">
372
+ <span class="lbl">Tempo for tapping</span><span class="levels">
373
+ {#each RHYTHM_TEMPOS as tempo (tempo)}<button
374
+ class="chip sm"
375
+ data-set="rtempo"
376
+ data-val={tempo}
377
+ aria-pressed={(settings.rtempo || 80) === tempo}
378
+ onclick={() => change('rtempo', tempo)}>{tempo}</button
379
+ >{/each}
380
+ </span>
381
+ </div>
382
+ <div class="setrow">
383
+ <span class="lbl">Slowest it will go</span><span class="levels">
384
+ {#each RHYTHM_FLOORS as tempo (tempo)}<button
385
+ class="chip sm"
386
+ data-set="rfloor"
387
+ data-val={tempo}
388
+ aria-pressed={rhFloor() === tempo}
389
+ onclick={() => change('rfloor', tempo)}>{tempo}</button
390
+ >{/each}
391
+ </span><span class="hint"
392
+ >Miss a bar and the tapping drill eases the tempo back a few beats to let you catch it.
393
+ This is where that stops. Very slow is not easier — the gaps between beats get long enough
394
+ that you are counting from memory instead of feeling a pulse, and the bar falls apart. {Math.round(
395
+ 60000 / rhFloor(),
396
+ )} ms between beats at {rhFloor()}.</span
397
+ >
398
+ </div>
399
+ <div class="setrow">
400
+ <span class="lbl">Tap delay allowed for</span><span class="tick"
401
+ >{Math.round((settings.rlat || 0) * 1000)} ms</span
402
+ >
403
+ <span class="hint"
404
+ >Phones take 40–150 ms to notice a tap and make a sound. Measure it once and the tapping
405
+ drill grades what you played rather than how quick your screen is.</span
406
+ >
407
+ </div>
408
+ {#if hasRoute('rcal')}<div class="bar">
409
+ <button class="ghost" id="rcalib" onclick={() => go('rcal', { from: 'setup' })}
410
+ >Measure the tap delay</button
411
+ >
412
+ </div>{/if}
413
+ </div>
414
+ </div>
415
+ {/if}
416
+
417
+ {#if has('sound') || has('mic')}
418
+ <div class="sec">
419
+ <h2>Sound</h2>
420
+ <div class="card">
421
+ {#if has('sound')}
422
+ <div class="setrow">
423
+ <span class="lbl">Play the note when I ask for it</span><button
424
+ class="chip sm"
425
+ data-set="sound"
426
+ data-val={String(!settings.sound)}
427
+ aria-pressed={settings.sound}
428
+ onclick={() => change('sound', !settings.sound)}>{settings.sound ? 'On' : 'Off'}</button
429
+ >
430
+ </div>
431
+ {/if}
432
+ {#if has('mic')}
433
+ {#if usable}
434
+ <div class="setrow">
435
+ <span class="lbl">Listen to my bass and check the note</span><button
436
+ class="chip sm"
437
+ data-set="mic"
438
+ data-val={String(!settings.mic)}
439
+ aria-pressed={settings.mic}
440
+ onclick={() => change('mic', !settings.mic)}>{settings.mic ? 'On' : 'Off'}</button
441
+ >
442
+ <span class="hint"
443
+ >Point the phone or laptop mic at your amp. Turn the amp up a little and cut the tone
444
+ knob back — the detector wants the fundamental, not the click of the pick.</span
445
+ >
446
+ </div>
447
+ {:else}
448
+ <div class="setrow">
449
+ <span class="lbl">Listen to my bass and check the note</span><span class="tick"
450
+ >not available here</span
451
+ >
452
+ <span class="hint"
453
+ >This viewer does not give the page a microphone, so the play-along drills ask you to
454
+ mark yourself instead. They still count towards your progress.</span
455
+ >
456
+ </div>
457
+ {/if}
458
+ {/if}
459
+ </div>
460
+ </div>
461
+ {/if}
462
+
463
+ {#if has('mic')}
464
+ <div class="sec">
465
+ <h2>{usable ? 'Check the mic' : 'Microphone'}</h2>
466
+ <div class="card stack">
467
+ {#if !usable}
468
+ <p class="note">
469
+ Nothing in this app can reach a microphone here. {whyNoMic()}
470
+ </p>
471
+ <p class="note">
472
+ The three play-along drills work anyway — they show you the note, play it back on request,
473
+ and you mark whether you got it. Every other drill is unaffected.
474
+ </p>
475
+ {:else}
476
+ <p class="note">
477
+ Play each open string once, let it ring. If a string will not register, that is the string
478
+ to distrust during the play-along drill — the low E is the hardest for any detector, and
479
+ mains hum sits right next to G on that string.
480
+ </p>
481
+ {#if calib.on}
482
+ <div class="tuner">
483
+ <span class="nt">{heardMidi ? bothNames(pcOf(heardMidi), settings.pref) : '—'}</span
484
+ ><span class="hz"
485
+ >{calib.live?.freq ? calib.live.freq.toFixed(1) + ' Hz' : 'listening'}</span
486
+ >
487
+ </div>
488
+ <div class="meter">
489
+ <i
490
+ style="width:{Math.min(
491
+ 100,
492
+ Math.round(((calib.live?.rms || 0) * 100) / FULL_METER_RMS),
493
+ )}%"
494
+ ></i>
495
+ </div>
496
+ {/if}
497
+ <table class="opens">
498
+ <tbody>
499
+ {#each STRINGS as string, i (string.n)}
500
+ <tr class:done={calib.open[i]}
501
+ ><td class="st">{string.n}</td><td
502
+ >{freqOfSounding(string.open - OCTAVE).toFixed(1)} Hz</td
503
+ ><td class="hit">{calib.open[i] ? 'heard ✓' : '—'}</td></tr
504
+ >
505
+ {/each}
506
+ </tbody>
507
+ </table>
508
+ <div class="bar">
509
+ <button class="ghost" class:on={calib.on} id="calib" onclick={toggleCalib}
510
+ >{calib.on ? 'Stop listening' : 'Start listening'}</button
511
+ >
512
+ </div>
513
+ {#if calib.err}<div class="failbox">
514
+ <div class="ft">{calib.err.t}</div>
515
+ <p class="note" style="margin:4px 0 0">{calib.err.m}</p>
516
+ </div>{/if}
517
+ {/if}
518
+ <p class="note" style="font-family:var(--f-mono);font-size:11.5px">
519
+ secure {yesNo(env.secure)} · capture API {yesNo(env.api)} · embedded {yesNo(env.framed)} · allowed
520
+ in this frame {policyText}
521
+ </p>
522
+ </div>
523
+ </div>
524
+ {/if}
525
+
526
+ {#if has('data')}<DataCard {appId} />{/if}
527
+
528
+ {#if has('how') && how.length}
529
+ <div class="sec">
530
+ <h2>How this works</h2>
531
+ <div class="card stack">
532
+ {#if how.includes('written')}<p class="note">
533
+ <b>Written pitch.</b> Bass is written an octave above where it sounds, so the open E string
534
+ is the note on the first ledger line below the staff, and the drills use that written pitch
535
+ throughout. Fret numbers and your ears agree; only the paper is transposed.
536
+ </p>{/if}
537
+ {#if how.includes('position')}<p class="note">
538
+ <b>Pitch is not position.</b> E on the A string at fret 7 and E on the D string at fret 2 are
539
+ the same sound, so a play-along drill accepts the right note wherever you play it. To be graded
540
+ on a specific spot, use Name → fret.
541
+ </p>{/if}
542
+ {#if how.includes('repetition')}<p class="note">
543
+ <b>Repetition.</b> Positions you miss come back sooner and more often; ones you nail drop out
544
+ of rotation until they have gone stale. Ten minutes most days beats an hour on Sunday.
545
+ </p>{/if}
546
+ </div>
547
+ </div>
548
+ {/if}