@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,624 @@
|
|
|
1
|
+
import { honmoon, saveLocal, queueSave } from '../store/honmoon.svelte';
|
|
2
|
+
import type { RhythmLevelId, TempoRec, TempoVerdict } from '../store/types';
|
|
3
|
+
import { NUMWORD, todayKey } from '../util/text';
|
|
4
|
+
import { rnd } from '../util/random';
|
|
5
|
+
|
|
6
|
+
/* ---------------- rhythm ----------------
|
|
7
|
+
Pitch and rhythm are two independent halves of notation, so rhythm gets its
|
|
8
|
+
own single-line staff: no clef, no pitch, nothing to read but time.
|
|
9
|
+
Everything is counted in sixteenths, because every value in use here is a
|
|
10
|
+
whole number of them: a quarter is 4, a dotted quarter 6, a whole note 16. */
|
|
11
|
+
export interface RValue {
|
|
12
|
+
u: number;
|
|
13
|
+
n: string;
|
|
14
|
+
ab: string;
|
|
15
|
+
beats: string;
|
|
16
|
+
}
|
|
17
|
+
export const RVALS: RValue[] = [
|
|
18
|
+
{ u: 16, n: 'Whole note', ab: 'w', beats: '4 beats' },
|
|
19
|
+
{ u: 12, n: 'Dotted half', ab: 'h.', beats: '3 beats' },
|
|
20
|
+
{ u: 8, n: 'Half note', ab: 'h', beats: '2 beats' },
|
|
21
|
+
{ u: 6, n: 'Dotted quarter', ab: 'q.', beats: '1½ beats' },
|
|
22
|
+
{ u: 4, n: 'Quarter note', ab: 'q', beats: '1 beat' },
|
|
23
|
+
{ u: 3, n: 'Dotted eighth', ab: 'e.', beats: '¾ of a beat' },
|
|
24
|
+
{ u: 2, n: 'Eighth note', ab: 'e', beats: 'half a beat' },
|
|
25
|
+
{ u: 1, n: 'Sixteenth note', ab: 's', beats: 'a quarter of a beat' },
|
|
26
|
+
];
|
|
27
|
+
export function rvalDef(sixteenths: number): RValue {
|
|
28
|
+
return RVALS.find((value) => value.u === sixteenths) ?? (null as unknown as RValue);
|
|
29
|
+
}
|
|
30
|
+
export function restName(sixteenths: number): string {
|
|
31
|
+
const value = rvalDef(sixteenths);
|
|
32
|
+
if (!value) {
|
|
33
|
+
return sixteenths + '/16 rest';
|
|
34
|
+
}
|
|
35
|
+
return value.n.replace(' note', '') + ' rest';
|
|
36
|
+
}
|
|
37
|
+
/** Everything here is counted in sixteenths; a quarter-note beat is four. */
|
|
38
|
+
const SIXTEENTHS_PER_BEAT = 4;
|
|
39
|
+
const FRACTIONAL_BEATS: Record<number, string> = {
|
|
40
|
+
0.25: '¼ beat',
|
|
41
|
+
0.5: '½ beat',
|
|
42
|
+
0.75: '¾ beat',
|
|
43
|
+
1.5: '1½ beats',
|
|
44
|
+
2.5: '2½ beats',
|
|
45
|
+
3: '3 beats',
|
|
46
|
+
};
|
|
47
|
+
export function beatsText(sixteenths: number): string {
|
|
48
|
+
const beats = sixteenths / SIXTEENTHS_PER_BEAT;
|
|
49
|
+
if (Number.isInteger(beats)) {
|
|
50
|
+
return beats + (beats === 1 ? ' beat' : ' beats');
|
|
51
|
+
}
|
|
52
|
+
return FRACTIONAL_BEATS[beats] || beats + ' beats';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type TimeSig = [number, number];
|
|
56
|
+
/** The four a bassist actually meets, with the feel spelled out rather than
|
|
57
|
+
just the arithmetic — the arithmetic is the easy half. */
|
|
58
|
+
export const METERS: { ts: TimeSig; n: string; d: string }[] = [
|
|
59
|
+
{
|
|
60
|
+
ts: [4, 4],
|
|
61
|
+
n: '4/4 — common time',
|
|
62
|
+
d: 'Four quarter-note beats to a bar. Somewhere past nine tenths of the rock, pop, funk and blues you will ever play is in 4/4, which is why it is also written as a <b>C</b> in older parts. Counted <i>1 2 3 4</i>, with the weight on 1 and a lesser one on 3.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
ts: [3, 4],
|
|
66
|
+
n: '3/4 — waltz time',
|
|
67
|
+
d: 'Three quarter-note beats to a bar. The count never settles into pairs, so instead of marching it turns: <i>1 2 3, 1 2 3</i>. Waltzes, plenty of folk songs, and a fair number of ballads.',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
ts: [2, 4],
|
|
71
|
+
n: '2/4 — march time',
|
|
72
|
+
d: 'Two quarter-note beats to a bar. Very close to 4/4 but with the barline coming twice as often, so beat 1 comes round twice as fast and the feel is brisker. Marches, polkas, a lot of Latin.',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
ts: [6, 8],
|
|
76
|
+
n: '6/8 — compound time',
|
|
77
|
+
d: 'Six eighth notes to a bar, but felt as two beats of three rather than six of one. Blues shuffles, ballads with a lilt, and anything that swings in triplets.',
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
export const RLEVELS: { id: RhythmLevelId; n: string; d: string }[] = [
|
|
81
|
+
{
|
|
82
|
+
id: 'r1',
|
|
83
|
+
n: 'Quarters and eighths',
|
|
84
|
+
d: '4/4 and 3/4. Whole, half, quarter and eighth notes, and their rests.',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'r2',
|
|
88
|
+
n: 'Add sixteenths and dots',
|
|
89
|
+
d: 'Sixteenths, dotted quarters and dotted eighths — the syncopations that make most grooves.',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'r3',
|
|
93
|
+
n: 'Add ties and 6/8',
|
|
94
|
+
d: 'Notes tied across the beat, plus compound time where the beat divides in three.',
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
export function rlev() {
|
|
98
|
+
const chosen = honmoon.settings.rlev || 'r1';
|
|
99
|
+
return RLEVELS.find((level) => level.id === chosen) ?? RLEVELS[0];
|
|
100
|
+
}
|
|
101
|
+
/** The rhythm level as a number: r1 → 1. */
|
|
102
|
+
export function rlevN(): number {
|
|
103
|
+
return +String(rlev().id).slice(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* Tapping tempo is tracked the way the warm-up families are: the tempo you
|
|
107
|
+
are on now, the fastest you have ever played clean, and a history. Each
|
|
108
|
+
rhythm level keeps its own, because holding 3/4 quarter notes at 100 and
|
|
109
|
+
holding sixteenths at 100 are not the same feat. */
|
|
110
|
+
export function rhKey(): string {
|
|
111
|
+
return 'rh:' + rlev().id;
|
|
112
|
+
}
|
|
113
|
+
export function rhRec(): Partial<TempoRec> {
|
|
114
|
+
return honmoon.tempo[rhKey()] || {};
|
|
115
|
+
}
|
|
116
|
+
/** Below a certain tempo a rhythm stops getting easier and starts getting
|
|
117
|
+
harder: the gaps grow until there is nothing left to feel, and you are
|
|
118
|
+
counting from memory rather than riding a pulse. Where that happens is
|
|
119
|
+
personal, so it is a setting rather than a constant, and the automatic
|
|
120
|
+
easing after a missed bar will not take you past it. */
|
|
121
|
+
const FLOOR_MIN = 30;
|
|
122
|
+
const FLOOR_MAX = 120;
|
|
123
|
+
const FLOOR_DEFAULT = 50;
|
|
124
|
+
export const TEMPO_MAX = 160;
|
|
125
|
+
const TEMPO_DEFAULT = 72;
|
|
126
|
+
/** After a clean bar the tempo goes up this much; after a missed one, down. */
|
|
127
|
+
const STEP_UP_AFTER_CLEAN = 3;
|
|
128
|
+
const STEP_DOWN_AFTER_MISS = 4;
|
|
129
|
+
const TEMPO_HISTORY_LENGTH = 80;
|
|
130
|
+
/** A bar is clean when the average error is under this share of the tolerance. */
|
|
131
|
+
const CLEAN_SHARE_OF_TOLERANCE = 0.45;
|
|
132
|
+
|
|
133
|
+
const clamp = (value: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, value));
|
|
134
|
+
|
|
135
|
+
export function rhFloor(): number {
|
|
136
|
+
return clamp(honmoon.settings.rfloor || FLOOR_DEFAULT, FLOOR_MIN, FLOOR_MAX);
|
|
137
|
+
}
|
|
138
|
+
export function rhBpm(): number {
|
|
139
|
+
return clamp(honmoon.settings.rtempo || TEMPO_DEFAULT, rhFloor(), TEMPO_MAX);
|
|
140
|
+
}
|
|
141
|
+
export function rhSetBpm(bpm: number): void {
|
|
142
|
+
honmoon.settings.rtempo = clamp(Math.round(bpm), rhFloor(), TEMPO_MAX);
|
|
143
|
+
saveLocal();
|
|
144
|
+
queueSave();
|
|
145
|
+
}
|
|
146
|
+
/** clean = every note landed and tightly; sloppy = all there but loose;
|
|
147
|
+
fast = notes missed or extra taps, which at this stage nearly always means
|
|
148
|
+
the tempo is ahead of you. */
|
|
149
|
+
export function tapVerdict(grade: TapGrade): TempoVerdict {
|
|
150
|
+
if (!grade.ok) {
|
|
151
|
+
return 'fast';
|
|
152
|
+
}
|
|
153
|
+
return grade.mean < grade.tol * CLEAN_SHARE_OF_TOLERANCE ? 'clean' : 'sloppy';
|
|
154
|
+
}
|
|
155
|
+
export function rhRecordTempo(bpm: number, verdict: TempoVerdict): boolean {
|
|
156
|
+
const key = rhKey();
|
|
157
|
+
const record: TempoRec = honmoon.tempo[key] || { bpm, best: 0, n: 0, hist: [] };
|
|
158
|
+
let newBest = false;
|
|
159
|
+
record.bpm = bpm;
|
|
160
|
+
if (verdict === 'clean') {
|
|
161
|
+
record.bpm = Math.min(TEMPO_MAX, bpm + STEP_UP_AFTER_CLEAN);
|
|
162
|
+
if (bpm > (record.best || 0)) {
|
|
163
|
+
record.best = bpm;
|
|
164
|
+
newBest = true;
|
|
165
|
+
}
|
|
166
|
+
} else if (verdict === 'fast') {
|
|
167
|
+
record.bpm = Math.max(rhFloor(), bpm - STEP_DOWN_AFTER_MISS);
|
|
168
|
+
}
|
|
169
|
+
record.n = (record.n || 0) + 1;
|
|
170
|
+
record.hist = (record.hist || [])
|
|
171
|
+
.concat([{ d: todayKey(), bpm, v: verdict }])
|
|
172
|
+
.slice(-TEMPO_HISTORY_LENGTH);
|
|
173
|
+
honmoon.tempo[key] = record;
|
|
174
|
+
honmoon.settings.rtempo = record.bpm;
|
|
175
|
+
saveLocal();
|
|
176
|
+
queueSave();
|
|
177
|
+
return newBest;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* A bar: {ts:[beats, beatValue], u:total sixteenths, g:beat group size, ev:[...]}
|
|
181
|
+
An event: {u:length in sixteenths, r:true if a rest, tie:true if tied on} */
|
|
182
|
+
export interface RhEvent {
|
|
183
|
+
u: number;
|
|
184
|
+
r?: boolean;
|
|
185
|
+
tie?: boolean;
|
|
186
|
+
}
|
|
187
|
+
export interface Bar {
|
|
188
|
+
ts: TimeSig;
|
|
189
|
+
u: number;
|
|
190
|
+
g: number;
|
|
191
|
+
cmp: boolean;
|
|
192
|
+
ev: RhEvent[];
|
|
193
|
+
}
|
|
194
|
+
const RTS: Record<string, { ts: TimeSig; u: number; g: number; cmp: boolean }> = {
|
|
195
|
+
'4/4': { ts: [4, 4], u: 16, g: 4, cmp: false },
|
|
196
|
+
'3/4': { ts: [3, 4], u: 12, g: 4, cmp: false },
|
|
197
|
+
'2/4': { ts: [2, 4], u: 8, g: 4, cmp: false },
|
|
198
|
+
'6/8': { ts: [6, 8], u: 12, g: 6, cmp: true },
|
|
199
|
+
};
|
|
200
|
+
/** Which time signatures each rhythm level draws from; repeats make 4/4 (and
|
|
201
|
+
at the top level 6/8) come up more often. */
|
|
202
|
+
const METERS_BY_LEVEL: Record<number, string[]> = {
|
|
203
|
+
1: ['4/4', '3/4'],
|
|
204
|
+
2: ['4/4', '4/4', '3/4', '2/4'],
|
|
205
|
+
3: ['4/4', '4/4', '3/4', '2/4', '6/8', '6/8'],
|
|
206
|
+
};
|
|
207
|
+
function tsFor(level: number) {
|
|
208
|
+
const meters = METERS_BY_LEVEL[Math.min(3, Math.max(1, level))];
|
|
209
|
+
return RTS[meters[rnd(meters.length)]];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* Beat-sized cells, in sixteenths. Negative means a rest. */
|
|
213
|
+
const RCELL: Record<number, number[][]> = {
|
|
214
|
+
1: [[4], [2, 2], [-4], [2, -2], [-2, 2], [4]],
|
|
215
|
+
2: [
|
|
216
|
+
[4],
|
|
217
|
+
[2, 2],
|
|
218
|
+
[-4],
|
|
219
|
+
[2, -2],
|
|
220
|
+
[-2, 2],
|
|
221
|
+
[1, 1, 1, 1],
|
|
222
|
+
[2, 1, 1],
|
|
223
|
+
[1, 1, 2],
|
|
224
|
+
[3, 1],
|
|
225
|
+
[1, -1, 1, 1],
|
|
226
|
+
[2, 2],
|
|
227
|
+
],
|
|
228
|
+
3: [[4], [2, 2], [-4], [2, -2], [1, 1, 1, 1], [2, 1, 1], [1, 1, 2], [3, 1], [2, 2]],
|
|
229
|
+
};
|
|
230
|
+
/* Cells that cover two beats, and four. */
|
|
231
|
+
const RCELL2: Record<number, number[][]> = {
|
|
232
|
+
1: [[8], [-8]],
|
|
233
|
+
2: [[8], [-8], [6, 2], [2, 6], [6, 1, 1], [4, 4]],
|
|
234
|
+
3: [[8], [6, 2], [2, 6], [4, 4], [3, 3, 2]],
|
|
235
|
+
};
|
|
236
|
+
const RCELL4: Record<number, number[][]> = {
|
|
237
|
+
1: [[16], [12, 4], [8, 8], [4, 12]],
|
|
238
|
+
2: [[16], [12, 4], [4, 12], [8, 8]],
|
|
239
|
+
3: [[16], [12, 4], [8, 8]],
|
|
240
|
+
};
|
|
241
|
+
/* Compound (6/8): the beat is a dotted quarter, six sixteenths long. */
|
|
242
|
+
const RCELL6 = [[6], [2, 2, 2], [4, 2], [2, 4], [-6], [-2, 2, 2], [2, 2, -2], [4, -2]];
|
|
243
|
+
|
|
244
|
+
/** Odds, per cell, of a four-beat and of a two-beat cell where one fits. */
|
|
245
|
+
const FOUR_BEAT_CELL_CHANCE = 0.09;
|
|
246
|
+
const TWO_BEAT_CELL_CHANCE = 0.38;
|
|
247
|
+
/** At the top level, the odds that a bar gets a tie. */
|
|
248
|
+
const TIE_CHANCE = 0.45;
|
|
249
|
+
/** The odds that a bar ending on a rest has it turned into a note. */
|
|
250
|
+
const END_ON_NOTE_CHANCE = 0.7;
|
|
251
|
+
const MAX_CELLS = 40;
|
|
252
|
+
|
|
253
|
+
const cellLength = (cell: number[]) => cell.reduce((sum, value) => sum + Math.abs(value), 0);
|
|
254
|
+
|
|
255
|
+
/** The next cell for a simple-time bar: a four- or two-beat cell when one fits
|
|
256
|
+
at this point of the bar, a one-beat cell otherwise — cut to what is left. */
|
|
257
|
+
function simpleCell(level: number, left: number, at: number): number[] {
|
|
258
|
+
const fourFits = left >= 16 && at % 16 === 0;
|
|
259
|
+
const twoFits = left >= 8 && at % 8 === 0;
|
|
260
|
+
const roll = Math.random();
|
|
261
|
+
let pool: number[][];
|
|
262
|
+
if (fourFits && roll < FOUR_BEAT_CELL_CHANCE) {
|
|
263
|
+
pool = RCELL4[level];
|
|
264
|
+
} else if (twoFits && roll < TWO_BEAT_CELL_CHANCE) {
|
|
265
|
+
pool = RCELL2[level];
|
|
266
|
+
} else {
|
|
267
|
+
pool = RCELL[level];
|
|
268
|
+
}
|
|
269
|
+
const cell = pool[rnd(pool.length)];
|
|
270
|
+
return cellLength(cell) > left ? [Math.min(4, left)] : cell;
|
|
271
|
+
}
|
|
272
|
+
/** Trim or pad so the bar is exactly full, whatever the cells added up to. */
|
|
273
|
+
function fitToLength(events: RhEvent[], length: number): void {
|
|
274
|
+
let total = events.reduce((sum, event) => sum + event.u, 0);
|
|
275
|
+
while (total > length) {
|
|
276
|
+
const last = events.pop()!;
|
|
277
|
+
total -= last.u;
|
|
278
|
+
if (total < length) {
|
|
279
|
+
events.push({ u: length - total, r: last.r });
|
|
280
|
+
total = length;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (total < length) {
|
|
284
|
+
events.push({ u: length - total, r: false });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** Ties, at the top level only, and never onto a rest or off the end. */
|
|
288
|
+
function maybeTie(events: RhEvent[], level: number): void {
|
|
289
|
+
if (!(level >= 3 && Math.random() < TIE_CHANCE)) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const tieable: number[] = [];
|
|
293
|
+
for (let i = 0; i < events.length - 1; i++) {
|
|
294
|
+
const [here, next] = [events[i], events[i + 1]];
|
|
295
|
+
if (!here.r && !next.r && here.u <= 4 && next.u <= 4) {
|
|
296
|
+
tieable.push(i);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (tieable.length) {
|
|
300
|
+
events[tieable[rnd(tieable.length)]].tie = true;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** A bar of nothing but rests is silent, which makes Hear the rhythm
|
|
304
|
+
unanswerable and Tap the rhythm meaningless. Turn rests back into notes,
|
|
305
|
+
starting at the front, until there are at least two things to play. */
|
|
306
|
+
function ensureTwoAttacks(bar: Bar): void {
|
|
307
|
+
const events = bar.ev;
|
|
308
|
+
for (let i = 0; i < events.length && barOnsets(bar).length < 2; i++) {
|
|
309
|
+
if (events[i].r) {
|
|
310
|
+
events[i].r = false;
|
|
311
|
+
if (i > 0 && events[i - 1].tie) {
|
|
312
|
+
events[i - 1].tie = false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function rhMakeBar(level?: number): Bar {
|
|
319
|
+
level = level || rlevN();
|
|
320
|
+
const meter = tsFor(level);
|
|
321
|
+
const events: RhEvent[] = [];
|
|
322
|
+
let left = meter.u;
|
|
323
|
+
let at = 0;
|
|
324
|
+
let cells = 0;
|
|
325
|
+
while (left > 0 && cells++ < MAX_CELLS) {
|
|
326
|
+
const cell = meter.cmp ? RCELL6[rnd(RCELL6.length)] : simpleCell(level, left, at);
|
|
327
|
+
cell.forEach((value) => events.push({ u: Math.abs(value), r: value < 0 }));
|
|
328
|
+
left -= cellLength(cell);
|
|
329
|
+
at += cellLength(cell);
|
|
330
|
+
}
|
|
331
|
+
fitToLength(events, meter.u);
|
|
332
|
+
maybeTie(events, level);
|
|
333
|
+
const last = events[events.length - 1];
|
|
334
|
+
if (events.length && last.r && events.length > 1 && Math.random() < END_ON_NOTE_CHANCE) {
|
|
335
|
+
last.r = false;
|
|
336
|
+
}
|
|
337
|
+
const bar: Bar = { ts: meter.ts, u: meter.u, g: meter.g, cmp: meter.cmp, ev: events };
|
|
338
|
+
ensureTwoAttacks(bar);
|
|
339
|
+
return bar;
|
|
340
|
+
}
|
|
341
|
+
export function barTotal(bar: Bar): number {
|
|
342
|
+
return bar.ev.reduce((sum, event) => sum + event.u, 0);
|
|
343
|
+
}
|
|
344
|
+
/** A note that is played: not a rest, and not the far end of a tie. */
|
|
345
|
+
function isAttack(events: RhEvent[], i: number): boolean {
|
|
346
|
+
return !events[i].r && !(i > 0 && events[i - 1].tie);
|
|
347
|
+
}
|
|
348
|
+
/** Where the attacks fall, in sixteenths from the start of the bar. */
|
|
349
|
+
export function barOnsets(bar: Bar): number[] {
|
|
350
|
+
const out: number[] = [];
|
|
351
|
+
let at = 0;
|
|
352
|
+
bar.ev.forEach((event, i) => {
|
|
353
|
+
if (isAttack(bar.ev, i)) {
|
|
354
|
+
out.push(at);
|
|
355
|
+
}
|
|
356
|
+
at += event.u;
|
|
357
|
+
});
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
/** What the bar sounds like: where the attacks fall, and nothing else. Two
|
|
361
|
+
bars can be written very differently — a quarter rest and two eighth
|
|
362
|
+
rests, say — and be the same sound, so anything asking you to pick a bar
|
|
363
|
+
by ear has to tell its options apart by this rather than by the notation. */
|
|
364
|
+
export function barSoundKey(b: Bar): string {
|
|
365
|
+
return b.ts.join('/') + '|' + barOnsets(b).join('.');
|
|
366
|
+
}
|
|
367
|
+
/** A stable fingerprint, so the same rhythm is the same card next time. */
|
|
368
|
+
export function barKey(b: Bar): string {
|
|
369
|
+
return (
|
|
370
|
+
b.ts.join('/') + '|' + b.ev.map((e) => (e.r ? 'r' : '') + e.u + (e.tie ? '~' : '')).join('.')
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* ---- counting syllables ---- */
|
|
375
|
+
/** Within a beat: "1 e & a". */
|
|
376
|
+
const SUBDIVISION_SYLLABLES = ['', 'e', '&', 'a'];
|
|
377
|
+
export function countAt(bar: Bar, sixteenth: number): string {
|
|
378
|
+
if (bar.cmp) {
|
|
379
|
+
// compound time counts eighths: 1 2 3 4 5 6
|
|
380
|
+
const eighth = sixteenth / 2;
|
|
381
|
+
return Number.isInteger(eighth) ? String(eighth + 1) : '·';
|
|
382
|
+
}
|
|
383
|
+
const beat = Math.floor(sixteenth / SIXTEENTHS_PER_BEAT) + 1;
|
|
384
|
+
const withinBeat = sixteenth % SIXTEENTHS_PER_BEAT;
|
|
385
|
+
return withinBeat === 0 ? String(beat) : SUBDIVISION_SYLLABLES[withinBeat];
|
|
386
|
+
}
|
|
387
|
+
/** Greatest common divisor. */
|
|
388
|
+
export function gcd2(a: number, b: number): number {
|
|
389
|
+
while (b) {
|
|
390
|
+
[a, b] = [b, a % b];
|
|
391
|
+
}
|
|
392
|
+
return a;
|
|
393
|
+
}
|
|
394
|
+
/** The coarsest grid, in sixteenths, on which every note of the bar starts. */
|
|
395
|
+
export function barGridStep(bar: Bar): number {
|
|
396
|
+
let step = 0;
|
|
397
|
+
bar.ev.forEach((event) => {
|
|
398
|
+
step = gcd2(step, event.u);
|
|
399
|
+
});
|
|
400
|
+
let at = 0;
|
|
401
|
+
for (const event of bar.ev) {
|
|
402
|
+
step = gcd2(step, at);
|
|
403
|
+
at += event.u;
|
|
404
|
+
}
|
|
405
|
+
return Math.max(1, step || SIXTEENTHS_PER_BEAT);
|
|
406
|
+
}
|
|
407
|
+
/** The counting is written out as a full grid — a syllable where a note is
|
|
408
|
+
attacked, a dot where nothing is. Listing only the attacks is shorter but
|
|
409
|
+
ambiguous: "1 e e" could sit anywhere in the bar. */
|
|
410
|
+
export function barCounting(bar: Bar, step?: number): string {
|
|
411
|
+
step = step || barGridStep(bar);
|
|
412
|
+
const attacks = new Set(barOnsets(bar));
|
|
413
|
+
const out: string[] = [];
|
|
414
|
+
for (let at = 0; at < bar.u; at += step) {
|
|
415
|
+
out.push(attacks.has(at) ? countAt(bar, at) : '·');
|
|
416
|
+
}
|
|
417
|
+
return out.join(' ');
|
|
418
|
+
}
|
|
419
|
+
/** Beams a note gets: eighths (and dotted eighths) one, sixteenths two. */
|
|
420
|
+
const BEAMS: Record<number, number> = { 1: 2, 2: 1, 3: 1 };
|
|
421
|
+
export function beamsFor(sixteenths: number): number {
|
|
422
|
+
return BEAMS[sixteenths] ?? 0;
|
|
423
|
+
}
|
|
424
|
+
/** Notes shorter than a quarter are beamed together inside a beat, the way an
|
|
425
|
+
engraver would, so the beat groups are visible at a glance. */
|
|
426
|
+
export function beamGroups(bar: Bar): { g: number; idx: number[] }[] {
|
|
427
|
+
const groups: { g: number; idx: number[] }[] = [];
|
|
428
|
+
let current: { g: number; idx: number[] } | null = null;
|
|
429
|
+
let at = 0;
|
|
430
|
+
bar.ev.forEach((event, i) => {
|
|
431
|
+
const beatGroup = Math.floor(at / bar.g);
|
|
432
|
+
const endGroup = Math.floor((at + event.u - 0.001) / bar.g);
|
|
433
|
+
const beamable = !event.r && event.u < SIXTEENTHS_PER_BEAT && beatGroup === endGroup;
|
|
434
|
+
if (!beamable) {
|
|
435
|
+
current = null;
|
|
436
|
+
} else if (current && current.g === beatGroup) {
|
|
437
|
+
current.idx.push(i);
|
|
438
|
+
} else {
|
|
439
|
+
current = { g: beatGroup, idx: [i] };
|
|
440
|
+
groups.push(current);
|
|
441
|
+
}
|
|
442
|
+
at += event.u;
|
|
443
|
+
});
|
|
444
|
+
return groups.filter((group) => group.idx.length > 1);
|
|
445
|
+
}
|
|
446
|
+
const VALWORD: Record<number, string> = {
|
|
447
|
+
1: 'whole note',
|
|
448
|
+
2: 'half note',
|
|
449
|
+
4: 'quarter note',
|
|
450
|
+
8: 'eighth note',
|
|
451
|
+
16: 'sixteenth note',
|
|
452
|
+
};
|
|
453
|
+
/** What a time signature is saying, in words. */
|
|
454
|
+
export function tsSentence(ts: TimeSig): string {
|
|
455
|
+
const [count, unit] = ts;
|
|
456
|
+
const isCompound = unit === 8 && count % 3 === 0 && count > 3;
|
|
457
|
+
if (isCompound) {
|
|
458
|
+
return (
|
|
459
|
+
NUMWORD[count] + ' eighth notes in a bar, felt as ' + NUMWORD[count / 3] + ' beats of three'
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
return (
|
|
463
|
+
NUMWORD[count] +
|
|
464
|
+
' beat' +
|
|
465
|
+
(count === 1 ? '' : 's') +
|
|
466
|
+
' in a bar, each one a ' +
|
|
467
|
+
(VALWORD[unit] || '1/' + unit)
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/* ---- cards ----
|
|
472
|
+
Value cards are one per value; bar cards are one per rhythm level and drill,
|
|
473
|
+
because a fresh bar every time is the point. */
|
|
474
|
+
export function rhValues(level: number): number[] {
|
|
475
|
+
return level >= 2 ? [16, 12, 8, 6, 4, 3, 2, 1] : [16, 8, 4, 2];
|
|
476
|
+
}
|
|
477
|
+
export function rhCards(): string[] {
|
|
478
|
+
const level = rlevN();
|
|
479
|
+
const valueCards = rhValues(level).flatMap((value) => ['rh:v:' + value, 'rh:r:' + value]);
|
|
480
|
+
const barCards = ['count', 'fill', 'hear', 'tap'].map((kind) => 'rh:' + kind + ':' + level);
|
|
481
|
+
return [...valueCards, ...barCards];
|
|
482
|
+
}
|
|
483
|
+
export type RhythmKind = 'value' | 'count' | 'fill' | 'hear' | 'tap';
|
|
484
|
+
export function rhCardFits(id: string, kind: RhythmKind): boolean {
|
|
485
|
+
const cardKind = id.split(':')[1];
|
|
486
|
+
if (cardKind === 'v' || cardKind === 'r') {
|
|
487
|
+
return kind === 'value';
|
|
488
|
+
}
|
|
489
|
+
return kind === cardKind;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/* ---- grading a tapped rhythm ----
|
|
493
|
+
Each written onset looks for the nearest tap. A tap that no onset claims is
|
|
494
|
+
an extra; an onset with no tap is a miss. Tolerance scales with tempo, so a
|
|
495
|
+
slow bar is not graded more strictly than a fast one. */
|
|
496
|
+
export interface TapGrade {
|
|
497
|
+
want: number[];
|
|
498
|
+
hits: { i: number; err: number | null }[];
|
|
499
|
+
stray: number[];
|
|
500
|
+
extra: number;
|
|
501
|
+
n: number;
|
|
502
|
+
got: number;
|
|
503
|
+
mean: number;
|
|
504
|
+
tol: number;
|
|
505
|
+
taps: number;
|
|
506
|
+
ok: boolean;
|
|
507
|
+
}
|
|
508
|
+
/** Never stricter than this, in seconds, however slow the tempo… */
|
|
509
|
+
const MAX_TOLERANCE_S = 0.2;
|
|
510
|
+
/** …and otherwise this share of a beat either side. */
|
|
511
|
+
const TOLERANCE_SHARE_OF_BEAT = 0.3;
|
|
512
|
+
|
|
513
|
+
export function gradeTaps(
|
|
514
|
+
bar: Bar,
|
|
515
|
+
taps: number[],
|
|
516
|
+
start: number,
|
|
517
|
+
secondsPerSixteenth: number,
|
|
518
|
+
latency: number,
|
|
519
|
+
): TapGrade {
|
|
520
|
+
const want = barOnsets(bar).map((sixteenth) => start + sixteenth * secondsPerSixteenth);
|
|
521
|
+
const tol = Math.min(
|
|
522
|
+
MAX_TOLERANCE_S,
|
|
523
|
+
secondsPerSixteenth * SIXTEENTHS_PER_BEAT * TOLERANCE_SHARE_OF_BEAT,
|
|
524
|
+
);
|
|
525
|
+
const heard = taps.map((tap) => tap - latency);
|
|
526
|
+
const claimed = new Set<number>();
|
|
527
|
+
const hits = want.map((onset, i) => {
|
|
528
|
+
let nearest = -1;
|
|
529
|
+
let nearestDistance = 1e9;
|
|
530
|
+
heard.forEach((tap, j) => {
|
|
531
|
+
const distance = Math.abs(tap - onset);
|
|
532
|
+
if (!claimed.has(j) && distance < nearestDistance) {
|
|
533
|
+
nearestDistance = distance;
|
|
534
|
+
nearest = j;
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
if (nearest >= 0 && nearestDistance <= tol) {
|
|
538
|
+
claimed.add(nearest);
|
|
539
|
+
return { i, err: (heard[nearest] - onset) as number | null };
|
|
540
|
+
}
|
|
541
|
+
return { i, err: null as number | null };
|
|
542
|
+
});
|
|
543
|
+
/* Every tap that did not land close enough to a written onset is kept with
|
|
544
|
+
its time, not just counted. A tap you cannot see on the picture afterwards
|
|
545
|
+
is indistinguishable from a tap the app never registered, and you would
|
|
546
|
+
have no way of telling which happened. */
|
|
547
|
+
const stray = heard.filter((_, j) => !claimed.has(j));
|
|
548
|
+
const landed = hits.filter((hit) => hit.err != null);
|
|
549
|
+
const mean = landed.length
|
|
550
|
+
? landed.reduce((sum, hit) => sum + Math.abs(hit.err as number), 0) / landed.length
|
|
551
|
+
: 0;
|
|
552
|
+
return {
|
|
553
|
+
want,
|
|
554
|
+
hits,
|
|
555
|
+
stray,
|
|
556
|
+
extra: stray.length,
|
|
557
|
+
n: want.length,
|
|
558
|
+
got: landed.length,
|
|
559
|
+
mean,
|
|
560
|
+
tol,
|
|
561
|
+
taps: taps.length,
|
|
562
|
+
ok: landed.length === want.length && stray.length === 0,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/* ---- wrong answers for the bar drills ---- */
|
|
567
|
+
const VARIANT_TRIES = 20;
|
|
568
|
+
/** One small change to a bar: swap two neighbouring values (same total,
|
|
569
|
+
different feel), or split one value in two. */
|
|
570
|
+
function rhVariant(bar: Bar): Bar | null {
|
|
571
|
+
const copy: Bar = {
|
|
572
|
+
ts: bar.ts,
|
|
573
|
+
u: bar.u,
|
|
574
|
+
g: bar.g,
|
|
575
|
+
cmp: bar.cmp,
|
|
576
|
+
ev: bar.ev.map((event) => ({ u: event.u, r: event.r, tie: event.tie })),
|
|
577
|
+
};
|
|
578
|
+
const events = copy.ev;
|
|
579
|
+
for (let tries = 0; tries < VARIANT_TRIES; tries++) {
|
|
580
|
+
const i = rnd(events.length);
|
|
581
|
+
if (events.length > 1 && Math.random() < 0.5) {
|
|
582
|
+
const j = Math.min(events.length - 1, i + 1);
|
|
583
|
+
if (events[i].u !== events[j].u || events[i].r !== events[j].r) {
|
|
584
|
+
[events[i], events[j]] = [events[j], events[i]];
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
} else if (events[i].u >= 2) {
|
|
588
|
+
const half = events[i].u / 2;
|
|
589
|
+
if (Number.isInteger(half)) {
|
|
590
|
+
events.splice(i, 1, { u: half, r: events[i].r }, { u: half, r: events[i].r });
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return barKey(copy) === barKey(bar) ? null : copy;
|
|
596
|
+
}
|
|
597
|
+
const VARIANT_ROUNDS = 120;
|
|
598
|
+
/** The odds of building a wrong answer by changing the real bar rather than
|
|
599
|
+
making a fresh one. */
|
|
600
|
+
const CHANGE_REAL_BAR_CHANCE = 0.6;
|
|
601
|
+
/** `count` wrong answers for a bar, all in its time signature and all
|
|
602
|
+
different from it and from each other — by sound when `bySound`. */
|
|
603
|
+
export function rhVariants(bar: Bar, count: number, bySound?: boolean): Bar[] {
|
|
604
|
+
const keyOf = bySound ? barSoundKey : barKey;
|
|
605
|
+
const sameMeter = (other: Bar) => other.ts[0] === bar.ts[0] && other.ts[1] === bar.ts[1];
|
|
606
|
+
const out: Bar[] = [];
|
|
607
|
+
const seen = new Set<string>([keyOf(bar)]);
|
|
608
|
+
const offer = (candidate: Bar | null) => {
|
|
609
|
+
if (!candidate || !sameMeter(candidate) || seen.has(keyOf(candidate))) {
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
seen.add(keyOf(candidate));
|
|
613
|
+
out.push(candidate);
|
|
614
|
+
};
|
|
615
|
+
for (let round = 0; out.length < count && round < VARIANT_ROUNDS; round++) {
|
|
616
|
+
offer(Math.random() < CHANGE_REAL_BAR_CHANCE ? rhVariant(bar) : rhMakeBar(rlevN()));
|
|
617
|
+
}
|
|
618
|
+
/* Padding has to respect the same rule, or the last option slips through
|
|
619
|
+
sounding identical to one already on the list. */
|
|
620
|
+
for (let round = 0; out.length < count && round < VARIANT_ROUNDS; round++) {
|
|
621
|
+
offer(rhMakeBar(rlevN()));
|
|
622
|
+
}
|
|
623
|
+
return out;
|
|
624
|
+
}
|