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:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
|
.vite
|
||||||
+14
@@ -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>
|
||||||
Generated
+2308
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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 doesn’t 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>
|
||||||
@@ -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()
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="svelte" />
|
||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user