@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,241 @@
1
+ import { honmoon } from '../store/honmoon.svelte';
2
+ import { STRINGS, placesFor } from '../music/bass';
3
+ import { bothNames, degName, keyName, mdoName, noteName, pcOf } from '../music/notes';
4
+ import { chDef, ivDef } from '../music/intervals';
5
+ import { scaleDef, degPretty, scaleName, scaleNotes, noteText } from '../music/scales';
6
+ import { barCounting, beatsText, restName, rvalDef } from '../music/rhythm';
7
+ import { anA } from '../util/text';
8
+ import type { Q, Question } from './questions';
9
+ import type { DrillState } from './runtime.svelte';
10
+
11
+ /** What the answer panel says: the headline, and the lines under it. */
12
+ export interface Verdict {
13
+ ok: boolean;
14
+ txt: string;
15
+ subs: string[];
16
+ }
17
+
18
+ const plural = (count: number, word: string) => count + ' ' + word + (count === 1 ? '' : 's');
19
+ /** The note at a tapped place on the neck. */
20
+ const pcAtCell = (cell: { s: number; f: number }) => pcOf(STRINGS[cell.s].open + cell.f);
21
+
22
+ type Pref = 'sharp' | 'flat';
23
+
24
+ function degreeVerdict(
25
+ q: Q<'mdo'> | Q<'deg2fb'> | Q<'singdeg'>,
26
+ state: DrillState,
27
+ pref: Pref,
28
+ ): Verdict {
29
+ const ok = state.ok;
30
+ const rightAnswer = q.kind === 'mdo' && q.from === 'syll' ? noteName(q.pc, 'en', pref) : q.ask;
31
+ let headline = ok ? 'Correct' : rightAnswer;
32
+ if (q.kind === 'singdeg') {
33
+ headline = ok ? 'Marked clean' : 'Marked missed';
34
+ }
35
+ if (q.kind === 'deg2fb' && !ok && state.wrongCell) {
36
+ const tappedPc = pcAtCell(state.wrongCell);
37
+ headline =
38
+ 'That is ' +
39
+ noteName(tappedPc, 'en', pref) +
40
+ ', the ' +
41
+ degName(tappedPc, q.keyPc, pref) +
42
+ ' of this key';
43
+ }
44
+ const explanation =
45
+ 'In ' +
46
+ keyName(q.keyPc) +
47
+ ': ' +
48
+ noteName(q.pc, 'en', pref) +
49
+ ' is ' +
50
+ degName(q.pc, q.keyPc, pref) +
51
+ ' · ' +
52
+ mdoName(q.pc, q.keyPc, pref);
53
+ return { ok, txt: headline, subs: [explanation] };
54
+ }
55
+ function valueVerdict(q: Q<'rvalue'>, ok: boolean): Verdict {
56
+ const name = q.rest ? restName(q.u) : rvalDef(q.u).n;
57
+ const explanation = name + ' · ' + beatsText(q.u) + ' in 4/4 · ' + plural(q.u, 'sixteenth');
58
+ return { ok, txt: ok ? 'Correct' : beatsText(q.u), subs: [explanation] };
59
+ }
60
+ function barVerdict(q: Q<'rcount'> | Q<'rhear'> | Q<'rfill'>, ok: boolean): Verdict {
61
+ let headline: string;
62
+ if (ok) {
63
+ headline = 'Correct';
64
+ } else if (q.kind === 'rcount') {
65
+ headline = barCounting(q.bar);
66
+ } else if (q.kind === 'rfill') {
67
+ headline = q.miss.r ? restName(q.miss.u) : rvalDef(q.miss.u).n;
68
+ } else {
69
+ headline = 'That was a different bar';
70
+ }
71
+ return { ok, txt: headline, subs: ['Count it: ' + barCounting(q.bar)] };
72
+ }
73
+ function tapVerdictText(q: Q<'rtap'>, ok: boolean): Verdict {
74
+ const grade = q.grade || { got: 0, n: 0, extra: 0, mean: 0 };
75
+ let headline: string;
76
+ if (q.newBest) {
77
+ headline = 'New best — ' + q.playedAt + ' bpm';
78
+ } else if (q.tv === 'clean') {
79
+ headline = 'Tight';
80
+ } else if (q.tv === 'sloppy') {
81
+ headline = 'All there, a little loose';
82
+ } else if (grade.got < grade.n) {
83
+ headline = grade.n - grade.got + ' missed';
84
+ } else {
85
+ headline = plural(grade.extra, 'extra tap');
86
+ }
87
+ const averageOffMs = Math.round(grade.mean * 1000);
88
+ const subs = [
89
+ q.playedAt +
90
+ ' bpm · ' +
91
+ grade.got +
92
+ ' of ' +
93
+ grade.n +
94
+ ' landed' +
95
+ (grade.extra ? ', ' + grade.extra + ' extra' : '') +
96
+ (grade.got ? ' · average ' + averageOffMs + ' ms off' : ''),
97
+ ];
98
+ if (q.nextBpm && q.nextBpm !== q.playedAt) {
99
+ subs.push(
100
+ q.nextBpm > q.playedAt!
101
+ ? 'Nudging up to ' + q.nextBpm + ' bpm — press − if that is too much.'
102
+ : 'Easing back to ' + q.nextBpm + ' bpm. Tempo you can hold beats tempo you can survive.',
103
+ );
104
+ }
105
+ return { ok, txt: headline, subs };
106
+ }
107
+ function intervalVerdict(
108
+ q: Q<'ivhear'> | Q<'ivsee'> | Q<'singit'> | Q<'ivbuild'>,
109
+ state: DrillState,
110
+ ): Verdict {
111
+ const ok = state.ok;
112
+ const interval = ivDef(q.semis);
113
+ let headline: string;
114
+ if (ok) {
115
+ headline = 'Correct';
116
+ } else if (q.kind === 'ivbuild') {
117
+ const fretsUp = state.wrongCell ? state.wrongCell.f - q.base.f : 0;
118
+ const whatYouTapped =
119
+ fretsUp < 0 ? 'That is below the root' : 'That is ' + plural(fretsUp, 'fret');
120
+ headline = whatYouTapped + ' — ' + anA(interval.n.toLowerCase()) + ' is ' + q.semis;
121
+ } else {
122
+ headline = interval.n;
123
+ }
124
+ return { ok, txt: headline, subs: [interval.n + ' · ' + q.semis + ' semitones · ' + interval.c] };
125
+ }
126
+ function scaleHeadline(q: Question & { sid: string }, ok: boolean): string {
127
+ switch (q.kind) {
128
+ case 'scbuild':
129
+ return ok
130
+ ? 'Clean run'
131
+ : 'Finished with ' + q.misses + ' wrong ' + (q.misses === 1 ? 'tap' : 'taps');
132
+ case 'scwrong':
133
+ return ok ? 'Correct' : 'The moved note was the ' + degPretty(q.shape.pos[q.bad].deg.deg);
134
+ case 'scplay':
135
+ return ok ? 'Marked clean' : 'Marked missed';
136
+ case 'scwrite': {
137
+ const rightStaff = q.sopts.findIndex((staff) => staff.ok); // exactly one is right
138
+ return ok ? 'Correct' : 'It was staff ' + (rightStaff + 1);
139
+ }
140
+ default:
141
+ if (ok) {
142
+ return 'Correct';
143
+ }
144
+ return 'choices' in q ? q.choices.filter((choice) => choice.ok)[0].t : '';
145
+ }
146
+ }
147
+ /** The plain note questions: naming, finding and playing a note. */
148
+ function noteVerdict(q: Question, state: DrillState, pref: Pref): Verdict {
149
+ const ok = state.ok;
150
+ let headline: string;
151
+ if (ok) {
152
+ headline = state.octaveOff ? 'Right note, different octave' : 'Correct';
153
+ } else if (q.kind === 'name2fb') {
154
+ /* Skipping leaves no tapped cell, and this line used to read it blind and
155
+ throw, which took the whole answer panel down with it. */
156
+ headline = state.wrongCell
157
+ ? 'That was ' +
158
+ bothNames(pcAtCell(state.wrongCell), pref) +
159
+ ' — the answer is fret ' +
160
+ q.pos.f
161
+ : 'Fret ' + q.pos.f + ' on the ' + STRINGS[q.pos.s].n + ' string';
162
+ } else if (q.kind === 'play' && state.played != null) {
163
+ headline =
164
+ 'Heard ' + bothNames(pcOf(state.played), pref) + ' — wanted ' + bothNames(q.pc, pref);
165
+ } else {
166
+ headline = bothNames((q as { pc: number }).pc, pref);
167
+ }
168
+ const subs: string[] = [];
169
+ if (q.kind === 'play' && q.sys !== 'staff' && !ok) {
170
+ const where = placesFor(q.pc)
171
+ .slice(0, 3)
172
+ .map((pos) => STRINGS[pos.s].n + ' ' + pos.f)
173
+ .join(', ');
174
+ if (where) {
175
+ subs.push('Try: ' + where);
176
+ }
177
+ }
178
+ if (state.requeued) {
179
+ subs.push('Back again later in this session.');
180
+ }
181
+ return { ok, txt: headline, subs };
182
+ }
183
+
184
+ /** What the answer panel says for a question once it is answered. */
185
+ export function verdictOf(q: Question, state: DrillState): Verdict {
186
+ const ok = state.ok;
187
+ const pref = honmoon.settings.pref;
188
+ switch (q.kind) {
189
+ case 'mdo':
190
+ case 'deg2fb':
191
+ case 'singdeg':
192
+ return degreeVerdict(q, state, pref);
193
+ case 'rvalue':
194
+ return valueVerdict(q, ok);
195
+ case 'rcount':
196
+ case 'rhear':
197
+ case 'rfill':
198
+ return barVerdict(q, ok);
199
+ case 'rtap':
200
+ return tapVerdictText(q, ok);
201
+ case 'ivhear':
202
+ case 'ivsee':
203
+ case 'singit':
204
+ case 'ivbuild':
205
+ return intervalVerdict(q, state);
206
+ case 'chhear': {
207
+ const chord = chDef(q.cid);
208
+ const intervals = chord.iv.map((semis) => ivDef(semis).ab).join(' ');
209
+ return {
210
+ ok,
211
+ txt: ok ? 'Correct' : chord.n,
212
+ subs: [chord.n + ' · ' + intervals + ' from the root'],
213
+ };
214
+ }
215
+ case 'earscale': {
216
+ const scale = scaleDef(q.sid);
217
+ const degrees = scale.dg.map(degPretty).join(' ');
218
+ return {
219
+ ok,
220
+ txt: ok ? 'Correct' : scale.n,
221
+ subs: [scale.n + ' · ' + degrees + ' · ' + scale.d],
222
+ };
223
+ }
224
+ case 'staffkey': {
225
+ let headline: string;
226
+ if (ok) {
227
+ headline = 'Correct';
228
+ } else if (state.wrongKey != null) {
229
+ headline = 'That was ' + noteName(pcOf(state.wrongKey), 'en', pref);
230
+ } else {
231
+ headline = noteName(q.pc, 'en', pref);
232
+ }
233
+ return { ok, txt: headline, subs: [bothNames(q.pc, pref)] };
234
+ }
235
+ }
236
+ if (q.kind.startsWith('sc') && 'sid' in q) {
237
+ const notes = scaleNotes(q.sid, q.root).map(noteText).join(' ');
238
+ return { ok, txt: scaleHeadline(q, ok), subs: [scaleName(q.sid, q.root) + ' · ' + notes] };
239
+ }
240
+ return noteVerdict(q, state, pref);
241
+ }
@@ -0,0 +1,117 @@
1
+ /* @greenblacksky/honmoon — everything the three apps (Mira, Rumi, Zoey) share.
2
+ Logic and data are plain TypeScript; the screens and widgets are Svelte
3
+ components under ui/. Import what you need from the package root. */
4
+
5
+ /* store */
6
+ export * from './store/types';
7
+ export {
8
+ honmoon,
9
+ DEF,
10
+ configureStore,
11
+ addNormalizer,
12
+ card,
13
+ mastery,
14
+ record,
15
+ queueSave,
16
+ save,
17
+ saveLocal,
18
+ setSetting,
19
+ payload,
20
+ load,
21
+ apply,
22
+ resetProgress,
23
+ exportText,
24
+ importText,
25
+ syncLabel,
26
+ } from './store/honmoon.svelte';
27
+ export { FirebaseSync, type SyncAdapter, type FirebaseSyncOptions } from './store/sync';
28
+
29
+ /* music */
30
+ export * from './music/notes';
31
+ export * from './music/bass';
32
+ export * from './music/scales';
33
+ export * from './music/clefs';
34
+ export * from './music/intervals';
35
+ export * from './music/voice';
36
+ export * from './music/rhythm';
37
+
38
+ /* notation */
39
+ export * from './notation/glyphs';
40
+ export * from './notation/staff';
41
+ export * from './notation/fretboard';
42
+ export * from './notation/keyboard';
43
+ export * from './notation/rhythmsvg';
44
+ export * from './notation/tab';
45
+ export { IC, type IconName } from './notation/icons';
46
+
47
+ /* audio */
48
+ export { ac, acIfAny, audioGate } from './audio/context';
49
+ export * from './audio/synth';
50
+ export * from './audio/rhythmplay';
51
+ export * from './audio/metronome.svelte';
52
+ export * from './audio/mic';
53
+ export { nsdfPitch, decimate } from './audio/pitch';
54
+
55
+ /* learning */
56
+ export * from './learn/pools';
57
+ export * from './learn/session';
58
+ export * from './drills/defs';
59
+ export * from './drills/registry.svelte';
60
+ export * from './drills/questions';
61
+ export * from './drills/runtime.svelte';
62
+
63
+ /* navigation */
64
+ export * from './nav.svelte';
65
+
66
+ /* utilities */
67
+ export * from './util/random';
68
+ export * from './util/text';
69
+ export * from './util/list';
70
+
71
+ /* progress and descriptions */
72
+ export * from './learn/progress';
73
+ export { describeCard } from './learn/describe';
74
+ export { verdictOf, type Verdict } from './drills/verdict';
75
+
76
+ /* ui: building blocks */
77
+ export { installTapFix } from './ui/tapfix';
78
+ export { hscroll, fretFrac, type HScrollParams } from './ui/actions';
79
+ export { follow, kbFollow, kbFollowStop } from './ui/keyfollow.svelte';
80
+ export * from './ui/scaleboard';
81
+ export * from './ui/types';
82
+ export { GLOSS, LEARN, DEGQ, DEGTABS } from './ui/refdata';
83
+ export { default as Shell } from './ui/Shell.svelte';
84
+ export { default as Fretboard } from './ui/Fretboard.svelte';
85
+ export { default as Keyboard } from './ui/Keyboard.svelte';
86
+ export { default as StaffRun } from './ui/StaffRun.svelte';
87
+ export { default as RhythmBar } from './ui/RhythmBar.svelte';
88
+ export { default as Chips } from './ui/Chips.svelte';
89
+ export { default as Stat } from './ui/Stat.svelte';
90
+ export { default as BackBar } from './ui/BackBar.svelte';
91
+ export { default as BassRow } from './ui/BassRow.svelte';
92
+ export { default as AccRow } from './ui/AccRow.svelte';
93
+ export { default as DataCard } from './ui/DataCard.svelte';
94
+ export { default as MetronomePanel } from './ui/MetronomePanel.svelte';
95
+
96
+ /* ui: screens */
97
+ export { default as PracticeHome } from './ui/screens/PracticeHome.svelte';
98
+ export { default as FamilyScreen } from './ui/screens/FamilyScreen.svelte';
99
+ export { default as ScalesScreen } from './ui/screens/ScalesScreen.svelte';
100
+ export { default as DrillScreen } from './ui/screens/DrillScreen.svelte';
101
+ export { default as SessionDone } from './ui/screens/SessionDone.svelte';
102
+ export { default as StatsScreen } from './ui/screens/StatsScreen.svelte';
103
+ export { default as SetupScreen } from './ui/screens/SetupScreen.svelte';
104
+ export { default as StaffMap } from './ui/screens/StaffMap.svelte';
105
+ export { default as RhythmRef } from './ui/screens/RhythmRef.svelte';
106
+ export { default as IntervalRef } from './ui/screens/IntervalRef.svelte';
107
+ export { default as DegreesRef } from './ui/screens/DegreesRef.svelte';
108
+ export { default as Glossary } from './ui/screens/Glossary.svelte';
109
+ export { default as Explorer } from './ui/screens/Explorer.svelte';
110
+ export { default as LearnIndex } from './ui/screens/LearnIndex.svelte';
111
+ export { default as TapCalibration } from './ui/screens/TapCalibration.svelte';
112
+ export { default as MetronomeScreen } from './ui/screens/MetronomeScreen.svelte';
113
+
114
+ /* warm-ups */
115
+ export * from './music/warmups';
116
+ export { vCueLen, vPlayOnce } from './audio/voiceplay';
117
+ export { installDefaultNormalizers } from './store/normalize';
@@ -0,0 +1,72 @@
1
+ import { honmoon } from '../store/honmoon.svelte';
2
+ import { STRINGS } from '../music/bass';
3
+ import { bothNames, degName, keyName, noteName, pcOf } from '../music/notes';
4
+ import { clefDef, parseST } from '../music/clefs';
5
+ import { chDef, ivDef } from '../music/intervals';
6
+ import { restName, rvalDef } from '../music/rhythm';
7
+ import { parseSC, scaleDef, scaleName } from '../music/scales';
8
+ import { parseMD } from './pools';
9
+
10
+ const BAR_DRILL_WORDS: Record<string, string> = {
11
+ count: 'counting a bar',
12
+ fill: 'completing a bar',
13
+ hear: 'hearing a bar',
14
+ tap: 'tapping a bar',
15
+ };
16
+ function describeRhythm(id: string): string {
17
+ const [, kind, value] = id.split(':');
18
+ if (kind === 'v') {
19
+ return 'Rhythm — ' + rvalDef(+value).n;
20
+ }
21
+ if (kind === 'r') {
22
+ return 'Rhythm — ' + restName(+value);
23
+ }
24
+ return 'Rhythm — ' + (BAR_DRILL_WORDS[kind] || kind);
25
+ }
26
+
27
+ /** A card id in words, for the session summary and the weakest-spots list. */
28
+ export function describeCard(id: string): string {
29
+ const pref = honmoon.settings.pref;
30
+ const rest = id.slice(3);
31
+ switch (id.slice(0, 3)) {
32
+ case 'sc:': {
33
+ const { sid, root } = parseSC(id);
34
+ return scaleName(sid, root);
35
+ }
36
+ case 'iv:':
37
+ return 'Interval — ' + ivDef(+rest).n;
38
+ case 'ch:':
39
+ return 'Chord by ear — ' + chDef(rest).n;
40
+ case 'fb:': {
41
+ const [, stringPart, fretPart] = id.split(':');
42
+ const string = STRINGS[+stringPart];
43
+ const pc = pcOf(string.open + +fretPart);
44
+ return string.n + ' string, fret ' + fretPart + ' — ' + bothNames(pc, pref);
45
+ }
46
+ case 'st:': {
47
+ const { clef, m } = parseST(id);
48
+ return 'On the ' + clefDef(clef).n.toLowerCase() + ' staff — ' + bothNames(pcOf(m), pref);
49
+ }
50
+ case 'md:': {
51
+ const { keyPc, pc } = parseMD(id);
52
+ const note = noteName(pc, 'en', pref);
53
+ return 'In ' + keyName(keyPc) + ' — ' + note + ', the ' + degName(pc, keyPc, pref);
54
+ }
55
+ case 'rh:':
56
+ return describeRhythm(id);
57
+ case 'pl:':
58
+ return 'Playing ' + bothNames(+rest, pref);
59
+ case 'se:': {
60
+ const scale = scaleDef(rest);
61
+ return 'Scale by ear — ' + (scale ? scale.n : rest);
62
+ }
63
+ case 'tr:':
64
+ return 'Naming ' + bothNames(+rest, pref);
65
+ default:
66
+ /* A card type with no description of its own used to fall into the line
67
+ above and be read as a pitch class, which turned "se:dorian" into
68
+ "Naming undefined · undefined". Printing the id looks daft; printing
69
+ nonsense confidently is worse. */
70
+ return id;
71
+ }
72
+ }
@@ -0,0 +1,44 @@
1
+ import { MKEYS, pcOf } from '../music/notes';
2
+ import { accOn, allowedPc, positions } from '../music/bass';
3
+ import { enabledScales } from '../music/scales';
4
+
5
+ /* ---------------- card pools ----------------
6
+ Every card id carries its own meaning, and the prefixes are kept exactly as
7
+ Fretboard Fluency had them so old progress still lines up:
8
+ fb:s:f a position on the neck st:m / st:clef:m a note on a staff
9
+ tr:pc naming a pitch class pl:pc playing a pitch class
10
+ md:k:pc a note in a key rh:… rhythm
11
+ iv:n an interval ch:id a chord
12
+ se:id a scale by ear sc:id:root a scale in a key */
13
+
14
+ const PITCH_CLASSES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
15
+ const MAJOR_SCALE_STEPS = [0, 2, 4, 5, 7, 9, 11];
16
+
17
+ /** Pitch classes in rotation, for translating between letters and solfège. */
18
+ export function pcs(): { pc: number; id: string }[] {
19
+ return PITCH_CLASSES.filter(allowedPc).map((pc) => ({ pc, id: 'tr:' + pc }));
20
+ }
21
+ /** Pitch classes you can actually reach at this range, for "play that note" —
22
+ the answer is the right note in any octave, so the card is the pitch class. */
23
+ export function playPcs(): { pc: number; id: string }[] {
24
+ const reachable = new Set(positions().map((pos) => pcOf(pos.m)));
25
+ return [...reachable].sort((a, b) => a - b).map((pc) => ({ pc, id: 'pl:' + pc }));
26
+ }
27
+ export function parseMD(id: string): { keyPc: number; pc: number } {
28
+ const parts = id.split(':');
29
+ return { keyPc: +parts[1], pc: +parts[2] };
30
+ }
31
+ /** The degrees asked about in movable-do cards: all twelve when the black
32
+ notes are on, otherwise the seven of the major scale. */
33
+ export function mdoRels(): number[] {
34
+ return accOn() ? [...PITCH_CLASSES] : [...MAJOR_SCALE_STEPS];
35
+ }
36
+ export function mdoCards(): string[] {
37
+ const degrees = mdoRels();
38
+ return MKEYS.flatMap((key) =>
39
+ degrees.map((degree) => 'md:' + key.pc + ':' + pcOf(key.pc + degree)),
40
+ );
41
+ }
42
+ export function scalearCards(): string[] {
43
+ return enabledScales().map((scale) => 'se:' + scale.id);
44
+ }
@@ -0,0 +1,100 @@
1
+ import { honmoon, mastery } from '../store/honmoon.svelte';
2
+ import { dayKey, todayKey } from '../util/text';
3
+ import { positions } from '../music/bass';
4
+ import { staffNotes } from '../music/clefs';
5
+ import { earCards } from '../music/intervals';
6
+ import { scaleCards } from '../music/scales';
7
+ import { pcs, playPcs, scalearCards } from './pools';
8
+ import { drillDef, famDrills, groupDef } from '../drills/registry.svelte';
9
+ import type { DrillDef } from '../drills/defs';
10
+
11
+ const MAX_MASTERY = 5;
12
+ /** Mastery summed over some cards, as a percentage of all of them being solid. */
13
+ function masteryPercent(ids: string[]): number {
14
+ const total = ids.reduce((sum, id) => sum + mastery(id), 0);
15
+ return Math.round((100 * total) / (ids.length * MAX_MASTERY));
16
+ }
17
+
18
+ /** The cards a drill is measured over. */
19
+ function cardsMeasuredBy(drill: DrillDef): string[] {
20
+ const asIds = (items: { id: string }[]) => items.map((item) => item.id);
21
+ switch (drill.kind) {
22
+ case 'earscale':
23
+ return scalearCards();
24
+ case 'ear':
25
+ return earCards();
26
+ case 'translate':
27
+ return asIds(pcs());
28
+ case 'staff2name':
29
+ case 'staffkey':
30
+ return asIds(staffNotes());
31
+ case 'play':
32
+ return drill.sys === 'staff' ? asIds(staffNotes()) : asIds(playPcs());
33
+ default:
34
+ return asIds(positions());
35
+ }
36
+ }
37
+ /** How solid a drill's cards are, as a percentage of full mastery. */
38
+ export function drillMastery(id: string): { pct: number; n: number } {
39
+ const drill = drillDef(id);
40
+ if (!drill) {
41
+ return { pct: 0, n: 0 };
42
+ }
43
+ const ids = cardsMeasuredBy(drill);
44
+ if (!ids.length) {
45
+ return { pct: 0, n: 0 };
46
+ }
47
+ return { pct: masteryPercent(ids), n: ids.length };
48
+ }
49
+ /** How solid the scale-and-key pairs in rotation are. */
50
+ export function scaleDrillMastery(): number {
51
+ const ids = scaleCards();
52
+ return ids.length ? masteryPercent(ids) : 0;
53
+ }
54
+ export function famMastery(familyKey: string): { pct: number; n: number } {
55
+ const drills = famDrills(familyKey);
56
+ if (!drills.length) {
57
+ return { pct: 0, n: 0 };
58
+ }
59
+ /* (The original added scale mastery as an object's missing .pct here, which
60
+ made the family's figure NaN and hid it. It is a plain number now.) */
61
+ const isScaleFamily = groupDef(familyKey)?.k === 'scale';
62
+ let total = 0;
63
+ for (const drill of drills) {
64
+ total += isScaleFamily ? scaleDrillMastery() : drillMastery(drill.id).pct;
65
+ }
66
+ return { pct: Math.round(total / drills.length), n: drills.length };
67
+ }
68
+
69
+ /** Don't count back further than this many days. */
70
+ const MAX_STREAK = 400;
71
+ /** Consecutive days with at least one answer, counting today only once it has one. */
72
+ export function streakDays(): number {
73
+ const day = new Date();
74
+ let streak = 0;
75
+ for (;;) {
76
+ const key = dayKey(day);
77
+ if (honmoon.daily[key] && honmoon.daily[key].n > 0) {
78
+ streak++;
79
+ day.setDate(day.getDate() - 1);
80
+ } else if (streak === 0 && key === todayKey()) {
81
+ // nothing yet today: the streak can still be yesterday's
82
+ day.setDate(day.getDate() - 1);
83
+ } else {
84
+ break;
85
+ }
86
+ if (streak > MAX_STREAK) {
87
+ break;
88
+ }
89
+ }
90
+ return streak;
91
+ }
92
+ export function totals(): { n: number; c: number; acc: number } {
93
+ let answers = 0;
94
+ let correct = 0;
95
+ for (const key in honmoon.daily) {
96
+ answers += honmoon.daily[key].n;
97
+ correct += honmoon.daily[key].c;
98
+ }
99
+ return { n: answers, c: correct, acc: answers ? Math.round((100 * correct) / answers) : 0 };
100
+ }