@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,420 @@
1
+ import { honmoon, mastery } from '../store/honmoon.svelte';
2
+ import { STRINGS } from '../music/bass';
3
+ import { pcOf } from '../music/notes';
4
+ import {
5
+ SCALES,
6
+ accG,
7
+ degPretty,
8
+ enabledScales,
9
+ noteText,
10
+ rootDef,
11
+ rootName,
12
+ rootPool,
13
+ scaleCards,
14
+ scaleDef,
15
+ scaleName,
16
+ scaleNotes,
17
+ scaleShape,
18
+ scaleSteps,
19
+ stepWord,
20
+ midFret,
21
+ type ScaleNote,
22
+ type Shape,
23
+ } from '../music/scales';
24
+ import { fretboardSVG, type FretCell } from '../notation/fretboard';
25
+ import { NUMWORD, esc } from '../util/text';
26
+
27
+ /* Static teaching pictures and tables built from the scale model. They are
28
+ strings rather than components because nothing in them is interactive, and
29
+ it keeps them comparable, byte for byte, with the original app's. */
30
+
31
+ const ACCENT = 'var(--accent)';
32
+ const ACCENT_INK = 'var(--accent-ink)';
33
+ const isRootNote = (semis: number) => semis % 12 === 0;
34
+
35
+ /** The last fret to draw so every given fret shows: at least 12, at most 17. */
36
+ export function boardTopFor(frets: number[]): number {
37
+ return Math.max(12, Math.min(17, Math.max(...frets) + 1));
38
+ }
39
+ const boardTop = (shape: Shape) => boardTopFor(shape.pos.map((pos) => pos.f));
40
+ /** What is written on a note of the playable box. */
41
+ function boxLabel(pos: Shape['pos'][number], mode: 'deg' | 'note' | 'fin'): string {
42
+ if (mode === 'deg') {
43
+ return degPretty(pos.deg.deg);
44
+ }
45
+ if (mode === 'fin') {
46
+ return pos.f === 0 ? '0' : String(pos.fin);
47
+ }
48
+ return noteText(pos.deg);
49
+ }
50
+
51
+ /** Every tone of a scale on the neck, quietly, with one playable box on top. */
52
+ export function scaleBoard(
53
+ sid: string,
54
+ root: number,
55
+ start: number,
56
+ mode: 'deg' | 'note' | 'fin',
57
+ extra?: FretCell[],
58
+ ) {
59
+ const shape: Shape = scaleShape(sid, root, start, 1);
60
+ const byPc: Record<number, ScaleNote> = {};
61
+ for (const note of scaleNotes(sid, root)) {
62
+ byPc[note.pc] = note;
63
+ }
64
+ const lo = 0;
65
+ const hi = boardTop(shape);
66
+ const cells: FretCell[] = [];
67
+ // every place the scale's notes occur: rings, the roots in the accent colour
68
+ for (let string = 0; string < STRINGS.length; string++) {
69
+ for (let fret = lo; fret <= hi; fret++) {
70
+ const note = byPc[pcOf(STRINGS[string].open + fret)];
71
+ if (!note) {
72
+ continue;
73
+ }
74
+ const isRoot = isRootNote(note.semis);
75
+ cells.push({
76
+ s: string,
77
+ f: fret,
78
+ fill: 'none',
79
+ stroke: isRoot ? ACCENT : 'var(--ink-3)',
80
+ sw: isRoot ? 2 : 1.2,
81
+ r: 8,
82
+ });
83
+ }
84
+ }
85
+ // the box itself, filled and labelled
86
+ for (const pos of shape.pos) {
87
+ const isRoot = isRootNote(pos.deg.semis);
88
+ cells.push({
89
+ s: pos.s,
90
+ f: pos.f,
91
+ fill: isRoot ? ACCENT : 'var(--good)',
92
+ r: 11,
93
+ label: boxLabel(pos, mode),
94
+ fs: mode === 'note' ? 11 : 12,
95
+ tc: isRoot ? ACCENT_INK : 'var(--bg)',
96
+ });
97
+ }
98
+ if (extra) {
99
+ cells.push(...extra);
100
+ }
101
+ return { cells, shape, lo, hi, cf: midFret(shape.pos) };
102
+ }
103
+
104
+ /** Notes with the gap between them written in, so the recipe is visible. */
105
+ export function stepRowHTML(sid: string, root: number): string {
106
+ const notes = scaleNotes(sid, root);
107
+ const steps = scaleSteps(sid);
108
+ const last = notes[notes.length - 1];
109
+ /* A scale whose own list already ends on the octave closes the loop with a
110
+ step of nothing. Drawing that gave a "0" circle and a second copy of the
111
+ root, which reads as a mistake rather than as arriving home. */
112
+ const endsOnOctave = notes.length && isRootNote(last.semis) && last.semis > 0;
113
+ const parts = ['<div class="steprow">'];
114
+ notes.forEach((note, i) => {
115
+ const isTop = endsOnOctave && i === notes.length - 1;
116
+ const rootClass = isRootNote(note.semis) ? ' root' : '';
117
+ const degree = isTop ? '8' : degPretty(note.deg);
118
+ parts.push(`<span class="sn${rootClass}"><b>${esc(noteText(note))}</b><i>${degree}</i></span>`);
119
+ if (steps[i] > 0) {
120
+ parts.push(stepGap(steps[i]));
121
+ }
122
+ });
123
+ if (!endsOnOctave) {
124
+ parts.push(`<span class="sn root"><b>${esc(noteText(notes[0]))}</b><i>8</i></span>`);
125
+ }
126
+ parts.push('</div>');
127
+ return '<div class="fbwrap">' + parts.join('') + '</div>';
128
+ }
129
+ function stepGap(semitones: number): string {
130
+ return `<span class="sg"><b>${semitones}</b><i>${stepWord(semitones)}</i></span>`;
131
+ }
132
+ /** The steps of a scale with the notes left blank: root, gaps, octave. Just
133
+ the gaps, in order, with the root and octave as small end marks: a row of
134
+ note boxes wrapped onto three lines on a phone, and a pattern you have to
135
+ read across three lines is not a pattern any more. */
136
+ export function stepsOnlyHTML(sid: string): string {
137
+ const gaps = scaleSteps(sid)
138
+ .filter((step) => step > 0)
139
+ .map(stepGap)
140
+ .join('');
141
+ return `<div class="steponly"><span class="se">1</span>${gaps}<span class="se">8</span></div>`;
142
+ }
143
+ /** What the keyboard picture is actually showing for this scale: where its
144
+ half steps fall, named, or that it has none — which is the whole character
145
+ of a pentatonic and worth saying out loud rather than implying. */
146
+ export function kbHalfStepLine(sid: string, root: number): string {
147
+ const notes = scaleNotes(sid, root);
148
+ const steps = scaleSteps(sid);
149
+ const halfSteps: string[] = [];
150
+ steps.forEach((step, i) => {
151
+ if (step === 1) {
152
+ halfSteps.push(noteText(notes[i]) + '–' + noteText(notes[(i + 1) % notes.length]));
153
+ }
154
+ });
155
+ if (!halfSteps.length && scaleDef(sid).iv.length <= 4) {
156
+ return 'An arpeggio is a chord strung out in a line, so the gaps are wide: these are only the notes the chord is built from.';
157
+ }
158
+ if (!halfSteps.length) {
159
+ return (
160
+ 'This one has no half steps at all — every lit key has at least one key between it and the next. ' +
161
+ 'That even spacing is why it is so hard to play a wrong note with.'
162
+ );
163
+ }
164
+ const where =
165
+ halfSteps.length === 1
166
+ ? 'step is the one place'
167
+ : 'steps are the ' + (NUMWORD[halfSteps.length] || halfSteps.length) + ' places';
168
+ return (
169
+ 'Its half ' +
170
+ where +
171
+ ' two lit keys touch with nothing between them: <b>' +
172
+ halfSteps.join('</b> and <b>') +
173
+ '</b>. ' +
174
+ 'That pattern, not the letters, is what makes the scale sound as it does.'
175
+ );
176
+ }
177
+
178
+ /* ---------------- reading scale degrees ---------------- */
179
+ /** The major scale, in semitones: the plain numbers on the ruler. */
180
+ export const MAJIV = [0, 2, 4, 5, 7, 9, 11];
181
+ /** What each of the twelve semitones above the root is called as a degree. */
182
+ export const RULER = [
183
+ ['1'],
184
+ ['♭2'],
185
+ ['2'],
186
+ ['♭3'],
187
+ ['3'],
188
+ ['4'],
189
+ ['♯4', '♭5'],
190
+ ['5'],
191
+ ['♭6'],
192
+ ['6'],
193
+ ['♭7'],
194
+ ['7'],
195
+ ];
196
+ /** The last (flat) name of a semitone on the ruler: ♭5 rather than ♯4. */
197
+ const rulerName = (semis: number) => RULER[semis][RULER[semis].length - 1];
198
+ /** A scale's degree names, by semitones above the root. */
199
+ function degreeLabels(sid: string | null, belowOctaveOnly: boolean): Record<number, string> {
200
+ const labels: Record<number, string> = {};
201
+ const scale = sid ? scaleDef(sid) : null;
202
+ scale?.iv.forEach((semis, k) => {
203
+ if (!belowOctaveOnly || semis < 12) {
204
+ labels[semis] = degPretty(scale.dg[k]);
205
+ }
206
+ });
207
+ return labels;
208
+ }
209
+
210
+ /** The twelve frets from a root, as a strip. The seven plain numbers are the
211
+ major scale; everything in between is a flattened or sharpened one. Pass a
212
+ scale to fill in only its members, labelled the way that scale spells them. */
213
+ export function degRuler(
214
+ sid: string | null,
215
+ options: { compact?: boolean; frets?: boolean; mark?: number[] } = {},
216
+ ): string {
217
+ const labels = degreeLabels(sid, true);
218
+ const parts = ['<div class="druler' + (options.compact ? ' compact' : '') + '">'];
219
+ for (let semis = 0; semis < 12; semis++) {
220
+ const inMajor = MAJIV.includes(semis);
221
+ const lit = sid ? labels[semis] != null : true;
222
+ const label = sid ? labels[semis] || '' : RULER[semis].join('<br>');
223
+ const marked = options.mark && options.mark.includes(semis);
224
+ const classes =
225
+ 'dr' + (inMajor ? ' maj' : ' alt') + (lit ? ' on' : '') + (marked ? ' mark' : '');
226
+ const fretNumber = options.frets ? `<i>${semis}</i>` : '';
227
+ parts.push(`<span class="${classes}"><b>${label}</b>${fretNumber}</span>`);
228
+ }
229
+ parts.push('</div>');
230
+ return parts.join('');
231
+ }
232
+ /** Where each degree sits under the hand with the root on the E string: a
233
+ one-octave map that never changes shape, because every fret is a semitone.
234
+ Each entry: string, frets from the root, semitones above the root. With a
235
+ scale chosen, its members are filled and the rest drop to rings. */
236
+ const DEGMAP = [
237
+ [3, 0, 0],
238
+ [3, 1, 1],
239
+ [3, 2, 2],
240
+ [3, 3, 3],
241
+ [2, -1, 4],
242
+ [2, 0, 5],
243
+ [2, 1, 6],
244
+ [2, 2, 7],
245
+ [2, 3, 8],
246
+ [1, -1, 9],
247
+ [1, 0, 10],
248
+ [1, 1, 11],
249
+ [1, 2, 12],
250
+ ];
251
+ export function degMapSVG(sid: string | null, rootFret: number): string {
252
+ const labels = degreeLabels(sid, false);
253
+ const cells: FretCell[] = DEGMAP.map(([string, fromRoot, semis]) => {
254
+ const fret = rootFret + fromRoot;
255
+ const name = semis === 12 ? '8' : labels[semis] || rulerName(semis);
256
+ const inScale = !sid || labels[semis % 12] != null;
257
+ if (semis === 0 || semis === 12) {
258
+ return { s: string, f: fret, fill: ACCENT, r: 12, label: name, fs: 11, tc: ACCENT_INK };
259
+ }
260
+ if (inScale) {
261
+ const plain = MAJIV.includes(semis);
262
+ /* green flips light/dark with the theme, so its text follows --bg; the cream dot does not */
263
+ return {
264
+ s: string,
265
+ f: fret,
266
+ fill: plain ? 'var(--dot)' : 'var(--good)',
267
+ r: 12,
268
+ label: name,
269
+ fs: 10.5,
270
+ tc: plain ? 'var(--dot-ink)' : 'var(--bg)',
271
+ };
272
+ }
273
+ return {
274
+ s: string,
275
+ f: fret,
276
+ stroke: 'var(--wire)',
277
+ sw: 1.3,
278
+ r: 11,
279
+ label: rulerName(semis),
280
+ fs: 9.5,
281
+ tc: 'var(--wire)',
282
+ };
283
+ });
284
+ return fretboardSVG({ lo: Math.max(0, rootFret - 2), hi: rootFret + 4, cells, scale: 1.25 });
285
+ }
286
+ /** Two degrees one fret apart on the same string, to make "flat means one fret
287
+ lower" something you can see rather than take on trust. */
288
+ export function degThirdsSVG(): string {
289
+ const root = 5; /* A on the E string */
290
+ const eString = 3;
291
+ return fretboardSVG({
292
+ lo: 4,
293
+ hi: 10,
294
+ scale: 1.25,
295
+ cells: [
296
+ { s: eString, f: root, fill: ACCENT, r: 12, label: '1', fs: 11, tc: ACCENT_INK },
297
+ {
298
+ s: eString,
299
+ f: root + 3,
300
+ fill: 'var(--good)',
301
+ r: 12,
302
+ label: '♭3',
303
+ fs: 10.5,
304
+ tc: 'var(--bg)',
305
+ },
306
+ {
307
+ s: eString,
308
+ f: root + 4,
309
+ fill: 'var(--dot)',
310
+ r: 12,
311
+ label: '3',
312
+ fs: 11,
313
+ tc: 'var(--dot-ink)',
314
+ },
315
+ ],
316
+ });
317
+ }
318
+ const hotClass = (hot: boolean | undefined) => (hot ? ' class="hot"' : '');
319
+ /** A small table: keys down the side, degrees across the top. */
320
+ export function degKeyTable(sid: string, roots: number[], mark?: number[]): string {
321
+ const scale = scaleDef(sid);
322
+ const parts = ['<div class="fbwrap"><table class="dtab"><tr><th class="corner">key</th>'];
323
+ scale.dg.forEach((degree, k) => {
324
+ parts.push(`<th${hotClass(mark?.includes(scale.iv[k]))}>${degPretty(degree)}</th>`);
325
+ });
326
+ parts.push('</tr>');
327
+ for (const root of roots) {
328
+ parts.push(`<tr><th>${esc(rootName(root))}</th>`);
329
+ for (const note of scaleNotes(sid, root)) {
330
+ parts.push(`<td${hotClass(mark?.includes(note.semis))}>${esc(noteText(note))}</td>`);
331
+ }
332
+ parts.push('</tr>');
333
+ }
334
+ parts.push('</table></div>');
335
+ return parts.join('');
336
+ }
337
+ /** The worked example: a major scale, then the same key with the formula's
338
+ flats applied, the changed notes marked. */
339
+ export function degWorked(fromSid: string, toSid: string, rootPc: number): string {
340
+ const before = scaleNotes(fromSid, rootPc);
341
+ const after = scaleNotes(toSid, rootPc);
342
+ const parts = ['<div class="fbwrap"><table class="dtab"><tr><th></th>'];
343
+ after.forEach((note) => parts.push(`<th>${degPretty(note.deg)}</th>`));
344
+ parts.push(`</tr><tr><th>${esc(scaleDef(fromSid).n)}</th>`);
345
+ before.forEach((note) => parts.push(`<td>${esc(noteText(note))}</td>`));
346
+ parts.push(`</tr><tr><th>${esc(scaleDef(toSid).n)}</th>`);
347
+ after.forEach((note, k) => {
348
+ const changed = !!before[k] && noteText(before[k]) !== noteText(note);
349
+ parts.push(`<td${hotClass(changed)}>${esc(noteText(note))}</td>`);
350
+ });
351
+ parts.push('</tr></table></div>');
352
+ return parts.join('');
353
+ }
354
+
355
+ /* ---------------- scale progress ---------------- */
356
+ /** The heat-map class for a card: m0 not met, m1 shaky, m2/m3 getting there, m5 solid. */
357
+ function masteryClass(id: string): string {
358
+ const stats = honmoon.cards[id];
359
+ if (!stats || !stats.s) {
360
+ return 'm0';
361
+ }
362
+ const level = mastery(id);
363
+ if (level <= 1) {
364
+ return 'm1';
365
+ }
366
+ if (level < 3) {
367
+ return 'm2';
368
+ }
369
+ return level < 5 ? 'm3' : 'm5';
370
+ }
371
+ /** One square per scale in one key, shaded by how solid it is. */
372
+ export function scaleMatrix(): string {
373
+ const scales = enabledScales();
374
+ const roots = rootPool();
375
+ if (!scales.length) {
376
+ return '';
377
+ }
378
+ const parts = ['<div class="matwrap"><table class="mat"><thead><tr><th></th>'];
379
+ for (const root of roots) {
380
+ parts.push(`<th>${esc(rootDef(root).l + accG(rootDef(root).a))}</th>`);
381
+ }
382
+ parts.push('</tr></thead><tbody>');
383
+ for (const scale of scales) {
384
+ parts.push(`<tr><th>${esc(scale.n)}</th>`);
385
+ for (const root of roots) {
386
+ const id = 'sc:' + scale.id + ':' + root;
387
+ parts.push(`<td class="${masteryClass(id)}" title="${esc(scaleName(scale.id, root))}"></td>`);
388
+ }
389
+ parts.push('</tr>');
390
+ }
391
+ parts.push('</tbody></table></div>');
392
+ return parts.join('');
393
+ }
394
+ export function scaleStats() {
395
+ const pool = scaleCards();
396
+ let seen = 0;
397
+ let solid = 0;
398
+ let answers = 0;
399
+ let right = 0;
400
+ for (const id of pool) {
401
+ const stats = honmoon.cards[id];
402
+ if (!stats || !stats.s) {
403
+ continue;
404
+ }
405
+ seen++;
406
+ answers += stats.s;
407
+ right += stats.c;
408
+ if (mastery(id) >= 5) {
409
+ solid++;
410
+ }
411
+ }
412
+ return {
413
+ total: pool.length,
414
+ seen,
415
+ solid,
416
+ ans: answers,
417
+ acc: answers ? Math.round((100 * right) / answers) : 0,
418
+ };
419
+ }
420
+ export { SCALES };