diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a777a79 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.git +.vite +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a14c46f --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..89d84ec --- /dev/null +++ b/README.md @@ -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:///drill/ +``` + +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. diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..eda51c6 --- /dev/null +++ b/nginx/default.conf @@ -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; + } +} diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 0000000..ad37e2c --- /dev/null +++ b/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/src/components/drone/DronePlayer.svelte b/src/components/drone/DronePlayer.svelte new file mode 100644 index 0000000..72a440f --- /dev/null +++ b/src/components/drone/DronePlayer.svelte @@ -0,0 +1,127 @@ + + +
+ + +
+ Root +
+ {#each CHROMATIC_ROOTS as r} + + {/each} +
+
+ +
+
+ Octave +
+ {#each [2, 3, 4] as o} + + {/each} +
+
+ +
+
+ + diff --git a/src/components/views/InfoView.svelte b/src/components/views/InfoView.svelte new file mode 100644 index 0000000..4b275f1 --- /dev/null +++ b/src/components/views/InfoView.svelte @@ -0,0 +1,35 @@ + + + + {#if drill.hasDrone} + + {/if} + +
+ {#each drill.body as para} +

{para}

+ {/each} +
+
+ + diff --git a/src/components/views/ModeDroneView.svelte b/src/components/views/ModeDroneView.svelte new file mode 100644 index 0000000..9e96f6a --- /dev/null +++ b/src/components/views/ModeDroneView.svelte @@ -0,0 +1,65 @@ + + + + + +
+ Mode +
+ {#each MODES as m} + + {/each} +
+
+ +

{root} {mode.label}: {notes.join(' · ')}

+ + +
+ + diff --git a/src/drills.ts b/src/drills.ts index b8767f2..5df6fc1 100644 --- a/src/drills.ts +++ b/src/drills.ts @@ -467,6 +467,13 @@ const T5: TheoryDrill = { description: 'See a root and an interval shape on adjacent strings.', 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 @@ -480,13 +487,56 @@ const L4: TheoryDrill = { 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[] = [ R1, R2, R3, R4, R5, F1, F2, F3, - T1, T2, T3, T4, T5, + T1, T2, T3, T4, T5, T6, L1, L2, L3, L4, + E1, E2, E3, ] export const DRILLS_BY_CODE: Record = Object.fromEntries( diff --git a/src/lib/audio/Metronome.test.ts b/src/lib/audio/Metronome.test.ts new file mode 100644 index 0000000..86a071f --- /dev/null +++ b/src/lib/audio/Metronome.test.ts @@ -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) + }) +}) diff --git a/src/lib/audio/drone.ts b/src/lib/audio/drone.ts new file mode 100644 index 0000000..08c5113 --- /dev/null +++ b/src/lib/audio/drone.ts @@ -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 { + 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 + } +} diff --git a/src/lib/audio/metronomeController.svelte.ts b/src/lib/audio/metronomeController.svelte.ts index 07b8bec..1861a63 100644 --- a/src/lib/audio/metronomeController.svelte.ts +++ b/src/lib/audio/metronomeController.svelte.ts @@ -4,6 +4,7 @@ import { Metronome, type MetroPattern } from './Metronome' import { audio } from './AudioEngine' +import { keepAwake, allowSleep } from '../wakeLock' export const BPM_MIN = 30 export const BPM_MAX = 300 @@ -43,12 +44,14 @@ export class MetronomeController { await audio.unlock() this.metro.start() this.isPlaying = true + keepAwake() this.tick() } stop(): void { this.metro.stop() this.isPlaying = false + allowSleep() cancelAnimationFrame(this.raf) this.currentStep = -1 } diff --git a/src/lib/music/music.test.ts b/src/lib/music/music.test.ts new file mode 100644 index 0000000..f93f509 --- /dev/null +++ b/src/lib/music/music.test.ts @@ -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) + } + }) +}) diff --git a/src/lib/wakeLock.ts b/src/lib/wakeLock.ts new file mode 100644 index 0000000..e4ba930 --- /dev/null +++ b/src/lib/wakeLock.ts @@ -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 +} +interface WakeLockLike { + request(type: 'screen'): Promise +} + +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 { + 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 +} diff --git a/src/pages/DrillPage.svelte b/src/pages/DrillPage.svelte index 86cd319..2e12ead 100644 --- a/src/pages/DrillPage.svelte +++ b/src/pages/DrillPage.svelte @@ -10,6 +10,8 @@ import IntervalsView from '../components/views/IntervalsView.svelte' import NotePrompterView from '../components/views/NotePrompterView.svelte' import ReferenceToneView from '../components/views/ReferenceToneView.svelte' + import ModeDroneView from '../components/views/ModeDroneView.svelte' + import InfoView from '../components/views/InfoView.svelte' interface Props { code: string @@ -42,6 +44,10 @@ {:else if drill.kind === 'reference-tone'} + {:else if drill.kind === 'mode-drone'} + + {:else if drill.kind === 'info'} + {:else} {/if}