Phase 3: chord/fretboard diagrams + theory drills + R4

Add ChordDiagram (SVG chord box) and horizontal Fretboard components, plus a
tonal-backed music layer: fretboard note mapping, scale/interval/diatonic helpers,
curated chord shapes, and computed triad inversions + CAGED shapes. Wire T1
(note prompter), T2 (CAGED), T3 (triads, optional click + auto-advance), T4
(diatonic), T5 (intervals), R4 (one-minute changes with best score), and L4
(reference tone). Upgrade F1-F3 to show real chord diagrams.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 00:31:33 +02:00
parent c7e8e457da
commit 365aac58a0
18 changed files with 1888 additions and 14 deletions
+161
View File
@@ -0,0 +1,161 @@
<script lang="ts">
import type { ChordShape } from '../../lib/music/chords'
interface Props {
shape: ChordShape
label?: string
/** Extra caption under the name, e.g. a position hint. */
positionLabel?: string
size?: number
}
let { shape, label, positionLabel, size = 130 }: Props = $props()
const STRINGS = 6
const ROWS = 4
// Determine the fret window to show.
let fretted = $derived(shape.frets.filter((f) => f > 0))
let maxF = $derived(fretted.length ? Math.max(...fretted) : 0)
let minF = $derived(fretted.length ? Math.min(...fretted) : 0)
let startFret = $derived(maxF > ROWS ? minF : 1)
let showNut = $derived(startFret === 1)
// Geometry (viewBox units).
const padX = 14
const padTop = 26
const boxW = 92
const boxH = 104
const colW = boxW / (STRINGS - 1)
const rowH = boxH / ROWS
function stringX(i: number): number {
return padX + i * colW
}
function fretY(row: number): number {
return padTop + row * rowH
}
interface Dot {
x: number
y: number
finger?: number
}
let dots = $derived.by(() => {
const out: Dot[] = []
shape.frets.forEach((f, i) => {
if (f > 0) {
const row = f - startFret + 1
if (row >= 1 && row <= ROWS) {
out.push({
x: stringX(i),
y: fretY(row) - rowH / 2,
finger: shape.fingers?.[i] || undefined,
})
}
}
})
return out
})
</script>
<figure class="chord" style="width:{size}px">
<svg viewBox="0 0 120 150" role="img" aria-label={`${label ?? shape.name} chord diagram`}>
<!-- Open / muted markers above the nut -->
{#each shape.frets as f, i}
{#if f === -1}
<text class="mark" x={stringX(i)} y={padTop - 8} text-anchor="middle"></text>
{:else if f === 0}
<circle class="open" cx={stringX(i)} cy={padTop - 12} r="3.4" />
{/if}
{/each}
<!-- Nut or position label -->
{#if showNut}
<rect x={padX - 1} y={padTop - 3} width={boxW + 2} height="3.5" class="nut" />
{:else}
<text class="pos" x={padX - 6} y={fretY(0) + rowH - 4} text-anchor="end">{startFret}</text>
{/if}
<!-- Fret lines -->
{#each Array(ROWS + 1) as _, r}
<line class="fret" x1={padX} y1={fretY(r)} x2={padX + boxW} y2={fretY(r)} />
{/each}
<!-- String lines -->
{#each Array(STRINGS) as _, i}
<line class="str" x1={stringX(i)} y1={padTop} x2={stringX(i)} y2={padTop + boxH} />
{/each}
<!-- Finger dots -->
{#each dots as d}
<circle class="dot" cx={d.x} cy={d.y} r="6.5" />
{#if d.finger}
<text class="finger" x={d.x} y={d.y + 3} text-anchor="middle">{d.finger}</text>
{/if}
{/each}
</svg>
<figcaption>
<span class="name">{label ?? shape.name}</span>
{#if positionLabel}<span class="poslabel">{positionLabel}</span>{/if}
</figcaption>
</figure>
<style>
.chord {
margin: 0;
display: inline-flex;
flex-direction: column;
align-items: center;
}
svg {
width: 100%;
height: auto;
display: block;
}
.nut {
fill: var(--inlay-dim);
}
.fret,
.str {
stroke: var(--fretwire-dim);
stroke-width: 1;
}
.dot {
fill: var(--inlay);
}
.finger {
fill: var(--bench);
font-family: var(--font-mono);
font-size: 8px;
}
.open {
fill: none;
stroke: var(--inlay-dim);
stroke-width: 1.2;
}
.mark {
fill: var(--inlay-faint);
font-size: 10px;
}
.pos {
fill: var(--inlay-dim);
font-family: var(--font-mono);
font-size: 9px;
}
figcaption {
margin-top: 0.35rem;
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.2;
}
.name {
font-family: var(--font-display);
font-size: var(--step-2);
}
.poslabel {
font-family: var(--font-mono);
font-size: var(--step-0);
color: var(--inlay-faint);
}
</style>
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import { STRING_LABELS } from '../../drills'
import { pcAt, type Pos } from '../../lib/music/fretboard'
interface Props {
/** Positions to mark. */
positions?: Pos[]
/** Subset of positions drawn as roots (amber). */
roots?: Pos[]
/** A single position to emphasise (e.g. T1 current note). */
focus?: Pos | null
fromFret?: number
toFret?: number
/** Show note names inside the markers. */
labels?: boolean
}
let {
positions = [],
roots = [],
focus = null,
fromFret = 0,
toFret = 12,
labels = false,
}: Props = $props()
const STRINGS = 6
const INLAYS = [3, 5, 7, 9, 12, 15, 17, 19, 21]
const rowH = 22
const openW = 26
const fretW = 34
const padL = 20
const padT = 14
let fretCount = $derived(toFret - fromFret)
let width = $derived(padL + openW + fretCount * fretW + 12)
let height = $derived(padT * 2 + (STRINGS - 1) * rowH)
function keyOf(p: Pos): string {
return `${p.string}:${p.fret}`
}
let rootSet = $derived(new Set(roots.map(keyOf)))
// x-centre of a fret's marker (fret 0 = open area left of the nut).
function fretX(fret: number): number {
if (fret === 0) return padL + openW / 2
return padL + openW + (fret - fromFret - 0.5) * fretW
}
function fretLineX(fret: number): number {
return padL + openW + (fret - fromFret) * fretW
}
function stringY(string: number): number {
return padT + string * rowH
}
let visible = $derived(
positions.filter((p) => p.fret >= fromFret && p.fret <= toFret),
)
</script>
<div class="wrap">
<svg viewBox="0 0 {width} {height}" style="max-width:{width}px" role="img" aria-label="Fretboard diagram">
<!-- Inlay markers -->
{#each INLAYS as f}
{#if f > fromFret && f <= toFret}
<circle class="inlay" cx={fretX(f)} cy={padT + ((STRINGS - 1) * rowH) / 2} r={f % 12 === 0 ? 5 : 3.5} />
{/if}
{/each}
<!-- Frets -->
{#each Array(fretCount + 1) as _, i}
<line
class="fretline"
class:nut={fromFret + i === 0}
x1={fretLineX(fromFret + i)}
y1={stringY(0)}
x2={fretLineX(fromFret + i)}
y2={stringY(STRINGS - 1)}
/>
{/each}
<!-- Strings + labels -->
{#each Array(STRINGS) as _, s}
<line class="string" x1={padL} y1={stringY(s)} x2={fretLineX(toFret)} y2={stringY(s)} />
<text class="slabel" x={padL - 8} y={stringY(s) + 3} text-anchor="end">{STRING_LABELS[s]}</text>
{/each}
<!-- Fret numbers -->
{#each Array(fretCount + 1) as _, i}
{#if (fromFret + i) % 2 === 1 || fromFret + i === 12}
<text class="fnum" x={fretX(fromFret + i)} y={height - 1} text-anchor="middle">{fromFret + i}</text>
{/if}
{/each}
<!-- Highlighted notes -->
{#each visible as p (keyOf(p))}
<g class="note" class:root={rootSet.has(keyOf(p))} class:focus={focus && focus.string === p.string && focus.fret === p.fret}>
<circle cx={fretX(p.fret)} cy={stringY(p.string)} r="8.5" />
{#if labels}
<text x={fretX(p.fret)} y={stringY(p.string) + 3} text-anchor="middle">{pcAt(p.string, p.fret)}</text>
{/if}
</g>
{/each}
</svg>
</div>
<style>
.wrap {
overflow-x: auto;
margin-bottom: 1.5rem;
-webkit-overflow-scrolling: touch;
}
svg {
height: auto;
display: block;
}
.inlay {
fill: var(--fretwire-dim);
}
.fretline {
stroke: var(--fretwire-dim);
stroke-width: 1;
}
.fretline.nut {
stroke: var(--inlay-dim);
stroke-width: 3;
}
.string {
stroke: var(--fretwire);
stroke-width: 1;
opacity: 0.5;
}
.slabel,
.fnum {
fill: var(--inlay-faint);
font-family: var(--font-mono);
font-size: 10px;
}
.note circle {
fill: var(--inlay-dim);
}
.note text {
fill: var(--bench);
font-family: var(--font-mono);
font-size: 9px;
font-weight: 600;
}
.note.root circle {
fill: var(--tubeglow);
}
.note.focus circle {
fill: var(--tubeglow);
stroke: var(--inlay);
stroke-width: 1.5;
}
</style>
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import type { TheoryDrill } from '../../drills'
import { CHROMATIC_ROOTS } from '../../lib/music/theory'
import { cagedShapes } from '../../lib/music/caged'
import * as storage from '../../lib/storage'
import { router } from '../../router.svelte'
import DrillLayout from '../common/DrillLayout.svelte'
import Fretboard from '../diagrams/Fretboard.svelte'
interface Props {
drill: TheoryDrill
params: URLSearchParams
}
let { drill, params }: Props = $props()
const KEYS = ['C', 'G', 'D', 'A', 'E', 'F']
function initialKey(): string {
const q = params.get('key')
if (q && CHROMATIC_ROOTS.includes(q as never)) return q
const stored = storage.getString(`${drill.code}:key`, 'C')
return stored
}
let key = $state(initialKey())
let shapes = $derived(cagedShapes(key))
let idx = $state(0)
let shape = $derived(shapes[Math.min(idx, shapes.length - 1)])
let window = $derived({
from: Math.max(0, shape.barreFret - 1),
to: Math.min(15, shape.barreFret + 4),
})
function selectKey(k: string) {
key = k
idx = 0
storage.setString(`${drill.code}:key`, k)
router.setQuery({ key: k })
}
function prev() {
idx = (idx - 1 + shapes.length) % shapes.length
}
function next() {
idx = (idx + 1) % shapes.length
}
</script>
<DrillLayout code={drill.code} name={drill.name} category={drill.category} description={drill.description}>
<div class="keys">
<span class="eyebrow">Key</span>
<div class="chips">
{#each KEYS as k}
<button class="chip" class:sel={k === key} onclick={() => selectKey(k)}>{k}</button>
{/each}
</div>
</div>
<div class="shapehead">
<button class="nav" onclick={prev} aria-label="Previous shape"></button>
<div class="shapename">
<span class="form">{shape.form}</span>
<span class="pos num">{shape.positionLabel}</span>
</div>
<button class="nav" onclick={next} aria-label="Next shape"></button>
</div>
<Fretboard
positions={shape.positions}
roots={shape.rootPositions}
fromFret={window.from}
toFret={window.to}
labels
/>
<p class="hint">Amber notes are the root ({key}). Step through all five shapes to cover the neck.</p>
</DrillLayout>
<style>
.keys {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.chips {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.chip {
min-width: 3rem;
padding: 0.5rem 0.75rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: 999px;
color: var(--inlay-dim);
font-family: var(--font-mono);
}
.chip.sel {
color: var(--tubeglow);
border-color: var(--tubeglow-soft);
background: color-mix(in srgb, var(--tubeglow) 10%, transparent);
}
.shapehead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.nav {
width: 3rem;
height: 3rem;
font-size: var(--step-3);
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius);
color: var(--inlay);
}
.nav:hover {
border-color: var(--tubeglow-soft);
}
.shapename {
display: flex;
flex-direction: column;
align-items: center;
}
.form {
font-family: var(--font-display);
font-size: var(--step-3);
}
.pos {
color: var(--inlay-faint);
font-size: var(--step-0);
}
.hint {
color: var(--inlay-dim);
font-size: var(--step-0);
}
</style>
+199
View File
@@ -0,0 +1,199 @@
<script lang="ts">
import { onDestroy } from 'svelte'
import type { ChangesDrill } from '../../drills'
import { CHANGE_PAIRS, getChordShape } from '../../lib/music/chords'
import * as storage from '../../lib/storage'
import { router } from '../../router.svelte'
import DrillLayout from '../common/DrillLayout.svelte'
import ChordDiagram from '../diagrams/ChordDiagram.svelte'
interface Props {
drill: ChangesDrill
params: URLSearchParams
}
let { drill, params }: Props = $props()
const DURATION = 60
function initialPair(): string {
const q = params.get('pair')
if (q && CHANGE_PAIRS.some((p) => p.id === q)) return q
const stored = storage.getString(`${drill.code}:pair`, CHANGE_PAIRS[0].id)
return CHANGE_PAIRS.some((p) => p.id === stored) ? stored : CHANGE_PAIRS[0].id
}
let pairId = $state(initialPair())
let pair = $derived(CHANGE_PAIRS.find((p) => p.id === pairId) ?? CHANGE_PAIRS[0])
let count = $state(0)
let timeLeft = $state(DURATION)
let running = $state(false)
let finished = $state(false)
let timer: ReturnType<typeof setInterval> | null = null
let best = $derived(storage.getNumber(`${drill.code}:best:${pairId}`, 0))
let beatBest = $derived(finished && count > 0 && count >= best)
function start() {
count = 0
timeLeft = DURATION
finished = false
running = true
timer = setInterval(() => {
timeLeft -= 1
if (timeLeft <= 0) stop()
}, 1000)
}
function stop() {
running = false
finished = true
if (timer) clearInterval(timer)
timer = null
if (count > storage.getNumber(`${drill.code}:best:${pairId}`, 0)) {
storage.setNumber(`${drill.code}:best:${pairId}`, count)
}
}
function tap() {
if (running) count += 1
}
function selectPair(id: string) {
if (running) return
pairId = id
finished = false
count = 0
timeLeft = DURATION
storage.setString(`${drill.code}:pair`, id)
router.setQuery({ pair: id })
}
function shuffle() {
if (running) return
// Cycle to a different pair deterministically.
const idx = CHANGE_PAIRS.findIndex((p) => p.id === pairId)
selectPair(CHANGE_PAIRS[(idx + 1) % CHANGE_PAIRS.length].id)
}
onDestroy(() => {
if (timer) clearInterval(timer)
})
</script>
<DrillLayout code={drill.code} name={drill.name} category={drill.category} description={drill.description}>
<div class="chords">
{#each [pair.a, pair.b] as name}
{@const shape = getChordShape(name)}
{#if shape}
<ChordDiagram {shape} label={name} size={130} />
{/if}
{/each}
</div>
<div class="stats">
<div class="stat">
<span class="eyebrow">Time</span>
<span class="val num" class:low={timeLeft <= 10 && running}>{timeLeft}s</span>
</div>
<div class="stat">
<span class="eyebrow">Best</span>
<span class="val num">{best || '—'}</span>
</div>
</div>
<button class="counter" class:running onclick={tap} disabled={!running}>
<span class="count num">{count}</span>
<span class="hint">{running ? 'tap on every clean change' : 'press start, then tap'}</span>
</button>
{#if beatBest}
<p class="new-best">New best — {count} changes!</p>
{/if}
<div class="controls">
{#if running}
<button class="primary stop" onclick={stop}>Stop</button>
{:else}
<button class="primary" onclick={start}>Start 60 seconds</button>
{/if}
</div>
<div class="pairs">
<div class="pairs-head">
<span class="eyebrow">Chord pair</span>
<button class="shuffle" onclick={shuffle} disabled={running}>Shuffle ⤮</button>
</div>
<div class="chips">
{#each CHANGE_PAIRS as p}
<button class="chip" class:sel={p.id === pairId} onclick={() => selectPair(p.id)} disabled={running}>
{p.a}{p.b}
</button>
{/each}
</div>
</div>
</DrillLayout>
<style>
.chords { display: flex; justify-content: center; gap: 1.5rem; margin-bottom: 1.5rem; }
.stats { display: flex; gap: 1rem; margin-bottom: 1rem; }
.stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.6rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius);
}
.val { font-size: var(--step-3); }
.val.low { color: var(--tubeglow); }
.counter {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 1.5rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius-lg);
}
.counter.running { border-color: var(--tubeglow-soft); }
.counter:disabled { opacity: 0.6; }
.count { font-size: clamp(4rem, 24vw, 7rem); line-height: 0.9; color: var(--tubeglow); }
.hint { color: var(--inlay-faint); font-size: var(--step-0); }
.new-best { color: var(--patina); text-align: center; margin: 0.75rem 0 0; }
.controls { margin-top: 1rem; }
.primary {
width: 100%;
padding: 0.9rem;
font-size: var(--step-2);
font-family: var(--font-display);
background: var(--tubeglow);
color: #201607;
border: none;
border-radius: var(--radius);
font-weight: 600;
}
.primary.stop { background: transparent; color: var(--tubeglow); border: 1px solid var(--tubeglow-soft); }
.pairs { margin-top: 1.5rem; }
.pairs-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
.shuffle {
background: transparent;
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius);
color: var(--inlay-dim);
padding: 0.35rem 0.6rem;
font-size: var(--step-0);
}
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.chip {
padding: 0.5rem 0.75rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: 999px;
color: var(--inlay-dim);
font-family: var(--font-mono);
font-size: var(--step-0);
}
.chip.sel { color: var(--tubeglow); border-color: var(--tubeglow-soft); }
.chip:disabled, .shuffle:disabled { opacity: 0.5; }
</style>
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import type { TheoryDrill } from '../../drills'
import { diatonicChords } from '../../lib/music/theory'
import { getChordShape } from '../../lib/music/chords'
import * as storage from '../../lib/storage'
import { router } from '../../router.svelte'
import DrillLayout from '../common/DrillLayout.svelte'
import ChordDiagram from '../diagrams/ChordDiagram.svelte'
interface Props {
drill: TheoryDrill
params: URLSearchParams
}
let { drill, params }: Props = $props()
const KEYS = ['G', 'D', 'C', 'A']
function initialKey(): string {
const q = params.get('key')
if (q && KEYS.includes(q)) return q
const stored = storage.getString(`${drill.code}:key`, 'G')
return KEYS.includes(stored) ? stored : 'G'
}
let key = $state(initialKey())
let chords = $derived(diatonicChords(key))
function selectKey(k: string) {
key = k
storage.setString(`${drill.code}:key`, k)
router.setQuery({ key: k })
}
</script>
<DrillLayout code={drill.code} name={drill.name} category={drill.category} description={drill.description}>
<div class="keys">
<span class="eyebrow">Key</span>
<div class="chips">
{#each KEYS as k}
<button class="chip" class:sel={k === key} onclick={() => selectKey(k)}>{k}</button>
{/each}
</div>
</div>
<div class="grid">
{#each chords as c}
{@const shape = getChordShape(c.name)}
<div class="deg">
<span class="roman num">{c.degree}</span>
{#if shape}
<ChordDiagram {shape} label={c.name} size={104} />
{:else}
<div class="fallback">
<span class="name">{c.name}</span>
<span class="qual">{c.quality}</span>
</div>
{/if}
</div>
{/each}
</div>
</DrillLayout>
<style>
.keys {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.chips {
display: flex;
gap: 0.5rem;
}
.chip {
min-width: 3rem;
padding: 0.55rem 0.8rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: 999px;
color: var(--inlay-dim);
font-family: var(--font-mono);
}
.chip.sel {
color: var(--tubeglow);
border-color: var(--tubeglow-soft);
background: color-mix(in srgb, var(--tubeglow) 10%, transparent);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
gap: 1rem 0.75rem;
}
.deg {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
}
.roman {
color: var(--inlay-faint);
font-size: var(--step-1);
}
.fallback {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 150px;
width: 104px;
border: 1px dashed var(--fretwire-dim);
border-radius: var(--radius);
}
.fallback .name {
font-family: var(--font-display);
font-size: var(--step-2);
}
.fallback .qual {
font-size: var(--step-0);
color: var(--inlay-faint);
}
</style>
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import type { TheoryDrill } from '../../drills'
import { CHROMATIC_ROOTS, INTERVALS, chroma, intervalTarget } from '../../lib/music/theory'
import { positionsForChromas } from '../../lib/music/fretboard'
import * as storage from '../../lib/storage'
import { router } from '../../router.svelte'
import DrillLayout from '../common/DrillLayout.svelte'
import Fretboard from '../diagrams/Fretboard.svelte'
interface Props {
drill: TheoryDrill
params: URLSearchParams
}
let { drill, params }: Props = $props()
const initRoot = () => params.get('root') ?? storage.getString(`${drill.code}:root`, 'A')
const initInterval = () => params.get('interval') ?? storage.getString(`${drill.code}:interval`, 'P5')
let root = $state(initRoot())
let intervalId = $state(initInterval())
let interval = $derived(INTERVALS.find((i) => i.id === intervalId) ?? INTERVALS[3])
let target = $derived(intervalTarget(root, interval.semitones))
let rootPos = $derived(positionsForChromas(new Set([chroma(root)]), 12))
let targetPos = $derived(positionsForChromas(new Set([chroma(target)]), 12))
let all = $derived([...rootPos, ...targetPos])
function selectRoot(r: string) {
root = r
storage.setString(`${drill.code}:root`, r)
router.setQuery({ root: r })
}
function selectInterval(id: string) {
intervalId = id
storage.setString(`${drill.code}:interval`, id)
router.setQuery({ interval: id })
}
</script>
<DrillLayout code={drill.code} name={drill.name} category={drill.category} description={drill.description}>
<div class="picker">
<span class="eyebrow">Root</span>
<div class="chips small">
{#each CHROMATIC_ROOTS as r}
<button class="chip" class:sel={r === root} onclick={() => selectRoot(r)}>{r}</button>
{/each}
</div>
</div>
<div class="picker">
<span class="eyebrow">Interval</span>
<div class="chips">
{#each INTERVALS as iv}
<button class="chip" class:sel={iv.id === intervalId} onclick={() => selectInterval(iv.id)}>
{iv.label}
</button>
{/each}
</div>
</div>
<p class="legend">
<span class="swatch root"></span> root <span class="mono num">{root}</span>
<span class="swatch tgt"></span> {interval.label} <span class="mono num">{target}</span>
</p>
<Fretboard positions={all} roots={rootPos} fromFret={0} toFret={12} labels />
</DrillLayout>
<style>
.picker {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.chips {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.chip {
padding: 0.45rem 0.7rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: 999px;
color: var(--inlay-dim);
font-family: var(--font-mono);
font-size: var(--step-0);
}
.chips.small .chip {
min-width: 2.6rem;
text-align: center;
padding: 0.4rem 0.4rem;
}
.chip.sel {
color: var(--tubeglow);
border-color: var(--tubeglow-soft);
background: color-mix(in srgb, var(--tubeglow) 10%, transparent);
}
.legend {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--inlay-dim);
font-size: var(--step-0);
margin: 0.25rem 0 1rem;
}
.swatch {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
.swatch.root {
background: var(--tubeglow);
}
.swatch.tgt {
background: var(--inlay-dim);
margin-left: 0.75rem;
}
.mono {
color: var(--inlay);
}
</style>
@@ -0,0 +1,181 @@
<script lang="ts">
import { onDestroy } from 'svelte'
import type { TheoryDrill } from '../../drills'
import { STRING_LABELS } from '../../drills'
import { pcAt } from '../../lib/music/fretboard'
import { MetronomeController } from '../../lib/audio/metronomeController.svelte'
import * as storage from '../../lib/storage'
import DrillLayout from '../common/DrillLayout.svelte'
import Transport from '../metronome/Transport.svelte'
import Fretboard from '../diagrams/Fretboard.svelte'
interface Props {
drill: TheoryDrill
params: URLSearchParams
}
let { drill, params }: Props = $props()
const initString = () => storage.getNumber(`${drill.code}:string`, 5)
const initRandom = () => storage.getString(`${drill.code}:order`, 'seq') === 'rand'
let stringIdx = $state(initString())
let randomOrder = $state(initRandom())
function initialBpm(): number {
const q = Number(params.get('bpm'))
if (Number.isFinite(q) && q > 0) return q
return storage.getDrillBpm(drill.code, drill.defaultBpm ?? 60)
}
const controller = new MetronomeController(initialBpm())
// Quarter-note click; the prompter advances on each beat.
controller.setPattern({ beatsPerBar: 4, stepsPerBeat: 1, roles: ['accent', 'normal', 'normal', 'normal'] })
function makeOrder(): number[] {
const seq = Array.from({ length: 13 }, (_, i) => i)
if (!randomOrder) return seq
// Deterministic-ish shuffle without Math.random dependence at module load.
for (let i = seq.length - 1; i > 0; i--) {
const j = (i * 7 + 3) % (i + 1)
;[seq[i], seq[j]] = [seq[j], seq[i]]
}
return seq
}
let order = $state(makeOrder())
let orderPos = $state(0)
let revealed = $state(false)
let fret = $derived(order[orderPos])
let note = $derived(pcAt(stringIdx, fret))
// Advance on every metronome step change: recall, then confirm.
let prevStep = -1
$effect(() => {
const step = controller.currentStep
if (controller.isPlaying && step !== prevStep && prevStep !== -1) {
advance()
}
prevStep = step
})
function advance() {
if (!revealed) {
revealed = true
} else {
revealed = false
orderPos = (orderPos + 1) % order.length
}
}
function persistBpm(bpm: number) {
storage.setDrillBpm(drill.code, bpm)
}
function selectString(i: number) {
stringIdx = i
storage.setNumber(`${drill.code}:string`, i)
reset()
}
function toggleOrder() {
randomOrder = !randomOrder
storage.setString(`${drill.code}:order`, randomOrder ? 'rand' : 'seq')
order = makeOrder()
reset()
}
function reset() {
orderPos = 0
revealed = false
}
onDestroy(() => controller.destroy())
</script>
<DrillLayout code={drill.code} name={drill.name} category={drill.category} description={drill.description}>
<div class="picker">
<span class="eyebrow">String</span>
<div class="chips">
{#each STRING_LABELS as label, i}
<button class="chip" class:sel={i === stringIdx} onclick={() => selectString(i)}>
{label}
</button>
{/each}
</div>
</div>
<div class="prompt">
<div class="fret-readout">
<span class="eyebrow">Fret</span>
<span class="big num">{fret}</span>
</div>
<div class="note-readout" class:revealed>
<span class="eyebrow">Note</span>
<span class="big num">{revealed ? note : '·'}</span>
</div>
</div>
<Fretboard
positions={[{ string: stringIdx, fret }]}
focus={{ string: stringIdx, fret }}
fromFret={0}
toFret={12}
labels={revealed}
/>
<Transport {controller} stepsPerBeat={1} onBpmCommit={persistBpm} />
<div class="opts">
<button class="ctl" class:on={randomOrder} onclick={toggleOrder}>
{randomOrder ? 'Random frets' : 'In order (0→12)'}
</button>
<button class="ctl" onclick={reset}>Reset</button>
</div>
</DrillLayout>
<style>
.picker { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.25rem; }
.chips { display: flex; gap: 0.5rem; }
.chip {
min-width: 2.8rem;
padding: 0.5rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius);
color: var(--inlay-dim);
font-family: var(--font-mono);
text-align: center;
}
.chip.sel { color: var(--tubeglow); border-color: var(--tubeglow-soft); }
.prompt {
display: flex;
gap: 1rem;
margin-bottom: 1.25rem;
}
.fret-readout,
.note-readout {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
padding: 1rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius-lg);
}
.big { font-size: clamp(3rem, 16vw, 4.5rem); line-height: 1; }
.fret-readout .big { color: var(--inlay); }
.note-readout .big { color: var(--inlay-faint); }
.note-readout.revealed .big {
color: var(--tubeglow);
text-shadow: 0 0 18px rgba(242, 160, 61, 0.5);
}
.opts { display: flex; gap: 0.6rem; margin-top: 1rem; }
.ctl {
flex: 1;
padding: 0.6rem;
background: transparent;
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius);
color: var(--inlay-dim);
font-size: var(--step-0);
}
.ctl.on { color: var(--tubeglow); border-color: var(--tubeglow-soft); }
</style>
+8 -1
View File
@@ -8,6 +8,8 @@
import DrillLayout from '../common/DrillLayout.svelte'
import Transport from '../metronome/Transport.svelte'
import PickingLane from '../patterns/PickingLane.svelte'
import ChordDiagram from '../diagrams/ChordDiagram.svelte'
import { getChordShape } from '../../lib/music/chords'
interface Props {
drill: PickingDrill
@@ -89,10 +91,15 @@
{#if drill.chordLabels && drill.chordLabels.length}
<div class="chords">
<span class="eyebrow">Chords{drill.chordLabels.length > 1 ? ' (per bar)' : ''}</span>
<span class="eyebrow">Chords{drill.chordLabels.length > 1 ? ' (one per bar)' : ''}</span>
<div class="chip-row">
{#each drill.chordLabels as c}
{@const shape = getChordShape(c)}
{#if shape}
<ChordDiagram {shape} label={c} size={96} />
{:else}
<span class="chord num">{c}</span>
{/if}
{/each}
</div>
</div>
@@ -0,0 +1,102 @@
<script lang="ts">
import { Note } from 'tonal'
import type { TheoryDrill } from '../../drills'
import { CHROMATIC_ROOTS } from '../../lib/music/theory'
import { playTone } from '../../lib/audio/tone'
import * as storage from '../../lib/storage'
import DrillLayout from '../common/DrillLayout.svelte'
interface Props {
drill: TheoryDrill
params: URLSearchParams
}
let { drill, params }: Props = $props()
const OCTAVES = [2, 3, 4]
const initNote = () => params.get('note') ?? storage.getString(`${drill.code}:note`, 'A')
const initOctave = () => storage.getNumber(`${drill.code}:oct`, 3)
let noteName = $state(initNote())
let octave = $state(initOctave())
let midi = $derived(Note.midi(`${noteName}${octave}`) ?? 57)
let playing = $state(false)
async function play() {
playing = true
await playTone(midi, 1.6)
setTimeout(() => (playing = false), 1600)
}
function selectNote(n: string) {
noteName = n
storage.setString(`${drill.code}:note`, n)
}
function selectOctave(o: number) {
octave = o
storage.setNumber(`${drill.code}:oct`, o)
}
</script>
<DrillLayout code={drill.code} name={drill.name} category={drill.category} description={drill.description}>
<div class="readout">
<span class="note num">{noteName}<sub>{octave}</sub></span>
</div>
<button class="play" class:playing onclick={play}> Play reference</button>
<div class="picker">
<span class="eyebrow">Target note</span>
<div class="chips small">
{#each CHROMATIC_ROOTS as n}
<button class="chip" class:sel={n === noteName} onclick={() => selectNote(n)}>{n}</button>
{/each}
</div>
</div>
<div class="picker">
<span class="eyebrow">Octave</span>
<div class="chips">
{#each OCTAVES as o}
<button class="chip" class:sel={o === octave} onclick={() => selectOctave(o)}>{o}</button>
{/each}
</div>
</div>
<p class="hint">Play the tone, then bend up to match it. Hold your bend against the ringing reference.</p>
</DrillLayout>
<style>
.readout {
display: flex;
justify-content: center;
padding: 1.5rem 0 1rem;
}
.note { font-size: clamp(4rem, 24vw, 7rem); color: var(--tubeglow); line-height: 1; }
.note sub { font-size: 0.4em; color: var(--inlay-faint); }
.play {
width: 100%;
padding: 0.9rem;
font-size: var(--step-2);
font-family: var(--font-display);
background: var(--tubeglow);
color: #201607;
border: none;
border-radius: var(--radius);
font-weight: 600;
margin-bottom: 1.5rem;
}
.play.playing { filter: brightness(1.1); }
.picker { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
.chips { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.chip {
padding: 0.5rem 0.7rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: 999px;
color: var(--inlay-dim);
font-family: var(--font-mono);
}
.chips.small .chip { min-width: 2.6rem; text-align: center; padding: 0.45rem 0.4rem; font-size: var(--step-0); }
.chip.sel { color: var(--tubeglow); border-color: var(--tubeglow-soft); }
.hint { color: var(--inlay-dim); font-size: var(--step-0); }
</style>
+152
View File
@@ -0,0 +1,152 @@
<script lang="ts">
import { onDestroy } from 'svelte'
import type { TheoryDrill } from '../../drills'
import { CHROMATIC_ROOTS } from '../../lib/music/theory'
import { STRING_SETS, INVERSIONS, triadShape, type Quality } from '../../lib/music/triads'
import { MetronomeController } from '../../lib/audio/metronomeController.svelte'
import * as storage from '../../lib/storage'
import DrillLayout from '../common/DrillLayout.svelte'
import ChordDiagram from '../diagrams/ChordDiagram.svelte'
interface Props {
drill: TheoryDrill
params: URLSearchParams
}
let { drill, params }: Props = $props()
const initRoot = () => params.get('root') ?? storage.getString(`${drill.code}:root`, 'C')
const initQuality = () => storage.getString(`${drill.code}:quality`, 'major') as Quality
const initSet = () => storage.getString(`${drill.code}:set`, '123')
const initBpm = () => drill.defaultBpm ?? 60
let root = $state(initRoot())
let quality = $state<Quality>(initQuality())
let setId = $state(initSet())
let invIdx = $state(0)
let auto = $state(false)
let set = $derived(STRING_SETS.find((s) => s.id === setId) ?? STRING_SETS[0])
let inversion = $derived(INVERSIONS[invIdx])
let shape = $derived(triadShape(root, quality, set, inversion.id))
const controller = new MetronomeController(initBpm())
controller.setPattern({ beatsPerBar: 4, stepsPerBeat: 1, roles: ['accent', 'normal', 'normal', 'normal'] })
// Auto-advance every 2 bars when the click is running.
let prevStep = -1
let bars = 0
$effect(() => {
const step = controller.currentStep
if (auto && controller.isPlaying) {
if (prevStep === 3 && step === 0) {
bars++
if (bars % 2 === 0) invIdx = (invIdx + 1) % INVERSIONS.length
}
}
prevStep = step
})
function persist() {
storage.setString(`${drill.code}:root`, root)
storage.setString(`${drill.code}:quality`, quality)
storage.setString(`${drill.code}:set`, setId)
}
function prev() {
invIdx = (invIdx - 1 + INVERSIONS.length) % INVERSIONS.length
}
function next() {
invIdx = (invIdx + 1) % INVERSIONS.length
}
onDestroy(() => controller.destroy())
</script>
<DrillLayout code={drill.code} name={drill.name} category={drill.category} description={drill.description}>
<div class="rows">
<div class="picker">
<span class="eyebrow">Root</span>
<div class="chips small">
{#each CHROMATIC_ROOTS as r}
<button class="chip" class:sel={r === root} onclick={() => { root = r; persist() }}>{r}</button>
{/each}
</div>
</div>
<div class="picker inline">
<div>
<span class="eyebrow">Quality</span>
<div class="chips">
<button class="chip" class:sel={quality === 'major'} onclick={() => { quality = 'major'; persist() }}>Major</button>
<button class="chip" class:sel={quality === 'minor'} onclick={() => { quality = 'minor'; persist() }}>Minor</button>
</div>
</div>
<div>
<span class="eyebrow">String set</span>
<div class="chips">
{#each STRING_SETS as s}
<button class="chip" class:sel={s.id === setId} onclick={() => { setId = s.id; persist() }}>{s.label}</button>
{/each}
</div>
</div>
</div>
</div>
<div class="stage">
<button class="nav" onclick={prev} aria-label="Previous inversion"></button>
<div class="diagram">
<ChordDiagram {shape} label={shape.name} positionLabel={inversion.label} size={140} />
</div>
<button class="nav" onclick={next} aria-label="Next inversion"></button>
</div>
<div class="metro">
<button class="ctl" class:on={controller.isPlaying} onclick={() => controller.toggle()}>
{controller.isPlaying ? 'Stop click' : 'Play click'} · {controller.bpm} bpm
</button>
<label class="toggle">
<input type="checkbox" bind:checked={auto} /> Auto-advance (2 bars)
</label>
</div>
</DrillLayout>
<style>
.rows { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 1.25rem; }
.picker { display: flex; flex-direction: column; gap: 0.5rem; }
.picker.inline { flex-direction: row; gap: 1.5rem; flex-wrap: wrap; }
.chips { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.chip {
padding: 0.45rem 0.7rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: 999px;
color: var(--inlay-dim);
font-size: var(--step-0);
font-family: var(--font-mono);
}
.chips.small .chip { min-width: 2.6rem; text-align: center; padding: 0.4rem; }
.chip.sel {
color: var(--tubeglow);
border-color: var(--tubeglow-soft);
background: color-mix(in srgb, var(--tubeglow) 10%, transparent);
}
.stage { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; margin-bottom: 1.25rem; }
.diagram { flex: 1; display: flex; justify-content: center; }
.nav {
width: 3rem; height: 3rem; font-size: var(--step-3);
background: var(--walnut); border: 1px solid var(--fretwire-dim);
border-radius: var(--radius); color: var(--inlay);
}
.nav:hover { border-color: var(--tubeglow-soft); }
.metro { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.ctl {
padding: 0.7rem 1rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius);
color: var(--inlay);
font-family: var(--font-mono);
font-size: var(--step-0);
}
.ctl.on { color: var(--tubeglow); border-color: var(--tubeglow-soft); }
.toggle { display: flex; align-items: center; gap: 0.5rem; color: var(--inlay-dim); font-size: var(--step-0); }
.toggle input { accent-color: var(--tubeglow); width: 1.1rem; height: 1.1rem; }
</style>
+100 -3
View File
@@ -81,8 +81,37 @@ export interface PickingDrill extends DrillBase, Meter {
chordLabels?: string[]
}
// Union grows as later phases add kinds (chords, fretboard, drone, info…).
export type Drill = StrumDrill | PickingDrill
/** R4 — one-minute chord changes (countdown + tap counter, no metronome). */
export interface ChangesDrill extends DrillBase {
kind: 'changes'
}
/** Theory + reference drills; most own their own state in the view. */
export interface TheoryDrill extends DrillBase {
kind:
| 'note-prompter' // T1
| 'caged' // T2
| 'triads' // T3
| 'diatonic' // T4
| 'intervals' // T5
| 'mode-drone' // T6
| 'reference-tone' // L4
defaultBpm?: number
}
/** Simple description pages (E1E3); some carry a drone. */
export interface InfoDrill extends DrillBase {
kind: 'info'
body: string[]
hasDrone?: boolean
}
export type Drill =
| StrumDrill
| PickingDrill
| ChangesDrill
| TheoryDrill
| InfoDrill
/** Drills that run the metronome and persist a BPM. */
export type ClickDrill = StrumDrill | PickingDrill
@@ -388,9 +417,77 @@ const L3: PickingDrill = {
],
}
// R4 — one-minute chord changes
const R4: ChangesDrill = {
code: 'R4',
name: 'One-minute chord changes',
category: 'Rhythm',
description: 'Pick a chord pair and count clean changes in 60 seconds. Beat your best.',
kind: 'changes',
}
// ==============================================================================
// THEORY
// ==============================================================================
export const DRILLS: Drill[] = [R1, R2, R3, R5, F1, F2, F3, L1, L2, L3]
const T1: TheoryDrill = {
code: 'T1',
name: 'Name every note on one string',
category: 'Theory',
description: 'The metronome steps up a string fret by fret. Say the note before it reveals.',
kind: 'note-prompter',
defaultBpm: 60,
}
const T2: TheoryDrill = {
code: 'T2',
name: 'CAGED shapes',
category: 'Theory',
description: 'Walk the five major-chord shapes up the neck for a chosen key.',
kind: 'caged',
}
const T3: TheoryDrill = {
code: 'T3',
name: 'Triads on a 3-string set',
category: 'Theory',
description: 'Step through the three inversions of a triad on a string set.',
kind: 'triads',
defaultBpm: 60,
}
const T4: TheoryDrill = {
code: 'T4',
name: 'Diatonic chords of a key',
category: 'Theory',
description: 'The seven chords of a key with their degree, quality, and shape.',
kind: 'diatonic',
}
const T5: TheoryDrill = {
code: 'T5',
name: 'Intervals from a root',
category: 'Theory',
description: 'See a root and an interval shape on adjacent strings.',
kind: 'intervals',
}
// ==============================================================================
// LEAD — reference
// ==============================================================================
const L4: TheoryDrill = {
code: 'L4',
name: 'Bends and vibrato',
category: 'Lead',
description: 'Play a target pitch and match your bend to it by ear.',
kind: 'reference-tone',
}
// ==============================================================================
export const DRILLS: Drill[] = [
R1, R2, R3, R4, R5,
F1, F2, F3,
T1, T2, T3, T4, T5,
L1, L2, L3, L4,
]
export const DRILLS_BY_CODE: Record<string, Drill> = Object.fromEntries(
DRILLS.map((d) => [d.code, d]),
+44
View File
@@ -0,0 +1,44 @@
// Short sustained reference tone (for L4 bend checks and T1 note reveals).
// Reuses the shared AudioContext; a plucked-ish envelope on a filtered sawtooth.
import { audio } from './AudioEngine'
export function midiToFreq(midi: number): number {
return 440 * Math.pow(2, (midi - 69) / 12)
}
/** Play a reference tone at the given MIDI note for `duration` seconds. */
export async function playTone(midi: number, duration = 1.4): Promise<void> {
await audio.unlock()
const ctx = audio.context
const now = ctx.currentTime
const osc = ctx.createOscillator()
const osc2 = ctx.createOscillator()
const gain = ctx.createGain()
const filter = ctx.createBiquadFilter()
const freq = midiToFreq(midi)
osc.type = 'sawtooth'
osc2.type = 'sawtooth'
osc.frequency.value = freq
osc2.frequency.value = freq
osc2.detune.value = 6
filter.type = 'lowpass'
filter.frequency.value = Math.min(freq * 6, 6000)
gain.gain.setValueAtTime(0.0001, now)
gain.gain.exponentialRampToValueAtTime(0.5, now + 0.02)
gain.gain.exponentialRampToValueAtTime(0.0001, now + duration)
osc.connect(filter)
osc2.connect(filter)
filter.connect(gain)
gain.connect(audio.master)
osc.start(now)
osc2.start(now)
osc.stop(now + duration + 0.05)
osc2.stop(now + duration + 0.05)
}
+84
View File
@@ -0,0 +1,84 @@
// CAGED major-chord shapes across the neck for a given key. Each canonical form is
// stored relative to its barre fret; the barre fret for a key is the semitone
// distance from the form's open-chord root to the key root. This tiles the neck
// with the five familiar shapes.
//
// Frets/roots are indexed 0 = high e (1st) … 5 = low E (6th) to match Fretboard.
import { chroma } from './theory'
import type { Pos } from './fretboard'
interface CagedForm {
name: 'C' | 'A' | 'G' | 'E' | 'D'
openRootChroma: number
/** Fret offset from the barre per string (index 0 = high e), null = muted. */
offsets: (number | null)[]
/** Which strings carry the root note. */
roots: boolean[]
}
const FORMS: CagedForm[] = [
{
name: 'E',
openRootChroma: chroma('E'),
offsets: [0, 0, 1, 2, 2, 0],
roots: [true, false, false, true, false, true],
},
{
name: 'D',
openRootChroma: chroma('D'),
offsets: [2, 3, 2, 0, null, null],
roots: [false, true, false, true, false, false],
},
{
name: 'C',
openRootChroma: chroma('C'),
offsets: [0, 1, 0, 2, 3, null],
roots: [false, true, false, false, true, false],
},
{
name: 'A',
openRootChroma: chroma('A'),
offsets: [0, 2, 2, 2, 0, null],
roots: [false, false, true, false, true, false],
},
{
name: 'G',
openRootChroma: chroma('G'),
offsets: [3, 0, 0, 0, 2, 3],
roots: [true, false, true, false, false, true],
},
]
export interface CagedShape {
form: string
barreFret: number
positionLabel: string
positions: Pos[]
rootPositions: Pos[]
}
/** The five CAGED shapes for `key`, ordered up the neck by barre fret. */
export function cagedShapes(key: string): CagedShape[] {
const keyChroma = chroma(key)
const shapes = FORMS.map((form) => {
const barreFret = ((keyChroma - form.openRootChroma) % 12 + 12) % 12
const positions: Pos[] = []
const rootPositions: Pos[] = []
form.offsets.forEach((off, string) => {
if (off === null) return
const fret = off + barreFret
const p = { string, fret }
positions.push(p)
if (form.roots[string]) rootPositions.push(p)
})
return {
form: `${form.name} shape`,
barreFret,
positionLabel: barreFret === 0 ? 'open' : `fret ${barreFret}`,
positions,
rootPositions,
}
})
return shapes.sort((a, b) => a.barreFret - b.barreFret)
}
+53
View File
@@ -0,0 +1,53 @@
// Curated chord shapes where playability matters (open + common barre forms).
// Frets are ABSOLUTE fret numbers, ordered low string -> high string
// (index 0 = low E / 6th string … index 5 = high e / 1st string), matching the
// conventional diagram layout with the low E on the left.
// -1 = muted 0 = open
// fingers (optional, same order): 0 = open/none, 1..4 = fretting fingers.
export interface ChordShape {
name: string
frets: number[] // length 6, low -> high
fingers?: number[]
}
const S = (
name: string,
frets: number[],
fingers?: number[],
): ChordShape => ({ name, frets, fingers })
const SHAPES: ChordShape[] = [
S('G', [3, 2, 0, 0, 0, 3], [2, 1, 0, 0, 0, 3]),
S('C', [-1, 3, 2, 0, 1, 0], [0, 3, 2, 0, 1, 0]),
S('D', [-1, -1, 0, 2, 3, 2], [0, 0, 0, 1, 3, 2]),
S('A', [-1, 0, 2, 2, 2, 0], [0, 0, 1, 2, 3, 0]),
S('E', [0, 2, 2, 1, 0, 0], [0, 2, 3, 1, 0, 0]),
S('Am', [-1, 0, 2, 2, 1, 0], [0, 0, 2, 3, 1, 0]),
S('Em', [0, 2, 2, 0, 0, 0], [0, 2, 3, 0, 0, 0]),
S('Dm', [-1, -1, 0, 2, 3, 1], [0, 0, 0, 2, 3, 1]),
S('F', [1, 3, 3, 2, 1, 1], [1, 3, 4, 2, 1, 1]),
S('Bm', [-1, 2, 4, 4, 3, 2], [0, 1, 3, 4, 2, 1]),
S('F#m', [2, 4, 4, 2, 2, 2], [1, 3, 4, 1, 1, 1]),
S('C#m', [-1, 4, 6, 6, 5, 4], [0, 1, 3, 4, 2, 1]),
]
const BY_NAME: Record<string, ChordShape> = Object.fromEntries(
SHAPES.map((s) => [s.name, s]),
)
export function getChordShape(name: string): ChordShape | undefined {
return BY_NAME[name]
}
/** Chord pairs for the R4 "one-minute changes" drill. */
export const CHANGE_PAIRS: { id: string; a: string; b: string }[] = [
{ id: 'g-c', a: 'G', b: 'C' },
{ id: 'c-d', a: 'C', b: 'D' },
{ id: 'g-d', a: 'G', b: 'D' },
{ id: 'a-d', a: 'A', b: 'D' },
{ id: 'em-c', a: 'Em', b: 'C' },
{ id: 'am-f', a: 'Am', b: 'F' },
{ id: 'f-bm', a: 'F', b: 'Bm' },
{ id: 'd-a', a: 'D', b: 'A' },
]
+45
View File
@@ -0,0 +1,45 @@
// Fretboard note mapping for standard tuning. String index 0 = high e (1st, top
// lane) … 5 = low E (6th). Uses tonal for note naming; chroma (0..11, C=0) is used
// for pitch-class comparison against scales/chords.
import { Midi } from 'tonal'
/** Open-string MIDI numbers, index 0 = high e (E4) … 5 = low E (E2). */
export const OPEN_MIDI = [64, 59, 55, 50, 45, 40] as const
export const FRET_COUNT = 15
export interface Pos {
string: number
fret: number
}
export function midiAt(string: number, fret: number): number {
return OPEN_MIDI[string] + fret
}
export function chromaAt(string: number, fret: number): number {
return midiAt(string, fret) % 12
}
export function noteNameAt(string: number, fret: number, sharps = true): string {
return Midi.midiToNoteName(midiAt(string, fret), { sharps, pitchClass: false })
}
export function pcAt(string: number, fret: number, sharps = true): string {
return Midi.midiToNoteName(midiAt(string, fret), { sharps, pitchClass: true })
}
/** All positions (within maxFret) whose pitch class is in the given chroma set. */
export function positionsForChromas(
chromas: Set<number>,
maxFret = FRET_COUNT,
): Pos[] {
const out: Pos[] = []
for (let string = 0; string < OPEN_MIDI.length; string++) {
for (let fret = 0; fret <= maxFret; fret++) {
if (chromas.has(chromaAt(string, fret))) out.push({ string, fret })
}
}
return out
}
+102
View File
@@ -0,0 +1,102 @@
// Thin wrappers over tonal for the theory drills. Keeps tonal usage in one place
// and returns plain data the components can render.
import { Scale, Key, Note, Interval } from 'tonal'
export const CHROMATIC_ROOTS = [
'C',
'C#',
'D',
'D#',
'E',
'F',
'F#',
'G',
'G#',
'A',
'A#',
'B',
] as const
export const ROMAN = ['I', 'ii', 'iii', 'IV', 'V', 'vi', 'vii°'] as const
/** Diatonic modes, in brightness order. */
export const MODES = [
{ id: 'ionian', label: 'Ionian (major)' },
{ id: 'dorian', label: 'Dorian' },
{ id: 'phrygian', label: 'Phrygian' },
{ id: 'lydian', label: 'Lydian' },
{ id: 'mixolydian', label: 'Mixolydian' },
{ id: 'aeolian', label: 'Aeolian (minor)' },
{ id: 'locrian', label: 'Locrian' },
] as const
export function chroma(note: string): number {
return Note.chroma(note) ?? 0
}
/** Pitch classes (names) of a scale/mode, e.g. scaleNotes('G','dorian'). */
export function scaleNotes(tonic: string, name: string): string[] {
return Scale.get(`${tonic} ${name}`).notes
}
export function scaleChromas(tonic: string, name: string): number[] {
return scaleNotes(tonic, name).map(chroma)
}
export interface DiatonicChord {
degree: string
name: string
quality: string
}
const QUALITIES = [
'major',
'minor',
'minor',
'major',
'major',
'minor',
'diminished',
]
export function diatonicChords(key: string): DiatonicChord[] {
const triads = Key.majorKey(key).triads
return triads.map((name, i) => ({
degree: ROMAN[i],
name,
quality: QUALITIES[i],
}))
}
/** Notes of a triad (root + third + fifth) for the given quality. */
export function triadNotes(root: string, quality: 'major' | 'minor'): string[] {
const third = quality === 'major' ? '3M' : '3m'
return [root, Note.transpose(root, third), Note.transpose(root, '5P')].map(
(n) => Note.pitchClass(n),
)
}
export interface IntervalDef {
id: string
label: string
semitones: string // tonal interval token
}
export const INTERVALS: IntervalDef[] = [
{ id: 'm3', label: 'Minor 3rd', semitones: '3m' },
{ id: 'M3', label: 'Major 3rd', semitones: '3M' },
{ id: 'P4', label: 'Perfect 4th', semitones: '4P' },
{ id: 'P5', label: 'Perfect 5th', semitones: '5P' },
{ id: 'M6', label: 'Major 6th', semitones: '6M' },
{ id: 'm7', label: 'Minor 7th', semitones: '7m' },
{ id: 'M7', label: 'Major 7th', semitones: '7M' },
]
export function intervalTarget(root: string, token: string): string {
return Note.pitchClass(Note.transpose(root + '3', token))
}
export function intervalSemitones(token: string): number {
return Interval.semitones(token) ?? 0
}
+86
View File
@@ -0,0 +1,86 @@
// Triad inversions on a three-string set, computed from chord tones + the
// fretboard map (no hand-curated tables). Returns diagram-ready shapes.
import { chromaAt } from './fretboard'
import { triadNotes, chroma } from './theory'
import type { ChordShape } from './chords'
export type Quality = 'major' | 'minor'
export interface StringSet {
id: string
label: string
/** String indices, LOW pitch first (bottom of the voicing). */
bottomToTop: number[]
}
export const STRING_SETS: StringSet[] = [
{ id: '123', label: 'Strings 1·2·3', bottomToTop: [2, 1, 0] }, // G B e
{ id: '234', label: 'Strings 2·3·4', bottomToTop: [3, 2, 1] }, // D G B
]
export const INVERSIONS = [
{ id: 'root', label: 'Root position' },
{ id: '1st', label: '1st inversion' },
{ id: '2nd', label: '2nd inversion' },
] as const
// pc order (bottom→top) for each inversion, given [root, third, fifth].
const INV_ORDER: Record<string, [number, number, number]> = {
root: [0, 1, 2],
'1st': [1, 2, 0],
'2nd': [2, 0, 1],
}
/**
* Build a triad-inversion shape on a string set. Chooses the frets that minimise
* the span (and prefer lower positions). `frets` is length 6, low→high, -1 muted.
*/
export function triadShape(
root: string,
quality: Quality,
set: StringSet,
inversionId: string,
maxFret = 15,
): ChordShape & { positionLabel: string } {
const tones = triadNotes(root, quality) // [R, 3, 5] pitch classes
const order = INV_ORDER[inversionId]
const targetChromas = order.map((i) => chroma(tones[i])) // bottom→top
// Candidate frets per string that produce the required chroma.
const candidates = set.bottomToTop.map((stringIdx, i) => {
const want = targetChromas[i]
const frets: number[] = []
for (let f = 0; f <= maxFret; f++) if (chromaAt(stringIdx, f) === want) frets.push(f)
return frets
})
// Search combinations for the tightest, lowest voicing.
let best: { frets: number[]; span: number; min: number } | null = null
for (const fb of candidates[0]) {
for (const fm of candidates[1]) {
for (const ft of candidates[2]) {
const min = Math.min(fb, fm, ft)
const span = Math.max(fb, fm, ft) - min
if (span > 4) continue
if (!best || span < best.span || (span === best.span && min < best.min)) {
best = { frets: [fb, fm, ft], span, min }
}
}
}
}
const frets = new Array<number>(6).fill(-1)
if (best) {
set.bottomToTop.forEach((stringIdx, i) => {
frets[stringIdx] = best!.frets[i]
})
}
const lowest = best ? best.min : 0
return {
name: `${root}${quality === 'minor' ? 'm' : ''}`,
frets,
positionLabel: lowest > 0 ? `fret ${lowest}` : 'open',
}
}
+25 -4
View File
@@ -3,6 +3,13 @@
import NotFound from './NotFound.svelte'
import StrumView from '../components/views/StrumView.svelte'
import PickingView from '../components/views/PickingView.svelte'
import ChangesView from '../components/views/ChangesView.svelte'
import DiatonicView from '../components/views/DiatonicView.svelte'
import CagedView from '../components/views/CagedView.svelte'
import TriadsView from '../components/views/TriadsView.svelte'
import IntervalsView from '../components/views/IntervalsView.svelte'
import NotePrompterView from '../components/views/NotePrompterView.svelte'
import ReferenceToneView from '../components/views/ReferenceToneView.svelte'
interface Props {
code: string
@@ -15,14 +22,28 @@
{#if !drill}
<NotFound {code} />
{:else if drill.kind === 'strum'}
{:else}
{#key drill.code}
{#if drill.kind === 'strum'}
<StrumView {drill} {params} />
{/key}
{:else if drill.kind === 'picking'}
{#key drill.code}
<PickingView {drill} {params} />
{/key}
{:else if drill.kind === 'changes'}
<ChangesView {drill} {params} />
{:else if drill.kind === 'diatonic'}
<DiatonicView {drill} {params} />
{:else if drill.kind === 'caged'}
<CagedView {drill} {params} />
{:else if drill.kind === 'triads'}
<TriadsView {drill} {params} />
{:else if drill.kind === 'intervals'}
<IntervalsView {drill} {params} />
{:else if drill.kind === 'note-prompter'}
<NotePrompterView {drill} {params} />
{:else if drill.kind === 'reference-tone'}
<ReferenceToneView {drill} {params} />
{:else}
<NotFound {code} />
{/if}
{/key}
{/if}