diff --git a/src/components/diagrams/ChordDiagram.svelte b/src/components/diagrams/ChordDiagram.svelte
new file mode 100644
index 0000000..27030e1
--- /dev/null
+++ b/src/components/diagrams/ChordDiagram.svelte
@@ -0,0 +1,161 @@
+
+
+
+
+
+ {label ?? shape.name}
+ {#if positionLabel}{positionLabel}{/if}
+
+
+
+
diff --git a/src/components/diagrams/Fretboard.svelte b/src/components/diagrams/Fretboard.svelte
new file mode 100644
index 0000000..abb0d90
--- /dev/null
+++ b/src/components/diagrams/Fretboard.svelte
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
diff --git a/src/components/views/CagedView.svelte b/src/components/views/CagedView.svelte
new file mode 100644
index 0000000..0f77e4b
--- /dev/null
+++ b/src/components/views/CagedView.svelte
@@ -0,0 +1,139 @@
+
+
+
+
+
Key
+
+ {#each KEYS as k}
+
+ {/each}
+
+
+
+
+
+
+ {shape.form}
+ {shape.positionLabel}
+
+
+
+
+
+
+ Amber notes are the root ({key}). Step through all five shapes to cover the neck.
+
+
+
diff --git a/src/components/views/ChangesView.svelte b/src/components/views/ChangesView.svelte
new file mode 100644
index 0000000..73d6f09
--- /dev/null
+++ b/src/components/views/ChangesView.svelte
@@ -0,0 +1,199 @@
+
+
+
+
+ {#each [pair.a, pair.b] as name}
+ {@const shape = getChordShape(name)}
+ {#if shape}
+
+ {/if}
+ {/each}
+
+
+
+
+ Time
+ {timeLeft}s
+
+
+ Best
+ {best || '—'}
+
+
+
+
+
+ {#if beatBest}
+ New best — {count} changes!
+ {/if}
+
+
+ {#if running}
+
+ {:else}
+
+ {/if}
+
+
+
+
+ Chord pair
+
+
+
+ {#each CHANGE_PAIRS as p}
+
+ {/each}
+
+
+
+
+
diff --git a/src/components/views/DiatonicView.svelte b/src/components/views/DiatonicView.svelte
new file mode 100644
index 0000000..8da816f
--- /dev/null
+++ b/src/components/views/DiatonicView.svelte
@@ -0,0 +1,119 @@
+
+
+
+
+
Key
+
+ {#each KEYS as k}
+
+ {/each}
+
+
+
+
+ {#each chords as c}
+ {@const shape = getChordShape(c.name)}
+
+
{c.degree}
+ {#if shape}
+
+ {:else}
+
+ {c.name}
+ {c.quality}
+
+ {/if}
+
+ {/each}
+
+
+
+
diff --git a/src/components/views/IntervalsView.svelte b/src/components/views/IntervalsView.svelte
new file mode 100644
index 0000000..d6ebdd2
--- /dev/null
+++ b/src/components/views/IntervalsView.svelte
@@ -0,0 +1,125 @@
+
+
+
+
+
Root
+
+ {#each CHROMATIC_ROOTS as r}
+
+ {/each}
+
+
+
+
+
Interval
+
+ {#each INTERVALS as iv}
+
+ {/each}
+
+
+
+
+ root {root}
+ {interval.label} {target}
+
+
+
+
+
+
diff --git a/src/components/views/NotePrompterView.svelte b/src/components/views/NotePrompterView.svelte
new file mode 100644
index 0000000..91747d4
--- /dev/null
+++ b/src/components/views/NotePrompterView.svelte
@@ -0,0 +1,181 @@
+
+
+
+
+
String
+
+ {#each STRING_LABELS as label, i}
+
+ {/each}
+
+
+
+
+
+ Fret
+ {fret}
+
+
+ Note
+ {revealed ? note : '·'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/views/PickingView.svelte b/src/components/views/PickingView.svelte
index 42faddb..3081e86 100644
--- a/src/components/views/PickingView.svelte
+++ b/src/components/views/PickingView.svelte
@@ -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}
-
Chords{drill.chordLabels.length > 1 ? ' (per bar)' : ''}
+
Chords{drill.chordLabels.length > 1 ? ' (one per bar)' : ''}
{#each drill.chordLabels as c}
- {c}
+ {@const shape = getChordShape(c)}
+ {#if shape}
+
+ {:else}
+ {c}
+ {/if}
{/each}
diff --git a/src/components/views/ReferenceToneView.svelte b/src/components/views/ReferenceToneView.svelte
new file mode 100644
index 0000000..c0e7e0b
--- /dev/null
+++ b/src/components/views/ReferenceToneView.svelte
@@ -0,0 +1,102 @@
+
+
+
+
+ {noteName}{octave}
+
+
+
+
+
+
Target note
+
+ {#each CHROMATIC_ROOTS as n}
+
+ {/each}
+
+
+
+
+
Octave
+
+ {#each OCTAVES as o}
+
+ {/each}
+
+
+
+ Play the tone, then bend up to match it. Hold your bend against the ringing reference.
+
+
+
diff --git a/src/components/views/TriadsView.svelte b/src/components/views/TriadsView.svelte
new file mode 100644
index 0000000..1f1df66
--- /dev/null
+++ b/src/components/views/TriadsView.svelte
@@ -0,0 +1,152 @@
+
+
+
+
+
+
Root
+
+ {#each CHROMATIC_ROOTS as r}
+
+ {/each}
+
+
+
+
+
Quality
+
+
+
+
+
+
+
String set
+
+ {#each STRING_SETS as s}
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/drills.ts b/src/drills.ts
index 9cd9be4..b8767f2 100644
--- a/src/drills.ts
+++ b/src/drills.ts
@@ -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 = Object.fromEntries(
DRILLS.map((d) => [d.code, d]),
diff --git a/src/lib/audio/tone.ts b/src/lib/audio/tone.ts
new file mode 100644
index 0000000..abf52b4
--- /dev/null
+++ b/src/lib/audio/tone.ts
@@ -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 {
+ 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)
+}
diff --git a/src/lib/music/caged.ts b/src/lib/music/caged.ts
new file mode 100644
index 0000000..0a7ee69
--- /dev/null
+++ b/src/lib/music/caged.ts
@@ -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)
+}
diff --git a/src/lib/music/chords.ts b/src/lib/music/chords.ts
new file mode 100644
index 0000000..45e3a04
--- /dev/null
+++ b/src/lib/music/chords.ts
@@ -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 = 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' },
+]
diff --git a/src/lib/music/fretboard.ts b/src/lib/music/fretboard.ts
new file mode 100644
index 0000000..e3fe60e
--- /dev/null
+++ b/src/lib/music/fretboard.ts
@@ -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,
+ 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
+}
diff --git a/src/lib/music/theory.ts b/src/lib/music/theory.ts
new file mode 100644
index 0000000..7a85b6c
--- /dev/null
+++ b/src/lib/music/theory.ts
@@ -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
+}
diff --git a/src/lib/music/triads.ts b/src/lib/music/triads.ts
new file mode 100644
index 0000000..b1cf18b
--- /dev/null
+++ b/src/lib/music/triads.ts
@@ -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 = {
+ 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(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',
+ }
+}
diff --git a/src/pages/DrillPage.svelte b/src/pages/DrillPage.svelte
index cf8515f..86cd319 100644
--- a/src/pages/DrillPage.svelte
+++ b/src/pages/DrillPage.svelte
@@ -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}
-{:else if drill.kind === 'strum'}
- {#key drill.code}
-
- {/key}
-{:else if drill.kind === 'picking'}
- {#key drill.code}
-
- {/key}
{:else}
-
+ {#key drill.code}
+ {#if drill.kind === 'strum'}
+
+ {:else if drill.kind === 'picking'}
+
+ {:else if drill.kind === 'changes'}
+
+ {:else if drill.kind === 'diatonic'}
+
+ {:else if drill.kind === 'caged'}
+
+ {:else if drill.kind === 'triads'}
+
+ {:else if drill.kind === 'intervals'}
+
+ {:else if drill.kind === 'note-prompter'}
+
+ {:else if drill.kind === 'reference-tone'}
+
+ {:else}
+
+ {/if}
+ {/key}
{/if}