@greenblacksky/honmoon 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -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,80 @@
|
|
|
1
|
+
import { STRINGS } from '../music/bass';
|
|
2
|
+
import { esc } from '../util/text';
|
|
3
|
+
|
|
4
|
+
/** One note of a tabbed exercise: string, fret, left-hand finger, right-hand
|
|
5
|
+
finger, and an optional label marking the start of a section. */
|
|
6
|
+
export interface TabStep {
|
|
7
|
+
s: number;
|
|
8
|
+
f: number;
|
|
9
|
+
fin?: number;
|
|
10
|
+
rh?: string;
|
|
11
|
+
bar?: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const FIRST_X = 34;
|
|
15
|
+
const STEP_WIDTH = 26;
|
|
16
|
+
/** y of each string's line, G (top) to E. */
|
|
17
|
+
const STRING_ROWS = [14, 34, 54, 74];
|
|
18
|
+
/** Rows for the left-hand (finger) and right-hand (i/m) lines under the tab. */
|
|
19
|
+
const LH_ROW_Y = 98;
|
|
20
|
+
const RH_ROW_Y = 114;
|
|
21
|
+
const MONO = 'IBM Plex Mono,monospace';
|
|
22
|
+
const DISPLAY = 'Saira Condensed,sans-serif';
|
|
23
|
+
|
|
24
|
+
function tabStrings(width: number): string {
|
|
25
|
+
let out = '';
|
|
26
|
+
STRING_ROWS.forEach((y, string) => {
|
|
27
|
+
out += `<line x1="${FIRST_X - 8}" y1="${y}" x2="${width - 8}" y2="${y}" stroke="var(--line-2)" stroke-width="1.2"/>`;
|
|
28
|
+
out += `<text x="${FIRST_X - 14}" y="${y + 4.5}" text-anchor="end" font-size="12" font-family="${MONO}" font-weight="600" fill="var(--ink-3)">${STRINGS[string].n}</text>`;
|
|
29
|
+
});
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** A section line and its name, at the left edge of a step. */
|
|
33
|
+
function sectionMark(left: number, name: string): string {
|
|
34
|
+
return (
|
|
35
|
+
`<line x1="${left}" y1="8" x2="${left}" y2="80" stroke="var(--ink-3)" stroke-width="1.4"/>` +
|
|
36
|
+
`<text x="${left + 4}" y="4" font-size="10.5" font-family="${DISPLAY}" letter-spacing="1" fill="var(--accent)">${esc(name).toUpperCase()}</text>`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
/** A fret number on its string, on a patch of background so the line breaks. */
|
|
40
|
+
function fretNumber(x: number, y: number, fret: number): string {
|
|
41
|
+
return (
|
|
42
|
+
`<rect x="${x - 9}" y="${y - 7}" width="18" height="14" fill="var(--surface)"/>` +
|
|
43
|
+
`<text x="${x}" y="${y + 4.5}" text-anchor="middle" font-size="13" font-weight="600" font-family="${MONO}" fill="var(--ink)">${fret}</text>`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
function smallText(x: number, y: number, fill: string, text: string | number): string {
|
|
47
|
+
return `<text x="${x}" y="${y}" text-anchor="middle" font-size="11.5" font-family="${MONO}" fill="${fill}">${text}</text>`;
|
|
48
|
+
}
|
|
49
|
+
function rowName(y: number, name: string): string {
|
|
50
|
+
return `<text x="${FIRST_X - 14}" y="${y}" text-anchor="end" font-size="10" font-family="${DISPLAY}" fill="var(--ink-3)">${name}</text>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function tabSVG(steps: TabStep[]): string {
|
|
54
|
+
const width = FIRST_X + steps.length * STEP_WIDTH + 12;
|
|
55
|
+
const hasRightHand = steps.some((step) => !!step.rh);
|
|
56
|
+
const height = hasRightHand ? 132 : 116;
|
|
57
|
+
const parts = [tabStrings(width)];
|
|
58
|
+
steps.forEach((step, k) => {
|
|
59
|
+
const x = FIRST_X + k * STEP_WIDTH + STEP_WIDTH / 2;
|
|
60
|
+
if (step.bar) {
|
|
61
|
+
parts.push(sectionMark(x - STEP_WIDTH / 2, step.bar));
|
|
62
|
+
}
|
|
63
|
+
parts.push(fretNumber(x, STRING_ROWS[step.s], step.f));
|
|
64
|
+
if (step.fin) {
|
|
65
|
+
parts.push(smallText(x, LH_ROW_Y, 'var(--accent)', step.fin));
|
|
66
|
+
}
|
|
67
|
+
if (step.rh) {
|
|
68
|
+
parts.push(smallText(x, hasRightHand ? RH_ROW_Y : LH_ROW_Y, 'var(--ink-3)', step.rh));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
parts.push(rowName(LH_ROW_Y, 'LH'));
|
|
72
|
+
if (hasRightHand) {
|
|
73
|
+
parts.push(rowName(RH_ROW_Y, 'RH'));
|
|
74
|
+
}
|
|
75
|
+
return (
|
|
76
|
+
`<svg viewBox="0 -8 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="tab">` +
|
|
77
|
+
parts.join('') +
|
|
78
|
+
'</svg>'
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import type { Card, HonmoonState, Payload, Settings } from './types';
|
|
2
|
+
import { todayKey } from '../util/text';
|
|
3
|
+
import type { SyncAdapter } from './sync';
|
|
4
|
+
|
|
5
|
+
export const DEF: Settings = {
|
|
6
|
+
pref: 'sharp',
|
|
7
|
+
level: 'l1',
|
|
8
|
+
acc: false,
|
|
9
|
+
bass: true,
|
|
10
|
+
mic: true,
|
|
11
|
+
sound: true,
|
|
12
|
+
labels: true,
|
|
13
|
+
slen: 30,
|
|
14
|
+
off: [],
|
|
15
|
+
woff: [],
|
|
16
|
+
wlen: 3,
|
|
17
|
+
sen: null,
|
|
18
|
+
sroots: 'nat',
|
|
19
|
+
sstart: 'both',
|
|
20
|
+
sslen: 20,
|
|
21
|
+
sdoff: [],
|
|
22
|
+
wmode: 'bass',
|
|
23
|
+
vlo: null,
|
|
24
|
+
vhi: null,
|
|
25
|
+
voff: null,
|
|
26
|
+
vtempo: 76,
|
|
27
|
+
vgap: true,
|
|
28
|
+
ivset: 'basic',
|
|
29
|
+
clefs: ['bass'],
|
|
30
|
+
mdo: false,
|
|
31
|
+
rlev: 'r1',
|
|
32
|
+
rtempo: 72,
|
|
33
|
+
rfloor: 50,
|
|
34
|
+
rlat: 0,
|
|
35
|
+
theme: 'auto',
|
|
36
|
+
hints: true,
|
|
37
|
+
doff: [],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Everything the app knows about you. Reactive: any screen reading from it
|
|
41
|
+
redraws by itself when it changes. */
|
|
42
|
+
export const honmoon: HonmoonState = $state({
|
|
43
|
+
cards: {},
|
|
44
|
+
daily: {},
|
|
45
|
+
tempo: {},
|
|
46
|
+
settings: { ...DEF, off: [], woff: [], sdoff: [], doff: [], clefs: ['bass'] },
|
|
47
|
+
answers: 0,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/* ---------------- configuration ----------------
|
|
51
|
+
Each app keeps its own copy of everything under its own key, and tells the
|
|
52
|
+
store how to tidy a payload it loads (an old save may name drills or
|
|
53
|
+
families that no longer exist). */
|
|
54
|
+
interface StoreConfig {
|
|
55
|
+
key: string;
|
|
56
|
+
normalize: ((s: Settings) => void)[];
|
|
57
|
+
sync: SyncAdapter | null;
|
|
58
|
+
}
|
|
59
|
+
const cfg: StoreConfig = { key: 'honmoon.v1', normalize: [], sync: null };
|
|
60
|
+
|
|
61
|
+
export function configureStore(options: { key: string; sync?: SyncAdapter | null }): void {
|
|
62
|
+
cfg.key = options.key;
|
|
63
|
+
cfg.sync = options.sync ?? null;
|
|
64
|
+
}
|
|
65
|
+
/** Register a tidy-up step applied to settings whenever a payload is adopted. */
|
|
66
|
+
export function addNormalizer(normalize: (settings: Settings) => void): void {
|
|
67
|
+
cfg.normalize.push(normalize);
|
|
68
|
+
}
|
|
69
|
+
function runNormalizers(settings: Settings): void {
|
|
70
|
+
for (const normalize of cfg.normalize) {
|
|
71
|
+
normalize(settings);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ---------------- cards ----------------
|
|
76
|
+
A card's mastery climbs one step for each right answer and falls two for
|
|
77
|
+
each wrong one, between 0 (new or shaky) and 5 (solid). */
|
|
78
|
+
const MAX_MASTERY = 5;
|
|
79
|
+
const MASTERY_UP = 1;
|
|
80
|
+
const MASTERY_DOWN = 2;
|
|
81
|
+
/** Time spent on one answer counts for at most a minute (a phone left on). */
|
|
82
|
+
const MAX_ANSWER_MS = 60000;
|
|
83
|
+
|
|
84
|
+
export function card(id: string): Card {
|
|
85
|
+
if (!honmoon.cards[id]) {
|
|
86
|
+
honmoon.cards[id] = { s: 0, c: 0, t: 0, k: 0 };
|
|
87
|
+
}
|
|
88
|
+
return honmoon.cards[id];
|
|
89
|
+
}
|
|
90
|
+
export function mastery(id: string): number {
|
|
91
|
+
const existing = honmoon.cards[id];
|
|
92
|
+
if (!existing || !existing.s) {
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
return Math.max(0, Math.min(MAX_MASTERY, existing.k));
|
|
96
|
+
}
|
|
97
|
+
/** Count one answer to a card: its counts and mastery, and today's log. */
|
|
98
|
+
export function record(id: string, ok: boolean, ms: number): void {
|
|
99
|
+
const answered = card(id);
|
|
100
|
+
answered.s++;
|
|
101
|
+
if (ok) {
|
|
102
|
+
answered.c++;
|
|
103
|
+
answered.k = Math.min(MAX_MASTERY, (answered.k || 0) + MASTERY_UP);
|
|
104
|
+
} else {
|
|
105
|
+
answered.k = Math.max(0, (answered.k || 0) - MASTERY_DOWN);
|
|
106
|
+
}
|
|
107
|
+
answered.t = Date.now();
|
|
108
|
+
const dayKey = todayKey();
|
|
109
|
+
const today = honmoon.daily[dayKey] || { n: 0, c: 0, ms: 0 };
|
|
110
|
+
today.n++;
|
|
111
|
+
if (ok) {
|
|
112
|
+
today.c++;
|
|
113
|
+
}
|
|
114
|
+
today.ms += Math.min(ms || 0, MAX_ANSWER_MS);
|
|
115
|
+
honmoon.daily[dayKey] = today;
|
|
116
|
+
honmoon.answers++;
|
|
117
|
+
queueSave();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ---------------- saving ---------------- */
|
|
121
|
+
let dirty = false;
|
|
122
|
+
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
123
|
+
let synced = false;
|
|
124
|
+
/** Progress is written a few seconds after the last change, not on every answer. */
|
|
125
|
+
const SAVE_DELAY_MS = 3500;
|
|
126
|
+
|
|
127
|
+
export function queueSave(): void {
|
|
128
|
+
dirty = true;
|
|
129
|
+
if (saveTimer) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
saveTimer = setTimeout(() => {
|
|
133
|
+
saveTimer = null;
|
|
134
|
+
save();
|
|
135
|
+
}, SAVE_DELAY_MS);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function payload(): Payload {
|
|
139
|
+
const snapshot = $state.snapshot(honmoon);
|
|
140
|
+
return {
|
|
141
|
+
v: 1,
|
|
142
|
+
t: Date.now(),
|
|
143
|
+
cards: snapshot.cards,
|
|
144
|
+
daily: snapshot.daily,
|
|
145
|
+
tempo: snapshot.tempo,
|
|
146
|
+
settings: snapshot.settings,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function writeLocal(): void {
|
|
151
|
+
try {
|
|
152
|
+
localStorage.setItem(cfg.key, JSON.stringify(payload()));
|
|
153
|
+
} catch {
|
|
154
|
+
/* private mode, quota: progress stays in memory */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Settings are written through at once: they are tiny, and a preference that
|
|
159
|
+
vanishes because the page was closed within the save delay is maddening.
|
|
160
|
+
Progress still goes through the debounce. */
|
|
161
|
+
export function saveLocal(): void {
|
|
162
|
+
writeLocal();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function save(): void {
|
|
166
|
+
if (!dirty) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
dirty = false;
|
|
170
|
+
writeLocal();
|
|
171
|
+
if (cfg.sync && synced) {
|
|
172
|
+
cfg.sync.save(payload()).catch(() => {});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Change one setting, write it through, and schedule a sync. */
|
|
177
|
+
export function setSetting<K extends keyof Settings>(key: K, value: Settings[K]): void {
|
|
178
|
+
honmoon.settings[key] = value;
|
|
179
|
+
saveLocal();
|
|
180
|
+
queueSave();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A label for the Stats screen. */
|
|
184
|
+
export function syncLabel(): string {
|
|
185
|
+
return cfg.sync && synced ? 'Synced across your devices' : 'Saved in this browser only';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* ---------------- loading ---------------- */
|
|
189
|
+
/** Total answers recorded in a set of cards. */
|
|
190
|
+
function answersIn(cards: Record<string, Card> | undefined): number {
|
|
191
|
+
let total = 0;
|
|
192
|
+
for (const id in cards || {}) {
|
|
193
|
+
total += cards![id].s || 0;
|
|
194
|
+
}
|
|
195
|
+
return total;
|
|
196
|
+
}
|
|
197
|
+
function progressOf(saved: Partial<Payload> | null): number {
|
|
198
|
+
if (!saved || typeof saved !== 'object') {
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
return answersIn(saved.cards);
|
|
202
|
+
}
|
|
203
|
+
/** Snapshots from a store may be frozen, so nothing read from one goes into
|
|
204
|
+
honmoon directly — the first c.s++ would throw. Copy before adopting. */
|
|
205
|
+
function thaw<T>(value: T): T | null {
|
|
206
|
+
try {
|
|
207
|
+
return value ? JSON.parse(JSON.stringify(value)) : null;
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Bring settings from an older save up to date. */
|
|
213
|
+
function upgradeSettings(settings: Settings): void {
|
|
214
|
+
/* An old save may name a level that no longer exists. "All 12 notes" was the
|
|
215
|
+
full neck plus the black notes, so it becomes exactly that. */
|
|
216
|
+
if ((settings.level as string) === 'l3') {
|
|
217
|
+
settings.level = 'l2';
|
|
218
|
+
settings.acc = true;
|
|
219
|
+
}
|
|
220
|
+
if (!['l0', 'l1', 'l2'].includes(settings.level)) {
|
|
221
|
+
settings.level = 'l1';
|
|
222
|
+
}
|
|
223
|
+
/* an older save has no clef list; bass is where everyone starts */
|
|
224
|
+
if (!Array.isArray(settings.clefs) || !settings.clefs.length) {
|
|
225
|
+
settings.clefs = ['bass'];
|
|
226
|
+
}
|
|
227
|
+
if (!Array.isArray(settings.doff)) {
|
|
228
|
+
settings.doff = [];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Adopt a saved payload as the current state. */
|
|
233
|
+
export function apply(saved: Partial<Payload> | null, keepSettings?: boolean): void {
|
|
234
|
+
if (!saved) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
honmoon.cards = thaw(saved.cards) || {};
|
|
238
|
+
honmoon.daily = thaw(saved.daily) || {};
|
|
239
|
+
honmoon.tempo = thaw(saved.tempo) || {};
|
|
240
|
+
if (!keepSettings) {
|
|
241
|
+
honmoon.settings = Object.assign({}, structuredClone(DEF), thaw(saved.settings) || {});
|
|
242
|
+
}
|
|
243
|
+
upgradeSettings(honmoon.settings);
|
|
244
|
+
runNormalizers(honmoon.settings);
|
|
245
|
+
honmoon.answers = answersIn(honmoon.cards);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Read what this browser has, then — if a sync service is configured — take
|
|
249
|
+
progress from wherever there is more of it, and settings from whichever
|
|
250
|
+
copy was written last. Otherwise a sync from a device you have not touched
|
|
251
|
+
in a week quietly undoes the preferences you changed a minute ago. */
|
|
252
|
+
export function load(): void {
|
|
253
|
+
let local: Partial<Payload> | null = null;
|
|
254
|
+
try {
|
|
255
|
+
const raw = localStorage.getItem(cfg.key);
|
|
256
|
+
if (raw) {
|
|
257
|
+
local = JSON.parse(raw);
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
/* unreadable: start fresh */
|
|
261
|
+
}
|
|
262
|
+
if (local) {
|
|
263
|
+
apply(local);
|
|
264
|
+
} else {
|
|
265
|
+
runNormalizers(honmoon.settings);
|
|
266
|
+
}
|
|
267
|
+
const sync = cfg.sync;
|
|
268
|
+
if (!sync) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
sync
|
|
272
|
+
.load()
|
|
273
|
+
.then((remote) => {
|
|
274
|
+
synced = true;
|
|
275
|
+
if (remote && remote.cards && progressOf(remote) >= progressOf(local)) {
|
|
276
|
+
const localWrittenAt = (local && local.t) || 0;
|
|
277
|
+
const remoteWrittenAt = remote.t || 0;
|
|
278
|
+
const keepLocalSettings = remoteWrittenAt <= localWrittenAt && !!(local && local.settings);
|
|
279
|
+
apply(remote, keepLocalSettings);
|
|
280
|
+
} else {
|
|
281
|
+
dirty = true;
|
|
282
|
+
save();
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
.catch(() => {});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function resetProgress(): void {
|
|
289
|
+
honmoon.cards = {};
|
|
290
|
+
honmoon.daily = {};
|
|
291
|
+
honmoon.answers = 0;
|
|
292
|
+
dirty = true;
|
|
293
|
+
save();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Progress as a JSON file's text, for the export button. */
|
|
297
|
+
export function exportText(): string {
|
|
298
|
+
return JSON.stringify(payload(), null, 1);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Replace progress with an exported payload — from any of the apps or from the original
|
|
302
|
+
Fretboard Fluency (same format). Settings are kept unless asked for. */
|
|
303
|
+
export function importText(
|
|
304
|
+
text: string,
|
|
305
|
+
withSettings = false,
|
|
306
|
+
): { ok: boolean; answers: number; msg: string } {
|
|
307
|
+
let imported: Partial<Payload>;
|
|
308
|
+
try {
|
|
309
|
+
imported = JSON.parse(text);
|
|
310
|
+
} catch {
|
|
311
|
+
return { ok: false, answers: 0, msg: 'That file is not readable JSON.' };
|
|
312
|
+
}
|
|
313
|
+
if (!imported || typeof imported !== 'object' || !imported.cards) {
|
|
314
|
+
return { ok: false, answers: 0, msg: 'That file has no progress in it.' };
|
|
315
|
+
}
|
|
316
|
+
apply(imported, !withSettings);
|
|
317
|
+
dirty = true;
|
|
318
|
+
save();
|
|
319
|
+
return { ok: true, answers: honmoon.answers, msg: 'Imported ' + honmoon.answers + ' answers.' };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (typeof document !== 'undefined') {
|
|
323
|
+
document.addEventListener('visibilitychange', () => {
|
|
324
|
+
if (document.hidden) {
|
|
325
|
+
save();
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
window.addEventListener('pagehide', save);
|
|
329
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { addNormalizer } from './honmoon.svelte';
|
|
2
|
+
import { SCALES } from '../music/scales';
|
|
3
|
+
import { VSTAGES } from '../music/voice';
|
|
4
|
+
import { appHasDrill, appHasGroup } from '../drills/registry.svelte';
|
|
5
|
+
|
|
6
|
+
/** The tidy-ups the original applied to every save it loaded. Call once at
|
|
7
|
+
start-up, after configureGroups(). */
|
|
8
|
+
export function installDefaultNormalizers(): void {
|
|
9
|
+
addNormalizer((settings) => {
|
|
10
|
+
if (!settings.sen) {
|
|
11
|
+
settings.sen = SCALES.filter((scale) => scale.on).map((scale) => scale.id);
|
|
12
|
+
}
|
|
13
|
+
if (!settings.voff) {
|
|
14
|
+
settings.voff = VSTAGES.filter((stage) => !stage.on).map((stage) => stage.k);
|
|
15
|
+
}
|
|
16
|
+
/* The families were regrouped; an old save may hold keys that no longer
|
|
17
|
+
name anything. Guessing what the owner meant would switch off the wrong
|
|
18
|
+
half, so anything unrecognised is simply dropped and that family comes
|
|
19
|
+
back on. */
|
|
20
|
+
settings.off = Array.isArray(settings.off) ? settings.off.filter(appHasGroup) : [];
|
|
21
|
+
settings.doff = Array.isArray(settings.doff) ? settings.doff.filter(appHasDrill) : [];
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Payload } from './types';
|
|
2
|
+
|
|
3
|
+
/** Anything that can keep a copy of the progress payload somewhere other than
|
|
4
|
+
this browser. The store calls load() once at start-up and save() after
|
|
5
|
+
every debounced write. */
|
|
6
|
+
export interface SyncAdapter {
|
|
7
|
+
/** Short name for the Stats screen, e.g. "Firebase". */
|
|
8
|
+
readonly name: string;
|
|
9
|
+
load(): Promise<Payload | null>;
|
|
10
|
+
save(payload: Payload): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* ---------------- Firebase (not wired up yet) ----------------
|
|
14
|
+
This is the stub the apps will fill in once there is a Firebase project.
|
|
15
|
+
The plan, so the shape of this file does not have to change later:
|
|
16
|
+
|
|
17
|
+
1. Sign in with Firebase Auth (Google sign-in is the simplest on Android).
|
|
18
|
+
2. Keep one Firestore document per app per user:
|
|
19
|
+
users/{uid}/apps/{appId} -> the Payload, as is
|
|
20
|
+
A payload is a few tens of kilobytes at most, far inside one document.
|
|
21
|
+
3. load() reads that document; save() writes it with set().
|
|
22
|
+
4. Turn on Firestore's offline persistence, so practising on the bus with
|
|
23
|
+
no signal still saves and catches up later.
|
|
24
|
+
|
|
25
|
+
Until then load() rejects, which the store treats exactly like "no sync
|
|
26
|
+
configured": everything stays in this browser, and the Stats screen says so. */
|
|
27
|
+
export interface FirebaseSyncOptions {
|
|
28
|
+
appId: string;
|
|
29
|
+
/** The web-app config from the Firebase console. */
|
|
30
|
+
config?: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class FirebaseSync implements SyncAdapter {
|
|
34
|
+
readonly name = 'Firebase';
|
|
35
|
+
constructor(readonly opts: FirebaseSyncOptions) {}
|
|
36
|
+
async load(): Promise<Payload | null> {
|
|
37
|
+
// TODO(firebase): sign in, then read users/{uid}/apps/{appId}
|
|
38
|
+
throw new Error('Firebase sync is not set up yet');
|
|
39
|
+
}
|
|
40
|
+
async save(_payload: Payload): Promise<void> {
|
|
41
|
+
// TODO(firebase): write users/{uid}/apps/{appId}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* The shape of everything the apps remember. One payload per app, in the same
|
|
2
|
+
format the original Fretboard Fluency used (`ff.v1`), so progress exported
|
|
3
|
+
from the old app can be imported into any of the new ones unchanged. */
|
|
4
|
+
|
|
5
|
+
export type Pref = 'sharp' | 'flat';
|
|
6
|
+
export type Sys = 'en' | 'solf';
|
|
7
|
+
export type ClefId = 'bass' | 'treble' | 'tenor' | 'alto';
|
|
8
|
+
export type LevelId = 'l0' | 'l1' | 'l2';
|
|
9
|
+
export type RhythmLevelId = 'r1' | 'r2' | 'r3';
|
|
10
|
+
export type Theme = 'auto' | 'light' | 'dark';
|
|
11
|
+
|
|
12
|
+
/** One thing you can be asked about. s = seen, c = correct, t = last seen (ms), k = mastery 0–5. */
|
|
13
|
+
export interface Card {
|
|
14
|
+
s: number;
|
|
15
|
+
c: number;
|
|
16
|
+
t: number;
|
|
17
|
+
k: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One calendar day: answers, correct answers, milliseconds spent. */
|
|
21
|
+
export interface Day {
|
|
22
|
+
n: number;
|
|
23
|
+
c: number;
|
|
24
|
+
ms: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TempoEntry {
|
|
28
|
+
d: string;
|
|
29
|
+
bpm: number;
|
|
30
|
+
v: TempoVerdict;
|
|
31
|
+
}
|
|
32
|
+
export type TempoVerdict = 'clean' | 'sloppy' | 'fast';
|
|
33
|
+
|
|
34
|
+
/** Tempo tracking for a warm-up family or a rhythm level. */
|
|
35
|
+
export interface TempoRec {
|
|
36
|
+
bpm: number;
|
|
37
|
+
best: number;
|
|
38
|
+
n: number;
|
|
39
|
+
hist: TempoEntry[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Settings {
|
|
43
|
+
/* notes */
|
|
44
|
+
pref: Pref;
|
|
45
|
+
level: LevelId;
|
|
46
|
+
acc: boolean;
|
|
47
|
+
clefs: ClefId[];
|
|
48
|
+
mdo: boolean;
|
|
49
|
+
/* screen */
|
|
50
|
+
theme: Theme;
|
|
51
|
+
hints: boolean;
|
|
52
|
+
labels: boolean;
|
|
53
|
+
/* instrument and sound */
|
|
54
|
+
bass: boolean;
|
|
55
|
+
mic: boolean;
|
|
56
|
+
sound: boolean;
|
|
57
|
+
/* daily session */
|
|
58
|
+
slen: number;
|
|
59
|
+
off: string[];
|
|
60
|
+
doff: string[];
|
|
61
|
+
/* scales */
|
|
62
|
+
sen: string[] | null;
|
|
63
|
+
sroots: 'nat' | 'all';
|
|
64
|
+
sstart: 'both' | 'E' | 'A';
|
|
65
|
+
sslen: number;
|
|
66
|
+
sdoff: string[];
|
|
67
|
+
/* ear */
|
|
68
|
+
ivset: 'basic' | 'all';
|
|
69
|
+
/* bass warm-ups */
|
|
70
|
+
woff: string[];
|
|
71
|
+
wlen: number;
|
|
72
|
+
wmode: 'bass' | 'voice';
|
|
73
|
+
/* voice */
|
|
74
|
+
vlo: number | null;
|
|
75
|
+
vhi: number | null;
|
|
76
|
+
voff: string[] | null;
|
|
77
|
+
vtempo: number;
|
|
78
|
+
vgap: boolean;
|
|
79
|
+
/* rhythm */
|
|
80
|
+
rlev: RhythmLevelId;
|
|
81
|
+
rtempo: number;
|
|
82
|
+
rfloor: number;
|
|
83
|
+
rlat: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface Payload {
|
|
87
|
+
v: 1;
|
|
88
|
+
t: number;
|
|
89
|
+
cards: Record<string, Card>;
|
|
90
|
+
daily: Record<string, Day>;
|
|
91
|
+
tempo: Record<string, TempoRec>;
|
|
92
|
+
settings: Settings;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface HonmoonState {
|
|
96
|
+
cards: Record<string, Card>;
|
|
97
|
+
daily: Record<string, Day>;
|
|
98
|
+
tempo: Record<string, TempoRec>;
|
|
99
|
+
settings: Settings;
|
|
100
|
+
answers: number;
|
|
101
|
+
}
|