Phase 1: scaffold + metronome engine + R1

Svelte 5 + Vite static SPA with absolute base and History-API path routing.
Core lookahead-scheduler metronome (no drift), AudioContext unlock-on-gesture,
synthesized 3-voice clicks, rAF-synced beat highlight. StrumGrid renderer and
R1 wired end-to-end with variants, BPM/variant persistence, query overrides,
and keyboard shortcuts (space, arrow up/down). Dark amp-light theme tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 00:13:44 +02:00
commit a77a515351
25 changed files with 3858 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.DS_Store
*.local
.vite
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#17140f" />
<title>Practice — Guitar Practice Helper</title>
<meta name="description" content="Interactive helpers for a guitar practice routine: metronome, pattern grids, chord & fretboard diagrams, drone." />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2308
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "guitar-practice-tool",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "svelte-check --tsconfig ./tsconfig.json && vite build",
"build:only": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tsconfig/svelte": "^5.0.4",
"@types/node": "^26.1.1",
"svelte": "^5.19.0",
"svelte-check": "^4.1.4",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vitest": "^3.0.4"
},
"dependencies": {
"tonal": "^6.4.0"
}
}
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import { router } from './router.svelte'
import Index from './pages/Index.svelte'
import DrillPage from './pages/DrillPage.svelte'
import NotFound from './pages/NotFound.svelte'
let route = $derived(router.current)
</script>
<main class="app-shell">
{#if route.name === 'index'}
<Index />
{:else if route.name === 'drill' && route.code}
{#key route.code}
<DrillPage code={route.code} params={route.params} />
{/key}
{:else}
<NotFound />
{/if}
</main>
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import { href } from '../../router.svelte'
import type { Category } from '../../drills'
interface Props {
code: string
name: string
category: Category
description: string
meterLabel?: string
children?: import('svelte').Snippet
}
let { code, name, category, description, meterLabel, children }: Props = $props()
</script>
<header class="drill-head">
<a class="back" href={href('')} aria-label="Back to all drills">← all drills</a>
<div class="titleblock">
<span class="eyebrow">{category} · {code}{meterLabel ? ` · ${meterLabel}` : ''}</span>
<h1>{name}</h1>
<p class="desc">{description}</p>
</div>
</header>
{@render children?.()}
<style>
.drill-head {
margin-bottom: 1.5rem;
}
.back {
display: inline-block;
font-family: var(--font-mono);
font-size: var(--step-0);
color: var(--inlay-dim);
padding: 0.35rem 0;
margin-bottom: 0.75rem;
}
.back:hover {
color: var(--tubeglow);
}
.titleblock h1 {
font-size: var(--step-4);
margin: 0.15rem 0 0.4rem;
}
.desc {
margin: 0;
color: var(--inlay-dim);
max-width: 42ch;
}
</style>
+208
View File
@@ -0,0 +1,208 @@
<script lang="ts">
import type { MetronomeController } from '../../lib/audio/metronomeController.svelte'
import { BPM_MIN, BPM_MAX } from '../../lib/audio/metronomeController.svelte'
interface Props {
controller: MetronomeController
/** Steps per reference beat — used to pulse the readout on downbeats only. */
stepsPerBeat: number
onBpmCommit?: (bpm: number) => void
}
let { controller, stepsPerBeat, onBpmCommit }: Props = $props()
// Pulse the readout on every reference beat (downbeat of each step group).
let onBeat = $derived(
controller.isPlaying && controller.currentStep % stepsPerBeat === 0,
)
function commit() {
onBpmCommit?.(controller.bpm)
}
function nudge(delta: number) {
controller.nudgeBpm(delta)
commit()
}
// --- Tap tempo ---------------------------------------------------------------
let taps: number[] = []
function tap() {
const now = performance.now()
// Reset if the last tap was long ago.
if (taps.length && now - taps[taps.length - 1] > 2000) taps = []
taps.push(now)
if (taps.length > 5) taps = taps.slice(-5)
if (taps.length >= 2) {
const intervals = taps.slice(1).map((t, i) => t - taps[i])
const avg = intervals.reduce((a, b) => a + b, 0) / intervals.length
controller.setBpm(60000 / avg)
commit()
}
}
function onSlider(e: Event) {
controller.setBpm(Number((e.target as HTMLInputElement).value))
}
</script>
<section class="transport">
<!-- Signature element: illuminated amp readout that glows on the beat. -->
<div class="readout" class:on={onBeat}>
<span class="bpm num">{controller.bpm}</span>
<span class="bpm-label eyebrow">bpm</span>
</div>
<div class="controls">
<button
class="play"
class:playing={controller.isPlaying}
onclick={() => controller.toggle()}
aria-label={controller.isPlaying ? 'Pause' : 'Play'}
>
{#if controller.isPlaying}
<span class="glyph">❚❚</span> Pause
{:else}
<span class="glyph"></span> Play
{/if}
</button>
<div class="bpm-row">
<button class="step" onclick={() => nudge(-2)} aria-label="Decrease tempo 2 BPM">2</button>
<input
class="slider"
type="range"
min={BPM_MIN}
max={BPM_MAX}
step="1"
value={controller.bpm}
oninput={onSlider}
onchange={commit}
aria-label="Tempo in BPM"
/>
<button class="step" onclick={() => nudge(2)} aria-label="Increase tempo 2 BPM">+2</button>
</div>
<div class="extras">
<button class="ghost" onclick={tap}>Tap tempo</button>
<button class="clean" onclick={() => nudge(2)} title="Only raise the tempo once the drill is clean">
Clean? +2
</button>
</div>
</div>
</section>
<style>
.transport {
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius-lg);
padding: 1.25rem 1.1rem 1.35rem;
margin-bottom: 1.5rem;
}
/* --- Illuminated readout --- */
.readout {
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.5rem;
padding: 0.6rem 0 1rem;
border-radius: var(--radius);
color: var(--tubeglow-soft);
transition: color 0.09s var(--ease), text-shadow 0.09s var(--ease);
}
.readout.on {
color: var(--tubeglow);
text-shadow: 0 0 18px rgba(242, 160, 61, 0.55);
}
.bpm {
font-size: var(--readout);
font-weight: 500;
line-height: 0.9;
}
.bpm-label {
color: var(--inlay-faint);
}
.controls {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.play {
width: 100%;
padding: 0.9rem;
font-size: var(--step-2);
font-family: var(--font-display);
letter-spacing: 0.03em;
background: var(--tubeglow);
color: #201607;
border: none;
border-radius: var(--radius);
font-weight: 600;
transition: filter 0.12s var(--ease);
}
.play:hover {
filter: brightness(1.08);
}
.play.playing {
background: transparent;
color: var(--tubeglow);
border: 1px solid var(--tubeglow-soft);
}
.glyph {
margin-right: 0.4rem;
}
.bpm-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.step {
flex: 0 0 auto;
min-width: 3rem;
padding: 0.7rem 0;
background: var(--walnut-hi);
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius);
font-family: var(--font-mono);
color: var(--inlay);
}
.step:hover {
border-color: var(--fretwire);
}
.slider {
flex: 1 1 auto;
accent-color: var(--tubeglow);
height: 2.5rem;
}
.extras {
display: flex;
gap: 0.6rem;
}
.ghost,
.clean {
flex: 1 1 0;
padding: 0.6rem;
background: transparent;
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius);
color: var(--inlay-dim);
font-size: var(--step-0);
}
.clean {
color: var(--patina);
border-color: color-mix(in srgb, var(--patina) 40%, transparent);
}
.ghost:hover {
border-color: var(--fretwire);
color: var(--inlay);
}
.clean:hover {
background: color-mix(in srgb, var(--patina) 12%, transparent);
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<script lang="ts">
import type { Stroke } from '../../drills'
interface Props {
strokes: Stroke[]
stepsPerBeat: number
currentStep: number
}
let { strokes, stepsPerBeat, currentStep }: Props = $props()
// Glyph + semantics per stroke. Lowercase = ghosted (muted) hand motion.
function glyph(s: Stroke): string {
switch (s) {
case 'D':
case 'd':
return '↓'
case 'U':
case 'u':
return '↑'
case 'x':
return '✕'
case '-':
return ''
}
}
const isGhost = (s: Stroke) => s === 'd' || s === 'u'
const isRest = (s: Stroke) => s === '-'
</script>
<div class="grid" role="group" aria-label="Strum pattern" style="--cols:{strokes.length}">
{#each strokes as s, i}
<div
class="cell"
class:beat={i % stepsPerBeat === 0}
class:ghost={isGhost(s)}
class:rest={isRest(s)}
class:active={i === currentStep}
>
<span class="stroke">{glyph(s)}</span>
<span class="dot" aria-hidden="true"></span>
</div>
{/each}
</div>
<style>
.grid {
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
gap: 0.3rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius);
padding: 0.75rem 0.6rem;
margin-bottom: 1.5rem;
}
.cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0 0.4rem;
border-radius: 8px;
/* Beat boundary hairline on the left of each downbeat (except the first). */
position: relative;
}
.cell.beat:not(:first-child)::before {
content: '';
position: absolute;
left: -0.18rem;
top: 10%;
bottom: 10%;
width: 1px;
background: var(--fretwire-dim);
}
.stroke {
font-size: var(--step-2);
line-height: 1;
color: var(--inlay);
transition: color 0.08s var(--ease);
}
.cell.ghost .stroke {
color: var(--inlay-faint);
}
.cell.rest .stroke {
color: transparent;
}
/* Mother-of-pearl inlay dot; ignites amber on the active step. */
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--inlay-faint);
transition:
background 0.08s var(--ease),
box-shadow 0.08s var(--ease),
transform 0.08s var(--ease);
}
.cell.beat .dot {
background: var(--inlay-dim);
}
.cell.active .dot {
background: var(--tubeglow);
box-shadow: 0 0 12px 2px rgba(242, 160, 61, 0.6);
transform: scale(1.25);
}
.cell.active .stroke {
color: var(--tubeglow);
}
</style>
+140
View File
@@ -0,0 +1,140 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import type { StrumDrill } from '../../drills'
import { patternForStrumVariant } from '../../drills'
import { MetronomeController } from '../../lib/audio/metronomeController.svelte'
import * as storage from '../../lib/storage'
import { router } from '../../router.svelte'
import DrillLayout from '../common/DrillLayout.svelte'
import Transport from '../metronome/Transport.svelte'
import StrumGrid from '../patterns/StrumGrid.svelte'
interface Props {
drill: StrumDrill
params: URLSearchParams
}
let { drill, params }: Props = $props()
// Resolve initial settings: query param > stored > drill default.
function initialBpm(): number {
const q = Number(params.get('bpm'))
if (Number.isFinite(q) && q > 0) return q
return storage.getDrillBpm(drill.code, drill.defaultBpm)
}
function initialVariantId(): string {
const q = params.get('variant')
if (q && drill.variants.some((v) => v.id === q)) return q
const stored = storage.getDrillVariant(drill.code, drill.variants[0].id)
return drill.variants.some((v) => v.id === stored) ? stored : drill.variants[0].id
}
const controller = new MetronomeController(initialBpm())
let variantId = $state(initialVariantId())
let variant = $derived(
drill.variants.find((v) => v.id === variantId) ?? drill.variants[0],
)
// Keep the engine pattern in sync with the selected variant.
$effect(() => {
controller.setPattern(patternForStrumVariant(drill, variant))
})
function persistBpm(bpm: number) {
storage.setDrillBpm(drill.code, bpm)
}
function selectVariant(id: string) {
variantId = id
storage.setDrillVariant(drill.code, id)
router.setQuery({ variant: id })
}
function onKey(e: KeyboardEvent) {
const t = e.target as HTMLElement
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return
if (e.code === 'Space') {
e.preventDefault()
controller.toggle()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
controller.nudgeBpm(2)
persistBpm(controller.bpm)
} else if (e.key === 'ArrowDown') {
e.preventDefault()
controller.nudgeBpm(-2)
persistBpm(controller.bpm)
}
}
onMount(() => {
window.addEventListener('keydown', onKey)
})
onDestroy(() => {
window.removeEventListener('keydown', onKey)
controller.destroy()
})
</script>
<DrillLayout
code={drill.code}
name={drill.name}
category={drill.category}
description={drill.description}
meterLabel={drill.meterLabel}
>
<Transport {controller} stepsPerBeat={drill.stepsPerBeat} onBpmCommit={persistBpm} />
<StrumGrid
strokes={variant.strokes}
stepsPerBeat={drill.stepsPerBeat}
currentStep={controller.currentStep}
/>
{#if drill.variants.length > 1}
<div class="variants">
<span class="eyebrow">Variant</span>
<div class="chips">
{#each drill.variants as v}
<button
class="chip"
class:sel={v.id === variantId}
onclick={() => selectVariant(v.id)}
>
{v.label}
</button>
{/each}
</div>
</div>
{/if}
</DrillLayout>
<style>
.variants {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.chip {
padding: 0.55rem 0.8rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: 999px;
color: var(--inlay-dim);
font-size: var(--step-0);
}
.chip:hover {
border-color: var(--fretwire);
color: var(--inlay);
}
.chip.sel {
color: var(--tubeglow);
border-color: var(--tubeglow-soft);
background: color-mix(in srgb, var(--tubeglow) 10%, transparent);
}
</style>
+143
View File
@@ -0,0 +1,143 @@
// Single source of truth for every drill. Pages are generated from this data —
// no handwritten drill pages. Each drill's `kind` selects the helper composition
// rendered by DrillPage.svelte. Drills are added per build phase.
import type { MetroPattern, StepRole } from './lib/audio/Metronome'
export type Category = 'Rhythm' | 'Fingerstyle' | 'Theory' | 'Lead' | 'Ear'
export const CATEGORY_ORDER: Category[] = [
'Rhythm',
'Fingerstyle',
'Theory',
'Lead',
'Ear',
]
// --- Strum grid vocabulary ------------------------------------------------------
// Uppercase = sounded, lowercase = ghosted (hand keeps moving, string muted/silent).
// D/d down-stroke U/u up-stroke x muted (percussive) hit - rest (no motion)
export type Stroke = 'D' | 'U' | 'd' | 'u' | 'x' | '-'
export interface StrumVariant {
id: string
label: string
/** One stroke per step; length = beatsPerBar * stepsPerBeat. */
strokes: Stroke[]
/** Step indices that get the ACCENT click. Downbeats otherwise click 'normal'. */
accentSteps: number[]
}
interface DrillBase {
code: string
name: string
category: Category
description: string
}
export interface StrumDrill extends DrillBase {
kind: 'strum'
defaultBpm: number
beatsPerBar: number
stepsPerBeat: number
/** Time-signature label for display, e.g. "4/4" or "7/8". */
meterLabel: string
variants: StrumVariant[]
}
// Union grows as later phases add kinds (picking, chords, fretboard, drone, info…).
export type Drill = StrumDrill
// --- Helpers --------------------------------------------------------------------
/**
* Derive metronome roles from an accent map: accented steps get the accent voice,
* downbeats (first step of each reference beat) click 'normal', in-between
* subdivisions click 'subdivision'.
*/
export function deriveRoles(
totalSteps: number,
stepsPerBeat: number,
accentSteps: number[],
): StepRole[] {
const accents = new Set(accentSteps)
return Array.from({ length: totalSteps }, (_, i) => {
if (accents.has(i)) return 'accent'
return i % stepsPerBeat === 0 ? 'normal' : 'subdivision'
})
}
export function patternForStrumVariant(
drill: StrumDrill,
variant: StrumVariant,
): MetroPattern {
const total = drill.beatsPerBar * drill.stepsPerBeat
return {
beatsPerBar: drill.beatsPerBar,
stepsPerBeat: drill.stepsPerBeat,
roles: deriveRoles(total, drill.stepsPerBeat, variant.accentSteps),
}
}
// --- Drill data -----------------------------------------------------------------
// Compact builders for authoring 16-step continuous-motion strum rows.
// alt(sounded) → continuous D-U where the given step indices sound (rest ghosted).
function altStrokes(total: number, sounded: number[]): Stroke[] {
const set = new Set(sounded)
return Array.from({ length: total }, (_, i) => {
const down = i % 2 === 0
if (set.has(i)) return down ? 'D' : 'U'
return down ? 'd' : 'u'
})
}
const R1: StrumDrill = {
code: 'R1',
name: '16th-note strum, mute the off-beats',
category: 'Rhythm',
description:
'Keep a continuous 16th-note down-up strum hand going; only let selected steps ring, mute the rest.',
kind: 'strum',
defaultBpm: 70,
beatsPerBar: 4,
stepsPerBeat: 4,
meterLabel: '4/4',
variants: [
{
id: 'quarters',
label: 'Sound on quarters only',
strokes: altStrokes(16, [0, 4, 8, 12]),
accentSteps: [0],
},
{
id: '1e-and-a-1-3',
label: '1e&a on beats 1 and 3',
strokes: altStrokes(16, [0, 1, 2, 3, 8, 9, 10, 11]),
accentSteps: [0, 8],
},
{
id: 'all-16ths',
label: 'All 16ths ring',
strokes: altStrokes(16, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]),
accentSteps: [0],
},
],
}
export const DRILLS: Drill[] = [R1]
export const DRILLS_BY_CODE: Record<string, Drill> = Object.fromEntries(
DRILLS.map((d) => [d.code, d]),
)
export function getDrill(code: string): Drill | undefined {
return DRILLS_BY_CODE[code.toUpperCase()]
}
export function drillsByCategory(): { category: Category; drills: Drill[] }[] {
return CATEGORY_ORDER.map((category) => ({
category,
drills: DRILLS.filter((d) => d.category === category),
})).filter((g) => g.drills.length > 0)
}
+53
View File
@@ -0,0 +1,53 @@
// Single shared AudioContext for the whole app. Created lazily and resumed on the
// first user gesture so we satisfy the mobile-browser autoplay policy.
type AudioContextCtor = typeof AudioContext
function getCtor(): AudioContextCtor {
const w = window as unknown as {
AudioContext?: AudioContextCtor
webkitAudioContext?: AudioContextCtor
}
const Ctor = w.AudioContext ?? w.webkitAudioContext
if (!Ctor) throw new Error('Web Audio API is not supported in this browser.')
return Ctor
}
class AudioEngine {
private ctx: AudioContext | null = null
private _master: GainNode | null = null
/** Create the context if needed. Safe to call repeatedly. */
ensure(): AudioContext {
if (!this.ctx) {
this.ctx = new (getCtor())()
this._master = this.ctx.createGain()
this._master.gain.value = 0.9
this._master.connect(this.ctx.destination)
}
return this.ctx
}
/** Call from a user gesture (pointerdown / keydown) before playing anything. */
async unlock(): Promise<void> {
const ctx = this.ensure()
if (ctx.state === 'suspended') {
await ctx.resume()
}
}
get context(): AudioContext {
return this.ensure()
}
get master(): GainNode {
this.ensure()
return this._master as GainNode
}
get currentTime(): number {
return this.ensure().currentTime
}
}
export const audio = new AudioEngine()
+135
View File
@@ -0,0 +1,135 @@
// Shared metronome engine used by all click-track drills.
//
// Timing uses the lookahead-scheduler pattern (Chris Wilson, "A Tale of Two Clocks"):
// a coarse setTimeout tick wakes us ~every LOOKAHEAD_MS and schedules any audio events
// falling inside the next SCHEDULE_AHEAD_S window against AudioContext.currentTime.
// Clicks are therefore sample-accurate regardless of setTimeout jitter, and never drift.
//
// Visual sync is decoupled: every scheduled step is pushed to `scheduledQueue` with its
// audio time. A requestAnimationFrame consumer (BeatIndicator) drains it against
// currentTime, so the highlight follows the audio clock, not a separate timer.
import { audio } from './AudioEngine'
import { scheduleClick, type ClickVoice } from './click'
export type StepRole = 'accent' | 'normal' | 'subdivision' | 'silent'
export interface MetroPattern {
/** Reference beats per bar (e.g. 4 for 4/4, 7 for 7/8 counted in eighths). */
beatsPerBar: number
/** Subdivisions per reference beat (1, 2, 3, or 4). */
stepsPerBeat: number
/** One role per step; length must equal beatsPerBar * stepsPerBeat. */
roles: StepRole[]
}
export interface ScheduledStep {
step: number
time: number
}
const LOOKAHEAD_MS = 25
const SCHEDULE_AHEAD_S = 0.1
const ROLE_TO_VOICE: Record<Exclude<StepRole, 'silent'>, ClickVoice> = {
accent: 'accent',
normal: 'normal',
subdivision: 'subdivision',
}
/** Pure: seconds per step for a given tempo and subdivision. */
export function stepDuration(bpm: number, stepsPerBeat: number): number {
return 60 / bpm / stepsPerBeat
}
export class Metronome {
bpm = 90
private pattern: MetroPattern = { beatsPerBar: 4, stepsPerBeat: 4, roles: [] }
private pendingPattern: MetroPattern | null = null
isPlaying = false
/** Steps scheduled but not yet reached by the audio clock. Drained by the UI. */
readonly scheduledQueue: ScheduledStep[] = []
private nextNoteTime = 0
private currentStep = 0
private timer: ReturnType<typeof setTimeout> | null = null
setPattern(p: MetroPattern): void {
if (this.isPlaying) {
// Apply at the next bar boundary so a live meter/accent switch stays in time.
this.pendingPattern = p
} else {
this.pattern = p
}
}
/** The pattern currently sounding (ignores a pending swap). */
getPattern(): MetroPattern {
return this.pattern
}
setBpm(bpm: number): void {
this.bpm = bpm
}
start(): void {
if (this.isPlaying) return
const ctx = audio.ensure()
this.isPlaying = true
this.currentStep = 0
this.scheduledQueue.length = 0
// Small offset so the first click isn't scheduled in the past.
this.nextNoteTime = ctx.currentTime + 0.06
this.scheduler()
}
stop(): void {
this.isPlaying = false
if (this.timer !== null) {
clearTimeout(this.timer)
this.timer = null
}
this.scheduledQueue.length = 0
}
toggle(): void {
if (this.isPlaying) this.stop()
else this.start()
}
private scheduler = (): void => {
const ctx = audio.context
while (this.nextNoteTime < ctx.currentTime + SCHEDULE_AHEAD_S) {
this.scheduleStep(this.currentStep, this.nextNoteTime)
this.advance()
}
this.timer = setTimeout(this.scheduler, LOOKAHEAD_MS)
}
private scheduleStep(step: number, time: number): void {
const role = this.pattern.roles[step]
if (role && role !== 'silent') {
scheduleClick(ctx(), audio.master, time, ROLE_TO_VOICE[role])
}
this.scheduledQueue.push({ step, time })
}
private advance(): void {
this.nextNoteTime += stepDuration(this.bpm, this.pattern.stepsPerBeat)
const total = this.pattern.roles.length || 1
this.currentStep++
if (this.currentStep >= total) {
this.currentStep = 0
// Bar boundary: adopt a pending pattern (live meter/accent switch).
if (this.pendingPattern) {
this.pattern = this.pendingPattern
this.pendingPattern = null
}
}
}
}
function ctx(): AudioContext {
return audio.context
}
+46
View File
@@ -0,0 +1,46 @@
// Synthesized metronome clicks. No sample files — each click is a short oscillator
// blip with a fast decay envelope. Three voices: accent, normal, subdivision.
export type ClickVoice = 'accent' | 'normal' | 'subdivision'
interface VoiceSpec {
freq: number
peak: number
type: OscillatorType
decay: number
}
const VOICES: Record<ClickVoice, VoiceSpec> = {
accent: { freq: 1760, peak: 1.0, type: 'square', decay: 0.05 },
normal: { freq: 1200, peak: 0.55, type: 'square', decay: 0.04 },
subdivision: { freq: 880, peak: 0.28, type: 'sine', decay: 0.03 },
}
/**
* Schedule one click at an absolute AudioContext time.
* Nodes are created per click and self-clean on stop.
*/
export function scheduleClick(
ctx: AudioContext,
dest: AudioNode,
time: number,
voice: ClickVoice,
): void {
const spec = VOICES[voice]
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = spec.type
osc.frequency.setValueAtTime(spec.freq, time)
// Fast attack, exponential decay — a tight click with no lingering tail.
gain.gain.setValueAtTime(0.0001, time)
gain.gain.exponentialRampToValueAtTime(spec.peak, time + 0.001)
gain.gain.exponentialRampToValueAtTime(0.0001, time + spec.decay)
osc.connect(gain)
gain.connect(dest)
osc.start(time)
osc.stop(time + spec.decay + 0.01)
}
@@ -0,0 +1,74 @@
// Reactive wrapper around the (framework-agnostic) Metronome. Exposes $state for
// bpm / isPlaying / currentStep so Svelte components stay in sync, and drives the
// visual beat via requestAnimationFrame draining the engine's scheduled queue.
import { Metronome, type MetroPattern } from './Metronome'
import { audio } from './AudioEngine'
export const BPM_MIN = 30
export const BPM_MAX = 300
export function clampBpm(n: number): number {
return Math.max(BPM_MIN, Math.min(BPM_MAX, Math.round(n)))
}
export class MetronomeController {
private metro = new Metronome()
bpm = $state(90)
isPlaying = $state(false)
/** Current step index within the bar, or -1 when stopped. */
currentStep = $state(-1)
private raf = 0
constructor(bpm: number) {
this.bpm = clampBpm(bpm)
this.metro.setBpm(this.bpm)
}
setPattern(p: MetroPattern): void {
this.metro.setPattern(p)
}
setBpm(n: number): void {
this.bpm = clampBpm(n)
this.metro.setBpm(this.bpm)
}
nudgeBpm(delta: number): void {
this.setBpm(this.bpm + delta)
}
async play(): Promise<void> {
if (this.isPlaying) return
await audio.unlock()
this.metro.start()
this.isPlaying = true
this.tick()
}
stop(): void {
this.metro.stop()
this.isPlaying = false
cancelAnimationFrame(this.raf)
this.currentStep = -1
}
async toggle(): Promise<void> {
if (this.isPlaying) this.stop()
else await this.play()
}
private tick = (): void => {
const q = this.metro.scheduledQueue
const now = audio.currentTime
while (q.length && q[0].time <= now) {
const s = q.shift()
if (s) this.currentStep = s.step
}
if (this.isPlaying) this.raf = requestAnimationFrame(this.tick)
}
destroy(): void {
this.stop()
}
}
+57
View File
@@ -0,0 +1,57 @@
// Namespaced localStorage helpers. All keys are prefixed `gph:` so the app never
// collides with anything else on the origin. Per-drill settings use `gph:<CODE>:<key>`.
const NS = 'gph'
function safeGet(key: string): string | null {
try {
return localStorage.getItem(key)
} catch {
return null
}
}
function safeSet(key: string, value: string): void {
try {
localStorage.setItem(key, value)
} catch {
/* storage full or blocked (private mode) — ignore, settings just won't persist */
}
}
export function getString(key: string, fallback: string): string {
return safeGet(`${NS}:${key}`) ?? fallback
}
export function setString(key: string, value: string): void {
safeSet(`${NS}:${key}`, value)
}
export function getNumber(key: string, fallback: number): number {
const raw = safeGet(`${NS}:${key}`)
if (raw === null) return fallback
const n = Number(raw)
return Number.isFinite(n) ? n : fallback
}
export function setNumber(key: string, value: number): void {
safeSet(`${NS}:${key}`, String(value))
}
// Per-drill convenience wrappers -------------------------------------------------
export function getDrillBpm(code: string, fallback: number): number {
return getNumber(`${code}:bpm`, fallback)
}
export function setDrillBpm(code: string, bpm: number): void {
setNumber(`${code}:bpm`, bpm)
}
export function getDrillVariant(code: string, fallback: string): string {
return getString(`${code}:variant`, fallback)
}
export function setDrillVariant(code: string, id: string): void {
setString(`${code}:variant`, id)
}
+12
View File
@@ -0,0 +1,12 @@
import { mount } from 'svelte'
import './styles/theme.css'
import App from './App.svelte'
import { router } from './router.svelte'
router.init()
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { getDrill } from '../drills'
import NotFound from './NotFound.svelte'
import StrumView from '../components/views/StrumView.svelte'
interface Props {
code: string
params: URLSearchParams
}
let { code, params }: Props = $props()
let drill = $derived(getDrill(code))
</script>
{#if !drill}
<NotFound {code} />
{:else if drill.kind === 'strum'}
{#key drill.code}
<StrumView {drill} {params} />
{/key}
{:else}
<NotFound {code} />
{/if}
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import { drillsByCategory, type Drill } from '../drills'
import { href } from '../router.svelte'
import * as storage from '../lib/storage'
const groups = drillsByCategory()
// Last-used BPM shown next to click-track drills (only kinds that persist bpm).
function lastBpm(d: Drill): number | null {
if (d.kind === 'strum') {
return storage.getDrillBpm(d.code, d.defaultBpm)
}
return null
}
</script>
<section class="hero">
<span class="eyebrow">Guitar practice</span>
<h1>Open a drill.<br />Press play.<br /><span class="accent">Five clean minutes.</span></h1>
<p>Each code from your routine gets a helper — a click track, a pattern, a diagram, a drone.</p>
</section>
{#each groups as group}
<section class="cat">
<h2>{group.category}</h2>
<ul>
{#each group.drills as d}
{@const bpm = lastBpm(d)}
<li>
<a href={href(`drill/${d.code}`)}>
<span class="code num">{d.code}</span>
<span class="body">
<span class="name">{d.name}</span>
<span class="desc">{d.description}</span>
</span>
{#if bpm !== null}
<span class="bpm num">{bpm}<small>bpm</small></span>
{/if}
</a>
</li>
{/each}
</ul>
</section>
{/each}
<style>
.hero {
padding: 1rem 0 2.25rem;
}
.hero h1 {
font-size: clamp(2.2rem, 11vw, 3.2rem);
margin: 0.5rem 0 0.9rem;
}
.hero .accent {
color: var(--tubeglow);
}
.hero p {
color: var(--inlay-dim);
max-width: 38ch;
margin: 0;
}
.cat {
margin-bottom: 2rem;
}
.cat h2 {
font-size: var(--step-2);
color: var(--inlay-dim);
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fretwire-dim);
margin-bottom: 0.75rem;
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
a {
display: grid;
grid-template-columns: 2.75rem 1fr auto;
align-items: center;
gap: 0.85rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius);
padding: 0.85rem 0.9rem;
transition: border-color 0.12s var(--ease), background 0.12s var(--ease);
}
a:hover {
border-color: var(--tubeglow-soft);
background: var(--walnut-hi);
}
.code {
font-size: var(--step-2);
color: var(--tubeglow);
text-align: center;
}
.body {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
font-weight: 600;
}
.desc {
color: var(--inlay-faint);
font-size: var(--step-0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bpm {
color: var(--inlay-dim);
font-size: var(--step-2);
display: flex;
align-items: baseline;
gap: 0.15rem;
}
.bpm small {
font-size: 0.6em;
color: var(--inlay-faint);
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import { href } from '../router.svelte'
interface Props {
code?: string
}
let { code }: Props = $props()
</script>
<div class="notfound">
<span class="eyebrow">Unknown drill</span>
<h1>{code ? `No drill “${code}` : 'Page not found'}</h1>
<p>That drill code doesnt exist yet. Check the link, or browse the full list.</p>
<a class="cta" href={href('')}>See all drills →</a>
</div>
<style>
.notfound {
padding: 3rem 0;
}
h1 {
font-size: var(--step-4);
margin: 0.3rem 0 0.6rem;
}
p {
color: var(--inlay-dim);
max-width: 40ch;
}
.cta {
display: inline-block;
margin-top: 1.2rem;
color: var(--tubeglow);
font-family: var(--font-display);
font-size: var(--step-2);
}
</style>
+98
View File
@@ -0,0 +1,98 @@
// Minimal History-API router for a static SPA with clean (path) URLs.
//
// Routes, relative to the app base (import.meta.env.BASE_URL):
// "" -> index
// "drill/<CODE>"-> drill page
// anything else -> notfound
//
// Deep-link refreshes are served index.html by the host (Cloudflare _redirects /
// nginx try_files), then this router resolves the real route client-side.
export type RouteName = 'index' | 'drill' | 'notfound'
export interface Route {
name: RouteName
code?: string
params: URLSearchParams
}
const BASE = import.meta.env.BASE_URL // e.g. "/" or "/practice/"
function stripBase(pathname: string): string {
let p = pathname
if (p.startsWith(BASE)) p = p.slice(BASE.length)
else if (BASE !== '/' && p.startsWith(BASE.replace(/\/$/, ''))) {
p = p.slice(BASE.replace(/\/$/, '').length)
}
return p.replace(/^\/+/, '').replace(/\/+$/, '')
}
function parse(): Route {
const rel = stripBase(location.pathname)
const params = new URLSearchParams(location.search)
if (rel === '') return { name: 'index', params }
const segments = rel.split('/')
if (segments[0] === 'drill' && segments[1]) {
return { name: 'drill', code: decodeURIComponent(segments[1]), params }
}
return { name: 'notfound', params }
}
/** Build a full href (including base) for an in-app path like "drill/R1". */
export function href(path: string, query?: Record<string, string | number>): string {
const clean = path.replace(/^\/+/, '')
let url = BASE + clean
if (query) {
const q = new URLSearchParams()
for (const [k, v] of Object.entries(query)) q.set(k, String(v))
const qs = q.toString()
if (qs) url += `?${qs}`
}
return url
}
class Router {
current = $state<Route>(parse())
navigate(fullHref: string, replace = false): void {
if (replace) history.replaceState({}, '', fullHref)
else history.pushState({}, '', fullHref)
this.current = parse()
window.scrollTo(0, 0)
}
/** Update the query string of the current route without adding history entries. */
setQuery(updates: Record<string, string | null>): void {
const params = new URLSearchParams(location.search)
for (const [k, v] of Object.entries(updates)) {
if (v === null) params.delete(k)
else params.set(k, v)
}
const qs = params.toString()
history.replaceState({}, '', location.pathname + (qs ? `?${qs}` : ''))
this.current = parse()
}
init(): void {
window.addEventListener('popstate', () => {
this.current = parse()
})
// Intercept in-app link clicks so navigation stays client-side.
document.addEventListener('click', (e) => {
if (e.defaultPrevented || e.button !== 0) return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const anchor = (e.target as HTMLElement)?.closest('a')
if (!anchor) return
const target = anchor.getAttribute('target')
if (target && target !== '_self') return
if (anchor.hasAttribute('download')) return
const url = new URL(anchor.href, location.href)
if (url.origin !== location.origin) return
if (!url.pathname.startsWith(BASE)) return
e.preventDefault()
this.navigate(url.pathname + url.search)
})
}
}
export const router = new Router()
+129
View File
@@ -0,0 +1,129 @@
/* ============================================================================
Guitar Practice Helper — theme tokens
Direction: "practice bench under amp light."
Warm dark rosewood/amp-panel surfaces, mother-of-pearl inlay text, one bold
tube-glow amber accent for the live/now signal, sparse aged-copper patina for
clean/confirmed states. Boldness is spent on the illuminated transport readout;
everything else stays quiet.
============================================================================ */
:root {
/* Palette */
--bench: #17140f; /* page — unlit amp panel / rosewood in shadow */
--walnut: #221d19; /* raised cards */
--walnut-hi: #2c2621; /* card hover / inset */
--fretwire: #8a8578; /* hairline rules, muted nickel */
--fretwire-dim: #4a453d; /* dim rules / borders */
--inlay: #ede6d3; /* primary text — mother-of-pearl */
--inlay-dim: #a9a293; /* secondary text */
--inlay-faint: #6d675c; /* tertiary / ghosted */
--tubeglow: #f2a03d; /* accent — active beat, play, current step */
--tubeglow-soft: #b9752a; /* accent, dimmed */
--patina: #5fa392; /* success / clean / confirmed */
/* Typography */
--font-display: 'Barlow Semi Condensed', 'Oswald', 'Helvetica Neue Condensed',
system-ui, sans-serif;
--font-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Mono',
'Roboto Mono', monospace;
/* Scale */
--step-0: 0.82rem;
--step-1: 1rem;
--step-2: 1.25rem;
--step-3: 1.6rem;
--step-4: 2.1rem;
--readout: clamp(4rem, 22vw, 7rem);
/* Space & shape */
--gap: 1rem;
--radius: 10px;
--radius-lg: 16px;
--maxw: 640px;
--ease: cubic-bezier(0.2, 0.7, 0.3, 1);
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: var(--bench);
color: var(--inlay);
font-family: var(--font-body);
font-size: var(--step-1);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* Safe-area padding for notched phones. */
padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left);
min-height: 100dvh;
}
h1,
h2,
h3 {
font-family: var(--font-display);
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.1;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
button {
font-family: inherit;
color: inherit;
cursor: pointer;
}
.num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
}
.eyebrow {
font-family: var(--font-mono);
font-size: var(--step-0);
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--inlay-faint);
}
.app-shell {
width: 100%;
max-width: var(--maxw);
margin: 0 auto;
padding: 1.25rem 1.1rem 4rem;
}
/* Focus visibility — part of the quality floor. */
:focus-visible {
outline: 2px solid var(--tubeglow);
outline-offset: 2px;
border-radius: 4px;
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
preprocess: vitePreprocess(),
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleResolution": "bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"sourceMap": true
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// Base path is absolute so nested path-routed URLs (e.g. /practice/drill/R3)
// resolve hashed asset URLs correctly. Use '/' for a dedicated domain, or set
// VITE_BASE=/practice/ at build time for a subpath deploy.
// (Relative base './' would break assets on nested routes — do not use it here.)
const base = process.env.VITE_BASE ?? '/'
export default defineConfig({
base,
plugins: [svelte()],
build: {
target: 'es2020',
},
})