@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,119 @@
|
|
|
1
|
+
<!-- The summary after a session: how it went, what it taught you, what is still shaky. -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { minsSecs } from '../../util/text';
|
|
4
|
+
import { describeCard } from '../../learn/describe';
|
|
5
|
+
import { streakDays } from '../../learn/progress';
|
|
6
|
+
import {
|
|
7
|
+
session,
|
|
8
|
+
startMissSession,
|
|
9
|
+
startSession,
|
|
10
|
+
stillMissed,
|
|
11
|
+
} from '../../drills/runtime.svelte';
|
|
12
|
+
import { go } from '../../nav.svelte';
|
|
13
|
+
import Stat from '../Stat.svelte';
|
|
14
|
+
|
|
15
|
+
const current = $derived(session.current);
|
|
16
|
+
const summary = $derived.by(() => {
|
|
17
|
+
if (!current) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const total = current.results.length;
|
|
21
|
+
const right = current.right;
|
|
22
|
+
const pct = total ? Math.round((100 * right) / total) : 0;
|
|
23
|
+
/* A card counts as shaky if it was missed and never got a clean pass after. */
|
|
24
|
+
const last: Record<string, boolean> = {};
|
|
25
|
+
for (const result of current.results) {
|
|
26
|
+
last[result.card] = result.ok;
|
|
27
|
+
}
|
|
28
|
+
const missed: string[] = [];
|
|
29
|
+
const fixed: string[] = [];
|
|
30
|
+
const seen: Record<string, 1> = {};
|
|
31
|
+
for (const result of current.results) {
|
|
32
|
+
if (result.ok || seen[result.card]) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
seen[result.card] = 1;
|
|
36
|
+
(last[result.card] ? fixed : missed).push(result.card);
|
|
37
|
+
}
|
|
38
|
+
return { total, right, pct, missed, fixed };
|
|
39
|
+
});
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
{#if current && summary}
|
|
43
|
+
<div class="sec">
|
|
44
|
+
<h2>Session done</h2>
|
|
45
|
+
<Stat
|
|
46
|
+
items={[
|
|
47
|
+
{ v: summary.right + '/' + summary.total, k: 'Correct' },
|
|
48
|
+
{ v: summary.pct + '%', k: 'Accuracy' },
|
|
49
|
+
{ v: minsSecs(current.secs || 0), k: 'Time' },
|
|
50
|
+
{ v: streakDays(), k: 'Day streak' },
|
|
51
|
+
]}
|
|
52
|
+
/>
|
|
53
|
+
<p class="note">
|
|
54
|
+
{current.planned} cards planned{current.redos
|
|
55
|
+
? ', ' +
|
|
56
|
+
current.redos +
|
|
57
|
+
' of them missed and sent round a second time — ' +
|
|
58
|
+
summary.total +
|
|
59
|
+
' questions in all'
|
|
60
|
+
: ''}.
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
{#if summary.fixed.length}
|
|
64
|
+
<div class="sec">
|
|
65
|
+
<h2>Missed, then got right</h2>
|
|
66
|
+
<div class="card stack">
|
|
67
|
+
{#each summary.fixed.slice(0, 8) as card (card)}<div class="setrow">
|
|
68
|
+
<span class="lbl">{describeCard(card)}</span><span
|
|
69
|
+
class="tick"
|
|
70
|
+
style="color:var(--good)">fixed</span
|
|
71
|
+
>
|
|
72
|
+
</div>{/each}
|
|
73
|
+
</div>
|
|
74
|
+
<p class="note">
|
|
75
|
+
These are the ones the session actually taught you — you saw them again a few questions
|
|
76
|
+
later and got them.
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
{/if}
|
|
80
|
+
{#if summary.missed.length}
|
|
81
|
+
<div class="sec">
|
|
82
|
+
<h2>Still shaky</h2>
|
|
83
|
+
<div class="card stack">
|
|
84
|
+
{#each summary.missed.slice(0, 10) as card (card)}<div class="setrow">
|
|
85
|
+
<span class="lbl">{describeCard(card)}</span><span class="tick" style="color:var(--bad)"
|
|
86
|
+
>missed</span
|
|
87
|
+
>
|
|
88
|
+
</div>{/each}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
{/if}
|
|
92
|
+
{#if !summary.missed.length && !summary.fixed.length}
|
|
93
|
+
<div class="sec">
|
|
94
|
+
<div class="card">
|
|
95
|
+
<p class="lead">Clean run — nothing missed. If that happens twice more, move up a range.</p>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
{/if}
|
|
99
|
+
<div class="sec">
|
|
100
|
+
<div class="bar">
|
|
101
|
+
{#if summary.missed.length}<button
|
|
102
|
+
class="start"
|
|
103
|
+
id="redomiss"
|
|
104
|
+
style="width:auto"
|
|
105
|
+
onclick={() => startMissSession(stillMissed(current))}
|
|
106
|
+
>Drill the {summary.missed.length} misses</button
|
|
107
|
+
>{/if}
|
|
108
|
+
<button class="ghost" id="againsess" onclick={() => startSession(current.mode)}
|
|
109
|
+
>Another session</button
|
|
110
|
+
>
|
|
111
|
+
<span class="spacer"></span>
|
|
112
|
+
<button
|
|
113
|
+
class="ghost"
|
|
114
|
+
id={current.mode === 'scales' ? 'backscales' : 'back'}
|
|
115
|
+
onclick={() => go(current.mode === 'scales' ? 'scales' : 'home')}>Done</button
|
|
116
|
+
>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
{/if}
|
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
<!-- Setup: every setting the original had, grouped as it was; each app picks the rows that apply to it. -->
|
|
2
|
+
<script lang="ts" module>
|
|
3
|
+
export const MDO_ADDS =
|
|
4
|
+
'Turning this on adds seven drills in their own group: reading a note as a syllable in a given key, naming its scale degree, finding a degree on the neck, and singing one. It is the thing that makes a melody transposable in your head, and the thing that makes singing a written line possible without a reference note for every phrase. It is also a real second language, so leave it off until the fixed-do and staff drills feel easy.';
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { onDestroy } from 'svelte';
|
|
9
|
+
import { honmoon, setSetting, saveLocal, queueSave } from '../../store/honmoon.svelte';
|
|
10
|
+
import type { ClefId, Settings } from '../../store/types';
|
|
11
|
+
import { LEVELS, STRINGS, bassHere, lvl } from '../../music/bass';
|
|
12
|
+
import { CLEFS, clefOn, enabledClefs } from '../../music/clefs';
|
|
13
|
+
import { bothNames, freqOfSounding, pcOf } from '../../music/notes';
|
|
14
|
+
import { RLEVELS, rhFloor, rlev } from '../../music/rhythm';
|
|
15
|
+
import { clefSVG } from '../../notation/staff';
|
|
16
|
+
import {
|
|
17
|
+
micEnv,
|
|
18
|
+
micListen,
|
|
19
|
+
micOn,
|
|
20
|
+
micReason,
|
|
21
|
+
micStart,
|
|
22
|
+
micStop,
|
|
23
|
+
micUsable,
|
|
24
|
+
type MicReading,
|
|
25
|
+
type MicReason,
|
|
26
|
+
} from '../../audio/mic';
|
|
27
|
+
import { go, hasRoute } from '../../nav.svelte';
|
|
28
|
+
import { toggledIn } from '../../util/list';
|
|
29
|
+
import AccRow from '../AccRow.svelte';
|
|
30
|
+
import DataCard from '../DataCard.svelte';
|
|
31
|
+
import type { SetupRow } from '../types';
|
|
32
|
+
|
|
33
|
+
interface Props {
|
|
34
|
+
rows: SetupRow[];
|
|
35
|
+
/** which paragraphs "How this works" carries */
|
|
36
|
+
how?: ('written' | 'position' | 'repetition')[];
|
|
37
|
+
/** how many drills the movable-do switch adds in this app, and what they are */
|
|
38
|
+
mdoAdds?: string;
|
|
39
|
+
appId?: string;
|
|
40
|
+
}
|
|
41
|
+
const {
|
|
42
|
+
rows,
|
|
43
|
+
how = ['written', 'position', 'repetition'],
|
|
44
|
+
mdoAdds = MDO_ADDS,
|
|
45
|
+
appId = 'honmoon',
|
|
46
|
+
}: Props = $props();
|
|
47
|
+
const has = (row: SetupRow) => rows.includes(row);
|
|
48
|
+
const settings = $derived(honmoon.settings);
|
|
49
|
+
/** Choices offered for the rhythm drills' starting tempo and their lowest tempo. */
|
|
50
|
+
const RHYTHM_TEMPOS = [60, 70, 80, 92, 104];
|
|
51
|
+
const RHYTHM_FLOORS = [40, 45, 50, 56, 63];
|
|
52
|
+
/** The written pitch is an octave above the sounding one: the mic hears the latter. */
|
|
53
|
+
const OCTAVE = 12;
|
|
54
|
+
/** Input level that fills the meter. */
|
|
55
|
+
const FULL_METER_RMS = 0.06;
|
|
56
|
+
|
|
57
|
+
function change<K extends keyof Settings>(key: K, value: Settings[K]) {
|
|
58
|
+
setSetting(key, value);
|
|
59
|
+
if (key === 'mic' && !value) {
|
|
60
|
+
stopCalib();
|
|
61
|
+
}
|
|
62
|
+
/* Raising the floor above where you are now has to carry the tempo with
|
|
63
|
+
it, or the drill would sit below its own limit until you nudged it. */
|
|
64
|
+
if (key === 'rfloor' && (honmoon.settings.rtempo || 72) < rhFloor()) {
|
|
65
|
+
setSetting('rtempo', rhFloor());
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Switch a clef on or off — but never the last one. */
|
|
69
|
+
function toggleClef(clef: ClefId) {
|
|
70
|
+
const on = toggledIn(honmoon.settings.clefs || ['bass'], clef);
|
|
71
|
+
if (!on.length) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
honmoon.settings.clefs = CLEFS.filter((def) => on.includes(def.id)).map((def) => def.id);
|
|
75
|
+
saveLocal();
|
|
76
|
+
queueSave();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ---- checking the mic against the open strings ---- */
|
|
80
|
+
const calib = $state({
|
|
81
|
+
open: STRINGS.map(() => false),
|
|
82
|
+
live: null as MicReading | null,
|
|
83
|
+
err: null as MicReason | null,
|
|
84
|
+
on: false,
|
|
85
|
+
});
|
|
86
|
+
function stopCalib() {
|
|
87
|
+
if (micOn()) {
|
|
88
|
+
micStop();
|
|
89
|
+
}
|
|
90
|
+
calib.on = false;
|
|
91
|
+
calib.live = null;
|
|
92
|
+
}
|
|
93
|
+
/** Tick off every open string the reading matches. */
|
|
94
|
+
function markOpenString(reading: MicReading) {
|
|
95
|
+
calib.live = reading;
|
|
96
|
+
if (!reading.stable || !reading.midi) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const heard = Math.round(reading.midi);
|
|
100
|
+
STRINGS.forEach((string, i) => {
|
|
101
|
+
if (heard === string.open - OCTAVE) {
|
|
102
|
+
calib.open[i] = true;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function toggleCalib() {
|
|
107
|
+
if (calib.on) {
|
|
108
|
+
stopCalib();
|
|
109
|
+
calib.err = null;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
calib.err = null;
|
|
113
|
+
micStart()
|
|
114
|
+
.then(() => {
|
|
115
|
+
calib.on = true;
|
|
116
|
+
micListen(markOpenString);
|
|
117
|
+
})
|
|
118
|
+
.catch((error) => {
|
|
119
|
+
calib.err = micReason(error);
|
|
120
|
+
calib.live = null;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
onDestroy(() => {
|
|
124
|
+
if (calib.on) {
|
|
125
|
+
stopCalib();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
const env = micEnv();
|
|
129
|
+
const usable = micUsable();
|
|
130
|
+
const heardMidi = $derived(calib.live && calib.live.midi ? Math.round(calib.live.midi) : 0);
|
|
131
|
+
/** Why no microphone can be had here, in a sentence. */
|
|
132
|
+
function whyNoMic(): string {
|
|
133
|
+
if (env.policy === false) {
|
|
134
|
+
return 'The page this one is embedded in does not pass microphone access through to it, and no browser setting overrides that.';
|
|
135
|
+
}
|
|
136
|
+
if (!env.secure) {
|
|
137
|
+
return 'The connection is not a secure one, which browsers require before handing over a microphone.';
|
|
138
|
+
}
|
|
139
|
+
return 'This browser exposes no capture API in this context.';
|
|
140
|
+
}
|
|
141
|
+
const yesNo = (value: boolean | null | undefined) => (value ? 'yes' : 'no');
|
|
142
|
+
const policyText = $derived(env.policy === null ? 'unknown' : yesNo(env.policy));
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
{#if has('range') || has('acc') || has('pref')}
|
|
146
|
+
<div class="sec">
|
|
147
|
+
<h2>Notes in rotation</h2>
|
|
148
|
+
<div class="card">
|
|
149
|
+
{#if has('range')}
|
|
150
|
+
<div class="setrow">
|
|
151
|
+
<span class="lbl">Range</span><span class="levels">
|
|
152
|
+
{#each LEVELS as level (level.id)}<button
|
|
153
|
+
class="chip sm"
|
|
154
|
+
data-lvl={level.id}
|
|
155
|
+
aria-pressed={level.id === lvl().id}
|
|
156
|
+
onclick={() => change('level', level.id)}>{level.name}</button
|
|
157
|
+
>{/each}
|
|
158
|
+
</span><span class="hint"
|
|
159
|
+
>{lvl().desc}. This is the same control as the one on the neck screen, and it sets how
|
|
160
|
+
far the reading drills range too.</span
|
|
161
|
+
>
|
|
162
|
+
</div>
|
|
163
|
+
{/if}
|
|
164
|
+
{#if has('acc')}<AccRow />{/if}
|
|
165
|
+
{#if has('pref')}
|
|
166
|
+
<div class="setrow">
|
|
167
|
+
<span class="lbl">Sharps or flats</span><span class="levels">
|
|
168
|
+
<button
|
|
169
|
+
class="chip sm"
|
|
170
|
+
data-set="pref"
|
|
171
|
+
data-val="sharp"
|
|
172
|
+
aria-pressed={settings.pref === 'sharp'}
|
|
173
|
+
onclick={() => change('pref', 'sharp')}>♯ sharps</button
|
|
174
|
+
>
|
|
175
|
+
<button
|
|
176
|
+
class="chip sm"
|
|
177
|
+
data-set="pref"
|
|
178
|
+
data-val="flat"
|
|
179
|
+
aria-pressed={settings.pref === 'flat'}
|
|
180
|
+
onclick={() => change('pref', 'flat')}>♭ flats</button
|
|
181
|
+
></span
|
|
182
|
+
>
|
|
183
|
+
<span class="hint"
|
|
184
|
+
>Which name a black note goes by. The same key is F♯ or G♭ depending on where you are
|
|
185
|
+
coming from; this picks the one the app uses when it has a free choice.</span
|
|
186
|
+
>
|
|
187
|
+
</div>
|
|
188
|
+
{/if}
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
{/if}
|
|
192
|
+
|
|
193
|
+
{#if has('appearance') || has('bass') || has('hints') || has('labels')}
|
|
194
|
+
<div class="sec">
|
|
195
|
+
<h2>The board</h2>
|
|
196
|
+
<div class="card">
|
|
197
|
+
{#if has('appearance')}
|
|
198
|
+
<div class="setrow">
|
|
199
|
+
<span class="lbl">Appearance</span><span class="levels">
|
|
200
|
+
<button
|
|
201
|
+
class="chip sm"
|
|
202
|
+
data-set="theme"
|
|
203
|
+
data-val="auto"
|
|
204
|
+
aria-pressed={(settings.theme || 'auto') === 'auto'}
|
|
205
|
+
onclick={() => change('theme', 'auto')}>Match device</button
|
|
206
|
+
>
|
|
207
|
+
<button
|
|
208
|
+
class="chip sm"
|
|
209
|
+
data-set="theme"
|
|
210
|
+
data-val="light"
|
|
211
|
+
aria-pressed={settings.theme === 'light'}
|
|
212
|
+
onclick={() => change('theme', 'light')}>Light</button
|
|
213
|
+
>
|
|
214
|
+
<button
|
|
215
|
+
class="chip sm"
|
|
216
|
+
data-set="theme"
|
|
217
|
+
data-val="dark"
|
|
218
|
+
aria-pressed={settings.theme === 'dark'}
|
|
219
|
+
onclick={() => change('theme', 'dark')}>Dark</button
|
|
220
|
+
>
|
|
221
|
+
</span><span class="hint"
|
|
222
|
+
>The staff is drawn in ink on the page in one and chalk on a board in the other — the
|
|
223
|
+
lines sit a step back from the notes either way, so the notes read first.</span
|
|
224
|
+
>
|
|
225
|
+
</div>
|
|
226
|
+
{/if}
|
|
227
|
+
{#if has('bass')}
|
|
228
|
+
<div class="setrow">
|
|
229
|
+
<span class="lbl">Bass with me</span>
|
|
230
|
+
<button
|
|
231
|
+
class="chip sm"
|
|
232
|
+
data-set="bass"
|
|
233
|
+
data-val={String(!bassHere())}
|
|
234
|
+
aria-pressed={bassHere()}
|
|
235
|
+
onclick={() => change('bass', !bassHere())}
|
|
236
|
+
>{bassHere() ? 'Yes' : 'Left at home'}</button
|
|
237
|
+
>
|
|
238
|
+
<span class="hint"
|
|
239
|
+
>Say no and the four play-along drills step out of the sessions until you say otherwise.
|
|
240
|
+
The same switch sits on the Practice and Scales screens, which is where you will
|
|
241
|
+
actually want it.</span
|
|
242
|
+
>
|
|
243
|
+
</div>
|
|
244
|
+
{/if}
|
|
245
|
+
{#if has('hints')}
|
|
246
|
+
<div class="setrow">
|
|
247
|
+
<span class="lbl">Explain the drill on screen</span>
|
|
248
|
+
<button
|
|
249
|
+
class="chip sm"
|
|
250
|
+
data-set="hints"
|
|
251
|
+
data-val={String(settings.hints === false)}
|
|
252
|
+
aria-pressed={settings.hints !== false}
|
|
253
|
+
onclick={() => change('hints', settings.hints === false)}
|
|
254
|
+
>{settings.hints !== false ? 'On' : 'Off'}</button
|
|
255
|
+
>
|
|
256
|
+
<span class="hint"
|
|
257
|
+
>A short note under the newer drills saying what is being asked and how to work it out.
|
|
258
|
+
Turn it off once they are familiar — nothing else changes.</span
|
|
259
|
+
>
|
|
260
|
+
</div>
|
|
261
|
+
{/if}
|
|
262
|
+
{#if has('labels')}
|
|
263
|
+
<div class="setrow">
|
|
264
|
+
<span class="lbl">String names on the board</span>
|
|
265
|
+
<button
|
|
266
|
+
class="chip sm"
|
|
267
|
+
data-set="labels"
|
|
268
|
+
data-val={String(!settings.labels)}
|
|
269
|
+
aria-pressed={settings.labels}
|
|
270
|
+
onclick={() => change('labels', !settings.labels)}
|
|
271
|
+
>{settings.labels ? 'Shown' : 'Hidden'}</button
|
|
272
|
+
>
|
|
273
|
+
<span class="hint"
|
|
274
|
+
>Only affects the two Fret → name drills, where the G D A E labels let you count up from
|
|
275
|
+
a known string instead of recognising the note. Turn them off once that drill feels easy
|
|
276
|
+
— knowing the string order by heart is four facts, and it is worth having.</span
|
|
277
|
+
>
|
|
278
|
+
</div>
|
|
279
|
+
{/if}
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
{/if}
|
|
283
|
+
|
|
284
|
+
{#if has('clefs')}
|
|
285
|
+
<div class="sec">
|
|
286
|
+
<h2>Clefs</h2>
|
|
287
|
+
<div class="card stack">
|
|
288
|
+
<p class="note">
|
|
289
|
+
Bass clef is the one your instrument is written in, so it starts on its own. Add the others
|
|
290
|
+
when you are ready and they join the reading and movable-do drills, mixed in at random.
|
|
291
|
+
Turning one off does not lose what you have learned on it.
|
|
292
|
+
</p>
|
|
293
|
+
<div class="clefgrid">
|
|
294
|
+
{#each CLEFS as clef (clef.id)}
|
|
295
|
+
{@const on = clefOn(clef.id)}
|
|
296
|
+
{@const only = on && enabledClefs().length === 1}
|
|
297
|
+
<div class="clefrow">
|
|
298
|
+
<svg viewBox="-2 -22 40 84" aria-hidden="true">
|
|
299
|
+
<g stroke="var(--line)" stroke-width="1.1"
|
|
300
|
+
><line x1="0" y1="0" x2="38" y2="0" /><line x1="0" y1="10" x2="38" y2="10" /><line
|
|
301
|
+
x1="0"
|
|
302
|
+
y1="20"
|
|
303
|
+
x2="38"
|
|
304
|
+
y2="20"
|
|
305
|
+
/><line x1="0" y1="30" x2="38" y2="30" /><line x1="0" y1="40" x2="38" y2="40" /></g
|
|
306
|
+
>
|
|
307
|
+
{@html clefSVG(clef.id)}</svg
|
|
308
|
+
>
|
|
309
|
+
<div style="flex:1;min-width:0">
|
|
310
|
+
<div class="setrow" style="border:none;padding:0">
|
|
311
|
+
<span class="lbl">{clef.n} clef</span>
|
|
312
|
+
<button
|
|
313
|
+
class="chip sm"
|
|
314
|
+
data-clef={clef.id}
|
|
315
|
+
aria-pressed={on}
|
|
316
|
+
disabled={only}
|
|
317
|
+
onclick={() => toggleClef(clef.id)}>{on ? 'On' : 'Off'}</button
|
|
318
|
+
>
|
|
319
|
+
</div>
|
|
320
|
+
<p class="note" style="margin:4px 0 0">{clef.sb}</p>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
{/each}
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
{/if}
|
|
328
|
+
|
|
329
|
+
{#if has('mdo')}
|
|
330
|
+
<div class="sec">
|
|
331
|
+
<h2>Movable do and degrees</h2>
|
|
332
|
+
<div class="card stack">
|
|
333
|
+
<div class="setrow">
|
|
334
|
+
<span class="lbl">Key & degrees drills</span>
|
|
335
|
+
<button
|
|
336
|
+
class="chip sm"
|
|
337
|
+
data-set="mdo"
|
|
338
|
+
data-val={String(!settings.mdo)}
|
|
339
|
+
aria-pressed={!!settings.mdo}
|
|
340
|
+
onclick={() => change('mdo', !settings.mdo)}>{settings.mdo ? 'On' : 'Off'}</button
|
|
341
|
+
>
|
|
342
|
+
<span class="hint">Off by default, and nothing else changes while it is off.</span>
|
|
343
|
+
</div>
|
|
344
|
+
<p class="note">
|
|
345
|
+
<b>Fixed do</b> is what the app uses everywhere else: Do is C, always, in every key. It is a
|
|
346
|
+
second name for a letter. <b>Movable do</b> ties the syllables to the key instead — Do is
|
|
347
|
+
whichever note the key is built on, so Do Re Mi sounds the same shape in every key and Sol
|
|
348
|
+
always pulls home the same way. Movable do says <i>Ti</i> rather than Si, because Si is the raised
|
|
349
|
+
fifth.
|
|
350
|
+
</p>
|
|
351
|
+
{#if mdoAdds}<p class="note">{@html mdoAdds}</p>{/if}
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
{/if}
|
|
355
|
+
|
|
356
|
+
{#if has('rhythm')}
|
|
357
|
+
<div class="sec">
|
|
358
|
+
<h2>Rhythm</h2>
|
|
359
|
+
<div class="card stack">
|
|
360
|
+
<div class="setrow">
|
|
361
|
+
<span class="lbl">How much notation</span><span class="levels">
|
|
362
|
+
{#each RLEVELS as level (level.id)}<button
|
|
363
|
+
class="chip sm"
|
|
364
|
+
data-set="rlev"
|
|
365
|
+
data-val={level.id}
|
|
366
|
+
aria-pressed={(settings.rlev || 'r1') === level.id}
|
|
367
|
+
onclick={() => change('rlev', level.id)}>{level.n}</button
|
|
368
|
+
>{/each}
|
|
369
|
+
</span><span class="hint">{rlev().d}</span>
|
|
370
|
+
</div>
|
|
371
|
+
<div class="setrow">
|
|
372
|
+
<span class="lbl">Tempo for tapping</span><span class="levels">
|
|
373
|
+
{#each RHYTHM_TEMPOS as tempo (tempo)}<button
|
|
374
|
+
class="chip sm"
|
|
375
|
+
data-set="rtempo"
|
|
376
|
+
data-val={tempo}
|
|
377
|
+
aria-pressed={(settings.rtempo || 80) === tempo}
|
|
378
|
+
onclick={() => change('rtempo', tempo)}>{tempo}</button
|
|
379
|
+
>{/each}
|
|
380
|
+
</span>
|
|
381
|
+
</div>
|
|
382
|
+
<div class="setrow">
|
|
383
|
+
<span class="lbl">Slowest it will go</span><span class="levels">
|
|
384
|
+
{#each RHYTHM_FLOORS as tempo (tempo)}<button
|
|
385
|
+
class="chip sm"
|
|
386
|
+
data-set="rfloor"
|
|
387
|
+
data-val={tempo}
|
|
388
|
+
aria-pressed={rhFloor() === tempo}
|
|
389
|
+
onclick={() => change('rfloor', tempo)}>{tempo}</button
|
|
390
|
+
>{/each}
|
|
391
|
+
</span><span class="hint"
|
|
392
|
+
>Miss a bar and the tapping drill eases the tempo back a few beats to let you catch it.
|
|
393
|
+
This is where that stops. Very slow is not easier — the gaps between beats get long enough
|
|
394
|
+
that you are counting from memory instead of feeling a pulse, and the bar falls apart. {Math.round(
|
|
395
|
+
60000 / rhFloor(),
|
|
396
|
+
)} ms between beats at {rhFloor()}.</span
|
|
397
|
+
>
|
|
398
|
+
</div>
|
|
399
|
+
<div class="setrow">
|
|
400
|
+
<span class="lbl">Tap delay allowed for</span><span class="tick"
|
|
401
|
+
>{Math.round((settings.rlat || 0) * 1000)} ms</span
|
|
402
|
+
>
|
|
403
|
+
<span class="hint"
|
|
404
|
+
>Phones take 40–150 ms to notice a tap and make a sound. Measure it once and the tapping
|
|
405
|
+
drill grades what you played rather than how quick your screen is.</span
|
|
406
|
+
>
|
|
407
|
+
</div>
|
|
408
|
+
{#if hasRoute('rcal')}<div class="bar">
|
|
409
|
+
<button class="ghost" id="rcalib" onclick={() => go('rcal', { from: 'setup' })}
|
|
410
|
+
>Measure the tap delay</button
|
|
411
|
+
>
|
|
412
|
+
</div>{/if}
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
{/if}
|
|
416
|
+
|
|
417
|
+
{#if has('sound') || has('mic')}
|
|
418
|
+
<div class="sec">
|
|
419
|
+
<h2>Sound</h2>
|
|
420
|
+
<div class="card">
|
|
421
|
+
{#if has('sound')}
|
|
422
|
+
<div class="setrow">
|
|
423
|
+
<span class="lbl">Play the note when I ask for it</span><button
|
|
424
|
+
class="chip sm"
|
|
425
|
+
data-set="sound"
|
|
426
|
+
data-val={String(!settings.sound)}
|
|
427
|
+
aria-pressed={settings.sound}
|
|
428
|
+
onclick={() => change('sound', !settings.sound)}>{settings.sound ? 'On' : 'Off'}</button
|
|
429
|
+
>
|
|
430
|
+
</div>
|
|
431
|
+
{/if}
|
|
432
|
+
{#if has('mic')}
|
|
433
|
+
{#if usable}
|
|
434
|
+
<div class="setrow">
|
|
435
|
+
<span class="lbl">Listen to my bass and check the note</span><button
|
|
436
|
+
class="chip sm"
|
|
437
|
+
data-set="mic"
|
|
438
|
+
data-val={String(!settings.mic)}
|
|
439
|
+
aria-pressed={settings.mic}
|
|
440
|
+
onclick={() => change('mic', !settings.mic)}>{settings.mic ? 'On' : 'Off'}</button
|
|
441
|
+
>
|
|
442
|
+
<span class="hint"
|
|
443
|
+
>Point the phone or laptop mic at your amp. Turn the amp up a little and cut the tone
|
|
444
|
+
knob back — the detector wants the fundamental, not the click of the pick.</span
|
|
445
|
+
>
|
|
446
|
+
</div>
|
|
447
|
+
{:else}
|
|
448
|
+
<div class="setrow">
|
|
449
|
+
<span class="lbl">Listen to my bass and check the note</span><span class="tick"
|
|
450
|
+
>not available here</span
|
|
451
|
+
>
|
|
452
|
+
<span class="hint"
|
|
453
|
+
>This viewer does not give the page a microphone, so the play-along drills ask you to
|
|
454
|
+
mark yourself instead. They still count towards your progress.</span
|
|
455
|
+
>
|
|
456
|
+
</div>
|
|
457
|
+
{/if}
|
|
458
|
+
{/if}
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
{/if}
|
|
462
|
+
|
|
463
|
+
{#if has('mic')}
|
|
464
|
+
<div class="sec">
|
|
465
|
+
<h2>{usable ? 'Check the mic' : 'Microphone'}</h2>
|
|
466
|
+
<div class="card stack">
|
|
467
|
+
{#if !usable}
|
|
468
|
+
<p class="note">
|
|
469
|
+
Nothing in this app can reach a microphone here. {whyNoMic()}
|
|
470
|
+
</p>
|
|
471
|
+
<p class="note">
|
|
472
|
+
The three play-along drills work anyway — they show you the note, play it back on request,
|
|
473
|
+
and you mark whether you got it. Every other drill is unaffected.
|
|
474
|
+
</p>
|
|
475
|
+
{:else}
|
|
476
|
+
<p class="note">
|
|
477
|
+
Play each open string once, let it ring. If a string will not register, that is the string
|
|
478
|
+
to distrust during the play-along drill — the low E is the hardest for any detector, and
|
|
479
|
+
mains hum sits right next to G on that string.
|
|
480
|
+
</p>
|
|
481
|
+
{#if calib.on}
|
|
482
|
+
<div class="tuner">
|
|
483
|
+
<span class="nt">{heardMidi ? bothNames(pcOf(heardMidi), settings.pref) : '—'}</span
|
|
484
|
+
><span class="hz"
|
|
485
|
+
>{calib.live?.freq ? calib.live.freq.toFixed(1) + ' Hz' : 'listening'}</span
|
|
486
|
+
>
|
|
487
|
+
</div>
|
|
488
|
+
<div class="meter">
|
|
489
|
+
<i
|
|
490
|
+
style="width:{Math.min(
|
|
491
|
+
100,
|
|
492
|
+
Math.round(((calib.live?.rms || 0) * 100) / FULL_METER_RMS),
|
|
493
|
+
)}%"
|
|
494
|
+
></i>
|
|
495
|
+
</div>
|
|
496
|
+
{/if}
|
|
497
|
+
<table class="opens">
|
|
498
|
+
<tbody>
|
|
499
|
+
{#each STRINGS as string, i (string.n)}
|
|
500
|
+
<tr class:done={calib.open[i]}
|
|
501
|
+
><td class="st">{string.n}</td><td
|
|
502
|
+
>{freqOfSounding(string.open - OCTAVE).toFixed(1)} Hz</td
|
|
503
|
+
><td class="hit">{calib.open[i] ? 'heard ✓' : '—'}</td></tr
|
|
504
|
+
>
|
|
505
|
+
{/each}
|
|
506
|
+
</tbody>
|
|
507
|
+
</table>
|
|
508
|
+
<div class="bar">
|
|
509
|
+
<button class="ghost" class:on={calib.on} id="calib" onclick={toggleCalib}
|
|
510
|
+
>{calib.on ? 'Stop listening' : 'Start listening'}</button
|
|
511
|
+
>
|
|
512
|
+
</div>
|
|
513
|
+
{#if calib.err}<div class="failbox">
|
|
514
|
+
<div class="ft">{calib.err.t}</div>
|
|
515
|
+
<p class="note" style="margin:4px 0 0">{calib.err.m}</p>
|
|
516
|
+
</div>{/if}
|
|
517
|
+
{/if}
|
|
518
|
+
<p class="note" style="font-family:var(--f-mono);font-size:11.5px">
|
|
519
|
+
secure {yesNo(env.secure)} · capture API {yesNo(env.api)} · embedded {yesNo(env.framed)} · allowed
|
|
520
|
+
in this frame {policyText}
|
|
521
|
+
</p>
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
{/if}
|
|
525
|
+
|
|
526
|
+
{#if has('data')}<DataCard {appId} />{/if}
|
|
527
|
+
|
|
528
|
+
{#if has('how') && how.length}
|
|
529
|
+
<div class="sec">
|
|
530
|
+
<h2>How this works</h2>
|
|
531
|
+
<div class="card stack">
|
|
532
|
+
{#if how.includes('written')}<p class="note">
|
|
533
|
+
<b>Written pitch.</b> Bass is written an octave above where it sounds, so the open E string
|
|
534
|
+
is the note on the first ledger line below the staff, and the drills use that written pitch
|
|
535
|
+
throughout. Fret numbers and your ears agree; only the paper is transposed.
|
|
536
|
+
</p>{/if}
|
|
537
|
+
{#if how.includes('position')}<p class="note">
|
|
538
|
+
<b>Pitch is not position.</b> E on the A string at fret 7 and E on the D string at fret 2 are
|
|
539
|
+
the same sound, so a play-along drill accepts the right note wherever you play it. To be graded
|
|
540
|
+
on a specific spot, use Name → fret.
|
|
541
|
+
</p>{/if}
|
|
542
|
+
{#if how.includes('repetition')}<p class="note">
|
|
543
|
+
<b>Repetition.</b> Positions you miss come back sooner and more often; ones you nail drop out
|
|
544
|
+
of rotation until they have gone stale. Ten minutes most days beats an hour on Sunday.
|
|
545
|
+
</p>{/if}
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
{/if}
|