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:
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,6 +8,8 @@
|
|||||||
import DrillLayout from '../common/DrillLayout.svelte'
|
import DrillLayout from '../common/DrillLayout.svelte'
|
||||||
import Transport from '../metronome/Transport.svelte'
|
import Transport from '../metronome/Transport.svelte'
|
||||||
import PickingLane from '../patterns/PickingLane.svelte'
|
import PickingLane from '../patterns/PickingLane.svelte'
|
||||||
|
import ChordDiagram from '../diagrams/ChordDiagram.svelte'
|
||||||
|
import { getChordShape } from '../../lib/music/chords'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
drill: PickingDrill
|
drill: PickingDrill
|
||||||
@@ -89,10 +91,15 @@
|
|||||||
|
|
||||||
{#if drill.chordLabels && drill.chordLabels.length}
|
{#if drill.chordLabels && drill.chordLabels.length}
|
||||||
<div class="chords">
|
<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">
|
<div class="chip-row">
|
||||||
{#each drill.chordLabels as c}
|
{#each drill.chordLabels as c}
|
||||||
|
{@const shape = getChordShape(c)}
|
||||||
|
{#if shape}
|
||||||
|
<ChordDiagram {shape} label={c} size={96} />
|
||||||
|
{:else}
|
||||||
<span class="chord num">{c}</span>
|
<span class="chord num">{c}</span>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
@@ -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
@@ -81,8 +81,37 @@ export interface PickingDrill extends DrillBase, Meter {
|
|||||||
chordLabels?: string[]
|
chordLabels?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Union grows as later phases add kinds (chords, fretboard, drone, info…).
|
/** R4 — one-minute chord changes (countdown + tap counter, no metronome). */
|
||||||
export type Drill = StrumDrill | PickingDrill
|
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 (E1–E3); 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. */
|
/** Drills that run the metronome and persist a BPM. */
|
||||||
export type ClickDrill = StrumDrill | PickingDrill
|
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(
|
export const DRILLS_BY_CODE: Record<string, Drill> = Object.fromEntries(
|
||||||
DRILLS.map((d) => [d.code, d]),
|
DRILLS.map((d) => [d.code, d]),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,13 @@
|
|||||||
import NotFound from './NotFound.svelte'
|
import NotFound from './NotFound.svelte'
|
||||||
import StrumView from '../components/views/StrumView.svelte'
|
import StrumView from '../components/views/StrumView.svelte'
|
||||||
import PickingView from '../components/views/PickingView.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 {
|
interface Props {
|
||||||
code: string
|
code: string
|
||||||
@@ -15,14 +22,28 @@
|
|||||||
|
|
||||||
{#if !drill}
|
{#if !drill}
|
||||||
<NotFound {code} />
|
<NotFound {code} />
|
||||||
{:else if drill.kind === 'strum'}
|
{:else}
|
||||||
{#key drill.code}
|
{#key drill.code}
|
||||||
|
{#if drill.kind === 'strum'}
|
||||||
<StrumView {drill} {params} />
|
<StrumView {drill} {params} />
|
||||||
{/key}
|
|
||||||
{:else if drill.kind === 'picking'}
|
{:else if drill.kind === 'picking'}
|
||||||
{#key drill.code}
|
|
||||||
<PickingView {drill} {params} />
|
<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}
|
{:else}
|
||||||
<NotFound {code} />
|
<NotFound {code} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user