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 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}
|
||||
<span class="chord num">{c}</span>
|
||||
{@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>
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
// 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 (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. */
|
||||
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]),
|
||||
|
||||
@@ -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 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'}
|
||||
{#key drill.code}
|
||||
<StrumView {drill} {params} />
|
||||
{/key}
|
||||
{:else if drill.kind === 'picking'}
|
||||
{#key drill.code}
|
||||
<PickingView {drill} {params} />
|
||||
{/key}
|
||||
{:else}
|
||||
<NotFound {code} />
|
||||
{#key drill.code}
|
||||
{#if drill.kind === 'strum'}
|
||||
<StrumView {drill} {params} />
|
||||
{:else if drill.kind === 'picking'}
|
||||
<PickingView {drill} {params} />
|
||||
{: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}
|
||||
|
||||
Reference in New Issue
Block a user