@greenblacksky/honmoon 0.0.0-stage → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +85 -2
  3. package/package.json +58 -4
  4. package/src/lib/audio/context.ts +29 -0
  5. package/src/lib/audio/metronome.svelte.ts +146 -0
  6. package/src/lib/audio/mic.ts +254 -0
  7. package/src/lib/audio/pitch.ts +133 -0
  8. package/src/lib/audio/rhythmplay.ts +152 -0
  9. package/src/lib/audio/synth.ts +409 -0
  10. package/src/lib/audio/voiceplay.ts +60 -0
  11. package/src/lib/drills/defs.ts +459 -0
  12. package/src/lib/drills/questions.ts +920 -0
  13. package/src/lib/drills/registry.svelte.ts +167 -0
  14. package/src/lib/drills/runtime.svelte.ts +629 -0
  15. package/src/lib/drills/verdict.ts +241 -0
  16. package/src/lib/index.ts +117 -0
  17. package/src/lib/learn/describe.ts +72 -0
  18. package/src/lib/learn/pools.ts +44 -0
  19. package/src/lib/learn/progress.ts +100 -0
  20. package/src/lib/learn/session.ts +304 -0
  21. package/src/lib/music/bass.ts +139 -0
  22. package/src/lib/music/clefs.ts +218 -0
  23. package/src/lib/music/intervals.ts +94 -0
  24. package/src/lib/music/notes.ts +133 -0
  25. package/src/lib/music/rhythm.ts +624 -0
  26. package/src/lib/music/scales.ts +560 -0
  27. package/src/lib/music/voice.ts +227 -0
  28. package/src/lib/music/warmups.ts +318 -0
  29. package/src/lib/nav.svelte.ts +53 -0
  30. package/src/lib/notation/fretboard.ts +187 -0
  31. package/src/lib/notation/glyphdata.ts +101 -0
  32. package/src/lib/notation/glyphs.ts +71 -0
  33. package/src/lib/notation/icons.ts +12 -0
  34. package/src/lib/notation/keyboard.ts +166 -0
  35. package/src/lib/notation/rhythmsvg.ts +341 -0
  36. package/src/lib/notation/staff.ts +288 -0
  37. package/src/lib/notation/svg.ts +37 -0
  38. package/src/lib/notation/tab.ts +80 -0
  39. package/src/lib/store/honmoon.svelte.ts +329 -0
  40. package/src/lib/store/normalize.ts +23 -0
  41. package/src/lib/store/sync.ts +43 -0
  42. package/src/lib/store/types.ts +101 -0
  43. package/src/lib/styles/honmoon.css +2064 -0
  44. package/src/lib/ui/AccRow.svelte +23 -0
  45. package/src/lib/ui/BackBar.svelte +23 -0
  46. package/src/lib/ui/BassRow.svelte +30 -0
  47. package/src/lib/ui/Chips.svelte +23 -0
  48. package/src/lib/ui/DataCard.svelte +66 -0
  49. package/src/lib/ui/Fretboard.svelte +71 -0
  50. package/src/lib/ui/Keyboard.svelte +54 -0
  51. package/src/lib/ui/MetronomePanel.svelte +101 -0
  52. package/src/lib/ui/RefBar.svelte +28 -0
  53. package/src/lib/ui/RhythmBar.svelte +9 -0
  54. package/src/lib/ui/Shell.svelte +96 -0
  55. package/src/lib/ui/StaffRun.svelte +46 -0
  56. package/src/lib/ui/Stat.svelte +13 -0
  57. package/src/lib/ui/actions.ts +36 -0
  58. package/src/lib/ui/keyfollow.svelte.ts +48 -0
  59. package/src/lib/ui/overlay.ts +15 -0
  60. package/src/lib/ui/refdata.ts +355 -0
  61. package/src/lib/ui/scaleboard.ts +420 -0
  62. package/src/lib/ui/screens/DegreesRef.svelte +405 -0
  63. package/src/lib/ui/screens/DrillScreen.svelte +1155 -0
  64. package/src/lib/ui/screens/Explorer.svelte +245 -0
  65. package/src/lib/ui/screens/FamilyScreen.svelte +280 -0
  66. package/src/lib/ui/screens/Glossary.svelte +65 -0
  67. package/src/lib/ui/screens/IntervalRef.svelte +37 -0
  68. package/src/lib/ui/screens/LearnIndex.svelte +44 -0
  69. package/src/lib/ui/screens/MetronomeScreen.svelte +17 -0
  70. package/src/lib/ui/screens/PracticeHome.svelte +159 -0
  71. package/src/lib/ui/screens/RhythmRef.svelte +239 -0
  72. package/src/lib/ui/screens/ScalesScreen.svelte +210 -0
  73. package/src/lib/ui/screens/SessionDone.svelte +119 -0
  74. package/src/lib/ui/screens/SetupScreen.svelte +548 -0
  75. package/src/lib/ui/screens/StaffMap.svelte +230 -0
  76. package/src/lib/ui/screens/StatsScreen.svelte +478 -0
  77. package/src/lib/ui/screens/TapCalibration.svelte +186 -0
  78. package/src/lib/ui/tapfix.ts +93 -0
  79. package/src/lib/ui/types.ts +40 -0
  80. package/src/lib/util/list.ts +4 -0
  81. package/src/lib/util/random.ts +23 -0
  82. package/src/lib/util/text.ts +50 -0
@@ -0,0 +1,227 @@
1
+ import { honmoon } from '../store/honmoon.svelte';
2
+ import { pickOf } from '../util/random';
3
+
4
+ /* ---------------- voice ----------------
5
+ A cold voice doing agility work is how people strain, so the stages run in
6
+ a fixed order and only the pattern and syllable inside each one are random. */
7
+ export interface VoicePattern {
8
+ id: string;
9
+ n: string;
10
+ iv?: number[];
11
+ /** a siren: how far it slides, in semitones */
12
+ glide?: number;
13
+ beats: number;
14
+ staccato?: boolean;
15
+ }
16
+ export const VPATS: VoicePattern[] = [
17
+ { id: 'sus', n: 'Sustained', iv: [0], beats: 4 },
18
+ { id: 'p3', n: 'Three-note', iv: [0, 2, 4, 2, 0], beats: 1 },
19
+ { id: 'p5', n: 'Five-note scale', iv: [0, 2, 4, 5, 7, 5, 4, 2, 0], beats: 1 },
20
+ { id: 'tri', n: 'Triad', iv: [0, 4, 7, 4, 0], beats: 1 },
21
+ { id: 'arp', n: 'Arpeggio to the octave', iv: [0, 4, 7, 12, 7, 4, 0], beats: 1 },
22
+ { id: 'oct', n: 'Octave leap', iv: [0, 12, 0], beats: 2 },
23
+ {
24
+ id: 'p9',
25
+ n: 'Nine-note scale',
26
+ iv: [0, 2, 4, 5, 7, 9, 11, 12, 11, 9, 7, 5, 4, 2, 0],
27
+ beats: 0.5,
28
+ },
29
+ { id: 'stac', n: 'Staccato arpeggio', iv: [0, 4, 7, 12, 7, 4, 0], beats: 0.75, staccato: true },
30
+ { id: 'siren', n: 'Siren', glide: 13, beats: 6 },
31
+ ];
32
+ export function vpat(id: string): VoicePattern {
33
+ return VPATS.find((pattern) => pattern.id === id) ?? VPATS[0];
34
+ }
35
+
36
+ /** A sound to make (`s`, e.g. "Lip trill") and a cue for how (`c`). */
37
+ export interface VoiceSound {
38
+ s: string;
39
+ c: string;
40
+ }
41
+ export interface VoiceStage {
42
+ /** key, e.g. "sovt" */
43
+ k: string;
44
+ /** name */
45
+ n: string;
46
+ /** 1 when switched on by default */
47
+ on: 0 | 1;
48
+ /** a breathing stage: timed, with no notes to sing */
49
+ timed?: boolean;
50
+ /** the patterns this stage can use (ids from VPATS) */
51
+ pats?: string[];
52
+ /** what the stage is for */
53
+ d: string;
54
+ sounds: VoiceSound[];
55
+ }
56
+ export const VSTAGES: VoiceStage[] = [
57
+ {
58
+ k: 'breath',
59
+ n: 'Breath',
60
+ on: 1,
61
+ timed: true,
62
+ d: 'No pitch, just air. Sets the support everything else rests on.',
63
+ sounds: [
64
+ {
65
+ s: 'sss',
66
+ c: 'Hiss on a steady "sss" for the whole count. Even pressure, no fade at the end.',
67
+ },
68
+ {
69
+ s: '4 in, 8 out',
70
+ c: 'In through the nose for four, out on a thin "fff" for eight. Shoulders still.',
71
+ },
72
+ { s: 'shh', c: 'Short, firm "shh" pulses from the belly — four per breath.' },
73
+ ],
74
+ },
75
+ {
76
+ k: 'sovt',
77
+ n: 'Semi-occluded',
78
+ on: 1,
79
+ pats: ['sus', 'p3', 'p5', 'tri'],
80
+ d: 'A partly closed mouth balances the pressure across the folds. The safest thing you can do to a cold voice, and always first.',
81
+ sounds: [
82
+ {
83
+ s: 'Lip trill',
84
+ c: 'Brrr. If it stalls, rest two fingers on your cheeks to take the weight off.',
85
+ },
86
+ { s: 'Tongue trill', c: 'Rolled r. If you cannot roll one, use the lip trill instead.' },
87
+ {
88
+ s: 'Straw (oo)',
89
+ c: 'Sing "oo" through a narrow straw, or just purse your lips into a tiny circle.',
90
+ },
91
+ { s: 'vvv', c: 'A buzzy "v" — feel it in the lips and teeth.' },
92
+ { s: 'zzz', c: 'Voiced "z". Steady buzz, no wobble.' },
93
+ { s: 'Hum (mmm)', c: 'Lips together, teeth apart, jaw loose. Feel the buzz on the lips.' },
94
+ { s: 'ng', c: 'The back of "sing". Tongue up at the soft palate, sound in the nose.' },
95
+ ],
96
+ },
97
+ {
98
+ k: 'fry',
99
+ n: 'Fry and onset',
100
+ on: 1,
101
+ pats: ['sus', 'oct'],
102
+ d: 'Wakes the folds gently and teaches a clean start to a note, without pushing air.',
103
+ sounds: [
104
+ { s: 'Vocal fry', c: 'The lowest creaky rattle you can make. No pushing — almost no air.' },
105
+ {
106
+ s: 'Fry into "ah"',
107
+ c: 'Start in fry, then let it bloom into a tone on the note without a break.',
108
+ },
109
+ { s: 'uh-oh', c: 'A light glottal "uh-oh". Clean onset, no grab.' },
110
+ ],
111
+ },
112
+ {
113
+ k: 'glide',
114
+ n: 'Sirens and glides',
115
+ on: 1,
116
+ pats: ['siren'],
117
+ d: 'One smooth slide through your whole range. Finds the joins between registers and smooths them.',
118
+ sounds: [
119
+ {
120
+ s: 'ng siren',
121
+ c: 'Slide up and down on "ng" like a fire engine. Do not push through the bumps — go lighter.',
122
+ },
123
+ { s: 'Lip trill siren', c: 'Same slide, on a lip trill. Easiest way through the break.' },
124
+ { s: 'oo glide', c: 'Narrow "oo", slide slowly. Keep the volume even top to bottom.' },
125
+ ],
126
+ },
127
+ {
128
+ k: 'vowel',
129
+ n: 'Vowels',
130
+ on: 1,
131
+ pats: ['p5', 'tri', 'arp'],
132
+ d: 'The shapes you actually sing in. Keep the jaw still and let the tongue do the work.',
133
+ sounds: [
134
+ { s: 'ee', c: 'As in "see". Bright, forward, tongue high.' },
135
+ { s: 'eh', c: 'As in "bed". Jaw a touch lower than "ee".' },
136
+ { s: 'ah', c: 'As in "father". Open, but do not drop the jaw as far as it wants to go.' },
137
+ { s: 'oh', c: 'As in "go". Lips rounded, space inside.' },
138
+ { s: 'oo', c: 'As in "boot". Narrow lips, open throat.' },
139
+ { s: 'ee-eh-ah-oh-oo', c: 'One vowel per note, jaw as still as you can keep it.' },
140
+ ],
141
+ },
142
+ {
143
+ k: 'reson',
144
+ n: 'Resonance',
145
+ on: 1,
146
+ pats: ['p5', 'tri', 'p3'],
147
+ d: 'Moves the sound forward so it carries without extra effort.',
148
+ sounds: [
149
+ { s: 'nay', c: 'Deliberately bratty and nasal. It sounds awful alone and fixes placement.' },
150
+ { s: 'mum', c: 'Relaxed and round. Balances out the "nay".' },
151
+ { s: 'goo', c: 'Releases a tight tongue root.' },
152
+ { s: 'ng-ah', c: 'Start on "ng", open to "ah" keeping the same buzz.' },
153
+ ],
154
+ },
155
+ {
156
+ k: 'agility',
157
+ n: 'Agility',
158
+ on: 0,
159
+ pats: ['p9', 'stac', 'arp'],
160
+ d: 'Speed and flexibility. Last, never first — and drop the tempo before you drop the accuracy.',
161
+ sounds: [
162
+ { s: 'ah', c: 'Light and bouncy. Do not let it get heavy at the top.' },
163
+ {
164
+ s: 'hah-hah-hah',
165
+ c: 'A soft "h" on each note, powered from the belly rather than the throat.',
166
+ },
167
+ { s: 'ya', c: 'Quick and forward, tongue relaxed.' },
168
+ ],
169
+ },
170
+ ];
171
+ export function vstage(key: string): VoiceStage {
172
+ return VSTAGES.find((stage) => stage.k === key) ?? (null as unknown as VoiceStage);
173
+ }
174
+ export function vstageOn(key: string): boolean {
175
+ return !(honmoon.settings.voff || []).includes(key);
176
+ }
177
+ /** Until a range has been measured: A2 to A4, a span most voices can manage
178
+ somewhere in it. */
179
+ const DEFAULT_LOW = 45;
180
+ const DEFAULT_HIGH = 69;
181
+ /** The singer's comfortable range, or a default until it has been measured. */
182
+ export function vRange(): { lo: number; hi: number; set: boolean } {
183
+ const low = honmoon.settings.vlo;
184
+ const high = honmoon.settings.vhi;
185
+ return {
186
+ lo: low ?? DEFAULT_LOW,
187
+ hi: high ?? DEFAULT_HIGH,
188
+ set: low != null && high != null,
189
+ };
190
+ }
191
+
192
+ /* ---------------- the warm-up routine ---------------- */
193
+ export interface RoutineStep {
194
+ k: string;
195
+ sound: VoiceSound;
196
+ pat: VoicePattern | null;
197
+ }
198
+ /** Every stage that is switched on, in the fixed order, each with a sound and
199
+ a pattern drawn at random. */
200
+ export function vBuildRoutine(): RoutineStep[] {
201
+ const out: RoutineStep[] = [];
202
+ for (const stage of VSTAGES) {
203
+ if (!vstageOn(stage.k)) {
204
+ continue;
205
+ }
206
+ const sound = pickOf(stage.sounds);
207
+ const pattern = stage.timed ? null : vpat(pickOf(stage.pats!));
208
+ out.push({ k: stage.k, sound, pat: pattern });
209
+ }
210
+ return out;
211
+ }
212
+ /** How far a pattern climbs above its starting note. */
213
+ export function vSpan(pattern: VoicePattern | null): number {
214
+ if (!pattern) {
215
+ return 0;
216
+ }
217
+ if (pattern.glide) {
218
+ return pattern.glide;
219
+ }
220
+ return Math.max(...pattern.iv!);
221
+ }
222
+ /** Where the pattern can start without taking the top note outside the range. */
223
+ export function vStartRange(pattern: VoicePattern | null): { lo: number; hi: number } {
224
+ const range = vRange();
225
+ const highestStart = range.hi - vSpan(pattern);
226
+ return { lo: range.lo, hi: Math.max(range.lo, highestStart) };
227
+ }
@@ -0,0 +1,318 @@
1
+ import { honmoon } from '../store/honmoon.svelte';
2
+ import { STRINGS } from './bass';
3
+ import { pickOf, rnd } from '../util/random';
4
+ import type { TabStep } from '../notation/tab';
5
+
6
+ /* ---------------- bass warm-ups ----------------
7
+ The pattern is random, the family is not. Novelty keeps a warm-up from
8
+ getting stale; the tempo log hangs off the family, so the number that
9
+ measures you still means something six weeks from now. */
10
+ export interface WarmFamily {
11
+ k: string;
12
+ n: string;
13
+ d: string;
14
+ bpm: number;
15
+ }
16
+ export const FAMS: WarmFamily[] = [
17
+ {
18
+ k: 'perm',
19
+ n: 'Permutations',
20
+ d: 'One finger per fret, four notes reordered. The spider, in all 24 of its orders.',
21
+ bpm: 60,
22
+ },
23
+ {
24
+ k: 'cross',
25
+ n: 'String crossing',
26
+ d: 'A small shape bounced between two strings, adjacent or skipping. Where the right hand falls apart.',
27
+ bpm: 60,
28
+ },
29
+ {
30
+ k: 'shift',
31
+ n: 'Position shifts',
32
+ d: 'One shape relocated up and down the neck. Builds confidence away from the nut.',
33
+ bpm: 52,
34
+ },
35
+ {
36
+ k: 'chrom',
37
+ n: 'Chromatic runs',
38
+ d: 'Every fret in order, along one string or across the neck at one position.',
39
+ bpm: 64,
40
+ },
41
+ {
42
+ k: 'right',
43
+ n: 'Right hand',
44
+ d: 'Strict alternation and rakes, one note per string. The half nobody practises.',
45
+ bpm: 70,
46
+ },
47
+ {
48
+ k: 'sub',
49
+ n: 'Subdivisions',
50
+ d: 'One dull pattern as quarters, eighths, triplets, sixteenths. The least fun, the most useful.',
51
+ bpm: 60,
52
+ },
53
+ ];
54
+ export function famDef(key: string): WarmFamily {
55
+ return FAMS.find((family) => family.k === key) ?? (null as unknown as WarmFamily);
56
+ }
57
+ export function famOff(key: string): boolean {
58
+ return (honmoon.settings.woff || []).includes(key);
59
+ }
60
+ const FALLBACK_BPM = 60;
61
+ /** The tempo this family is on now: where you left it, or its starting tempo. */
62
+ export function famBpm(key: string): number {
63
+ const record = honmoon.tempo[key];
64
+ return (record && record.bpm) || (famDef(key) || { bpm: FALLBACK_BPM }).bpm || FALLBACK_BPM;
65
+ }
66
+
67
+ export interface Exercise {
68
+ fam: string;
69
+ title: string;
70
+ steps: TabStep[];
71
+ note: string;
72
+ sub?: number[];
73
+ }
74
+
75
+ /** String indices, top row first: 0 = G … 3 = E. */
76
+ const LOWEST_STRING = 3;
77
+ const FINGERS = 4;
78
+ /** The right-hand fingers, index and middle. */
79
+ const RH = ['i', 'm'];
80
+
81
+ /** All 24 orders of the four fingers (0 = index … 3 = little). */
82
+ const PERMS: number[][] = (() => {
83
+ const out: number[][] = [];
84
+ (function permute(chosen: number[], remaining: number[]) {
85
+ if (!remaining.length) {
86
+ out.push(chosen.slice());
87
+ return;
88
+ }
89
+ for (let i = 0; i < remaining.length; i++) {
90
+ const rest = remaining.slice();
91
+ const next = rest.splice(i, 1)[0];
92
+ permute(chosen.concat(next), rest);
93
+ }
94
+ })([], [0, 1, 2, 3]);
95
+ return out;
96
+ })();
97
+
98
+ /** One finger per fret from `startFret`, the four fingers in `fingerOrder`,
99
+ up through the strings from the lowest and back down. */
100
+ function acrossAndBack(startFret: number, fingerOrder: number[]): TabStep[] {
101
+ const steps: TabStep[] = [];
102
+ const play = (string: number, finger: number) =>
103
+ steps.push({ s: string, f: startFret + finger, fin: finger + 1 });
104
+ for (let string = LOWEST_STRING; string >= 0; string--) {
105
+ for (let i = 0; i < FINGERS; i++) {
106
+ play(string, fingerOrder[i]);
107
+ }
108
+ }
109
+ for (let string = 1; string <= LOWEST_STRING; string++) {
110
+ for (let i = FINGERS - 1; i >= 0; i--) {
111
+ play(string, fingerOrder[i]);
112
+ }
113
+ }
114
+ return steps;
115
+ }
116
+
117
+ function genPerm(): Exercise {
118
+ const fingerOrder = pickOf(PERMS);
119
+ const startFret = 3 + rnd(6);
120
+ return {
121
+ fam: 'perm',
122
+ title: 'Permutation ' + fingerOrder.map((finger) => finger + 1).join('-'),
123
+ steps: acrossAndBack(startFret, fingerOrder),
124
+ note:
125
+ 'One finger per fret starting at fret ' +
126
+ startFret +
127
+ '. Up through the strings and back down. When it is clean, move the whole thing up a fret and go again.',
128
+ };
129
+ }
130
+ function genCross(): Exercise {
131
+ const [first, second] = pickOf([
132
+ [3, 2],
133
+ [2, 1],
134
+ [1, 0],
135
+ [3, 1],
136
+ [2, 0],
137
+ [3, 0],
138
+ ]);
139
+ const fret = 3 + rnd(6);
140
+ const span = pickOf([2, 3]);
141
+ const steps: TabStep[] = [];
142
+ const reps = pickOf([2, 3]);
143
+ const low = (string: number) => ({ s: string, f: fret, fin: 1 });
144
+ const high = (string: number) => ({ s: string, f: fret + span, fin: span + 1 });
145
+ for (let rep = 0; rep < reps; rep++) {
146
+ steps.push(low(first), low(second), high(first), high(second));
147
+ }
148
+ for (let rep = 0; rep < reps; rep++) {
149
+ steps.push(high(second), high(first), low(second), low(first));
150
+ }
151
+ steps.forEach((step, i) => {
152
+ step.rh = RH[i % 2];
153
+ });
154
+ const skipsAString = Math.abs(first - second) > 1;
155
+ return {
156
+ fam: 'cross',
157
+ title: STRINGS[first].n + ' ↔ ' + STRINGS[second].n + (skipsAString ? ' (skipping)' : ''),
158
+ steps,
159
+ note:
160
+ 'Alternate i and m strictly. ' +
161
+ (skipsAString
162
+ ? 'The skipped string in the middle must stay silent — mute it with the plucking finger as you pass.'
163
+ : 'Keep the two notes even in volume; the crossing is where the tone usually dips.'),
164
+ };
165
+ }
166
+ function genShift(): Exercise {
167
+ const string = rnd(4);
168
+ const route = pickOf([
169
+ [1, 3, 5, 7],
170
+ [5, 7, 9, 7],
171
+ [3, 5, 7, 5, 3],
172
+ [1, 4, 7, 4],
173
+ [7, 5, 3, 5, 7],
174
+ ]);
175
+ const steps: TabStep[] = [];
176
+ for (const position of route) {
177
+ for (let finger = 0; finger < FINGERS; finger++) {
178
+ steps.push({
179
+ s: string,
180
+ f: position + finger,
181
+ fin: finger + 1,
182
+ bar: finger === 0 ? 'fret ' + position : null,
183
+ });
184
+ }
185
+ }
186
+ return {
187
+ fam: 'shift',
188
+ title: 'Shifts on the ' + STRINGS[string].n + ' string',
189
+ steps,
190
+ note: 'Move the whole hand, do not stretch. Look at the fret you are going to before you leave the one you are on.',
191
+ };
192
+ }
193
+ function genChrom(): Exercise {
194
+ const across = Math.random() < 0.5;
195
+ if (across) {
196
+ const position = 1 + rnd(7);
197
+ return {
198
+ fam: 'chrom',
199
+ title: 'Chromatic across, position ' + position,
200
+ steps: acrossAndBack(position, [0, 1, 2, 3]),
201
+ note: 'The hand never moves. Every finger stays over its own fret for the whole exercise.',
202
+ };
203
+ }
204
+ const string = rnd(4);
205
+ const from = 1 + rnd(3);
206
+ /** eight frets: four, a shift, four more — and back */
207
+ const length = 2 * FINGERS;
208
+ const steps: TabStep[] = [];
209
+ for (let i = 0; i < length; i++) {
210
+ steps.push({
211
+ s: string,
212
+ f: from + i,
213
+ fin: (i % FINGERS) + 1,
214
+ bar: i % FINGERS === 0 ? 'shift ' + (i / FINGERS + 1) : null,
215
+ });
216
+ }
217
+ for (let i = length - 1; i >= 0; i--) {
218
+ steps.push({ s: string, f: from + i, fin: (i % FINGERS) + 1 });
219
+ }
220
+ return {
221
+ fam: 'chrom',
222
+ title: 'Chromatic along the ' + STRINGS[string].n + ' string, from fret ' + from,
223
+ steps,
224
+ note: 'Four frets, shift, four more, and back. The shift should not be audible — no gap, no scoop.',
225
+ };
226
+ }
227
+ /** The left-hand finger for a fret held all exercise: none when open, the
228
+ index at fret 5, the middle at fret 7 (so the hand sits in position). */
229
+ function holdingFinger(fret: number): number {
230
+ if (!fret) {
231
+ return 0;
232
+ }
233
+ return fret === 5 ? 1 : 2;
234
+ }
235
+ function genRight(): Exercise {
236
+ const fret = pickOf([0, 0, 5, 7]);
237
+ const stringOrder = pickOf([
238
+ [3, 2, 1, 0, 0, 1, 2, 3],
239
+ [3, 1, 2, 0, 3, 1, 2, 0],
240
+ [0, 1, 2, 3, 3, 2, 1, 0],
241
+ [3, 2, 3, 1, 3, 0, 3, 1],
242
+ [0, 2, 1, 3, 0, 2, 1, 3],
243
+ ]);
244
+ const startFinger = rnd(2);
245
+ const steps: TabStep[] = stringOrder.map((string, i) => ({
246
+ s: string,
247
+ f: fret,
248
+ fin: holdingFinger(fret),
249
+ rh: RH[(i + startFinger) % 2],
250
+ }));
251
+ /* a line that only ever moves towards thicker strings can be raked */
252
+ const onlyDescends = stringOrder.every((string, i) => i === 0 || string >= stringOrder[i - 1]);
253
+ return {
254
+ fam: 'right',
255
+ title: 'Alternation, starting with ' + RH[startFinger],
256
+ steps,
257
+ note:
258
+ (fret ? 'All at fret ' + fret + '. ' : 'All open strings. ') +
259
+ 'Strict alternation — no repeated finger, even across a string change.' +
260
+ (onlyDescends
261
+ ? ' Where the line moves to a thicker string, let one finger rake through instead.'
262
+ : ''),
263
+ };
264
+ }
265
+ const BEATS_PER_BAR = 4;
266
+ /** Notes per beat → what they are called. */
267
+ const SUBDIVISION_NAMES: Record<number, string> = {
268
+ 1: 'quarters',
269
+ 2: 'eighths',
270
+ 3: 'triplets',
271
+ 4: 'sixteenths',
272
+ };
273
+ function genSub(): Exercise {
274
+ const stringOrder = pickOf([
275
+ [3, 2, 1, 0],
276
+ [3, 1, 2, 0],
277
+ [0, 1, 2, 3],
278
+ [3, 2, 3, 1],
279
+ ]);
280
+ const fret = pickOf([0, 5, 7]);
281
+ /** the two subdivisions, as notes per beat: one bar of each */
282
+ const subdivisions = pickOf([
283
+ [1, 2],
284
+ [2, 3],
285
+ [2, 4],
286
+ [1, 3],
287
+ ]);
288
+ const steps: TabStep[] = [];
289
+ for (const perBeat of subdivisions) {
290
+ const notesInBar = BEATS_PER_BAR * perBeat;
291
+ for (let i = 0; i < notesInBar; i++) {
292
+ steps.push({
293
+ s: stringOrder[i % stringOrder.length],
294
+ f: fret,
295
+ fin: fret ? 1 : 0,
296
+ bar: i === 0 ? SUBDIVISION_NAMES[perBeat] : null,
297
+ });
298
+ }
299
+ }
300
+ return {
301
+ fam: 'sub',
302
+ title: SUBDIVISION_NAMES[subdivisions[0]] + ' into ' + SUBDIVISION_NAMES[subdivisions[1]],
303
+ steps,
304
+ sub: subdivisions,
305
+ note: 'Leave the metronome on quarter notes and fit the subdivision between the clicks yourself. One bar of each, no pause at the change — that join is the whole exercise.',
306
+ };
307
+ }
308
+ const GENS: Record<string, () => Exercise> = {
309
+ perm: genPerm,
310
+ cross: genCross,
311
+ shift: genShift,
312
+ chrom: genChrom,
313
+ right: genRight,
314
+ sub: genSub,
315
+ };
316
+ export function genExercise(key: string): Exercise {
317
+ return (GENS[key] || genPerm)();
318
+ }
@@ -0,0 +1,53 @@
1
+ import { holdStop } from './audio/synth';
2
+
3
+ /* ---------------- navigation ----------------
4
+ One current screen, by name. Each app decides which names it has and which
5
+ component draws each one; the engine only ever asks to go somewhere by name,
6
+ and checks hasRoute() before offering a link to a screen an app may not
7
+ include (the Staff map lives in Zoey, for instance). */
8
+
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ export type ViewOpt = Record<string, any>;
11
+ export interface View {
12
+ name: string;
13
+ opt: ViewOpt;
14
+ }
15
+ export const nav: { view: View } = $state({ view: { name: 'home', opt: {} } });
16
+
17
+ // Set once at start-up by the app and never watched: a plain Set on purpose.
18
+ let routes: ReadonlySet<string> = new Set();
19
+ export function configureRoutes(names: string[]): void {
20
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
21
+ routes = new Set(names);
22
+ }
23
+ export function hasRoute(name: string): boolean {
24
+ return routes.has(name);
25
+ }
26
+
27
+ type Hook = (from: string, to: string, opt: ViewOpt) => void;
28
+ const hooks: Hook[] = [];
29
+ /** Run something whenever the screen changes — stopping a timer, releasing the
30
+ microphone, dropping a finished session. */
31
+ export function onNavigate(hook: Hook): void {
32
+ hooks.push(hook);
33
+ }
34
+
35
+ let scroller: HTMLElement | null = null;
36
+ export function setScroller(element: HTMLElement | null): void {
37
+ scroller = element;
38
+ }
39
+ export function scrollToTop(): void {
40
+ if (scroller && scroller.scrollTo) {
41
+ scroller.scrollTo(0, 0);
42
+ }
43
+ }
44
+
45
+ export function go(name: string, opt: ViewOpt = {}): void {
46
+ holdStop();
47
+ const from = nav.view.name;
48
+ for (const hook of hooks) {
49
+ hook(from, name, opt);
50
+ }
51
+ nav.view = { name, opt };
52
+ scrollToTop();
53
+ }