@greenblacksky/honmoon 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -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,478 @@
1
+ <!-- Stats: every section the original had, and each app picks the ones that mean something for it. -->
2
+ <script lang="ts">
3
+ import { honmoon, mastery, resetProgress, syncLabel } from '../../store/honmoon.svelte';
4
+ import { STRINGS } from '../../music/bass';
5
+ import { clefDef, enabledClefs, parseST, staffNotes } from '../../music/clefs';
6
+ import { mdoOn, noteName, pcOf } from '../../music/notes';
7
+ import { SCALES, enabledScales, rootName, rootPool, scaleOn } from '../../music/scales';
8
+ import { dayKey } from '../../util/text';
9
+ import { heatFretboard } from '../../notation/fretboard';
10
+ import { heatStaffSVG } from '../../notation/staff';
11
+ import { describeCard } from '../../learn/describe';
12
+ import { parseMD } from '../../learn/pools';
13
+ import { streakDays, totals } from '../../learn/progress';
14
+ import { scaleMatrix, scaleStats } from '../scaleboard';
15
+ import Stat from '../Stat.svelte';
16
+ import DataCard from '../DataCard.svelte';
17
+ import type { StatsSection } from '../types';
18
+
19
+ interface Props {
20
+ sections: StatsSection[];
21
+ /** tempo histories to chart: warm-up families, rhythm levels */
22
+ tempoRows?: { k: string; n: string }[];
23
+ /** what to say when there is no tempo history yet */
24
+ tempoEmpty?: string;
25
+ tempoNote?: string;
26
+ }
27
+ const {
28
+ sections,
29
+ tempoRows = [],
30
+ tempoEmpty = 'Nothing logged yet.',
31
+ tempoNote = '',
32
+ }: Props = $props();
33
+ const has = (section: StatsSection) => sections.includes(section);
34
+
35
+ const DAYS_SHOWN = 14;
36
+ const MS_PER_MINUTE = 60000;
37
+ /** A card at this mastery counts as solid. */
38
+ const SOLID = 5;
39
+ /** The weakest-spots list wants at least this many tries and this error rate. */
40
+ const ROUGH_MIN_TRIES = 2;
41
+ const ROUGH_MIN_ERROR_RATE = 0.25;
42
+ const TEMPO_HISTORY_SHOWN = 24;
43
+
44
+ type Tally = { s: number; c: number; n: number };
45
+ type Area = 'theory' | 'scales' | 'ear' | 'rhythm' | 'key';
46
+ const emptyTally = (): Tally => ({ s: 0, c: 0, n: 0 });
47
+ /** Which Stats area a card counts towards, by its id prefix. */
48
+ function areaOf(id: string): Area {
49
+ const prefix = id.slice(0, 3);
50
+ if (prefix === 'sc:') {
51
+ return 'scales';
52
+ }
53
+ if (prefix === 'iv:' || prefix === 'ch:' || prefix === 'se:') {
54
+ return 'ear';
55
+ }
56
+ if (prefix === 'rh:') {
57
+ return 'rhythm';
58
+ }
59
+ return prefix === 'md:' ? 'key' : 'theory';
60
+ }
61
+ /** The pitch class a note card asks about, or -1 for cards that are not about one note. */
62
+ function pitchClassOf(id: string): number {
63
+ const prefix = id.slice(0, 3);
64
+ if (prefix === 'fb:') {
65
+ const [, string, fret] = id.split(':').map(Number);
66
+ return pcOf(STRINGS[string].open + fret);
67
+ }
68
+ if (prefix === 'st:') {
69
+ return pcOf(parseST(id).m);
70
+ }
71
+ if (prefix === 'md:') {
72
+ return parseMD(id).pc;
73
+ }
74
+ if (prefix === 'tr:' || prefix === 'pl:') {
75
+ return +id.slice(3);
76
+ }
77
+ return -1;
78
+ }
79
+
80
+ const overall = $derived(totals());
81
+ const days = $derived.by(() => {
82
+ const out: { k: string; n: number }[] = [];
83
+ const now = new Date();
84
+ for (let back = DAYS_SHOWN - 1; back >= 0; back--) {
85
+ const key = dayKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - back));
86
+ out.push({ k: key, n: (honmoon.daily[key] || { n: 0 }).n });
87
+ }
88
+ return out;
89
+ });
90
+ const busiestCount = $derived(Math.max(1, ...days.map((day) => day.n)));
91
+ const busiest = $derived(days.reduce((best, day) => (day.n > best.n ? day : best), days[0]));
92
+ const minutes = $derived.by(() => {
93
+ let ms = 0;
94
+ for (const key in honmoon.daily) {
95
+ ms += honmoon.daily[key].ms;
96
+ }
97
+ return Math.round(ms / MS_PER_MINUTE);
98
+ });
99
+ const area = $derived.by(() => {
100
+ const tallies: Record<Area, Tally> = {
101
+ theory: emptyTally(),
102
+ scales: emptyTally(),
103
+ ear: emptyTally(),
104
+ rhythm: emptyTally(),
105
+ key: emptyTally(),
106
+ };
107
+ for (const id in honmoon.cards) {
108
+ const stats = honmoon.cards[id];
109
+ if (!stats || !stats.s) {
110
+ continue;
111
+ }
112
+ const tally = tallies[areaOf(id)];
113
+ tally.s += stats.s;
114
+ tally.c += stats.c;
115
+ tally.n++;
116
+ }
117
+ return tallies;
118
+ });
119
+ const grand = $derived(area.theory.s + area.scales.s + area.ear.s + area.rhythm.s + area.key.s);
120
+ const byPitchClass = $derived.by(() => {
121
+ const out = Array.from({ length: 12 }, () => ({ s: 0, c: 0 }));
122
+ for (const id in honmoon.cards) {
123
+ const pc = pitchClassOf(id);
124
+ if (pc < 0) {
125
+ continue;
126
+ }
127
+ out[pc].s += honmoon.cards[id].s;
128
+ out[pc].c += honmoon.cards[id].c;
129
+ }
130
+ return out;
131
+ });
132
+ /** Bar colour for a note's hit rate. */
133
+ function accuracyColour(tries: number, accuracy: number): string {
134
+ if (!tries) {
135
+ return 'var(--line-2)';
136
+ }
137
+ if (accuracy >= 0.9) {
138
+ return 'var(--good)';
139
+ }
140
+ return accuracy >= 0.7 ? 'var(--mid)' : 'var(--bad)';
141
+ }
142
+ const staffSummary = $derived.by(() => {
143
+ let met = 0;
144
+ let total = 0;
145
+ let solid = 0;
146
+ for (const note of staffNotes()) {
147
+ total++;
148
+ const stats = honmoon.cards[note.id];
149
+ if (stats && stats.s) {
150
+ met++;
151
+ if (mastery(note.id) >= SOLID) {
152
+ solid++;
153
+ }
154
+ }
155
+ }
156
+ return { met, total, solid };
157
+ });
158
+ const scaleSummary = $derived(scaleStats());
159
+ type Weakest = { r: number; m: number };
160
+ const scaleRows = $derived(
161
+ SCALES.filter(
162
+ (scale) =>
163
+ scaleOn(scale.id) &&
164
+ rootPool().some((root) => honmoon.cards['sc:' + scale.id + ':' + root]),
165
+ )
166
+ .map((scale) => {
167
+ let masterySum = 0;
168
+ let keys = 0;
169
+ let worst: Weakest | null = null;
170
+ for (const root of rootPool()) {
171
+ const id = 'sc:' + scale.id + ':' + root;
172
+ const stats = honmoon.cards[id];
173
+ if (!stats || !stats.s) {
174
+ continue;
175
+ }
176
+ const level = mastery(id);
177
+ masterySum += level;
178
+ keys++;
179
+ if (worst === null || level < worst.m) {
180
+ worst = { r: root, m: level };
181
+ }
182
+ }
183
+ return { scale, masterySum, keys, worst: worst as Weakest | null };
184
+ })
185
+ .filter((row) => row.keys),
186
+ );
187
+ const rough = $derived.by(() => {
188
+ const out: { k: string; wr: number; s: number }[] = [];
189
+ for (const id in honmoon.cards) {
190
+ const stats = honmoon.cards[id];
191
+ if (!stats || stats.s < ROUGH_MIN_TRIES) {
192
+ continue;
193
+ }
194
+ const errorRate = (stats.s - stats.c) / stats.s;
195
+ if (errorRate < ROUGH_MIN_ERROR_RATE) {
196
+ continue;
197
+ }
198
+ out.push({ k: id, wr: errorRate, s: stats.s });
199
+ }
200
+ return out.sort((a, b) => b.wr - a.wr || b.s - a.s);
201
+ });
202
+ const tempoShown = $derived(
203
+ tempoRows.filter((row) => {
204
+ const tempo = honmoon.tempo[row.k];
205
+ return tempo && (tempo.hist || []).length;
206
+ }),
207
+ );
208
+ const TEMPO_COLOURS: Record<string, string> = { clean: 'var(--good)', fast: 'var(--bad)' };
209
+ const tempoColour = (verdict: string) => TEMPO_COLOURS[verdict] || 'var(--mid)';
210
+ const pctOf = (got: number, total: number) => (total ? Math.round((100 * got) / total) : 0);
211
+ const accOf = (tally: { s: number; c: number }) =>
212
+ tally.s ? Math.round((100 * tally.c) / tally.s) : null;
213
+ const plural = (count: number, word: string) => count + ' ' + word + (count === 1 ? '' : 's');
214
+
215
+ function reset() {
216
+ if (confirm('Erase every answer and start from zero?')) {
217
+ resetProgress();
218
+ }
219
+ }
220
+ </script>
221
+
222
+ {#snippet split(label: string, tally: { s: number; c: number })}
223
+ {@const acc = accOf(tally)}
224
+ <div class="splitrow">
225
+ <span class="lbl">{label}</span>
226
+ <div class="sbar"><i style="width:{pctOf(tally.s, grand)}%"></i></div>
227
+ <span class="tick">{tally.s}{acc != null ? ' · ' + acc + '%' : ''}</span>
228
+ </div>
229
+ {/snippet}
230
+
231
+ {#if has('overall')}
232
+ <div class="sec">
233
+ <h2>Overall</h2>
234
+ <Stat
235
+ items={[
236
+ { v: overall.n, k: 'Answers' },
237
+ { v: overall.n ? overall.acc + '%' : '—', k: 'Accuracy' },
238
+ { v: minutes, k: 'Minutes' },
239
+ { v: streakDays(), k: 'Day streak' },
240
+ ]}
241
+ />
242
+ </div>
243
+ {/if}
244
+
245
+ {#if has('twoweeks')}
246
+ <div class="sec">
247
+ <h2>Last two weeks</h2>
248
+ <div class="card">
249
+ <div class="spark">
250
+ {#each days as day (day.k)}<i
251
+ class:z={!day.n}
252
+ style="height:{Math.max(3, Math.round((100 * day.n) / busiestCount))}%"
253
+ title="{day.k}: {day.n}"
254
+ ></i>{/each}
255
+ </div>
256
+ <div class="axis">
257
+ <span>{days[0].k.slice(5)}</span><span>{days[DAYS_SHOWN - 1].k.slice(5)}</span>
258
+ </div>
259
+ <p class="note">
260
+ {busiest.n
261
+ ? 'Busiest day was ' + busiest.k.slice(5) + ' with ' + busiest.n + ' answers.'
262
+ : 'Nothing logged in the last fortnight. Ten minutes most days beats an hour on Sunday.'}
263
+ </p>
264
+ </div>
265
+ </div>
266
+ {/if}
267
+
268
+ {#if has('areas')}
269
+ <div class="sec">
270
+ <h2>Where the time goes</h2>
271
+ <div class="card stack">
272
+ {#if grand}
273
+ {@render split('Drills', area.theory)}
274
+ {@render split('Scales', area.scales)}
275
+ {@render split('Ear', area.ear)}
276
+ {@render split('Rhythm', area.rhythm)}
277
+ {#if area.key.s || mdoOn()}{@render split('Key & degrees', area.key)}{/if}
278
+ <p class="note">
279
+ Answers in each area, and how often you were right. {area.theory.n} notes, {area.scales.n} scale-and-key
280
+ pairs, {area.ear.n} intervals or chords{area.rhythm.n
281
+ ? ', ' + plural(area.rhythm.n, 'rhythm card')
282
+ : ''}{area.key.n ? ' and ' + plural(area.key.n, 'note-in-a-key pair') : ''} met so far.
283
+ </p>
284
+ {:else}<p class="note">
285
+ Nothing answered yet. Start a session on the Practice tab and this fills in.
286
+ </p>{/if}
287
+ </div>
288
+ </div>
289
+ {/if}
290
+
291
+ {#if has('bynote')}
292
+ <div class="sec">
293
+ <h2>Accuracy by note</h2>
294
+ <div class="card">
295
+ <div class="pcbars">
296
+ {#each byPitchClass as tally, pc (pc)}
297
+ {@const acc = tally.s ? tally.c / tally.s : 0}
298
+ <div class="b">
299
+ <i
300
+ style="height:{!tally.s
301
+ ? 6
302
+ : Math.max(8, Math.round(acc * 100))}%;background:{accuracyColour(tally.s, acc)}"
303
+ ></i>
304
+ </div>
305
+ {/each}
306
+ </div>
307
+ <div class="pclabels">
308
+ {#each byPitchClass as _, p (p)}<span>{noteName(p, 'en', honmoon.settings.pref)}</span
309
+ >{/each}
310
+ </div>
311
+ <p class="note">
312
+ {area.theory.s
313
+ ? 'Your hit rate for each note wherever it turns up — on the neck, on the staff, or in translation. Grey means not met yet.'
314
+ : 'Grey all the way across: no note drills answered yet.'}
315
+ </p>
316
+ </div>
317
+ </div>
318
+ {/if}
319
+
320
+ {#if has('neck')}
321
+ <div class="sec">
322
+ <h2>The neck</h2>
323
+ <div class="card">
324
+ <div class="fbwrap fit">{@html heatFretboard()}</div>
325
+ <div class="legend" style="margin-top:10px">
326
+ <span><b style="border:1.6px solid var(--ink-3);border-radius:50%"></b>not tried</span>
327
+ <span><b style="background:var(--bad);border-radius:50%"></b>shaky</span>
328
+ <span><b style="background:var(--mid);border-radius:50%"></b>getting there</span>
329
+ <span><b style="background:var(--good);border-radius:50%"></b>solid</span>
330
+ </div>
331
+ <p class="note">
332
+ Each dot is one position on the neck. It fills in as you answer it correctly; a wrong answer
333
+ knocks it back two steps.
334
+ </p>
335
+ </div>
336
+ </div>
337
+ {/if}
338
+
339
+ {#if has('staff')}
340
+ {@const clefs = enabledClefs()}
341
+ <div class="sec">
342
+ <h2>The staff</h2>
343
+ <div class="card">
344
+ {#each clefs as clef (clef)}
345
+ {@const svg = heatStaffSVG(clef)}
346
+ {#if svg}
347
+ {#if clefs.length > 1}<div class="tick" style="margin:6px 0 2px">
348
+ {clefDef(clef).n}
349
+ </div>{/if}
350
+ <div class="hswrap">{@html svg}</div>
351
+ {/if}
352
+ {/each}
353
+ <div class="legend" style="margin-top:10px">
354
+ <span><b style="background:var(--line-2);border-radius:50%"></b>not tried</span>
355
+ <span><b style="background:var(--bad);border-radius:50%"></b>shaky</span>
356
+ <span><b style="background:var(--mid);border-radius:50%"></b>getting there</span>
357
+ <span><b style="background:var(--good);border-radius:50%"></b>solid</span>
358
+ </div>
359
+ <p class="note">
360
+ {staffSummary.met
361
+ ? staffSummary.met +
362
+ ' of ' +
363
+ staffSummary.total +
364
+ ' notes met, ' +
365
+ staffSummary.solid +
366
+ ' solid. The same note read on two clefs is two separate cards — knowing where D sits in bass does not tell you where it sits in tenor.'
367
+ : 'One notehead per note the reading drills can ask you for, in pitch order. None met yet — a reading drill fills these in.'}
368
+ </p>
369
+ </div>
370
+ </div>
371
+ {/if}
372
+
373
+ {#if has('scales')}
374
+ <div class="sec">
375
+ <h2>Scales</h2>
376
+ <div class="card">
377
+ {#if enabledScales().length}
378
+ {@html scaleMatrix()}
379
+ <div class="legend" style="margin-top:10px">
380
+ <span><b class="m0"></b>not met</span><span><b class="m1"></b>shaky</span>
381
+ <span><b class="m3"></b>getting there</span><span><b class="m5"></b>solid</span>
382
+ </div>
383
+ <p class="note">
384
+ {scaleSummary.seen
385
+ ? scaleSummary.seen +
386
+ ' of ' +
387
+ scaleSummary.total +
388
+ ' pairs met, ' +
389
+ scaleSummary.solid +
390
+ ' solid.'
391
+ : 'One square per scale in one key. None met yet — a scale session fills these in.'}
392
+ </p>
393
+ {:else}<p class="note">No scales switched on. Turn some on from the Scales page.</p>{/if}
394
+ </div>
395
+ </div>
396
+ {/if}
397
+
398
+ {#if has('byscale') && scaleRows.length}
399
+ <div class="sec">
400
+ <h2>By scale</h2>
401
+ <div class="card stack">
402
+ {#each scaleRows as row (row.scale.id)}
403
+ <div class="setrow">
404
+ <span class="lbl">{row.scale.n}</span><span class="tick"
405
+ >{Math.round((100 * row.masterySum) / (row.keys * SOLID))}% over {plural(
406
+ row.keys,
407
+ 'key',
408
+ )}{row.worst && row.worst.m < 3 ? ' · weakest ' + rootName(row.worst.r) : ''}</span
409
+ >
410
+ </div>
411
+ {/each}
412
+ </div>
413
+ </div>
414
+ {/if}
415
+
416
+ {#if has('weakest')}
417
+ <div class="sec">
418
+ <h2>Weakest spots</h2>
419
+ <div class="card stack">
420
+ {#if rough.length}
421
+ {#each rough.slice(0, 10) as spot (spot.k)}<div class="setrow">
422
+ <span class="lbl">{describeCard(spot.k)}</span><span class="tick"
423
+ >{Math.round((1 - spot.wr) * 100)}% of {spot.s}</span
424
+ >
425
+ </div>{/each}
426
+ <p class="note">
427
+ These come round sooner and more often than everything else until they stick.
428
+ </p>
429
+ {:else}<p class="note">
430
+ {overall.n
431
+ ? 'Nothing below 75% yet with enough attempts to judge.'
432
+ : 'Answer a few sessions and the things you keep missing will be listed here.'}
433
+ </p>{/if}
434
+ </div>
435
+ </div>
436
+ {/if}
437
+
438
+ {#if has('tempo')}
439
+ <div class="sec">
440
+ <h2>Tempo</h2>
441
+ <div class="card stack">
442
+ {#each tempoShown as row (row.k)}
443
+ {@const tempo = honmoon.tempo[row.k]}
444
+ {@const history = tempo.hist.slice(-TEMPO_HISTORY_SHOWN)}
445
+ {@const lo = Math.min(...history.map((entry) => entry.bpm))}
446
+ {@const hi = Math.max(...history.map((entry) => entry.bpm))}
447
+ {@const span = Math.max(6, hi - lo)}
448
+ <div>
449
+ <div class="setrow" style="border:none;padding-bottom:4px">
450
+ <span class="lbl">{row.n}</span>
451
+ <span class="tick">{tempo.bpm} bpm{tempo.best ? ' · best ' + tempo.best : ''}</span>
452
+ </div>
453
+ <div class="tline">
454
+ {#each history as entry, i (i)}
455
+ <i
456
+ style="height:{Math.max(
457
+ 6,
458
+ Math.round((100 * (entry.bpm - lo + 2)) / (span + 4)),
459
+ )}%;background:{tempoColour(entry.v)}"
460
+ title="{entry.d} {entry.bpm} bpm"
461
+ ></i>
462
+ {/each}
463
+ </div>
464
+ </div>
465
+ {/each}
466
+ <p class="note">{tempoShown.length ? tempoNote : tempoEmpty}</p>
467
+ </div>
468
+ </div>
469
+ {/if}
470
+
471
+ {#if has('data')}<DataCard />{/if}
472
+
473
+ <div class="sec">
474
+ <div class="bar">
475
+ <button class="ghost" id="reset" onclick={reset}>Reset all progress</button>
476
+ <span class="tick">{syncLabel()}</span>
477
+ </div>
478
+ </div>
@@ -0,0 +1,186 @@
1
+ <!--
2
+ Between the speaker, the screen and the touch digitiser there is a fixed
3
+ delay of anywhere from 20 to 200 ms. Measure it once and everything after
4
+ is graded on what you actually played, not on your phone's reaction time.
5
+ -->
6
+ <script lang="ts" module>
7
+ const RCAL = $state({
8
+ on: false,
9
+ /** how many clicks this run plays */
10
+ n: 12,
11
+ taps: 0,
12
+ done: false,
13
+ result: null as number | null,
14
+ flash: false,
15
+ });
16
+ </script>
17
+
18
+ <script lang="ts">
19
+ import { onDestroy } from 'svelte';
20
+ import { honmoon, queueSave } from '../../store/honmoon.svelte';
21
+ import { ac, acIfAny } from '../../audio/context';
22
+ import { metClick } from '../../audio/synth';
23
+ import { drill } from '../../drills/runtime.svelte';
24
+ import { go, nav } from '../../nav.svelte';
25
+
26
+ const from = nav.view.opt.from === 'setup' ? 'setup' : 'drill';
27
+ let clicks: number[] = [];
28
+ let taps: number[] = [];
29
+ let timer: ReturnType<typeof setTimeout> | null = null;
30
+ Object.assign(RCAL, { on: false, taps: 0, done: false, result: null });
31
+
32
+ /** Twelve clicks at 88 bpm, accented every four, after a short lead-in. */
33
+ const CLICK_COUNT = 12;
34
+ const CALIBRATION_BPM = 88;
35
+ const BEATS_PER_BAR = 4;
36
+ const LEAD_IN_S = 0.9;
37
+ /** Time after the last click to keep listening for a late tap. */
38
+ const TAIL_MS = 400;
39
+ const FLASH_MS = 90;
40
+ /** A tap further than this from every click is a fumble, not a delay; it is also the largest delay accepted. */
41
+ const MAX_OFFSET_S = 0.35;
42
+ /** Fewer usable taps than this measure nothing. */
43
+ const MIN_TAPS = 5;
44
+ /** Above this the delay is worth a word of warning. */
45
+ const SLOW_DELAY_S = 0.25;
46
+
47
+ function start() {
48
+ const context = ac();
49
+ if (!context) {
50
+ return;
51
+ }
52
+ Object.assign(RCAL, { on: true, n: CLICK_COUNT, taps: 0, done: false, result: null });
53
+ clicks = [];
54
+ taps = [];
55
+ const secondsPerBeat = 60 / CALIBRATION_BPM;
56
+ const firstClick = context.currentTime + LEAD_IN_S;
57
+ for (let i = 0; i < RCAL.n; i++) {
58
+ const time = firstClick + i * secondsPerBeat;
59
+ metClick(time, i % BEATS_PER_BAR === 0 ? 'accent' : 'beat');
60
+ clicks.push(time);
61
+ }
62
+ const end = firstClick + RCAL.n * secondsPerBeat;
63
+ timer = setTimeout(finish, (end - context.currentTime) * 1000 + TAIL_MS);
64
+ }
65
+ function hit() {
66
+ const context = acIfAny();
67
+ if (!RCAL.on || !context) {
68
+ return;
69
+ }
70
+ taps.push(context.currentTime);
71
+ RCAL.taps = taps.length;
72
+ RCAL.flash = true;
73
+ setTimeout(() => {
74
+ RCAL.flash = false;
75
+ }, FLASH_MS);
76
+ }
77
+ /** How far a tap is from its nearest click (positive: late). */
78
+ function offsetFromNearestClick(tap: number): number {
79
+ let best = Infinity;
80
+ for (const click of clicks) {
81
+ const offset = tap - click;
82
+ if (Math.abs(offset) < Math.abs(best)) {
83
+ best = offset;
84
+ }
85
+ }
86
+ return best;
87
+ }
88
+ const median = (sorted: number[]) => sorted[Math.floor(sorted.length / 2)];
89
+ function finish() {
90
+ RCAL.on = false;
91
+ if (timer) {
92
+ clearTimeout(timer);
93
+ }
94
+ /* Each tap is matched to its nearest click; the median offset is the delay.
95
+ The median, not the mean, so one fumbled tap cannot move the answer. */
96
+ const offsets = taps
97
+ .map(offsetFromNearestClick)
98
+ .filter((offset) => Math.abs(offset) < MAX_OFFSET_S)
99
+ .sort((a, b) => a - b);
100
+ RCAL.result =
101
+ offsets.length >= MIN_TAPS ? Math.max(0, Math.min(MAX_OFFSET_S, median(offsets))) : null;
102
+ RCAL.done = true;
103
+ }
104
+ function leave() {
105
+ if (from === 'setup') {
106
+ go('setup');
107
+ } else {
108
+ go('drill', { id: drill.id });
109
+ }
110
+ }
111
+ function use() {
112
+ if (RCAL.result != null) {
113
+ honmoon.settings.rlat = RCAL.result;
114
+ queueSave();
115
+ }
116
+ leave();
117
+ }
118
+ onDestroy(() => {
119
+ if (timer) {
120
+ clearTimeout(timer);
121
+ }
122
+ RCAL.on = false;
123
+ });
124
+ </script>
125
+
126
+ <div class="sec">
127
+ <div class="bar">
128
+ <button class="ghost" id="backdrill" onclick={leave}>← Back</button><span class="spacer"
129
+ ></span><span class="tick">Tap delay</span>
130
+ </div>
131
+ </div>
132
+ <div class="sec">
133
+ <div class="card stack">
134
+ <p class="note">
135
+ Your phone takes a moment to notice a tap, and its speaker takes a moment to make a sound. Tap
136
+ along with twelve clicks and the app measures that delay once, so tapping drills grade what
137
+ you played rather than how fast your screen is.
138
+ </p>
139
+ <div class="setrow">
140
+ <span class="lbl">Currently allowing</span><span class="tick"
141
+ >{Math.round((honmoon.settings.rlat || 0) * 1000)} ms</span
142
+ >
143
+ </div>
144
+ </div>
145
+ </div>
146
+ {#if !RCAL.on && !RCAL.done}<div class="sec">
147
+ <button class="start" id="calgo" onclick={start}>Start · {CLICK_COUNT} clicks</button>
148
+ </div>{/if}
149
+ {#if RCAL.on}
150
+ <button class="tappad live" class:flash={RCAL.flash} id="calpad" onpointerdown={hit}
151
+ ><span>Tap with the click</span></button
152
+ >
153
+ <div class="bar">
154
+ <span class="spacer"></span><span class="tick" id="calcount">{RCAL.taps} / {RCAL.n}</span>
155
+ </div>
156
+ {/if}
157
+ {#if RCAL.done}
158
+ {#if RCAL.result == null}
159
+ <div class="sec">
160
+ <div class="card">
161
+ <p class="note">
162
+ Not enough taps landed near a click to measure anything. Try again, and tap every click.
163
+ </p>
164
+ </div>
165
+ </div>
166
+ {:else}
167
+ <div class="sec">
168
+ <div class="card stack">
169
+ <div class="setrow">
170
+ <span class="lbl">Measured delay</span><span class="tick"
171
+ >{Math.round(RCAL.result * 1000)} ms</span
172
+ >
173
+ </div>
174
+ <p class="note">
175
+ {RCAL.result > SLOW_DELAY_S
176
+ ? 'That is on the slow side — some browsers add a lot. It will still work; just keep the tempo modest.'
177
+ : 'That is a normal figure for a touchscreen.'}
178
+ </p>
179
+ <div class="bar">
180
+ <button class="ghost" id="calsave" onclick={use}>Use this</button><span class="spacer"
181
+ ></span><button class="ghost" id="calgo" onclick={start}>Measure again</button>
182
+ </div>
183
+ </div>
184
+ </div>
185
+ {/if}
186
+ {/if}