Phase 2: pattern renderers + rhythm/fingerstyle/lead drills
Add PickingLane renderer and PickingView. Variant-level meter overrides so R3 can drop from 7/8 to 4/4 live at the next bar boundary. Wire R2, R3, R5, F1-F3, L1-L3 (incl. L2 eighth-note triplets via stepsPerBeat=3). Refactor drill model into a strum/picking union with shared meter + accent-map helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { STRING_LABELS, type PickStep, type PickMarker } from '../../drills'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
steps: (PickStep | null)[]
|
||||||
|
stepsPerBeat: number
|
||||||
|
currentStep: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let { steps, stepsPerBeat, currentStep }: Props = $props()
|
||||||
|
|
||||||
|
const rows = STRING_LABELS.length // 6 strings, index 0 = high e (top)
|
||||||
|
|
||||||
|
function glyph(m: PickMarker): string {
|
||||||
|
switch (m) {
|
||||||
|
case 'down':
|
||||||
|
return '⊓'
|
||||||
|
case 'up':
|
||||||
|
return 'V'
|
||||||
|
case 'h':
|
||||||
|
return 'H'
|
||||||
|
case 'po':
|
||||||
|
return 'P'
|
||||||
|
case 'x':
|
||||||
|
return '✕'
|
||||||
|
default:
|
||||||
|
return m // p / i / m / a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="lane" style="--cols:{steps.length}" role="group" aria-label="Picking pattern">
|
||||||
|
{#each Array(rows) as _, r}
|
||||||
|
<div class="label num">{STRING_LABELS[r]}</div>
|
||||||
|
{#each steps as step, c}
|
||||||
|
<div
|
||||||
|
class="cell"
|
||||||
|
class:beat={c % stepsPerBeat === 0}
|
||||||
|
class:active={c === currentStep}
|
||||||
|
>
|
||||||
|
<span class="string-line"></span>
|
||||||
|
{#if step && step.string === r}
|
||||||
|
<span class="mark" class:ghost={step.ghost}>{glyph(step.marker)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.lane {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4rem repeat(var(--cols), 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--walnut);
|
||||||
|
border: 1px solid var(--fretwire-dim);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.6rem 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--inlay-faint);
|
||||||
|
font-size: var(--step-0);
|
||||||
|
}
|
||||||
|
.cell {
|
||||||
|
position: relative;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
/* The string as a faint horizontal line through each lane. */
|
||||||
|
.string-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--fretwire-dim);
|
||||||
|
}
|
||||||
|
.cell.beat::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -1px;
|
||||||
|
top: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--fretwire-dim);
|
||||||
|
}
|
||||||
|
.cell.active {
|
||||||
|
background: color-mix(in srgb, var(--tubeglow) 12%, transparent);
|
||||||
|
}
|
||||||
|
.mark {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--step-0);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.15rem 0.25rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--walnut-hi);
|
||||||
|
color: var(--inlay);
|
||||||
|
}
|
||||||
|
.mark.ghost {
|
||||||
|
color: var(--inlay-faint);
|
||||||
|
}
|
||||||
|
.cell.active .mark {
|
||||||
|
background: var(--tubeglow);
|
||||||
|
color: #201607;
|
||||||
|
box-shadow: 0 0 10px 1px rgba(242, 160, 61, 0.5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<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'
|
||||||
|
|
||||||
|
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 ? ' (per bar)' : ''}</span>
|
||||||
|
<div class="chip-row">
|
||||||
|
{#each drill.chordLabels as c}
|
||||||
|
<span class="chord num">{c}</span>
|
||||||
|
{/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>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import type { StrumDrill } from '../../drills'
|
import type { StrumDrill } from '../../drills'
|
||||||
import { patternForStrumVariant } from '../../drills'
|
import { effectiveMeter, patternFromAccents } from '../../drills'
|
||||||
import { MetronomeController } from '../../lib/audio/metronomeController.svelte'
|
import { MetronomeController } from '../../lib/audio/metronomeController.svelte'
|
||||||
import * as storage from '../../lib/storage'
|
import * as storage from '../../lib/storage'
|
||||||
import { router } from '../../router.svelte'
|
import { router } from '../../router.svelte'
|
||||||
@@ -34,10 +34,12 @@
|
|||||||
let variant = $derived(
|
let variant = $derived(
|
||||||
drill.variants.find((v) => v.id === variantId) ?? drill.variants[0],
|
drill.variants.find((v) => v.id === variantId) ?? drill.variants[0],
|
||||||
)
|
)
|
||||||
|
let meter = $derived(effectiveMeter(drill, variant))
|
||||||
|
|
||||||
// Keep the engine pattern in sync with the selected variant.
|
// Keep the engine pattern in sync with the selected variant (applied at the
|
||||||
|
// next bar boundary while playing — this is R3's live "drop to 4/4").
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
controller.setPattern(patternForStrumVariant(drill, variant))
|
controller.setPattern(patternFromAccents(meter, variant.accentSteps))
|
||||||
})
|
})
|
||||||
|
|
||||||
function persistBpm(bpm: number) {
|
function persistBpm(bpm: number) {
|
||||||
@@ -81,13 +83,13 @@
|
|||||||
name={drill.name}
|
name={drill.name}
|
||||||
category={drill.category}
|
category={drill.category}
|
||||||
description={drill.description}
|
description={drill.description}
|
||||||
meterLabel={drill.meterLabel}
|
meterLabel={meter.meterLabel}
|
||||||
>
|
>
|
||||||
<Transport {controller} stepsPerBeat={drill.stepsPerBeat} onBpmCommit={persistBpm} />
|
<Transport {controller} stepsPerBeat={meter.stepsPerBeat} onBpmCommit={persistBpm} />
|
||||||
|
|
||||||
<StrumGrid
|
<StrumGrid
|
||||||
strokes={variant.strokes}
|
strokes={variant.strokes}
|
||||||
stepsPerBeat={drill.stepsPerBeat}
|
stepsPerBeat={meter.stepsPerBeat}
|
||||||
currentStep={controller.currentStep}
|
currentStep={controller.currentStep}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
+296
-31
@@ -14,7 +14,17 @@ export const CATEGORY_ORDER: Category[] = [
|
|||||||
'Ear',
|
'Ear',
|
||||||
]
|
]
|
||||||
|
|
||||||
// --- Strum grid vocabulary ------------------------------------------------------
|
/** Standard tuning, top lane first: 0 = high e (1st) … 5 = low E (6th). */
|
||||||
|
export const STRING_LABELS = ['e', 'B', 'G', 'D', 'A', 'E'] as const
|
||||||
|
|
||||||
|
// --- Meter --------------------------------------------------------------------
|
||||||
|
export interface Meter {
|
||||||
|
beatsPerBar: number
|
||||||
|
stepsPerBeat: number
|
||||||
|
meterLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Strum grid vocabulary ----------------------------------------------------
|
||||||
// Uppercase = sounded, lowercase = ghosted (hand keeps moving, string muted/silent).
|
// Uppercase = sounded, lowercase = ghosted (hand keeps moving, string muted/silent).
|
||||||
// D/d down-stroke U/u up-stroke x muted (percussive) hit - rest (no motion)
|
// D/d down-stroke U/u up-stroke x muted (percussive) hit - rest (no motion)
|
||||||
export type Stroke = 'D' | 'U' | 'd' | 'u' | 'x' | '-'
|
export type Stroke = 'D' | 'U' | 'd' | 'u' | 'x' | '-'
|
||||||
@@ -26,6 +36,28 @@ export interface StrumVariant {
|
|||||||
strokes: Stroke[]
|
strokes: Stroke[]
|
||||||
/** Step indices that get the ACCENT click. Downbeats otherwise click 'normal'. */
|
/** Step indices that get the ACCENT click. Downbeats otherwise click 'normal'. */
|
||||||
accentSteps: number[]
|
accentSteps: number[]
|
||||||
|
/** Optional per-variant meter override (e.g. R3's "drop to 4/4"). */
|
||||||
|
meter?: Meter
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Picking lane vocabulary --------------------------------------------------
|
||||||
|
export type PickMarker = 'p' | 'i' | 'm' | 'a' | 'down' | 'up' | 'h' | 'po' | 'x'
|
||||||
|
|
||||||
|
export interface PickStep {
|
||||||
|
/** String index 0..5 (0 = high e). */
|
||||||
|
string: number
|
||||||
|
marker: PickMarker
|
||||||
|
/** Ghosted / un-plucked motion (rare). */
|
||||||
|
ghost?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PickingVariant {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
/** One entry per step; null = rest (no attack). */
|
||||||
|
steps: (PickStep | null)[]
|
||||||
|
accentSteps: number[]
|
||||||
|
meter?: Meter
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DrillBase {
|
interface DrillBase {
|
||||||
@@ -35,20 +67,38 @@ interface DrillBase {
|
|||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StrumDrill extends DrillBase {
|
export interface StrumDrill extends DrillBase, Meter {
|
||||||
kind: 'strum'
|
kind: 'strum'
|
||||||
defaultBpm: number
|
defaultBpm: number
|
||||||
beatsPerBar: number
|
|
||||||
stepsPerBeat: number
|
|
||||||
/** Time-signature label for display, e.g. "4/4" or "7/8". */
|
|
||||||
meterLabel: string
|
|
||||||
variants: StrumVariant[]
|
variants: StrumVariant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Union grows as later phases add kinds (picking, chords, fretboard, drone, info…).
|
export interface PickingDrill extends DrillBase, Meter {
|
||||||
export type Drill = StrumDrill
|
kind: 'picking'
|
||||||
|
defaultBpm: number
|
||||||
|
variants: PickingVariant[]
|
||||||
|
/** Chord names shown alongside the lane (diagrams wired in a later phase). */
|
||||||
|
chordLabels?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helpers --------------------------------------------------------------------
|
// Union grows as later phases add kinds (chords, fretboard, drone, info…).
|
||||||
|
export type Drill = StrumDrill | PickingDrill
|
||||||
|
|
||||||
|
/** Drills that run the metronome and persist a BPM. */
|
||||||
|
export type ClickDrill = StrumDrill | PickingDrill
|
||||||
|
export function isClickDrill(d: Drill): d is ClickDrill {
|
||||||
|
return d.kind === 'strum' || d.kind === 'picking'
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function effectiveMeter(drill: Meter, variant?: { meter?: Meter }): Meter {
|
||||||
|
return variant?.meter ?? {
|
||||||
|
beatsPerBar: drill.beatsPerBar,
|
||||||
|
stepsPerBeat: drill.stepsPerBeat,
|
||||||
|
meterLabel: drill.meterLabel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive metronome roles from an accent map: accented steps get the accent voice,
|
* Derive metronome roles from an accent map: accented steps get the accent voice,
|
||||||
@@ -67,22 +117,18 @@ export function deriveRoles(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patternForStrumVariant(
|
export function patternFromAccents(meter: Meter, accentSteps: number[]): MetroPattern {
|
||||||
drill: StrumDrill,
|
const total = meter.beatsPerBar * meter.stepsPerBeat
|
||||||
variant: StrumVariant,
|
|
||||||
): MetroPattern {
|
|
||||||
const total = drill.beatsPerBar * drill.stepsPerBeat
|
|
||||||
return {
|
return {
|
||||||
beatsPerBar: drill.beatsPerBar,
|
beatsPerBar: meter.beatsPerBar,
|
||||||
stepsPerBeat: drill.stepsPerBeat,
|
stepsPerBeat: meter.stepsPerBeat,
|
||||||
roles: deriveRoles(total, drill.stepsPerBeat, variant.accentSteps),
|
roles: deriveRoles(total, meter.stepsPerBeat, accentSteps),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Drill data -----------------------------------------------------------------
|
// --- Authoring builders -------------------------------------------------------
|
||||||
|
|
||||||
// Compact builders for authoring 16-step continuous-motion strum rows.
|
/** Continuous D-U motion; `sounded` step indices ring, the rest are ghosted. */
|
||||||
// alt(sounded) → continuous D-U where the given step indices sound (rest ghosted).
|
|
||||||
function altStrokes(total: number, sounded: number[]): Stroke[] {
|
function altStrokes(total: number, sounded: number[]): Stroke[] {
|
||||||
const set = new Set(sounded)
|
const set = new Set(sounded)
|
||||||
return Array.from({ length: total }, (_, i) => {
|
return Array.from({ length: total }, (_, i) => {
|
||||||
@@ -91,6 +137,11 @@ function altStrokes(total: number, sounded: number[]): Stroke[] {
|
|||||||
return down ? 'd' : 'u'
|
return down ? 'd' : 'u'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
const allSteps = (n: number) => Array.from({ length: n }, (_, i) => i)
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
// RHYTHM
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
const R1: StrumDrill = {
|
const R1: StrumDrill = {
|
||||||
code: 'R1',
|
code: 'R1',
|
||||||
@@ -104,28 +155,242 @@ const R1: StrumDrill = {
|
|||||||
stepsPerBeat: 4,
|
stepsPerBeat: 4,
|
||||||
meterLabel: '4/4',
|
meterLabel: '4/4',
|
||||||
variants: [
|
variants: [
|
||||||
{
|
{ id: 'quarters', label: 'Sound on quarters', strokes: altStrokes(16, [0, 4, 8, 12]), accentSteps: [0] },
|
||||||
id: 'quarters',
|
|
||||||
label: 'Sound on quarters only',
|
|
||||||
strokes: altStrokes(16, [0, 4, 8, 12]),
|
|
||||||
accentSteps: [0],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: '1e-and-a-1-3',
|
id: '1e-and-a-1-3',
|
||||||
label: '1e&a on beats 1 and 3',
|
label: '1e&a on beats 1 & 3',
|
||||||
strokes: altStrokes(16, [0, 1, 2, 3, 8, 9, 10, 11]),
|
strokes: altStrokes(16, [0, 1, 2, 3, 8, 9, 10, 11]),
|
||||||
accentSteps: [0, 8],
|
accentSteps: [0, 8],
|
||||||
},
|
},
|
||||||
|
{ id: 'all-16ths', label: 'All 16ths ring', strokes: altStrokes(16, allSteps(16)), accentSteps: [0] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const R2: StrumDrill = {
|
||||||
|
code: 'R2',
|
||||||
|
name: 'Accent patterns',
|
||||||
|
category: 'Rhythm',
|
||||||
|
description: 'Continuous 16ths, all ringing. Drive the accent to different places in the bar.',
|
||||||
|
kind: 'strum',
|
||||||
|
defaultBpm: 80,
|
||||||
|
beatsPerBar: 4,
|
||||||
|
stepsPerBeat: 4,
|
||||||
|
meterLabel: '4/4',
|
||||||
|
variants: [
|
||||||
|
{ id: 'accents-1-3', label: 'Accents on 1 & 3', strokes: altStrokes(16, allSteps(16)), accentSteps: [0, 8] },
|
||||||
|
{ id: 'accents-2-4', label: 'Accents on 2 & 4', strokes: altStrokes(16, allSteps(16)), accentSteps: [4, 12] },
|
||||||
{
|
{
|
||||||
id: 'all-16ths',
|
id: 'every-3rd',
|
||||||
label: 'All 16ths ring',
|
label: 'Every 3rd 16th',
|
||||||
strokes: altStrokes(16, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]),
|
strokes: altStrokes(16, allSteps(16)),
|
||||||
|
accentSteps: [0, 3, 6, 9, 12, 15],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVEN_EIGHT: Meter = { beatsPerBar: 7, stepsPerBeat: 1, meterLabel: '7/8' }
|
||||||
|
const R3: StrumDrill = {
|
||||||
|
code: 'R3',
|
||||||
|
name: '7/8 loop',
|
||||||
|
category: 'Rhythm',
|
||||||
|
description:
|
||||||
|
'Down-strum each eighth; feel the grouping from the accents. Switch to “Drop to 4/4” mid-play — it takes effect on the next bar.',
|
||||||
|
kind: 'strum',
|
||||||
|
defaultBpm: 65,
|
||||||
|
...SEVEN_EIGHT,
|
||||||
|
variants: [
|
||||||
|
{ id: '2-2-3', label: '2 + 2 + 3', strokes: ['D', 'd', 'D', 'd', 'D', 'd', 'd'], accentSteps: [0, 2, 4] },
|
||||||
|
{ id: '3-2-2', label: '3 + 2 + 2', strokes: ['D', 'd', 'd', 'D', 'd', 'D', 'd'], accentSteps: [0, 3, 5] },
|
||||||
|
{ id: '2-3-2', label: '2 + 3 + 2', strokes: ['D', 'd', 'D', 'd', 'd', 'D', 'd'], accentSteps: [0, 2, 5] },
|
||||||
|
{
|
||||||
|
id: 'drop-4-4',
|
||||||
|
label: 'Drop to 4/4',
|
||||||
|
strokes: altStrokes(8, [0, 2, 4, 6]),
|
||||||
|
accentSteps: [0, 4],
|
||||||
|
meter: { beatsPerBar: 4, stepsPerBeat: 2, meterLabel: '4/4' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const R5: StrumDrill = {
|
||||||
|
code: 'R5',
|
||||||
|
name: 'Palm-mute chugging, steady 8ths',
|
||||||
|
category: 'Rhythm',
|
||||||
|
description: 'All downstrokes, palm-muted eighths. Keep them even; accent beat 1 only.',
|
||||||
|
kind: 'strum',
|
||||||
|
defaultBpm: 90,
|
||||||
|
beatsPerBar: 4,
|
||||||
|
stepsPerBeat: 2,
|
||||||
|
meterLabel: '4/4',
|
||||||
|
variants: [
|
||||||
|
{ id: 'chug', label: 'Steady chug', strokes: ['D', 'D', 'D', 'D', 'D', 'D', 'D', 'D'], accentSteps: [0] },
|
||||||
|
{
|
||||||
|
id: 'gallop',
|
||||||
|
label: 'Gallop (16-16-8)',
|
||||||
|
// Per beat: two 16ths + an eighth (last 16th silent), all palm-muted downs.
|
||||||
|
strokes: ['D', 'D', 'D', '-', 'D', 'D', 'D', '-', 'D', 'D', 'D', '-', 'D', 'D', 'D', '-'],
|
||||||
|
accentSteps: [0],
|
||||||
|
meter: { beatsPerBar: 4, stepsPerBeat: 4, meterLabel: '4/4' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
// FINGERSTYLE
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
const s = (string: number, marker: PickMarker): PickStep => ({ string, marker })
|
||||||
|
|
||||||
|
const F1: PickingDrill = {
|
||||||
|
code: 'F1',
|
||||||
|
name: 'Travis picking',
|
||||||
|
category: 'Fingerstyle',
|
||||||
|
description:
|
||||||
|
'Alternating thumb bass on the beats, index and middle filling the off-beats. Loop it clean before adding melody.',
|
||||||
|
kind: 'picking',
|
||||||
|
defaultBpm: 60,
|
||||||
|
beatsPerBar: 4,
|
||||||
|
stepsPerBeat: 2,
|
||||||
|
meterLabel: '4/4',
|
||||||
|
chordLabels: ['C', 'Am'],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
id: 'classic',
|
||||||
|
label: 'Classic pattern',
|
||||||
|
steps: [s(4, 'p'), s(1, 'i'), s(3, 'p'), s(0, 'm'), s(4, 'p'), s(1, 'i'), s(3, 'p'), s(0, 'm')],
|
||||||
accentSteps: [0],
|
accentSteps: [0],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DRILLS: Drill[] = [R1]
|
const F2: PickingDrill = {
|
||||||
|
code: 'F2',
|
||||||
|
name: 'PIMA arpeggio over I–V–vi–IV',
|
||||||
|
category: 'Fingerstyle',
|
||||||
|
description: 'Roll p-i-m-a across the progression, one chord per bar. Even volume across all four fingers.',
|
||||||
|
kind: 'picking',
|
||||||
|
defaultBpm: 65,
|
||||||
|
beatsPerBar: 4,
|
||||||
|
stepsPerBeat: 2,
|
||||||
|
meterLabel: '4/4',
|
||||||
|
chordLabels: ['G', 'D', 'Em', 'C'],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
id: 'ascending',
|
||||||
|
label: 'p-i-m-a ascending',
|
||||||
|
steps: [s(4, 'p'), s(2, 'i'), s(1, 'm'), s(0, 'a'), s(4, 'p'), s(2, 'i'), s(1, 'm'), s(0, 'a')],
|
||||||
|
accentSteps: [0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'descending',
|
||||||
|
label: 'p-a-m-i descending',
|
||||||
|
steps: [s(4, 'p'), s(0, 'a'), s(1, 'm'), s(2, 'i'), s(4, 'p'), s(0, 'a'), s(1, 'm'), s(2, 'i')],
|
||||||
|
accentSteps: [0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const F3: PickingDrill = {
|
||||||
|
code: 'F3',
|
||||||
|
name: 'Thumb-independent bass + melody',
|
||||||
|
category: 'Fingerstyle',
|
||||||
|
description: 'Steady thumb pulse on the low string under a simple two-bar melody on top. Keep the thumb metronomic.',
|
||||||
|
kind: 'picking',
|
||||||
|
defaultBpm: 55,
|
||||||
|
beatsPerBar: 8, // two bars of 4/4 looped
|
||||||
|
stepsPerBeat: 2,
|
||||||
|
meterLabel: '4/4',
|
||||||
|
chordLabels: ['C'],
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
id: 'preset-1',
|
||||||
|
label: 'Preset',
|
||||||
|
// Bar 1: thumb (A) each beat, melody on high e/B off-beats.
|
||||||
|
steps: [
|
||||||
|
s(4, 'p'), s(0, 'm'), s(4, 'p'), s(1, 'i'), s(4, 'p'), s(0, 'm'), s(4, 'p'), s(1, 'i'),
|
||||||
|
s(4, 'p'), s(1, 'i'), s(4, 'p'), s(0, 'm'), s(4, 'p'), s(1, 'i'), s(4, 'p'), s(0, 'm'),
|
||||||
|
],
|
||||||
|
accentSteps: [0, 8],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
// LEAD (maintenance)
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
const L1: PickingDrill = {
|
||||||
|
code: 'L1',
|
||||||
|
name: 'Alternate picking builder',
|
||||||
|
category: 'Lead',
|
||||||
|
description: 'Strict down-up 16ths on a one-bar fragment. Metronome-tight; only speed up when every note is even.',
|
||||||
|
kind: 'picking',
|
||||||
|
defaultBpm: 120,
|
||||||
|
beatsPerBar: 4,
|
||||||
|
stepsPerBeat: 4,
|
||||||
|
meterLabel: '4/4',
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
id: 'single-string',
|
||||||
|
label: 'Single string',
|
||||||
|
steps: Array.from({ length: 16 }, (_, i) => s(2, i % 2 === 0 ? 'down' : 'up')),
|
||||||
|
accentSteps: [0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'two-string',
|
||||||
|
label: 'Two strings',
|
||||||
|
steps: Array.from({ length: 16 }, (_, i) => s(i % 8 < 4 ? 2 : 1, i % 2 === 0 ? 'down' : 'up')),
|
||||||
|
accentSteps: [0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const L2: PickingDrill = {
|
||||||
|
code: 'L2',
|
||||||
|
name: 'Legato runs',
|
||||||
|
category: 'Lead',
|
||||||
|
description: 'Eighth-note triplets: pick the first of each group, hammer and pull the rest. Let the fretting hand do the work.',
|
||||||
|
kind: 'picking',
|
||||||
|
defaultBpm: 100,
|
||||||
|
beatsPerBar: 4,
|
||||||
|
stepsPerBeat: 3, // eighth-note triplets
|
||||||
|
meterLabel: '4/4',
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
id: 'ham-pull',
|
||||||
|
label: 'Hammer / pull',
|
||||||
|
steps: Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const pos = i % 3
|
||||||
|
return s(2, pos === 0 ? 'down' : pos === 1 ? 'h' : 'po')
|
||||||
|
}),
|
||||||
|
accentSteps: [0, 3, 6, 9],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const L3: PickingDrill = {
|
||||||
|
code: 'L3',
|
||||||
|
name: 'String skipping',
|
||||||
|
category: 'Lead',
|
||||||
|
description: 'Alternate picking across non-adjacent strings. Keep the skipped string quiet and the rhythm even.',
|
||||||
|
kind: 'picking',
|
||||||
|
defaultBpm: 90,
|
||||||
|
beatsPerBar: 4,
|
||||||
|
stepsPerBeat: 4,
|
||||||
|
meterLabel: '4/4',
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
id: 'skip-1',
|
||||||
|
label: 'Skip one string',
|
||||||
|
steps: Array.from({ length: 16 }, (_, i) => s(i % 2 === 0 ? 2 : 0, i % 2 === 0 ? 'down' : 'up')),
|
||||||
|
accentSteps: [0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================================================
|
||||||
|
|
||||||
|
export const DRILLS: Drill[] = [R1, R2, R3, R5, F1, F2, F3, L1, L2, L3]
|
||||||
|
|
||||||
export const DRILLS_BY_CODE: Record<string, Drill> = Object.fromEntries(
|
export const DRILLS_BY_CODE: Record<string, Drill> = Object.fromEntries(
|
||||||
DRILLS.map((d) => [d.code, d]),
|
DRILLS.map((d) => [d.code, d]),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { getDrill } from '../drills'
|
import { getDrill } from '../drills'
|
||||||
import NotFound from './NotFound.svelte'
|
import NotFound from './NotFound.svelte'
|
||||||
import StrumView from '../components/views/StrumView.svelte'
|
import StrumView from '../components/views/StrumView.svelte'
|
||||||
|
import PickingView from '../components/views/PickingView.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
code: string
|
code: string
|
||||||
@@ -18,6 +19,10 @@
|
|||||||
{#key drill.code}
|
{#key drill.code}
|
||||||
<StrumView {drill} {params} />
|
<StrumView {drill} {params} />
|
||||||
{/key}
|
{/key}
|
||||||
|
{:else if drill.kind === 'picking'}
|
||||||
|
{#key drill.code}
|
||||||
|
<PickingView {drill} {params} />
|
||||||
|
{/key}
|
||||||
{:else}
|
{:else}
|
||||||
<NotFound {code} />
|
<NotFound {code} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { drillsByCategory, type Drill } from '../drills'
|
import { drillsByCategory, isClickDrill, type Drill } from '../drills'
|
||||||
import { href } from '../router.svelte'
|
import { href } from '../router.svelte'
|
||||||
import * as storage from '../lib/storage'
|
import * as storage from '../lib/storage'
|
||||||
|
|
||||||
@@ -7,10 +7,7 @@
|
|||||||
|
|
||||||
// Last-used BPM shown next to click-track drills (only kinds that persist bpm).
|
// Last-used BPM shown next to click-track drills (only kinds that persist bpm).
|
||||||
function lastBpm(d: Drill): number | null {
|
function lastBpm(d: Drill): number | null {
|
||||||
if (d.kind === 'strum') {
|
return isClickDrill(d) ? storage.getDrillBpm(d.code, d.defaultBpm) : null
|
||||||
return storage.getDrillBpm(d.code, d.defaultBpm)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user