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.',
|
||||
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<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 { 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
|
||||
}
|
||||
|
||||
@@ -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 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 @@
|
||||
<NotePrompterView {drill} {params} />
|
||||
{:else if drill.kind === 'reference-tone'}
|
||||
<ReferenceToneView {drill} {params} />
|
||||
{:else if drill.kind === 'mode-drone'}
|
||||
<ModeDroneView {drill} {params} />
|
||||
{:else if drill.kind === 'info'}
|
||||
<InfoView {drill} {params} />
|
||||
{:else}
|
||||
<NotFound {code} />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user