365aac58a0
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>
166 lines
4.6 KiB
Svelte
166 lines
4.6 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte'
|
|
import type { PickingDrill } from '../../drills'
|
|
import { effectiveMeter, patternFromAccents } from '../../drills'
|
|
import { MetronomeController } from '../../lib/audio/metronomeController.svelte'
|
|
import * as storage from '../../lib/storage'
|
|
import { router } from '../../router.svelte'
|
|
import DrillLayout from '../common/DrillLayout.svelte'
|
|
import Transport from '../metronome/Transport.svelte'
|
|
import PickingLane from '../patterns/PickingLane.svelte'
|
|
import ChordDiagram from '../diagrams/ChordDiagram.svelte'
|
|
import { getChordShape } from '../../lib/music/chords'
|
|
|
|
interface Props {
|
|
drill: PickingDrill
|
|
params: URLSearchParams
|
|
}
|
|
|
|
let { drill, params }: Props = $props()
|
|
|
|
function initialBpm(): number {
|
|
const q = Number(params.get('bpm'))
|
|
if (Number.isFinite(q) && q > 0) return q
|
|
return storage.getDrillBpm(drill.code, drill.defaultBpm)
|
|
}
|
|
function initialVariantId(): string {
|
|
const q = params.get('variant')
|
|
if (q && drill.variants.some((v) => v.id === q)) return q
|
|
const stored = storage.getDrillVariant(drill.code, drill.variants[0].id)
|
|
return drill.variants.some((v) => v.id === stored) ? stored : drill.variants[0].id
|
|
}
|
|
|
|
const controller = new MetronomeController(initialBpm())
|
|
let variantId = $state(initialVariantId())
|
|
let variant = $derived(
|
|
drill.variants.find((v) => v.id === variantId) ?? drill.variants[0],
|
|
)
|
|
let meter = $derived(effectiveMeter(drill, variant))
|
|
|
|
$effect(() => {
|
|
controller.setPattern(patternFromAccents(meter, variant.accentSteps))
|
|
})
|
|
|
|
function persistBpm(bpm: number) {
|
|
storage.setDrillBpm(drill.code, bpm)
|
|
}
|
|
function selectVariant(id: string) {
|
|
variantId = id
|
|
storage.setDrillVariant(drill.code, id)
|
|
router.setQuery({ variant: id })
|
|
}
|
|
|
|
function onKey(e: KeyboardEvent) {
|
|
const t = e.target as HTMLElement
|
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return
|
|
if (e.code === 'Space') {
|
|
e.preventDefault()
|
|
controller.toggle()
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
controller.nudgeBpm(2)
|
|
persistBpm(controller.bpm)
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
controller.nudgeBpm(-2)
|
|
persistBpm(controller.bpm)
|
|
}
|
|
}
|
|
|
|
onMount(() => window.addEventListener('keydown', onKey))
|
|
onDestroy(() => {
|
|
window.removeEventListener('keydown', onKey)
|
|
controller.destroy()
|
|
})
|
|
</script>
|
|
|
|
<DrillLayout
|
|
code={drill.code}
|
|
name={drill.name}
|
|
category={drill.category}
|
|
description={drill.description}
|
|
meterLabel={meter.meterLabel}
|
|
>
|
|
<Transport {controller} stepsPerBeat={meter.stepsPerBeat} onBpmCommit={persistBpm} />
|
|
|
|
<PickingLane
|
|
steps={variant.steps}
|
|
stepsPerBeat={meter.stepsPerBeat}
|
|
currentStep={controller.currentStep}
|
|
/>
|
|
|
|
{#if drill.chordLabels && drill.chordLabels.length}
|
|
<div class="chords">
|
|
<span class="eyebrow">Chords{drill.chordLabels.length > 1 ? ' (one per bar)' : ''}</span>
|
|
<div class="chip-row">
|
|
{#each drill.chordLabels as c}
|
|
{@const shape = getChordShape(c)}
|
|
{#if shape}
|
|
<ChordDiagram {shape} label={c} size={96} />
|
|
{:else}
|
|
<span class="chord num">{c}</span>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if drill.variants.length > 1}
|
|
<div class="variants">
|
|
<span class="eyebrow">Variant</span>
|
|
<div class="chips">
|
|
{#each drill.variants as v}
|
|
<button class="chip" class:sel={v.id === variantId} onclick={() => selectVariant(v.id)}>
|
|
{v.label}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</DrillLayout>
|
|
|
|
<style>
|
|
.chords,
|
|
.variants {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.55rem;
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
.chip-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.chord {
|
|
padding: 0.4rem 0.7rem;
|
|
background: var(--walnut);
|
|
border: 1px solid var(--fretwire-dim);
|
|
border-radius: var(--radius);
|
|
color: var(--inlay);
|
|
font-size: var(--step-2);
|
|
}
|
|
.chips {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
.chip {
|
|
padding: 0.55rem 0.8rem;
|
|
background: var(--walnut);
|
|
border: 1px solid var(--fretwire-dim);
|
|
border-radius: 999px;
|
|
color: var(--inlay-dim);
|
|
font-size: var(--step-0);
|
|
}
|
|
.chip:hover {
|
|
border-color: var(--fretwire);
|
|
color: var(--inlay);
|
|
}
|
|
.chip.sel {
|
|
color: var(--tubeglow);
|
|
border-color: var(--tubeglow-soft);
|
|
background: color-mix(in srgb, var(--tubeglow) 10%, transparent);
|
|
}
|
|
</style>
|