@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,629 @@
|
|
|
1
|
+
import { honmoon, record, save, queueSave } from '../store/honmoon.svelte';
|
|
2
|
+
import { STRINGS } from '../music/bass';
|
|
3
|
+
import { pcOf } from '../music/notes';
|
|
4
|
+
import { rhBpm, gradeTaps, tapVerdict, rhRecordTempo } from '../music/rhythm';
|
|
5
|
+
import { ac, acIfAny } from '../audio/context';
|
|
6
|
+
import { holdStop, piano, pianoChord, playInterval, playScaleEar } from '../audio/synth';
|
|
7
|
+
import { rhPlay, rhStopPlay } from '../audio/rhythmplay';
|
|
8
|
+
import {
|
|
9
|
+
micListen,
|
|
10
|
+
micOn,
|
|
11
|
+
micReason,
|
|
12
|
+
micStart,
|
|
13
|
+
micStop,
|
|
14
|
+
micUsable,
|
|
15
|
+
type MicReason,
|
|
16
|
+
} from '../audio/mic';
|
|
17
|
+
import { shuffle } from '../util/random';
|
|
18
|
+
import { go, nav, onNavigate } from '../nav.svelte';
|
|
19
|
+
import { buildSession, cardToItem, drillsForCard, type QueueEntry } from '../learn/session';
|
|
20
|
+
import { anyDrillDef } from './defs';
|
|
21
|
+
import { makeQ, playToNaming, type Question, type Q } from './questions';
|
|
22
|
+
|
|
23
|
+
type Cell = { s: number; f: number };
|
|
24
|
+
|
|
25
|
+
/* ---------------- the drill in progress ---------------- */
|
|
26
|
+
export interface DrillState {
|
|
27
|
+
id: string | null;
|
|
28
|
+
q: Question | null;
|
|
29
|
+
/** when the question appeared, for timing the answer */
|
|
30
|
+
t0: number;
|
|
31
|
+
answered: boolean;
|
|
32
|
+
ok: boolean;
|
|
33
|
+
/** answers and correct answers since the drill was opened */
|
|
34
|
+
session: { n: number; c: number };
|
|
35
|
+
/** the family the drill was opened from, for the back button */
|
|
36
|
+
fam: string | null;
|
|
37
|
+
/** a tap still travelling from the last answer must not land on the next question */
|
|
38
|
+
lockUntil: number;
|
|
39
|
+
fbScroll: number;
|
|
40
|
+
wrongCell: Cell | null;
|
|
41
|
+
altCell: Cell | null;
|
|
42
|
+
wrongKey: number | null;
|
|
43
|
+
octaveOff: boolean;
|
|
44
|
+
played: number | null;
|
|
45
|
+
requeued: boolean;
|
|
46
|
+
chosen: number | null;
|
|
47
|
+
chosenIdx: number | null;
|
|
48
|
+
skipped: boolean;
|
|
49
|
+
selfMarked: boolean;
|
|
50
|
+
}
|
|
51
|
+
/** What is reset for every new question. */
|
|
52
|
+
function freshAnswer() {
|
|
53
|
+
return {
|
|
54
|
+
answered: false,
|
|
55
|
+
fbScroll: 0,
|
|
56
|
+
wrongCell: null,
|
|
57
|
+
altCell: null,
|
|
58
|
+
wrongKey: null,
|
|
59
|
+
octaveOff: false,
|
|
60
|
+
played: null,
|
|
61
|
+
requeued: false,
|
|
62
|
+
chosen: null,
|
|
63
|
+
chosenIdx: null,
|
|
64
|
+
skipped: false,
|
|
65
|
+
selfMarked: false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function freshDrill(): DrillState {
|
|
69
|
+
return {
|
|
70
|
+
id: null,
|
|
71
|
+
q: null,
|
|
72
|
+
t0: 0,
|
|
73
|
+
ok: false,
|
|
74
|
+
session: { n: 0, c: 0 },
|
|
75
|
+
fam: null,
|
|
76
|
+
lockUntil: 0,
|
|
77
|
+
...freshAnswer(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** The drill on screen: its question and how it has been answered. */
|
|
81
|
+
export const drill: DrillState = $state(freshDrill());
|
|
82
|
+
|
|
83
|
+
export interface Session {
|
|
84
|
+
queue: QueueEntry[];
|
|
85
|
+
/** index of the current entry in the queue */
|
|
86
|
+
i: number;
|
|
87
|
+
right: number;
|
|
88
|
+
wrong: number;
|
|
89
|
+
/** when it started */
|
|
90
|
+
t0: number;
|
|
91
|
+
/** questions planned (missed cards that come back are extra) */
|
|
92
|
+
planned: number;
|
|
93
|
+
plannedDone: number;
|
|
94
|
+
/** missed cards put back into the queue */
|
|
95
|
+
redos: number;
|
|
96
|
+
results: { card: string; drill: string; ok: boolean }[];
|
|
97
|
+
mode: 'scales' | 'theory';
|
|
98
|
+
/** how long it took, set when it ends */
|
|
99
|
+
secs?: number;
|
|
100
|
+
}
|
|
101
|
+
/** The running session, if any. */
|
|
102
|
+
export const session: { current: Session | null } = $state({ current: null });
|
|
103
|
+
|
|
104
|
+
/** After a new question appears, taps are ignored this long. */
|
|
105
|
+
const ANSWER_LOCK_MS = 320;
|
|
106
|
+
/** After a right answer, the next question comes this long after. */
|
|
107
|
+
const AUTO_ADVANCE_MS = 1200;
|
|
108
|
+
/** Ear questions play by themselves this long after they appear. */
|
|
109
|
+
const AUTOPLAY_DELAY_MS = 260;
|
|
110
|
+
/** A missed card comes back 6–9 questions later. */
|
|
111
|
+
const RETRY_MIN_GAP = 6;
|
|
112
|
+
const RETRY_EXTRA_GAP = 4;
|
|
113
|
+
/** Card-and-drill pairs tried before giving up on finding a question. */
|
|
114
|
+
const MAX_SKIPPED_PAIRS = 25;
|
|
115
|
+
|
|
116
|
+
export function locked(): boolean {
|
|
117
|
+
return !!(drill.lockUntil && Date.now() < drill.lockUntil);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ---------------- starting and moving on ---------------- */
|
|
121
|
+
export function startDrill(id: string, fam?: string | null): void {
|
|
122
|
+
const def = anyDrillDef(id);
|
|
123
|
+
Object.assign(drill, freshDrill(), { id, fam: fam ?? (def ? def.g : null) });
|
|
124
|
+
go('drill', { id });
|
|
125
|
+
nextQ();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The next question of the running session. A card and a drill can turn out
|
|
129
|
+
not to fit each other at the current range — a degree that lands on a note
|
|
130
|
+
the neck does not reach here, say. Drop the pairing and take the next one
|
|
131
|
+
rather than showing a dead end, and shorten the plan so the count stays
|
|
132
|
+
honest. */
|
|
133
|
+
function nextSessionQuestion(current: Session): Question | null {
|
|
134
|
+
for (let tries = 0; current.i < current.queue.length && tries < MAX_SKIPPED_PAIRS; tries++) {
|
|
135
|
+
const entry = current.queue[current.i];
|
|
136
|
+
drill.id = entry.drill;
|
|
137
|
+
const question = makeQ(entry.drill, cardToItem(entry.card));
|
|
138
|
+
if (question) {
|
|
139
|
+
return question;
|
|
140
|
+
}
|
|
141
|
+
current.queue.splice(current.i, 1);
|
|
142
|
+
if (!entry.retry) {
|
|
143
|
+
current.planned = Math.max(current.plannedDone, current.planned - 1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/** Ear questions sound by themselves, a moment after they appear. */
|
|
149
|
+
function autoplayIfEar(question: Question | null): void {
|
|
150
|
+
if (!question || !['ivhear', 'chhear', 'earscale'].includes(question.kind)) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
setTimeout(() => {
|
|
154
|
+
const q = drill.q;
|
|
155
|
+
if (!q || drill.answered) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (q.kind === 'chhear') {
|
|
159
|
+
pianoChord(q.notes, null, 2.2, 0.75);
|
|
160
|
+
} else if (q.kind === 'earscale') {
|
|
161
|
+
playScaleEar(q.sid, q.root);
|
|
162
|
+
} else if (q.kind === 'ivhear') {
|
|
163
|
+
playInterval(q.root, q.semis, false);
|
|
164
|
+
}
|
|
165
|
+
}, AUTOPLAY_DELAY_MS);
|
|
166
|
+
}
|
|
167
|
+
export function nextQ(): void {
|
|
168
|
+
holdStop();
|
|
169
|
+
Object.assign(drill, freshAnswer());
|
|
170
|
+
drill.lockUntil = Date.now() + ANSWER_LOCK_MS;
|
|
171
|
+
const current = session.current;
|
|
172
|
+
if (current) {
|
|
173
|
+
drill.q = null;
|
|
174
|
+
drill.q = nextSessionQuestion(current);
|
|
175
|
+
if (!drill.q) {
|
|
176
|
+
endSession();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
drill.q = makeQ(drill.id!);
|
|
181
|
+
}
|
|
182
|
+
drill.t0 = Date.now();
|
|
183
|
+
if (drill.q && drill.q.kind === 'play') {
|
|
184
|
+
armListen();
|
|
185
|
+
}
|
|
186
|
+
autoplayIfEar(drill.q);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type AnswerExtra = Partial<
|
|
190
|
+
Pick<
|
|
191
|
+
DrillState,
|
|
192
|
+
'wrongCell' | 'altCell' | 'wrongKey' | 'octaveOff' | 'played' | 'skipped' | 'selfMarked'
|
|
193
|
+
>
|
|
194
|
+
>;
|
|
195
|
+
|
|
196
|
+
/** Count an answer in the running session; a missed card is put back to come
|
|
197
|
+
round again. */
|
|
198
|
+
function recordInSession(current: Session, ok: boolean): void {
|
|
199
|
+
if (current.i >= current.queue.length) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const entry = current.queue[current.i];
|
|
203
|
+
current.results.push({ card: entry.card, drill: entry.drill, ok });
|
|
204
|
+
if (!entry.retry) {
|
|
205
|
+
current.plannedDone++;
|
|
206
|
+
}
|
|
207
|
+
if (ok) {
|
|
208
|
+
current.right++;
|
|
209
|
+
} else {
|
|
210
|
+
current.wrong++;
|
|
211
|
+
/* Within-session spacing: a missed card comes back a handful of
|
|
212
|
+
questions later, once, so you get a second attempt while it is still
|
|
213
|
+
fresh. That second pass is what makes a session stick. */
|
|
214
|
+
if (!entry.retry) {
|
|
215
|
+
const gap = RETRY_MIN_GAP + Math.floor(Math.random() * RETRY_EXTRA_GAP);
|
|
216
|
+
const at = Math.min(current.queue.length, current.i + gap);
|
|
217
|
+
current.queue.splice(at, 0, { card: entry.card, drill: entry.drill, retry: 1 });
|
|
218
|
+
current.redos++;
|
|
219
|
+
drill.requeued = true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
current.i++;
|
|
223
|
+
}
|
|
224
|
+
export function answer(ok: boolean, extra?: AnswerExtra): void {
|
|
225
|
+
if (drill.answered || !drill.q || locked()) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
drill.answered = true;
|
|
229
|
+
drill.ok = ok;
|
|
230
|
+
drill.session.n++;
|
|
231
|
+
if (ok) {
|
|
232
|
+
drill.session.c++;
|
|
233
|
+
}
|
|
234
|
+
record(drill.q.id, ok, Date.now() - drill.t0);
|
|
235
|
+
if (extra) {
|
|
236
|
+
Object.assign(drill, extra);
|
|
237
|
+
}
|
|
238
|
+
if (session.current) {
|
|
239
|
+
recordInSession(session.current, ok);
|
|
240
|
+
}
|
|
241
|
+
if (ok) {
|
|
242
|
+
setTimeout(() => {
|
|
243
|
+
if (nav.view.name === 'drill' && drill.answered) {
|
|
244
|
+
nextQ();
|
|
245
|
+
}
|
|
246
|
+
}, AUTO_ADVANCE_MS);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
export function skip(): void {
|
|
250
|
+
if (!drill.answered) {
|
|
251
|
+
answer(false, { skipped: true });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
export function selfMark(ok: boolean): void {
|
|
255
|
+
if (!drill.answered && !locked()) {
|
|
256
|
+
answer(ok, { selfMarked: true });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** A tap on one of the multiple-choice options (choices, bars or staves). */
|
|
260
|
+
export function chooseOption(index: number): void {
|
|
261
|
+
const q = drill.q;
|
|
262
|
+
if (drill.answered || !q || locked()) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
let list: { ok: boolean }[] | null = null;
|
|
266
|
+
if ('choices' in q) {
|
|
267
|
+
list = q.choices;
|
|
268
|
+
} else if ('ropts' in q) {
|
|
269
|
+
list = q.ropts;
|
|
270
|
+
} else if ('sopts' in q) {
|
|
271
|
+
list = q.sopts;
|
|
272
|
+
}
|
|
273
|
+
if (!list) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
drill.chosen = index;
|
|
277
|
+
answer(!!list[index].ok);
|
|
278
|
+
}
|
|
279
|
+
/** A tap on a note-name key. */
|
|
280
|
+
export function choosePc(pc: number): void {
|
|
281
|
+
if (drill.answered || locked() || !drill.q) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
drill.chosen = pc;
|
|
285
|
+
answer(pc === (drill.q as { pc: number }).pc);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/* ---------------- sessions ---------------- */
|
|
289
|
+
function newSession(queue: QueueEntry[], mode: 'scales' | 'theory'): Session {
|
|
290
|
+
return {
|
|
291
|
+
queue,
|
|
292
|
+
i: 0,
|
|
293
|
+
right: 0,
|
|
294
|
+
wrong: 0,
|
|
295
|
+
t0: Date.now(),
|
|
296
|
+
planned: queue.length,
|
|
297
|
+
plannedDone: 0,
|
|
298
|
+
redos: 0,
|
|
299
|
+
results: [],
|
|
300
|
+
mode,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function beginSession(current: Session): void {
|
|
304
|
+
session.current = current;
|
|
305
|
+
Object.assign(drill, freshDrill(), { id: current.queue[0].drill });
|
|
306
|
+
go('drill');
|
|
307
|
+
nextQ();
|
|
308
|
+
}
|
|
309
|
+
export function startSession(mode: 'scales' | 'theory'): void {
|
|
310
|
+
const length = mode === 'scales' ? honmoon.settings.sslen || 20 : honmoon.settings.slen || 30;
|
|
311
|
+
const queue = buildSession(length, mode);
|
|
312
|
+
if (!queue.length) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
beginSession(newSession(queue, mode || 'theory'));
|
|
316
|
+
}
|
|
317
|
+
export function endSession(): void {
|
|
318
|
+
const current = session.current;
|
|
319
|
+
if (!current) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
current.secs = Math.round((Date.now() - current.t0) / 1000);
|
|
323
|
+
save();
|
|
324
|
+
if (micOn()) {
|
|
325
|
+
micStop();
|
|
326
|
+
}
|
|
327
|
+
go('done');
|
|
328
|
+
}
|
|
329
|
+
/** The cards missed in this session and never got right afterwards. */
|
|
330
|
+
export function stillMissed(current: Session): string[] {
|
|
331
|
+
const missed: string[] = [];
|
|
332
|
+
const lastResult: Record<string, boolean> = {};
|
|
333
|
+
for (const result of current.results) {
|
|
334
|
+
if (!result.ok && !missed.includes(result.card)) {
|
|
335
|
+
missed.push(result.card);
|
|
336
|
+
}
|
|
337
|
+
lastResult[result.card] = result.ok;
|
|
338
|
+
}
|
|
339
|
+
return missed.filter((card) => !lastResult[card]);
|
|
340
|
+
}
|
|
341
|
+
/** A short session of just the given cards, each with a drill that can ask it. */
|
|
342
|
+
export function startMissSession(cards: string[]): void {
|
|
343
|
+
const queue = cards
|
|
344
|
+
.map((card) => {
|
|
345
|
+
const drills = drillsForCard(card);
|
|
346
|
+
if (!drills.length) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
return { card, drill: drills[Math.floor(Math.random() * drills.length)].id };
|
|
350
|
+
})
|
|
351
|
+
.filter(Boolean) as QueueEntry[];
|
|
352
|
+
if (!queue.length) {
|
|
353
|
+
go('home');
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const current = newSession(shuffle(queue), 'theory');
|
|
357
|
+
current.planned = queue.length;
|
|
358
|
+
beginSession(current);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* ---------------- taps on the instruments ---------------- */
|
|
362
|
+
/** A tap on a key. In play mode it just sounds; in a drill it is the answer,
|
|
363
|
+
and the note sounds as feedback — by then the answer is committed, so
|
|
364
|
+
nothing can be hunted for by ear beforehand. */
|
|
365
|
+
export function handleKey(midi: number): void {
|
|
366
|
+
if (!isFinite(midi)) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const q = drill.q;
|
|
370
|
+
if (nav.view.name === 'drill' && q && q.kind === 'staffkey' && !drill.answered && !locked()) {
|
|
371
|
+
const ok = pcOf(midi) === q.pc;
|
|
372
|
+
piano(midi, null, 1.1, 0.85);
|
|
373
|
+
answer(ok, { wrongKey: ok ? null : midi });
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
piano(midi, null, 1.4, 0.8);
|
|
377
|
+
}
|
|
378
|
+
/** A tap on the neck, at string `string` (0 = G), fret `fret`. */
|
|
379
|
+
export function handleFret(string: number, fret: number, scrollLeft = 0): void {
|
|
380
|
+
const q = drill.q;
|
|
381
|
+
if (drill.answered || !q || locked()) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const tapped = { s: string, f: fret };
|
|
385
|
+
switch (q.kind) {
|
|
386
|
+
case 'scbuild':
|
|
387
|
+
return buildTap(q, string, fret, scrollLeft);
|
|
388
|
+
case 'scwrong':
|
|
389
|
+
return wrongTap(q, string, fret);
|
|
390
|
+
case 'ivbuild': {
|
|
391
|
+
const ok = string === q.target.s && fret === q.target.f;
|
|
392
|
+
return answer(ok, { wrongCell: ok ? null : tapped });
|
|
393
|
+
}
|
|
394
|
+
case 'deg2fb': {
|
|
395
|
+
/* any octave counts: the question is which degree, not which fret */
|
|
396
|
+
const ok = pcOf(STRINGS[string].open + fret) === q.pc;
|
|
397
|
+
return answer(ok, { wrongCell: ok ? null : tapped });
|
|
398
|
+
}
|
|
399
|
+
case 'name2fb':
|
|
400
|
+
return findNoteTap(q, string, fret, scrollLeft);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/** "Find A on the A string" has two answers on a twelve-fret neck: the open
|
|
404
|
+
string and the twelfth fret. The question does not say which, so both
|
|
405
|
+
count — it only says so afterwards, when it can point at the other one
|
|
406
|
+
without giving anything away. */
|
|
407
|
+
function findNoteTap(q: Q<'name2fb'>, string: number, fret: number, scrollLeft: number): void {
|
|
408
|
+
drill.fbScroll = scrollLeft;
|
|
409
|
+
const exact = string === q.pos.s && fret === q.pos.f;
|
|
410
|
+
const sameNote = string === q.pos.s && pcOf(STRINGS[string].open + fret) === q.pc;
|
|
411
|
+
const ok = exact || sameNote;
|
|
412
|
+
answer(ok, {
|
|
413
|
+
wrongCell: ok ? null : { s: string, f: fret },
|
|
414
|
+
octaveOff: ok && !exact,
|
|
415
|
+
altCell: ok && !exact ? { s: string, f: fret } : null,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
/** After two misses in a scale run, the next note is shown. */
|
|
419
|
+
const MISSES_BEFORE_REVEAL = 2;
|
|
420
|
+
function buildTap(q: Q<'scbuild'>, string: number, fret: number, scrollLeft: number): void {
|
|
421
|
+
const wanted = q.shape.pos[q.idx];
|
|
422
|
+
q.scroll = scrollLeft;
|
|
423
|
+
if (wanted.s === string && wanted.f === fret) {
|
|
424
|
+
q.wrong = null;
|
|
425
|
+
q.reveal = false;
|
|
426
|
+
q.idx++;
|
|
427
|
+
if (q.idx >= q.shape.pos.length) {
|
|
428
|
+
answer(q.misses === 0);
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
q.misses++;
|
|
433
|
+
q.wrong = { s: string, f: fret };
|
|
434
|
+
if (q.misses >= MISSES_BEFORE_REVEAL) {
|
|
435
|
+
q.reveal = true;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function wrongTap(q: Q<'scwrong'>, string: number, fret: number): void {
|
|
439
|
+
const hit = q.shown.findIndex((pos) => pos.s === string && pos.f === fret);
|
|
440
|
+
if (hit < 0) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
drill.chosenIdx = hit;
|
|
444
|
+
answer(hit === q.bad);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/* ---------------- listening for the play-along drills ---------------- */
|
|
448
|
+
export const listen: {
|
|
449
|
+
status: 'idle' | 'on' | 'fail';
|
|
450
|
+
freq: number;
|
|
451
|
+
midi: number;
|
|
452
|
+
rms: number;
|
|
453
|
+
reason: MicReason | null;
|
|
454
|
+
} = $state({ status: 'idle', freq: 0, midi: 0, rms: 0, reason: null });
|
|
455
|
+
|
|
456
|
+
/** A wrong note only counts as an answer when it is this close (in
|
|
457
|
+
semitones) to the target: further off is more likely noise, or the
|
|
458
|
+
instrument still being picked up. */
|
|
459
|
+
const WRONG_NOTE_RANGE_EXACT = 7;
|
|
460
|
+
const WRONG_NOTE_RANGE_ANY_OCTAVE = 9;
|
|
461
|
+
|
|
462
|
+
/** Grade a stable note heard by the mic against a play-along question. */
|
|
463
|
+
function gradeHeardNote(q: Q<'play'>, heard: number): void {
|
|
464
|
+
if (q.exact) {
|
|
465
|
+
if (heard === q.target) {
|
|
466
|
+
answer(true);
|
|
467
|
+
} else if (pcOf(heard) === pcOf(q.target!)) {
|
|
468
|
+
answer(true, { octaveOff: true });
|
|
469
|
+
} else if (Math.abs(heard - q.target!) <= WRONG_NOTE_RANGE_EXACT) {
|
|
470
|
+
answer(false, { played: heard });
|
|
471
|
+
}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (pcOf(heard) === q.pc) {
|
|
475
|
+
answer(true);
|
|
476
|
+
} else if (q.target == null || Math.abs(heard - q.target) <= WRONG_NOTE_RANGE_ANY_OCTAVE) {
|
|
477
|
+
answer(false, { played: heard });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
export function armListen(): void {
|
|
481
|
+
const q = drill.q;
|
|
482
|
+
if (!q || q.kind !== 'play' || !honmoon.settings.mic || !micUsable()) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
Object.assign(listen, { status: 'on', freq: 0, midi: 0, rms: 0, reason: null });
|
|
486
|
+
micStart()
|
|
487
|
+
.then(() => {
|
|
488
|
+
micListen((reading) => {
|
|
489
|
+
if (nav.view.name !== 'drill' || !drill.q || drill.q.kind !== 'play') {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
listen.rms = reading.rms || 0;
|
|
493
|
+
listen.freq = reading.freq || 0;
|
|
494
|
+
listen.midi = reading.midi || 0;
|
|
495
|
+
if (drill.answered || !reading.stable || reading.muted) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
gradeHeardNote(q, Math.round(reading.midi!));
|
|
499
|
+
});
|
|
500
|
+
})
|
|
501
|
+
.catch((error) => {
|
|
502
|
+
listen.status = 'fail';
|
|
503
|
+
listen.reason = micReason(error);
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
/** "Answer by tapping": give up on the mic for this and future questions. */
|
|
507
|
+
export function tapInstead(): void {
|
|
508
|
+
micStop();
|
|
509
|
+
honmoon.settings.mic = false;
|
|
510
|
+
queueSave();
|
|
511
|
+
if (drill.q && drill.q.kind === 'play' && drill.q.sys === 'staff') {
|
|
512
|
+
drill.q = playToNaming(drill.q);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/* ---------------- tapping a rhythm ----------------
|
|
517
|
+
Taps are timestamped off the audio clock, the same clock the clicks were
|
|
518
|
+
scheduled on, so the comparison is exact to the sample. Screen and speaker
|
|
519
|
+
latency is a fixed offset measured once in calibration and subtracted. */
|
|
520
|
+
const RTAP: {
|
|
521
|
+
/** ends the count-in */
|
|
522
|
+
t1: ReturnType<typeof setTimeout> | undefined;
|
|
523
|
+
/** grades the bar after it has ended */
|
|
524
|
+
t2: ReturnType<typeof setTimeout> | undefined;
|
|
525
|
+
} = { t1: undefined, t2: undefined };
|
|
526
|
+
/** The pad's flash, for a fraction of a second after each tap. */
|
|
527
|
+
export const tapPad = $state({ flash: false });
|
|
528
|
+
const PAD_FLASH_MS = 90;
|
|
529
|
+
/** Taps start counting a moment before the bar, and grading waits a moment after. */
|
|
530
|
+
const COUNT_IN_END_EARLY_MS = 60;
|
|
531
|
+
const GRADE_AFTER_MS = 260;
|
|
532
|
+
|
|
533
|
+
function clearRhythmTimers(): void {
|
|
534
|
+
clearTimeout(RTAP.t1);
|
|
535
|
+
clearTimeout(RTAP.t2);
|
|
536
|
+
}
|
|
537
|
+
export function rtapGo(): void {
|
|
538
|
+
const q = drill.q;
|
|
539
|
+
if (!q || q.kind !== 'rtap' || q.phase === 'run') {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const ctx = ac();
|
|
543
|
+
if (!ctx) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
q.taps = [];
|
|
547
|
+
q.grade = null;
|
|
548
|
+
q.phase = 'run';
|
|
549
|
+
q.counting = true;
|
|
550
|
+
q.playedAt = rhBpm();
|
|
551
|
+
const timing = rhPlay(q.bar, q.playedAt, { countIn: 4, beats: true, silent: true });
|
|
552
|
+
if (!timing) {
|
|
553
|
+
q.phase = 'ready';
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
q.gstart = timing.start;
|
|
557
|
+
q.gspu = timing.spu;
|
|
558
|
+
clearRhythmTimers();
|
|
559
|
+
const msUntil = (time: number) => (time - ctx.currentTime) * 1000;
|
|
560
|
+
RTAP.t1 = setTimeout(
|
|
561
|
+
() => {
|
|
562
|
+
if (!drill.q || drill.q !== q || q.phase !== 'run') {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
q.counting = false;
|
|
566
|
+
},
|
|
567
|
+
Math.max(0, msUntil(timing.start) - COUNT_IN_END_EARLY_MS),
|
|
568
|
+
);
|
|
569
|
+
RTAP.t2 = setTimeout(() => rtapFinish(q), Math.max(0, msUntil(timing.end) + GRADE_AFTER_MS));
|
|
570
|
+
}
|
|
571
|
+
/** Called on pointerdown, not on click: the pad has to register the instant
|
|
572
|
+
the finger lands. */
|
|
573
|
+
export function rtapHit(): void {
|
|
574
|
+
const q = drill.q;
|
|
575
|
+
const ctx = acIfAny();
|
|
576
|
+
if (!q || q.kind !== 'rtap' || q.phase !== 'run' || !ctx) {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (q.counting) {
|
|
580
|
+
return; /* the count-in is not part of the bar */
|
|
581
|
+
}
|
|
582
|
+
q.taps.push(ctx.currentTime);
|
|
583
|
+
tapPad.flash = true;
|
|
584
|
+
setTimeout(() => {
|
|
585
|
+
tapPad.flash = false;
|
|
586
|
+
}, PAD_FLASH_MS);
|
|
587
|
+
}
|
|
588
|
+
export function rtapCancel(): void {
|
|
589
|
+
clearRhythmTimers();
|
|
590
|
+
rhStopPlay();
|
|
591
|
+
const q = drill.q;
|
|
592
|
+
if (q && q.kind === 'rtap') {
|
|
593
|
+
q.phase = 'ready';
|
|
594
|
+
q.taps = [];
|
|
595
|
+
q.counting = false;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function rtapFinish(q: Q<'rtap'>): void {
|
|
599
|
+
if (!drill.q || drill.q !== q || q.phase !== 'run' || drill.answered) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
clearRhythmTimers();
|
|
603
|
+
q.phase = 'done';
|
|
604
|
+
q.grade = gradeTaps(q.bar, q.taps, q.gstart!, q.gspu!, honmoon.settings.rlat || 0);
|
|
605
|
+
q.tv = tapVerdict(q.grade);
|
|
606
|
+
q.newBest = rhRecordTempo(q.playedAt!, q.tv);
|
|
607
|
+
q.nextBpm = rhBpm();
|
|
608
|
+
answer(q.grade.ok);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/* ---------------- leaving the drill screen ---------------- */
|
|
612
|
+
/** Screens a session survives a visit to: its summary, and the references
|
|
613
|
+
and calibration a drill links to. */
|
|
614
|
+
const SESSION_KEEPS_ON = ['drill', 'done', 'ref', 'rref', 'rcal'];
|
|
615
|
+
onNavigate((from, to) => {
|
|
616
|
+
if (to !== 'drill') {
|
|
617
|
+
clearRhythmTimers();
|
|
618
|
+
rhStopPlay();
|
|
619
|
+
}
|
|
620
|
+
if (from === 'drill' && to !== 'drill') {
|
|
621
|
+
save();
|
|
622
|
+
if (micOn() && to !== 'setup') {
|
|
623
|
+
micStop();
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (session.current && !SESSION_KEEPS_ON.includes(to)) {
|
|
627
|
+
session.current = null;
|
|
628
|
+
}
|
|
629
|
+
});
|