Phase 4: drone + T6/E-series + polish + deploy + README
Add drone synth + DronePlayer, wire T6 (mode over drone) and E1-E3 info pages (E3 carries a drone). Screen Wake Lock while any click/drone plays. Vitest suite for scheduler timing (no drift over 5 min) and music-layer correctness (triads, CAGED, diatonic, fretboard). Deploy artifacts: public/_redirects, multi-stage Dockerfile + nginx.conf (SPA fallback, immutable asset caching, gzip), .dockerignore. README covering build, Cloudflare Pages / nginx / Docker deploy, and the deep-link URL format. prefers-reduced-motion honored throughout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.vite
|
||||||
|
*.log
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
# --- Build stage ---------------------------------------------------------------
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Base path baked at build time. Default '/' (dedicated domain).
|
||||||
|
# For a subpath deploy: docker build --build-arg VITE_BASE=/practice/ .
|
||||||
|
ARG VITE_BASE=/
|
||||||
|
ENV VITE_BASE=$VITE_BASE
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Serve stage ---------------------------------------------------------------
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Guitar Practice Helper
|
||||||
|
|
||||||
|
A static web app that turns the drill codes from my Obsidian practice wiki (R1, F2,
|
||||||
|
T3, …) into interactive helper pages: click tracks, pattern grids, chord & fretboard
|
||||||
|
diagrams, and a drone. Open a drill link from the wiki on phone or desktop, press
|
||||||
|
play, practise for ~5 minutes. No accounts, no backend, no analytics.
|
||||||
|
|
||||||
|
Built with **Svelte 5 + Vite** as a fully static SPA. Music theory is powered by
|
||||||
|
[`tonal`](https://github.com/tonaljs/tonal). Timing uses a Web Audio lookahead
|
||||||
|
scheduler so the metronome never drifts.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # local dev server
|
||||||
|
npm run check # svelte-check (typecheck)
|
||||||
|
npm test # vitest (scheduler timing + music-theory correctness)
|
||||||
|
npm run build # typecheck + production build to dist/
|
||||||
|
npm run preview # serve the built dist/ locally
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
The build is fully static (`dist/`). Because the app uses **path routing**
|
||||||
|
(clean URLs like `/drill/R3`), the host must serve `index.html` for unknown paths
|
||||||
|
(SPA fallback), and the base path is baked in at build time.
|
||||||
|
|
||||||
|
### Base path
|
||||||
|
|
||||||
|
- Dedicated domain / served at the root: default, `base = '/'`.
|
||||||
|
- Subpath (e.g. `https://example.com/practice/`): build with
|
||||||
|
`VITE_BASE=/practice/ npm run build`.
|
||||||
|
|
||||||
|
### Cloudflare Pages
|
||||||
|
|
||||||
|
Framework preset: none. Build command `npm run build`, output directory `dist`.
|
||||||
|
`public/_redirects` (already included) provides the SPA fallback:
|
||||||
|
|
||||||
|
```
|
||||||
|
/* /index.html 200
|
||||||
|
```
|
||||||
|
|
||||||
|
For a subpath, set the `VITE_BASE` environment variable in the Pages build settings.
|
||||||
|
|
||||||
|
### Docker + nginx
|
||||||
|
|
||||||
|
A multi-stage `Dockerfile` (Node build → `nginx:alpine`) with the correct config
|
||||||
|
(`nginx/default.conf`: SPA fallback via `try_files`, immutable caching for hashed
|
||||||
|
assets, `no-cache` for the HTML shell, gzip):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t guitar-practice . # root deploy
|
||||||
|
docker build --build-arg VITE_BASE=/practice/ -t guitar-practice . # subpath
|
||||||
|
docker run -p 8080:80 guitar-practice # http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing nginx server
|
||||||
|
|
||||||
|
Serve `dist/` and add the SPA fallback to your location block:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deep-link URL format
|
||||||
|
|
||||||
|
The whole point — link to any drill directly from the wiki:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://<host>/<base>drill/<CODE>
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional query parameters override the stored/default settings:
|
||||||
|
|
||||||
|
| Param | Applies to | Example |
|
||||||
|
|------------|-----------------------|----------------|
|
||||||
|
| `bpm` | click-track drills | `?bpm=66` |
|
||||||
|
| `variant` | drills with variants | `?variant=2-3-2` |
|
||||||
|
| `key` | T2, T4 | `?key=D` |
|
||||||
|
| `root` | T3, T5, T6, L4 | `?root=A` |
|
||||||
|
| `interval` | T5 | `?interval=P5` |
|
||||||
|
| `mode` | T6 | `?mode=dorian` |
|
||||||
|
| `pair` | R4 | `?pair=g-c` |
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://example.com/practice/drill/R3?bpm=66&variant=2-3-2
|
||||||
|
https://example.com/practice/drill/T6?root=A&mode=dorian
|
||||||
|
https://example.com/practice/drill/R4?pair=f-bm
|
||||||
|
```
|
||||||
|
|
||||||
|
Unknown codes resolve to a friendly “unknown drill” page linking back to the index
|
||||||
|
(`/` shows all drills grouped by category).
|
||||||
|
|
||||||
|
## Drills
|
||||||
|
|
||||||
|
| Code | Name | Helper |
|
||||||
|
|------|------|--------|
|
||||||
|
| R1 | 16th-note strum, mute the off-beats | metronome + strum grid |
|
||||||
|
| R2 | Accent patterns | metronome + strum grid |
|
||||||
|
| R3 | 7/8 loop (drop to 4/4 live) | metronome + strum grid |
|
||||||
|
| R4 | One-minute chord changes | countdown + tap counter + chord diagrams |
|
||||||
|
| R5 | Palm-mute chugging | metronome + strum grid |
|
||||||
|
| F1 | Travis picking | metronome + picking lane + chords |
|
||||||
|
| F2 | PIMA arpeggio over I–V–vi–IV | metronome + picking lane + chords |
|
||||||
|
| F3 | Thumb-independent bass + melody | metronome + picking lane |
|
||||||
|
| T1 | Name every note on one string | metronome prompter + fretboard |
|
||||||
|
| T2 | CAGED shapes | fretboard, prev/next |
|
||||||
|
| T3 | Triads on a 3-string set | chord diagrams, inversions |
|
||||||
|
| T4 | Diatonic chords of a key | chord diagram row |
|
||||||
|
| T5 | Intervals from a root | fretboard |
|
||||||
|
| T6 | Mode over a drone | drone + fretboard |
|
||||||
|
| L1 | Alternate picking builder | metronome + picking lane |
|
||||||
|
| L2 | Legato runs (triplets) | metronome + picking lane |
|
||||||
|
| L3 | String skipping | metronome + picking lane |
|
||||||
|
| L4 | Bends and vibrato | reference tone |
|
||||||
|
| E1 | Interval recognition | info |
|
||||||
|
| E2 | Chord quality recognition | info |
|
||||||
|
| E3 | Play along by ear | info + drone |
|
||||||
|
|
||||||
|
## Keyboard (desktop)
|
||||||
|
|
||||||
|
- **Space** — play / pause
|
||||||
|
- **↑ / ↓** — BPM ±2
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Audio unlocks on the first tap/keypress (mobile autoplay policy).
|
||||||
|
- Per-drill settings (BPM, variant, key, best scores) persist to `localStorage`
|
||||||
|
under the `gph:` namespace.
|
||||||
|
- Screen Wake Lock keeps the phone awake while a click or drone is playing.
|
||||||
|
- Respects `prefers-reduced-motion`: the beat still advances, without the glow/swing.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
# Hashed build assets are immutable — cache hard.
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Never cache the HTML shell so new deploys are picked up.
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback: path-routed deep links (e.g. /drill/R3) must serve the app.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/* /index.html 200
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte'
|
||||||
|
import { Note } from 'tonal'
|
||||||
|
import { CHROMATIC_ROOTS } from '../../lib/music/theory'
|
||||||
|
import { Drone } from '../../lib/audio/drone'
|
||||||
|
import { keepAwake, allowSleep } from '../../lib/wakeLock'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
root?: string
|
||||||
|
octave?: number
|
||||||
|
}
|
||||||
|
// Bindable so a parent (T6) can tie a fretboard to the drone's root.
|
||||||
|
let { root = $bindable('A'), octave = $bindable(3) }: Props = $props()
|
||||||
|
|
||||||
|
let volume = $state(0.35)
|
||||||
|
let playing = $state(false)
|
||||||
|
const drone = new Drone()
|
||||||
|
|
||||||
|
let midi = $derived(Note.midi(`${root}${octave}`) ?? 57)
|
||||||
|
|
||||||
|
// Follow root/octave changes live while playing.
|
||||||
|
$effect(() => {
|
||||||
|
if (playing) drone.setNote(midi)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
if (playing) {
|
||||||
|
drone.stop()
|
||||||
|
playing = false
|
||||||
|
allowSleep()
|
||||||
|
} else {
|
||||||
|
await drone.start(midi)
|
||||||
|
drone.setVolume(volume)
|
||||||
|
playing = true
|
||||||
|
keepAwake()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onVol(e: Event) {
|
||||||
|
volume = Number((e.target as HTMLInputElement).value)
|
||||||
|
drone.setVolume(volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
drone.stop()
|
||||||
|
allowSleep()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="drone">
|
||||||
|
<button class="play" class:playing onclick={toggle} aria-label={playing ? 'Stop drone' : 'Play drone'}>
|
||||||
|
<span class="glyph">{playing ? '❚❚' : '▶'}</span>
|
||||||
|
{playing ? 'Stop drone' : 'Play drone'}
|
||||||
|
<span class="note num">{root}<sub>{octave}</sub></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<span class="eyebrow">Root</span>
|
||||||
|
<div class="chips">
|
||||||
|
{#each CHROMATIC_ROOTS as r}
|
||||||
|
<button class="chip" class:sel={r === root} onclick={() => (root = r)}>{r}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row inline">
|
||||||
|
<div class="oct">
|
||||||
|
<span class="eyebrow">Octave</span>
|
||||||
|
<div class="chips">
|
||||||
|
{#each [2, 3, 4] as o}
|
||||||
|
<button class="chip" class:sel={o === octave} onclick={() => (octave = o)}>{o}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="vol">
|
||||||
|
<span class="eyebrow">Volume</span>
|
||||||
|
<input type="range" min="0" max="1" step="0.01" value={volume} oninput={onVol} aria-label="Drone volume" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.drone {
|
||||||
|
background: var(--walnut);
|
||||||
|
border: 1px solid var(--fretwire-dim);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.play {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.9rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--step-2);
|
||||||
|
background: var(--tubeglow);
|
||||||
|
color: #201607;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.play.playing { background: transparent; color: var(--tubeglow); border: 1px solid var(--tubeglow-soft); }
|
||||||
|
.note { margin-left: 0.4rem; font-weight: 500; }
|
||||||
|
.note sub { font-size: 0.6em; }
|
||||||
|
.row { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
.row.inline { flex-direction: row; align-items: flex-end; justify-content: space-between; gap: 1rem; }
|
||||||
|
.chips { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||||
|
.chip {
|
||||||
|
min-width: 2.5rem;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
background: var(--walnut-hi);
|
||||||
|
border: 1px solid var(--fretwire-dim);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--inlay-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--step-0);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.chip.sel { color: var(--tubeglow); border-color: var(--tubeglow-soft); }
|
||||||
|
.vol { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; max-width: 45%; }
|
||||||
|
.vol input { accent-color: var(--tubeglow); width: 100%; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { InfoDrill } from '../../drills'
|
||||||
|
import DrillLayout from '../common/DrillLayout.svelte'
|
||||||
|
import DronePlayer from '../drone/DronePlayer.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
drill: InfoDrill
|
||||||
|
params: URLSearchParams
|
||||||
|
}
|
||||||
|
// params kept for a consistent view signature.
|
||||||
|
let { drill }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrillLayout code={drill.code} name={drill.name} category={drill.category} description={drill.description}>
|
||||||
|
{#if drill.hasDrone}
|
||||||
|
<DronePlayer />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="prose">
|
||||||
|
{#each drill.body as para}
|
||||||
|
<p>{para}</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</DrillLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.prose {
|
||||||
|
color: var(--inlay-dim);
|
||||||
|
max-width: 52ch;
|
||||||
|
}
|
||||||
|
.prose p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { TheoryDrill } from '../../drills'
|
||||||
|
import { MODES, scaleChromas, scaleNotes, chroma } from '../../lib/music/theory'
|
||||||
|
import { positionsForChromas } from '../../lib/music/fretboard'
|
||||||
|
import * as storage from '../../lib/storage'
|
||||||
|
import DrillLayout from '../common/DrillLayout.svelte'
|
||||||
|
import DronePlayer from '../drone/DronePlayer.svelte'
|
||||||
|
import Fretboard from '../diagrams/Fretboard.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
drill: TheoryDrill
|
||||||
|
params: URLSearchParams
|
||||||
|
}
|
||||||
|
let { drill, params }: Props = $props()
|
||||||
|
|
||||||
|
const initRoot = () => params.get('root') ?? storage.getString(`${drill.code}:root`, 'A')
|
||||||
|
const initMode = () => params.get('mode') ?? storage.getString(`${drill.code}:mode`, 'dorian')
|
||||||
|
|
||||||
|
let root = $state(initRoot())
|
||||||
|
let octave = $state(3)
|
||||||
|
let modeId = $state(initMode())
|
||||||
|
|
||||||
|
let mode = $derived(MODES.find((m) => m.id === modeId) ?? MODES[1])
|
||||||
|
let notes = $derived(scaleNotes(root, mode.id))
|
||||||
|
let positions = $derived(positionsForChromas(new Set(scaleChromas(root, mode.id)), 12))
|
||||||
|
let rootPos = $derived(positionsForChromas(new Set([chroma(root)]), 12))
|
||||||
|
|
||||||
|
// Persist selections (root also comes from the bound DronePlayer).
|
||||||
|
$effect(() => {
|
||||||
|
storage.setString(`${drill.code}:root`, root)
|
||||||
|
storage.setString(`${drill.code}:mode`, modeId)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrillLayout code={drill.code} name={drill.name} category={drill.category} description={drill.description}>
|
||||||
|
<DronePlayer bind:root bind:octave />
|
||||||
|
|
||||||
|
<div class="modes">
|
||||||
|
<span class="eyebrow">Mode</span>
|
||||||
|
<div class="chips">
|
||||||
|
{#each MODES as m}
|
||||||
|
<button class="chip" class:sel={m.id === modeId} onclick={() => (modeId = m.id)}>{m.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="notes num">{root} {mode.label}: {notes.join(' · ')}</p>
|
||||||
|
|
||||||
|
<Fretboard {positions} roots={rootPos} fromFret={0} toFret={12} labels />
|
||||||
|
</DrillLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modes { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
|
||||||
|
.chips { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||||
|
.chip {
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
background: var(--walnut);
|
||||||
|
border: 1px solid var(--fretwire-dim);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--inlay-dim);
|
||||||
|
font-size: var(--step-0);
|
||||||
|
}
|
||||||
|
.chip.sel { color: var(--tubeglow); border-color: var(--tubeglow-soft); background: color-mix(in srgb, var(--tubeglow) 10%, transparent); }
|
||||||
|
.notes { color: var(--inlay-dim); font-size: var(--step-0); margin: 0 0 1rem; }
|
||||||
|
</style>
|
||||||
+51
-1
@@ -467,6 +467,13 @@ const T5: TheoryDrill = {
|
|||||||
description: 'See a root and an interval shape on adjacent strings.',
|
description: 'See a root and an interval shape on adjacent strings.',
|
||||||
kind: 'intervals',
|
kind: 'intervals',
|
||||||
}
|
}
|
||||||
|
const T6: TheoryDrill = {
|
||||||
|
code: 'T6',
|
||||||
|
name: 'Mode over a drone',
|
||||||
|
category: 'Theory',
|
||||||
|
description: 'Hold a root drone and improvise the selected mode against it.',
|
||||||
|
kind: 'mode-drone',
|
||||||
|
}
|
||||||
|
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
// LEAD — reference
|
// LEAD — reference
|
||||||
@@ -480,13 +487,56 @@ const L4: TheoryDrill = {
|
|||||||
kind: 'reference-tone',
|
kind: 'reference-tone',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
// EAR
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
const E1: InfoDrill = {
|
||||||
|
code: 'E1',
|
||||||
|
name: 'Interval recognition',
|
||||||
|
category: 'Ear',
|
||||||
|
description: 'Train your ear to name intervals by sound.',
|
||||||
|
kind: 'info',
|
||||||
|
body: [
|
||||||
|
'Sing or hum a reference, then play two notes and name the interval between them before checking.',
|
||||||
|
'Anchor each interval to a song you know (e.g. a perfect 4th = the opening of “Here Comes the Bride”).',
|
||||||
|
'Five minutes daily beats a long session once a week. Use the T5 helper to see interval shapes on the neck.',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const E2: InfoDrill = {
|
||||||
|
code: 'E2',
|
||||||
|
name: 'Chord quality recognition',
|
||||||
|
category: 'Ear',
|
||||||
|
description: 'Hear major vs minor vs dominant vs diminished.',
|
||||||
|
kind: 'info',
|
||||||
|
body: [
|
||||||
|
'Play a random chord and name its quality before looking: major, minor, dominant 7, diminished.',
|
||||||
|
'Start with just major vs minor. Add sevenths once that is automatic.',
|
||||||
|
'Alternate between playing and just listening with your eyes closed.',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const E3: InfoDrill = {
|
||||||
|
code: 'E3',
|
||||||
|
name: 'Play along by ear',
|
||||||
|
category: 'Ear',
|
||||||
|
description: 'Find melodies and changes over a drone or backing track.',
|
||||||
|
kind: 'info',
|
||||||
|
hasDrone: true,
|
||||||
|
body: [
|
||||||
|
'Hold the drone below and find scale tones and simple melodies against it by ear.',
|
||||||
|
'When you are ready, swap the drone for a real backing track and work out the changes.',
|
||||||
|
'Backing tracks: search your favourite source for the key and feel you want, then loop it while you explore.',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
// ==============================================================================
|
// ==============================================================================
|
||||||
|
|
||||||
export const DRILLS: Drill[] = [
|
export const DRILLS: Drill[] = [
|
||||||
R1, R2, R3, R4, R5,
|
R1, R2, R3, R4, R5,
|
||||||
F1, F2, F3,
|
F1, F2, F3,
|
||||||
T1, T2, T3, T4, T5,
|
T1, T2, T3, T4, T5, T6,
|
||||||
L1, L2, L3, L4,
|
L1, L2, L3, L4,
|
||||||
|
E1, E2, E3,
|
||||||
]
|
]
|
||||||
|
|
||||||
export const DRILLS_BY_CODE: Record<string, Drill> = Object.fromEntries(
|
export const DRILLS_BY_CODE: Record<string, Drill> = Object.fromEntries(
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { stepDuration } from './Metronome'
|
||||||
|
|
||||||
|
describe('stepDuration', () => {
|
||||||
|
it('computes seconds per step from bpm and subdivision', () => {
|
||||||
|
expect(stepDuration(120, 1)).toBeCloseTo(0.5, 10) // quarter at 120
|
||||||
|
expect(stepDuration(120, 2)).toBeCloseTo(0.25, 10) // eighths
|
||||||
|
expect(stepDuration(120, 4)).toBeCloseTo(0.125, 10) // sixteenths
|
||||||
|
expect(stepDuration(90, 3)).toBeCloseTo(60 / 90 / 3, 10) // eighth triplets
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('scheduler timing does not drift', () => {
|
||||||
|
// The scheduler advances nextNoteTime by repeated addition (as in Metronome.advance).
|
||||||
|
// Verify accumulated error stays negligible over 5 minutes of continuous play.
|
||||||
|
it('stays within 1µs of analytic time over 5 minutes', () => {
|
||||||
|
const bpm = 300
|
||||||
|
const stepsPerBeat = 4
|
||||||
|
const dur = stepDuration(bpm, stepsPerBeat)
|
||||||
|
const totalSteps = Math.ceil((5 * 60) / dur) // ~6000 steps
|
||||||
|
|
||||||
|
let t = 0
|
||||||
|
for (let i = 0; i < totalSteps; i++) t += dur
|
||||||
|
|
||||||
|
const analytic = totalSteps * dur
|
||||||
|
expect(Math.abs(t - analytic)).toBeLessThan(1e-6)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Sustained drone pad: two slightly detuned oscillators through a lowpass filter.
|
||||||
|
// Smooth attack/release avoids clicks. Frequency and volume can change while playing.
|
||||||
|
|
||||||
|
import { audio } from './AudioEngine'
|
||||||
|
import { midiToFreq } from './tone'
|
||||||
|
|
||||||
|
export class Drone {
|
||||||
|
isPlaying = false
|
||||||
|
private osc1: OscillatorNode | null = null
|
||||||
|
private osc2: OscillatorNode | null = null
|
||||||
|
private gain: GainNode | null = null
|
||||||
|
private filter: BiquadFilterNode | null = null
|
||||||
|
private level = 0.35
|
||||||
|
|
||||||
|
async start(midi: number): Promise<void> {
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.setNote(midi)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await audio.unlock()
|
||||||
|
const ctx = audio.context
|
||||||
|
const now = ctx.currentTime
|
||||||
|
const freq = midiToFreq(midi)
|
||||||
|
|
||||||
|
this.osc1 = ctx.createOscillator()
|
||||||
|
this.osc2 = ctx.createOscillator()
|
||||||
|
this.gain = ctx.createGain()
|
||||||
|
this.filter = ctx.createBiquadFilter()
|
||||||
|
|
||||||
|
this.osc1.type = 'sawtooth'
|
||||||
|
this.osc2.type = 'sawtooth'
|
||||||
|
this.osc1.frequency.value = freq
|
||||||
|
this.osc2.frequency.value = freq
|
||||||
|
this.osc1.detune.value = -6
|
||||||
|
this.osc2.detune.value = 6
|
||||||
|
|
||||||
|
this.filter.type = 'lowpass'
|
||||||
|
this.filter.frequency.value = 900
|
||||||
|
this.filter.Q.value = 0.8
|
||||||
|
|
||||||
|
this.gain.gain.setValueAtTime(0.0001, now)
|
||||||
|
this.gain.gain.exponentialRampToValueAtTime(this.level, now + 0.25)
|
||||||
|
|
||||||
|
this.osc1.connect(this.filter)
|
||||||
|
this.osc2.connect(this.filter)
|
||||||
|
this.filter.connect(this.gain)
|
||||||
|
this.gain.connect(audio.master)
|
||||||
|
|
||||||
|
this.osc1.start(now)
|
||||||
|
this.osc2.start(now)
|
||||||
|
this.isPlaying = true
|
||||||
|
}
|
||||||
|
|
||||||
|
setNote(midi: number): void {
|
||||||
|
if (!this.osc1 || !this.osc2) return
|
||||||
|
const now = audio.currentTime
|
||||||
|
const freq = midiToFreq(midi)
|
||||||
|
this.osc1.frequency.linearRampToValueAtTime(freq, now + 0.08)
|
||||||
|
this.osc2.frequency.linearRampToValueAtTime(freq, now + 0.08)
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(v: number): void {
|
||||||
|
this.level = Math.max(0, Math.min(1, v))
|
||||||
|
if (this.gain) {
|
||||||
|
const now = audio.currentTime
|
||||||
|
this.gain.gain.linearRampToValueAtTime(this.level, now + 0.05)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (!this.isPlaying || !this.gain || !this.osc1 || !this.osc2) return
|
||||||
|
const now = audio.currentTime
|
||||||
|
this.gain.gain.cancelScheduledValues(now)
|
||||||
|
this.gain.gain.setValueAtTime(this.gain.gain.value, now)
|
||||||
|
this.gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.2)
|
||||||
|
this.osc1.stop(now + 0.25)
|
||||||
|
this.osc2.stop(now + 0.25)
|
||||||
|
this.isPlaying = false
|
||||||
|
this.osc1 = null
|
||||||
|
this.osc2 = null
|
||||||
|
this.gain = null
|
||||||
|
this.filter = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { Metronome, type MetroPattern } from './Metronome'
|
import { Metronome, type MetroPattern } from './Metronome'
|
||||||
import { audio } from './AudioEngine'
|
import { audio } from './AudioEngine'
|
||||||
|
import { keepAwake, allowSleep } from '../wakeLock'
|
||||||
|
|
||||||
export const BPM_MIN = 30
|
export const BPM_MIN = 30
|
||||||
export const BPM_MAX = 300
|
export const BPM_MAX = 300
|
||||||
@@ -43,12 +44,14 @@ export class MetronomeController {
|
|||||||
await audio.unlock()
|
await audio.unlock()
|
||||||
this.metro.start()
|
this.metro.start()
|
||||||
this.isPlaying = true
|
this.isPlaying = true
|
||||||
|
keepAwake()
|
||||||
this.tick()
|
this.tick()
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.metro.stop()
|
this.metro.stop()
|
||||||
this.isPlaying = false
|
this.isPlaying = false
|
||||||
|
allowSleep()
|
||||||
cancelAnimationFrame(this.raf)
|
cancelAnimationFrame(this.raf)
|
||||||
this.currentStep = -1
|
this.currentStep = -1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { chromaAt, pcAt } from './fretboard'
|
||||||
|
import { triadNotes, diatonicChords, scaleNotes, intervalTarget } from './theory'
|
||||||
|
import { triadShape, STRING_SETS } from './triads'
|
||||||
|
import { cagedShapes } from './caged'
|
||||||
|
import { deriveRoles } from '../../drills'
|
||||||
|
|
||||||
|
describe('fretboard mapping', () => {
|
||||||
|
it('maps open strings and frets correctly (index 0 = high e)', () => {
|
||||||
|
expect(pcAt(0, 0)).toBe('E') // high e open
|
||||||
|
expect(pcAt(5, 0)).toBe('E') // low E open
|
||||||
|
expect(pcAt(5, 3)).toBe('G') // low E, 3rd fret
|
||||||
|
expect(pcAt(4, 2)).toBe('B') // A string, 2nd fret
|
||||||
|
expect(chromaAt(0, 0)).toBe(4) // E
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('theory helpers', () => {
|
||||||
|
it('builds triads and diatonic chords', () => {
|
||||||
|
expect(triadNotes('C', 'major')).toEqual(['C', 'E', 'G'])
|
||||||
|
expect(triadNotes('A', 'minor')).toEqual(['A', 'C', 'E'])
|
||||||
|
expect(diatonicChords('G').map((c) => c.name)).toEqual([
|
||||||
|
'G', 'Am', 'Bm', 'C', 'D', 'Em', 'F#dim',
|
||||||
|
])
|
||||||
|
expect(scaleNotes('G', 'dorian')).toEqual(['G', 'A', 'Bb', 'C', 'D', 'E', 'F'])
|
||||||
|
expect(intervalTarget('A', '5P')).toBe('E')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deriveRoles', () => {
|
||||||
|
it('accents, downbeats, and subdivisions land correctly', () => {
|
||||||
|
// 4/4 sixteenths, accent on beat 1.
|
||||||
|
const roles = deriveRoles(16, 4, [0])
|
||||||
|
expect(roles[0]).toBe('accent')
|
||||||
|
expect(roles[4]).toBe('normal') // beat 2 downbeat
|
||||||
|
expect(roles[1]).toBe('subdivision')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('triad inversions', () => {
|
||||||
|
it('produces C major tones on the 1-2-3 string set', () => {
|
||||||
|
const set = STRING_SETS.find((s) => s.id === '123')!
|
||||||
|
for (const inv of ['root', '1st', '2nd']) {
|
||||||
|
const shape = triadShape('C', 'major', set, inv)
|
||||||
|
const chromas = shape.frets
|
||||||
|
.map((f, string) => (f >= 0 ? chromaAt(string, f) : null))
|
||||||
|
.filter((c): c is number => c !== null)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
// C(0) E(4) G(7)
|
||||||
|
expect(chromas).toEqual([0, 4, 7])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CAGED shapes', () => {
|
||||||
|
it('returns 5 shapes with the E-shape barre at fret 3 for key G', () => {
|
||||||
|
const shapes = cagedShapes('G')
|
||||||
|
expect(shapes).toHaveLength(5)
|
||||||
|
const eShape = shapes.find((s) => s.form === 'E shape')!
|
||||||
|
expect(eShape.barreFret).toBe(3)
|
||||||
|
// Every marked position should be a chord tone of G major (G B D).
|
||||||
|
const gTones = new Set([7, 11, 2])
|
||||||
|
for (const p of shapes[0].positions) {
|
||||||
|
expect(gTones.has(chromaAt(p.string, p.fret))).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Screen Wake Lock so the phone doesn't sleep mid-drill while propped against the
|
||||||
|
// amp. Feature-detected; silent no-op where unsupported. Re-acquires on tab return.
|
||||||
|
|
||||||
|
interface WakeLockSentinelLike {
|
||||||
|
released: boolean
|
||||||
|
release(): Promise<void>
|
||||||
|
}
|
||||||
|
interface WakeLockLike {
|
||||||
|
request(type: 'screen'): Promise<WakeLockSentinelLike>
|
||||||
|
}
|
||||||
|
|
||||||
|
function api(): WakeLockLike | null {
|
||||||
|
const nav = navigator as unknown as { wakeLock?: WakeLockLike }
|
||||||
|
return nav.wakeLock ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
let sentinel: WakeLockSentinelLike | null = null
|
||||||
|
let desired = false
|
||||||
|
let wired = false
|
||||||
|
|
||||||
|
async function acquire(): Promise<void> {
|
||||||
|
const wl = api()
|
||||||
|
if (!wl || sentinel) return
|
||||||
|
try {
|
||||||
|
sentinel = await wl.request('screen')
|
||||||
|
} catch {
|
||||||
|
/* user gesture required or blocked — ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire(): void {
|
||||||
|
if (wired) return
|
||||||
|
wired = true
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (desired && document.visibilityState === 'visible') void acquire()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the screen awake. Call from a user gesture (e.g. pressing play). */
|
||||||
|
export function keepAwake(): void {
|
||||||
|
desired = true
|
||||||
|
wire()
|
||||||
|
void acquire()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Allow the screen to sleep again. */
|
||||||
|
export function allowSleep(): void {
|
||||||
|
desired = false
|
||||||
|
void sentinel?.release()
|
||||||
|
sentinel = null
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
import IntervalsView from '../components/views/IntervalsView.svelte'
|
import IntervalsView from '../components/views/IntervalsView.svelte'
|
||||||
import NotePrompterView from '../components/views/NotePrompterView.svelte'
|
import NotePrompterView from '../components/views/NotePrompterView.svelte'
|
||||||
import ReferenceToneView from '../components/views/ReferenceToneView.svelte'
|
import ReferenceToneView from '../components/views/ReferenceToneView.svelte'
|
||||||
|
import ModeDroneView from '../components/views/ModeDroneView.svelte'
|
||||||
|
import InfoView from '../components/views/InfoView.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
code: string
|
code: string
|
||||||
@@ -42,6 +44,10 @@
|
|||||||
<NotePrompterView {drill} {params} />
|
<NotePrompterView {drill} {params} />
|
||||||
{:else if drill.kind === 'reference-tone'}
|
{:else if drill.kind === 'reference-tone'}
|
||||||
<ReferenceToneView {drill} {params} />
|
<ReferenceToneView {drill} {params} />
|
||||||
|
{:else if drill.kind === 'mode-drone'}
|
||||||
|
<ModeDroneView {drill} {params} />
|
||||||
|
{:else if drill.kind === 'info'}
|
||||||
|
<InfoView {drill} {params} />
|
||||||
{:else}
|
{:else}
|
||||||
<NotFound {code} />
|
<NotFound {code} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user