@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.
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/package.json +58 -4
- package/src/lib/audio/context.ts +29 -0
- package/src/lib/audio/metronome.svelte.ts +146 -0
- package/src/lib/audio/mic.ts +254 -0
- package/src/lib/audio/pitch.ts +133 -0
- package/src/lib/audio/rhythmplay.ts +152 -0
- package/src/lib/audio/synth.ts +409 -0
- package/src/lib/audio/voiceplay.ts +60 -0
- package/src/lib/drills/defs.ts +459 -0
- package/src/lib/drills/questions.ts +920 -0
- package/src/lib/drills/registry.svelte.ts +167 -0
- package/src/lib/drills/runtime.svelte.ts +629 -0
- package/src/lib/drills/verdict.ts +241 -0
- package/src/lib/index.ts +117 -0
- package/src/lib/learn/describe.ts +72 -0
- package/src/lib/learn/pools.ts +44 -0
- package/src/lib/learn/progress.ts +100 -0
- package/src/lib/learn/session.ts +304 -0
- package/src/lib/music/bass.ts +139 -0
- package/src/lib/music/clefs.ts +218 -0
- package/src/lib/music/intervals.ts +94 -0
- package/src/lib/music/notes.ts +133 -0
- package/src/lib/music/rhythm.ts +624 -0
- package/src/lib/music/scales.ts +560 -0
- package/src/lib/music/voice.ts +227 -0
- package/src/lib/music/warmups.ts +318 -0
- package/src/lib/nav.svelte.ts +53 -0
- package/src/lib/notation/fretboard.ts +187 -0
- package/src/lib/notation/glyphdata.ts +101 -0
- package/src/lib/notation/glyphs.ts +71 -0
- package/src/lib/notation/icons.ts +12 -0
- package/src/lib/notation/keyboard.ts +166 -0
- package/src/lib/notation/rhythmsvg.ts +341 -0
- package/src/lib/notation/staff.ts +288 -0
- package/src/lib/notation/svg.ts +37 -0
- package/src/lib/notation/tab.ts +80 -0
- package/src/lib/store/honmoon.svelte.ts +329 -0
- package/src/lib/store/normalize.ts +23 -0
- package/src/lib/store/sync.ts +43 -0
- package/src/lib/store/types.ts +101 -0
- package/src/lib/styles/honmoon.css +2064 -0
- package/src/lib/ui/AccRow.svelte +23 -0
- package/src/lib/ui/BackBar.svelte +23 -0
- package/src/lib/ui/BassRow.svelte +30 -0
- package/src/lib/ui/Chips.svelte +23 -0
- package/src/lib/ui/DataCard.svelte +66 -0
- package/src/lib/ui/Fretboard.svelte +71 -0
- package/src/lib/ui/Keyboard.svelte +54 -0
- package/src/lib/ui/MetronomePanel.svelte +101 -0
- package/src/lib/ui/RefBar.svelte +28 -0
- package/src/lib/ui/RhythmBar.svelte +9 -0
- package/src/lib/ui/Shell.svelte +96 -0
- package/src/lib/ui/StaffRun.svelte +46 -0
- package/src/lib/ui/Stat.svelte +13 -0
- package/src/lib/ui/actions.ts +36 -0
- package/src/lib/ui/keyfollow.svelte.ts +48 -0
- package/src/lib/ui/overlay.ts +15 -0
- package/src/lib/ui/refdata.ts +355 -0
- package/src/lib/ui/scaleboard.ts +420 -0
- package/src/lib/ui/screens/DegreesRef.svelte +405 -0
- package/src/lib/ui/screens/DrillScreen.svelte +1155 -0
- package/src/lib/ui/screens/Explorer.svelte +245 -0
- package/src/lib/ui/screens/FamilyScreen.svelte +280 -0
- package/src/lib/ui/screens/Glossary.svelte +65 -0
- package/src/lib/ui/screens/IntervalRef.svelte +37 -0
- package/src/lib/ui/screens/LearnIndex.svelte +44 -0
- package/src/lib/ui/screens/MetronomeScreen.svelte +17 -0
- package/src/lib/ui/screens/PracticeHome.svelte +159 -0
- package/src/lib/ui/screens/RhythmRef.svelte +239 -0
- package/src/lib/ui/screens/ScalesScreen.svelte +210 -0
- package/src/lib/ui/screens/SessionDone.svelte +119 -0
- package/src/lib/ui/screens/SetupScreen.svelte +548 -0
- package/src/lib/ui/screens/StaffMap.svelte +230 -0
- package/src/lib/ui/screens/StatsScreen.svelte +478 -0
- package/src/lib/ui/screens/TapCalibration.svelte +186 -0
- package/src/lib/ui/tapfix.ts +93 -0
- package/src/lib/ui/types.ts +40 -0
- package/src/lib/util/list.ts +4 -0
- package/src/lib/util/random.ts +23 -0
- package/src/lib/util/text.ts +50 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { honmoon, mastery } from '../store/honmoon.svelte';
|
|
2
|
+
import { STRINGS, allowedPc, bassHere, placesFor, positions } from '../music/bass';
|
|
3
|
+
import { parseST, staffNotes } from '../music/clefs';
|
|
4
|
+
import { earCards } from '../music/intervals';
|
|
5
|
+
import { mdoOn } from '../music/notes';
|
|
6
|
+
import { rhCardFits, rhCards } from '../music/rhythm';
|
|
7
|
+
import { parseSC, scaleCards, scaleOn } from '../music/scales';
|
|
8
|
+
import { shuffle } from '../util/random';
|
|
9
|
+
import {
|
|
10
|
+
appDrills,
|
|
11
|
+
drillAvailable,
|
|
12
|
+
drillOn,
|
|
13
|
+
groupOfDrill,
|
|
14
|
+
groupUsable,
|
|
15
|
+
sdrillOn,
|
|
16
|
+
} from '../drills/registry.svelte';
|
|
17
|
+
import { isScaleDrill, type DrillDef } from '../drills/defs';
|
|
18
|
+
import { mdoCards, parseMD, pcs, playPcs, scalearCards } from './pools';
|
|
19
|
+
|
|
20
|
+
/* ---------------- sampling ----------------
|
|
21
|
+
How badly a card needs practice: error rate, how stale it is, whether it has
|
|
22
|
+
ever been seen, and how far its mastery is from solid. */
|
|
23
|
+
const MS_PER_HOUR = 3600000;
|
|
24
|
+
/** Staleness stops counting after three days. */
|
|
25
|
+
const MAX_STALE_HOURS = 72;
|
|
26
|
+
/** Every this many hours unseen adds the card's base weight again. */
|
|
27
|
+
const STALE_SCALE_HOURS = 22;
|
|
28
|
+
const ERROR_RATE_WEIGHT = 3.2;
|
|
29
|
+
const NEVER_SEEN_BOOST = 2.4;
|
|
30
|
+
/** Each step of mastery short of solid adds this share. */
|
|
31
|
+
const PER_MISSING_MASTERY = 0.22;
|
|
32
|
+
/** The card just asked is this much less likely to come straight back. */
|
|
33
|
+
const JUST_ASKED_FACTOR = 0.06;
|
|
34
|
+
|
|
35
|
+
export function weightOf(id: string): number {
|
|
36
|
+
const stats = honmoon.cards[id];
|
|
37
|
+
const seen = stats ? stats.s : 0;
|
|
38
|
+
const wrong = stats ? stats.s - stats.c : 0;
|
|
39
|
+
const errorRate = seen ? wrong / seen : 0;
|
|
40
|
+
const hoursStale =
|
|
41
|
+
stats && stats.t
|
|
42
|
+
? Math.min((Date.now() - stats.t) / MS_PER_HOUR, MAX_STALE_HOURS)
|
|
43
|
+
: MAX_STALE_HOURS;
|
|
44
|
+
return (
|
|
45
|
+
(1 + ERROR_RATE_WEIGHT * errorRate) *
|
|
46
|
+
(1 + hoursStale / STALE_SCALE_HOURS) *
|
|
47
|
+
(seen === 0 ? NEVER_SEEN_BOOST : 1) *
|
|
48
|
+
(1 + (5 - mastery(id)) * PER_MISSING_MASTERY)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
/** A random index, each chosen with probability proportional to its weight. */
|
|
52
|
+
function weightedIndex(weights: number[]): number {
|
|
53
|
+
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
|
54
|
+
let remaining = Math.random() * total;
|
|
55
|
+
for (let i = 0; i < weights.length; i++) {
|
|
56
|
+
remaining -= weights[i];
|
|
57
|
+
if (remaining <= 0) {
|
|
58
|
+
return i;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return weights.length - 1;
|
|
62
|
+
}
|
|
63
|
+
let lastId: string | null = null;
|
|
64
|
+
/** One card from a pool, weighted by need — and almost never the one just asked. */
|
|
65
|
+
export function pick<T extends { id: string }>(pool: T[]): T | null {
|
|
66
|
+
if (!pool.length) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const weights = pool.map((item) => {
|
|
70
|
+
const weight = weightOf(item.id);
|
|
71
|
+
return item.id === lastId && pool.length > 1 ? weight * JUST_ASKED_FACTOR : weight;
|
|
72
|
+
});
|
|
73
|
+
const chosen = pool[weightedIndex(weights)];
|
|
74
|
+
lastId = chosen.id;
|
|
75
|
+
return chosen;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Semitones between two pitch classes, the short way round (0–6). */
|
|
79
|
+
function pcDistance(a: number, b: number): number {
|
|
80
|
+
const apart = Math.abs(a - b);
|
|
81
|
+
return Math.min(apart, 12 - apart);
|
|
82
|
+
}
|
|
83
|
+
/** Take a random element out of an array. */
|
|
84
|
+
function takeRandom<T>(items: T[]): T {
|
|
85
|
+
return items.splice(Math.floor(Math.random() * items.length), 1)[0];
|
|
86
|
+
}
|
|
87
|
+
/** Wrong answers near the right one: a semitone or two away first. */
|
|
88
|
+
export function distractors(correctPc: number, count: number): number[] {
|
|
89
|
+
const pool: number[] = [];
|
|
90
|
+
for (let pc = 0; pc < 12; pc++) {
|
|
91
|
+
if (allowedPc(pc) && pc !== correctPc) {
|
|
92
|
+
pool.push(pc);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const near = pool.filter((pc) => pcDistance(pc, correctPc) <= 2);
|
|
96
|
+
const out: number[] = [];
|
|
97
|
+
while (out.length < count && near.length) {
|
|
98
|
+
out.push(takeRandom(near));
|
|
99
|
+
}
|
|
100
|
+
while (out.length < count && pool.length) {
|
|
101
|
+
const pc = takeRandom(pool);
|
|
102
|
+
if (!out.includes(pc)) {
|
|
103
|
+
out.push(pc);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
export interface Choice {
|
|
109
|
+
/** the label shown */
|
|
110
|
+
t: string;
|
|
111
|
+
/** whether it is the right answer */
|
|
112
|
+
ok: boolean;
|
|
113
|
+
}
|
|
114
|
+
/** Multiple-choice answers, built safely.
|
|
115
|
+
Every option must read differently from every other, and exactly one is
|
|
116
|
+
right. When a pool cannot supply four distinct labels the question asks
|
|
117
|
+
with fewer — padding it out by repeating a label does not make the
|
|
118
|
+
question harder, it makes it unanswerable, because the correct answer
|
|
119
|
+
becomes indistinguishable from a wrong one. */
|
|
120
|
+
export function options(correct: string, pool: string[], count?: number): Choice[] {
|
|
121
|
+
const max = count || 4;
|
|
122
|
+
const out: Choice[] = [{ t: correct, ok: true }];
|
|
123
|
+
const used = new Set([correct]);
|
|
124
|
+
for (const label of shuffle(pool.slice())) {
|
|
125
|
+
if (out.length >= max || used.has(label)) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
used.add(label);
|
|
129
|
+
out.push({ t: label, ok: false });
|
|
130
|
+
}
|
|
131
|
+
return shuffle(out);
|
|
132
|
+
}
|
|
133
|
+
/** options() shuffles what it is handed, so the best three wrong answers are
|
|
134
|
+
chosen here, after dropping any that would read the same as the right one. */
|
|
135
|
+
export function altOptions(correct: string, labels: string[]): Choice[] {
|
|
136
|
+
const used = new Set([correct]);
|
|
137
|
+
const keep: string[] = [];
|
|
138
|
+
for (const label of labels) {
|
|
139
|
+
if (!used.has(label) && keep.length < 3) {
|
|
140
|
+
used.add(label);
|
|
141
|
+
keep.push(label);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return options(correct, keep);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ---------------- which drills can ask a card ---------------- */
|
|
148
|
+
/** For an ordinary (non-scale, non-ear) card: which drill kinds can ask it,
|
|
149
|
+
by card prefix. */
|
|
150
|
+
const ASKS_CARD: Record<string, (drill: DrillDef, id: string) => boolean> = {
|
|
151
|
+
'fb:': (drill) => drill.kind === 'fb2name' || drill.kind === 'name2fb',
|
|
152
|
+
'st:': (drill, id) =>
|
|
153
|
+
drill.kind === 'staff2name' ||
|
|
154
|
+
drill.kind === 'staffkey' ||
|
|
155
|
+
(drill.kind === 'play' && drill.sys === 'staff' && parseST(id).clef === 'bass'),
|
|
156
|
+
'tr:': (drill) => drill.kind === 'translate',
|
|
157
|
+
'pl:': (drill) => drill.kind === 'play' && drill.sys !== 'staff',
|
|
158
|
+
'md:': (drill, id) =>
|
|
159
|
+
drill.kind === 'mdo' && (drill.from !== 'fb' || placesFor(parseMD(id).pc).length > 0),
|
|
160
|
+
'rh:': (drill, id) => drill.kind === 'rhythm' && rhCardFits(id, drill.rk!),
|
|
161
|
+
'se:': (drill, id) => drill.kind === 'earscale' && scaleOn(id.slice(3)),
|
|
162
|
+
};
|
|
163
|
+
export function drillsForCard(id: string): DrillDef[] {
|
|
164
|
+
const prefix = id.slice(0, 3);
|
|
165
|
+
const drills = appDrills();
|
|
166
|
+
if (prefix === 'sc:') {
|
|
167
|
+
return drills.filter(
|
|
168
|
+
(drill) => isScaleDrill(drill.id) && sdrillOn(drill.id) && (!drill.bass || bassHere()),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (prefix === 'iv:' || prefix === 'ch:') {
|
|
172
|
+
return drills.filter((drill) => {
|
|
173
|
+
if (drill.kind !== 'ear' || !groupUsable(groupOfDrill(drill.id)!) || !drillOn(drill.id)) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
// chords are asked only by the chord drill, intervals by the others
|
|
177
|
+
return prefix === 'ch:' ? drill.id === 'chhear' : drill.id !== 'chhear';
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const asks = ASKS_CARD[prefix];
|
|
181
|
+
return drills.filter(
|
|
182
|
+
(drill) =>
|
|
183
|
+
!isScaleDrill(drill.id) &&
|
|
184
|
+
groupUsable(groupOfDrill(drill.id)!) &&
|
|
185
|
+
drillOn(drill.id) &&
|
|
186
|
+
drillAvailable(drill) &&
|
|
187
|
+
!!asks &&
|
|
188
|
+
asks(drill, id),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Every card the daily session can draw from, in this app. */
|
|
193
|
+
export function allCards(): string[] {
|
|
194
|
+
const out: string[] = [];
|
|
195
|
+
if (groupUsable('ear') || groupUsable('voice')) {
|
|
196
|
+
out.push(...earCards());
|
|
197
|
+
}
|
|
198
|
+
if (groupUsable('ear')) {
|
|
199
|
+
out.push(...scalearCards());
|
|
200
|
+
}
|
|
201
|
+
out.push(...positions().map((pos) => pos.id));
|
|
202
|
+
out.push(...staffNotes().map((note) => note.id));
|
|
203
|
+
out.push(...pcs().map((item) => item.id));
|
|
204
|
+
out.push(...playPcs().map((item) => item.id));
|
|
205
|
+
if (mdoOn()) {
|
|
206
|
+
out.push(...mdoCards());
|
|
207
|
+
}
|
|
208
|
+
if (groupUsable('read')) {
|
|
209
|
+
out.push(...rhCards());
|
|
210
|
+
}
|
|
211
|
+
/* Scale-and-key pairs join the daily mix when the family is left on. The
|
|
212
|
+
Scales screen keeps its own session for when you want a run of nothing
|
|
213
|
+
else, and that one ignores this switch. */
|
|
214
|
+
if (groupUsable('scale')) {
|
|
215
|
+
out.push(...scaleCards());
|
|
216
|
+
}
|
|
217
|
+
return out.filter((id) => drillsForCard(id).length > 0);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface QueueEntry {
|
|
221
|
+
card: string;
|
|
222
|
+
drill: string;
|
|
223
|
+
/** a card coming back after being missed */
|
|
224
|
+
retry?: 1;
|
|
225
|
+
}
|
|
226
|
+
/** Draw the weakest cards first, but without replacement, so one bad note
|
|
227
|
+
cannot eat the whole session. Refill if the range is smaller than the
|
|
228
|
+
session length. */
|
|
229
|
+
export function buildSession(length: number, mode?: 'scales' | 'theory'): QueueEntry[] {
|
|
230
|
+
const cards =
|
|
231
|
+
mode === 'scales' ? scaleCards().filter((id) => drillsForCard(id).length > 0) : allCards();
|
|
232
|
+
if (!cards.length) {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
let pool = cards.slice();
|
|
236
|
+
const out: QueueEntry[] = [];
|
|
237
|
+
for (let k = 0; k < length; k++) {
|
|
238
|
+
if (!pool.length) {
|
|
239
|
+
pool = cards.slice();
|
|
240
|
+
}
|
|
241
|
+
const index = weightedIndex(pool.map(weightOf));
|
|
242
|
+
const id = pool.splice(index, 1)[0];
|
|
243
|
+
const drills = drillsForCard(id);
|
|
244
|
+
out.push({ card: id, drill: drills[Math.floor(Math.random() * drills.length)].id });
|
|
245
|
+
}
|
|
246
|
+
return decluster(shuffle(out));
|
|
247
|
+
}
|
|
248
|
+
/** No drill three times running, if it can be helped. */
|
|
249
|
+
function decluster(queue: QueueEntry[]): QueueEntry[] {
|
|
250
|
+
for (let i = 2; i < queue.length; i++) {
|
|
251
|
+
const threeInARow =
|
|
252
|
+
queue[i].drill === queue[i - 1].drill && queue[i - 1].drill === queue[i - 2].drill;
|
|
253
|
+
if (!threeInARow) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
for (let j = i + 1; j < queue.length; j++) {
|
|
257
|
+
if (queue[j].drill !== queue[i].drill) {
|
|
258
|
+
[queue[i], queue[j]] = [queue[j], queue[i]];
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return queue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** What a card id stands for, as the question makers want it. */
|
|
267
|
+
export type CardItem =
|
|
268
|
+
| { id: string; sid: string; root: number }
|
|
269
|
+
| { id: string; s: number; f: number; m: number }
|
|
270
|
+
| { id: string; m: number; clef: string }
|
|
271
|
+
| { id: string; keyPc: number; pc: number }
|
|
272
|
+
| { id: string; pc: number }
|
|
273
|
+
| { id: string };
|
|
274
|
+
export function cardToItem(id: string): CardItem | null {
|
|
275
|
+
const prefix = id.slice(0, 3);
|
|
276
|
+
switch (prefix) {
|
|
277
|
+
case 'sc:': {
|
|
278
|
+
const { sid, root } = parseSC(id);
|
|
279
|
+
return { sid, root, id };
|
|
280
|
+
}
|
|
281
|
+
case 'iv:':
|
|
282
|
+
case 'ch:':
|
|
283
|
+
case 'se:':
|
|
284
|
+
case 'rh:':
|
|
285
|
+
return { id };
|
|
286
|
+
case 'fb:': {
|
|
287
|
+
const [, string, fret] = id.split(':').map(Number);
|
|
288
|
+
return { s: string, f: fret, m: STRINGS[string].open + fret, id };
|
|
289
|
+
}
|
|
290
|
+
case 'st:': {
|
|
291
|
+
const { m, clef } = parseST(id);
|
|
292
|
+
return { m, clef, id };
|
|
293
|
+
}
|
|
294
|
+
case 'md:': {
|
|
295
|
+
const { keyPc, pc } = parseMD(id);
|
|
296
|
+
return { keyPc, pc, id };
|
|
297
|
+
}
|
|
298
|
+
case 'tr:':
|
|
299
|
+
case 'pl:':
|
|
300
|
+
return { pc: +id.slice(3), id };
|
|
301
|
+
default:
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { honmoon } from '../store/honmoon.svelte';
|
|
2
|
+
import { NAT, pcOf } from './notes';
|
|
3
|
+
import type { LevelId } from '../store/types';
|
|
4
|
+
|
|
5
|
+
/* Written pitch: bass sounds an octave below what is written.
|
|
6
|
+
Open strings written: E2 40, A2 45, D3 50, G3 55. Top row -> bottom row. */
|
|
7
|
+
export const STRINGS = [
|
|
8
|
+
{ n: 'G', open: 55 },
|
|
9
|
+
{ n: 'D', open: 50 },
|
|
10
|
+
{ n: 'A', open: 45 },
|
|
11
|
+
{ n: 'E', open: 40 },
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
/* How far up the neck, and — separately — whether the black notes are in.
|
|
15
|
+
These used to be one four-step ladder ending in "All 12 notes", which meant
|
|
16
|
+
the only way to meet a sharp was to jump to the whole neck, and nothing in
|
|
17
|
+
the name of a range said anything about sharps at all. They are two
|
|
18
|
+
different questions, so they are two separate controls. */
|
|
19
|
+
export interface Level {
|
|
20
|
+
id: LevelId;
|
|
21
|
+
name: string;
|
|
22
|
+
lo: number;
|
|
23
|
+
hi: number;
|
|
24
|
+
/** how far the board is drawn, when that is wider than what is carded */
|
|
25
|
+
vhi?: number;
|
|
26
|
+
desc: string;
|
|
27
|
+
}
|
|
28
|
+
export const LEVELS: Level[] = [
|
|
29
|
+
{
|
|
30
|
+
id: 'l0',
|
|
31
|
+
name: 'Open strings',
|
|
32
|
+
lo: 0,
|
|
33
|
+
hi: 0,
|
|
34
|
+
vhi: 4,
|
|
35
|
+
desc: 'The four open strings only — the place to start on the staff',
|
|
36
|
+
},
|
|
37
|
+
{ id: 'l1', name: 'Open position', lo: 0, hi: 5, desc: 'Frets 0–5' },
|
|
38
|
+
{ id: 'l2', name: 'Full neck', lo: 0, hi: 12, desc: 'Frets 0–12' },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export function lvl(): Level {
|
|
42
|
+
return LEVELS.find((level) => level.id === honmoon.settings.level) ?? LEVELS[1];
|
|
43
|
+
}
|
|
44
|
+
/** The highest fret drawn on the board (can be wider than the carded range). */
|
|
45
|
+
export function vhi(): number {
|
|
46
|
+
const level = lvl();
|
|
47
|
+
return level.vhi ?? level.hi;
|
|
48
|
+
}
|
|
49
|
+
export function accOn(): boolean {
|
|
50
|
+
return honmoon.settings.acc === true;
|
|
51
|
+
}
|
|
52
|
+
export function allowedPc(pc: number): boolean {
|
|
53
|
+
return accOn() || NAT.includes(pc);
|
|
54
|
+
}
|
|
55
|
+
/** Whether the instrument is to hand. Separate from the microphone setting:
|
|
56
|
+
the mic is about how a played note gets graded, this is about whether
|
|
57
|
+
there is a bass in the room at all. */
|
|
58
|
+
export function bassHere(): boolean {
|
|
59
|
+
return honmoon.settings.bass !== false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A place on the neck: string index `s` (0 = G, top row), fret `f`, the
|
|
63
|
+
written MIDI pitch `m`, and its card id. */
|
|
64
|
+
export interface FretPos {
|
|
65
|
+
s: number;
|
|
66
|
+
f: number;
|
|
67
|
+
m: number;
|
|
68
|
+
id: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const STRING_COUNT = STRINGS.length;
|
|
72
|
+
/** The neck as drawn on maps and used to place a written pitch: frets 0–12. */
|
|
73
|
+
const MAP_LAST_FRET = 12;
|
|
74
|
+
/** The highest fret `findPos` will suggest. */
|
|
75
|
+
const REACHABLE_LAST_FRET = 15;
|
|
76
|
+
/** String indices from the lowest string (E) up. */
|
|
77
|
+
const LOW_TO_HIGH_STRINGS = [3, 2, 1, 0];
|
|
78
|
+
|
|
79
|
+
function cardIdForFret(string: number, fret: number): string {
|
|
80
|
+
return 'fb:' + string + ':' + fret;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every carded position on the neck at the current range. */
|
|
84
|
+
export function positions(): FretPos[] {
|
|
85
|
+
const level = lvl();
|
|
86
|
+
const out: FretPos[] = [];
|
|
87
|
+
for (let string = 0; string < STRING_COUNT; string++) {
|
|
88
|
+
for (let fret = level.lo; fret <= level.hi; fret++) {
|
|
89
|
+
const midi = STRINGS[string].open + fret;
|
|
90
|
+
if (allowedPc(pcOf(midi))) {
|
|
91
|
+
out.push({ s: string, f: fret, m: midi, id: cardIdForFret(string, fret) });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
/** Every place a pitch class can be played at this range. */
|
|
98
|
+
export function placesFor(pc: number): FretPos[] {
|
|
99
|
+
return positions().filter((pos) => pcOf(pos.m) === pc);
|
|
100
|
+
}
|
|
101
|
+
/** Every place a written pitch sits on a 12-fret neck. */
|
|
102
|
+
export function placesForMidi(midi: number): { s: number; f: number }[] {
|
|
103
|
+
const out: { s: number; f: number }[] = [];
|
|
104
|
+
for (let string = 0; string < STRING_COUNT; string++) {
|
|
105
|
+
for (let fret = 0; fret <= MAP_LAST_FRET; fret++) {
|
|
106
|
+
if (STRINGS[string].open + fret === midi) {
|
|
107
|
+
out.push({ s: string, f: fret });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/** Where one pitch class sits on a single string, within the range on show.
|
|
114
|
+
Two, when the string covers a full octave. The whole span that is drawn and
|
|
115
|
+
tappable counts, not just the carded one: if a fret is on the board and is
|
|
116
|
+
that note, tapping it is a fair answer. */
|
|
117
|
+
export function sameNoteFrets(string: number, pc: number): number[] {
|
|
118
|
+
const out: number[] = [];
|
|
119
|
+
for (let fret = lvl().lo; fret <= vhi(); fret++) {
|
|
120
|
+
if (pcOf(STRINGS[string].open + fret) === pc) {
|
|
121
|
+
out.push(fret);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
/** The first playable spot for a written pitch, trying a preferred string first. */
|
|
127
|
+
export function findPos(
|
|
128
|
+
midi: number,
|
|
129
|
+
preferString?: number | null,
|
|
130
|
+
): { s: number; f: number } | null {
|
|
131
|
+
const order = preferString == null ? LOW_TO_HIGH_STRINGS : [preferString, ...LOW_TO_HIGH_STRINGS];
|
|
132
|
+
for (const string of order) {
|
|
133
|
+
const fret = midi - STRINGS[string].open;
|
|
134
|
+
if (fret >= 0 && fret <= REACHABLE_LAST_FRET) {
|
|
135
|
+
return { s: string, f: fret };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { honmoon } from '../store/honmoon.svelte';
|
|
2
|
+
import type { ClefId, Pref } from '../store/types';
|
|
3
|
+
import { LETTERS, NATPC, STEP, pcOf, spell } from './notes';
|
|
4
|
+
import { STRINGS, allowedPc, lvl } from './bass';
|
|
5
|
+
import { rnd } from '../util/random';
|
|
6
|
+
|
|
7
|
+
/* staff coords: line y = 0,10,20,30,40, bottom line at y=40.
|
|
8
|
+
A clef is nothing but an offset: it names the diatonic index sitting on
|
|
9
|
+
the bottom line. bot 18 = G2 (bass), 30 = E4 (treble), 24 = F3 (alto),
|
|
10
|
+
22 = D3 (tenor). Everything else about the staff is identical. */
|
|
11
|
+
export interface ClefDef {
|
|
12
|
+
id: ClefId;
|
|
13
|
+
n: string;
|
|
14
|
+
ab: string;
|
|
15
|
+
bot: number;
|
|
16
|
+
/** mnemonic for the lines, and for the spaces */
|
|
17
|
+
ml: string;
|
|
18
|
+
ms: string;
|
|
19
|
+
sb: string;
|
|
20
|
+
}
|
|
21
|
+
export const CLEFS: ClefDef[] = [
|
|
22
|
+
{
|
|
23
|
+
id: 'bass',
|
|
24
|
+
n: 'Bass',
|
|
25
|
+
ab: 'F',
|
|
26
|
+
bot: 18,
|
|
27
|
+
ml: 'Good Boys Do Fine Always',
|
|
28
|
+
ms: 'All Cows Eat Grass',
|
|
29
|
+
sb: 'The F clef. Where bass guitar and the low half of the piano are written.',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'treble',
|
|
33
|
+
n: 'Treble',
|
|
34
|
+
ab: 'G',
|
|
35
|
+
bot: 30,
|
|
36
|
+
ml: 'Every Good Boy Deserves Fruit',
|
|
37
|
+
ms: 'bottom to top they spell FACE',
|
|
38
|
+
sb: 'The G clef. Singing, guitar, the right hand of the piano — most music you will ever meet.',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'tenor',
|
|
42
|
+
n: 'Tenor',
|
|
43
|
+
ab: 'C4',
|
|
44
|
+
bot: 22,
|
|
45
|
+
ml: 'Dogs From Africa Chase Elephants',
|
|
46
|
+
ms: 'Every Good Bass Does',
|
|
47
|
+
sb: 'A C clef on the fourth line. Turns up in high bass, cello and trombone parts, to dodge ledger lines.',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'alto',
|
|
51
|
+
n: 'Alto',
|
|
52
|
+
ab: 'C3',
|
|
53
|
+
bot: 24,
|
|
54
|
+
ml: 'Fat Alley Cats Eat Garbage',
|
|
55
|
+
ms: 'Good Bass Does Fine',
|
|
56
|
+
sb: 'A C clef on the middle line. Viola, and the occasional arrangement. Learn it last.',
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
export function clefDef(id: string): ClefDef {
|
|
60
|
+
return CLEFS.find((clef) => clef.id === id) ?? CLEFS[0];
|
|
61
|
+
}
|
|
62
|
+
export function enabledClefs(): ClefId[] {
|
|
63
|
+
const chosen = honmoon.settings.clefs;
|
|
64
|
+
if (!chosen || !chosen.length) {
|
|
65
|
+
return ['bass'];
|
|
66
|
+
}
|
|
67
|
+
const out = CLEFS.filter((clef) => chosen.includes(clef.id)).map((clef) => clef.id);
|
|
68
|
+
return out.length ? out : ['bass'];
|
|
69
|
+
}
|
|
70
|
+
export function clefOn(id: ClefId): boolean {
|
|
71
|
+
return enabledClefs().includes(id);
|
|
72
|
+
}
|
|
73
|
+
export function multiClef(): boolean {
|
|
74
|
+
return enabledClefs().length > 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Staff geometry in SVG units: five lines 10 apart, so one diatonic step
|
|
78
|
+
(line to space) is 5, and the bottom line sits at y = 40. */
|
|
79
|
+
const STAFF_BOTTOM_Y = 40;
|
|
80
|
+
const Y_PER_STEP = 5;
|
|
81
|
+
const STEPS_PER_OCTAVE = 7;
|
|
82
|
+
const STAFF_LINES = 5;
|
|
83
|
+
const STAFF_SPACES = 4;
|
|
84
|
+
/** Two ledger lines = four diatonic steps beyond the staff. */
|
|
85
|
+
const LEDGER_STEPS = 4;
|
|
86
|
+
/** From the bottom line to the top line is eight steps. */
|
|
87
|
+
const STAFF_HEIGHT_STEPS = 8;
|
|
88
|
+
const MIDDLE_C = 60;
|
|
89
|
+
|
|
90
|
+
/** Notation is diatonic, not chromatic: C and C♯ share a line. So position is
|
|
91
|
+
found from a diatonic index, offset by whichever clef is in force. */
|
|
92
|
+
export function staffPos(
|
|
93
|
+
writtenMidi: number,
|
|
94
|
+
pref: Pref,
|
|
95
|
+
clef: string = 'bass',
|
|
96
|
+
): { y: number; l: string; a: number } {
|
|
97
|
+
const bottomLineStep = clefDef(clef || 'bass').bot;
|
|
98
|
+
const octave = Math.floor(writtenMidi / 12) - 1;
|
|
99
|
+
const spelling = spell(pcOf(writtenMidi), pref);
|
|
100
|
+
const diatonicIndex = octave * STEPS_PER_OCTAVE + STEP[spelling.l];
|
|
101
|
+
return {
|
|
102
|
+
y: STAFF_BOTTOM_Y - (diatonicIndex - bottomLineStep) * Y_PER_STEP,
|
|
103
|
+
l: spelling.l,
|
|
104
|
+
a: spelling.a,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** The natural MIDI note at a diatonic index (C-1 = 0, D-1 = 1, …). */
|
|
108
|
+
export function diatonicMidi(diatonicIndex: number): number {
|
|
109
|
+
const octave = Math.floor(diatonicIndex / STEPS_PER_OCTAVE);
|
|
110
|
+
const stepInOctave = diatonicIndex - octave * STEPS_PER_OCTAVE;
|
|
111
|
+
for (let i = 0; i < LETTERS.length; i++) {
|
|
112
|
+
if (STEP[LETTERS[i]] === stepInOctave) {
|
|
113
|
+
return (octave + 1) * 12 + NATPC[i];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return MIDDLE_C;
|
|
117
|
+
}
|
|
118
|
+
export function clefLines(clef: string): number[] {
|
|
119
|
+
const bottom = clefDef(clef).bot;
|
|
120
|
+
const out: number[] = [];
|
|
121
|
+
for (let line = 0; line < STAFF_LINES; line++) {
|
|
122
|
+
out.push(diatonicMidi(bottom + line * 2));
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
export function clefSpaces(clef: string): number[] {
|
|
127
|
+
const bottom = clefDef(clef).bot;
|
|
128
|
+
const out: number[] = [];
|
|
129
|
+
for (let space = 0; space < STAFF_SPACES; space++) {
|
|
130
|
+
out.push(diatonicMidi(bottom + 1 + space * 2));
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
/** The written notes that sit comfortably on a clef: the staff itself plus
|
|
135
|
+
two ledger lines either side. */
|
|
136
|
+
export function clefRange(clef: string): number[] {
|
|
137
|
+
const bottom = clefDef(clef).bot;
|
|
138
|
+
const lowest = bottom - LEDGER_STEPS;
|
|
139
|
+
const highest = bottom + STAFF_HEIGHT_STEPS + LEDGER_STEPS;
|
|
140
|
+
const out: number[] = [];
|
|
141
|
+
for (let step = lowest; step <= highest; step++) {
|
|
142
|
+
out.push(diatonicMidi(step));
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ---- staff cards ----
|
|
148
|
+
Bass-clef cards keep their old id, "st:<midi>", so nothing already learned
|
|
149
|
+
is thrown away; the other clefs add their name in the middle. */
|
|
150
|
+
export interface StaffNote {
|
|
151
|
+
m: number;
|
|
152
|
+
clef: ClefId;
|
|
153
|
+
id: string;
|
|
154
|
+
}
|
|
155
|
+
export function stId(clef: ClefId, m: number): string {
|
|
156
|
+
return clef === 'bass' ? 'st:' + m : 'st:' + clef + ':' + m;
|
|
157
|
+
}
|
|
158
|
+
export function parseST(id: string): { clef: ClefId; m: number } {
|
|
159
|
+
const parts = id.split(':');
|
|
160
|
+
// 'st:m' is a bass-clef note (the original format); 'st:clef:m' any other clef
|
|
161
|
+
return parts.length === 2
|
|
162
|
+
? { clef: 'bass', m: +parts[1] }
|
|
163
|
+
: { clef: parts[1] as ClefId, m: +parts[2] };
|
|
164
|
+
}
|
|
165
|
+
/** Bass clef follows the fret range you are working in, so the staff and the
|
|
166
|
+
neck always agree. The other clefs have no neck to follow, so they use the
|
|
167
|
+
staff itself plus two ledger lines either way. */
|
|
168
|
+
export function staffNotesFor(clef: ClefId): StaffNote[] {
|
|
169
|
+
const out: StaffNote[] = clef === 'bass' ? bassClefNotesOnNeck() : notesAroundStaff(clef);
|
|
170
|
+
out.sort((a, b) => a.m - b.m);
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
/** Each distinct written pitch the neck covers at the current range. */
|
|
174
|
+
function bassClefNotesOnNeck(): StaffNote[] {
|
|
175
|
+
const level = lvl();
|
|
176
|
+
const seen = new Set<number>();
|
|
177
|
+
const out: StaffNote[] = [];
|
|
178
|
+
for (let string = 0; string < STRINGS.length; string++) {
|
|
179
|
+
for (let fret = level.lo; fret <= level.hi; fret++) {
|
|
180
|
+
const midi = STRINGS[string].open + fret;
|
|
181
|
+
if (allowedPc(pcOf(midi)) && !seen.has(midi)) {
|
|
182
|
+
seen.add(midi);
|
|
183
|
+
out.push({ m: midi, clef: 'bass', id: stId('bass', midi) });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
function notesAroundStaff(clef: ClefId): StaffNote[] {
|
|
190
|
+
return clefRange(clef)
|
|
191
|
+
.filter((midi) => allowedPc(pcOf(midi)))
|
|
192
|
+
.map((midi) => ({ m: midi, clef, id: stId(clef, midi) }));
|
|
193
|
+
}
|
|
194
|
+
export function staffNotes(): StaffNote[] {
|
|
195
|
+
return enabledClefs().flatMap((clef) => staffNotesFor(clef));
|
|
196
|
+
}
|
|
197
|
+
/** A written pitch carrying this pitch class, sitting on the given clef. */
|
|
198
|
+
export function midiForPcInClef(pc: number, clef: ClefId): number {
|
|
199
|
+
const matches = clefRange(clef).filter((midi) => pcOf(midi) === pc);
|
|
200
|
+
if (matches.length) {
|
|
201
|
+
return matches[rnd(matches.length)];
|
|
202
|
+
}
|
|
203
|
+
/* spelled notes only cover naturals, so step to the nearest one and let
|
|
204
|
+
the accidental do the rest */
|
|
205
|
+
const range = clefRange(clef);
|
|
206
|
+
let nearest = range[0];
|
|
207
|
+
let nearestDistance = Infinity;
|
|
208
|
+
for (const midi of range) {
|
|
209
|
+
const distance = Math.abs(pcOf(midi) - pc);
|
|
210
|
+
if (distance < nearestDistance) {
|
|
211
|
+
nearestDistance = distance;
|
|
212
|
+
nearest = midi;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const nearestPc = pcOf(nearest);
|
|
216
|
+
const wrapsToNextOctave = pc < nearestPc && nearestPc - pc > 6;
|
|
217
|
+
return nearest - nearestPc + pc + (wrapsToNextOctave ? 12 : 0);
|
|
218
|
+
}
|