@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,23 @@
|
|
|
1
|
+
<!-- The sharps-and-flats switch, shown in both the places you might look for it:
|
|
2
|
+
beside the range on the neck screen, and in Setup under Notes. -->
|
|
3
|
+
<script lang="ts">
|
|
4
|
+
import { setSetting } from '../store/honmoon.svelte';
|
|
5
|
+
import { accOn } from '../music/bass';
|
|
6
|
+
const on = $derived(accOn());
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<div class="setrow">
|
|
10
|
+
<span class="lbl">Sharps and flats</span>
|
|
11
|
+
<button
|
|
12
|
+
class="chip sm"
|
|
13
|
+
data-set="acc"
|
|
14
|
+
data-val={String(!on)}
|
|
15
|
+
aria-pressed={on}
|
|
16
|
+
onclick={() => setSetting('acc', !on)}>{on ? 'In' : 'Out'}</button
|
|
17
|
+
>
|
|
18
|
+
<span class="hint"
|
|
19
|
+
>{on
|
|
20
|
+
? 'All twelve notes are in rotation. Which name a black note gets — F♯ or G♭ — follows the sharps-or-flats choice below.'
|
|
21
|
+
: 'Only the seven natural notes: A B C D E F G. Switch this on when those feel automatic, and the five black notes join in — on the neck, on the staff, and in every drill that names a note.'}</span
|
|
22
|
+
>
|
|
23
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!-- The bar at the top of a sub-screen: a way back on the left, a title on the right. -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
interface Props {
|
|
5
|
+
label: string;
|
|
6
|
+
onBack: () => void;
|
|
7
|
+
title?: string;
|
|
8
|
+
id?: string;
|
|
9
|
+
/** wrap in a .sec like most screens do */
|
|
10
|
+
sec?: boolean;
|
|
11
|
+
extra?: Snippet;
|
|
12
|
+
}
|
|
13
|
+
const { label, onBack, title = '', id = 'back', sec = false, extra }: Props = $props();
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
{#snippet bar()}
|
|
17
|
+
<div class="bar">
|
|
18
|
+
<button class="ghost" {id} onclick={onBack}>← {label}</button>
|
|
19
|
+
{@render extra?.()}
|
|
20
|
+
<span class="spacer"></span><span class="tick">{title}</span>
|
|
21
|
+
</div>
|
|
22
|
+
{/snippet}
|
|
23
|
+
{#if sec}<div class="sec">{@render bar()}</div>{:else}{@render bar()}{/if}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!-- One control, offered wherever a session is about to start: is the bass in the room? -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { setSetting } from '../store/honmoon.svelte';
|
|
4
|
+
import { bassHere } from '../music/bass';
|
|
5
|
+
const on = $derived(bassHere());
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<div>
|
|
9
|
+
<div class="hint" style="margin-bottom:6px">Bass</div>
|
|
10
|
+
<div class="levels">
|
|
11
|
+
<button
|
|
12
|
+
class="chip sm"
|
|
13
|
+
data-set="bass"
|
|
14
|
+
data-val="true"
|
|
15
|
+
aria-pressed={on}
|
|
16
|
+
onclick={() => setSetting('bass', true)}>With me</button
|
|
17
|
+
>
|
|
18
|
+
<button
|
|
19
|
+
class="chip sm"
|
|
20
|
+
data-set="bass"
|
|
21
|
+
data-val="false"
|
|
22
|
+
aria-pressed={!on}
|
|
23
|
+
onclick={() => setSetting('bass', false)}>Left at home</button
|
|
24
|
+
>
|
|
25
|
+
</div>
|
|
26
|
+
{#if !on}<p class="note" style="margin-top:6px">
|
|
27
|
+
The play-along drills are out until you say otherwise. Everything else works with nothing but
|
|
28
|
+
the screen.
|
|
29
|
+
</p>{/if}
|
|
30
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!-- A row of choice chips, exactly one pressed. -->
|
|
2
|
+
<script lang="ts" generics="T">
|
|
3
|
+
interface Props {
|
|
4
|
+
items: { v: T; label: string }[];
|
|
5
|
+
value: T;
|
|
6
|
+
onPick: (v: T) => void;
|
|
7
|
+
sm?: boolean;
|
|
8
|
+
/** extra attributes for tests: data-set="<name>" on each chip */
|
|
9
|
+
name?: string;
|
|
10
|
+
}
|
|
11
|
+
const { items, value, onPick, sm = true, name }: Props = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
{#each items as it (String(it.v))}
|
|
15
|
+
<button
|
|
16
|
+
class="chip"
|
|
17
|
+
class:sm
|
|
18
|
+
data-set={name}
|
|
19
|
+
data-val={String(it.v)}
|
|
20
|
+
aria-pressed={value === it.v}
|
|
21
|
+
onclick={() => onPick(it.v)}>{@html it.label}</button
|
|
22
|
+
>
|
|
23
|
+
{/each}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A copy of your progress you can keep, and a way to bring one back. Until
|
|
3
|
+
Firebase sync is set up this is the only backup there is: clearing the
|
|
4
|
+
browser's data, or uninstalling the app from the home screen, loses it.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { exportText, importText } from '../store/honmoon.svelte';
|
|
8
|
+
const { appId = 'honmoon' }: { appId?: string } = $props();
|
|
9
|
+
let msg = $state('');
|
|
10
|
+
let input: HTMLInputElement;
|
|
11
|
+
|
|
12
|
+
function exportNow() {
|
|
13
|
+
const blob = new Blob([exportText()], { type: 'application/json' });
|
|
14
|
+
const link = document.createElement('a');
|
|
15
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
16
|
+
link.href = URL.createObjectURL(blob);
|
|
17
|
+
link.download = appId + '-progress-' + today + '.json';
|
|
18
|
+
document.body.appendChild(link);
|
|
19
|
+
link.click();
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
URL.revokeObjectURL(link.href);
|
|
22
|
+
link.remove();
|
|
23
|
+
}, 1000);
|
|
24
|
+
msg = 'Saved ' + link.download + ' to your downloads.';
|
|
25
|
+
}
|
|
26
|
+
async function picked(event: Event) {
|
|
27
|
+
const file = (event.target as HTMLInputElement).files?.[0];
|
|
28
|
+
if (!file) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const text = await file.text();
|
|
32
|
+
if (
|
|
33
|
+
!confirm(
|
|
34
|
+
'Replace the progress in this app with the file you picked? Your settings stay as they are.',
|
|
35
|
+
)
|
|
36
|
+
) {
|
|
37
|
+
input.value = '';
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
msg = importText(text).msg;
|
|
41
|
+
input.value = '';
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<div class="sec">
|
|
46
|
+
<h2>Your data</h2>
|
|
47
|
+
<div class="card stack">
|
|
48
|
+
<p class="note">
|
|
49
|
+
Progress is kept in this browser only. Save a copy now and then — clearing the browser's data
|
|
50
|
+
or removing the app from your home screen would lose it. A file saved from Fretboard Fluency
|
|
51
|
+
can be brought in here too.
|
|
52
|
+
</p>
|
|
53
|
+
<div class="bar">
|
|
54
|
+
<button class="ghost" id="exportprog" onclick={exportNow}>Save a copy</button>
|
|
55
|
+
<button class="ghost" id="importprog" onclick={() => input.click()}>Bring one back</button>
|
|
56
|
+
<input
|
|
57
|
+
bind:this={input}
|
|
58
|
+
type="file"
|
|
59
|
+
accept="application/json,.json"
|
|
60
|
+
hidden
|
|
61
|
+
onchange={picked}
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
{#if msg}<p class="note" style="margin:0">{msg}</p>{/if}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The bass neck, plus — when it can be tapped — a layer of real HTML buttons
|
|
3
|
+
positioned over it in percentages of the drawing. Reliable everywhere,
|
|
4
|
+
unlike transparent SVG hit areas, and reachable from a keyboard.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { percentBox } from './overlay';
|
|
8
|
+
import { fbCell, fbDims, fretboardSVG, type FretboardOpts } from '../notation/fretboard';
|
|
9
|
+
import { STRINGS } from '../music/bass';
|
|
10
|
+
|
|
11
|
+
interface Props extends FretboardOpts {
|
|
12
|
+
/** restrict the tappable spots to these */
|
|
13
|
+
only?: { s: number; f: number }[] | null;
|
|
14
|
+
onTap?: (s: number, f: number, scrollLeft: number) => void;
|
|
15
|
+
}
|
|
16
|
+
const { onTap, only = null, ...options }: Props = $props();
|
|
17
|
+
|
|
18
|
+
const lo = $derived(options.lo || 0);
|
|
19
|
+
const hi = $derived(options.hi == null ? 12 : options.hi);
|
|
20
|
+
const size = $derived(fbDims(lo, hi));
|
|
21
|
+
const svg = $derived(fretboardSVG(options));
|
|
22
|
+
const spotKey = (string: number, fret: number) => string + ':' + fret;
|
|
23
|
+
const targets = $derived.by(() => {
|
|
24
|
+
if (!options.tap) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const allowed = only ? only.map((spot) => spotKey(spot.s, spot.f)) : null;
|
|
28
|
+
const out: { s: number; f: number; style: string; label: string }[] = [];
|
|
29
|
+
for (let string = 0; string < STRINGS.length; string++) {
|
|
30
|
+
if (options.onlyString != null && string !== options.onlyString) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
for (let fret = lo; fret <= hi; fret++) {
|
|
34
|
+
if (allowed && !allowed.includes(spotKey(string, fret))) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
out.push({
|
|
38
|
+
s: string,
|
|
39
|
+
f: fret,
|
|
40
|
+
label: STRINGS[string].n + ' string, fret ' + fret,
|
|
41
|
+
style: percentBox(fbCell(lo, string, fret), size.W, size.H),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function tap(event: MouseEvent, string: number, fret: number) {
|
|
49
|
+
const wrap = (event.currentTarget as HTMLElement).closest('.fbwrap') as HTMLElement | null;
|
|
50
|
+
onTap?.(string, fret, wrap ? wrap.scrollLeft : 0);
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<div class="fbstage">
|
|
55
|
+
{@html svg}
|
|
56
|
+
{#if options.tap}
|
|
57
|
+
<div class="taplayer">
|
|
58
|
+
{#each targets as target (spotKey(target.s, target.f))}
|
|
59
|
+
<button
|
|
60
|
+
class="hit"
|
|
61
|
+
type="button"
|
|
62
|
+
data-s={target.s}
|
|
63
|
+
data-f={target.f}
|
|
64
|
+
aria-label={target.label}
|
|
65
|
+
style={target.style}
|
|
66
|
+
onclick={(event) => tap(event, target.s, target.f)}
|
|
67
|
+
></button>
|
|
68
|
+
{/each}
|
|
69
|
+
</div>
|
|
70
|
+
{/if}
|
|
71
|
+
</div>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A piano keyboard. Real buttons over the drawing, the same trick the
|
|
3
|
+
fretboard uses, for the same reasons: transparent SVG hit areas are
|
|
4
|
+
unreliable on touch, and buttons can be reached from a keyboard. Black keys
|
|
5
|
+
come last so they sit above the white ones they overlap.
|
|
6
|
+
-->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { percentBox } from './overlay';
|
|
9
|
+
import { kbDims, kbKeys, kbSVG, type KeyboardOpts } from '../notation/keyboard';
|
|
10
|
+
import { midiName } from '../music/notes';
|
|
11
|
+
import { handleKey } from '../drills/runtime.svelte';
|
|
12
|
+
import { follow, followTarget } from './keyfollow.svelte';
|
|
13
|
+
|
|
14
|
+
interface Props extends KeyboardOpts {
|
|
15
|
+
/** taps answer (in a drill) or sound (anywhere else) */
|
|
16
|
+
tap?: boolean | 'play';
|
|
17
|
+
/** centre the board instead of letting it scroll */
|
|
18
|
+
fit?: boolean;
|
|
19
|
+
onKey?: (midi: number) => void;
|
|
20
|
+
}
|
|
21
|
+
const { tap = false, fit = true, onKey = handleKey, ...options }: Props = $props();
|
|
22
|
+
|
|
23
|
+
const size = $derived(kbDims(options));
|
|
24
|
+
const svg = $derived(kbSVG(options));
|
|
25
|
+
const keys = $derived(kbKeys(options));
|
|
26
|
+
const lit = $derived(
|
|
27
|
+
followTarget(
|
|
28
|
+
follow.lit,
|
|
29
|
+
keys.map((key) => key.midi),
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<div class="kbwrap" class:fit>
|
|
35
|
+
<div class="kbstage">
|
|
36
|
+
{@html svg}
|
|
37
|
+
{#if tap}
|
|
38
|
+
<div class="taplayer">
|
|
39
|
+
{#each keys as key (key.midi)}
|
|
40
|
+
<button
|
|
41
|
+
class="hit"
|
|
42
|
+
class:bk={key.black}
|
|
43
|
+
class:lit={lit === key.midi}
|
|
44
|
+
type="button"
|
|
45
|
+
data-kb={key.midi}
|
|
46
|
+
aria-label={midiName(key.midi)}
|
|
47
|
+
style={percentBox({ ...key, y: 0 }, size.W, size.H)}
|
|
48
|
+
onclick={() => onKey(key.midi)}
|
|
49
|
+
></button>
|
|
50
|
+
{/each}
|
|
51
|
+
</div>
|
|
52
|
+
{/if}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<!-- The metronome's controls: tempo, start/stop, feel, subdivision, tap tempo. -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import {
|
|
4
|
+
BPM_MAX,
|
|
5
|
+
BPM_MIN,
|
|
6
|
+
met,
|
|
7
|
+
metSetBpm,
|
|
8
|
+
metSetSub,
|
|
9
|
+
metTap,
|
|
10
|
+
metToggle,
|
|
11
|
+
} from '../audio/metronome.svelte';
|
|
12
|
+
const { compact = false }: { compact?: boolean } = $props();
|
|
13
|
+
|
|
14
|
+
/** From here up the buttons move two beats per minute at a time, below it one. */
|
|
15
|
+
const FAST_TEMPO = 120;
|
|
16
|
+
const stepDown = () => (met.bpm > FAST_TEMPO ? 2 : 1);
|
|
17
|
+
const stepUp = () => (met.bpm >= FAST_TEMPO ? 2 : 1);
|
|
18
|
+
const clampBpm = (bpm: number) => Math.max(BPM_MIN, Math.min(BPM_MAX, bpm));
|
|
19
|
+
const sliderValue = (event: Event) => +(event.target as HTMLInputElement).value;
|
|
20
|
+
const FEELS: [number, string][] = [
|
|
21
|
+
[4, '4/4'],
|
|
22
|
+
[3, '3/4'],
|
|
23
|
+
[6, '6/8'],
|
|
24
|
+
];
|
|
25
|
+
const SUBDIVISIONS: [number, string][] = [
|
|
26
|
+
[1, 'Quarters'],
|
|
27
|
+
[2, 'Eighths'],
|
|
28
|
+
[3, 'Triplets'],
|
|
29
|
+
[4, '16ths'],
|
|
30
|
+
];
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<div class="card met stack">
|
|
34
|
+
<div class="metrow">
|
|
35
|
+
<button class="mbtn" id="bpmdn" onclick={() => metSetBpm(met.bpm - stepDown())}>−</button>
|
|
36
|
+
<div class="bpm"><span class="v">{met.bpm}</span><span class="u">bpm</span></div>
|
|
37
|
+
<button class="mbtn" id="bpmup" onclick={() => metSetBpm(met.bpm + stepUp())}>+</button>
|
|
38
|
+
<span class="metdot {met.flash}" id="metdot"></span>
|
|
39
|
+
<span class="tick" id="metbeat">{met.on ? met.beatLabel : '—'}</span>
|
|
40
|
+
<span class="spacer"></span>
|
|
41
|
+
<button
|
|
42
|
+
class={met.on ? 'ghost on' : 'start'}
|
|
43
|
+
id="mettoggle"
|
|
44
|
+
style="width:auto"
|
|
45
|
+
onclick={metToggle}>{met.on ? 'Stop' : 'Start'}</button
|
|
46
|
+
>
|
|
47
|
+
</div>
|
|
48
|
+
<input
|
|
49
|
+
class="slider"
|
|
50
|
+
id="bpmslider"
|
|
51
|
+
type="range"
|
|
52
|
+
min={BPM_MIN}
|
|
53
|
+
max={BPM_MAX}
|
|
54
|
+
step="1"
|
|
55
|
+
value={met.bpm}
|
|
56
|
+
aria-label="tempo"
|
|
57
|
+
oninput={(event) => {
|
|
58
|
+
met.bpm = clampBpm(sliderValue(event));
|
|
59
|
+
}}
|
|
60
|
+
onchange={(event) => metSetBpm(sliderValue(event))}
|
|
61
|
+
/>
|
|
62
|
+
{#if !compact}
|
|
63
|
+
<div class="bar">
|
|
64
|
+
<span class="hint">Feel</span>
|
|
65
|
+
{#each FEELS as [beats, title] (beats)}
|
|
66
|
+
<button
|
|
67
|
+
class="chip sm"
|
|
68
|
+
data-met="beats"
|
|
69
|
+
data-val={beats}
|
|
70
|
+
aria-pressed={met.beats === beats}
|
|
71
|
+
onclick={() => {
|
|
72
|
+
met.beats = beats;
|
|
73
|
+
}}>{title}</button
|
|
74
|
+
>
|
|
75
|
+
{/each}
|
|
76
|
+
</div>
|
|
77
|
+
<div class="bar">
|
|
78
|
+
<span class="hint">Click</span>
|
|
79
|
+
{#each SUBDIVISIONS as [sub, title] (sub)}
|
|
80
|
+
<button
|
|
81
|
+
class="chip sm"
|
|
82
|
+
data-met="sub"
|
|
83
|
+
data-val={sub}
|
|
84
|
+
aria-pressed={met.sub === sub}
|
|
85
|
+
onclick={() => metSetSub(sub)}>{title}</button
|
|
86
|
+
>
|
|
87
|
+
{/each}
|
|
88
|
+
</div>
|
|
89
|
+
<div class="bar">
|
|
90
|
+
<button
|
|
91
|
+
class="ghost"
|
|
92
|
+
class:on={met.subClick}
|
|
93
|
+
id="metsubclick"
|
|
94
|
+
onclick={() => {
|
|
95
|
+
met.subClick = !met.subClick;
|
|
96
|
+
}}>{met.subClick ? 'Subdivisions audible' : 'Subdivisions silent'}</button
|
|
97
|
+
>
|
|
98
|
+
<button class="ghost" id="mettap" onclick={metTap}>Tap tempo</button>
|
|
99
|
+
</div>
|
|
100
|
+
{/if}
|
|
101
|
+
</div>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!-- The bar at the top of a reference page: back to Learn, and back to the drill if one is in progress. -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { drill, armListen } from '../drills/runtime.svelte';
|
|
4
|
+
import { go } from '../nav.svelte';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
title: string;
|
|
8
|
+
back?: { label: string; to: string };
|
|
9
|
+
/** offer "Back to the drill" when a question is open */
|
|
10
|
+
resume?: boolean;
|
|
11
|
+
}
|
|
12
|
+
const { title, back = { label: 'Learn', to: 'learn' }, resume = true }: Props = $props();
|
|
13
|
+
|
|
14
|
+
function toDrill() {
|
|
15
|
+
go('drill', { id: drill.id });
|
|
16
|
+
if (drill.q && drill.q.kind === 'play' && !drill.answered) {
|
|
17
|
+
armListen();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<div class="bar">
|
|
23
|
+
<button class="ghost" id="backlearn" onclick={() => go(back.to)}>← {back.label}</button>
|
|
24
|
+
{#if resume && drill.q}<button class="ghost on" id="resume" onclick={toDrill}
|
|
25
|
+
>Back to the drill</button
|
|
26
|
+
>{/if}
|
|
27
|
+
<span class="spacer"></span><span class="tick">{title}</span>
|
|
28
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- A bar of rhythm on its one-line staff, scrolling sideways if it is wider than the screen. -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { rhythmSVG, type RhythmOpts } from '../notation/rhythmsvg';
|
|
4
|
+
import type { Bar } from '../music/rhythm';
|
|
5
|
+
|
|
6
|
+
const { bar, ...options }: { bar: Bar } & RhythmOpts = $props();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<div class="rhwrap"><div class="fbstage">{@html rhythmSVG(bar, options)}</div></div>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The frame every Honmoon app sits in: the header with its tabs, the scrolling
|
|
3
|
+
main column, and the error bar. If anything throws it says so on screen —
|
|
4
|
+
a silent exception looks exactly like a dead button, and that is not a
|
|
5
|
+
thing to debug twice.
|
|
6
|
+
-->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import type { Snippet } from 'svelte';
|
|
9
|
+
import { onMount } from 'svelte';
|
|
10
|
+
import { honmoon } from '../store/honmoon.svelte';
|
|
11
|
+
import { met, metStop } from '../audio/metronome.svelte';
|
|
12
|
+
import { go, nav, setScroller } from '../nav.svelte';
|
|
13
|
+
import { installTapFix } from './tapfix';
|
|
14
|
+
import type { Tab } from './types';
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
appName: string;
|
|
18
|
+
/** the app's mark beside its name: an SVG drawn in currentColor (the accent) */
|
|
19
|
+
icon?: string;
|
|
20
|
+
tabs: Tab[];
|
|
21
|
+
/** which tab owns a screen no tab lists */
|
|
22
|
+
fallbackTab?: string;
|
|
23
|
+
/** override for screens whose tab depends on where they were opened from */
|
|
24
|
+
tabFor?: (view: string) => string | null;
|
|
25
|
+
children: Snippet;
|
|
26
|
+
}
|
|
27
|
+
const { appName, icon, tabs, fallbackTab, tabFor, children }: Props = $props();
|
|
28
|
+
|
|
29
|
+
let main: HTMLElement;
|
|
30
|
+
let oops = $state('');
|
|
31
|
+
|
|
32
|
+
const active = $derived.by(() => {
|
|
33
|
+
const view = nav.view.name;
|
|
34
|
+
const custom = tabFor?.(view);
|
|
35
|
+
if (custom) {
|
|
36
|
+
return custom;
|
|
37
|
+
}
|
|
38
|
+
const owner = tabs.find((tab) => tab.views.includes(view));
|
|
39
|
+
return owner?.id ?? fallbackTab ?? tabs[0]?.id;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/* The stylesheet already knows how to be forced one way or the other; this
|
|
43
|
+
just sets the attribute it looks for, and removes it to follow the device. */
|
|
44
|
+
$effect(() => {
|
|
45
|
+
const theme = honmoon.settings.theme || 'auto';
|
|
46
|
+
if (theme === 'auto') {
|
|
47
|
+
document.documentElement.removeAttribute('data-theme');
|
|
48
|
+
} else {
|
|
49
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
onMount(() => {
|
|
54
|
+
installTapFix();
|
|
55
|
+
setScroller(main);
|
|
56
|
+
/* an uncaught error shows in a strip at the bottom: there is no console on a phone */
|
|
57
|
+
const onError = (event: ErrorEvent) => {
|
|
58
|
+
oops = '⚠ ' + ((event && event.message) || 'script error');
|
|
59
|
+
};
|
|
60
|
+
const onRejection = (event: PromiseRejectionEvent) => {
|
|
61
|
+
const reason = event && event.reason;
|
|
62
|
+
oops = '⚠ promise: ' + ((reason && (reason.message || reason.code)) || reason);
|
|
63
|
+
};
|
|
64
|
+
window.addEventListener('error', onError);
|
|
65
|
+
window.addEventListener('unhandledrejection', onRejection);
|
|
66
|
+
return () => {
|
|
67
|
+
window.removeEventListener('error', onError);
|
|
68
|
+
window.removeEventListener('unhandledrejection', onRejection);
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<div id="app">
|
|
74
|
+
<header>
|
|
75
|
+
<div class="brand">
|
|
76
|
+
{#if icon}<span class="mk icon">{@html icon}</span>{:else}<span class="mk"></span>{/if}<span
|
|
77
|
+
class="bt">{appName}</span
|
|
78
|
+
>
|
|
79
|
+
</div>
|
|
80
|
+
{#if met.on}
|
|
81
|
+
<button class="hbtn metchip" id="metchip" onclick={metStop}>♩{met.bpm} ■</button>
|
|
82
|
+
{/if}
|
|
83
|
+
{#each tabs as tab (tab.id)}
|
|
84
|
+
<button
|
|
85
|
+
class="hbtn"
|
|
86
|
+
id={tab.id}
|
|
87
|
+
aria-current={active === tab.id ? 'true' : 'false'}
|
|
88
|
+
onclick={() => go(tab.to)}>{@html tab.label}</button
|
|
89
|
+
>
|
|
90
|
+
{/each}
|
|
91
|
+
</header>
|
|
92
|
+
<main id="main" bind:this={main}>
|
|
93
|
+
{@render children()}
|
|
94
|
+
</main>
|
|
95
|
+
<div id="oops" hidden={!oops}>{oops}</div>
|
|
96
|
+
</div>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!-- One staff carrying a run of notes, each labelled in both systems, optionally tappable. -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { percentBox } from './overlay';
|
|
4
|
+
import type { Pref } from '../store/types';
|
|
5
|
+
import { RUN, runWidth, runX0, staffRunSVG } from '../notation/staff';
|
|
6
|
+
import { noteName, pcOf } from '../music/notes';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
midis: number[];
|
|
10
|
+
pref: Pref;
|
|
11
|
+
sel?: number;
|
|
12
|
+
tappable?: boolean;
|
|
13
|
+
clef?: string;
|
|
14
|
+
onNote?: (m: number) => void;
|
|
15
|
+
}
|
|
16
|
+
const { midis, pref, sel = -1, tappable = true, clef = 'bass', onNote }: Props = $props();
|
|
17
|
+
|
|
18
|
+
const svg = $derived(staffRunSVG(midis, pref, sel, true, clef));
|
|
19
|
+
const width = $derived(runWidth(midis.length, clef));
|
|
20
|
+
const firstX = $derived(runX0(clef));
|
|
21
|
+
/** The tap target for the note at an index: one note's width, full height. */
|
|
22
|
+
const noteBox = (index: number) => ({
|
|
23
|
+
x: firstX + index * RUN.dx - RUN.dx / 2 + 2,
|
|
24
|
+
y: 0,
|
|
25
|
+
w: RUN.dx,
|
|
26
|
+
h: 1,
|
|
27
|
+
});
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<div class="fbstage">
|
|
31
|
+
{@html svg}
|
|
32
|
+
{#if tappable}
|
|
33
|
+
<div class="taplayer">
|
|
34
|
+
{#each midis as midi, index (index)}
|
|
35
|
+
<button
|
|
36
|
+
class="hit"
|
|
37
|
+
type="button"
|
|
38
|
+
data-refn={midi}
|
|
39
|
+
aria-label={'note ' + noteName(pcOf(midi), 'en', pref)}
|
|
40
|
+
style={percentBox(noteBox(index), width, 1)}
|
|
41
|
+
onclick={() => onNote?.(midi)}
|
|
42
|
+
></button>
|
|
43
|
+
{/each}
|
|
44
|
+
</div>
|
|
45
|
+
{/if}
|
|
46
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!-- The four-up row of big numbers at the top of Practice, Stats and the summaries. -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
const { items }: { items: { v: string | number; k: string }[] } = $props();
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<div class="statrow">
|
|
7
|
+
{#each items as item (item.k)}
|
|
8
|
+
<div>
|
|
9
|
+
<div class="v">{item.v}</div>
|
|
10
|
+
<div class="k">{item.k}</div>
|
|
11
|
+
</div>
|
|
12
|
+
{/each}
|
|
13
|
+
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Action } from 'svelte/action';
|
|
2
|
+
|
|
3
|
+
export interface HScrollParams {
|
|
4
|
+
/** where to centre, as a fraction of the content width */
|
|
5
|
+
frac?: number | null;
|
|
6
|
+
/** or an exact scroll position to restore */
|
|
7
|
+
left?: number | null;
|
|
8
|
+
/** re-apply whenever this changes (a new question, a new selection) */
|
|
9
|
+
key?: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Brings the interesting part of a wide board into view — the marked fret,
|
|
13
|
+
the selected note — instead of leaving it off to the right on a phone. */
|
|
14
|
+
export const hscroll: Action<HTMLElement, HScrollParams> = (node, params) => {
|
|
15
|
+
let lastKey: unknown = Symbol('none');
|
|
16
|
+
function apply(options: HScrollParams) {
|
|
17
|
+
if (options.key === lastKey) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
lastKey = options.key;
|
|
21
|
+
requestAnimationFrame(() => {
|
|
22
|
+
if (options.left != null) {
|
|
23
|
+
node.scrollLeft = options.left;
|
|
24
|
+
} else if (options.frac != null) {
|
|
25
|
+
node.scrollLeft = Math.max(0, options.frac * node.scrollWidth - node.clientWidth / 2);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
apply(params);
|
|
30
|
+
return { update: apply };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** The fraction of a board's width at which fret `f` sits, for a board drawn from `lo` to `hi`. */
|
|
34
|
+
export function fretFrac(fret: number, lo: number, hi: number): number {
|
|
35
|
+
return (fret - lo + 0.5) / (hi - lo + 1);
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { pcOf } from '../music/notes';
|
|
2
|
+
|
|
3
|
+
/* follow mode: light keys from outside while something plays. The board shows
|
|
4
|
+
one octave; a line that runs outside it still has a key of that name, so a
|
|
5
|
+
note off the board lights the key with the same name rather than nothing. */
|
|
6
|
+
export const follow: { lit: number | null } = $state({ lit: null });
|
|
7
|
+
let timers: ReturnType<typeof setTimeout>[] = [];
|
|
8
|
+
|
|
9
|
+
/** The last key stays lit this long after its note. */
|
|
10
|
+
const LINGER_MS = 420;
|
|
11
|
+
|
|
12
|
+
/** Light each note's key in turn, `step` seconds apart. */
|
|
13
|
+
export function kbFollow(midis: number[], step: number): void {
|
|
14
|
+
kbFollowStop();
|
|
15
|
+
midis.forEach((midi, i) => {
|
|
16
|
+
timers.push(
|
|
17
|
+
setTimeout(
|
|
18
|
+
() => {
|
|
19
|
+
follow.lit = midi;
|
|
20
|
+
},
|
|
21
|
+
i * step * 1000,
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
timers.push(
|
|
26
|
+
setTimeout(
|
|
27
|
+
() => {
|
|
28
|
+
follow.lit = null;
|
|
29
|
+
},
|
|
30
|
+
midis.length * step * 1000 + LINGER_MS,
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
export function kbFollowStop(): void {
|
|
35
|
+
timers.forEach(clearTimeout);
|
|
36
|
+
timers = [];
|
|
37
|
+
follow.lit = null;
|
|
38
|
+
}
|
|
39
|
+
/** Which of the drawn keys the followed note lights. */
|
|
40
|
+
export function followTarget(lit: number | null, drawn: number[]): number | null {
|
|
41
|
+
if (lit == null) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (drawn.includes(lit)) {
|
|
45
|
+
return lit;
|
|
46
|
+
}
|
|
47
|
+
return drawn.find((key) => pcOf(key) === pcOf(lit)) ?? null;
|
|
48
|
+
}
|