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