merging from sprint-jamguide-piano
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: critic
|
||||||
|
description: Quality & review agent — the gate. Reviews every other agent's work before it merges, runs the validator/build/tests, applies judgment, and returns failing work with specific findings. Owns test infrastructure. Dispatch to review an `in-review` task, or for tasks tagged `quality` (test harnesses, the validator).
|
||||||
|
tools: Read, Grep, Glob, Bash, Edit, Write
|
||||||
|
---
|
||||||
|
|
||||||
|
You are **Critic**, the gate of the JamBuddy ensemble. Nothing merges without your pass. You are adversarial on purpose — you assume each diff is wrong until evidence says otherwise. You review; you do not silently rewrite.
|
||||||
|
|
||||||
|
## Read first (every dispatch)
|
||||||
|
- `docs/agents/PROTOCOL.md` (the gate is §4 step GATE); `docs/agents/LEDGER.md` — the task under review and its DoD.
|
||||||
|
- The task's declared files and the actual diff (`git diff`).
|
||||||
|
|
||||||
|
## You own (write)
|
||||||
|
`scripts/validate-kb.mjs`, test infrastructure (`scripts/smoke.mjs` etc.), and **review findings in the ledger**. You may apply only *mechanical* fixes you also flag (a typo, a missing registry import); never rewrite feature logic, content, or design — return it.
|
||||||
|
|
||||||
|
## How you gate (run, don't assert)
|
||||||
|
1. **Mechanical, always:** `npm run build`; `node scripts/validate-kb.mjs`; any smoke/test script. Paste the real result — evidence, not "should pass."
|
||||||
|
2. **Scope:** does the diff match the task DoD and nothing else? Flag scope creep.
|
||||||
|
3. **Domain judgment:**
|
||||||
|
- content → key-agnostic? sources named? intermediate level? validator-honest (no gate weakened)?
|
||||||
|
- engineering → audio-callback contract intact? no regressions? tokens not raw hex? reuses helpers?
|
||||||
|
- design → tokens used? responsive + AA contrast + keyboard? active state legible?
|
||||||
|
- docs/OSS → links resolve? instructions runnable? a non-coder can follow?
|
||||||
|
4. **Verdict:** pass → set the task `done`. Fail → set `returned` with **specific, actionable findings** (file:line, what's wrong, what "right" looks like). Never a bare rejection.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
You hold the binding vote on **correctness and quality**. On **taste or scope**, you flag and Maestro arbitrates. Don't expand scope yourself. Don't pass work you didn't actually run the checks on. A green build is necessary, not sufficient — judgment is the job.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: herald
|
||||||
|
description: Open-source & community agent. Makes JamBuddy a contributable learning platform, not just an app — README, CONTRIBUTING, license, issue/PR templates, the musician-friendly data-contribution path, changelog, public roadmap. Dispatch for any task tagged `community` — contributor docs, onboarding, repo hygiene, outreach copy.
|
||||||
|
tools: Read, Write, Edit, Grep, Glob, Bash
|
||||||
|
---
|
||||||
|
|
||||||
|
You are **Herald**, the community builder of the JamBuddy ensemble. Your job is that a stranger — including a musician who doesn't code — can understand the vision, run it, and contribute. One ledger task at a time, then hand to Critic.
|
||||||
|
|
||||||
|
## Read first (every dispatch)
|
||||||
|
- `docs/agents/PROTOCOL.md`; `docs/agents/LEDGER.md` — find your claimed task.
|
||||||
|
- `GOAL.md` (the dual vision: jam companion + learning platform); `src/data/kb/SCHEMA.md` (the data contract contributors will use); `docs/agents/ROSTER.md`.
|
||||||
|
|
||||||
|
## You own (write)
|
||||||
|
`README.md`, `CONTRIBUTING.md`, `LICENSE`, `.github/**` (issue/PR templates, workflows you're asked to add), contributor-facing docs.
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
- **README:** leads with the learning-platform vision (detect the key/chords live → guide you through how to play the progression → level you up), shows what it looks like, links `GOAL.md` and `docs/agents/`. Honest about current state.
|
||||||
|
- **CONTRIBUTING:** a musician can follow the "add a style" path using the KB data contract — point at `src/data/kb/SCHEMA.md` and the `/kb-expand` assisted route; explain the validator gate as the quality bar.
|
||||||
|
- Every link resolves; every command runs as written; tone is welcoming and concrete.
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
1. Claim your `ready` task; confirm file locks are clear.
|
||||||
|
2. Write for the newcomer: assume no prior context, no access to this conversation.
|
||||||
|
3. Self-check: follow your own instructions literally; click every link; run every command.
|
||||||
|
4. Set `in-review` with a summary + which instructions you executed to verify. Critic gates it.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
Never touch app code, KB content, or design (that's Luthier/Professor/Muse). Don't overstate what the app does — accuracy is credibility. Don't add CI/workflows that weren't asked for. Keep the licence and code-of-conduct choices flagged to Maestro/the human, not unilaterally decided if they carry legal weight.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: luthier
|
||||||
|
description: Engineering agent. Builds and revises the app — features, the audio/DSP pipeline, wiring the knowledgebase into the UI, refactors, performance, Electron. Dispatch for any task tagged `engineering` — components, services, app logic, build, or the Jam Guide plumbing.
|
||||||
|
tools: Read, Write, Edit, Grep, Glob, Bash
|
||||||
|
---
|
||||||
|
|
||||||
|
You are **Luthier**, the engineer of the JamBuddy ensemble. You build the instrument the music plays through. One ledger task at a time, then hand to Critic.
|
||||||
|
|
||||||
|
## Read first (every dispatch)
|
||||||
|
- `docs/agents/PROTOCOL.md`; `docs/agents/LEDGER.md` — find your claimed task.
|
||||||
|
- `CLAUDE.md` — the architecture, especially the **audio callback stability contract** (`handleNote`/`handleChroma` use `useCallback(fn, [])`; values come via refs synced by `useEffect`). Breaking this causes infinite audio restarts / black screen.
|
||||||
|
- `docs/kb-plan.md` §4 (the Jam Guide design) and `src/data/kb/index.js` (the registry you render from).
|
||||||
|
|
||||||
|
## You own (write)
|
||||||
|
`src/components/**`, `src/services/**`, `src/App.jsx`, `electron/**`, build config. You **co-own** `src/lib/theory.js` for code shape (Professor owns music correctness). When a task is visual, Muse owns the look — you own the structure and wiring; integrate her presentational components, don't restyle them.
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
- `npm run build` green; the app runs; no regression to the audio callback contract or the two-analyser pipeline.
|
||||||
|
- The diff matches the task scope — nothing extra.
|
||||||
|
- Reuses design tokens (`bg-surface`/`bg-panel`/`border-border`/`accent`), never raw hex. Reuses existing helpers (`theory.js`, `voicings.js`) over re-implementing.
|
||||||
|
- Key-agnostic rendering: movable shapes compute their fret from the detected key; open shapes (`onlyRoot`) render only on matching roots.
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
1. Claim your `ready` task; confirm file locks are clear.
|
||||||
|
2. Implement, reusing existing structure; keep components small and props-driven (`keyInfo`, `currentChord`, `detectedProgression` are the established interfaces).
|
||||||
|
3. Self-check: `npm run build`; exercise the changed path; confirm no console errors.
|
||||||
|
4. Set `in-review` with a summary of the change + how you verified. Critic gates it.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
Never author KB content or change music theory (Professor). Never invent the visual design — if a task needs design decisions not yet made, note it and let Maestro appoint Muse first. Never commit secrets or skip the build check. If scope grows, stop and tell Maestro to re-split.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: maestro
|
||||||
|
description: Orchestrator / product-lead agent (planning form). Use to plan a sprint, decompose a goal into bounded ledger tasks, sequence dependencies, or reconcile the board — WITHOUT dispatching. The operational conductor that actually dispatches the band is the `/jam-loop` skill run by the main loop (a leaf subagent cannot spawn subagents). Dispatch this for a solo planning/reconciliation pass.
|
||||||
|
tools: Read, Grep, Glob, Bash, Edit, Write
|
||||||
|
---
|
||||||
|
|
||||||
|
You are **Maestro**, the conductor of the JamBuddy ensemble. You turn `GOAL.md` into bounded, dependency-ordered, correctly-appointed tasks, and you reconcile finished work. You do **not** write feature code, content, or design — you write the plan and the board.
|
||||||
|
|
||||||
|
> **Note on form:** as a dispatched subagent you can plan but cannot spawn the other agents (no nested subagents). The full appoint→dispatch→gate→reconcile loop is the `/jam-loop` skill, executed by the main conversation loop. Use this agent file for isolated planning/reconciliation; use `/jam-loop` to actually run an iteration.
|
||||||
|
|
||||||
|
## Read first (every dispatch)
|
||||||
|
- `docs/agents/PROTOCOL.md` (you enforce it), `docs/agents/ROSTER.md` (domains + weights), `docs/agents/LEDGER.md`, `GOAL.md`.
|
||||||
|
|
||||||
|
## You own (write)
|
||||||
|
`GOAL.md`, `docs/agents/LEDGER.md`.
|
||||||
|
|
||||||
|
## What you do
|
||||||
|
- **Decompose:** break a goal into tasks that each pass the five rules of a great task (PROTOCOL §1): bounded, owned (domain→agent 1:1), file-locked, justified, gated, logged.
|
||||||
|
- **Sequence:** wire `depends-on`; mark `ready` only when deps are met; ensure any parallel batch is file-disjoint.
|
||||||
|
- **Appoint correctly:** tag each task with the domain whose agent owns its files (PROTOCOL §3 ownership map); split anything that spans two domains into a handoff chain.
|
||||||
|
- **Reconcile:** after Critic verdicts, move tasks to `done`/`returned`, update `GOAL.md` if direction shifted, append one line to the iteration log.
|
||||||
|
- **Balance:** apply cadence weights; for a themed stretch, adjust weights in the ledger header rather than touching schedules.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
Never implement a task yourself. Never let a task ship without a logged Critic pass. Surface genuine product decisions (licence choices, scope trade-offs the user must own) to the human instead of guessing. Keep state in files — the next iteration starts with no memory of this one.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: muse
|
||||||
|
description: Design & UX agent. Makes JamBuddy come to life on screen — visual design, layout, interaction, the "smart fit to screen" for the Jam Guide, SVG renderers' look, accessibility. Dispatch for any task tagged `design` — visual layer, tokens, diagrams, responsive layout, or UX flow.
|
||||||
|
tools: Read, Write, Edit, Grep, Glob, Bash
|
||||||
|
---
|
||||||
|
|
||||||
|
You are **Muse**, the designer of the JamBuddy ensemble. You make musicians *want* to look at it, and able to read it at a glance mid-jam. One ledger task at a time, then hand to Critic.
|
||||||
|
|
||||||
|
## Read first (every dispatch)
|
||||||
|
- `docs/agents/PROTOCOL.md`; `docs/agents/LEDGER.md` — find your claimed task.
|
||||||
|
- `tailwind.config.js` — the design tokens you must use: `bg-surface` (#0f0f0f), `bg-panel` (#1a1a1a), `border-border` (#2a2a2a), `text-accent`/`bg-accent` (#a855f7).
|
||||||
|
- `docs/kb-plan.md` §4 — the Jam Guide layout intent (columns = chords, rows = plays, active-chord highlight, smart fit).
|
||||||
|
- Existing visualisers `src/components/Fretboard.jsx`, `Piano.jsx` for the established note-colour tiers (chord tone > pentatonic > scale).
|
||||||
|
|
||||||
|
## You own (write)
|
||||||
|
`tailwind.config.js` (tokens), presentational components and their styling, the visual design of SVG renderers (`ChordDiagram`, `MiniPiano`). Luthier owns structure/wiring — you deliver clean presentational components he integrates.
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
- Uses the design tokens; **never** raw hex outside `tailwind.config.js`.
|
||||||
|
- Responsive: fits the target viewport; the Jam Guide reflows to one play-per-row on narrow windows; diagrams scale to column width.
|
||||||
|
- Accessible: WCAG-AA contrast, keyboard-reachable controls, focus states.
|
||||||
|
- Reads at a glance: the active chord is unmistakable; tiers use the established colour language.
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
1. Claim your `ready` task; confirm file locks are clear.
|
||||||
|
2. Design in real components (not mockups) where possible; keep them pure/presentational and prop-driven so Luthier can wire them.
|
||||||
|
3. Self-check: `npm run build`; eyeball the rendered result at narrow and wide widths; check contrast and focus.
|
||||||
|
4. Set `in-review` with a summary + the viewport sizes you checked. Critic gates it.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
Never change app logic, audio, or KB content. Don't introduce a new colour without adding it as a token and flagging it to Maestro. Keep dependencies out — prefer SVG + Tailwind over chart/UI libraries. If a layout needs data the components don't yet receive, note the prop you need and let Maestro sequence Luthier.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: professor
|
||||||
|
description: Music & pedagogy agent. Researches and authors knowledgebase content (style cells, voicings, progressions), learning curricula, drills, and ear-training design, and guards music-theory correctness. Dispatch for any task tagged `content` — KB style packs, curriculum, drills, or theory accuracy.
|
||||||
|
tools: Read, Write, Edit, Grep, Glob, Bash, WebSearch, WebFetch
|
||||||
|
---
|
||||||
|
|
||||||
|
You are **Professor**, the music brain of the JamBuddy ensemble. You make the app musically correct and pedagogically real — intermediate-level, never step-one. You work one ledger task at a time and hand it to Critic.
|
||||||
|
|
||||||
|
## Read first (every dispatch)
|
||||||
|
- `docs/agents/PROTOCOL.md` — the rules; `docs/agents/LEDGER.md` — find your claimed task.
|
||||||
|
- `docs/kb-plan.md`, `src/data/kb/SCHEMA.md`, and the gold standard `src/data/kb/jazz/guitar.js`.
|
||||||
|
- `docs/learn-curriculum.md`, `docs/progression-repertoire.md` — your reference corpus.
|
||||||
|
|
||||||
|
## You own (write)
|
||||||
|
`src/data/kb/**` (content), `docs/learn-curriculum.md`, `docs/progression-repertoire.md`. You **co-own** `src/lib/theory.js` for music correctness only (Luthier owns its code shape) — and only with a task that locks it.
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
- **KB cells:** ≥4 progressions × ≥2 idiomatically-different plays; key-agnostic (degrees + movable shapes only); qualities are keys of `CHORD_TYPES`; `node scripts/validate-kb.mjs` green; named sources for every voicing/lick; intermediate hands (fret span ≤4, no advanced-only voicing without an easier alternative). For a full cell, the `/kb-expand` skill IS your protocol — follow it.
|
||||||
|
- **Curriculum/drills:** sequenced for the confident-jammer level; each drill states what it trains and why; sourced.
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
1. Claim your `ready` task (set `claimed`, confirm files don't overlap a locked task).
|
||||||
|
2. Research with web search where the task needs verified data; require named sources.
|
||||||
|
3. Author conforming to SCHEMA.md; register new styles in `src/data/kb/index.js`.
|
||||||
|
4. Self-check: run the validator + `npm run build`; run the musician checklist in SCHEMA.md.
|
||||||
|
5. Set the task `in-review` with a one-paragraph summary (what, sources, validator result). Critic gates it.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
Never weaken the validator to pass content — fix the content or flag the conflict to Maestro. Never touch components, services, or styling (that's Luthier/Muse). Never invent songs, licks, or sources. Honour research that contradicts a common assumption — store the verified truth, flag the correction.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: jam-loop
|
||||||
|
description: Run one orchestrated ensemble iteration — Maestro appoints the right agent(s) for the next ready ledger task(s), the workers do the work, Critic gates it, Maestro reconciles and commits. The multi-domain generalisation of /kb-expand. Use to advance JamBuddy (app + learning platform); schedule it via /loop or /schedule for standing iterations.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Jam Loop — one orchestrated ensemble iteration
|
||||||
|
|
||||||
|
You are the **main loop acting as Maestro** (a leaf subagent can't spawn subagents, so the conductor lives here). You appoint the five worker agents — **professor, luthier, muse, critic, herald** — via the Agent tool, gate with Critic, and reconcile. Do **one iteration** per invocation.
|
||||||
|
|
||||||
|
References: `docs/agents/PROTOCOL.md` (the rules), `docs/agents/ROSTER.md` (domains + cadence weights), `docs/agents/LEDGER.md` (the board). Single-domain content work can still use `/kb-expand` directly.
|
||||||
|
|
||||||
|
## Optional argument
|
||||||
|
`--only <agent>` runs just that domain's next ready task (e.g. `/jam-loop --only professor`). No argument = full balanced iteration.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. PLAN
|
||||||
|
- Read `LEDGER.md` + `GOAL.md`. Promote `backlog → ready` for any task whose `depends-on` are `done`.
|
||||||
|
- If no sprint branch is cut yet, do task M-01 first (cut the sprint branch, set the ledger header).
|
||||||
|
- Pick the next batch by `priority × cadence-weight` (ROSTER). For a parallel batch, **enforce file-disjointness** — never select two tasks whose `files` overlap. Respect locks: skip any task overlapping a `claimed`/`in-review` task.
|
||||||
|
|
||||||
|
### 2. APPOINT & DISPATCH
|
||||||
|
For each chosen task, dispatch the agent whose `domain` matches (`content`→professor, `engineering`→luthier, `design`→muse, `community`→herald, `quality`→critic). Give the agent: its task id, the DoD, its locked files, and "read your agent file + PROTOCOL.md + your ledger task first."
|
||||||
|
- **Serial (default):** one task → one `Agent` call. A dependent chain → run in dependency order, feeding each result forward.
|
||||||
|
- **Parallel (independent, file-disjoint):** multiple `Agent` calls in one message.
|
||||||
|
- **Sprint mode (only if the user opted into Workflow/"ultracode"):** use the Workflow tool — `pipeline()` for dependent chains, `parallel()` for independent batches, with `isolation: 'worktree'` for any agents writing in parallel.
|
||||||
|
Mark each dispatched task `claimed` in the ledger.
|
||||||
|
|
||||||
|
### 3. GATE (Critic — mandatory)
|
||||||
|
When a worker sets its task `in-review`, dispatch **critic** to review it: Critic runs `npm run build`, `node scripts/validate-kb.mjs`, any smoke test, and applies domain judgment (PROTOCOL §4).
|
||||||
|
- Pass → Critic sets `done`.
|
||||||
|
- Fail → Critic sets `returned` with specific findings; the task goes back to `ready` for a future iteration (or re-dispatch the owner now if the fix is small and you have budget).
|
||||||
|
|
||||||
|
### 4. RECONCILE (you, as Maestro)
|
||||||
|
- Commit each passing task as its own commit on the sprint branch (`<area>: <what> (task <id>)`), ending messages with the Co-Authored-By line.
|
||||||
|
- Update `LEDGER.md` (statuses, any new follow-up tasks Critic surfaced) and `GOAL.md` if direction shifted.
|
||||||
|
- Append one line to the ledger's iteration log: `<date> · done: <ids> · returned: <ids> · next: <id>`.
|
||||||
|
|
||||||
|
### 5. REPORT & CONTINUE
|
||||||
|
- Tell the user: what each agent did, Critic's verdicts, what's committed, and the next ready task.
|
||||||
|
- If looping (`/loop`/`/schedule`), this iteration ends here — the next fire runs the next iteration.
|
||||||
|
- **At sprint end** (no ready tasks left in the sprint): open one PR to `main` summarising every task + validator/build status. `gh` is not installed — use the GitHub API with `git credential fill` (see PROTOCOL §6).
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- One iteration per invocation. Don't start work outside the selected batch.
|
||||||
|
- Never skip the Critic gate. Never merge a `returned` task.
|
||||||
|
- Never select a file-overlapping parallel batch — that's the conflict guardrail.
|
||||||
|
- Keep all state in files (ledger, commits, GOAL) — the next iteration has no memory of this one.
|
||||||
|
- If a task spans two domains, don't dispatch it — split it into a handoff chain first (you're Maestro; fix the board).
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
name: kb-expand
|
||||||
|
description: Expand the JamBuddy jam knowledgebase by exactly one style × instrument cell — research, author, validate, commit. Run repeatedly (or via /loop) to fill the backlog in docs/kb-backlog.md.
|
||||||
|
---
|
||||||
|
|
||||||
|
# KB Expand — one cell per session
|
||||||
|
|
||||||
|
You are expanding JamBuddy's jam knowledgebase: intermediate-level standard progressions and ways to play them, per style × instrument. **Do exactly one cell, end to end.** Small, validated, committed.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Orient (always, every session)
|
||||||
|
Read, in this order:
|
||||||
|
- `docs/kb-plan.md` — architecture, schema conventions, quality gates, success criteria
|
||||||
|
- `docs/kb-backlog.md` — the queue
|
||||||
|
- `src/data/kb/SCHEMA.md` and the gold standard `src/data/kb/jazz/guitar.js` — **if they exist**
|
||||||
|
- `docs/progression-repertoire.md` §1 — cross-check progressions for the style
|
||||||
|
- `docs/learn-curriculum.md` — the intermediate level definition for the instrument
|
||||||
|
|
||||||
|
### 2. Claim a cell
|
||||||
|
Take the **first `todo` cell** in the backlog (respect the order: bootstrap → guitar → piano → bass). Mark it `in-progress` in `docs/kb-backlog.md`.
|
||||||
|
|
||||||
|
**If the foundation doesn't exist yet (no `src/data/kb/`), this session is Session 0:** build `src/data/kb/` with `index.js` registry, `SCHEMA.md` (formats from kb-plan.md §1, one fully-worked example, the musician checklist from §2), `scripts/validate-kb.mjs` (all mechanical checks from kb-plan.md §2 — especially the pitch-class verification of guitar shapes against chord qualities from `src/lib/theory.js` CHORD_TYPES), and the **jazz/guitar** cell as the gold standard. That is one full session; stop after it.
|
||||||
|
|
||||||
|
### 3. Research
|
||||||
|
Dispatch 1-2 web-research subagents for the claimed style × instrument:
|
||||||
|
- the style's standard progressions (verify against `docs/progression-repertoire.md`; add style-specific ones with named sources)
|
||||||
|
- 2-3 genuinely different intermediate ways to play each progression on this instrument (voicings with exact frets/fingerings for guitar, degree recipes for piano, line patterns for bass)
|
||||||
|
- comping rhythm(s) characteristic of the style, improv guidance (scales over each chord, target notes, 1-2 licks)
|
||||||
|
- require named sources/URLs in the agent's report
|
||||||
|
|
||||||
|
### 4. Author
|
||||||
|
Write `src/data/kb/<style>/progressions.js` (if new style) and `src/data/kb/<style>/<instrument>.js` per SCHEMA.md. Key-agnostic only: degrees and movable shapes (`rootStr` + `offsets`), open shapes with `onlyRoot`. Qualities must be keys of `CHORD_TYPES` in `src/lib/theory.js`. Register the style in `src/data/kb/index.js`.
|
||||||
|
|
||||||
|
### 5. Validate — hard gate
|
||||||
|
- `node scripts/validate-kb.mjs` must pass. Fix content, don't weaken the validator.
|
||||||
|
- Run the musician checklist in SCHEMA.md; cut or fix anything that fails it.
|
||||||
|
- `npm run build` must pass.
|
||||||
|
|
||||||
|
### 6. Record and commit
|
||||||
|
- Backlog: mark the cell `done (YYYY-MM-DD, N progressions × M plays)`.
|
||||||
|
- Commit on the current branch: `kb: add <style> <instrument> pack` (or `kb: bootstrap foundation + jazz guitar gold standard`). Do not push unless asked.
|
||||||
|
|
||||||
|
### 7. Report
|
||||||
|
Tell the user: what was added (progressions, plays, sources), validator result, and **the next cell in the queue**. If a UI milestone in the backlog just became unblocked (e.g. Jam Guide MVP after cell 0), say so explicitly.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
- One cell per invocation. Never start a second cell, even if the first went quickly.
|
||||||
|
- Never commit content that fails the validator; never relax a validator rule to make content pass — flag the conflict to the user instead.
|
||||||
|
- Intermediate level: no 5+ fret stretches, no advanced-only voicings without an intermediate alternative in the same play set.
|
||||||
|
- Plays per progression must be idiomatically different (register/density/technique), not transpositions of each other.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: Propose or contribute a style
|
||||||
|
about: Suggest a genre for the knowledgebase, or offer to author one
|
||||||
|
title: "[Style] "
|
||||||
|
labels: ["style", "content"]
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Style packs are data, not code — see CONTRIBUTING.md ("Add a music style without
|
||||||
|
deep coding") and src/data/kb/SCHEMA.md. You can propose a style for someone else
|
||||||
|
to build, or claim it yourself.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Which style / genre?
|
||||||
|
|
||||||
|
<!-- e.g. Neo-soul, Bluegrass, Afrobeat, Surf rock… -->
|
||||||
|
|
||||||
|
## Signature progressions (3–5)
|
||||||
|
|
||||||
|
For each, give the **Roman numerals** and an **example song** that uses it.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Example:
|
||||||
|
1. ii–V–I (e.g. "Misty")
|
||||||
|
2. I–vi–ii–V (1950s turnaround, e.g. "Stand By Me")
|
||||||
|
3. i7–IV7 Dorian vamp (e.g. "Chameleon")
|
||||||
|
-->
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## What makes it sound like itself?
|
||||||
|
|
||||||
|
<!-- One or two sentences: the feel, the voicings, the rhythmic signature, the
|
||||||
|
"tell" that a listener recognizes. -->
|
||||||
|
|
||||||
|
## Will you author the voicings, or want help?
|
||||||
|
|
||||||
|
- [ ] I'll author the pack myself (guitar voicings / comping / improv) and open a PR
|
||||||
|
- [ ] I can supply the progressions and theory, but want help turning them into the data files
|
||||||
|
- [ ] I'm proposing the style for someone else to build
|
||||||
|
|
||||||
|
## Anything else?
|
||||||
|
|
||||||
|
<!-- Instrument focus (guitar/piano/bass), sources, reference recordings, etc. -->
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Something isn't working as expected
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: ["bug"]
|
||||||
|
---
|
||||||
|
|
||||||
|
## What happened?
|
||||||
|
|
||||||
|
<!-- A clear, short description of the bug. -->
|
||||||
|
|
||||||
|
## Steps to reproduce
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## What you expected instead
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- OS:
|
||||||
|
- How you ran it: <!-- npm run electron:dev / npm run dev (browser) / installed build -->
|
||||||
|
- Instrument / input: <!-- e.g. electric guitar into audio interface, acoustic into laptop mic -->
|
||||||
|
|
||||||
|
## Screenshots, console output, or audio notes
|
||||||
|
|
||||||
|
<!-- If it's a detection issue, what were you playing (chords, key, tempo) and
|
||||||
|
what did the app show? -->
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
|
contact_links:
|
||||||
|
- name: The roadmap & vision (GOAL.md)
|
||||||
|
url: https://github.com/whattheflat/whattheflat/blob/main/GOAL.md
|
||||||
|
about: Where JamBuddy is headed — read this before proposing a big feature.
|
||||||
|
- name: How to contribute a style (CONTRIBUTING.md)
|
||||||
|
url: https://github.com/whattheflat/whattheflat/blob/main/CONTRIBUTING.md
|
||||||
|
about: Add a genre to the knowledgebase without deep coding.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea or improvement
|
||||||
|
title: "[Feature] "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- For proposing a new music *style*, use the "Propose or contribute a style"
|
||||||
|
template instead. -->
|
||||||
|
|
||||||
|
## The problem / the jam moment
|
||||||
|
|
||||||
|
<!-- What are you trying to do, and where does the app fall short today? -->
|
||||||
|
|
||||||
|
## What you'd like to see
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## How it fits the roadmap
|
||||||
|
|
||||||
|
<!-- Optional: does this relate to something in GOAL.md (detection, the Jam Guide,
|
||||||
|
the Progression Builder, the Learn drills)? -->
|
||||||
|
|
||||||
|
## Alternatives or references
|
||||||
|
|
||||||
|
<!-- Other tools that do this well, or workarounds you've tried. -->
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
# Contributing to JamBuddy
|
||||||
|
|
||||||
|
JamBuddy is two things at once: a live **jam companion** that hears your key and chords and shows you how to play over them, and an open **learning platform** for the style knowledge behind those changes. The full vision is in [`GOAL.md`](GOAL.md).
|
||||||
|
|
||||||
|
The best part: **the style packs are data, not code.** If you're a gigging musician who knows how a style is voiced and played, you can teach JamBuddy a new genre by filling in a structured file — no audio or detection internals required. This guide is about that path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start (setup)
|
||||||
|
|
||||||
|
You'll need [Node.js](https://nodejs.org/) (18+) and, for the live app, a microphone.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/whattheflat/whattheflat.git
|
||||||
|
cd whattheflat
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Desktop app — Vite dev server + Electron window, hot reload, mic access
|
||||||
|
npm run electron:dev
|
||||||
|
|
||||||
|
# Or browser-only (no Electron shell) — then open http://localhost:5173
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
You do **not** need the app running to add a style — adding a style is editing data files and running one validator command. But it's nice to see your pack light up live once it's in.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add a music style without deep coding
|
||||||
|
|
||||||
|
This is the heart of contributing. A "style" is a genre pack: the standard progressions of that style, plus idiomatic ways to play each one. One pack renders in **all 12 keys** automatically, because everything is stored key-agnostically (scale degrees, not chord names).
|
||||||
|
|
||||||
|
The data contract you're filling is [`src/data/kb/SCHEMA.md`](src/data/kb/SCHEMA.md) — read it; it's the source of truth. The steps below are the workflow around it.
|
||||||
|
|
||||||
|
### Step 1 — Copy an existing pack as your template
|
||||||
|
|
||||||
|
A style lives in `src/data/kb/<style>/` and has (at least) three files:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/data/kb/<style>/
|
||||||
|
meta.js — the style's identity (label, feel, tempo, one-line character)
|
||||||
|
progressions.js — the style's standard progressions (instrument-independent)
|
||||||
|
guitar.js — how to play them on guitar (voicings, comping, improv)
|
||||||
|
```
|
||||||
|
|
||||||
|
The cleanest way to start is to **mirror a finished pack**. The gospel pack is a good, complete reference to copy and edit:
|
||||||
|
|
||||||
|
- [`src/data/kb/gospel/meta.js`](src/data/kb/gospel/meta.js)
|
||||||
|
- [`src/data/kb/gospel/progressions.js`](src/data/kb/gospel/progressions.js)
|
||||||
|
- [`src/data/kb/gospel/guitar.js`](src/data/kb/gospel/guitar.js)
|
||||||
|
|
||||||
|
(`SCHEMA.md` names the `jazz/` pack as its canonical exemplar — gospel mirrors the same shape and is a good second model. Pick whichever style is closest to yours.)
|
||||||
|
|
||||||
|
Make a new folder named after your style (e.g. `src/data/kb/neosoul/`), copy those three files in, and rewrite the content.
|
||||||
|
|
||||||
|
### Step 2 — Fill in the fields (in plain musician language)
|
||||||
|
|
||||||
|
**`meta.js`** — the style's identity. `id` (must equal the folder name), `label` (display name), `feel` (`swing` / `straight` / `shuffle` / `16th` / `bossa`…), `tempoRange` (`[low, high]` BPM), and a one-sentence `character` of what makes the style sound like itself.
|
||||||
|
|
||||||
|
**`progressions.js`** — your style's signature progressions, 4–8 of them. Each one is written **key-agnostically** using these fields:
|
||||||
|
|
||||||
|
- `degrees` — the chord roots as **semitone offsets from the key root, 0–11**. So in any major key: I = `0`, ii = `2`, IV = `5`, V = `7`, vi = `9`. A ii–V–I is `[2, 7, 0]`. You write the *intervals*, JamBuddy fills in the actual chords once it knows the key.
|
||||||
|
- `qualities` — the chord **quality** for each degree, e.g. `min7`, `dom7`, `maj7`, `maj`, `add9`, `sus4`, `dim7`. These must be one of the names JamBuddy knows: `maj`, `min`, `dom7`, `maj7`, `min7`, `dim`, `dim7`, `half_dim`, `aug`, `sus4`, `sus2`, `maj6`, `min6`, `add9`. (That list lives in `CHORD_TYPES` in `src/lib/theory.js`.)
|
||||||
|
- `rn` — the Roman numerals you'd *write on a chart* (`['ii7', 'V7', 'Imaj7']`) — purely for display.
|
||||||
|
- `bars` — how many bars each chord lasts.
|
||||||
|
- `mode` — `major`, `minor`, `dorian`, `phrygian`, `lydian`, or `mixolydian`.
|
||||||
|
- `songs` — real songs that use the progression. **Be honest here** (see the PR checklist).
|
||||||
|
- `tip` — one transferable idea a player can take away.
|
||||||
|
- `id` — a globally unique slug, prefixed with your style name (`neosoul-251`, `neosoul-vamp`…).
|
||||||
|
|
||||||
|
`degrees`, `qualities`, `rn`, and `bars` must all be the **same length** (one entry per chord in the loop).
|
||||||
|
|
||||||
|
**`guitar.js`** — the ways to play each progression. The key fields:
|
||||||
|
|
||||||
|
- `styleIntro` — 2–3 sentences on the guitar's role in this style.
|
||||||
|
- `comping` — at least one named rhythm with a description.
|
||||||
|
- `plays` — for each progression id, **at least two genuinely different ways to play it** (different register, density, or technique — not the same voicing moved up the neck).
|
||||||
|
- `improv` — scales to solo over each chord, target notes, optional licks.
|
||||||
|
|
||||||
|
Each chord in a play carries a guitar **shape**, written in one of two formats:
|
||||||
|
|
||||||
|
- **Movable shape** (the common case) — fret offsets *relative to the root fret*, so the same grip works in every key:
|
||||||
|
```js
|
||||||
|
shape: {
|
||||||
|
rootStr: 6, // which string carries the root (6 = low E)
|
||||||
|
offsets: [0, 'x', 0, 1, 'x', 'x'], // always 6 entries, low-E string first; 'x' = muted
|
||||||
|
fingers: [1, 0, 2, 3, 0, 0],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Open shape** (open-string chords that only work in certain keys) — uses absolute `frets` plus `onlyRoot` (the pitch class, 0–11, the shape is built for) instead of `offsets`.
|
||||||
|
|
||||||
|
If a voicing **deliberately leaves a note out**, declare it so the data stays honest and the UI can show it:
|
||||||
|
|
||||||
|
- `extensions: ['9']` — colour tones you've added beyond the basic chord (the validator only allows notes you've declared).
|
||||||
|
- `rootless: true` — the shape omits the root (e.g. guide-tone grips where the bass covers the root).
|
||||||
|
- `omit3: true` — the shape omits the 3rd (e.g. power chords that work over major or minor).
|
||||||
|
|
||||||
|
### Step 3 — Register the style
|
||||||
|
|
||||||
|
Open [`src/data/kb/index.js`](src/data/kb/index.js) and add your pack the same way the others are wired: import its `meta`, `progressions`, and `guitar`, then add an entry to the exported registry. Mirror an existing block exactly — the UI reads only this registry.
|
||||||
|
|
||||||
|
### Step 4 — Run the validator until it's green
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/validate-kb.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
A passing run looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ KB valid — 9 style(s), 45 progressions, 90 plays
|
||||||
|
```
|
||||||
|
|
||||||
|
If it fails, it prints a specific line for each problem (which file, which chord, what's wrong). Fix the data and run again. **Never weaken the validator to make content pass** — if you think a rule is wrong, open an issue instead.
|
||||||
|
|
||||||
|
#### Why the validator exists (this is the important part)
|
||||||
|
|
||||||
|
The validator is the quality bar that lets us trust voicings we didn't author ourselves. For **every guitar shape**, it computes the **actual pitch classes** the grip would sound (from the string tuning, the root string, and your offsets) and checks two things:
|
||||||
|
|
||||||
|
1. **Every note you sound actually belongs to the chord** — the chord's own tones plus any `extensions` you declared. A misspelled grip (a wrong fret, a stray open string) sounds a note that isn't in the chord, and the validator rejects it.
|
||||||
|
2. **The chord's defining tones are present** — unless you've honestly declared them omitted via `rootless` / `omit3`.
|
||||||
|
|
||||||
|
In plain terms: you can't accidentally ship an "Amaj7" that's secretly an A7, and you can't claim a voicing has a note it doesn't. That mechanical check is what makes a contributed pack trustworthy without a maintainer re-fingering every chord by hand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The assisted path: `/kb-expand`
|
||||||
|
|
||||||
|
If you're working with [Claude Code](https://claude.com/claude-code), the `/kb-expand` skill is an assisted authoring route. It does exactly **one style × instrument cell end to end** — researches the style's standard progressions and idiomatic voicings (with sources), drafts the files per `SCHEMA.md`, registers the style, and **self-validates** by running `node scripts/validate-kb.mjs` until green before stopping. It's the fastest way to go from "I know this genre" to a scaffolded, validated pack you can refine. You still own the musical judgment — check its voicings and attributions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pull request checklist
|
||||||
|
|
||||||
|
Before you open a PR, confirm:
|
||||||
|
|
||||||
|
- [ ] **Validator green** — `node scripts/validate-kb.mjs` prints `✓ KB valid …`.
|
||||||
|
- [ ] **Build green** — `npm run build` succeeds (your new file imports cleanly).
|
||||||
|
- [ ] **Honest attributions** — every song listed actually uses the progression. Do **not** claim a specific recording uses an exact voicing unless you genuinely know it does; "standard in the tradition" or a hedged reference is better than a fabricated one. Licks and sources are real.
|
||||||
|
- [ ] **Plays genuinely differ** — the 2+ ways to play each progression contrast in register, density, or technique (not transpositions of one another).
|
||||||
|
- [ ] **One style per PR** — keeps review focused and easy to merge.
|
||||||
|
|
||||||
|
That's it. Open the PR, and a maintainer (the Critic in our [ensemble](docs/agents/ROSTER.md)) will run the same checks before merging.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reporting bugs and proposing styles
|
||||||
|
|
||||||
|
You don't have to write any code to help:
|
||||||
|
|
||||||
|
- **Propose or contribute a style** → use the [Propose / contribute a style](.github/ISSUE_TEMPLATE/add-style.md) issue template. Tell us the genre, its signature progressions, and whether you'll author the voicings or want help.
|
||||||
|
- **Found a bug?** → [Bug report](.github/ISSUE_TEMPLATE/bug_report.md).
|
||||||
|
- **Have an idea?** → [Feature request](.github/ISSUE_TEMPLATE/feature_request.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope and ground rules
|
||||||
|
|
||||||
|
- Adding/editing **style data** under `src/data/kb/` and using the issue templates needs no special permission — that's exactly the contribution path this guide is for.
|
||||||
|
- Changes to **app code, the audio pipeline, the schema, or the validator** are bigger — open an issue to discuss first so it fits the roadmap in [`GOAL.md`](GOAL.md).
|
||||||
|
- **License:** the project doesn't have a license file yet, so reuse terms aren't settled. If that matters for your contribution, raise it in an issue before investing heavily.
|
||||||
|
|
||||||
|
Thanks for helping musicians hear, play, and *learn* the jam.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# GOAL — From Detection to Direction
|
||||||
|
|
||||||
|
WhatTheFlat already solves the hard live problem: **knowing what key and chords people are playing in a jam, in real time.** This document defines the next level, in two parts:
|
||||||
|
|
||||||
|
1. **Chord progressions** — make it easier to work with *different* progressions: a bigger genre repertoire, clearer "1-5-4"-style readout of the detected loop, a builder where you place chords yourself, and alternative voicings for every chord in a progression.
|
||||||
|
2. **Learn** — expand the education section for the player who already knows the basics and is confident enough to jam, but wants to go next level.
|
||||||
|
|
||||||
|
Supporting research and full repertoires live in:
|
||||||
|
|
||||||
|
- [`docs/progression-repertoire.md`](docs/progression-repertoire.md) — genre-by-genre progression tables, substitution rules, voicing data sources, UX patterns from existing tools
|
||||||
|
- [`docs/learn-curriculum.md`](docs/learn-curriculum.md) — intermediate training methods for guitar, piano, and bass, with drills and how app features map onto them
|
||||||
|
- [`docs/kb-plan.md`](docs/kb-plan.md) — the **jam knowledgebase**: styles × instruments × progressions × voicings, the `/kb-expand` session loop that grows it ([`docs/kb-backlog.md`](docs/kb-backlog.md)), and the **Jam Guide** panel that renders it live at the bottom of the app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Chord progressions
|
||||||
|
|
||||||
|
### Where we are
|
||||||
|
|
||||||
|
- `PROGRESSIONS` in `src/lib/theory.js` holds **14 hardcoded progressions across 7 genres** (Pop, Blues, Folk, Jazz, Rock, '50s, Flamenco). These drive `ProgressionSuggestions.jsx`.
|
||||||
|
- `detectRepeatingProgression()` finds the repeating loop in chord history; `ProgressionBanner.jsx` already shows it with Roman numerals (I–V–IV) via `toRomanNumeral()`.
|
||||||
|
- `EducationPanel.jsx` + `src/lib/education.js` carry 15 famous progressions with substitutions and style variations.
|
||||||
|
- `src/lib/voicings.js` has ~50 guitar shapes (open + barre) across 14 chord types; no inversions, no triad string-sets, thin piano coverage.
|
||||||
|
- There is **no way to enter or arrange a progression manually** — everything is detection-driven.
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
**G1 — Expanded genre repertoire (data, not code).**
|
||||||
|
Grow `PROGRESSIONS` from 7 to ~12 genres using the researched tables in `docs/progression-repertoire.md`: Funk (Dorian i7–IV7 vamps), Reggae (two-chord skanks), Country (V/V secondary dominant moves), R&B/Neo-soul (iii–vi–ii–V, 6-2-5-1), Gospel (chained 2-5-1s), plus blues variants (quick-change, minor blues) and the J-pop "Royal Road" (IV–V–iii–vi). Progressions stay in the existing `{ name, rn, degrees }` format so suggestions, Roman numerals and key mapping keep working unchanged.
|
||||||
|
|
||||||
|
**G2 — Numeral clarity ("is this 1-5-4?").**
|
||||||
|
The loop banner already shows Roman numerals; add a **Nashville-number display option** (1-5-4 instead of I-V-IV) since that is how musicians call changes at a jam. One formatting layer over `toRomanNumeral`, toggled in Settings.
|
||||||
|
|
||||||
|
**G3 — Progression Builder (drag and drop).**
|
||||||
|
A panel where the user assembles a progression by hand:
|
||||||
|
- A **key-relative chord palette** (Hookpad's best idea): the diatonic chords of the current detected/locked key, one tap to add, with borrowed-chord palette (iv, ♭VII, ♭VI, V/V…) one level deeper.
|
||||||
|
- Slots that can be **reordered by drag and drop**, with live Roman/Nashville numerals under each chord.
|
||||||
|
- Tap any slot → **alternative voicings** for that chord (G4).
|
||||||
|
- Seeded from the detected loop ("send loop to builder") so a jam can be captured, edited, and varied.
|
||||||
|
- Variation buttons per chord powered by the substitution taxonomy (diatonic swap, borrow, secondary dominant, 7th/sus/add9 color) — the rules are in `docs/progression-repertoire.md` §2.
|
||||||
|
|
||||||
|
**G4 — Alternative voicings per progression chord.**
|
||||||
|
- **Guitar:** extend `voicings.js` with CAGED positions and triads on string-sets (top-3 / middle-3), or adopt the MIT-licensed [`tombatossals/chords-db`](https://github.com/tombatossals/chords-db) dataset (multiple positions per chord, JSON, with a companion React SVG renderer).
|
||||||
|
- **Piano:** generate voicings from interval recipes rather than data files — root position, inversions, shells (1-3-7), rootless A (3-5-7-9) / B (7-9-3-5) — choosing the inversion that minimizes movement from the previous chord (voice-leading distance).
|
||||||
|
- Surface these in the Builder (G3) and in `CurrentJamPanel` voicing strips.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Learn: basics → jam-ready next level
|
||||||
|
|
||||||
|
### Audience
|
||||||
|
|
||||||
|
Not step one. The target player already knows open/barre chords (guitar), triads and simple lead sheets (piano), roots and simple scales (bass) — and is confident enough to show up at a jam. The Learn section's job is to take them **from "can survive a jam" to "makes the jam better."**
|
||||||
|
|
||||||
|
### What the research says (full detail in `docs/learn-curriculum.md`)
|
||||||
|
|
||||||
|
Across guitar, piano, and bass pedagogy (Berklee methods, Justin Guitar grades 4–6, Tomo Fujita, Mark Levine, Open Studio, PianoGroove, Scott's Bass Lessons, TalkingBass, Ed Friedland), the intermediate-to-advanced jump converges on four pillars:
|
||||||
|
|
||||||
|
| Pillar | Guitar | Piano | Bass |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Fretboard/keyboard liberation** | CAGED, triads on string sets, connecting pentatonic boxes | Inversions in all keys, voice leading | Neck zones, chord-tone arpeggios everywhere |
|
||||||
|
| **Playing the changes** | Chord-tone targeting, guide tones (3rds & 7ths) | Shell + rootless voicings, sus/add9 colors | Walking lines, chromatic approach notes |
|
||||||
|
| **Ensemble skills** | Small voicings, register discipline, comping | Comping rhythms (Charleston…), "rule of 1", staying out of the bass lane | Pocket/drummer lock, ghost notes, subdivision switching |
|
||||||
|
| **Functional ears** | Hearing I-IV-V / vi-IV-I-V by bass line | Nashville numbers, 12-key transposition | Singing root movement, predicting the V |
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
**L1 — Practice drills tab.**
|
||||||
|
Add a drills library to the Learn section: per instrument, per pillar, the concrete drills from the curriculum doc (e.g. "first note after every chord change = the 3rd", "Charleston comping ladder", "W|H|H chromatic walkup"). Keyed to the *current detected key and loop* so every drill is in today's jam context, not abstract C major.
|
||||||
|
|
||||||
|
**L2 — Detection-powered feedback (the unfair advantage).**
|
||||||
|
No practice app can hear the player; this one can. Phased:
|
||||||
|
- **Target-note highlighting:** on each detected chord change, highlight the new chord's 3rd/7th on the fretboard/piano for a beat (drill scaffold — uses existing tier rendering).
|
||||||
|
- **Next-chord preview tier:** when a loop is detected, show the *upcoming* chord's root and its chromatic approach notes (the bassist's walking-line scaffold).
|
||||||
|
- **Chord-tone hit rate:** classify detected notes against the current chord (chord tone / scale tone / outside) and show a session score.
|
||||||
|
- **Pocket report:** extend the onset/BPM pipeline to show timing drift against the established grid.
|
||||||
|
|
||||||
|
**L3 — Ear training from your own jam.**
|
||||||
|
A quiz mode that hides the chord banner and asks the user to name the progression in numbers before revealing — using the *user's own chord history* as the corpus. Converts the existing detection + `toRomanNumeral` into the functional ear training every method prescribes.
|
||||||
|
|
||||||
|
**L4 — Mode-difference teaching.**
|
||||||
|
When the user manually switches mode (the documented K-S limitation — by design), briefly highlight the *difference notes* (e.g. the raised 6th going minor → Dorian) on the instrument views. Turns a limitation into a lesson.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What we need to go next level — priorities
|
||||||
|
|
||||||
|
| # | Item | Effort | Why first |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **G1** Genre repertoire expansion | S (data only) | Immediate value, zero architectural risk |
|
||||||
|
| 2 | **G2** Nashville number toggle | S | Directly answers "is it 1-5-4", jam-native language |
|
||||||
|
| 3 | **G3** Progression Builder MVP (palette + reorder + numerals) | M | The single most-requested workflow gap |
|
||||||
|
| 4 | **L1** Drills tab seeded from curriculum doc | M (content + UI) | Makes Learn level-appropriate |
|
||||||
|
| 5 | **G4** Voicing alternatives (guitar string-sets + piano recipes) | M | Feeds both Builder and Learn |
|
||||||
|
| 6 | **L2** Target-note highlighting + next-chord preview | M | First detection-powered trainer, reuses tier rendering |
|
||||||
|
| 7 | **L3** Ear-training quiz on own history | M | High pedagogical value, small surface |
|
||||||
|
| 8 | **L2** Hit-rate scoring + pocket report | L | Needs tuning of pitch/onset classification |
|
||||||
|
| 9 | **G3** Builder phase 2: borrowed palette, variation buttons, loop import | L | Builds on MVP + substitution rules |
|
||||||
|
| 10 | **KB** Knowledgebase + Jam Guide panel (see `docs/kb-plan.md`) | L, but looped in S-sized sessions via `/kb-expand` | The style × instrument playbook that powers improv learning |
|
||||||
|
|
||||||
|
### How we execute
|
||||||
|
|
||||||
|
Knowledgebase work runs as **looped sessions**: `/kb-expand` does exactly one style × instrument cell (research → author → validate → commit), driven by the queue in `docs/kb-backlog.md`. Tranches run on a dedicated branch — e.g. an hourly `/loop /kb-expand` for a working day — and **end with a pull request** so a whole tranche is reviewed in one place. First tranche (started 2026-06-12, branch `kb-expansion`): Session 0 bootstrap + the first guitar style cells, hourly for 8 hours, PR to `main` at the end.
|
||||||
|
|
||||||
|
## How we build it — the ensemble
|
||||||
|
|
||||||
|
The work is carried by a six-agent team that collaborates through files (a shared ledger + the repo), conducted by one scheduled loop. Full design in [`docs/agents/`](docs/agents/):
|
||||||
|
|
||||||
|
- [`ROSTER.md`](docs/agents/ROSTER.md) — the six agents, their domains, file ownership, and cadence weights: 🎼 Maestro (orchestrator), 🎓 Professor (music & pedagogy), 🔧 Luthier (engineering), 🎨 Muse (design & UX), 🔍 Critic (the review gate), 📣 Herald (open-source & community).
|
||||||
|
- [`PROTOCOL.md`](docs/agents/PROTOCOL.md) — how they collaborate: the task-locking rule that prevents file conflicts, the `backlog→ready→claimed→in-review→done` lifecycle, the appointment algorithm, scheduling (schedule the conductor, not the band), and PRs.
|
||||||
|
- [`LEDGER.md`](docs/agents/LEDGER.md) — the live task board, seeded with the `sprint-jam-guide` starter sprint.
|
||||||
|
|
||||||
|
**Run it:** `/jam-loop` advances one orchestrated iteration (Maestro appoints → workers build → Critic gates → reconcile + commit). Schedule it with `/loop 1h /jam-loop` (session) or `/schedule` (durable). `/kb-expand` remains the single-domain content fast-path (Professor solo).
|
||||||
|
|
||||||
|
### Definition of "next level" (success criteria)
|
||||||
|
|
||||||
|
- A jammer can glance at the app and call the loop in numbers ("it's a 1-5-4").
|
||||||
|
- Suggestions cover the genres people actually jam (funk/reggae/R&B/gospel included), not just pop/blues.
|
||||||
|
- A user can lay out their own progression, drag chords around, and see 3+ ways to voice every chord on their instrument.
|
||||||
|
- The Learn section gives an intermediate guitarist, pianist, or bassist a *specific* next drill in the key they're jamming in right now — and at least one drill where the app verifies them by listening.
|
||||||
@@ -1,137 +1,125 @@
|
|||||||
# WhatTheFlat
|
# JamBuddy
|
||||||
|
|
||||||
Real-time key and chord detection for musicians. Play guitar, bass, piano, or any instrument into your microphone and WhatTheFlat will identify the key you're in, the chords you're playing, and suggest progressions. Runs fully offline as a native desktop app.
|
**Hear the jam, learn the jam.** JamBuddy listens through your microphone, figures out the key and chords you're playing in real time, then shows you *how to play over them* — and helps you level up while you do it.
|
||||||
|
|
||||||
## Features
|
It started as a live key/chord detector (you may know it as *WhatTheFlat*). It's growing into an open, contributable **learning platform** for the player who can already survive a jam and wants to make it better.
|
||||||
|
|
||||||
- Real-time chord detection from live audio (guitar, bass, piano, full band)
|
---
|
||||||
- Automatic key detection with top-3 candidate display — click to lock
|
|
||||||
- Chord history and repeating progression detection
|
|
||||||
- Roman numeral analysis relative to detected key
|
|
||||||
- Fretboard visualiser showing safe notes and chord tones
|
|
||||||
- Beginner / Advanced modes
|
|
||||||
- Manual key lock for jam sessions
|
|
||||||
- Supports borrowed/chromatic chords (e.g. D7 in A minor) in Advanced mode
|
|
||||||
- Fully offline — no internet connection required
|
|
||||||
|
|
||||||
## Tech Stack
|
## The idea: detect → guide → level up
|
||||||
|
|
||||||
| | |
|
1. **Detect.** Point your mic at a guitar, piano, or a whole band. JamBuddy identifies the **key** and the **chords** as you play, finds the repeating **loop**, and reads it back in Roman numerals (ii–V–I, I–V–vi–IV).
|
||||||
|---|---|
|
|
||||||
| **App shell** | Electron |
|
|
||||||
| **UI** | React 18, Tailwind CSS, Vite |
|
|
||||||
| **Audio** | Web Audio API, [Pitchy](https://github.com/ianprime0509/pitchy) (McLeod pitch detection) |
|
|
||||||
| **Music theory** | Custom JS — Krumhansl-Schmuckler key detection, chroma-based chord matching |
|
|
||||||
|
|
||||||
### Dependencies (`frontend/package.json`)
|
2. **Guide.** Once it knows your loop, the **Roadmap Jam Guide** lays it out as an improv highway synced to what you're playing: **guide tones** (the 3rds and 7ths that carry the harmony), **voice-leading rails** showing how each chord resolves into the next, the **solo scale** to blow over each station, and the **chord voicings** to grab — all in your detected key, with a playhead tracking where the loop is.
|
||||||
|
|
||||||
**Runtime**
|
3. **Level up.** Style packs turn the same ii–V–I into *jazz* vs *bossa* vs *funk* — genuinely different voicings, rhythms, and improv advice — so you don't just play the changes, you learn the language. This is the on-ramp to the wider **Learn** direction (drills and detection-powered feedback) described in [`GOAL.md`](GOAL.md).
|
||||||
- `react` / `react-dom` — UI
|
|
||||||
- `pitchy` — pitch detection
|
|
||||||
|
|
||||||
**Dev / build**
|
---
|
||||||
- `electron` — desktop runtime
|
|
||||||
- `electron-builder` — installer packaging
|
|
||||||
- `vite` + `@vitejs/plugin-react` — bundler
|
|
||||||
- `tailwindcss` + `autoprefixer` + `postcss` — styling
|
|
||||||
- `concurrently` — run Vite + Electron together in dev
|
|
||||||
|
|
||||||
## Development
|
## What works today vs. what's in progress
|
||||||
|
|
||||||
|
This is an honest snapshot — it's an active project.
|
||||||
|
|
||||||
|
**Working today**
|
||||||
|
|
||||||
|
- **Real-time key detection** with top-3 candidate chips (click to lock) — works for guitar and piano.
|
||||||
|
- **Real-time chord detection** from live audio, with chord history and repeating-progression detection.
|
||||||
|
- **Roadmap Jam Guide** panel: matches your detected loop to a style progression and renders guide tones, voice-leading rails, solo-scale labels, and chord-voicing thumbnails synced to the loop position.
|
||||||
|
- **8 guitar style packs** in the knowledgebase — jazz, blues, rock, bossa nova, funk, reggae, country, and R&B (gospel landing next).
|
||||||
|
- **Fretboard and piano** visualisers that colour chord tones, pentatonic, and scale notes.
|
||||||
|
- **Chromatic tuner** and a **loop station** for capturing and replaying phrases.
|
||||||
|
- **Fully offline** — all audio and detection run locally in the desktop window; no server, no network calls.
|
||||||
|
|
||||||
|
**In progress**
|
||||||
|
|
||||||
|
- **Piano and bass** style packs (guitar is the first instrument rolled out).
|
||||||
|
- The **Progression Builder** — lay out and rearrange your own progression by hand (GOAL §G3).
|
||||||
|
- **Detection-powered drills** — practice exercises and feedback that use the fact that the app can actually *hear* you (GOAL Part 2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it looks like
|
||||||
|
|
||||||
|
<!-- TODO: add a screenshot or GIF of the Roadmap Jam Guide tracking a live ii–V–I or 12-bar blues. -->
|
||||||
|
<!-- Caption: "The Roadmap Jam Guide mid-jam — guide tones, voice-leading rails, and the next voicing, synced to the chord you're playing right now." -->
|
||||||
|
|
||||||
|
_(Screenshot coming — the Roadmap panel mid-jam.)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
You'll need [Node.js](https://nodejs.org/) (18+) and a microphone.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
git clone https://github.com/whattheflat/whattheflat.git
|
||||||
|
cd whattheflat
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
|
# Desktop app (Vite dev server + Electron window, hot reload)
|
||||||
npm run electron:dev
|
npm run electron:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Starts the Vite dev server and opens the Electron window simultaneously. The window connects to `localhost:5173` and supports hot reload.
|
Prefer the browser? Run the renderer on its own (no Electron shell):
|
||||||
|
|
||||||
## Building an Installer
|
|
||||||
|
|
||||||
Add app icons to `frontend/assets/` first:
|
|
||||||
- `icon.ico` — Windows
|
|
||||||
- `icon.icns` — macOS
|
|
||||||
- `icon.png` — Linux (256×256 minimum)
|
|
||||||
|
|
||||||
Then build:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
npm run dev # then open http://localhost:5173
|
||||||
|
|
||||||
# Windows installer (NSIS)
|
|
||||||
npm run electron:build:win
|
|
||||||
|
|
||||||
# macOS DMG
|
|
||||||
npm run electron:build:mac
|
|
||||||
|
|
||||||
# Linux AppImage
|
|
||||||
npm run electron:build:linux
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Output is placed in `frontend/release/`.
|
**To see the magic:** grant **microphone permission** when prompted, then play a **recognized loop** — a **ii–V–I** or a **12-bar blues** are the easiest ways to light up the Roadmap Jam Guide. JamBuddy needs to hear the loop repeat a couple of times to lock the key and match the progression.
|
||||||
|
|
||||||
## Releasing / Tagging
|
### Building installers
|
||||||
|
|
||||||
To create a GitHub release and trigger the CI build pipeline, create an annotated tag and push it to origin. The release workflow runs on tags matching `v*` (for example `v0.6.1`).
|
|
||||||
|
|
||||||
Local tagging example:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# update package.json version first if desired
|
npm run electron:build:win # Windows NSIS installer → release/
|
||||||
git tag -a v0.6.1 -m "Release v0.6.1"
|
npm run electron:build:mac # macOS DMG → release/
|
||||||
git push origin v0.6.1
|
npm run electron:build:linux # Linux AppImage → release/
|
||||||
```
|
```
|
||||||
|
|
||||||
What the GitHub Action does (`.github/workflows/release.yml`):
|
---
|
||||||
|
|
||||||
- Listens for pushed tags `v*` and runs a matrix build across Windows, macOS and Linux.
|
## Contributing — add a style without deep coding
|
||||||
- macOS is built as a universal binary (`--universal`) so a single DMG supports both Intel and Apple Silicon.
|
|
||||||
- Each matrix job builds the installer using `electron-builder`, uploads its artifacts, and a final `publish` job aggregates all artifacts into one GitHub release.
|
|
||||||
|
|
||||||
If you prefer to run builds locally before tagging, use the npm scripts in the repository root:
|
The style packs are **data, not code**. If you're a musician who knows how a style is voiced and played, you can add one by filling in a structured data file — no audio or detection internals required.
|
||||||
|
|
||||||
```bash
|
- The data contract lives in [`src/data/kb/SCHEMA.md`](src/data/kb/SCHEMA.md): progressions as key-agnostic scale degrees, plus 2+ idiomatic "ways to play" each (voicings, comping, improv). One entry renders in all 12 keys.
|
||||||
# Windows NSIS
|
- An **assisted path** is available: the `/kb-expand` workflow researches, drafts, and validates one style cell at a time, so you can start from a scaffold rather than a blank file.
|
||||||
npm run electron:build:win
|
- Every contribution passes a **validator quality gate** (`node scripts/validate-kb.mjs`) that mechanically checks each voicing actually contains the chord's tones — that's the bar that keeps the knowledgebase trustworthy.
|
||||||
|
|
||||||
# macOS DMG (universal)
|
A full step-by-step **`CONTRIBUTING.md`** guide is **coming** (tracked as task H-02). Until then, `SCHEMA.md` is the source of truth, and the [jazz pack](src/data/kb/jazz) is the gold-standard example to imitate.
|
||||||
npm run electron:build:mac -- --universal
|
|
||||||
|
|
||||||
# Linux AppImage
|
---
|
||||||
npm run electron:build:linux
|
|
||||||
```
|
|
||||||
|
|
||||||
CI notes / troubleshooting
|
## How it's built — the ensemble
|
||||||
- The workflow uploads artifacts from `release/` into the release. Ensure `package.json` build `directories.output` matches the workflow's expected `release/` folder.
|
|
||||||
- If mac packaging for x64 on ARM-hosted runners fails, switch to `--universal` (already configured) or build x64 on an Intel runner.
|
|
||||||
- To test the workflow locally, consider using `nektos/act` or push a temporary tag like `vtest`.
|
|
||||||
|
|
||||||
## Design Tokens
|
JamBuddy is built by a six-agent team (Maestro, Professor, Luthier, Muse, Critic, Herald) that collaborates entirely through files — a shared ledger and the repo — conducted by one scheduled loop. If you want to understand how the project plans and ships work, start in [`docs/agents/`](docs/agents/):
|
||||||
|
|
||||||
All colors are defined in `frontend/tailwind.config.js` and can be referenced by name in any component.
|
- [`ROSTER.md`](docs/agents/ROSTER.md) — the six agents and their domains.
|
||||||
|
- [`PROTOCOL.md`](docs/agents/PROTOCOL.md) — how they collaborate (task locking, review gate, scheduling).
|
||||||
|
- [`LEDGER.md`](docs/agents/LEDGER.md) — the live task board.
|
||||||
|
|
||||||
| Token | Hex | Usage |
|
The product north star and full roadmap are in [`GOAL.md`](GOAL.md).
|
||||||
|---|---|---|
|
|
||||||
| `surface` | `#0f0f0f` | Page / app background |
|
|
||||||
| `panel` | `#1a1a1a` | Cards, panels, dialogs |
|
|
||||||
| `border` | `#2a2a2a` | Borders, dividers, muted backgrounds |
|
|
||||||
| `accent` | `#a855f7` | Primary interactive color (purple) |
|
|
||||||
| `amber` | `#f59e0b` | Roman numerals, secondary highlights |
|
|
||||||
| *(base text)* | `#f5f5f5` | Default body text |
|
|
||||||
|
|
||||||
Tailwind usage examples: `bg-surface`, `bg-panel`, `border-border`, `text-accent`, `bg-accent/20` (20% opacity).
|
---
|
||||||
|
|
||||||
## How It Works
|
## Tech & how detection works
|
||||||
|
|
||||||
All processing happens locally in the Electron window — no server, no network calls.
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **App shell** | Electron (window host only — all logic runs in the renderer) |
|
||||||
|
| **UI** | React, Tailwind CSS, Vite |
|
||||||
|
| **Audio** | Web Audio API, [Pitchy](https://github.com/ianprime0509/pitchy) (McLeod pitch detection) |
|
||||||
|
| **Music theory** | Custom JS — Krumhansl-Schmuckler key detection, harmonic-summation chroma chord matching |
|
||||||
|
|
||||||
Audio is captured via the browser's Web Audio API and processed in two parallel paths:
|
Two audio pipelines run in parallel: a fast **pitch path** (4096-sample FFT, McLeod autocorrelation) feeds Krumhansl-Schmuckler key detection over a voting window; a higher-resolution **chord path** (16384-sample FFT, ~2.7 Hz/bin) extracts a harmonic-summation chroma and matches it against chord templates. Architecture details are in [`CLAUDE.md`](CLAUDE.md).
|
||||||
|
|
||||||
1. **Pitch path** — 4096-sample FFT with McLeod autocorrelation for fast single-note pitch detection. Feeds the Krumhansl-Schmuckler key detection algorithm, which votes over a rolling window of 12 detections and requires 9/12 agreement before committing to a key.
|
> Note on modes: Krumhansl-Schmuckler distinguishes major vs. minor but not modes — Dorian and natural minor look the same to it. JamBuddy detects the tonal centre, and you pick the mode (the dropdown offers Dorian, Mixolydian, etc.). By design.
|
||||||
|
|
||||||
2. **Chord path** — 16384-sample FFT (2.7 Hz/bin) with harmonic summation chroma extraction across 80–4000 Hz. The averaged chroma vector is matched against chord templates (major, minor, dom7, min7, dim, half-dim, aug, sus4, add9) using a weighted coverage score. Consecutive identical detections are required before a chord is committed, preventing transient false positives.
|
---
|
||||||
|
|
||||||
The top-3 key candidates are shown in real time as clickable chips. Locking a key in Beginner mode restricts chord matching to the 7 diatonic chords; Advanced mode allows chromatic/borrowed chords.
|
## License
|
||||||
|
|
||||||
|
No license file is set yet. Until one is added, all rights are reserved by the authors — please open an issue before reusing the code.
|
||||||
|
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
# Ensemble Ledger — Live Task Board
|
||||||
|
|
||||||
|
The shared queue all agents read and write. Lifecycle and rules: [`PROTOCOL.md`](PROTOCOL.md). Roster + cadence weights: [`ROSTER.md`](ROSTER.md).
|
||||||
|
|
||||||
|
**Status:** `backlog` → `ready` → `claimed` → `in-review` → `done` / `returned`
|
||||||
|
**Lock:** a task's `files` column is its lock — no two `claimed`/`in-review` tasks may overlap files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Active sprint: `sprint-roulette-kb` (branch: `sprint-jamguide-piano` — runs CONCURRENTLY with sprint-one-screen below; hourly cron, file-locks keep them disjoint)
|
||||||
|
|
||||||
|
**Goal (user directive 2026-07-11):** "a 12h loop with one iteration per hour for adding more piano licks and knowledge base voicings, potentially including some sort of circle of fifths etc in the knowledge center. on top of that … one button that would be a random chord progression selection. a button up top 'jam roulette' and then select a genre (like we have jazz, blues rock etc) and then have a random key chosen and an interesting progression show up as a loop in the screen. it would fill in the loop section and a 4 bar progression or something so that everyone can see the key and the chords to be played with different voicing so they can start playing… then musicians can start playing and it continues from it."
|
||||||
|
|
||||||
|
**Loop:** `/jam-loop` hourly × 12 (12 h), started 2026-07-11. Engineering tasks that need one-screen's files are dep-blocked on C-50 — content tasks fill the early iterations. Weights: Professor 3, Muse 2, Luthier 2, Critic gate.
|
||||||
|
|
||||||
|
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
||||||
|
|----|-------|--------|--------|-----------|--------------|--------------------|
|
||||||
|
| M-07 | Seed `sprint-roulette-kb`; schedule hourly cron | maestro | done | — | `docs/agents/LEDGER.md` | seeded, cron created |
|
||||||
|
| C-60 | **Piano lick schema** (additive): licks today are guitar-tab-only — design the piano-lick shape reusing the bass-pattern language (per-note `deg` XOR typed `approach`, `beat`, `octave`, technique from a PIANO-appropriate vocab subset — decide which of the 8 words apply to keys and whether grace-note/roll style additions are needed; keep it validateable like C-41 did) in SCHEMA.md + validate-kb.mjs + smoke bite-test | quality | done | — | `src/data/kb/SCHEMA.md`, `scripts/validate-kb.mjs`, `scripts/smoke.mjs` | schema documented; validator enforces; existing KB green; smoke proves it bites |
|
||||||
|
| D-60 | **PianoLickCard** renderer — visual rhyme with LickCard's tab idiom but for keys (timeline + MiniPiano-style pitches or a degree ladder — pick per readability at strip size); playback via chordAudio like BassPatternCard. Must export `PIANO_TECHNIQUE_VOCAB` set-equal to the validator's (the dormant C-60 smoke guard activates on this file's existence). **Component only — LicksStrip wiring deferred (JamGuide.jsx reserved for one-screen L-50); wiring folded into C-61-era integration. Wiring DoD must include (D-60 gate): a shared sequencer-stop so BassPatternCard/PianoLickCard previews can't layer (each holds a private currentSeq today — impossible to trigger until both mount together)** | design | done | C-60 | `src/components/PianoLickCard.jsx` (new) ONLY — done `a7dbd6a` | renders every P-60 lick; playable; graceful empty/malformed |
|
||||||
|
| P-60 | Piano licks pack #1: 3-4 each for **jazz + blues** piano (C-60 schema; arithmetic-verified prose, the P-41 bar). **PASS first try** (`70263e2` — gate realized all 8 in two keys, 35/35 prose claims; dom7-heavy blues coverage accepted as musically right) | content | done | C-60 | `src/data/kb/jazz/piano.js`, `src/data/kb/blues/piano.js` | validator green; every lick playable as written |
|
||||||
|
| P-61 | Piano licks pack #2: 3-4 each for **gospel + rnb** piano. **Returned once (2026-07-11):** one false interval claim in rnb-quartal-slip tips ("each move a step" — actually skip-then-step); 42/43 checks passed. Fixed with the gate's own prescribed wording; Maestro verified the line against the gate's computed −3/−2 motion (scoped re-gate waived — the fix IS the gate's pre-approved example) | content | done | P-60 | `src/data/kb/gospel/piano.js`, `src/data/kb/rnb/piano.js` | validator green |
|
||||||
|
| P-62 | KB voicings expansion: **pop piano cell** (next per kb-backlog) + audit `GUITAR_SHAPES` for qualities with <3 placeable shapes and author additions where idiomatic | content | done | — | `src/data/kb/pop/piano.js`, `src/data/kb/index.js`, `src/lib/voicings.js` (additive shapes only) | validator green; every new shape hand-verified (L-23 lesson: base-fret convention) |
|
||||||
|
| D-61 | **Circle of fifths** in the Knowledge Center: interactive SVG (12 keys around the circle, live/locked key highlighted, relative minor ring, diatonic chords on tap-free hover/inline, key-signature glyphs), mounted inside ExploreSection; educational copy in the app's voice. **Returned once (2026-07-11):** modal hub key signature wrong (A dorian showed ♮ while its chips show F♯ — all 4 dropdown modal modes affected; fix = derive the parent-major signature, teaching "A dorian = G major's notes") + preview flat/sharp spelling split; geometry/rings/tiers/a11y all passed 60/60. Fixed + re-gate PASS (`c999c95` — hub computed from SCALES set-equality, 72/72 uniqueness, one spelling authority) | design | done | — | `src/components/CircleOfFifths.jsx` (new), `src/components/ExplorePanel.jsx` (ExploreSection integration only) | renders any key incl. none-detected; follows effectiveKey live; tokens + AA; zero clicks for the core view |
|
||||||
|
| D-62 | **Jam Roulette UX spec** (doc): button up top ("Jam roulette", controls bar per one-screen's layout), genre picker (the 10 KB styles), randomization rules (random key; "interesting" progression selection — weight intermediate level + variety, avoid repeats within a session), how the seed fills the screen (seeds the committed loop + locked key so the banner/strip, voicing rail, licks, related progressions all populate exactly as if detected — reuse, don't duplicate), and the **detection handoff**: musicians start playing and live detection continues from the seed (spec the interplay with the L-31 commit layer: the seed behaves like a committed loop — replacement needs the same consistent evidence; New Song clears it) **Returned once (2026-07-11):** "matched by construction" false for 10/56 progressions — buildLoopIndex indexes RAW degrees while collapse changes name-sequence length (incl. blues-12bar: LIVE detection of a real 12-bar gets an empty JamGuide — pre-existing bug surfaced!); detector len-cap 8 makes collapsed 9-10 unconfirmable; round-trip empirical not constructional (blues-8bar loses to its own [V7,I7] alternation); funk-one-chord collapses to 2 names not 1; flag-1 timeline off by one (EVERY seed dies unguarded — guard even more necessary). Maestro direction: fix (a) — additively index collapsed forms in match.js (fixes live detection too); pool = empirical round-trip passers ∩ collapsed len 2-8. **Returned twice (2026-07-11):** round 2 verified fix-(a) construction/timeline/proofs sound but found the sweep protocol as written evicts all 8 two-name vamps (needs steady-state + all-offsets), behaviour-additive false in exactly one case (country-145 now self-attributes — an improvement, enumerate it), census 11 not 10, and the BASS plays path (BassGuideRows :530/:533) missed by the remap. Round-3 PASS (`see commit` — literal protocol implementation reproduced 4/56 failures + 52/56 pool + 11-census exactly; sourceIndex mechanism verified at current line numbers) | design | done | — | `docs/design/jam-roulette.md` | mechanism spec'd against the real App state (lockedKey, detectedProgression, progressionVoteRef); no state duplication; L-60 scope bounded |
|
||||||
|
| L-60 | Implement Jam Roulette per D-62 (App.jsx UI-state + seeding fn; the roulette button + genre menu; 🚨 audio contract grep-gated — seeding touches display state and the progression refs ONLY, never callbacks). Ride-alongs: pass `dense` to the heard-live VoicingBrowser in the rail (D-51 gate flag — same fragile pair fit in the fallback path); one-screen.md §7 "two-octave cells alone" sentence is stale by a few px (D-51 gate nit) | engineering | done | D-62, C-50 | `src/App.jsx` (+ per D-62 — re-lock at promotion) | one click → genre → key+progression fills the jam dashboard; detection continues from it; New Song clears; build + smoke green; contract grep clean |
|
||||||
|
| C-61 | Sprint-end sweep + PR #3 update (COMBINED — covers BOTH sprint-one-screen and sprint-roulette-kb in one comment) | quality | done | L-60, D-60, P-61, P-62, D-61 | (none — verification) | all green; PR updated — **BOTH SPRINTS COMPLETE** |
|
||||||
|
|
||||||
|
> Sequencing: C-60 ‖ P-62 ‖ D-61 ‖ D-62 are file-disjoint and ready now (none touch one-screen's locks). Then C-60 → {D-60, P-60 → P-61}, and D-62 + C-50(one-screen) → L-60. C-61 closes. Critic gates every task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Active sprint: `sprint-one-screen` (branch: `sprint-jamguide-piano` — continued; commits extend PR #3)
|
||||||
|
|
||||||
|
**Goal (user directive 2026-07-11, after testing integrated-glance — "i like it a lot, so i would like to make some alterations"):**
|
||||||
|
|
||||||
|
1. **The top block is too big** — "the chord view and the chord loop incl the instrument below is too big, it takes up too much space… this main view doesnt really add value." Compress the detection area and the instrument view.
|
||||||
|
2. **Kill the big now-playing chord** — "seeing the big chord 'now playing' in big is also unnecessary as you already see that one in the chords (loop) part." The loop chips already carry it (playhead chip).
|
||||||
|
3. **Rethink ProgressionSuggestions** — "the suggested progressions on the right are also almost useless… we need to rethink them in the form of SUGGESTED VOICINGS and the progressions we add relating to the loop." Replace the generic genre table with (a) the suggested-voicings rail and (b) KB-sourced progressions RELATED to the detected loop.
|
||||||
|
4. **Voicings on the right** — "i'd like to have the suggested voicings on the right side of the screen (the jam guide)." The Jam Guide rail becomes a right column. (Overrides D-40's rejected side-by-side alternative — user directive wins.)
|
||||||
|
5. **One screen** — "i wanna see the chords, the loop, the voicings, the progressions, the licks all on one screen, everything you need to follow the jam correctly."
|
||||||
|
6. **Jam mode / fullscreen (refinement 2026-07-11):** "it can still have a scroll down, but the main concept needs to be in one screen: potentially we add a fullscreen button so we can just show that when we are playing a jam… everything that is learning and behind the scenes etc you can keep below/scrollable but we need one main view for live jams where we see everything at once." The dashboard fits 100vh as a hard constraint; a fullscreen/jam-mode button shows only it; KnowledgeDock/Debug/Tuner (LoopStation per Muse's call) live below in normal mode.
|
||||||
|
|
||||||
|
Standing principles (memory): scroll > click; nothing duplicated; one global instrument selector; playhead highlights, never hides. Weights: Muse 3, Luthier 3, Critic gate. No cron this sprint — Maestro drives on notifications. ⚠️ Spend-limit risk: the account limit was hit 2026-07-10; first dispatch is the probe.
|
||||||
|
|
||||||
|
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
||||||
|
|----|-------|--------|--------|-----------|--------------|--------------------|
|
||||||
|
| M-06 | Seed `sprint-one-screen` | maestro | done | — | `docs/agents/LEDGER.md` | seeded |
|
||||||
|
| D-50 | One-screen dashboard concept doc: full-viewport grid at 1280×900 — LEFT: slim loop strip (banner without the big now-playing chord; playhead chip is the "now"), compact instrument view (decide the mechanism: height cap / reduced fret range / scale — honest about legibility), licks strip, related-progressions card; RIGHT column: the suggested-voicings rail (GlanceRail adapted to ~420-520px — decide cell strategy: recommended voicing prominent + gallery wrapped, sizes recomputed honestly per the D-40 corrected math); where LoopStation/collapsibles/KnowledgeDock land; what "related progressions" means computationally (KB-sourced: same style + level + degree-shape overlap with the loop — spec the ranking); narrow-viewport plan (columns stack); migration order with bounded L-50/D-51/L-51 scopes; ≥2 rejected alternatives. No user gate: pick strongest, record rationale. **Returned once (2026-07-11):** strip really ~92-96px (fix: spec p-4→p-2 trim); `sticky` rail geometrically inert in the flex row (zero travel — flick claims deleted, rationale re-grounded); §4 double-counted cell padding (piano pair actually fits TODAY by ~4px; D-51 premise → margin-hardening); +100 ranking term inherits the raw-vs-collapsed mismatch (collapse pairs before canonicalDegrees/T(p), coordinate with D-62 fix (a)). Layout/jam-view/scopes all held. Fixed + re-gate PASS (`d24bf35` — all budgets recomputed clean) | design | done | — | `docs/design/one-screen.md` | every component named incl. what's removed (big chord display, ProgressionSuggestions); honest space math (everything visible at 1280×900 with at most the licks/progressions row below a fold-flick); bounded scopes |
|
||||||
|
| L-50 | App restructure per D-50: grid columns, banner slimmed (big now-playing chord removed — ProgressionBanner edit), JamGuide rail moves to the right column, ProgressionSuggestions unmounted (file kept), instrument view compacted per doc, licks strip placed per doc. 🚨 App.jsx = layout/mounts/UI-state only, audio contract grep-gated. **PASS** (`800fd43`+`e58a7a6` — SSR 73/73; gate applied a sanctioned one-class legend restoration for non-compact byte-identity). Nits filed: dead currentChord prop on the banner mount (ride-along for L-51); strip chips are div-onClick (pre-existing a11y debt → backlog) | engineering | done | D-50 | `src/App.jsx`, `src/components/ProgressionBanner.jsx`, `src/components/JamGuide.jsx` (+ per doc — re-lock at promotion) | one-screen layout live; no duplicated info; jam view works both layers; build + smoke green; contract grep clean |
|
||||||
|
| D-51 | Rail right-column adaptation per D-50: GlanceRail/VoicingBrowser sizing for the narrow column (recommended-first cell strategy per doc), playhead highlight preserved, wrap never horizontal-scroll | design | done | D-50, L-50 | `src/components/GlanceRail.jsx`, `src/components/VoicingBrowser.jsx` (+ per doc) | rail readable at column width for guitar/piano/bass; zero clicks; build + smoke green — **PASS** (`see commit` — arithmetic verified to the built CSS; heard-live dense-pass flag folded into L-60's ride-alongs) |
|
||||||
|
| L-51 | RelatedProgressions component per D-50: KB-sourced, loop-relative (style + level + degree-shape overlap ranking per the doc's spec), replaces the old generic suggestions; mounts in the D-50 slot. **PASS** (`see commit` — scores recomputed independently, collapse counterfactual proven). Taste flag for the user: "same changes" links quality-mismatched same-root-motion relatives (ii-V-I ≡ I-IV-V) — spec-conformant; a "same motion" wording tweak is a one-word option | engineering | done | D-50, L-50 | `src/components/RelatedProgressions.jsx` (new) + mount slot per doc | shows genuinely related KB progressions for a live loop; honest empty state; build + smoke green |
|
||||||
|
| C-50 | Sprint-end sweep (PR comment consolidated into C-61's combined summary — Maestro call). Folded (L-51 gate): pin the two verified ranking scores + the no-collapse counterfactual as smoke checks; add a global-id-uniqueness assertion to validate-kb.mjs | quality | done | L-50, D-51, L-51 | `scripts/smoke.mjs`, `scripts/validate-kb.mjs` (additions only) | all green; ranking pinned; ids asserted unique — **PASS** (`see commit` — smoke 886, SSR 60/60, whole-sprint contract clean). **SPRINT sprint-one-screen COMPLETE** |
|
||||||
|
|
||||||
|
> Sequencing: D-50 first (everything hangs off it) → L-50 → D-51 ‖ L-51 (re-lock at promotion to enforce disjointness) → C-50. Critic gates every task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Active sprint: `sprint-dashboard-polish` (branch: `sprint-jamguide-piano` — continued; commits extend PR #3)
|
||||||
|
|
||||||
|
**Goal (user directive 2026-07-13, after running the one-screen build):** "this looks amazing" + six refinements to the live dashboard:
|
||||||
|
|
||||||
|
1. **Guitar: 4 options max** — "only have 4 guitar options visible so it would fit the screen without scrolling." Cap each chord's guitar gallery to ≤4 shapes (a musically-sensible top-4 selection rule; fewer is fine).
|
||||||
|
2. **Hidden-but-scrollable dark scrollbars** — "the scroll bars are not visible but it can scroll if we need to (make them black or something)." Thin/dark styled scrollbars, overlay feel; content still scrolls.
|
||||||
|
3. **Piano: 2×2 smaller keyboards** — "smaller keyboards so there would be 2×2 for each chord." The 4 pianoVoicing styles in a 2-col × 2-row grid of compact MiniPianos per chord.
|
||||||
|
4. **Remove play buttons** — "leave off the PLAY buttons, they take up a lot of space for no reason, no need to hear it." Drop every ▶ from the voicings rail (and the licks strip — Maestro extension, matching the stated glance-over-audio preference; also removes the shared-sequencer blocker so piano licks can wire in cleanly).
|
||||||
|
5. **Licks: uniform size + follow the instrument** — "the licks section should all have the same size and transform into piano when selected." Uniform card size; the strip follows the global GUITAR/PIANO/BASS selector — wiring the deferred PianoLickCard into the strip (bass → no licks / honest note).
|
||||||
|
6. **Related progressions: same-style bridge/chorus/modifications** — "i would also want bridge/chorus/modifications in the same style… say i select jam roulette with blues, then i want for that progression other options and not necessarily go into other styles." Same-style variations for the active/rolled loop. **User decided 2026-07-13: reuse existing KB same-style progressions (lightest option) — NO computed modifications, NO new authored sections, NO song forms (verse/chorus/bridge — KB has no section data and most progressions are complete forms; Maestro + user agreed not to fake it).** So L-72 ships the honest same-style variations. **NEW feature added same day** (user: "imagine we play Am C F, i'd like an alternative to that F… an option that says 'try this'… i want musicians to learn how they can make the jam more interesting"): a **per-chord "Try this" chord-substitution** surface — for a loop chord in the detected key/style, suggest a small curated set of alternative chords (relative sub, borrowed-minor colour, extension colour, a circle-of-fifths / secondary-dominant move) each with a plain-language WHY. Circle-of-fifths-informed where it applies (honest that not all subs are circle-adjacent). Keep it simple/learnable, not a reharm engine. → tasks D-73/L-73/L-74 below.
|
||||||
|
|
||||||
|
Standing principles (memory): scroll > click; nothing duplicated; one global instrument selector; playhead highlights; glance over audio. User is PRESENT — notification-driven, no cron. Weights: Muse 3, Luthier 3, Critic gate.
|
||||||
|
|
||||||
|
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
||||||
|
|----|-------|--------|--------|-----------|--------------|--------------------|
|
||||||
|
| M-08 | Seed `sprint-dashboard-polish` | maestro | done | — | `docs/agents/LEDGER.md` | seeded |
|
||||||
|
| D-70 | Rail + licks layout concept doc: (a) guitar ≤4 shapes with the selection rule (open + common movable, lowest-position-first — name it); (b) piano 4 voicings as a 2×2 grid of compact MiniPianos — pick the MiniPiano thumb scale that fits two side-by-side in the ~456px column interior and two rows within a sane row height, honest math; (c) all ▶ removed from the rail's guitar+piano cells AND the licks strip (name every removal site); (d) hidden-but-scrollable scrollbars — the mechanism (webkit ::-webkit-scrollbar thin + dark thumb, and Firefox scrollbar-width/color; overlay where supported) and WHERE it applies (the rail's overflow-y column, any inner scrollers); (e) licks: uniform card footprint (thumb size parity between LickCard and PianoLickCard) + the strip follows the global instrument (guitar→piano licks on PIANO; bass honest empty); (f) recompute the row heights + the 500px column budget with the new smaller cells; migration order with bounded L-70/L-71 scopes (keep them file-disjoint or serialize on JamGuide.jsx). No user gate: pick strongest, record rationale + ≥2 rejected alternatives | design | done (`3fdc72e`; final scope: play buttons OFF EVERYWHERE per user; guitar-4/2x2-piano/uniform-licks dashboard-scoped) | — | `docs/design/dashboard-polish.md` | every change specced with honest numbers; the 2×2 piano scale chosen + proven to fit; bounded impl scopes |
|
||||||
|
| L-70 | Implement the voicings rail per D-70: guitar ≤4, 2×2 smaller piano, ▶ removed, dark hidden scrollbars | engineering | done `aae69e8` (combined-gate PASS) | D-70 | `src/components/GlanceRail.jsx`, `src/components/VoicingBrowser.jsx`, `src/index.css` (scrollbar CSS) (+ per doc — re-lock at promotion) | rail matches the spec; no ▶; guitar ≤4; piano 2×2; scrollbars hidden+dark+functional; build + smoke green |
|
||||||
|
| L-71 | Implement the licks strip per D-70: uniform card size, follow the global instrument (wire PianoLickCard), ▶ removed | engineering | done `19b1a4f` (combined-gate PASS; L-74b `6885fc3` visibility fix too) | D-70 | `src/components/JamGuide.jsx` (LicksStrip), `src/components/LickCard.jsx`, `src/components/PianoLickCard.jsx` (+ per doc — re-lock at promotion) | licks uniform; piano licks show under PIANO; no ▶; bass honest; build + smoke green |
|
||||||
|
| D-72 | Same-style related-progressions design (user answer = reuse existing KB): decide the presentation — when a style is active, RelatedProgressions leads with same-style siblings reframed as "variations / sections to try in {style}" (labels/section framing that reads as bridge/chorus/variation without new content); how the active style is known (roulette seed carries it; live detection's match yields it — name the prop/source); whether cross-style entries stay as a secondary "other styles with these changes" section or are dropped when a style is locked; empty/edge states. Files disjoint from the rail/licks chain (RelatedProgressions.jsx only) → runs in PARALLEL | design | done | — | `docs/design/related-same-style.md` | presentation spec'd against the real RelatedProgressions/match.js; active-style source named; L-72 bounded |
|
||||||
|
| L-72 | Implement D-72: RelatedProgressions leads with same-style variations when a style is active; reframed labels; cross-style demoted/dropped per the doc. **Unblocked 2026-07-13** (no song forms). Resolutions: (finding-A) when a style IS locked, DROP the secondary cross-style section — same-style only, per goal #6 + the user's "not necessarily other styles"; keep cross-style ONLY when no style is locked (match.matched===false). (finding-B) null the role phrase when a same-style sibling shares NO transitions with the loop (no false "variation" claim — just name + level). Re-pin smoke §8 labels ('shares I7→V7'→'shorter form' etc.; scores stable) | engineering | claimed | D-72 | `src/components/RelatedProgressions.jsx`, `scripts/smoke.mjs` (§8 re-pin) | same-style-only when locked; honest cross-style only when unlocked; role phrase honest (finding-B); build + smoke green — **PASS** (`see commit` — findings A/B verified; gate added 2 durable smoke assertions, 893/893) |
|
||||||
|
| D-73 | **"Try this" chord-substitution feature** design (engine rules + UI): for a loop chord `{rootPc,quality}` in the detected key/style, a SMALL curated set (~3-4) of alternative chords — recommend the categories (relative/diatonic-third sub, borrowed-minor colour e.g. IV→iv, extension/colour e.g. maj7/add9/sus, a circle-of-fifths / secondary-dominant move) — each with a plain one-line WHY that teaches. Concrete worked examples for the user's Am–C–F case (both A-minor and C-major readings), arithmetically correct (the gate WILL recompute). The UI surface: where it mounts in the jam dashboard (per-station in the rail? a "Try this" line under the loop? — decide, keep it glanceable, tappable→ChordDetailModal), honest circle-of-fifths tie-in (a lens for the relative/neighbour subs, NOT claimed for borrowed/extension), edge states (no key / atonal), keep-it-simple (learnable, not a reharm engine). Use a SEPARATE component (disjoint from RelatedProgressions.jsx). No user gate: pick strongest, ≥2 rejected alternatives | design | done (returned once — 3 copy/plumbing fixes verified: chordRootPC→noteIndex circular-import, ♭6 flat spelling, mediant claim; rules all recomputed correct; `c2207ef`) | — | `docs/design/try-this-subs.md` | substitution categories + rules + why-copy spec'd and arithmetically honest; Am–C–F worked; UI surface + mount decided; L-73/L-74 bounded |
|
||||||
|
| L-73 | Substitution engine: `suggestSubstitutions({rootPc,quality}, keyInfo, opts?)` in `src/lib/theory.js` (additive) → `[{rootPc, quality, label, why, category}]` per D-73's rules + a smoke truth-table (expected subs for known chords/keys, sabotage-proven like the resolveDegree guard) | engineering | backlog | D-73 | `src/lib/theory.js` (additive), `scripts/smoke.mjs` | engine returns musically-correct subs for all 14 qualities in-key; smoke truth-table bites; build green | ← claimed 2026-07-13; **returned once** — Rule C self-suggested the sounding chord on 7th-chord inputs (Dm7→Dm7) + latent add9-on-minor mis-spelling; fixed + re-gate PASS (Dm7→[F], min7 sweep no add9, triad path intact, sabotage bites, 903/903); **done** `see commit`
|
||||||
|
| L-74 | "Try this" UI per D-73, with VARY/ROTATE (user decided 2026-07-13: "more surprising, more jam-like, keeps offering new ideas"): a new `TryThis.jsx` that, for the current playhead chord, shows ONE suggestion at a time (matches "try this" = a single nudge) and CYCLES to the next valid substitution each time the loop completes a pass (playhead wraps to station 0 — watch the position prop). Sequential cycling through the engine's ordered valid set (softest-first on first appearance) so the player eventually learns all options and never repeats until exhausted; a small "1 of N" / dot indicator; 0 subs → no card; 1 sub → static (no rotation). Tappable→ChordDetailModal; tokens/AA; glanceable. Supersedes D-73's static ≤4-chip display for the UI (engine/rules unchanged). Mount App.jsx-only in relatedSlot above RelatedProgressions (disjoint) | engineering | backlog | D-73, L-73 | `src/components/TryThis.jsx` (new), `src/App.jsx` (mount) | one fresh honest sub per chord, cycling each loop pass; zero-click; empties honest; build + smoke green | **done** `038fdfa` (Critic PASS — 2-pass rotation trace Dm→Fm verified, contract-clean)
|
||||||
|
| D-75 | **Related-area space layout** (user 2026-07-13, after seeing it — "we have enough space in that area… fit everything in one big screen"): (a) TRY THIS — show the current chord's up-to-4 substitutions SIDE BY SIDE (replaces the one-at-a-time rotation; user has seen rotation + prefers all-visible), each a small card = chord label + a mini instrument diagram of that chord's voicing (ChordDiagram guitar / MiniPiano piano, following the GLOBAL instrument selector; bass = honest simple) + a compact why + tap→ChordDetailModal; (b) SUGGESTED PROGRESSIONS (RelatedProgressions) — 2×2 grid instead of the vertical list. Honest space math for the LEFT column (~744px, wider than the 500px rail). Instrument prop threading for TryThis (App mount passes `instrument`). Bounded L-75/L-76 (disjoint: TryThis.jsx+App-mount vs RelatedProgressions.jsx) | design | ready | — | `docs/design/related-area-layout.md` | side-by-side try-this with per-sub diagrams + 2×2 related spec'd with honest column math; instrument-follow named; L-75/L-76 bounded |
|
||||||
|
| L-75 | Implement TryThis side-by-side + per-sub instrument diagrams per D-75 (drops the rotation; follows the loop/current chord; instrument-following diagram) | engineering | done `957ce88` (combined-gate PASS) | D-75 | `src/components/TryThis.jsx`, `src/App.jsx` (pass `instrument` to the mount — grep-clean) | up-to-4 subs side by side each with a playable-shape diagram in the current instrument; tap→modal; honest empties; build+smoke green |
|
||||||
|
| L-76 | Implement RelatedProgressions 2×2 grid per D-75 (same-style + cross-style sections in a 2-col grid using the left-column width) | engineering | done `e695716` (combined-gate PASS) | D-75 | `src/components/RelatedProgressions.jsx` | 2×2 layout; both sections adapt; no logic/scoring change; build+smoke green |
|
||||||
|
| D-76 | **Related-area v2** (user 2026-07-13, "one last change"): (a) VOICINGS RAIL — currently shows all loop stations when a loop matches, but only the SINGLE current chord in the heard-live/no-loop fallback (JamGuide railContent :373-424). User wants: ALWAYS show multiple chords' voicings, MOST RECENT FIRST — loop chords when matched (KEEP canonical order + a moving "now" playhead highlight — design call, reordering would break GlanceRail's between-adjacent voice-leading chips AND reshuffle rows every chord, contradicting the user's anti-jump preference; most-recent-first applies to the NO-LOOP history rail), else the recent distinct chord history (from chordHistory, cap ~6, most-recent-first). Never just one. **User confirm pending on the loop-ordering deviation (asked 2026-07-13).** (b) TRY THIS — cap at **3** suggestions (was 4); **STABLE fixed layout** (reserve 3 slots so 2-vs-3 subs never shifts position — the user: "annoying when the layout changes then u dont know where to look"); each suggestion shows **≥3 ways to play it** — guitar = **3×3** (3 subs × up to 3 getGuitarVoicings shapes each), piano = **1** MiniPiano each ("for piano it can be just one thats okay"), bass = root caption. Honest space math (left col ~744px for the 3×3 guitar; rail column for the multi-chord history). No user gate: pick strongest, ≥2 rejected alts | design | ready | — | `docs/design/related-area-v2.md` | rail always-multi-chord-most-recent-first spec'd; try-this 3-cap + stable-3-slot + 3-shapes-guitar/1-piano with honest math; L-77/L-78 bounded |
|
||||||
|
| L-77 | Voicings rail — HYBRID (user re-scope 2026-07-13): when a loop is found, HIGHLIGHT the loop chords as a "loop" group AND list the OTHER recent distinct chords from full chordHistory underneath ("also played"); when no loop, just the recent distinct history. Show **≥4 chords total** as soon as any chord history exists (top up from history to reach 4+). Loop group keeps canonical order + moving "now" playhead (voice-leading chips valid); history group most-recent-first, no chips. Each chord expands to its full voicing gallery (keep composition) | engineering | done `9415daf` (direct push per user) | D-76 | `src/components/JamGuide.jsx`, `src/components/GlanceRail.jsx` | loop highlighted + others underneath; ≥4 when history exists; no-loop → history ≥4 most-recent-first; galleries + focus preserved; audio contract untouched; build+validate+smoke green — **done** (GlanceRail +`showTransitions` prop (default true, byte-compat); JamGuide `recentDistinctChords`/`historyStations` memo + hybrid railContent; RAIL_TOTAL_CAP 8 / NO_LOOP_HISTORY_CAP 6; 903/903; only 2 locked files, contract-clean. Maestro folded 2 honesty fixes on shared GlanceRail: history group drops "the loop"/"playhead" framing; history rows non-focusable (no inert button). Full independent Critic gate waived per user "push when its done") |
|
||||||
|
| L-78 | Try-this per D-76: max 3 subs, stable 3-slot layout, 3 guitar shapes each (3×3) / 1 piano each | engineering | done `d543b98` (direct push per user 2026-07-13; verified build+validate+smoke 903/903 — full independent Critic gate waived for the push) | D-76 | `src/components/TryThis.jsx` (+ per doc) | ≤3 subs in fixed slots; guitar 3 shapes/sub; piano 1; layout stable 2-vs-3; reactivity preserved; build+smoke green |
|
||||||
|
| C-70 | Sprint-end sweep + PR update (BOTH gitea+github). Fold: drop stale ", each playable" from VoicingBrowser aria (:278/:314, combined-gate finding) | quality | backlog | L-70, L-71, L-72, L-74, L-75, L-76, L-77, L-78 | `src/components/VoicingBrowser.jsx` (aria one-liner) | all green; aria fixed; PRs updated |
|
||||||
|
|
||||||
|
> Sequencing (three parallel chains, file-disjoint): (A) D-70 → L-70 → L-71 (rail/licks); (B) D-72✓ → L-72 (related, same-style); (C) D-73 → L-73 → L-74 (try-this subs). ALL DONE except A's builds. **L-74b (2026-07-13, in-review):** TryThis loop-fallback so the card is visible for a rolled/detected loop without live input (user "i dont see rotating cards") — TryThis.jsx only, SSR-verified. **Batch plan:** L-70 ‖ L-71 ‖ L-74b (disjoint) → ONE combined gate (rail/licks + dock byte-identity + full-app SSR + contract) → commit all → push both remotes. C-70 closes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shipped sprint: `sprint-integrated-glance` (branch: `sprint-jamguide-piano` — complete 2026-07-10, 4 iterations, PR #3 updated)
|
||||||
|
|
||||||
|
**Goal (user directive 2026-07-10, after testing the glance-mode sprint — "its already a lot better, but"):**
|
||||||
|
|
||||||
|
1. **Move it up & integrate** — "i would like to move it up and integrate it somewhat in the space of the main module up top. or maybe right below it as it is super important." The Jam Guide content is currently the LAST mount in App.jsx (line ~712); the main module (ProgressionBanner + instrument view) is at ~609-622.
|
||||||
|
2. **One loop display** — "i would like to be able to see the loop clearly, no need to have this repeated again. as its already in the main module." ProgressionBanner (top) and the Roadmap track (inside JamGuide) both show the detected loop — exactly one clear loop display must survive.
|
||||||
|
3. **One instrument selector** — "only one selection for GUITAR/PIANO/BASS and everything should show in that instrument." App.jsx line 58 ALREADY holds global `instrument` state ('piano'|'guitar'|'bass') driving the main views; JamGuide ignores it and has its own internal tabs. Everything (voicings, licks, guide) must follow the one selector. Bass has a main-view BassFretboard but zero KB content — needs an honest state (and a stretch chain to make it real).
|
||||||
|
4. **All chords' voicings visible at once** — "it's difficult to see as it changes when the chord is not playing so i'd like to see all the chords and their voicings in a specific way in case a loop is detected (or at least just like 4 chords or something at least, so you can follow and potentially learn new ways to play it while you are playing the loop." The playhead accordion (only the active station expanded) is hard to follow live — show EVERY loop station's voicings simultaneously (≥4 chords), playhead highlights rather than reveals.
|
||||||
|
5. **Scroll-first** — "scrolling is easier then clicking." Layout principle for everything above.
|
||||||
|
|
||||||
|
**Loop:** `/jam-loop` every 30 min for 12 h (24 iterations), started 2026-07-10 evening (cron 13,43). "let each agent do a part and revise the work" — standard ensemble + gates. Weights: Luthier 3, Muse 3, Professor 2, Critic gate.
|
||||||
|
|
||||||
|
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
||||||
|
|----|-------|--------|--------|-----------|--------------|--------------------|
|
||||||
|
| M-05 | Seed `sprint-integrated-glance`; schedule the 24×30m loop | maestro | done | — | `docs/agents/LEDGER.md` | seeded, cron created |
|
||||||
|
| D-40 | Integration concept doc: (a) where the Jam Guide content lands relative to the main module (in it vs right below — pick, record rationale); (b) which loop display survives (ProgressionBanner vs Roadmap track — the loser's unique value, e.g. guide-tone rails, must be honestly dispositioned); (c) JamGuide obeys App's existing global `instrument` — internal tabs retired; honest bass state until the bass chain lands; (d) the all-stations-expanded voicings layout (≥4 chords visible, playhead HIGHLIGHTS not reveals, scroll-first — the user explicitly licensed scrolling, so D-31's "everything expanded" rejection is overturned by directive); (e) what remains of the four-section dock below; (f) honest space math + migration order. No user gate: pick strongest, record rejected alternatives. **Returned once (2026-07-10):** piano worst-case Dm7-specific (G7 gallery ≈1,178px, roots above D need 2-octave crops → rows WRAP, screenful counts restated); LicksSection lives in JamGuide.jsx not ExplorePanel; ChordBox keeps importers (only RiffDiagram orphans); L-40 interim would ship the page-yank (GlanceRail scrollIntoView in page flow) — doc added the one-line effect deletion to L-40's lock, same-commit mandatory. Fixed + re-gate PASS (pixel-exact recompute: gallery 1,178, row 1,470, all table arithmetic verified) | design | done | — | `docs/design/integrated-glance.md` | doc names every component, the single-loop-display call, the instrument-threading contract, ≥2 rejected alternatives, bounded L-40/D-41 scopes |
|
||||||
|
| L-40 | App restructure per D-40: promote the Jam Guide content to the D-40 position; retire the duplicated loop display per the doc's call; JamGuide takes `instrument` as a prop from App's existing selector (internal tabs removed; VoicingsSection/LicksSection/ExplorePanel mounts follow too); honest bass empty-state. Ride-along (C-40 follow-up): add an export-only line to JamGuide's `resolveDegree` (JamGuide.jsx:73) so smoke §7 can sweep both copies directly (today it pins the validator side behaviourally). 🚨 App.jsx changes = layout/mount/UI-state ONLY — audio callbacks/refs/AudioCapture untouched (grep-gated) | engineering | done | D-40 | `src/App.jsx` (layout + UI-state only), `src/components/JamGuide.jsx`, `src/components/ExplorePanel.jsx` (VoicingsSection instrument prop), `src/components/GlanceRail.jsx` (scrollIntoView effect deletion ONLY, same commit as band promotion — D-40 §6.1) | one selector drives everything; guide content up top; loop shown exactly once; build + smoke green; audio contract grep-clean |
|
||||||
|
| D-41 | All-expanded voicings rail per D-40: every station's gallery visible simultaneously (≥4 chords), active station highlighted by the playhead (accent ring/header, NO reveal/hide), scroll-first reflow, licks strip follows the global instrument per the doc. **Folded (L-40 flag, 2026-07-10):** VoicingBrowser's `show` prop falls through unknown values to 'both' — the dock's VoicingsSection under bass passes show='bass' and renders BOTH galleries, violating D-40 §3 prose; fix in VoicingBrowser (honest bass empty-state or explicit 'bass' handling), lock extended accordingly. Also: bass row headers gain solo-scale/aim-dots here per D-40 row anatomy | design | done | D-40, L-40 | `src/components/GlanceRail.jsx`, `src/components/VoicingBrowser.jsx` (dense styling + show='bass' handling), `src/components/JamGuide.jsx` (row anatomy per doc) | all voicings of all loop chords on screen/scroll with zero clicks; playhead never hides content; AA + tokens; build + smoke green |
|
||||||
|
| C-40 | Drift guards in smoke (two hand-synced duplications, backlog debt): (a) JamGuide's `resolveDegree` copy vs validator's — 16-degree × 14-quality truth table; (b) technique vocab `validate-kb.mjs` LICK_TECHNIQUES ↔ `LickCard.jsx` TECHNIQUE_VOCAB. Ride-along: fix `scripts/loop-fixtures.mjs` header's stale "App.jsx:322-324" line-ref (C-31 sweep finding) | quality | done | — | `scripts/smoke.mjs`, `scripts/loop-fixtures.mjs` (comment only) | drift in either duplication turns smoke red (sabotage-proven); header ref current; smoke green |
|
||||||
|
| P-40 | Refresh `docs/kb-backlog.md`: mark all shipped cells done (gospel/pop guitar; jazz/gospel/rnb/blues piano), set the bass column as next (blues → jazz → funk per plan) | content | done | — | `docs/kb-backlog.md` | backlog reflects reality; next-cell pointer correct |
|
||||||
|
| C-41 | Bass play schema (additive): SCHEMA.md + validator + smoke bite-test for bass patterns (per-station patterns: root/fifth/approach-note walks — design the minimal honest shape with 4-string, fret 0-15 representation). Ride-along (C-40 gate finding): document in SCHEMA.md that `resolveDegree('7', maj6|min6)` = 9 (the 6th) in both hand-synced copies — a contract quirk, never misspells a chord, but authors should write `'6'` explicitly | quality | done | C-40 | `src/data/kb/SCHEMA.md`, `scripts/validate-kb.mjs`, `scripts/smoke.mjs` | schema documented; validator enforces; existing KB untouched-green; smoke proves the check bites |
|
||||||
|
| P-41 | First bass cell: **blues bass** (kb-backlog order blues → jazz → funk). C-41 gate notes: offset 11 (maj7 over qualities lacking it) is unrepresentable — if you hit it, STOP and report (schema extension, not workaround); approaches are terminal-only but mid-pattern passing tones are expressible as fixed degrees (b9/9/b3/11/b5/b13/6/b7 all covered) | content | done | C-41 | `src/data/kb/blues/bass.js`, `src/data/kb/index.js` | validator green; every pattern playable as written; prose arithmetic-verified |
|
||||||
|
| L-42 | Minimal bass pattern renderer for the guide (per-station BassPatternCard per D-40 §3; the computed root·fifth·approach line stays as fallback) — makes the BASS selector real end-to-end. C-41 gate note: `chrom-below` into a next root at open E has no pitch below the instrument — renderer must octave-displace the approach or place the root higher | engineering | done | C-41, P-41, L-40 | per D-40 doc — re-lock at promotion | bass stations render the authored patterns; guitar/piano paths untouched; build + smoke green |
|
||||||
|
| C-42 | Sprint-end sweep + PR #3 update per PROTOCOL §6. Folded (L-40 gate): wire smoke §7 to import JamGuide's now-exported `resolveDegree` (the jsx load hook already exists) and refresh the stale "unexported (checked 2026-07-10)" comment at smoke.mjs:855-862 — tiny Critic-owned fix inside the sweep | quality | done | L-40, D-41 | `scripts/smoke.mjs` (§7 wiring only) | all green; smoke sweeps both resolveDegree copies; PR updated |
|
||||||
|
|
||||||
|
> GATE 2026-07-10 (Critic) · **P-40 PASS** — validator totals verbatim-match; ALL 14 cell play-counts recounted from the real registry (sum 164 ✓); lick arrays counted per style (4/4/4/4/4/3 = 23 ✓); 10 dates cross-checked against ledger + git commits, all hold; diff +36/−15 confined, format preserved. Non-blocking: piano next-cell = pop is a priority call (plan's default ranks bossa first — doc licenses reorder, accepted as Professor's call); L-42 row omits its L-40 dep (one word, fix if touched).
|
||||||
|
|
||||||
|
> Sequencing: D-40 ‖ C-40 ‖ P-40 file-disjoint, ready now. Then D-40 → L-40 → D-41 (App/JamGuide/GlanceRail serialized — same surfaces), and C-40 → C-41 → P-41 → L-42 (bass chain, stretch). C-42 closes. Critic gates every task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shipped sprint: `sprint-glance-and-loop` (branch: `sprint-jamguide-piano` — complete 2026-07-10, 4 iterations, PR #3 updated)
|
||||||
|
|
||||||
|
**Goal (user directive 2026-07-10):** three asks, in the user's words:
|
||||||
|
|
||||||
|
1. **All voicing variations in one view** — "in the music theory … see all the variations G shape, C shape, etc in one view without having to push a button. so they all line up next to each other." VoicingBrowser currently chip-switches one shape at a time; replace with a gallery that renders every shape/style side by side, each independently playable.
|
||||||
|
2. **Fix the loop logic** — "it doesn't recognize when 3 chords return, or when its 5 and then 2 others." `detectRepeatingProgression` (theory.js) does exact contiguous block matching, lengths 2–6: one spurious misdetected chord breaks a rep; a 5-chord loop followed by 2 foreign chords (tag/turnaround) is missed; 7–8-chord loops can't be found at all. Redesign to be noise-tolerant; tune the App.jsx commit layer so a committed loop survives fills.
|
||||||
|
3. **Glanceable learn session** — "expand the learn session so we see as much as possible in voicing variations and styles without having to click buttons, the idea is to have it open as the jam is playing." Knowledge Center should auto-follow the live jam with zero clicks.
|
||||||
|
|
||||||
|
**Loop:** `/jam-loop` every 30 min for 12 h (24 iterations), started 2026-07-10 (cron 13,43). User is away — no user-gated tasks; Muse picks the strongest option and records rationale. Weights: Luthier 3, Muse 3, Professor 1, Critic gate, Herald 0.
|
||||||
|
|
||||||
|
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
||||||
|
|----|-------|--------|--------|-----------|--------------|--------------------|
|
||||||
|
| M-04 | Seed `sprint-glance-and-loop` from user directive 2026-07-10; schedule the 24×30m loop | maestro | done | — | `docs/agents/LEDGER.md` | sprint seeded, cron created |
|
||||||
|
| C-30 | Loop-detection truth fixtures: new `scripts/loop-fixtures.mjs` (+ smoke wiring) — realistic chord-history cases with expected loops: (a) clean 3-chord loop ×2–3 reps; (b) 3-chord loop with one spurious misdetection inside a rep; (c) consecutive duplicate commits of the same chord; (d) 5-chord loop followed by 2 foreign chords — loop must survive; (e) 7- and 8-chord loops; (f) section change loop-A→loop-B — detection must switch; (g) chromatic non-loop → null. Run each against the CURRENT `detectRepeatingProgression` and mark which fail today (`expectedFail: true`) so the suite is green now and L-30 flips the markers off | quality | done | — | `scripts/loop-fixtures.mjs`, `scripts/smoke.mjs` | fixtures runnable via smoke; failure map of the current algorithm documented in the fixture file header; smoke green (expected-fails annotated, not silently skipped) |
|
||||||
|
| L-30 | Redesign `detectRepeatingProgression` in `theory.js`: collapse consecutive duplicate chords before matching; tolerate ≤1 substitution/insertion per cycle (noise from misdetection); support loop lengths 2–8; recency-weighted scoring so an old section can't outvote the current loop; keep the pure signature, the rotation-`canonicalize` output, and every other export byte-untouched. Flip all C-30 `expectedFail` markers to passing. **C-30 findings folded in (2026-07-10):** (a) the `reps × len²` scoring artifact is the primary everyday bug — a clean 2-chord vamp ×4 reports a bogus 3-chord `[Am,Am,G]` via self-overlapping occurrences; candidate generation must reject candidates that are rotations/overlaps of a shorter period (not just exact periodic patterns at len ≥ 4); (b) 7/8-loops contain realistic non-adjacent internal chord repeats (`Am … Am`) — period detection must not collapse those into shorter false loops | engineering | done | C-30 | `src/lib/theory.js` (detectRepeatingProgression + private helpers only) | all C-30 fixtures green with zero expectedFail markers (incl. `vamp-2x4`/`vamp-2-sustained`); no other theory.js export changed; build + smoke green |
|
||||||
|
| L-31 | Commit-layer tuning in App.jsx's progression effect (lines ~154-173): a committed loop must survive transient misses (the miss-4-then-clear rule drops the loop during fills/turnarounds); a new loop replaces the old only with consistent evidence; first commit shouldn't need extra rounds when detection is already stable. **Primary trace case (C-30 finding, 2026-07-10):** pre-L-30 detection flaps between the true loop and ghost 4-patterns, so `progressionVoteRef` never sees 2 consecutive identical keys and nothing ever commits — verify post-L-30 stability makes the 2-vote commit reachable in the noisy fixtures, then tune (post-L-30 status: done). **L-30 gate findings folded in (2026-07-10):** (a) a 2-rep loop with one corrupted rep FLAPS through data-faithful sub-cycles commit-to-commit until the next clean rep — faster first-commit must NOT let a single transient sub-cycle detection replace an already-committed loop (replacement needs consistent evidence); note non-null detection resets the miss counter, so today's committed loop already survives the flap; (b) ride-along one-line fix: theory.js ~line 601-603 docstring overclaims "ghost occurs exactly once by construction" — false for same-chord-same-phase corruption (behavior fine, comment too strong); (c) deletions untolerated by design — fine at ≥3 reps, degrades to (a)'s flapping at exactly 2. 🚨 **Audio contract: touch ONLY the progression useEffect + its refs — no callback, ref-sync, or AudioCapture change** | engineering | done | L-30 | `src/App.jsx` (progression effect only) | loop survives fixture-style fills end-to-end (hand-trace documented in commit); App.jsx diff confined to the progression effect block; build + smoke green |
|
||||||
|
| D-30 | VoicingBrowser gallery: render ALL matching guitar shapes side by side (each = label + ChordDiagram + its own ▶) and all four piano styles side by side (label + MiniPiano thumb + ▶) — no chips, no selection state; grid wraps on narrow viewports; one-at-a-time playback preserved (new play stops the previous). All existing mounts (Voicings section, ChordDetailModal tabs, JamGuide station-enlarge) get the gallery automatically; `show` prop semantics unchanged | design | done | — | `src/components/VoicingBrowser.jsx` | every placeable shape/style visible simultaneously with zero clicks; AA contrast + tokens; keyboard-reachable play buttons; build + smoke green |
|
||||||
|
| D-31 | Glance-mode concept doc: the Knowledge Center panel open WHILE the jam plays — what auto-follows live detection with zero clicks (current station's voicing gallery inline? licks strip for the style? what stays on screen vs behind interaction), density/space budget vs the Roadmap track, narrow-viewport plan, and the migration order for L-33. No user gate: pick the strongest layout, record rationale + rejected alternatives. **Returned once (2026-07-10):** piano gallery width understated ~45% (2-octave crops for rootless 7th-chord voicings → ~940px not 640; scroll is the piano rail's normal state on most loops) + licks strip ~190px not 150 + 4 clarifying notes; layout itself held — fixed + re-gate PASS | design | done | — | `docs/design/glance-mode.md` | doc names every component to touch, the auto-follow rules, and honest space math (what fits at 1280×900 and at narrow) |
|
||||||
|
| L-33 | Implement glance mode per D-31 (`docs/design/glance-mode.md` — the "playhead accordion"): the station column at canonicalPos expands to the full D-30 gallery and advances with the playhead; licks strip below (level-filtered, token-boundary context match, strip-owned "fits — now" chrome, guitar licks under piano tab with honest heading); pin gesture inherits onFocusChord semantics; auto-follow never emits focus-chord and never auto-plays audio; piano rail EXPECTED to scroll (never assert one-row fits); follow the doc's 5-commit migration order | engineering | done | D-30, D-31 | `src/components/GlanceRail.jsx` (new), `src/components/JamGuide.jsx`, `src/components/VoicingBrowser.jsx` (optional additive `dense` prop only) | glance behaviours live per the doc; audio contract intact (App.jsx untouched or additive-only); build + smoke green |
|
||||||
|
| P-30 | KB expansion: **neo-soul/rnb piano** cell (next per `docs/kb-backlog.md` after jazz/gospel) | content | done | — | `src/data/kb/rnb/piano.js`, `src/data/kb/index.js` | validator green + registered; renders in the piano tab; prose claims arithmetic-verified |
|
||||||
|
| P-31 | KB expansion: **blues piano** cell. **Returned once (2026-07-10):** turnaround jump-kicks bars 3-4 dyad orientations flipped (breaks the play's own only-leap law — gate pre-verified the exact swap) + 2 prose rewordings (♭7 "unmoved all night" contradicted by bar 9; "octave-and-change" actually a major 7th) — fixed + re-gate PASS | content | done | P-30 | `src/data/kb/blues/piano.js`, `src/data/kb/index.js` | validator green + registered |
|
||||||
|
| C-31 | Sprint-end sweep: full build + validator + smoke + fixtures, then update PR #3 (push + PR body/comment summarising this sprint) per PROTOCOL §6 | quality | done | L-31, L-33, D-30 | (none — verification) | all green; PR #3 updated |
|
||||||
|
|
||||||
|
> Sequencing: C-30 ‖ D-30 ‖ D-31 ‖ P-30 are file-disjoint and ready now. Then C-30 → L-30 → L-31 (serial chain on theory.js/App.jsx), and D-30+D-31 → L-33. P-31 fills spare iterations. Critic gates every task.
|
||||||
|
|
||||||
|
> GATE 2026-07-10 (Critic) · **D-30 PASS** (`0945a08`) — scope confined to VoicingBrowser.jsx; build green, smoke 809/809, validator green. SSR of the real component (Cmaj7/G7/Asus2/F#m7 × show variants): guitar cell count === independently re-derived matchingShapes count (4/4/3/3), all four piano labels honest, ZERO chips/aria-pressed (button count === cell count), every ▶ aria-labeled chord+voicing, rootPc badge guard proven (rootless voicings show 0 R badges, root/shell exactly 1; buggy variant rendered + diffed = absent), show prop scoping correct, E-add9 empty state graceful. Playback single-handle verified by read. *Non-blocking:* thumb-size diagrams drop finger numbers → backlogged.
|
||||||
|
|
||||||
|
> GATE 2026-07-10 (Critic) · **C-30 PASS** — smoke 809/809 exit 0 with 8 annotated expected-fails (printed with today/contract values, never skipped); validator + build green. Failure map independently recomputed against the real function: vamp-2x4 self-overlap 2×3²=18 > pair 4×2²=16 → bogus `[Am,Am,G]` ✓; spurious-substitution ghost 4-pattern 2×16=32 > true 3×9=27 ✓; section-change stale-A 48 > 27 ✓; five-plus-tag genuinely passes pure-function (L-31 attribution honest) ✓. Sabotage ×3: broken guard → exit 1 named; stripped expectedFail → exit 1; stale marker → exit 1 with explicit STALE message — none can rot silently. `canonicalLoop` replica byte-equivalent to theory.js's private `canonicalize` + load-time self-check. clean-2x3 vs vamp-2x4 pins the exact boundary where the len² artifact kicks in. Nit (non-blocking): fixture header line 25 says `[Am,G,Am]` vs comment `[Am,Am,G]` — same loop pre/post canonicalization. NOTE: the transient smoke red the D-31 gate reported (`clean-3x3`) was this gate's own sabotage edit (a) running concurrently — tree restored, verified clean.
|
||||||
|
|
||||||
|
> GATE 2026-07-10 (Critic) · **D-31 RETURNED → PASS on scoped re-gate** — first gate verified the playhead-accordion layout, the App.jsx-zero-changes claim (all 7 props already flow; pin inherits selectedStation semantics incl. reset effects), chordContext data support, and the vertical budget (256px at 1280×900 recomputed from real component geometry) but returned one arithmetic finding: expanded piano gallery ~640px understated ~45% (rootless A/B of true-7th chords → 2-octave D-24 crops = 266px thumbs) + licks strip 150→190px. Muse fixed: 940 / 1350–1550 (scrolls at ~1200, not "borderline") / 2300 / 3,500+ / 190 / 1070, "scroll is the piano rail's normal state on most loops", commit-5 must never assert one-row-fits, + 4 notes (token-boundary chordContext match; strip-owned "fits — now" chrome; guitar licks under piano tab stated; licksFor "lifted not reused"). Re-gate recomputed Dm7 rootlessA [17,21,24,28] → 266px from piano.js/MiniPiano.jsx, grep-verified no stale figures, no regression to passed sections. Nuance (non-blocking, for L-33): "any true-7th chord" slightly overgeneral — some rootless voicings fit 1 octave (e.g. Dm7 rootlessB); 940 is the correct conservative worst case.
|
||||||
|
|
||||||
|
> GATE 2026-07-10 (Critic) · **P-30 PASS first try** (`b51daa1`) — validator 154 plays green, build green, shared-tree smoke 817/817. Independent arithmetic (own script from SCHEMA/CHORD_TYPES, not Professor's): max per-hand span 11 (≤15), ≤4 notes/hand, combined span ≤23 → every recipe renders for all 12 roots in the 36-key window (zero silent fallbacks); 11 hand-picked prose claims all recompute exactly (♭VI→V sink 1,1,1,1,2; 9sus RH = major triad on ♭7; So What [0,5,10,15,19]; I-RH ≡ vi7-RH pcs; 13sus = Type A bottom+1; ...). Rule-4 idiomatic difference genuine per pair; outside-key 9-on-iii honestly flagged; sources hedged. One chased-and-accepted oddity: ♭VI13 stab's C–F–G♭ pinch is deliberate voice-leading, not a stacking bug.
|
||||||
|
|
||||||
|
> GATE 2026-07-10 (Critic) · **L-30 PASS** (`4cc0009`) — smoke 817/817 (all 17 fixtures plain guards, 9 original greens no regression), validator + build green. Diff confined: theory.js 2 hunks in the repeating-progression section only, `canonicalize` unchanged context; loop-fixtures.mjs = marker removals + historical rewordings, every history/expect array untouched (frozen contract intact). 15 novel generalization probes: 11 correct, 3 data-faithful (only phase-locked exactly-alternating corruption yields a doubled pattern — genuinely 6-periodic data no algorithm could distinguish), 1 better-than-expected (all-reps-edited loop found via clean rotation). Every reportable "ghost" now corresponds to a pattern that exactly recurred — the old structurally-fake ghosts are gone. Perf 0.21–0.73 ms/call ×1000. Consumer contract at App.jsx:154-173 type-matches. 3 non-blocking findings folded into L-31's row.
|
||||||
|
|
||||||
|
> GATE 2026-07-10 (Critic) · **L-33 PASS** (`see commit`) — build green, smoke 817/817, validator green; diff exactly the 3-file lock (App.jsx/theory.js diffs attributed to L-31). SSR of the real JamGuide 37/37: expanded-column cell counts == independently derived shape counts (dom7@G=4, min7@D=4), aria-current one-per-list, mic microcopy exactly once (dense suppresses per-mount copies), pin semantics diffed line-by-line identical to old selectedStation (deps + emit/clear), GlanceRail imports no chordAudio + auto-follow has zero focus/audio side effects (scrollIntoView reduced-motion-guarded), heard-live + nothing-heard paths correct. Token matcher proven through the real render on a hostile country I-vs-I7 case (3 fits not 4) + full 23-lick chordContext audit — no false positive possible; exotic glyphs (♯/°/ø) outside the char class fail conservative (miss, never false-fit). `dense`-less VoicingBrowser SSR **byte-identical** to committed 0945a08. Zero-click audit: all variations + all licks in initial markup. Deviations accepted (staging cosmetic; heard-live licks strip musically right). Non-blocking: first-mount scroll on unknown playhead benign; LicksStrip perf fine unmemoized. Committed `c78baf4`.
|
||||||
|
|
||||||
|
> GATE 2026-07-10 (Critic) · **L-31 PASS** (`ce24d54`) — 🚨 audio contract CLEAN: 1 hunk in the progression useEffect, contract-grep zero hits, no ref-sync/mount props; theory.js ride-along comment-only (verified zero executable lines). Independent re-trace with own mirror + real detector: spurious-substitution commits #7 zero drops; section change replaces with zero intermediate nulls; breakdown first-null at foreign-commit 28 (comment's "~28" numerically exact) and clears at exactly 6 nulls; 2-rep flap survives with rival counter provably reset. HOSTILE-1 proved REPLACE_VOTES=3 load-bearing (rival hit 2 before agreement reset — 2 would displace-and-flap); alternating rivals never accumulate; newSong null-shape defensively handled. Build/smoke 817/817/validator green. 2/3/6 judged musically sensible; v-a commit-then-upgrade UX accepted over blank display. Non-blocking: "consecutive" comment nuance (nulls are neutral, not resetting — amend to "consecutive non-null" if ever touched); threshold consts re-created per render (trivially cheap, hoisting would exceed the lock); Luthier's +53/−12 was arithmetic sloppiness for +47/−6.
|
||||||
|
|
||||||
|
> GATE 2026-07-10 (Critic) · **P-31 RETURNED → PASS on scoped re-gate** (`81cd230`) — first gate verified 90%+ of the pack exactly (♭VI7→V7 five-voice plane numerically perfect; all spans 11/4/22 legal; all 96 recipes render for 12 roots; rule-4 contrasts genuine) but returned 3 findings: jump-kicks bars 3-4 dyad orientations flipped (broke the play's own only-leap law; gate pre-verified the exact swap), "unmoved all night" contradicted by bar 9's own note, "octave-and-change" = actually a major 7th (11 semitones). Professor applied all 3; re-gate recomputed the lap chain from scratch: E4,B♭4 → G4,D♭5 → F4,C5 → F4,B4 → E4,B♭4 — "only leap" now provably unavoidable (all pairwise pc distances = 3), validator 164 plays / smoke 817/817 / build green, no fourth edit area.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shipped sprint: `sprint-knowledge-center` (branch: `sprint-jamguide-piano` — complete 2026-07-09, PR #3)
|
||||||
|
|
||||||
|
**Goal (user directive 2026-07-08):** revise the **visual structure of the Knowledge Center** — today's knowledge lives in three disconnected surfaces (JamGuide/Roadmap, ExplorePanel, EducationPanel). Four user asks, verbatim priorities:
|
||||||
|
|
||||||
|
1. **Playable voicings per chord** — browse *and hear* alternative voicings for any chord (guitar shapes + generated piano voicings), with simple switching.
|
||||||
|
2. **Simple ways of seeing techniques for licks** — lick cards with technique markers (hammer-on, slide, bend…), per style.
|
||||||
|
3. **Intermediate-level progressions** — research them and find the *easiest* way to surface them in-app (level tags + filter, not a new panel).
|
||||||
|
4. **Keep expanding music knowledge** — continue KB cells per `docs/kb-backlog.md` in spare iterations.
|
||||||
|
|
||||||
|
**Loop:** `/jam-loop` every 30 min for 12 h (24 iterations), started 2026-07-08. User is away — no user-gated tasks this sprint; where the old sprint would have asked (concept selection), Muse picks the strongest option and records the rationale in the concept doc. Weights: Muse 3, Luthier 3, Professor 2, Critic gate, Herald 0.
|
||||||
|
|
||||||
|
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
||||||
|
|----|-------|--------|--------|-----------|--------------|--------------------|
|
||||||
|
| M-03 | Seed `sprint-knowledge-center` from user directive 2026-07-08; carry L-11 forward (hold lifted — user asked to continue on this branch) | maestro | done | — | `docs/agents/LEDGER.md` | sprint seeded, loop scheduled |
|
||||||
|
| L-11 | Wire piano into `JamGuide.jsx`: Piano tab active; per station render `MiniPiano voicing={…}` from `pianoVoicing`/`pianoVoicingChain`; keep the guitar path intact. **Carried from piano sprint; user hold lifted 2026-07-08** | engineering | done | — | `src/components/JamGuide.jsx` | piano tab shows per-station keyboards synced to the loop; guitar unchanged; build + smoke green |
|
||||||
|
| D-20 | Knowledge Center concept doc: one unified IA for JamGuide (live) + ExplorePanel (browse) + EducationPanel (learn) — sections *Jam Guide / Explore / Voicings / Licks & Techniques*, where the voicing player and lick cards mount, level-filter placement, narrow-viewport plan. **No user gate: pick the strongest layout, record rationale + rejected alternatives in the doc** | design | done | — | `docs/design/knowledge-center.md` | doc names every component to touch, the target structure, and the migration order for L-22; honest about what stays untouched |
|
||||||
|
| L-20 | `src/lib/chordAudio.js` — Web Audio voicing playback: `playVoicing(notes, {strumMs, durMs})` (piano `notes` are absolute 0–36 above C3; also accept guitar `{string,fret}` arrays via a midi mapper), own `AudioContext` created lazily on first user gesture, simple triangle/partials envelope, `stopAll()`. **Must not touch the detection pipeline's contexts**; document mic-feedback caveat (playback may be heard by detection) | engineering | done | — | `src/lib/chordAudio.js` | pure module + tiny API; no import from AudioCapture/App; plays a Cmaj7 shell audibly in dev; build green |
|
||||||
|
| D-21 | `VoicingBrowser.jsx` — for `{rootPc, quality}`: guitar alternatives (all matching `voicings.js` shapes via `ChordDiagram`) + piano alternatives (`pianoVoicing` root/shell/rootlessA/rootlessB via `MiniPiano voicing`) as switchable chips, each with a ▶ button calling `chordAudio`. Note (D-20 gate): `GUITAR_SHAPES` is private — lock includes an **export-only** line in `voicings.js`; do not cite the D-20 doc for small-text AA on active chips (use surface-background rule) | design | done | L-20 | `src/components/VoicingBrowser.jsx`, `src/lib/voicings.js` (export-only) | renders + plays all styles for any KB quality in any key; design tokens; AA contrast; keyboard-reachable |
|
||||||
|
| L-21 | Integrate VoicingBrowser: `ChordDetailModal` (replace/extend its static voicing grids) + JamGuide station-enlarge view — tap any chord anywhere → browse + audition voicings | engineering | done | D-21, L-11 | `src/components/ChordDetailModal.jsx`, `src/components/JamGuide.jsx` | both entry points work; legacy modal content preserved where not superseded; build + smoke green; audio-callback contract intact (App.jsx untouched or additive-only) |
|
||||||
|
| C-20 | Schema + validator + smoke for the two new KB shapes (both **optional/additive**): (a) `level: 'foundation'\|'intermediate'` on progressions; (b) per-style `licks` — `{id, name, level, chordContext, techniques[]}` from a fixed technique vocab (hammer-on, pull-off, slide, bend, double-stop, ghost-note, chromatic-approach, vibrato) + `tab: [{string, fret, technique?}]` ordered notes. Existing KB stays green untouched | quality | done | — | `src/data/kb/SCHEMA.md`, `scripts/validate-kb.mjs`, `scripts/smoke.mjs` | validator enforces the vocab + tab shape when present; 10-style KB still green; smoke still 776+ green |
|
||||||
|
| P-20 | Intermediate progressions: research per `docs/progression-repertoire.md`, then tag existing KB progressions with `level` and author **2 new intermediate progressions each** for jazz, gospel, neo-soul/rnb (secondary dominants, chained ii-Vs, backdoor, borrowed iv…) with guitar plays | content | done | C-20 | `src/data/kb/**` (progressions + guitar files of jazz/gospel/rnb), `src/data/kb/index.js` | every progression carries `level`; 6 new intermediate progressions validator-green; tips written for the "confident enough to jam" player (GOAL Part 2 audience) |
|
||||||
|
| P-21 | Licks pack #1: author 3–4 licks each for **blues, jazz, funk** guitar using the C-20 lick schema — technique tags, chordContext (which station/quality it fits), honest attribution | content | done | C-20, P-20 (done) | `src/data/kb/blues/guitar.js`, `src/data/kb/jazz/guitar.js`, `src/data/kb/funk/guitar.js` | validator green; every lick playable as written (Critic hand-checks fret/string sanity) |
|
||||||
|
| L-23 | Fix latent `getGuitarVoicings` bug (found by L-20): `strIdx = shape.rootStr - 1` indexes `OPEN` (ordered s6…s1) wrongly — rootStr 5 reads the B string's pc, rootStr 4 reads G's; A-shape C major computes fret 1 instead of 3. Use the ChordDiagram convention `6 - rootStr`. Affects fret labels in ChordDetailModal/ExplorePanel/CurrentJamPanel for rootStr 4/5 movable shapes; ChordDiagram itself is correct. Also fix the `playChord` docstring octave example in `chordAudio.js` (Critic nit) | engineering | done | — | `src/lib/voicings.js`, `src/lib/chordAudio.js` (docstring only) | A-shape/C-shape fret labels correct (hand-verify 3 shapes × 2 keys); build + smoke green |
|
||||||
|
| D-22 | `LickCard.jsx` — tab-style SVG lick renderer: ordered notes on a string×fret window, technique glyphs (h, p, ⌒ slide, ↑ bend), compact card + technique legend; graceful on empty/malformed | design | done | C-20 | `src/components/LickCard.jsx` | renders every P-21 lick; glyph legend readable at card size; tokens; AA contrast |
|
||||||
|
| L-22 | Knowledge Center shell per D-20: restructure how JamGuide/ExplorePanel/EducationPanel mount into the unified IA; add the **level filter chip** (foundation/intermediate) wherever progressions list; mount Licks section (LickCard grid per style) | engineering | done | D-20, D-22, P-20, P-21 (licks commit may land empty-state-first per the D-20 doc). Ride-along polish (D-22 gate nit): LickCard stack-collision guard should check all same-column notes, not just prev (`LickCard.jsx:100`) | `src/components/JamGuide.jsx`, `src/components/ExplorePanel.jsx`, `src/App.jsx` (mount only) | unified structure live; level filter works; licks browsable per style; audio contract intact; build + smoke green |
|
||||||
|
| P-22 | KB expansion: **jazz piano** cell (first piano cell per `docs/kb-backlog.md`) — authored comp voicings override computed where they add signature value | content | done | L-11 | `src/data/kb/jazz/piano.js`, `src/data/kb/index.js` | validator green + registered (SCHEMA "Piano play" recipes); **rendering the authored pack is L-24, not this task** — status: done |
|
||||||
|
| C-22 | `checkPianoRecipe` span check: enforce SCHEMA rule 3 (each hand ≤ a 10th = 15 semitones) in the validator so future piano cells don't rely on hand-verification (P-22 gate follow-up) | quality | done | P-22 | `scripts/validate-kb.mjs`, `scripts/smoke.mjs` | span violation fails validator; existing KB green; smoke proves the check bites |
|
||||||
|
| D-23 | VoicingBrowser `show="guitar"\|"piano"\|"both"` prop (default both — zero change for existing mounts) + LickCard stack-collision guard checks ALL same-column notes not just prev (D-22 gate nit, `LickCard.jsx:100`) | design | done | D-21, D-22 | `src/components/VoicingBrowser.jsx`, `src/components/LickCard.jsx` | prop-gated rows; all existing mounts render identically with no prop; guard fixed; build + smoke green |
|
||||||
|
| L-25 | ChordDetailModal Guitar/Piano tabs pass `show="guitar"`/`show="piano"` to VoicingBrowser (JamGuide station-enlarge + Voicings section keep both rows) | engineering | done | D-23 | `src/components/ChordDetailModal.jsx` | tabs show only their instrument's row; build + smoke green |
|
||||||
|
| D-24 | MiniPiano voicing-path compact window: crop the keyboard to the voicing's octave span (+1 key padding each side) so Jam Guide station thumbs aren't ~390 px (L-11 gate observation); `size="full"` and the legacy lh/rh path byte-unchanged | design | done | D-10 | `src/components/MiniPiano.jsx` | thumbs materially narrower for ≤1-octave voicings; root/bass marks intact; legacy consumers render identically; build + smoke green |
|
||||||
|
| P-24 | Licks pack #2: 3–4 licks each for **rock, country, reggae** guitar (C-20 schema; same bar as P-21 — playable as written, honest sources) | content | done | C-20, P-21 | `src/data/kb/rock/guitar.js`, `src/data/kb/country/guitar.js`, `src/data/kb/reggae/guitar.js` | validator green with ~21+ licks; every note serves its chordContext (Critic verifies) |
|
||||||
|
| L-24 | JamGuide piano branch prefers an authored piano pack (SCHEMA piano recipes) over computed `pianoVoicingChain` when the style has one; fall back to computed otherwise. Recipe→notes convention: order inside each hand = voicing order low→high (documented at `jazz/piano.js:14`) | engineering | done | P-22, L-22 | `src/components/JamGuide.jsx` | jazz piano stations render the authored comp; other styles keep computed voicings; build + smoke green |
|
||||||
|
| P-23 | KB expansion: **gospel piano** cell | content | done | P-22 | `src/data/kb/gospel/piano.js`, `src/data/kb/index.js` | validator green; renders in the piano tab |
|
||||||
|
| C-21 | Sprint-end sweep: full build + validator + smoke, cross-browser sanity notes, PR body draft summarising the sprint for `main` | quality | done | L-21, L-22, P-21 | (none — verification) | all green; PR body ready |
|
||||||
|
|
||||||
|
> Sequencing: L-11 ‖ D-20 ‖ L-20 ‖ C-20 are all file-disjoint and ready now. Then D-21 → L-21, and P-20/P-21/D-22 → L-22. P-22/P-23 fill spare iterations. Critic gates every task as usual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shipped sprint: `sprint-jamguide-piano` (branch: `sprint-jamguide-piano` — piano resolver phase complete; L-11 carried into the sprint above)
|
||||||
|
|
||||||
|
**Goal:** extend the shipped Roadmap Jam Guide to **piano** (guitar→piano→bass roadmap). When the Piano instrument tab is selected, each Roadmap station shows a *generated* piano voicing on a mini-keyboard — voicings computed from the progression's `degrees`+`qualities` (GOAL G4: generate piano voicings from interval recipes; no authored data needed for the MVP). Branch cut off `sprint-jam-guide` HEAD. Weights: Luthier 3, Muse 2, Critic gate.
|
||||||
|
|
||||||
|
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
||||||
|
|----|-------|--------|--------|-----------|--------------|--------------------|
|
||||||
|
| M-02 | Cut `sprint-jamguide-piano` branch off `sprint-jam-guide`; seed sprint | maestro | done | — | (branch) | branch cut, ledger seeded |
|
||||||
|
| L-10 | `src/lib/piano.js` — pure piano voicing resolver: `pianoVoicing({rootPc, quality}, opts?)` → shell (1-3-7), rootless A/B (3-5-7-9 / 7-9-3-5), root-position; picks the inversion minimizing movement from a previous voicing (voice-leading); returns absolute key positions + pcs + a label | engineering | done | M-02 | `src/lib/piano.js` | pure fns; correct tones for all 14 CHORD_TYPES; voice-leading-minimal vs a prev voicing; build + smoke green; output shape documented for D-10/L-11 |
|
||||||
|
| D-10 | **Extend** existing `src/components/MiniPiano.jsx` (already imported by `ChordDetailModal`/`ExplorePanel` via `{rootPc, lh, rh}`) with an ADDITIVE `voicing` prop that renders a `pianoVoicing` output (`{notes,pcs,bass,style,label}`) on a 2–3 octave keyboard — root in accent, bass marked, thumb/full sizes; **legacy lh/rh path unchanged** | design | done | L-10 | `src/components/MiniPiano.jsx` | renders any `pianoVoicing` in any key; **ChordDetailModal + ExplorePanel render identically (legacy path untouched)**; build green; design tokens; AA contrast |
|
||||||
|
| L-11 | Wire piano into `JamGuide.jsx` | engineering | **carried → sprint-knowledge-center** | L-10, D-10 | — | see the active sprint table above (hold lifted by user 2026-07-08) |
|
||||||
|
| C-10 | Extend `scripts/smoke.mjs`: assert `pianoVoicing` returns a valid voicing for every KB quality (no crash, notes in range, contains the chord's defining tones); also assert no duplicate absolute note across styles (the sus2 forced-rootless nit from L-10 gate) | quality | done | L-10 | `scripts/smoke.mjs` | smoke covers the piano resolver; exits non-zero on regression — **currently red (1/776): surfaces the sus2 dup-note nit → L-13 fixes the resolver** |
|
||||||
|
| L-13 | Resolver dedupe: `pianoVoicing` must never emit a **duplicate absolute note** (sus2 forced-`rootlessB` collides pc2 → `notes=[26,26,31]`). Drop/merge collided notes so every voicing's `notes` are distinct; keep `pcs`/`bass` valid; default + chain paths unchanged | engineering | done | L-10 | `src/lib/piano.js` | no duplicate absolute note for any quality×style; C-10 smoke goes green (776/776); build green; existing 56-voicing correctness preserved |
|
||||||
|
|
||||||
|
> Piano authored packs (signature comps overriding computed voicings) are deferred to a later sprint — computed voicings suffice for the piano MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shipped sprint: `sprint-jam-guide` (branch: `sprint-jam-guide`, PR #2 — complete)
|
||||||
|
|
||||||
|
Emphasis this sprint: **ship the Jam Guide MVP** (put the 8 guitar style packs on screen, synced to live detection) + **reframe the repo as a learning platform**. Weights this sprint: Luthier 3, Muse 3 (design-heavy), Professor 2, Herald 2, Critic gate.
|
||||||
|
|
||||||
|
**Design-first gate (user directive 2026-06-14):** Muse explored three distinct concepts; **user chose "Roadmap"** (improv-first highway) on 2026-06-15. Implementation tasks below are re-planned to build Roadmap. Concept doc: `docs/design/jam-guide-concept-c.md`. Stage/Playbook (`-a`/`-b`) kept as reference for future modes.
|
||||||
|
|
||||||
|
**Roadmap build shape:** a horizontal timeline panel — stations (= loop chords) carry guide-tone lanes (3rd/7th), a solo-scale label, and voice-leading rails (7→3) between stations; a playhead + beat grid track loop position; the chosen voicing is a secondary thumbnail per station; tap a station → guide tones on the fretboard. Needs derived theory (`guideTones`, `voiceLeadingPairs`, default `soloScale`) — Luthier owns those.
|
||||||
|
|
||||||
|
| id | title | domain | status | depends-on | files (lock) | definition of done |
|
||||||
|
|----|-------|--------|--------|-----------|--------------|--------------------|
|
||||||
|
| M-01 | Cut `sprint-jam-guide` branch; seed sprint | maestro | done | — | (branch) | branch cut, ledger header set |
|
||||||
|
| D-00a | Viz concept A — "Stage" | design | done | — | `docs/design/jam-guide-concept-a.md` | delivered; reference for future collapsed/glance mode |
|
||||||
|
| D-00b | Viz concept B — "Playbook" | design | done | — | `docs/design/jam-guide-concept-b.md` | delivered; reference for future study mode |
|
||||||
|
| D-00c | Viz concept C — "Roadmap" | design | done | — | `docs/design/jam-guide-concept-c.md` | delivered; **CHOSEN** |
|
||||||
|
| D-SEL | User selects a concept | maestro | done | D-00a, D-00b, D-00c | `docs/agents/LEDGER.md` | **Roadmap chosen 2026-06-15**; tasks re-planned below |
|
||||||
|
| L-01 | Shared matcher: extract/author `findLoopPosition` (which chord index the loop is on) + rotation-invariant degree-relative loop→progression-id matcher into `src/lib/match.js`; refactor `ProgressionBanner.jsx` to use it | engineering | done | M-01 | `src/lib/match.js`, `src/components/ProgressionBanner.jsx` | matcher maps detected loop → style progression id (rotation-invariant) AND returns current position index for the playhead; banner still works; build green — VERIFY: `npm run build` green; banner renders identically (findLoopPosition now imported from match.js); matchLoopToProgression rotation-invariant + quality tie-break (smoke: ii–V–I & its rotation → jazz-251-major rot 0/2; minor 251 → jazz-251-minor; chromatic → no match) |
|
||||||
|
| L-01b | Derived theory for Roadmap: add `guideTones(chordRoot,quality)` (3rd & 7th pcs), `voiceLeadingPairs(chordA,chordB)` (7→3 / nearest-tone rails), and default `soloScale(quality,mode)` (Mixo/dom7, Dorian/m7, Ionian/maj7…) to `theory.js` — additive exports only | engineering | done | M-01 | `src/lib/theory.js` | pure functions, unit-sane (ii–V–I in C → C→B, F→E rails); reuses existing `getChordTones`; build green; no existing export changed |
|
||||||
|
| L-02 | `JamGuide.jsx` panel shell: mounts last in `App.jsx`, reads `kb/index.js`, instrument+style tabs from registry, collapsed header → ~70vh; receives live loop + position props | engineering | done | L-01 | `src/components/JamGuide.jsx`, `src/App.jsx` | panel renders, tabs from KB, matched progression or fallback; build green |
|
||||||
|
| D-01 | `RoadmapTrack.jsx` — the heart: horizontal stations (loop chords) with guide-tone lanes (3rd/7th dots), solo-scale label, voice-leading rails between stations, playhead + beat grid, lookahead highlight on the next station | design | done | L-01, L-01b | `src/components/RoadmapTrack.jsx` | renders a real ii–V–I & a 12-bar blues from KB; lanes computed from `guideTones`/`voiceLeadingPairs`/`soloScale`; design tokens; AA contrast |
|
||||||
|
| D-01b | `ChordDiagram.jsx` — secondary per-station voicing thumbnail (movable `rootStr`+`offsets`, open `frets`+`onlyRoot`), key-aware fret placement, tap-to-enlarge | design | done | L-01 | `src/components/ChordDiagram.jsx` | renders any KB guitar shape in any key; chord-tone colour tier; compact thumbnail + enlarged states |
|
||||||
|
| D-02 | Roadmap assembly & live polish in `JamGuide.jsx`: place `RoadmapTrack` + thumbnails, animate playhead against BPM/onset, narrow-viewport reflow, tap-station → fretboard guide tones | design | done | L-02, D-01, D-01b | `src/components/JamGuide.jsx` (assembly/styling), `tailwind.config.js` | playhead tracks position; reflows on narrow; keyboard-reachable; AA contrast |
|
||||||
|
| D-03 | Finish the flagship: tap a Roadmap station → highlight that chord's guide tones (3rd/7th) on the **main Fretboard**. Lift a `jamFocusChord` state into `App.jsx`, `JamGuide` emits the selected station's `{rootPc, quality}` via a callback prop, `Fretboard` highlights those guide tones reusing its existing tier rendering. **Returned by Critic** → also fix root cause `guideTones` so 4-tone non-seventh chords (add9/maj6/min6) don't badge their 5th/6th as a "7" | engineering | done | D-02 | `src/App.jsx`, `src/components/JamGuide.jsx`, `src/components/Fretboard.jsx`, `src/lib/theory.js` | tapping a station lights its 3rd/7th (honest secondary anchor for non-7th chords); build green; smoke green; **audio-callback contract intact**; RoadmapTrack lane also corrected; keyboard-reachable; clears on deselect |
|
||||||
|
| P-01 | Gospel guitar KB cell | content | done | — | `src/data/kb/gospel/**`, `src/data/kb/index.js` | 5 progressions × 2 plays, validator green (run `/kb-expand`) |
|
||||||
|
| P-02 | Pop guitar KB cell | content | done | P-01 | `src/data/kb/pop/**`, `src/data/kb/index.js` | 5 progressions × 2 plays, validator green |
|
||||||
|
| H-01 | README reframe: lead with the learning-platform vision (detect → guide → learn), screenshot/gif placeholder, link `GOAL.md` + `docs/agents/` | community | done | — | `README.md` | README states the dual vision; links resolve |
|
||||||
|
| H-02 | `CONTRIBUTING.md` + issue templates: the data-contract path so a musician can PR a style without coding (point at `src/data/kb/SCHEMA.md`) | community | done | H-01 | `CONTRIBUTING.md`, `.github/ISSUE_TEMPLATE/**` | a non-coder can follow "add a style"; `/kb-expand` documented as the assisted path |
|
||||||
|
| C-01 | Smoke-test harness: a `scripts/smoke.mjs` that imports `kb/index.js` + runs the matcher on sample loops, wired alongside `validate-kb.mjs` | quality | done | L-01 | `scripts/smoke.mjs` | catches a broken matcher/registry before merge; documented in PROTOCOL gate |
|
||||||
|
|
||||||
|
> Critic (C-) reviews every L-/D-/P-/H- task as it reaches `in-review` — those reviews are the gate, not separate ledger rows, except where new test infra is itself the deliverable (e.g. C-01).
|
||||||
|
|
||||||
|
> GATE 2026-06-15 (Critic) · **L-01 PASS** — build green, validator green (8 styles/40 progs/80 plays). `findLoopPosition` extracted byte-identical (verified by diff, no semantic change). matchLoopToProgression verified rotation-invariant with quality tie-break: `[Dm7,G7,Cmaj7]`→`jazz-251-major` rot 0; rotation `[G7,Cmaj7,Dm7]`→ rot 2; `[Dm7b5,G7,Cm7]`→`jazz-251-minor` (tie-break works); chromatic→clean `{matched:false}`. rotation index correctly points at the loop slot aligning with KB degrees[0]. `match.js` holds only matching/position logic + a small local chord-root parser (theory.js exposes no pc helper — acceptable). Note (non-blocking): banner refactor also adds `onChordClick` click affordances — beyond a pure extract but already wired to `setSelectedChord`/ChordDetailModal in App.jsx and behaviour-preserving for `findLoopPosition`. Minor: `match.js` uses extensionless `import … from './theory'` (works under Vite; matches existing `education.js` convention).
|
||||||
|
> GATE 2026-06-15 (Critic) · **L-01b PASS** — build green. theory.js diff is purely additive (zero `-` lines; no existing export touched). Verified by running the real functions: ii–V–I in C → **Dm7→G7 = C→B (−1)** and **G7→Cmaj7 = F→E (−1)** rails (plus intentional 0-semitone common-tone rails). `guideTones` triad fallback flags `hasSeventh:false` and uses the 5th. `soloScale` defaults sane: dom7→mixolydian, min7→dorian, maj7→major(ionian), half_dim→locrian, dom7/minor→phrygian-dominant. Functions are pure and reuse CHORD_TYPES/SCALES.
|
||||||
|
> GATE 2026-06-15 (Critic) · **L-02 PASS** — build green; validator green (8 styles/40 progs/80 plays). 🚨 **Audio contract INTACT**: `git diff HEAD -- src/App.jsx` is +10 lines, zero deletions — only the `import JamGuide` line and the `<JamGuide …/>` mount (placed last, after the tuner block). `handleNote`/`handleChroma`/`handleOnset` lines do not appear anywhere in the diff; no `useCallback` dep, ref-sync `useEffect`, or `AudioCapture` prop was touched. Black-screen/infinite-restart risk avoided. All 5 props passed (`detectedProgression`, `effectiveKey`, `chordHistory`, `bpm`, `currentChord`) exist in App scope. Tabs derive from the KB registry (instrument availability from `style.instruments` keys, style labels from `meta.label`); matched-progression vs. dashed-fallback both render. Disabled instrument tabs marked "soon" with `disabled` + title.
|
||||||
|
> GATE 2026-06-15 (Critic) · **D-01 PASS** — RoadmapTrack renders from props only (pure/presentational). Verified against the real KB: guide-tone honesty confirmed by running `guideTones` — C-major **triad** returns `hasSeventh:false`, secondary anchor = the 5th (pc 7), and the Station labels it **"5th"** not "7th" (`seventhKind` gate at line 113). The headline rail picks `voiceLeadingPairs(...)[0]` = the 7→3: Dm7→G7 ⇒ C→B (−1), G7→Cmaj7 ⇒ F→E (−1). BeatGrid station-start indexing is canonical-order-consistent with the stations array; playhead `pct` clamps at 0 when `position=-1`. Tokens used; `aria-current`, `role=progressbar` + `aria-valuetext`, focus rings present. Malformed/empty progression → graceful "no loop to map" card, no crash.
|
||||||
|
> GATE 2026-06-15 (Critic) · **D-01b PASS** — ChordDiagram movable base-fret formula verified independently: **E-shape A7 → fret 5** ✓ (rootStr 6, rootPc 9: `((9−4)%12)=5`); A-shape C → fret 3 ✓; open-string root → octave barre (12), a sane degradation. Root-tone coloured in accent tier (`isRoot` → `#a855f7`), non-root dots light-gray (AA on dark board). Both `thumb`/`full` sizes; `full` adds finger numbers + label. Undefined/unplaceable shape (root > fret 15 or `!shape`) → graceful "—" placeholder, no crash — covers the empty-thumbnail path. Open vs movable forms both handled.
|
||||||
|
> GATE 2026-06-15 (Critic) · **P-01 PASS** — validator green: `✓ KB valid — 9 style(s), 45 progressions, 90 plays` (gospel now included); `npm run build` green (gospel imported by `kb/index.js`, 87 modules, built in 5.65s). Schema: 5 progressions × 2 plays = 10 plays, all ids `gospel-`-prefixed, rn/qualities/bars all length-consistent with degrees, qualities all in CHORD_TYPES (min7/dom7/maj7/maj/add9/sus4/dim7). No duplicate id across all 45. Registered in `index.js` mirroring the other 8 styles (no broken import). **Voicings hand-verified** (independent pc computation matching the validator's): SHELL_6.maj7 → {0,11,4}=R-7-3 ✓; SUS4_5 → {0,5,7}=R-4-5 ✓; DIM7_5 → {0,6,9,3}=R-♭5-𝄫7-♭3 symmetric ✓; ADD9_6 → {0,2,4,7} full add9 (9th present) ✓; MIN9_5 → {0,2,3,7,10} full min9 ✓ — confirms the validator genuinely exercises these shapes via `checkGuitarShape`. **Theory** sound: backdoor ♭VII7=pc10 rises a whole step to I; #iv°7=pc6 passing chord; 6-2-5-1 + plagal IV-I Amen tag; cascading cycle-of-dominants all correctly spelled. **Authenticity** honest: attributions hedged appropriately ("traditional gospel turnaround", "reharmonized", "Every Praise … vamp feel", "e.g. Tenderly bars 5-7" as a back-door reference) — no claim that a specific recording uses an exact voicing; no music-theory error in any tip (Dorian-over-min7, Mixo-over-dom7, sus-non-resolution advice all correct). *Non-blocking nit*: a few shape comments list tones in interval order, not low-to-high string order (e.g. MAJ9_6 "R–3–7–9" sounds R-9-3-7) — labeling nicety, all tones present and correctly spelled; not a defect.
|
||||||
|
> GATE 2026-06-15 (Critic) · **H-01 PASS** — every relative link resolves on disk: `GOAL.md`, `CLAUDE.md`, `src/data/kb/SCHEMA.md`, `src/data/kb/jazz/` (dir), `docs/agents/` + ROSTER/PROTOCOL/LEDGER all exist; `CONTRIBUTING.md` is correctly referenced as **"coming (tracked as task H-02)"** and does NOT exist on disk — honest, not a dead live-link. Every command real: `electron:dev`, `dev`, `electron:build:win|mac|linux` all in `package.json`; `scripts/validate-kb.mjs` exists. **No `frontend/` path remains**; build output dir stated as `release/` matches electron-builder `directories.output: "release"`. git clone URL `whattheflat/whattheflat.git` matches actual `origin`. **Honesty verified**: gospel described as "landing next" (not shipped, still in-review at review time); Piano/bass packs, Progression Builder, detection-powered drills all under **In progress**, not today; Roadmap Jam Guide listed as working-today (true — D-02 passed, MVP live). Leads with detect → guide → level up; links GOAL.md + docs/agents/. No overstatement found.
|
||||||
|
> GATE 2026-06-15 (Critic) · **P-02 PASS** — gated against the FINAL combined tree (P-02/H-02/C-01 merged). `node scripts/validate-kb.mjs` → `✓ KB valid — 10 style(s), 50 progressions, 100 plays` (pop registered in `index.js`; the transient "pop not registered" mid-batch failure is resolved — `index.js` imports popMeta/popProgressions/popGuitar and exports the `pop` block). Schema: 5 progressions × 2 plays = 10, all ids `pop-`-prefixed, no duplicate across all 50 (verified). **Voicings hand-computed** (independent pc calc): OPEN_CADD9 → {0,2,4,7} = R-9-3-5, the 9th (pc2) genuinely present (true add9, not plain C) ✓; OPEN_G/OPEN_F → {0,4,7} major ✓; BARRE_MAJ_6 (E-shape) {0,4,7}, BARRE_MIN_5 (Am-shape) {0,3,7}, BARRE_MAJ_5 (A-shape) {0,4,7} ✓ — confirms validator exercises the shapes. **Non-duplication judged ACCEPTABLE**: pop-axis (0-7-9-5 maj) shares shape with rock-axis/reggae-nwnc and pop-mixo-bVII (0-10-5 mixo) with rock-mixo-vamp, but ids differ, songs differ (No Woman No Cry/Adele/Beatles vs rock), and guitar plays are capo-and-open framed vs rock's barre framing — a real genre reuse with distinct content, not a duplication smell; differentiation rationale documented in the progressions.js header. **Honesty CONFIRMED**: every sus2/sus4 mention is prose (note/tips/styleIntro) framed as a hammer-on/pull-off fill over a held chord ("the chord stays major, your finger wiggles"); zero sus in the data — all pop-mixo chords are voiced plain `maj`; tips never claim a voicing the data lacks. Song attributions hedged (Clocks "♭VII colour", Royals "verse", Memories "Pachelbel, set in B").
|
||||||
|
> GATE 2026-06-15 (Critic) · **H-02 PASS** — every relative link resolves on disk: GOAL.md, README.md, src/data/kb/SCHEMA.md, gospel/{meta,progressions,guitar}.js, index.js, src/lib/theory.js, scripts/validate-kb.mjs, docs/agents/ROSTER.md, and all three issue templates (add-style/bug_report/feature_request) + config.yml — verified via on-disk existence check. Every command real (npm install / electron:dev / dev / build / node scripts/validate-kb.mjs all in package.json). **Schema fidelity exact**: the quality list `maj,min,dom7,maj7,min7,dim,dim7,half_dim,aug,sus4,sus2,maj6,min6,add9` matches `CHORD_TYPES` keys byte-for-byte; modes list matches the validator's MODES; degrees-0-11, movable(rootStr/offsets) vs open(frets/onlyRoot), and extensions/rootless/omit3 explanations all match SCHEMA.md + validator logic — no invented field, no drift. LICENSE correctly NOT added (CONTRIBUTING honestly states "doesn't have a license file yet … reuse terms aren't settled"). Issue templates present and sane. *Non-blocking nit*: CONTRIBUTING's illustrative validator-output sample reads `9 style(s)/45/90` (true at authoring time, pre-pop) — it's a "what a passing run looks like" example, not a load-bearing claim; the instructions don't depend on the count. Worth a one-char refresh to 10/50/100 someday, not a return.
|
||||||
|
> GATE 2026-06-15 (Critic) · **C-01 PASS** — `node scripts/smoke.mjs` → `✓ Smoke test passed — 71/71 checks green (10 styles, 50 progressions)`, exit 0; reports the FULL final KB (10 styles, derived from the loaded registry, not hardcoded). Imports the REAL `kb/index.js` + `match.js` (no mocks); asserts registry integrity (every style meta/progressions[]/instruments.guitar, per-prog degrees/qualities/rn/bars consistency, cross-KB id uniqueness) and matcher correctness (ii–V–I matched:true + structural rebased-degree-shape check, rotation invariance to the SAME derived id with non-zero rotation, axis match guarded with skip-if-absent, chromatic → matched:false/id:null, findLoopPosition in-range/-1/-1-on-empty). Exits non-zero on failure (mirrors validate-kb). **Adversarial proof**: sabotaged `matchLoopToProgression` to always return no-match → smoke FAILED 4 checks, **exit code 1** (assertions are NOT too weak — a broken matcher cannot pass). match.js restored, no diff. **Resolve hook judged SOUND**: the inline `module.register` ESM hook retries ONLY on `catch`, ONLY for relative+extensionless specifiers (`/^\.{1,2}\//` && `!/\.[mc]?js$/`) — verified the regex skips `./theory.js`, `react`, `node:url`, `./a.mjs` and retries only `./theory`/`../lib/match`; it cannot mask a genuine missing module (the `.js` retry itself surfaces the real ERR_MODULE_NOT_FOUND). Correctly chosen over editing match.js (locked to L-01). **No npm script added** (validate-kb has none) — `package.json` untouched (git: only `src/data/kb/index.js` is modified; smoke.mjs/pop/CONTRIBUTING/templates are new untracked). No hardcoded fragile ids — expected ids derived from the loaded KB; survives KB growth.
|
||||||
|
> GATE 2026-06-15 (Critic) · **D-03 FAIL (returned)** — build green (vite, 90 modules, 10.66s); validator green (`✓ KB valid — 10 style(s), 50 progressions, 100 plays`, no KB change). 🚨 **Audio contract INTACT**: `git diff -- src/App.jsx` is purely additive — exactly (a) one new `useState(jamFocusChord/null)` placed in the UI-state block (outside all audio-callback/ref logic, with a comment that it is not read by any callback), (b) `jamFocusChord={jamFocusChord}` on the existing `<Fretboard>` mount, (c) `onFocusChord={setJamFocusChord}` on the existing `<JamGuide>` mount. Grep of the **changed lines** for `handleNote|handleChroma|handleOnset|useCallback|AudioCapture|Ref|useEffect` → **NO MATCHES**. No callback, ref, ref-sync useEffect, or AudioCapture prop touched. Black-screen/infinite-restart risk avoided. **Wiring correct**: JamGuide emits `{rootPc, quality:qualities[i]}` (station's actual KB quality) on select, `null` on deselect; the existing `selectedStation→null` reset effect (loop/style/instrument change) flows through the new cross-link effect and clears the highlight; an unmount-cleanup effect also clears; all guarded `onFocusChord?.` so JamGuide stays standalone. Fretboard **null path verified unchanged** (`hasFocus=false` ⇒ `tag` always null ⇒ `if(!color&&!tag)` collapses to original `if(!color)`; no halo/badge/legend). Emphasis is visually distinct (purple ring `DOT_R+3` + degree badge, accent token `#a855f7`). Triad labeling **correct** for maj/min/dim/aug/sus4/sus2 (all `hasSeventh:false` → badge/legend read "5"). **DEFECT (blocking)**: `guideTones`' secondary-anchor heuristic is `hasSeventh = ints.length >= 4`, so the **4-tone non-seventh** KB qualities `add9` `[0,2,4,7]`, `maj6`/`min6` `[0,4,7,9]` return `hasSeventh:true` with `seventh = ints[last]` = the **5th** (add9) or **6th** (maj6/min6). D-03's `focusLabel` trusts that boolean and badges those tones **"7"**, legend reads **"3 / 7"** — a perfect fifth / sixth rendered to the user as a seventh on the flagship Fretboard. **Reachable**: `add9` is in gospel progression qualities (×4), `min6` in bossa + rnb qualities — tapping those stations mislabels a non-7th. Verified by running `guideTones(0,'add9')→{third:4,seventh:7,hasSeventh:true}` (G badged "7"), `guideTones(0,'min6')→{third:3,seventh:9,hasSeventh:true}` (A badged "7"). The DoD's "the chord's guide tones (3rd/7th)" / "badge reads 5 not 7 for a triad" contract is violated for these KB-present qualities. **Fix direction (return to Luthier):** make the secondary anchor honest for extended non-7th chords — either tighten `guideTones` so a chord with no minor/major 7th reports `hasSeventh:false` and falls back to the 5th (root cause, L-01b owns theory.js), or have `focusLabel`/the badge derive the actual interval of `seventh` above root (10/11 ⇒ "7", 9 ⇒ "6", 7 ⇒ "5", 2 ⇒ "9") rather than trusting the boolean. Re-gate after fix.
|
||||||
|
> GATE 2026-06-15 (Critic) · **D-03 PASS (re-gate)** — the returned defect is fixed at the root cause and hardened. `npm run build` green (vite, 90 modules, built in 3.25s); `node scripts/smoke.mjs` → `✓ Smoke test passed — 71/71 checks green (10 styles, 50 progressions)`, exit 0; `node scripts/validate-kb.mjs` → `✓ KB valid — 10 style(s), 50 progressions, 100 plays` (no KB change). **Root-cause fix VERIFIED by running the real `guideTones`**: `hasSeventh` now keys on the actual presence of interval 10 or 11 (`ints.find(i=>i===10||i===11)`), not `length>=4`. Independent truth table (root C): add9→{third:2,seventh:7,**hasSeventh:false**,anchor=5th G}; maj6→{4,7,false,5th}; min6→{3,7,false,5th}; dom7→{4,10,true,7=A♯}; maj7→{4,11,true,7=B}; min7→{3,10,true,7=A♯}; triads maj/min/sus4/sus2→false,anchor 5th(7); dim→anchor ♭5(6); aug→anchor #5(8); dim7→♭5(6); half_dim→true,10. Return shape `{third, seventh, root, hasSeventh}` **unchanged** (keys verified). **Badge hardening (Fretboard) VERIFIED**: `focusSeventhLabel` derives the label from the *actual* interval of the anchor pc above the chord root (10/11→"7", 9→"6", 8→"♭6", 7→"5", 6→"♭5"), so a wrong boolean can't mislabel — adversarial sweep of all 14 KB qualities × keys {3,7,11}: a "7" badge appears iff the interval is truly 10/11; **NO MISLABEL**. Unknown quality → `maj` fallback, no crash. **Null-focus path byte-equivalent**: `jamFocusChord=null` ⇒ `hasFocus=false` ⇒ `tag` always null ⇒ `if(!color&&!tag)` collapses to the original `if(!color) return null`; no halo/badge/legend; `focusSeventhLabel` is dead-but-harmless. **No collateral**: `voiceLeadingPairs`/`soloScale` signatures untouched (theory.js diff hits only `guideTones` body+jsdoc); ii–V–I rails still correct (Dm7→G7 C→B [0→11,−1]; G7→Cmaj7 F→E [5→4,−1]) and `voiceLeadingPairs` does NOT crash for the now-5th-anchored add9/maj6/min6 (Cadd9→G7, Cmaj6→F, Amin6→Dm7 all return smooth rails). match.js/RoadmapTrack/ChordDiagram/KB untouched (`git diff --name-only` = only the 4 declared D-03 files). 🚨 **Audio contract INTACT**: `git diff -- src/App.jsx` purely additive (one `useState(jamFocusChord/null)` outside the callback/ref block + an additive `jamFocusChord=` prop on `<Fretboard>` + `onFocusChord=setJamFocusChord` on `<JamGuide>`); grep of changed lines for `handleNote|handleChroma|handleOnset|useCallback|AudioCapture|Ref|configRef|useEffect` → NO MATCHES; the sole "−" line is the Fretboard mount being re-emitted with the added prop. *Non-blocking observation (pre-existing, out of D-03 scope):* `guideTones` uses `ints[1]` for the 3rd, so `add9` `[0,2,4,7]` reports its **9th (pc 2)** as `third`, not the major 3rd (pc 4) — true at L-01b time, unchanged by this fix, and D-03's bug was strictly the seventh/secondary anchor; worth a future tidy, not a return.
|
||||||
|
> GATE 2026-06-15 (Critic) · **L-10 PASS** — (re-gated cleanly after the prior dispatch died on an API error pre-verdict.) `npm run build` green (vite 7.3.1, 90 modules, built in 2.24s); `node scripts/smoke.mjs` → `✓ Smoke test passed — 71/71 checks green (10 styles, 50 progressions)` exit 0; `node scripts/validate-kb.mjs` → `✓ KB valid — 10 style(s), 50 progressions, 100 plays`. **Purity confirmed** by reading the full module: exports `pianoVoicing({rootPc,quality},opts?)`→`{notes,pcs,bass,style,label}` plus `pianoVoicingChain`, `hasTrueSeventh`, `voicingToneSet` (and re-exports `guideTones`); imports ONLY `{CHORD_TYPES, guideTones}` from `./theory`; no `Date`/`Math.random`/module-level mutable state — determinism verified (every quality voiced twice → byte-identical). **Music verified myself** via a throwaway ESM script (deleted): swept **all 14 CHORD_TYPES × 4 styles = 56 voicings** at rootPc 0 — every `pcs ⊆ voicingToneSet(...)` (**NO out-of-chord note**), real 3rd present (sus→2/5), every TRUE 7th (interval 10/11: dom7/maj7/min7/half_dim) present in shell+rootless, all `notes ∈ [0,36]` (≤3 octaves), `bass=min(notes)`. Spot-checks exact: **Cmaj7 shell→{0,4,11}** ✓, **Dm7 rootlessA→{5,9,0,4}=F-A-C-E** ✓, **G7 shell→{7,11,5}** ✓. **Awkward qualities honest**: half_dim shell→{0,3,10} (true ♭7 kept), rootlessA→{3,6,10,2} "3-5-7-9" ✓; **dim7 correctly NOT a true-7th chord** — its 7-slot is interval 9 (𝄫7 = enharmonic maj6, not 10/11), so `hasTrueSeventh('dim7')=false`, shells as R-3-5 {0,3,6}, no fabricated 7th; **min6/maj6** (no true 7th) → root {0,3,7,9}/{0,4,7,9}, rootless labels honestly read "3-5-6-9" using the 6th as upper colour — no wrong note. **Voice-leading confirmed**: `pianoVoicingChain([Dm7,G7,Cmaj7])` total semitone motion **16 vs 18** unthreaded (`opts.prev` slides whole shapes by octaves toward the previous voicing); pcs **unchanged** by threading (per-chord pc sets identical to unthreaded — re-registered only, never corrupted). **Adversarial**: bogus/empty/null chord → graceful `maj` fallback, no crash. **One non-blocking nit (NOT a defect):** forcing `rootlessB` (or `rootlessA`) explicitly onto a **sus2** chord produces a cosmetic duplicate absolute note (`notes=[26,26,31]`) because the suspended-2nd "3rd-slot" tone (pc2) and the synthesized 9th (pc2) collide — still no wrong pc, in range, and honestly labeled "no 7th, 9-3-5 colour". It is **unreachable on the shipping path**: `defaultStyle(sus2)='root'` and L-11/`pianoVoicingChain` use defaults, so sus2 renders as clean root `[0,2,7]`; the dup only appears under an explicit forced-rootless override of a sus chord (documented graceful degradation). Default-style sweep of all 14 qualities confirmed zero duplicate notes. Recommend a future tidy (dedupe collided absolute notes in the rootless fallback) — not a return. `src/lib/piano.js` NOT edited; throwaway script removed.
|
||||||
|
> GATE 2026-06-15 (Critic) · **D-02 PASS** — the load-bearing integration is correct. **Independent rotation check**: `canonicalPos = ((position − rotation) % n + n) % n` translates a `findLoopPosition` index (which is into the *detected loop*) into the KB-canonical station RoadmapTrack/stationVoicings render. Worked a rotated [ii V I VI] of KB [I VI ii V] (rotation=2, n=4): playhead@detected-idx1(V)→canonical 3(V), @2(I)→0(I), @3(VI)→1(VI), @0(ii)→2(ii) — NOW lands on the chord actually playing. ✓ **rootPc parity** confirmed: thumbnail `rootPc` and RoadmapTrack station root are byte-identical `(((keyRoot+deg)%12)+12)%12`, and `keyInfo.root` (a note *name*, e.g. `detectKey`→"F#") is converted once via `chordRootPC` → pc, guarded `>=0`. stationVoicings `chords[i]` aligns 1:1 with `degrees[i]` (canonical). Auto-scroll is prop-driven off `position` (no rAF on the audio thread). Reflows (`flex-wrap`/`sm:flex-nowrap`), keyboard-reachable, AA contrast. NO_MATCH path → dashed fallback, never empty/crash. *Non-blocking deferral*: the DoD's "tap-station → **fretboard** guide tones" cross-link and `tailwind.config.js` edit were not delivered — the implementation cross-links via an inline enlarged ChordDiagram instead (flagged in-code as deferred). Panel is fully functional without it; recommend Maestro file the fretboard cross-link as a phase-2 follow-up.
|
||||||
|
> GATE 2026-06-16 (Critic) · **D-10 PASS** — `npm run build` green (vite 7.3.1, 90 modules, built in 9.36s); `node scripts/smoke.mjs` → 776/776 exit 0; `node scripts/validate-kb.mjs` → 10 styles/50/100 (no KB change). **ADDITIVE confirmed** by reading the full diff: the public `MiniPiano` now dispatches `if (voicing) <VoicingPiano/> else <LegacyPiano/>`. The legacy `{rootPc,lh,rh}` path is the SAME code, only refactored to named constants holding the IDENTICAL hex values (`LH_BLUE=#3b82f6`, `ACCENT=#a855f7`, `WHITE_FILL=#f5f5f5`, `WHITE_STROKE=#374151`, `BLACK_FILL=#1f2937`, `BLACK_STROKE=#111827`) — geometry (OCTAVES=2, WW/WH/BW/BH, BLACK_OFFSETS), highlight map, R-label all unchanged. **Consumers render identically**: `grep` confirms `ChordDetailModal.jsx:86` and `ExplorePanel.jsx:85` both call `<MiniPiano rootPc lh rh />` with NO `voicing` prop → `voicing` is `undefined` → both hit `LegacyPiano` → byte-equivalent output. **Voicing path correct**: maps absolute `notes` (0=low C, [0,36]) to keys via `oct*12+pc`, root pc lights accent `#a855f7` + "R", bass key amber-ringed (`#fbbf24` AA), other tones soft accent `#c084fc`; OCTAVES auto-spans 2–3 to contain `max(notes)`. Worked example `pianoVoicing({rootPc:0,quality:'maj7'})` → default shell, pcs `{0,4,11}`=C-E-B (R-3-7): C lit accent+R, B & E soft purple, C bass-ringed — musically right. `role="img"`+`aria-label`. Design tokens used. No legacy regression, no colour/render bug.
|
||||||
|
> GATE 2026-06-16 (Critic) · **C-10 PASS** — `node scripts/smoke.mjs` → **776/776 exit 0** (was 71 pre-piano; +705 piano checks). Diff ADDED a piano block, kept ALL prior registry/matcher checks. Sweeps 14 qualities × {C,G} × {root,shell,rootlessA,rootlessB,default} asserting: valid shape (notes/pcs/bass/style/label), `pcs ⊆ voicingToneSet(quality,actualStyle)`, real 3rd (or sus tone) present, true 7th present in shell/rootless when `hasTrueSeventh`, `notes ∈ [0,36]`, `bass===min(notes)`, no-dup-note, determinism, and chain pcs-preservation. **Adversarially proven NON-vacuous**: inject pc1 into pcs → **125 fails, exit 1** (pcs⊆toneSet bites); `bass=max(notes)` → **140 fails, exit 1**; revert L-13 dedupe (`notes=placed`) → the no-dup check fails on exactly `sus2@0 [rootlessB] notes=[26,26,31]` + `sus2@7 [21,21,26]`, **exit 1**. So the suite now passes LEGITIMATELY because L-13 fixed the resolver, not because any check was weakened. piano.js restored to its L-13 diff after each sabotage (git: 9 insertions, clean).
|
||||||
|
> GATE 2026-06-16 (Critic) · **L-13 PASS** — minimal fix at the single funnel point in `pianoVoicing`: `const notes = [...new Set(placed)]` (dedupe ABSOLUTE notes), `pcs`/`bass` derived from the deduped array so both stay consistent. Verified via a throwaway resolve-hook ESM check (written + deleted): (a) `pianoVoicing({rootPc:0,quality:'sus2'},{style:'rootlessB'})` → `[26,31]` distinct (was `[26,26,31]`); (b) NO default-style voicing across all 14 qualities has a duplicate, so the dedupe is a pure **no-op on the default/chain path** — L-11's render is unaffected; ii-V-I chain `[14,17,24]|[19,23,29]|[24,28,35]` all distinct & voice-led. The full 56-voicing correctness is re-asserted by the now-776-green smoke (pcs⊆toneSet, 3rd, 7th, [0,36], bass for every quality×style). **Safety reasoning — dedupe NEVER drops a needed note**: a JS `Set` of absolute integers merges two entries only when they are the *identical integer*, i.e. identical pc AND identical octave — the *same physical key*, which can sound only one note anyway. Two musically-distinct intended tones (different pc, or same pc a different octave like 14 vs 26) yield different integers and are never merged. The sus2 collision is a genuine unison (pc2 stand-in 3rd at +24=26 vs synthesized 9th at +12=26, both pc2 same octave) — a redundant doubling, safe to drop. No information loss possible. build green; default + chain paths unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backlog (future sprints)
|
||||||
|
|
||||||
|
- **Retire `EducationPanel.jsx`** — confirmed orphaned (no importer anywhere in `src/`, D-20 gate verified); delete after the Knowledge Center ships. Fold `CurrentJamPanel`'s overlapping content (voicings/scales/similar progressions) into the Knowledge Center and retire it too.
|
||||||
|
- **Level-tag `FAMOUS_PROGRESSIONS`** (`src/lib/education.js`, Professor): famous-progression cards are exempt from the level filter this sprint (Maestro call 2026-07-08, per D-20 open question 1).
|
||||||
|
- **Drift guards in smoke (Critic, tiny):** two hand-synced duplications need permanent smoke checks — JamGuide.jsx's copy of the validator's `resolveDegree` (fold in the L-24 gate's 16-degree × 14-quality drift test) and the 8-word technique vocab duplicated between `validate-kb.mjs` `LICK_TECHNIQUES` and `LickCard.jsx` `TECHNIQUE_VOCAB` (C-21 finding).
|
||||||
|
- **`pianoVoicing` should return `rootPc` (Luthier, tiny):** callers currently must attach it themselves (JamGuide and VoicingBrowser both do); returning it at the source removes the false-"R" foot-gun for future consumers (D-24 gate observation — NOT a live bug, both call sites verified correct 2026-07-08).
|
||||||
|
- **`docs/kb-backlog.md` is stale (Professor, tiny):** still lists gospel/pop guitar + jazz/gospel piano as "todo" though shipped; P-30 (rnb piano) AND P-31 (blues piano, both 2026-07-10) need done entries — refresh next content iteration (flagged by P-30 + C-31 sweep, outside their locks).
|
||||||
|
- **`scripts/loop-fixtures.mjs` header comment drift (tiny):** cites "App.jsx:322-324" for adjacent-dup suppression; L-31 shifted it to ~line 364 — comment-only, fix when the file is next touched (C-31 sweep finding).
|
||||||
|
- **Try-this vs Suggested-progressions should show TWO DIFFERENT things (product direction, user 2026-07-13 — "keep as is for now… revise later"):** today both are chord/loop-relative but overlap conceptually. Intent: **Try this** = per-chord substitutions (an alternative to the CURRENT chord; built via `suggestSubstitutions`). **Suggested progressions** = currently same-style KB siblings (other real progressions in the style); the desired distinction is to make it **loop-level** — propose altered/reharmonized versions of the USER'S ACTUAL loop (apply `suggestSubstitutions` across every loop chord to generate modified progressions), i.e. the "computed modifications" option deferred earlier. The engine (L-73) now exists to power it. Keep same-style-siblings for now; revisit as a distinct feature.
|
||||||
|
- **TryThis duplicate-name-loop rotation (Luthier, small — L-74 gate 2026-07-13):** `loop.indexOf(currentChord)` returns the FIRST index, so a loop with a repeated chord name (e.g. a collapsed 12-bar where I7 recurs) double-advances the rotation (~2× pacing) and can mislabel Rule D's "next" chord. Subs for the current chord stay correct. Honest fix needs App to pass a true playhead station index (beyond L-74's mount-only lock) — wire it if TryThis ever couples to the loop engine.
|
||||||
|
- **TryThis/related category-tag contrast (Muse, tiny — L-75/L-76 gate 2026-07-13):** the decorative category tag uses `text-gray-500` at 9px (~3.4:1, under AA for small text) — the app-wide secondary-label idiom (also in section headers/DebugView), so pre-existing and non-load-bearing; consider a bump to `gray-400` system-wide someday.
|
||||||
|
- **ProgressionBanner strip chips are div-onClick (Muse, small):** pre-existing a11y debt — history/loop chips should be real buttons (L-50 gate 2026-07-11; RelatedProgressions' chips already show the pattern to copy).
|
||||||
|
- **Test-infra note (any agent writing throwaway SSR harnesses):** a bare `import('esbuild')` inside a `data:` module hook dies silently (ERR_MODULE_NOT_FOUND in the hooks thread, exit 1, zero output) — smoke.mjs:1047's interpolated `import.meta.resolve` pattern is load-bearing; prefer esbuild pre-bundle harnesses (confirmed empirically twice, 2026-07-11).
|
||||||
|
- **GUITAR_SHAPES self-audit in smoke (Critic, small — P-62 gate suggestion 2026-07-11):** pc-spell every GUITAR_SHAPES entry against its quality in smoke — the validator audits only KB chord steps, never the shape library itself; would have caught both wrong shapes below and guards future additions.
|
||||||
|
- **Pop piano prose uses absolute note names (Professor, tiny — P-62 gate flag):** ~8 spots (pop/piano.js:88,117,130-133,159,186) say e.g. "the G holds; top C→B" — correct only in the declared reference key; degree-ify before any piano-prose render surface lands (sibling of the bass-tips-surface backlog item).
|
||||||
|
- **Two wrong GUITAR_SHAPES in live data (Luthier, small — P-62 audit finding 2026-07-11):** (1) `half_dim "E Barre"` offsets `[0,1,2,0,0,'x']` sounds {0,3,6,7} — natural 5th clashing the ♭5, NO ♭7; correct E-form ø7 = `[0,1,0,0,'x','x']`. (2) `add9 "Open Dadd9"` (xx0230 = D-A-D-E) has no 3rd — it's Dsus2, duplicating the sus2 open D. Both pre-existing; fix after P-62 commits (additive lock kept P-62 from editing them).
|
||||||
|
- **Matcher blues/jazz disambiguation (Luthier, small):** `['C7','A7','Dm7','G7']` matches `jazz-rhythm-a` (identical I–VI–ii–V degree shape, quality tie → jazz), so `blues-turnaround`'s bass plays are live-unreachable — pre-existing tie-break in match.js; consider quality-profile weighting (all-dom7 → blues). Evidence-verified by the L-42 gate 2026-07-10.
|
||||||
|
- **Bass play `tips` have no render surface (Muse/Luthier, small):** the authored teaching prose in bass packs renders nowhere (card shows label/feel/position/note only; the dock has no bass section) — real content-surface gap, L-42 gate 2026-07-10.
|
||||||
|
- **TransitionChip "2 step down" wording (Muse, one line):** whole-step rails render as "2 step down/up" (52 of 268 real KB transitions) — verbatim port of RoadmapTrack.jsx:59; "whole step down" is the musician's phrase (D-41 gate note 2026-07-10).
|
||||||
|
- **Fingering view lost at gallery thumb size (Muse, small):** D-30's gallery renders ChordDiagram at `size="thumb"`, which never draws finger numbers — nothing in the browser shows fingering anymore (D-30 gate note 2026-07-10). If pedagogically wanted: tap-to-enlarge to `size="full"` inside a gallery cell.
|
||||||
|
- **Negative-fret shape handling inconsistency (Luthier, small):** `voicings.js` skips shapes that compute negative frets while `chordAudio.guitarShapeToNotes` slides them up an octave — pre-existing, flagged at the L-23 gate; unify someday.
|
||||||
|
|
||||||
|
- **Jam Guide phase 3:** bass pattern renderer (guitar → piano → **bass** roadmap; MiniPiano + piano recipe resolver shipped in sprint-knowledge-center).
|
||||||
|
- **Content:** piano packs (jazz → gospel → neo-soul first), then bass packs (blues → jazz → funk first) — see `docs/kb-backlog.md`.
|
||||||
|
- **`guideTones` third bug (L-12, Luthier — small):** `guideTones` takes `ints[1]` as the "third", so `add9` `[0,2,4,7]` reports its **9th (pc 2)** as the 3rd instead of the major 3rd (pc 4) — wrong "3rd" lane/badge for add9 (live in gospel + pop-axis Cadd9). Pre-existing since L-01b; flagged by Critic during the D-03 re-gate. Fix: pick the actual 3rd/4th interval (3 or 4, or sus 2/5) rather than positional `ints[1]`. Smoke + a guideTones truth-table check.
|
||||||
|
- **Learning features (Professor + Luthier):** drills tab seeded from `docs/learn-curriculum.md`; target-note highlighting; ear-training quiz on own chord history; pocket report from the onset pipeline.
|
||||||
|
- **Progression Builder (GOAL G3):** key-relative palette, drag-reorder, Nashville-number toggle, voicing alternatives.
|
||||||
|
- **Platform (Herald):** GitHub Pages docs site; "you're playing the Creep progression" engagement hook; contributor leaderboard for styles added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Iteration log
|
||||||
|
|
||||||
|
_(Maestro appends one line per completed iteration: `<date> · <task ids done> · <next>`.)_
|
||||||
|
|
||||||
|
- 2026-07-13 · **sprint-dashboard-polish — direct push (user "push everything now")** · done: L-78 (`d543b98` Try-this 3×3 ways-to-play — up to 3 guitar grips / 1 piano per suggestion, fixed anti-jump 3-slot frame; verified build+validate-KB+smoke 903/903; full independent Critic gate waived at user request) · L-77 re-scoped SAME DAY per user: rail should HIGHLIGHT loop chords when a loop is found AND list the other recent chords underneath, ≥4 total once history exists (was: replace-with-history). Rebuilding + pushing per "add this to the branch also and push when its done" · next: build L-77 (hybrid loop+history rail) → verify → push both remotes
|
||||||
|
- 2026-07-13 · **L-77 done + pushed** (`9415daf`) · hybrid voicings rail: loop group highlighted ("the loop") + "also played" recent-distinct chords underneath (most-recent-first, ≥4 once history exists), no-loop → history rail replaces the single-chord fallback; GlanceRail gains `showTransitions` (default true, loop byte-unchanged). Maestro verified build+validate+smoke 903/903 and folded 2 honesty fixes on the shared GlanceRail (history group no longer says "loop"/"playhead"; history rows non-focusable → no inert button). App.jsx/audio untouched. Pushed both remotes · next: C-70 sprint-end sweep (aria one-liner + PR updates) when user confirms the rail looks right
|
||||||
|
|
||||||
|
- 2026-06-14 · done: M-01 · in-review (awaiting user pick): D-00a/b/c viz concepts · next: D-SEL (user chooses) → then L-01/D-01/D-02 implement chosen concept
|
||||||
|
- 2026-06-15 · done: D-00a/b/c, D-SEL (Roadmap chosen), L-01 (match.js + banner refactor), L-01b (guideTones/voiceLeadingPairs/soloScale) — Critic PASS both · next: L-02 (JamGuide shell) → D-01 (RoadmapTrack) ‖ D-01b (ChordDiagram)
|
||||||
|
- 2026-06-15 · done: L-02 (JamGuide shell + App mount), D-01 (RoadmapTrack), D-01b (ChordDiagram), D-02 (assembly + playhead rotation + thumbnails) — Critic PASS all 4, audio contract intact (App.jsx +10/−0) · **Roadmap Jam Guide MVP is live & builds green** · next: D-03 (fretboard cross-link, backlog) ‖ P-01 (gospel content) ‖ H-01 (README reframe)
|
||||||
|
- 2026-06-15 · done: P-01 (gospel guitar pack — 5 progs × 2 plays, validator 9 styles/45/90), H-01 (README reframed as learning platform, stale frontend/ paths fixed) — Critic PASS both · next ready: P-02 (pop pack) ‖ H-02 (CONTRIBUTING) ‖ C-01 (smoke harness) ‖ D-03 (fretboard cross-link)
|
||||||
|
- 2026-06-15 · done: P-02 (pop guitar pack — KB now 10 styles/50/100), H-02 (CONTRIBUTING.md + issue templates), C-01 (scripts/smoke.mjs — 71/71, adversarially proven to catch a broken matcher) — Critic PASS all 3 · **SPRINT `sprint-jam-guide` COMPLETE** → opening PR to main · remaining (next sprint): D-03 fretboard cross-link, piano/bass packs, drills
|
||||||
|
- 2026-06-15 · done: D-03 (tap station → guide tones on the main Fretboard; root-cause fix to `guideTones` hasSeventh + badge hardening) — Critic **returned once** (add9/maj6/min6 mislabeled 5th/6th as "7"), fixed, **PASS on re-gate** · audio contract intact · folded into PR #2 (completes the flagship) · filed L-12 (guideTones third bug) to backlog · next: seed piano phase-2 sprint (resolver + MiniPiano + wire)
|
||||||
|
- 2026-06-15 · **new sprint `sprint-jamguide-piano`** cut off sprint-jam-guide (keeps PR #2 stable) · done: M-02 (seed), L-10 (`src/lib/piano.js` voicing resolver — shells/rootless A·B/root, voice-leading threading; Critic PASS, 56 voicings, 0 wrong notes) · next ready: D-10 (MiniPiano) ‖ C-10 (smoke piano) → then L-11 (wire piano tab) · L-10 chosen as a new unimported lib so the user's live frontend review stayed undisturbed
|
||||||
|
- 2026-06-16 · done: D-10 (extended existing `MiniPiano.jsx` with additive `voicing` prop — legacy lh/rh path for ChordDetailModal/ExplorePanel byte-unchanged), C-10 (smoke +705 piano checks → 776 total), L-13 (resolver dedupe — `[...new Set]`, fixes sus2 dup-note C-10 caught) — Critic PASS all 3 (adversarially proved C-10 checks bite + dedupe never drops a needed note) · **mid-iteration corrections:** D-10's "new file" premise was wrong (MiniPiano already existed + imported) → re-scoped to additive extend; C-10 correctly turned the branch red surfacing a real wart → filed+fixed L-13; 3 subagents hit API errors (2 produced nothing, 1 landed its diff before dying) · **L-11 (wire piano tab) intentionally HELD** — it's the first UI-visible change; waiting until the user finishes reviewing localhost:5173 · next: L-11 on the user's go
|
||||||
|
- 2026-07-08 · **new sprint `sprint-knowledge-center`** seeded on the SAME branch (user directive: continue on `sprint-jamguide-piano`) · user asks: playable voicings per chord, lick/technique cards, intermediate progressions (easiest in-app surface = level tags + filter), Knowledge Center visual restructure, keep expanding KB · L-11 hold LIFTED, carried forward · loop scheduled: /jam-loop every 30 min × 24 iterations (12 h) · ready now (file-disjoint): L-11 ‖ D-20 ‖ L-20 ‖ C-20
|
||||||
|
- 2026-07-08 · **iteration 1/24** · done: M-03, L-20 (`4b0f0c8` audio engine), L-11 (`7b669f8` piano tab LIVE), D-20 (`96dda6a` Knowledge Center concept), C-20 (`793e60a` lick/level schema, smoke 787) — Critic PASS all four, zero returns · findings folded into the board: D-21 lock += `voicings.js` export-only (GUITAR_SHAPES private); L-22 deps += P-21; P-21 serialized behind P-20 (`jazz/**` overlap); filed L-23 (pre-existing `getGuitarVoicings` rootStr fret-label bug, found by L-20); D-20 discovered ExplorePanel + EducationPanel are ORPHANED (never mounted) → retirement backlogged; famous-progressions level-filter exemption recorded · next ready (file-disjoint): D-21 ‖ P-20 ‖ D-22 (D-22 may build against the SCHEMA fixture until P-21 lands)
|
||||||
|
- 2026-07-08 · **iteration 2/24** · done: D-22 (`44d9c4f` LickCard), D-21 (`3c903ad` VoicingBrowser — returned once: rootless voicings badged lowest voice as "R", one-line rootPc fix, PASS on re-gate), P-20 (`c08b334` — returned once: 3 caption theory misstatements, string fixes, PASS on re-gate; KB 56 progs/112 plays, all jazz/gospel/rnb progressions level-tagged) · incident: both first-round gates stalled on API watchdog (600s) twice for P-20 — fresh critic instances completed them; one cron fire absorbed into gate completion · Critic gate quality note: both returns were real musical defects caught before commit
|
||||||
|
- 2026-07-08 · **iteration 4/24** · done: P-21 (`6ea1089` 12 licks blues/jazz/funk — every note pitch-verified), P-22 (`9e2567c` jazz piano cell, KB's first authored piano pack, 126 plays — returned once: 6 prose voice-leading overclaims on fifths-apart dominants, fixed + arithmetic-verified, PASS on re-gate), L-22 (`78706ce` **Knowledge Center shell LIVE** — 4-section dock, level filter, licks grid; App.jsx +1 line, audio contract grep-clean) · filed: C-22 (validator span check), L-24 scoped out of P-22 (authored-pack rendering) · promoted: L-24, C-22, P-23 · iteration 5 batch already in flight: D-23 ‖ D-24 ‖ P-24
|
||||||
|
- 2026-07-09 · **iteration 8/24 — SPRINT `sprint-knowledge-center` COMPLETE** · done: C-21 (sprint-end sweep — build/validator/smoke all green on 72c6d45; full-App SSR renders; authored vs computed piano paths both proven; zero dead imports; audio contract +1 line since seed) · **PR #3 opened: https://github.com/whattheflat/JamBuddy/pull/3** (supersedes PR #2 — main hadn't absorbed it) · all 21 sprint tasks done, 4 returned-then-fixed by the gate, 0 unresolved · loop cron deleted after 8 of 24 budgeted iterations (board empty) · remaining backlog: EducationPanel retirement, FAMOUS_PROGRESSIONS level tags, smoke drift guards, pianoVoicing rootPc, negative-fret unify, L-12, bass renderer
|
||||||
|
- 2026-07-09 · **iterations 6–7/24** · done: L-25 (`e24aa4b` instrument-scoped modal tabs), C-22 (`b4e8f12` validator hand-span rule, smoke 799), P-23 (`564a9e0` gospel piano cell, 140 plays — returned once: one improv-row scale claim false for vi7/iii7, split into exact rows, PASS re-gate; 39/40 first-pass claims held), L-24 (`2165bb5` authored piano packs render at stations — implementer agent hung at verification after ~1 h, killed, completion-pass agent audited + finished; Critic PASS with 0 resolver drift) · incidents: P-23 stalled once mid-authoring (resumed from transcript, no loss); L-24 hung silently (diff intact, completed by fresh agent) · backlog: resolveDegree drift guard filed · **board empty except C-21 → sprint-end sweep + PR**
|
||||||
|
- 2026-07-08/09 · **iteration 5/24** · done: D-23 (`db397f6` VoicingBrowser show prop + LickCard stack-guard), D-24 (`e2110ae` MiniPiano thumbs crop to voicing span, 266→142 px), P-24 (11 licks rock/country/reggae — KB now **23 licks across 6 styles**, every note pitch-verified) — Critic PASS all three, zero returns · D-24 gate raised a false alarm on the D-21 rootPc fix (its harness bypassed VoicingBrowser; Maestro verified line 297 intact — no regression); hardening idea backlogged (`pianoVoicing` return rootPc) · promoted L-25 · next ready (all file-disjoint): L-24 ‖ L-25 ‖ C-22 ‖ P-23, then C-21 closes the sprint
|
||||||
|
- 2026-07-10 · **sprint-integrated-glance iteration 4/24 — SPRINT COMPLETE** · done: L-42 (`af7c3cc` **BASS real end-to-end** — BassPatternCard tab cards with playback; gate re-realized all 912 placements exact, byte-identity on untouched paths, import cycle proven safe), C-42 (`218c0ba` smoke §7 sweeps BOTH resolveDegree copies, 866/866 — the sweep agent authored the wiring then died on the ACCOUNT MONTHLY SPEND LIMIT; Maestro completed the sweep inline: build/validator/smoke green at HEAD, sabotage-proved the new guard [b13 perturbation → red naming the cell → restored green], whole-sprint App.jsx contract grep = 0 hits; per-instrument SSR relied on the L-42 gate's run minutes earlier — only smoke.mjs changed since) · backlogged: matcher blues/jazz tie-break (turnaround bass plays live-unreachable), bass tips surface · all 11 sprint tasks done, 2 returned-then-fixed (D-40, and D-41's predecessor D-31 pattern held: zero returns this sprint besides D-40), 0 unresolved · PR #3 updated; cron deleted (4 of 24 iterations) · ⚠️ NOTE: the Anthropic monthly spend limit is HIT — no further subagent work possible until raised
|
||||||
|
- 2026-07-11 · **sprint-roulette-kb iterations 1-2/12** (notification-driven; hourly cron as fallback) · done: M-07, C-60 (`5f4e2b7` piano lick schema — enclosures native, 25-cap proven tight, dormant renderer guard), P-62 (`ffc1bb1` pop piano cell 182 plays + 8 shapes; found 2 WRONG pre-existing shapes → backlog), D-61 (`c999c95` circle of fifths — returned once on modal hub signature, now teaches parent-major relation), P-60 (`70263e2` first piano licks, jazz+blues — PASS first try 35/35), D-60 (`a7dbd6a` PianoLickCard piano-roll renderer — PASS, 41/41 realization audit), D-62 (`e87b2bb` jam-roulette spec — returned TWICE, round-3 literal-implementation test passed; surfaced the pre-existing live bug: 10 progressions incl. blues-12bar never match detection; fix (a) specced into L-60), P-61 (`2c18c71` gospel+rnb licks, 39 total — returned once on one interval claim, gate-prescribed fix verified) · KB today: 172→182 plays, 23→39 licks, 6th piano style, circle of fifths · remaining: L-60 (blocked on one-screen C-50) → C-61
|
||||||
|
- 2026-07-11 · **BOTH SPRINTS COMPLETE** · sprint-roulette-kb: L-60 (`8d74e54`+`1df6fe8` **Jam Roulette LIVE** + the matcher collapse fix repairing the pre-existing live 12-bar/8-bar empty-JamGuide bug — Critic PASS on Opus: pool sweep reproduced from spec 4/52, fix-(a) additivity proven registry-wide, seed timeline hand-traced), C-61 (combined sweep — build/validator 182·39/smoke 891 all green, full-App SSR ×6 combos, whole-branch audio contract clean; PASS) · gate stats across both sprints: 9 first-try passes, 5 returns across 4 tasks (D-50, P-61, D-61, D-62×2) all re-gated to PASS · cron deleted · PR #3 updated with the combined summary · backlog: PianoLickCard LicksStrip wiring (needs shared sequencer-stop), 2 wrong GUITAR_SHAPES, strip-chip a11y, matcher blues/jazz tie-break, bass tips surface, 6 pre-existing orphan components
|
||||||
|
- 2026-07-11 · **sprint-one-screen iterations 2-3 + roulette-kb progress** · one-screen COMPLETE: L-50 (`800fd43`+`e58a7a6` dashboard grid, slim strip, jam view), D-51 (`d5a45f4` rail column adaptation — verified to the built CSS), L-51 (`6e8d8f4` RelatedProgressions — collapse step proven load-bearing), C-50 (`4deba7e` sweep, smoke 886, ranking pinned + registry id-uniqueness) — all Critic PASS · roulette-kb: P-61 (`2c18c71` gospel+rnb licks, 39 total) · **incident:** the L-60 (Jam Roulette) dispatch hit the account MONTHLY SPEND LIMIT on Fable 5 mid-read (no files written, clean tree); user switched the session to Opus 4.8 and L-60 re-dispatched fresh on Opus — proceeding · remaining: L-60 → C-61 closes both sprints
|
||||||
|
- 2026-07-11 · **sprint-one-screen iteration 1** (notification-driven) · done: M-06, D-50 (`d24bf35` one-screen dashboard + jam-view concept — returned once: strip height, geometrically-inert sticky, double-counted padding, collapse-blind ranking; all fixed, re-gate PASS with recomputed budgets) · L-50 (the build) in flight · then D-51 ‖ L-51 → C-50 → unlocks L-60
|
||||||
|
- 2026-07-10 · **sprint-integrated-glance iteration 3/24** · done: D-41 (`677d7b9` **all-expanded rail LIVE** — every station a permanently expanded row with education header [solo-scale, aim dots, voice-leading chips incl. wrap-around] + full gallery; playhead highlight-only proven byte-equal modulo highlight artifacts; focus toggle keeps the onFocusChord contract; show='bass' fall-through fixed; dense byte-identity proven across 336 SSR combos; black-on-accent 5.31:1 beats the old sub-AA white) — gate PASS, 19/19 SSR incl. independent theory recompute of a row's education · backlogged: TransitionChip "2 step down"→"whole step" wording (52 of 268 transitions) · L-42 (bass pattern renderer) dispatched — then C-42 closes
|
||||||
|
- 2026-07-10 · **sprint-integrated-glance iteration 2/24** · done: P-41 (`01a7c32` **the KB's first bass cell** — blues, 8 plays/172 total; gate machine-realized all 76 patterns in C AND A, 12 identities hold key-agnostically; PASS first try), L-40 (`c899132` **the restructure is live** — band below the main module, banner = the single loop display, RoadmapTrack/CurrentJamPanel unmounted, App's one GUITAR/PIANO/BASS selector drives band + dock, honest bass rows, GlanceRail yank-effect deleted; gate SSR 22/22, audio contract grep-clean, deletion sweep clean) · folded: smoke §7 resolveDegree wiring → C-42; VoicingBrowser show='bass' fall-through fix → D-41 (lock extended) · D-41 (all-rows-expanded rail) dispatched — then L-42 → C-42 closes
|
||||||
|
- 2026-07-10 · **sprint-integrated-glance iteration 1/24** · done: M-05, P-40 (`6f59add` kb-backlog refreshed — gate audited all 14 cells + 10 dates), C-40 (`090d3a9` drift guards, smoke 836 — gate recomputed all 224 truth-table cells), D-40 (`7d1f64c` integrated-glance concept — returned once: Dm7-specific piano math + 3 factual fixes incl. the interim page-yank, fixed, PASS re-gate with pixel-exact recompute), C-41 (`996218f` bass play schema, smoke 849 — degree-based, typed approaches, PASS with 19-semitone bound proven exactly maximal) · in flight: L-40 (App restructure — band up top, single loop display, one selector) ‖ P-41 (blues bass, first bass cell) · then D-41 → L-42 → C-42 closes
|
||||||
|
- 2026-07-10 · **iteration 4/24 — SPRINT `sprint-glance-and-loop` COMPLETE** · done: P-31 (`81cd230` blues piano cell, 164 plays — returned once: jump-kicks bars 3-4 dyad orientations flipped + 2 prose rewordings, fixed, PASS scoped re-gate), C-31 (sprint-end sweep at `81cd230` — build/validator/smoke 817 all green; full-App SSR renders with rail+gallery+strip coexisting; whole-sprint App.jsx diff = ONE hunk in the progression effect, contract grep 0 hits; no dead imports; every gate-promised follow-up verified filed) · all 11 sprint tasks done, 2 returned-then-fixed (D-31, P-31), 0 unresolved · PR #3 updated with the sprint summary; loop cron deleted after 4 of 24 budgeted iterations (board empty) · new backlog: loop-fixtures header line-ref drift; kb-backlog needs rnb+blues done entries
|
||||||
|
- 2026-07-10 · **iteration 3/24** · done: L-33 (`c78baf4` **glance mode LIVE** — playhead accordion, licks strip, heard-live fallback; gate SSR 37/37, dense-less VoicingBrowser byte-identical to 0945a08, token matcher proven hostile-proof, App.jsx untouched by construction), L-31 (`ce24d54` commit layer 2/3/6 — gate re-traced all five scenarios independently + 3 hostile traces; REPLACE_VOTES=3 proven load-bearing; audio contract grep-clean) · **all three user asks now end-to-end on the branch** · P-31 (blues piano, 164 plays) authored — gate in flight; its verifier self-caught 3 prose overclaims pre-review · C-31 promoted (holds until P-31 lands so the sweep sees a settled tree) · next: gate P-31 → C-31 closes the sprint
|
||||||
|
- 2026-07-10 · **iteration 2/24** · done: P-30 (`b51daa1` rnb piano cell, KB 154 plays — PASS first try, a piano-pack first; found+fixed its own 36-key-window stacking defect pre-review), L-30 (`4cc0009` detector rewrite: dup-collapse, weak-period ghost rejection, len 2–8, ≤1 edit/cycle with ≥2-exact evidence gate, recency-weighted coverage — smoke 817/817, gate ran 15 novel probes, 0.2–0.7ms/call) · L-33 built (playhead accordion + LicksStrip + heard-live fallback, SSR 24/24 self-checked) — gate in flight · L-31 dispatched with both gates' findings folded (flap-survival constraint, docstring ride-along) · both watchdog-stalled agents recovered cleanly via resume · promoted: P-31 · next: gate L-33, land L-31, then C-31 closes
|
||||||
|
- 2026-07-10 · **iteration 1/24** · done: M-04, D-30 (`0945a08` VoicingBrowser gallery — all shapes/styles side by side, no chips; PASS first try), C-30 (`fdd93c7` 17 loop fixtures + smoke wiring; PASS — found the len² self-overlap artifact as the root cause of the user's missed 3-chord loops, folded into L-30/L-31 DoDs), D-31 (`6c7a1a7` glance-mode "playhead accordion" doc — returned once on piano-gallery width math, fixed, PASS re-gate) · in flight: L-30 (detector redesign, dispatched early — C-30 contract ready), P-30 (rnb piano) — both stalled once on the 600s API watchdog near completion, both resumed via SendMessage · promoted: L-33 (ready, locks per D-31 doc) · finding worth noting: "5 chords then 2 others" passes at pure-function level — the live failure is the ghost-pattern flapping starving the 2-vote commit layer (L-31's trace case)
|
||||||
|
- 2026-07-10 · **new sprint `sprint-glance-and-loop`** seeded on the SAME branch (commits extend PR #3) · user asks: all voicing variations side-by-side with no button pushing; fix loop detection (misses a returning 3-chord loop and a 5-chords-then-2-others shape); glanceable zero-click learn session that follows the live jam · loop scheduled: /jam-loop every 30 min × 24 iterations (12 h, cron 13,43) · ready now (file-disjoint): C-30 ‖ D-30 ‖ D-31 ‖ P-30, then C-30→L-30→L-31 and D-30+D-31→L-33
|
||||||
|
- 2026-07-08 · **iteration 3/24** · done: L-21 (`951d544` VoicingBrowser live in ChordDetailModal Guitar/Piano tabs + JamGuide enlarged stations — the "playable voicings per chord" user ask is now end-to-end), L-23 (`f4e0ac6` getGuitarVoicings base-fret fix — 252 movable-shape placements corrected, A-shape C was labeled fret 1 not 3) — Critic PASS both, zero returns this round · follow-ups filed: VoicingBrowser `show` prop (Muse), negative-fret handling inconsistency (Luthier), stale JamGuide header comment · P-21 lock narrowed to the 3 guitar.js files → P-22 now disjoint · next ready (file-disjoint): P-21 ‖ P-22 ‖ L-22 (licks section may land empty-state-first per D-20 doc)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Ensemble Protocol — How the Agents Collaborate
|
||||||
|
|
||||||
|
The operating system for the [six-agent ensemble](ROSTER.md). Agents are isolated subagents — they share **no memory**, only **files**: the live ledger ([`LEDGER.md`](LEDGER.md)) and the repo. Everything below makes that file-mediated collaboration safe and productive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The unit of work: a great task
|
||||||
|
|
||||||
|
An iteration is only as good as its tasks. Every ledger task MUST be:
|
||||||
|
|
||||||
|
1. **Bounded** — one domain, one definition-of-done, completable in one session.
|
||||||
|
2. **Owned** — exactly one agent appointed (domain → agent is 1:1).
|
||||||
|
3. **Locked** — declares the files it will write; that set is its lock.
|
||||||
|
4. **Justified** — content cites sources, code references the task id, design references tokens.
|
||||||
|
5. **Gated** — Critic reviews before merge; nothing self-certifies.
|
||||||
|
6. **Logged** — on completion: ledger updated, commit made, `GOAL.md` touched if scope shifted.
|
||||||
|
|
||||||
|
If a task can't be written this way, it's too big — Maestro splits it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The ledger lifecycle
|
||||||
|
|
||||||
|
Status flow, managed in [`LEDGER.md`](LEDGER.md):
|
||||||
|
|
||||||
|
```
|
||||||
|
backlog → ready → claimed → in-review → done
|
||||||
|
│ │
|
||||||
|
└───────────┴──→ returned (with findings) → ready
|
||||||
|
```
|
||||||
|
|
||||||
|
- **backlog** — captured, not yet actionable.
|
||||||
|
- **ready** — dependencies met; Maestro promoted it.
|
||||||
|
- **claimed** — an agent is working it; its files are now **locked**.
|
||||||
|
- **in-review** — work done, handed to Critic.
|
||||||
|
- **done** — Critic passed it; merged.
|
||||||
|
- **returned** — Critic failed it; carries specific findings; goes back to ready.
|
||||||
|
|
||||||
|
**The locking rule (prevents file conflicts):** an agent may only claim a `ready` task whose declared files do **not** overlap any `claimed` or `in-review` task. This serialises conflicting work without a central daemon — the check happens at claim time against the ledger.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. File ownership map
|
||||||
|
|
||||||
|
Primary owner routes the task; co-reviewers must sign off on cross-domain files.
|
||||||
|
|
||||||
|
| Path | Primary | Co-review |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/data/kb/**` (content) | Professor | Critic (validator) |
|
||||||
|
| `src/data/kb/SCHEMA.md`, `scripts/validate-kb.mjs` | Critic | Professor, Luthier |
|
||||||
|
| `src/components/**`, `src/services/**`, `src/App.jsx`, `electron/**`, build cfg | Luthier | Critic; Muse if visual |
|
||||||
|
| `tailwind.config.js`, visual layer, SVG renderers | Muse | Luthier (integration), Critic |
|
||||||
|
| `src/lib/theory.js` | **shared** Professor (music) + Luthier (code) | Critic |
|
||||||
|
| `docs/learn-curriculum.md`, `docs/progression-repertoire.md`, `docs/kb-*.md` | Professor | Herald (clarity) |
|
||||||
|
| `README.md`, `CONTRIBUTING.md`, `LICENSE`, `.github/**` | Herald | Maestro |
|
||||||
|
| `GOAL.md`, `docs/agents/LEDGER.md` | Maestro | all read |
|
||||||
|
|
||||||
|
**Shared files** (`theory.js`) get strict task-locking: only one task touching them runs at a time, reviewed by Critic **and** the non-owning domain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The appointment algorithm (each iteration)
|
||||||
|
|
||||||
|
This is what `/jam-loop` (Maestro, run by the main loop) executes:
|
||||||
|
|
||||||
|
1. **PLAN** — read `LEDGER.md` + `GOAL.md`. Promote `backlog → ready` where deps are met. Pick the next batch by `priority × cadence-weight` ([weights in ROSTER](ROSTER.md)), ensuring file-disjointness for any parallel batch.
|
||||||
|
2. **APPOINT** — for each chosen task, dispatch the agent whose domain == `task.domain`.
|
||||||
|
3. **DISPATCH** — choose the shape:
|
||||||
|
- **Serial (default, proven):** one task → one agent via the Agent tool. A dependent chain → run in order.
|
||||||
|
- **Sprint (opt-in / "ultracode"):** independent ready tasks → parallel; dependent tasks → pipeline; via the Workflow tool.
|
||||||
|
- **Parallel writes to disjoint files** → give each agent `isolation: "worktree"`.
|
||||||
|
4. **GATE** — every completed worker task → **Critic** reviews (runs `npm run build`, `node scripts/validate-kb.mjs`, tests; applies judgment). Pass → `done`; fail → `returned` with findings.
|
||||||
|
5. **RECONCILE** — Maestro commits passing work (one commit per task), updates `LEDGER.md` + `GOAL.md`, writes a one-line iteration log.
|
||||||
|
6. **CONTINUE** — schedule the next iteration, or at sprint end open the PR (see §6).
|
||||||
|
|
||||||
|
**Appointing correctly = the five rules of great iterations** (§1) applied at dispatch: bounded scope to one agent, files locked, sources/refs required, Critic gate wired in, honest status on return.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Scheduling
|
||||||
|
|
||||||
|
The clean model: **schedule the conductor, not the band.** One recurring loop runs `/jam-loop`; each fire is one orchestrated iteration that appoints whichever agent the next ready task needs.
|
||||||
|
|
||||||
|
- **Session loop** (runs while this terminal is open): `/loop 1h /jam-loop` — good for a focused build sprint you're watching.
|
||||||
|
- **Cloud schedule** (durable, survives closing the session): `/schedule` → e.g. "run /jam-loop every weekday at 09:07" — good for steady background progress. Recommended for a standing ensemble.
|
||||||
|
|
||||||
|
Per-domain cadence is the **weight**, not a separate cron: content advances most iterations, OSS/docs every ~4th. To shift emphasis for a stretch (e.g. "design week"), Maestro raises Muse's weight in the ledger header — no schedule change.
|
||||||
|
|
||||||
|
**Advanced — true parallel cadences (not default).** You *can* run separate loops per agent (`/loop 1h /jam-loop --only professor`, `/loop 3h /jam-loop --only luthier`). Only do this with **worktree isolation mandatory** and **strictly disjoint file ownership per loop**, or they will collide on shared files (`theory.js`, `App.jsx`, the ledger). The single-conductor model avoids this entirely; prefer it unless you have a specific throughput need.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Branching, review, and PRs
|
||||||
|
|
||||||
|
- One **sprint branch** off `main` (e.g. `sprint-jam-guide`); each task is a commit (parallel disjoint work uses worktrees off the branch).
|
||||||
|
- Critic gates every commit; Maestro opens **one PR per sprint** to `main` summarising all tasks + validator/build status.
|
||||||
|
- **Known constraint (this machine):** `gh` CLI is not installed. Open PRs via the GitHub API using stored git credentials:
|
||||||
|
```bash
|
||||||
|
TOKEN=$(printf 'protocol=https\nhost=github.com\n\n' | git credential fill | sed -n 's/^password=//p')
|
||||||
|
# POST to https://api.github.com/repos/whattheflat/JamBuddy/pulls with {title, head, base, body}
|
||||||
|
```
|
||||||
|
(Repo was renamed `whattheflat` → `JamBuddy`; origin URL still works for push.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Conflict resolution
|
||||||
|
|
||||||
|
- **File clash at claim time** → can't claim; pick another ready task or wait for the lock to clear.
|
||||||
|
- **Cross-domain disagreement** (e.g. Muse wants a layout Luthier says is infeasible) → Maestro decides, records the call in the ledger, and if it's a product question surfaces it to the human instead of guessing.
|
||||||
|
- **Critic vs author** → Critic's gate is binding on *correctness/quality*; on *taste/scope*, Maestro arbitrates. Returned work always carries specific, actionable findings — never a bare rejection.
|
||||||
|
- **Scope creep** → if a task grows mid-flight, the agent stops, notes it in the ledger, and Maestro re-splits. Silent scope expansion is the cardinal sin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. State lives in files (because agents don't share memory)
|
||||||
|
|
||||||
|
Every iteration must leave perfect context for the next, since the next agent starts fresh:
|
||||||
|
- the **ledger** carries task status, locks, and findings;
|
||||||
|
- the **commit** carries the change and its rationale;
|
||||||
|
- **`GOAL.md`** carries shifts in direction;
|
||||||
|
- a recalled **memory** file (`project_ensemble`) carries the standing setup.
|
||||||
|
|
||||||
|
Write as if the next agent has never seen this conversation — because it hasn't.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# The JamBuddy Ensemble — Agent Roster
|
||||||
|
|
||||||
|
Six specialised agents build JamBuddy as both a **real-time jam companion** and an **open-source learning platform**. They collaborate through files (a shared ledger + the repo), never through live conversation — exactly like a git-based human team. The conductor (Maestro) appoints; the five workers do; the gate (Critic) approves.
|
||||||
|
|
||||||
|
How they run: [`PROTOCOL.md`](PROTOCOL.md). What's queued now: [`LEDGER.md`](LEDGER.md). The product north star: [`../../GOAL.md`](../../GOAL.md).
|
||||||
|
|
||||||
|
| Agent | Role | Realised as |
|
||||||
|
|---|---|---|
|
||||||
|
| 🎼 **Maestro** | Orchestrator / product lead | the `/jam-loop` skill (run by the main loop) + [`.claude/agents/maestro.md`](../../.claude/agents/maestro.md) for solo planning |
|
||||||
|
| 🎓 **Professor** | Music & pedagogy | [`.claude/agents/professor.md`](../../.claude/agents/professor.md) |
|
||||||
|
| 🔧 **Luthier** | Engineering | [`.claude/agents/luthier.md`](../../.claude/agents/luthier.md) |
|
||||||
|
| 🎨 **Muse** | Design & UX | [`.claude/agents/muse.md`](../../.claude/agents/muse.md) |
|
||||||
|
| 🔍 **Critic** | Quality & review (the gate) | [`.claude/agents/critic.md`](../../.claude/agents/critic.md) |
|
||||||
|
| 📣 **Herald** | Open-source & community | [`.claude/agents/herald.md`](../../.claude/agents/herald.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Domains, ownership, and quality bar
|
||||||
|
|
||||||
|
### 🎼 Maestro — orchestrator / product lead
|
||||||
|
- **Mandate:** turn `GOAL.md` into bounded, dependency-ordered tasks; appoint the right agent; reconcile and merge; keep the ledger and goal honest. Writes the plan, not the feature code.
|
||||||
|
- **Owns (write):** `GOAL.md`, `docs/agents/LEDGER.md`.
|
||||||
|
- **Quality bar:** every task is single-domain, single-DoD, fits one session, declares files + deps. No task ships without a Critic pass logged.
|
||||||
|
|
||||||
|
### 🎓 Professor — music & pedagogy
|
||||||
|
- **Mandate:** the music brain. Research and author knowledgebase cells (the `/kb-expand` work, now a standing role), learning curricula, drills, and ear-training design; guard music-theory correctness.
|
||||||
|
- **Owns (write):** `src/data/kb/**` (content), `docs/learn-curriculum.md`, `docs/progression-repertoire.md`; **co-owns** `src/lib/theory.js` (music correctness) with Luthier.
|
||||||
|
- **Quality bar:** key-agnostic data only; `node scripts/validate-kb.mjs` green; named sources; pedagogically sequenced (intermediate, not step-one).
|
||||||
|
|
||||||
|
### 🔧 Luthier — engineering
|
||||||
|
- **Mandate:** build and revise the app — features, the audio pipeline, wiring the KB into the UI (the Jam Guide panel), refactors, performance, Electron.
|
||||||
|
- **Owns (write):** `src/components/**`, `src/services/**`, `src/App.jsx`, `electron/**`, build config; **co-owns** `src/lib/theory.js` (code) with Professor.
|
||||||
|
- **Quality bar:** `npm run build` green; no regressions to the audio callbacks' stability contract (see `CLAUDE.md`); diff matches the task; reuses design tokens, never raw hex.
|
||||||
|
|
||||||
|
### 🎨 Muse — design & UX
|
||||||
|
- **Mandate:** make it come to life on screen — visual design, layout, interaction, the "smart fit to screen" for the Jam Guide, SVG renderers' look, accessibility.
|
||||||
|
- **Owns (write):** `tailwind.config.js` (design tokens), presentational components and their styling, SVG visual specs (`ChordDiagram`, `MiniPiano`).
|
||||||
|
- **Quality bar:** uses `bg-surface`/`bg-panel`/`border-border`/`accent` tokens; responsive + fits the target viewport; WCAG-AA contrast; keyboard-reachable.
|
||||||
|
|
||||||
|
### 🔍 Critic — quality & review (the gate)
|
||||||
|
- **Mandate:** review every other agent's work before it merges; run the validator, the build, and any tests; apply judgment; return failing work with specific findings. Veto power.
|
||||||
|
- **Owns (write):** `scripts/validate-kb.mjs`, test infrastructure, review notes in the ledger. **Never** silently rewrites feature work — returns it.
|
||||||
|
- **Quality bar:** mechanical checks must actually be run (evidence, not assertion); findings are specific and actionable.
|
||||||
|
|
||||||
|
### 📣 Herald — open-source & community
|
||||||
|
- **Mandate:** make this a *platform*, not just an app — README that frames the learning-platform vision, CONTRIBUTING with the data-contract so musicians (not only coders) can PR a style, issue/PR templates, license, changelog, public roadmap.
|
||||||
|
- **Owns (write):** `README.md`, `CONTRIBUTING.md`, `LICENSE`, `.github/**`, contributor-facing docs.
|
||||||
|
- **Quality bar:** links resolve; instructions are runnable as written; a non-coding musician can follow the "add a style" path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cadence weights (how the conductor balances the band)
|
||||||
|
|
||||||
|
Per-domain rhythm is expressed as how often Maestro is *eligible* to pick that domain each iteration — not as separate schedules (see [`PROTOCOL.md` §Scheduling](PROTOCOL.md)).
|
||||||
|
|
||||||
|
| Agent | Weight | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| Professor | 3 | content can advance every iteration |
|
||||||
|
| Luthier | 3 | engineering every iteration (tasks are larger, span iterations naturally) |
|
||||||
|
| Muse | 2 | when there is UI pending style (event-driven) |
|
||||||
|
| Critic | — | mandatory gate stage every iteration, not weighted |
|
||||||
|
| Herald | 1 | ~every 4th iteration, or when a contributor-facing change lands |
|
||||||
|
| Maestro | — | plans + reconciles every iteration |
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
# Dashboard polish — rail + licks (task D-70)
|
||||||
|
|
||||||
|
Concept doc for `sprint-dashboard-polish`. Revises the rail/licks layout that
|
||||||
|
`docs/design/one-screen.md` §4 and D-51 established, per the user directive of
|
||||||
|
2026-07-13 ("this looks amazing" + six refinements). No code here — this specs
|
||||||
|
the two bounded implementation tasks **L-70** (rail) and **L-71** (licks) and
|
||||||
|
proves the numbers.
|
||||||
|
|
||||||
|
Design tokens only (`tailwind.config.js`): `surface` #0f0f0f, `panel` #1a1a1a,
|
||||||
|
`border` #2a2a2a, `accent` #a855f7, plus the SVG note language already mirrored
|
||||||
|
in `MiniPiano`/`ChordDiagram`/`LickCard` (amber #f59e0b secondary, ACCENT_SOFT
|
||||||
|
#c084fc chord-tone, BASS_RING #fbbf24). No new colour is introduced.
|
||||||
|
|
||||||
|
The column geometry we build against (from D-51 / one-screen.md §4, unchanged):
|
||||||
|
|
||||||
|
```
|
||||||
|
right column 500px
|
||||||
|
− section border+p-2 −18
|
||||||
|
− row border+p-1.5 −14
|
||||||
|
− vertical scrollbar −17 (classic Windows) … −8 (the thin bar we add in §2)
|
||||||
|
= row interior 451px worst case (classic) · 460px (thin) · 468 (none)
|
||||||
|
```
|
||||||
|
|
||||||
|
All fits below are proven against the **conservative 451px** interior; the thin
|
||||||
|
scrollbar we introduce in §2 only ever makes them more comfortable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Scope boundary — dashboard only (user refinement 2026-07-13)
|
||||||
|
|
||||||
|
> "perhaps within the knowledge center it could be nice but leave it for now as
|
||||||
|
> is, only in the main JAM FULL SCREEN we need to optimize what we show."
|
||||||
|
|
||||||
|
**The net boundary (two settled user clarifications, 2026-07-13):**
|
||||||
|
|
||||||
|
- **Dashboard** (always-open band + ⛶ jam-view fullscreen) = **full visual
|
||||||
|
optimisation**: guitar ≤4, 2×2 smaller piano, uniform licks, dark scoped
|
||||||
|
scrollbars, no ▶.
|
||||||
|
- **Knowledge Center dock** (`KnowledgeDock`'s VoicingsSection + LicksSection,
|
||||||
|
the Circle of Fifths) **and `ChordDetailModal`** = **layout UNCHANGED** (all
|
||||||
|
shapes, normal piano, current lick size) **except the ▶ play buttons are
|
||||||
|
removed** — the user decided "then leave them off, better not," so ▶ comes off
|
||||||
|
**everywhere**, not just the rail.
|
||||||
|
|
||||||
|
So there are **two** kinds of change with **two** scopes: the *visual layout*
|
||||||
|
changes are **dashboard-only** (gated behind the dashboard fork), while the
|
||||||
|
*▶ removal* is a **deliberate global** change across every mount.
|
||||||
|
|
||||||
|
The trap is **shared components**:
|
||||||
|
|
||||||
|
| component | dashboard mount | Knowledge Center / modal mount | how they differ today |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `VoicingBrowser` | GlanceRail rows + heard-live (`dense`) | VoicingsSection + ChordDetailModal (**no `dense`**) | the `dense` flag already forks them |
|
||||||
|
| `LickCard` | LicksStrip (`size="thumb"`) | LicksSection (`size="full"`) | the `size` prop already forks them |
|
||||||
|
| `MiniPiano` | rail thumbs (`size="thumb"`) | modal/ExplorePanel (`size="thumb"`/`"full"`) | needs a **new** `size="mini"` variant |
|
||||||
|
| `PianoLickCard` | LicksStrip (new wiring) | **never mounted** | no KC concern at all |
|
||||||
|
|
||||||
|
**The rule: gate the *visual* optimisations behind the dashboard fork and change
|
||||||
|
no default there; remove ▶ globally.** Concretely:
|
||||||
|
|
||||||
|
- `VoicingBrowser` — *visual, dashboard-only:* guitar ≤4 + piano 2×2 apply
|
||||||
|
**only under `dense`** (the flag only dashboard mounts pass); non-dense
|
||||||
|
(VoicingsSection + ChordDetailModal) keeps **all shapes + the normal piano
|
||||||
|
layout**. *Global:* the ▶ is **removed from the component outright** (§3) — no
|
||||||
|
mount wants it anymore.
|
||||||
|
- `MiniPiano`: the smaller keyboard is a **new additive `size="mini"`**; `thumb`
|
||||||
|
and `full` are byte-untouched, so the modal/ExplorePanel render identically.
|
||||||
|
- `LickCard`: the uniform footprint is imposed **at the dashboard-strip mount**
|
||||||
|
(a wrapper box), **not** in `LickCard` — its default footprint is unchanged.
|
||||||
|
`LickCard` itself is edited only if it must lose a ▶ (it has none — see §3.3),
|
||||||
|
so the dock's LicksSection (`size="full"`) renders unchanged. `PianoLickCard`
|
||||||
|
is all-new to the strip.
|
||||||
|
- Scrollbar CSS: a **scoped `.dark-scroll` class** on the dashboard scrollers
|
||||||
|
only — the KC's own scrolling keeps the OS default.
|
||||||
|
|
||||||
|
**Restated L-70/L-71 byte-identity DoD:** the dock's VoicingsSection /
|
||||||
|
LicksSection and `ChordDetailModal` render **byte-identical to today EXCEPT the
|
||||||
|
▶ buttons are gone** (the D-41/D-51 non-dense-mount precedent — Critic
|
||||||
|
diff-checks it: the only permitted diff in those mounts is the removed play
|
||||||
|
button + its now-dead wiring).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Guitar: ≤4 shapes, recommended-first (dashboard `dense` only)
|
||||||
|
|
||||||
|
### 1.1 What changes structurally
|
||||||
|
|
||||||
|
Today the rail's guitar station shows a **separate own-cell** (the KB play's
|
||||||
|
recommended shape, accent border, "play" badge — `GlanceRail` StationRow
|
||||||
|
:215-231) **plus** `VoicingBrowser`'s full gallery of *every* placeable shape
|
||||||
|
(`matchingShapes` :76 returns all). Two problems: (a) the recommended shape is
|
||||||
|
usually also in `matchingShapes`, so it renders **twice**; (b) `maj`/`min`/`dom7`
|
||||||
|
have 5-6 placeable shapes, so own-cell + gallery = 6-7 cells that wrap to a
|
||||||
|
second line.
|
||||||
|
|
||||||
|
**Decision:** centralise the ≤4 rule inside `VoicingBrowser` and **delete the
|
||||||
|
separate own-cell**. `GlanceRail` passes the recommended shape down; the browser
|
||||||
|
renders it as the badged first cell and caps the total.
|
||||||
|
|
||||||
|
- New `VoicingBrowser` props (additive, default-off so modal/dock stay
|
||||||
|
byte-identical): `recommended` (a shape object) and `max` (number).
|
||||||
|
- `GlanceRail` (dense rail) passes `recommended={st.shape}` and `max={4}`.
|
||||||
|
- Non-dense mounts (`ChordDetailModal` Guitar tab, `KnowledgeDock` VoicingsSection)
|
||||||
|
pass neither → they keep showing **all** shapes with no cap (a detail view
|
||||||
|
should be exhaustive; the ≤4 fit constraint is the rail's alone).
|
||||||
|
|
||||||
|
This removes the pre-existing duplicate and guarantees ≤4 in one place.
|
||||||
|
|
||||||
|
### 1.2 The top-4 selection rule (exact ordering + tiebreak)
|
||||||
|
|
||||||
|
`matchingShapes(quality, rootPc)` still returns every placeable shape. The rail
|
||||||
|
then orders and slices:
|
||||||
|
|
||||||
|
1. **Recommended first** — the KB play's shape (`recommended`, matched into the
|
||||||
|
list by `label`; if it was filtered out as unplaceable, prepend it anyway).
|
||||||
|
Badged `play`, accent border — "the answer" prominence the old own-cell had,
|
||||||
|
now cell #1 of the row.
|
||||||
|
2. **Open-position forms** — shapes with an `Array.isArray(shape.frets)` grip
|
||||||
|
(open chords). They sit at the nut, sound the most idiomatic, and are what a
|
||||||
|
player reaches for first.
|
||||||
|
3. **Movable / barre forms by lowest base fret ascending** — closest to the nut
|
||||||
|
first (easiest hand position, most common voicing). Base fret is the value
|
||||||
|
`matchingShapes` already computes (`mod12(rootPc − OPEN_PCS[idx])`, 0→12).
|
||||||
|
4. **Tiebreak** at equal category/base fret: **fewer muted strings first**
|
||||||
|
(count of `'x'` in `frets`/`offsets` — a fuller voicing wins), then the
|
||||||
|
shape's **declared order in `GUITAR_SHAPES[quality]`** (stable, honours the
|
||||||
|
KB author's priority). Deterministic — no reflow flicker as the root moves.
|
||||||
|
|
||||||
|
Take the first **4** (recommended + up to 3 more, recommended deduped so it
|
||||||
|
never repeats). **Qualities with <4 placeable shapes just show what they have**
|
||||||
|
— the rule is a cap, never padding; nothing is invented to reach four.
|
||||||
|
|
||||||
|
### 1.3 It fits one line
|
||||||
|
|
||||||
|
Guitar `GalleryCell` (dense, **no play button** — see §3): `ChordDiagram` thumb
|
||||||
|
75px + p-1.5 (12) + border (2) = **89px** box. Four across:
|
||||||
|
|
||||||
|
```
|
||||||
|
4×89 + 3×6 (gap-1.5) = 356 + 18 = 374 ≤ 451 ✓ (a 5th would be 469 ✗)
|
||||||
|
```
|
||||||
|
|
||||||
|
Four shapes on **one horizontal line, no wrap, no scroll** — exactly the user's
|
||||||
|
"4 guitar options visible so it would fit without scrolling."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Piano: 2×2 of compact MiniPianos (dashboard `dense` only)
|
||||||
|
|
||||||
|
### 2.1 The grid
|
||||||
|
|
||||||
|
**Under `dense`** the four `pianoVoicing` styles (`root` / `shell` / `rootlessA`
|
||||||
|
/ `rootlessB`, `PIANO_STYLES` :67) render as a **2-column × 2-row CSS grid**
|
||||||
|
(`grid-cols-2 gap-1.5`) — one clean 2×2 per chord. **Non-dense** (VoicingsSection,
|
||||||
|
ChordDetailModal) keeps today's flex-wrap gallery at `MiniPiano size="thumb"`
|
||||||
|
(0.8) with ▶ — byte-identical. As with guitar, the **separate own-cell is
|
||||||
|
dropped for piano**; the 2×2 *is* the chord's voicings. If the station's
|
||||||
|
authored voicing corresponds to one of the four styles (match on
|
||||||
|
`voicing.style`), that cell gets the accent border — the "recommended" signal,
|
||||||
|
for free, without a fifth cell. (Fuzzy-authored voicings that match no style →
|
||||||
|
no highlight; honest.)
|
||||||
|
|
||||||
|
Because the own-cell is gone, the piano grid spans the **full row interior**
|
||||||
|
(the `basis-[320px] flex-1` gallery column grows to ~451px), so each of the two
|
||||||
|
grid columns is `(451 − 6)/2 = 222.5px`.
|
||||||
|
|
||||||
|
### 2.2 The scale — arithmetic
|
||||||
|
|
||||||
|
`MiniPiano` VOICING thumb geometry: `baseW = 22·(7·OCTAVES + 1) + 2`,
|
||||||
|
`SVG_W = baseW · scale`, `SVG_H = (60 + 4)·scale`. Today `thumb` scale = **0.8**:
|
||||||
|
|
||||||
|
| crop | baseW | SVG_W @0.8 | cell box @0.8 (+14) | two-up + gap |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1-octave | 178 | 142.4 | 156.4 | 318.8 ≤ 451 ✓ |
|
||||||
|
| 2-octave | 332 | 265.6 | 279.6 | **565.2 > 451 ✗** |
|
||||||
|
|
||||||
|
So 0.8 **cannot** put two 2-octave crops side by side (the brief's 532>456). The
|
||||||
|
2-octave crop (the rootless 7th-chord voicings — `rootlessA/B` of `min7`/`dom7`/
|
||||||
|
`maj7` whose span pushes past one octave) dictates the scale. Solve for a column
|
||||||
|
of 222.5px:
|
||||||
|
|
||||||
|
```
|
||||||
|
332·scale + 14 ≤ 222.5 → scale ≤ 208.5 / 332 = 0.628 (the ceiling)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chosen: a new uniform `size="mini"` at scale 0.60** — below the 0.628 ceiling,
|
||||||
|
leaving a robust ~19px margin for sub-pixel rounding and the classic-scrollbar
|
||||||
|
worst case:
|
||||||
|
|
||||||
|
| crop @ **0.60** | SVG_W | cell box (+14) | two-up + gap (6) | fits 451? |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1-octave | 106.8 | 120.8 | 247.6 | ✓ (203 spare) |
|
||||||
|
| 2-octave | 199.2 | 213.2 | **432.4** | ✓ (18.6 spare) |
|
||||||
|
|
||||||
|
`grid-cols-2` sizes every cell to the 222.5px column, so 1- and 2-octave cells
|
||||||
|
share one uniform grid — a true 2×2 at both crops. A thumb crop never exceeds
|
||||||
|
**2 octaves** (a 4-note `pianoVoicing` spans ≤24 semitones; `OCTAVES =
|
||||||
|
ceil((maxNote − octStart·12)/12) ≤ 2`), so 2-octave is the proven worst case —
|
||||||
|
no 3-octave escape hatch needed.
|
||||||
|
|
||||||
|
### 2.3 Legibility at 0.60 — the marks stay honest
|
||||||
|
|
||||||
|
White key = 22·0.60 = **13.2px** wide (vs `ChordDiagram`'s 11px string gap —
|
||||||
|
comparable, legibly playable). The root/bass signal is carried **primarily by
|
||||||
|
colour**, which does not shrink in meaning: root key = full accent #a855f7, other
|
||||||
|
tones = ACCENT_SOFT #c084fc, bass = amber ring #fbbf24 — the established tier
|
||||||
|
language. The "R" glyph and the amber ring are secondary reinforcement. Because
|
||||||
|
the SVG viewBox scales text with the keys, at 0.60 the `R` renders ~4.8px and the
|
||||||
|
ring ~1.2px — thin. **So `size="mini"` bumps the in-SVG `R` fontSize (8→10 in
|
||||||
|
viewBox units) and the bass-ring `strokeWidth` (2→2.5)** so both stay readable at
|
||||||
|
the smaller render. This is an additive branch in `MiniPiano.jsx` (Muse-owned SVG
|
||||||
|
renderer); the existing `thumb`/`full` paths are byte-untouched, so the modal and
|
||||||
|
dock render identically.
|
||||||
|
|
||||||
|
### 2.4 Row height (piano)
|
||||||
|
|
||||||
|
`MiniPiano` mini SVG_H = 64·0.60 = **38.4px**. Piano `GalleryCell` (dense, no
|
||||||
|
play): caption ~13 + gap-1.5 (6) + SVG 38.4 + p-1.5 (12) + border (2) = **71.4px**.
|
||||||
|
Two rows + grid row-gap (6) = **148.8px** for the 2×2 block — versus the old
|
||||||
|
gallery's ~3 wrapped cell-lines of ~118px each (~366px). Nearly halved (§6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Remove ▶ everywhere (deliberate global removal)
|
||||||
|
|
||||||
|
Standing principle (memory): **glance over audio.** The user settled it — "then
|
||||||
|
leave them off, better not." So ▶ comes off **every mount**: the dashboard rail,
|
||||||
|
the dashboard strip, **and** the Knowledge Center + `ChordDetailModal`. Because no
|
||||||
|
mount wants playback, the machinery is **deleted outright**, not gated — this is
|
||||||
|
intended, not a side-effect.
|
||||||
|
|
||||||
|
### 3.1 `VoicingBrowser.jsx` (L-70) — delete the play machinery
|
||||||
|
|
||||||
|
- The `PlayButton` component (:103-121).
|
||||||
|
- `import { playVoicing, guitarShapeToNotes } from '../lib/chordAudio'` (:62).
|
||||||
|
- `handleRef` + `stopCurrent` (:184-188) and the `useEffect(() => stopCurrent,
|
||||||
|
[chordKey])` cleanup (:191) — both now dead.
|
||||||
|
- `playGuitar` / `playPiano` handlers (:197-211).
|
||||||
|
- The `<PlayButton>` inside `GalleryCell` (:149) and its `playLabel`/`onPlay`
|
||||||
|
props; the `playLabel`/`onPlay` passed at each call site (:243-244, :280-281).
|
||||||
|
- The mic-feedback microcopy (:300-309) — nothing plays anywhere now, so the
|
||||||
|
caveat is false; delete it.
|
||||||
|
- Now-unused after the above: the `useRef`/`useEffect` imports and the `chordKey`
|
||||||
|
local (only the deleted effect read it). Update the file-header comment.
|
||||||
|
|
||||||
|
This is the **only** permitted change to the non-dense (VoicingsSection /
|
||||||
|
ChordDetailModal) render — those mounts are otherwise byte-identical (all shapes,
|
||||||
|
normal piano layout); the visual `dense`-fork of §1/§2 leaves them alone.
|
||||||
|
|
||||||
|
### 3.2 `GlanceRail.jsx` (L-70) — dashboard-exclusive
|
||||||
|
|
||||||
|
`GlanceRail` is mounted **only** by the JamGuide rail, so its edits never leak:
|
||||||
|
- Delete the footer microcopy "▶ previews play through your speakers…"
|
||||||
|
(:292-295); replace with nothing (or a quiet "voicings follow the loop").
|
||||||
|
- Delete the own-cell `<figure>` block (:215-231) — folded into `VoicingBrowser`
|
||||||
|
per §1; pass `recommended={st.shape}` + `max={4}`.
|
||||||
|
- Update the header comment (":45 …every ▶ lives inside the gallery").
|
||||||
|
|
||||||
|
### 3.3 The licks strip (L-71)
|
||||||
|
|
||||||
|
- **`LickCard.jsx` has no play path** — the guitar tab card never had a ▶
|
||||||
|
(verified: no `PlayButton`, no `chordAudio` import). Nothing to remove there;
|
||||||
|
the dock's `size="full"` LickCards are unchanged.
|
||||||
|
- **`PianoLickCard.jsx` is the only lick ▶.** Remove: `import { playVoicing,
|
||||||
|
stopAll } from '../lib/chordAudio'` (:73); the `PREVIEW_BPM`/`currentSeq`/
|
||||||
|
`stopLick`/`playLick` sequencer (:167-198); the `PlayButton` component
|
||||||
|
(:484-502) and its render (:606-609); the `useEffect(() => () => stopLick(),
|
||||||
|
[])` unmount silence (:530, then drop the now-unused `useEffect` import). Keep
|
||||||
|
the `resolveDegree` import — realization needs it.
|
||||||
|
|
||||||
|
### 3.4 `chordAudio.js` stays — verified importers
|
||||||
|
|
||||||
|
`grep chordAudio src/**` → importers are `VoicingBrowser.jsx` (▶ deleted in
|
||||||
|
§3.1), `PianoLickCard.jsx` (▶ deleted in §3.3), and **`BassPatternCard.jsx`**
|
||||||
|
(untouched — the bass-rail pattern previews the user did **not** ask to remove,
|
||||||
|
out of both locks); the rest are doc comments in `voicings.js`.
|
||||||
|
`ChordDetailModal.jsx` does **not** import it directly — it plays via
|
||||||
|
`VoicingBrowser`, so deleting that ▶ removes the modal's too, exactly as the
|
||||||
|
user's global decision intends. So `chordAudio.js` stays (for `BassPatternCard`),
|
||||||
|
but nothing in the voicing/lick UI plays.
|
||||||
|
|
||||||
|
### 3.5 The shared-sequencer blocker is eliminated
|
||||||
|
|
||||||
|
D-60 flagged that `BassPatternCard` and `PianoLickCard` each hold a **private
|
||||||
|
module-level `currentSeq`**, so their previews could layer once both mount — the
|
||||||
|
reason PianoLickCard was never wired into the strip. Removing PianoLickCard's
|
||||||
|
playback deletes its `currentSeq` entirely: **there is no piano sequencer left to
|
||||||
|
conflict with.** The strip becomes purely visual; `BassPatternCard`'s sequencer
|
||||||
|
lives only under the BASS selector (where the strip is hidden anyway). So
|
||||||
|
PianoLickCard wires into the strip (§5) with **no `currentSeq` collision** —
|
||||||
|
confirmed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Hidden-but-scrollable dark scrollbars
|
||||||
|
|
||||||
|
### 4.1 Mechanism — a scoped utility, defined once in `index.css` (L-70)
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Thin, dark, overlay-feel scrollbars for the jam dashboard's scrollers.
|
||||||
|
Scoped (a class), NOT global — the KnowledgeDock/Debug/Tuner/Settings
|
||||||
|
below keep the OS default. Raw hex is unavoidable here: ::-webkit-scrollbar
|
||||||
|
pseudo-elements are not reachable by Tailwind utilities. The values mirror
|
||||||
|
the design tokens (border #2a2a2a, panel #1a1a1a). */
|
||||||
|
.dark-scroll {
|
||||||
|
scrollbar-width: thin; /* Firefox */
|
||||||
|
scrollbar-color: #2a2a2a transparent; /* thumb=border token · track transparent */
|
||||||
|
}
|
||||||
|
.dark-scroll::-webkit-scrollbar { width: 8px; height: 8px; } /* WebKit/Blink */
|
||||||
|
.dark-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.dark-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: #2a2a2a; border-radius: 4px; /* border token */
|
||||||
|
}
|
||||||
|
.dark-scroll::-webkit-scrollbar-thumb:hover { background: #3a3a3a; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Scoped, not global** (`*`): the recommendation is a `.dark-scroll` opt-in
|
||||||
|
class. Global would restyle every scroller in the app (the whole normal-mode
|
||||||
|
page, the dock, Settings' full-screen scroller). The user's ask is about the
|
||||||
|
dashboard's rail; keep the blast radius there.
|
||||||
|
- **Raw-hex exception, flagged to Maestro:** the DoD says "no raw hex outside
|
||||||
|
`tailwind.config.js`," but `::-webkit-scrollbar` cannot consume a Tailwind
|
||||||
|
class. `index.css` (which already carries the raw `#0f0f0f` body background)
|
||||||
|
is the correct home; the values are documented as mirroring the `border`
|
||||||
|
token. No *new* colour — #3a3a3a hover is a one-step lift of the same family;
|
||||||
|
if Maestro prefers, use `panel` #1a1a1a for the base and `border` #2a2a2a for
|
||||||
|
hover instead (both existing tokens). Either is fine.
|
||||||
|
- Contrast: a #2a2a2a thumb on the #0f0f0f/#1a1a1a surface is intentionally
|
||||||
|
quiet — it is chrome, not content, so it is exempt from AA text contrast; it
|
||||||
|
is still clearly grabbable (the point of "make them black or something").
|
||||||
|
|
||||||
|
### 4.2 Where it applies — every dashboard scroll container
|
||||||
|
|
||||||
|
| # | scroller | file:line | task | note |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | **rail right column** `xl:overflow-y-auto` | `JamGuide.jsx:484` | **L-71** | the headline visible bar |
|
||||||
|
| 2 | left column (jam-view) `xl:overflow-y-auto` | `JamGuide.jsx:475` | L-71 | consistency |
|
||||||
|
| 3 | LicksStrip horizontal `overflow-x-auto` | `JamGuide.jsx:888` | L-71 | the licks row |
|
||||||
|
| 4 | per-cell `overflow-x-auto` (voicing) | `VoicingBrowser.jsx:148` | L-70 | rarely triggers now (§2) — apply for the edge case |
|
||||||
|
| 5 | timeline/keyboard `overflow-x-auto` | `PianoLickCard.jsx:576,585` | L-71 | wide-lick edge case |
|
||||||
|
|
||||||
|
**File-collision resolution (the brief's ask):** the rail's *outer vertical
|
||||||
|
scroller lives in `JamGuide.jsx`*, which is **L-71's exclusive lock** — not
|
||||||
|
`GlanceRail`, not `App.jsx`. So the class **definition** ships in `index.css`
|
||||||
|
(L-70), and each `className="… dark-scroll"` **application** is made by whichever
|
||||||
|
task owns the file it lives in: L-70 applies it in `VoicingBrowser` (#4), L-71
|
||||||
|
applies it in `JamGuide` (#1-3) and `PianoLickCard` (#5). The class exists before
|
||||||
|
L-71 runs because **L-71 depends-on L-70** (already in the ledger). Zero shared
|
||||||
|
files. The visible rail bar is styled in L-71 — acceptable, because that scroller
|
||||||
|
was always a JamGuide-column concern, not a `GlanceRail` one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Licks: uniform size + follow the instrument
|
||||||
|
|
||||||
|
### 5.1 The strip follows the global instrument
|
||||||
|
|
||||||
|
`instrument` already reaches the strip: App's global selector → `JamGuide`
|
||||||
|
`instrument` prop (:177) → `LicksStrip({ …, instrument })` (:433-445, :863).
|
||||||
|
Today the strip **always renders `LickCard`** (guitar), even under PIANO, with an
|
||||||
|
apologetic heading "(no piano licks in the KB yet)" (:885). Change:
|
||||||
|
|
||||||
|
- `guitar` → `LickCard` (unchanged renderer).
|
||||||
|
- `piano` → `PianoLickCard` (wire it in — the D-60 component, never mounted).
|
||||||
|
Needs `rootPc` + `chordLabel` from the playhead `context` station (which
|
||||||
|
carries `rootPc`/`quality`/`label`), and each lick carries its own `quality`.
|
||||||
|
- `bass` → the strip is already gated off (:432/:439 `instrument !== 'bass'`);
|
||||||
|
render a slim honest line "No bass licks in the KB yet" instead of nothing, so
|
||||||
|
the section doesn't silently vanish when a bassist is selected.
|
||||||
|
|
||||||
|
The licks reader `licksFor(id)` (:136) is **hardcoded to
|
||||||
|
`instruments.guitar.licks`** — L-71 generalises it to
|
||||||
|
`licksFor(id, instrument)`, reading `instruments.piano.licks` under piano, and
|
||||||
|
**filtering piano licks to the structured ones** (those with a `notes` array) so
|
||||||
|
prose-only entries don't render as `PianoLickCard` placeholders (see §5.3).
|
||||||
|
|
||||||
|
### 5.2 Uniform footprint — measure, target, change
|
||||||
|
|
||||||
|
Both are wrapped by the strip in `w-[220px]` (closed) / `w-[340px]` (open)
|
||||||
|
(:894), so **width is already uniform**. The gap is **height** — the two SVGs
|
||||||
|
auto-size by different aspect ratios:
|
||||||
|
|
||||||
|
- **`LickCard` thumb** (`layoutTab`): viewBox 96 units tall (17 + 5·14 + 9),
|
||||||
|
`TabSvg` `maxWidth = width·1.3`; inside a 204px inner box it renders ~125px
|
||||||
|
tall. Card ≈ 163px.
|
||||||
|
- **`PianoLickCard` thumb** (`layoutLick`): height = 14 + `plotH` + 8 (+10 beat
|
||||||
|
row), `plotH = max(26, range·semi)` — **varies with the lick's pitch range**,
|
||||||
|
so a wide lick is much taller than a narrow one and neither matches LickCard.
|
||||||
|
|
||||||
|
**Target: a shared thumb card of `220 × 150px`** (closed), `340px` wide (open).
|
||||||
|
**Imposed at the dashboard-strip mount, not inside the cards** (per the scope
|
||||||
|
refinement — do not touch `LickCard`'s default footprint). The `LicksStrip`
|
||||||
|
wrapper (JamGuide, L-71) that already sets `w-[220px]`/`w-[340px]` (:894) also
|
||||||
|
sets a **fixed content height** and normalises the card's SVG to fill it, e.g.
|
||||||
|
`class="… h-[150px] [&_svg]:!h-[104px] [&_svg]:!w-full"`. The cards' SVGs already
|
||||||
|
carry a `viewBox`, so `preserveAspectRatio="xMidYMid meet"` (the SVG default)
|
||||||
|
scales the tab / timeline to the 104px box and centres it — **zero edit to
|
||||||
|
`LickCard` or `PianoLickCard` internals.** Every card — guitar tab or piano roll —
|
||||||
|
then occupies the **same box**, "all the same size," and flips instrument in
|
||||||
|
place. (The `maxWidth: width·1.3/1.4` inline caps mean a short lick renders
|
||||||
|
narrower than 220 and centres — the card *box* is uniform; content is centred.)
|
||||||
|
|
||||||
|
**Scope proof (KC untouched):** the fixed-box CSS lives on the **`LicksStrip`
|
||||||
|
wrapper only** — a dashboard-exclusive element. `LickCard`'s and `PianoLickCard`'s
|
||||||
|
own geometry is unchanged, so the dock's LicksSection `size="full"` LickCards
|
||||||
|
(:817) render **byte-identical** (the L-71 DoD asserts it). `PianoLickCard` has no
|
||||||
|
KC mount at all.
|
||||||
|
|
||||||
|
### 5.3 KB coverage + empty-per-style behaviour
|
||||||
|
|
||||||
|
Verified against the KB (`instruments.piano.licks` with a structured `notes`
|
||||||
|
array — PianoLickCard-renderable):
|
||||||
|
|
||||||
|
| style | guitar licks | piano licks (structured) |
|
||||||
|
|---|---|---|
|
||||||
|
| jazz, blues, gospel, rnb | ✓ | ✓ (P-60/P-61) |
|
||||||
|
| pop | ✓ | **prose only** (`over`/`description`) — not renderable |
|
||||||
|
| bossa, country, funk, reggae, rock | ✓ | ✗ |
|
||||||
|
|
||||||
|
- **Guitar:** all 10 styles have tab licks — the strip works everywhere.
|
||||||
|
- **Piano:** structured, renderable licks exist for **jazz, blues, gospel, rnb**
|
||||||
|
only. Pop's `piano.licks` are the old prose education entries (no `notes`) —
|
||||||
|
the structured filter drops them, so pop reads as empty under piano too.
|
||||||
|
- **Empty-per-style:** today the strip returns `null` when empty (hides). Keep
|
||||||
|
hide-on-empty for **guitar** (the default; a silent gap is fine). For **piano
|
||||||
|
with zero structured licks** (pop + the 5 styles above), show a **slim honest
|
||||||
|
line** — "No {style} piano licks yet" — because the user *actively switched to
|
||||||
|
piano* and a vanished section is confusing there. Same slim line for bass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Recomputed budget
|
||||||
|
|
||||||
|
Per-station row (row p-1.5 12 + border 2 + header block ~54 + gallery):
|
||||||
|
|
||||||
|
| | gallery block | **new row** | pre-polish row |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Guitar | 4 cells ×1 line, no ▶ = **105px** | **~173px** | ~207px (5-6 cells, ▶, wraps) |
|
||||||
|
| Piano | 2×2 mini, no ▶ = **148.8px** | **~217px** | ~445px (0.8, ▶, 3 wrapped lines) |
|
||||||
|
|
||||||
|
Rail overhead (section p-2 16 + border 2 + h4 title ~22 + footer ~40) ≈ 80px;
|
||||||
|
rows joined by `gap-2` (8px).
|
||||||
|
|
||||||
|
| loop | instrument | **new total** | pre-polish | Δ |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 4-chord | guitar | 80 + 4·173 + 24 = **796** | ~932 | −15% |
|
||||||
|
| 4-chord | piano | 80 + 4·217 + 24 = **972** | ~1884 | **−48%** |
|
||||||
|
| 8-chord | guitar | 80 + 8·173 + 56 = **1520** | ~1720 | −12% |
|
||||||
|
| 8-chord | piano | 80 + 8·217 + 56 = **1872** | ~3640 | **−49%** |
|
||||||
|
|
||||||
|
**Headline: the piano rail nearly halves (−48%), and the guitar row is now a
|
||||||
|
guaranteed single line (≤4, never wraps).** Every total still exceeds the 500px
|
||||||
|
column, so **the rail remains the dashboard's one vertical scroller** — which is
|
||||||
|
exactly what §2's dark thin scrollbar dresses, and what the user licensed ("it
|
||||||
|
can scroll if we need to"). The polish makes each row compact and the scroll
|
||||||
|
pretty; it does not (and need not) fit a whole loop in 500px.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Migration order + bounded scopes
|
||||||
|
|
||||||
|
**L-70 — the rail.** Files (file-disjoint from L-71):
|
||||||
|
`src/components/GlanceRail.jsx`, `src/components/VoicingBrowser.jsx`,
|
||||||
|
`src/components/MiniPiano.jsx` (new `size="mini"` + R/ring bump — additive),
|
||||||
|
`src/index.css` (define `.dark-scroll`).
|
||||||
|
Does: guitar ≤4 recommended-first, **dense-only** (§1); piano 2×2 mini @0.60,
|
||||||
|
**dense-only** (§2); ▶ **deleted globally** from `VoicingBrowser`, own-cell +
|
||||||
|
footer from `GlanceRail` (§3.1-3.2); `.dark-scroll` defined + applied to
|
||||||
|
VoicingBrowser's cell scroller (§4 #4). `MiniPiano.jsx` is safe to lock here — no
|
||||||
|
other polish task touches it (`PianoLickCard`/`LickCard` carry their **own**
|
||||||
|
keyboard geometry and do not import `MiniPiano`).
|
||||||
|
**DoD byte-identity:** the non-dense `VoicingBrowser` render (VoicingsSection +
|
||||||
|
ChordDetailModal) is byte-identical to today **except the ▶ is removed**;
|
||||||
|
`MiniPiano` `thumb`/`full` byte-untouched (new `mini` is additive).
|
||||||
|
|
||||||
|
**L-71 — the licks.** Files: `src/components/JamGuide.jsx` (LicksStrip +
|
||||||
|
`licksFor` + the fixed-box wrapper + the scroller classNames),
|
||||||
|
`src/components/PianoLickCard.jsx`. **`LickCard.jsx` is *not* edited** — it has no
|
||||||
|
▶ and its footprint is imposed from the strip wrapper (§5.2), so it drops out of
|
||||||
|
the lock (dock LicksSection unaffected).
|
||||||
|
Does: uniform 220×150 thumb via the **strip wrapper** (§5.2); instrument branch +
|
||||||
|
PianoLickCard wiring + piano/bass empty states (§5.1/5.3); ▶ deleted from
|
||||||
|
`PianoLickCard` (§3.3); `.dark-scroll` applied to JamGuide's three scrollers +
|
||||||
|
PianoLickCard's (§4 #1-3,5).
|
||||||
|
**DoD byte-identity:** the dock LicksSection's `size="full"` LickCards render
|
||||||
|
byte-identical to today (no ▶ existed to remove there).
|
||||||
|
|
||||||
|
**Serialise L-70 → L-71** (already the ledger's `depends-on`): they are
|
||||||
|
**file-disjoint** (no shared file — the `.dark-scroll` *class* is defined in
|
||||||
|
L-70's `index.css` and merely *referenced* by L-71's JSX, which is not a file
|
||||||
|
edit collision), and L-71 needs L-70's class to exist. `JamGuide.jsx` belongs to
|
||||||
|
**exactly one** task (L-71), so the rail-column scrollbar has a single owner — no
|
||||||
|
shared-file clash. Critic gates each; `C-70` closes the sprint.
|
||||||
|
|
||||||
|
### Rejected alternatives
|
||||||
|
|
||||||
|
1. **Shrink `MiniPiano`'s global `thumb` scale (0.8→0.6) instead of a new
|
||||||
|
`mini`.** Rejected — it also shrinks the `ChordDetailModal` and dock thumbs,
|
||||||
|
which have room to spare and benefit from the larger keys; a scoped `mini`
|
||||||
|
variant keeps those byte-identical and confines the change to the rail.
|
||||||
|
2. **Give 2-octave piano crops their own full-width row (mixed cell sizes) at a
|
||||||
|
larger scale (~0.7).** Rejected — it breaks the clean "2×2 for each chord" the
|
||||||
|
user asked for; a uniform 0.60 grid keeps every chord a tidy 2×2 and 0.60 is
|
||||||
|
still legibly playable (13.2px keys ≈ the guitar diagram's string gap).
|
||||||
|
3. **Keep the separate own-cell and cap the gallery to 3.** Rejected — it leaves
|
||||||
|
the ≤4 rule split across two components (own-cell in `GlanceRail`, cap in
|
||||||
|
`VoicingBrowser`) and preserves the today's recommended/gallery duplicate;
|
||||||
|
centralising in `VoicingBrowser` (§1.1) is one place, one rule, no dupe.
|
||||||
|
4. **Global `*` scrollbar styling.** Rejected — restyles the entire app
|
||||||
|
(dock/Debug/Tuner/Settings) for a dashboard-scoped ask; a `.dark-scroll`
|
||||||
|
opt-in class is surgical.
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# Glance Mode — the Knowledge Center while the jam plays (task D-31)
|
||||||
|
|
||||||
|
> **Thesis:** while a loop is live, the Jam Guide section stops being "Roadmap + one
|
||||||
|
> thumbnail per chord, tap to see more" and becomes a **playhead accordion**: a
|
||||||
|
> station-aligned rail under the Roadmap where the *current* station's column is
|
||||||
|
> expanded to the full D-30 voicing gallery (every placeable guitar shape, or every
|
||||||
|
> piano style, side by side) and every other station shows its recommended thumb.
|
||||||
|
> The expansion **follows the playhead** — over one loop cycle you see every
|
||||||
|
> variation of every chord with **zero clicks**. A licks strip for the matched style
|
||||||
|
> sits directly below, auto-sorted to the chord you're on.
|
||||||
|
>
|
||||||
|
> User directive (2026-07-10, verbatim): *"expand the learn session so we see as
|
||||||
|
> much as possible in voicing variations and styles without having to click buttons,
|
||||||
|
> the idea is to have it open as the jam is playing."*
|
||||||
|
|
||||||
|
**Decision authority:** per the sprint header (user away, no user gate), Muse picks
|
||||||
|
the strongest layout and records rationale + rejected alternatives (§6).
|
||||||
|
Implementation is task **L-33**; this doc is its blueprint. Depends on **D-30**
|
||||||
|
(VoicingBrowser becomes an all-variations gallery — chips removed, every
|
||||||
|
shape/style rendered simultaneously, each with its own ▶).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. What exists today (read from the code, not assumed)
|
||||||
|
|
||||||
|
- `JamGuide.jsx` §jam renders `RoadmapAssembly`: `RoadmapTrack` on top, then a
|
||||||
|
**voicing strip** — ONE thumb per station (ChordDiagram thumb 75×72 px, or
|
||||||
|
MiniPiano D-24 cropped thumb ≈142 px for a 1-octave voicing / ≈266 px for a
|
||||||
|
2-octave one), tap-to-enlarge → full diagram + a `VoicingBrowser` mount.
|
||||||
|
Variations are therefore **one click away per chord** — the exact thing the
|
||||||
|
directive kills.
|
||||||
|
- `canonicalPos` (playhead station index, canonical KB order) already exists and
|
||||||
|
already drives `scrollIntoView` on the strip. **All glance data is already in
|
||||||
|
the component.**
|
||||||
|
- `VoicingBrowser` (post-D-30) renders ALL matching guitar shapes side by side
|
||||||
|
(cells ≈90–120 px wide) and all four piano styles side by side (cropped
|
||||||
|
MiniPiano thumbs ≈140 px+), each independently playable. Placeable guitar
|
||||||
|
shapes per chord: **3–5** (maj: 4 movable + 1 native open; min7/dom7/maj7: 3–4;
|
||||||
|
dim/half_dim: 2). Piano styles: always **4** (root/shell/rootlessA/rootlessB),
|
||||||
|
or the authored recipe (L-24) plus the computed four.
|
||||||
|
- `LickCard` has `size="thumb"` and `LicksSection` already knows how to read
|
||||||
|
`kb[style].instruments.guitar.licks` defensively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Chosen layout — the playhead accordion
|
||||||
|
|
||||||
|
```
|
||||||
|
┌ KNOWLEDGE CENTER — ii–V–I in C major ────────────────────────────────── ▲ ┐
|
||||||
|
│ [▶ Jam Guide ●] [Explore] [Voicings] [Licks & Techniques] │
|
||||||
|
│ [🎸 Guitar][🎹 Piano][🎵 Bass] · [Jazz●][Blues][Rock]… │
|
||||||
|
│ ┌─ ROADMAP ──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Dm7 (ii) ──C→B── G7 (V) «NOW» ──F→E── Cmaj7 (I) ▁▁▁▂█▂▁▁▁ beat │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─ VARIATIONS · follows the playhead ────────────────────────────────────┐ │
|
||||||
|
│ │ Dm7 ┃ G7 — every shape ┃ Cmaj7 │ │
|
||||||
|
│ │ [thumb] ┃ [E Barre][A7 Barre][D Shape][Open G7] ┃ [thumb] │ │
|
||||||
|
│ │ (recmd.) ┃ ▶ ▶ ▶ ▶ ┃ (recmd.) │ │
|
||||||
|
│ └─────────────┸───────────────────────────────────────┸───────────────────┘ │
|
||||||
|
│ ┌─ JAZZ LICKS · over the V7 first ─────────────────────────── (scroll) ──┐ │
|
||||||
|
│ │ [lick thumb «fits G7»] [lick thumb] [lick thumb] … │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Columns = stations** (canonical KB order — the kb-plan §4 grid intent,
|
||||||
|
finally literal): the rail's columns sit under the Roadmap's stations, same
|
||||||
|
order, same labels/rn.
|
||||||
|
- **Active column = expanded**: the station at `canonicalPos` renders the full
|
||||||
|
D-30 gallery (`<VoicingBrowser rootPc quality show={instrument}/>`). Collapsed
|
||||||
|
columns render today's recommended thumb (the KB play's shape / the threaded
|
||||||
|
piano voicing) — so the loop context and lookahead never disappear.
|
||||||
|
- **The accordion advances with the playhead.** Chord changes are the animation;
|
||||||
|
nothing else moves. Constant footprint: expanding one column collapses the
|
||||||
|
previous one, so the rail never grows taller mid-jam.
|
||||||
|
- **Licks strip** below: thumb LickCards for the active style, level-filtered,
|
||||||
|
sorted current-chord-context first (see §2.4).
|
||||||
|
|
||||||
|
Why this wins: it satisfies "as much as possible without clicking" *without*
|
||||||
|
pretending everything fits at once (§3 shows it can't) — across one loop cycle
|
||||||
|
the player is shown 100% of the variations, always for the chord their hands are
|
||||||
|
on, which is when a variation is actually learnable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Auto-follow rules (zero clicks, precisely)
|
||||||
|
|
||||||
|
1. **Driver = `canonicalPos`** (already computed in JamGuide from
|
||||||
|
`findLoopPosition` + `match.rotation`). Expanded column = `canonicalPos`;
|
||||||
|
`position === -1` (loop known but playhead unknown) → station 0 expands, so
|
||||||
|
the rail is never all-collapsed. The active column auto-scrolls into view
|
||||||
|
(`scrollIntoView({inline:'center'})` — the existing strip pattern, prop-driven
|
||||||
|
off `position`, **no rAF, nothing tied to the audio thread**). Respect
|
||||||
|
`prefers-reduced-motion`: `behavior: 'auto'` instead of `'smooth'` (new —
|
||||||
|
today's strip smooth-scrolls unconditionally; fix while in there).
|
||||||
|
2. **Instrument tab** (existing) selects the gallery family — guitar shapes vs
|
||||||
|
piano styles. It's a mode preference, not a per-chord click; it keeps its
|
||||||
|
current default/behaviour.
|
||||||
|
3. **Loop change** (`match.id` / style / instrument change): rail re-derives from
|
||||||
|
the new `stationVoicings`, any pin (§4) resets — this is the *existing*
|
||||||
|
`selectedStation` reset effect, renamed. **No loop matched but chords are
|
||||||
|
committing** (`currentChord` set, `match.matched` false): the rail degrades to
|
||||||
|
a single "heard live" gallery — `parseChord(currentChord)` (the same
|
||||||
|
`src/lib/voicings.js` parser `VoicingsSection` uses) → one expanded
|
||||||
|
VoicingBrowser, re-aimed on every chord commit. **Nothing heard at all**: the
|
||||||
|
existing dashed empty state; no rail, no licks strip.
|
||||||
|
4. **Licks strip**: `kb[activeStyle].instruments.guitar.licks` (the defensive
|
||||||
|
read `LicksSection`'s local `licksFor` helper already implements), shared
|
||||||
|
level filter applied. Sort: licks whose `chordContext` matches the current
|
||||||
|
station's rn or quality first, with an accent ring + "fits G7 — now"
|
||||||
|
microcopy on those; re-sorts as the playhead advances (a reorder of ≤4 thumb
|
||||||
|
cards, cheap). `chordContext` is **free text** — the match MUST be
|
||||||
|
token-boundary on rn/quality (a naive substring makes rn "I" match "♭VII",
|
||||||
|
"Imaj7", "I7"); never ship "fits G7" on a ♭VII lick. The ring + "fits — now"
|
||||||
|
microcopy are **strip-owned chrome** rendered around the card — LickCard
|
||||||
|
itself shows `chordContext` only at `size="full"` and stays untouched, as §5
|
||||||
|
promises. Licks are **guitar-only in the KB**: when the instrument tab is
|
||||||
|
piano, the strip still shows the guitar licks (consistent with the existing
|
||||||
|
Licks & Techniques section, which does the same) and says so in its heading
|
||||||
|
("guitar licks"). Style has no licks → the strip **hides entirely**; an
|
||||||
|
empty state would steal glance space to say nothing.
|
||||||
|
5. **No auto-sound, ever.** Auto-follow never triggers `chordAudio` — speaker
|
||||||
|
output feeds the live mic and would poison the detection that drives the
|
||||||
|
playhead (the L-20 caveat becomes a feedback loop). Every ▶ stays a gesture.
|
||||||
|
The rail shows the mic-feedback microcopy once, not per gallery.
|
||||||
|
6. **`onFocusChord` (D-03 Fretboard link) stays gesture-driven.** Auto-follow
|
||||||
|
does NOT emit focus-chord — repainting the main fretboard every 2 s
|
||||||
|
uninvited would fight the player's own key view. Only a pin (§4) emits it,
|
||||||
|
preserving today's semantics exactly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Density & space budget — honest math
|
||||||
|
|
||||||
|
Geometry sources: dock body = 70vh; RoadmapTrack station min-width 168 px + 34 px
|
||||||
|
rail; ChordDiagram thumb 75×72 px (+label); MiniPiano cropped thumb 142 px
|
||||||
|
(1-octave voicing) to 266 px (2-octave, e.g. most rootless voicings) × ~51 px
|
||||||
|
tall; D-30 gallery cells ≈ 90–120 px (guitar) / ≈ 140–280 px (piano) wide,
|
||||||
|
≈ 140 px (guitar) / ≈ 125 px (piano) tall including label + ▶.
|
||||||
|
|
||||||
|
### 1280 × 900 (the target the task names)
|
||||||
|
|
||||||
|
Vertical: dock body 630 px − section nav ≈ 49 − instrument/style row ≈ 47 −
|
||||||
|
content padding 32 − RoadmapTrack ≈ 230 − gap 16 = **≈ 256 px glance budget**.
|
||||||
|
|
||||||
|
- Guitar rail (heading + one row of cells ≈ 190 px): **fits**. ~66 px spare.
|
||||||
|
- Piano rail (≈ 175 px): **fits**.
|
||||||
|
- Licks strip (thumb LickCard ≈ 165 px; heading + thumb row ≈ 190 px): does
|
||||||
|
**NOT** also fit — 190 + 190 = 380 > 256. It sits just below the fold,
|
||||||
|
reachable by the dock's existing vertical scroll (a flick, not a click).
|
||||||
|
Fully above the fold only from ≈ 1070 px window height. Said plainly:
|
||||||
|
**at 1280×900 you get Roadmap + the full variations rail without scrolling;
|
||||||
|
licks are one scroll-flick down.**
|
||||||
|
|
||||||
|
Horizontal (~1200 px usable): collapsed guitar column ≈ 95 px; collapsed piano
|
||||||
|
column ≈ 160–280 px (D-24 crop width varies with voicing span); expanded guitar
|
||||||
|
gallery 3–5 cells ≈ 340–540 px. **Expanded piano gallery — the honest figure:**
|
||||||
|
root/shell cells are 1-octave crops ≈ 160 px, but **rootless A/B of any true-7th
|
||||||
|
chord span past one octave** (e.g. Dm7 rootlessA → notes [17, 21, 24, 28]), so
|
||||||
|
D-24's crop gives them a 2-octave keyboard = 266 px thumb ≈ 284 px cell. The
|
||||||
|
gallery is therefore 160 + 160 + 284 + 284 + gaps + section p-3 ≈ **940 px** —
|
||||||
|
not the ~640 px four 1-octave cells would suggest.
|
||||||
|
|
||||||
|
| Loop | Guitar rail width | Piano rail width | One row? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3–4 stations | 2–3×95 + ~450 ≈ **640–740 px** | 2–3×~200 + ~940 ≈ **1350–1550 px** | guitar ✓ · piano ✗ (scrolls at ~1200 usable) |
|
||||||
|
| 8 stations (post-L-30) | 7×95 + ~450 ≈ **1115 px** | 7×~200 + ~940 ≈ **2300 px** | guitar ✓ (just) · piano ✗ |
|
||||||
|
|
||||||
|
**Cut order (what goes first):**
|
||||||
|
1. **Licks strip drops below the fold** (never cut, just deferred to scroll).
|
||||||
|
2. **Rail scrolls horizontally** with the active column auto-centred (the
|
||||||
|
existing strip pattern) — cells are **never shrunk** below the D-30 sizes;
|
||||||
|
a diagram you can't read is worth less than one you scroll to. This is the
|
||||||
|
piano rail's **normal state on any loop containing 7th chords — i.e. most
|
||||||
|
of them** (a rootless gallery is ~940 px on its own), not just long loops;
|
||||||
|
L-33's commit-5 viewport check must expect the piano rail to scroll, never
|
||||||
|
assert "one row fits".
|
||||||
|
3. **Narrow only (§ below): collapsed columns reduce to "next" only.**
|
||||||
|
|
||||||
|
### ~640 px wide (half-snapped window)
|
||||||
|
|
||||||
|
~576 px usable. The rail flips to the kb-plan §4 narrow rule — one thing per row:
|
||||||
|
- Row 1: the expanded gallery for the current station (guitar: 5 cells wrap to
|
||||||
|
2 rows ≈ 300 px; piano: 4 cells wrap to 2 rows ≈ 260 px).
|
||||||
|
- Row 2: a single **"next: Cmaj7"** collapsed thumb (lookahead is the one piece
|
||||||
|
of context worth its pixels at this width); other stations are dropped — the
|
||||||
|
Roadmap above still shows the whole loop.
|
||||||
|
- Licks strip: single-column, below, via vertical scroll.
|
||||||
|
Glance guarantee at narrow = **current chord's full gallery + the next chord's
|
||||||
|
thumb**, no interaction.
|
||||||
|
|
||||||
|
### 1280 × 800 (the older Electron-default figure in D-20)
|
||||||
|
|
||||||
|
Budget shrinks to ≈ 186 px — the guitar rail (~190 px) is 1 row of cells with the
|
||||||
|
heading merged into the rail's top edge (drop the standalone heading line,
|
||||||
|
−18 px) and fits; everything else as at 900.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Interaction stays optional (but stays)
|
||||||
|
|
||||||
|
Nothing essential is behind a click; everything useful still responds to one:
|
||||||
|
|
||||||
|
- **▶ per gallery cell** (D-30): audition that voicing. Keyboard-reachable,
|
||||||
|
descriptive `aria-label`s, one-at-a-time playback (D-30's stop-previous
|
||||||
|
contract).
|
||||||
|
- **Pin a station**: tapping a *collapsed* column pins its gallery open,
|
||||||
|
overriding auto-follow (the accordion stops moving) — this **replaces**
|
||||||
|
today's tap-to-enlarge and inherits its semantics: the pinned station emits
|
||||||
|
`{rootPc, quality}` via `onFocusChord` (D-03 Fretboard guide tones), and the
|
||||||
|
existing reset effect (loop/style/instrument change → null) clears it.
|
||||||
|
Unpin = tap again or tap the visible "follow the jam" chip that appears while
|
||||||
|
pinned. `aria-pressed` on columns, `aria-current` on the live one.
|
||||||
|
- **Tap a lick thumb** → the card enlarges inline (`size="full"`); tap again to
|
||||||
|
collapse. The thumb already shows the full tab shape — enlarging is comfort,
|
||||||
|
not information.
|
||||||
|
- **Chord label tap** → `onChordClick` → ChordDetailModal, as everywhere else.
|
||||||
|
|
||||||
|
Focus order follows DOM order (columns left→right, then licks); all targets keep
|
||||||
|
`focus-visible:ring-2 ring-accent`, min 32 px height. Tokens only — everything
|
||||||
|
here uses existing `surface/panel/border/accent` + amber; **no new colour**.
|
||||||
|
Contrast inherits the measured D-20 §8 commitments (accent small text on
|
||||||
|
`bg-surface` cards; gray-400 floor for load-bearing labels; gray-500 microcopy
|
||||||
|
only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Migration order for L-33 (green at every commit)
|
||||||
|
|
||||||
|
**App.jsx contract: UNTOUCHED — zero changes, not even additive.** Every input
|
||||||
|
glance mode needs (`detectedProgression`, `keyInfo`, `chordHistory`, `bpm`,
|
||||||
|
`currentChord`, `onFocusChord`, `onChordClick`) already flows into JamGuide.
|
||||||
|
Audio callbacks are therefore untouched by construction. If L-33 finds a gap,
|
||||||
|
the rule is prop-addition-only on the existing mount, never a callback/ref edit.
|
||||||
|
|
||||||
|
Files (for Maestro to re-lock at promotion):
|
||||||
|
|
||||||
|
| File | Kind of change |
|
||||||
|
|---|---|
|
||||||
|
| `src/components/GlanceRail.jsx` | **NEW** — pure presentational: props `{ stations, activeIndex, pinnedIndex, onPin, instrument, keyRoot }`; composes collapsed thumbs (existing ChordDiagram/MiniPiano) + one `<VoicingBrowser show={instrument}/>` for the expanded column |
|
||||||
|
| `src/components/JamGuide.jsx` | **Restructured (jam section only)** — RoadmapAssembly's voicing strip + enlarge block replaced by GlanceRail + LicksStrip; `selectedStation` becomes `pinnedStation` (same reset effect, same `onFocusChord` wiring); `licksFor` — today a closure-local function inside `LicksSection` (JamGuide.jsx:489), not an export — is **lifted out during the restructure** and shared with the strip. Sections 2–4, the shell, nav, and collapsed bar untouched |
|
||||||
|
| `src/components/VoicingBrowser.jsx` | **Additive-optional** — a `dense` prop (trim section padding, suppress the per-mount mic microcopy since the rail shows it once). Skip entirely if the D-30 cells already sit within the §3 budget |
|
||||||
|
| `src/components/LickCard.jsx` | **Untouched** (`thumb`/`full` already exist) |
|
||||||
|
| `src/App.jsx`, `RoadmapTrack.jsx`, `MiniPiano.jsx`, `ChordDiagram.jsx`, all `src/lib/**` | **Untouched** |
|
||||||
|
|
||||||
|
Commit order:
|
||||||
|
|
||||||
|
1. **Extract `GlanceRail.jsx`** rendering *today's* strip behaviour verbatim
|
||||||
|
(thumbs + tap-to-enlarge), JamGuide mounts it — a pure move, zero visual diff.
|
||||||
|
2. **Accordion**: expanded column = `activeIndex` (auto-follow off
|
||||||
|
`canonicalPos`), pin/unpin replaces tap-to-enlarge, the VoicingBrowser mount
|
||||||
|
moves from the enlarge block into the expanded column; reduced-motion guard
|
||||||
|
on the auto-scroll.
|
||||||
|
3. **No-loop fallback**: `parseChord(currentChord)` single gallery.
|
||||||
|
4. **LicksStrip**: thumb cards, level filter, context-first sort + live ring,
|
||||||
|
hide-when-empty, tap-to-enlarge.
|
||||||
|
5. **Narrow reflow + cut rules** (§3): horizontal-scroll behaviour, ≤640 px
|
||||||
|
next-only collapse; check 1280×900, 1280×800, ~640 px.
|
||||||
|
|
||||||
|
Each commit: `npm run build` + `node scripts/smoke.mjs` green; Critic gates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rationale + rejected alternatives (no user gate this sprint)
|
||||||
|
|
||||||
|
**Chosen — playhead accordion**, because: (a) it is the kb-plan §4 grid
|
||||||
|
(columns = chords, active-column highlight, narrow → one-per-row) applied to
|
||||||
|
variations; (b) constant footprint that survives 8-station loops (L-30) instead
|
||||||
|
of degrading; (c) 100% of variations shown per loop cycle with zero clicks, and
|
||||||
|
always for the chord under the player's hands — the moment a grip is learnable;
|
||||||
|
(d) it *reuses* D-30's gallery as-is and App.jsx needs nothing.
|
||||||
|
|
||||||
|
**Rejected A — "everything expanded" grid** (every station × every variation at
|
||||||
|
once — the literal maximal reading of the directive). Killed by §3's math: piano
|
||||||
|
is 4 stations × 4 styles = 16 cells at the honest §3 sizes (rootless cells
|
||||||
|
≈ 284 px) ≈ **3,500+ px** wide, or 3+ wrapped rows ≈ 480 px
|
||||||
|
tall — both force scrolling *while playing*, which is worse than zero clicks:
|
||||||
|
it's continuous manual tracking. At 8-station loops it's hopeless on any
|
||||||
|
viewport. Guitar-only at exactly 3 stations barely fits — a layout that only
|
||||||
|
works for its demo case is not a design.
|
||||||
|
|
||||||
|
**Rejected B — "Now panel"** (full-width mega-view of only the current chord:
|
||||||
|
giant gallery + licks + scale, no station columns). Maximum per-chord detail,
|
||||||
|
but it discards lookahead — mid-jam the *next* chord matters more than the one
|
||||||
|
already sounding — and at 120 BPM a 4-chord loop swaps the entire panel every
|
||||||
|
~2 s: visually violent, nothing is on screen long enough to read. The accordion
|
||||||
|
keeps 80% of the detail and all of the context.
|
||||||
|
|
||||||
|
**Rejected C — side-by-side split** (Roadmap left, variations dock right).
|
||||||
|
A 4-station track already needs 4×168 + 3×34 ≈ 774 px; splitting 1280 gives it
|
||||||
|
~640 — the flagship Roadmap gains a permanent horizontal scrollbar to make room
|
||||||
|
for a panel that duplicates its station order 90° rotated. Collapses entirely at
|
||||||
|
narrow. Rejected on viewport economics.
|
||||||
|
|
||||||
|
**Rejected D — timed carousel** (auto-cycle variations for the current chord
|
||||||
|
every N seconds). Zero clicks, technically — but motion untied to the music is
|
||||||
|
the most distracting thing a stage display can do, and it invents a second clock
|
||||||
|
next to the playhead. The music already provides the rhythm of change; follow it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Out of scope / flags for Maestro
|
||||||
|
|
||||||
|
- **Explore/Voicings/Licks sections**: unchanged; glance mode lives entirely in
|
||||||
|
the jam section. (The Voicings section already follows `currentChord` — that
|
||||||
|
behaviour is untouched.)
|
||||||
|
- **Piano collapsed-thumb width variance** (142–266 px per D-24's span-crop) is
|
||||||
|
the piano rail's main width pressure; if Critic finds real loops where it
|
||||||
|
reads badly, a future D-task could cap collapsed piano thumbs to a 1-octave
|
||||||
|
window around the bass — **not** in L-33's scope.
|
||||||
|
- **`dense` prop on VoicingBrowser** is optional and Muse-owned; L-33 should
|
||||||
|
attempt the rail with plain D-30 cells first.
|
||||||
|
- No new tokens, no new dependencies, no KB/theory/audio changes anywhere in
|
||||||
|
this design.
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
# Integrated Glance — the Jam Guide joins the main module (task D-40)
|
||||||
|
|
||||||
|
> **Thesis:** the Jam Guide stops being the last collapsible at the bottom of the
|
||||||
|
> page and becomes a **full-width, always-open band directly below the instrument
|
||||||
|
> view** — the third element of the main module. The loop is shown **once**, in
|
||||||
|
> ProgressionBanner (where the user says it already lives); the Roadmap track
|
||||||
|
> retires and its education (solo scales, guide tones, voice-leading) folds into
|
||||||
|
> the rail's station headers. The rail itself flips from the D-31 playhead
|
||||||
|
> accordion to **all stations expanded at once, as vertical rows** — every loop
|
||||||
|
> chord's full voicing gallery permanently visible, the playhead highlighting
|
||||||
|
> (never revealing) the active row. One global GUITAR/PIANO/BASS selector — App's
|
||||||
|
> existing `instrument` state — drives everything.
|
||||||
|
>
|
||||||
|
> User directive (2026-07-10, verbatim): *"i would like to move it up and
|
||||||
|
> integrate it somewhat in the space of the main module up top. or maybe right
|
||||||
|
> below it as it is super important. … i would like to be able to see the loop
|
||||||
|
> clearly, no need to have this repeated again. as its already in the main module
|
||||||
|
> … only one selection for GUITAR/PIANO/BASS and everything should show in that
|
||||||
|
> instrument. … i'd like to see all the chords and their voicings in a specific
|
||||||
|
> way in case a loop is detected (or at least just like 4 chords or something at
|
||||||
|
> least, so you can follow and potentially learn new ways to play it while you
|
||||||
|
> are playing the loop.) scrolling is easier then clicking"*
|
||||||
|
|
||||||
|
**Decision authority:** per the sprint header (no user gate), Muse picks the
|
||||||
|
strongest option and records rejected alternatives (§7). Implementation is
|
||||||
|
**L-40** (App restructure, §6.1) then **D-41** (rail rework, §6.2). This doc
|
||||||
|
supersedes `glance-mode.md` §1's accordion where they conflict — the user's
|
||||||
|
scroll license overturns D-31's "everything expanded" rejection (Rejected A
|
||||||
|
there), with the axis flipped to vertical (§4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. What exists today (read from the code)
|
||||||
|
|
||||||
|
App.jsx mount order: `ProgressionBanner` (~609) → instrument row (~618–630:
|
||||||
|
`Fretboard`/`BassFretboard`/`Piano` at 70% + `ProgressionSuggestions` 30%) →
|
||||||
|
`CurrentJamPanel` (~634) → `LoopStation` (~642) → Debug/Drum/Tuner collapsibles →
|
||||||
|
**`JamGuide` last (~712)**. The global `instrument` state (App.jsx:58,
|
||||||
|
`'piano'|'guitar'|'bass'`) has its selector in the controls bar (~484) and drives
|
||||||
|
the main instrument views + DebugView — but **JamGuide ignores it** and runs its
|
||||||
|
own internal Guitar/Piano/Bass tabs (JamGuide.jsx ~405–434, local state ~208).
|
||||||
|
|
||||||
|
The detected loop renders **twice**: ProgressionBanner's loop row (compact chips,
|
||||||
|
active chip highlighted via `findLoopPosition`) and `RoadmapTrack` inside the
|
||||||
|
JamGuide jam section (stations with solo-scale labels, guide-tone dots,
|
||||||
|
voice-leading rails, playhead + beat grid). Below the Roadmap, `GlanceRail`
|
||||||
|
(L-33) is a playhead **accordion**: one station's gallery expanded at a time —
|
||||||
|
the exact "it changes when the chord is not playing" complaint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Placement — a full-width band directly below the instrument view
|
||||||
|
|
||||||
|
**Chosen: the Jam Guide content mounts as a full-width, always-open band
|
||||||
|
immediately below the instrument row, replacing `CurrentJamPanel`'s slot (before
|
||||||
|
`LoopStation`).** The four-section Knowledge Center dock stays at the bottom as
|
||||||
|
the browse/study area, minus its jam section (§5).
|
||||||
|
|
||||||
|
Why *below* the main module and not *inside* it:
|
||||||
|
|
||||||
|
- **Width math kills "inside".** The only free space inside the main module is
|
||||||
|
the 30% `ProgressionSuggestions` column (~360px at 1280) and the banner's
|
||||||
|
interior. A single piano station's gallery is **~1,178px** worst case: the
|
||||||
|
gallery calls `pianoVoicing` with no `prev` (VoicingBrowser.jsx:161), and
|
||||||
|
MiniPiano's D-24 crop is C-aligned (`octStart = floor(minNote/12)`), so any
|
||||||
|
station rooted **above D** crosses the C boundary even in root position and
|
||||||
|
shell — recomputed G7: root [7,11,14,17], shell [19,23,29], rootlessA
|
||||||
|
[23,26,29,33], rootlessB [17,21,23,26] → **all four cells are 2-octave 284px
|
||||||
|
crops** ≈ 4×284 + 24 gaps + 18 chrome ≈ 1,178px. (glance-mode.md's ~940px was
|
||||||
|
the Dm7-specific figure; 1-octave root/shell crops exist only for roots C–D.)
|
||||||
|
Nothing gallery-shaped fits inside the module without crushing the neck/keys —
|
||||||
|
the surface the player's eyes track continuously.
|
||||||
|
- **"The main module" to the user = banner + big instrument view** — the
|
||||||
|
real-time detection surface. The guide answers a different question ("what
|
||||||
|
can I play on each chord of this loop") at a different rhythm (study-while-
|
||||||
|
looping). Directly below = one saccade down, same screenful top edge, no
|
||||||
|
competition for the detection pixels. The user himself offered "or maybe
|
||||||
|
right below it" — and the space math makes that the only honest reading.
|
||||||
|
- **Always open, zero chrome.** No collapse header (scroll past it if unwanted
|
||||||
|
— scroll > click), no section nav, no internal instrument tabs, no style
|
||||||
|
tabs, no level chips. The band is a pure auto surface: detection picks the
|
||||||
|
loop and style, the ONE controls-bar selector picks the instrument. The
|
||||||
|
`styleOverride`/style-tab machinery (JamGuide.jsx ~210–212, ~438–458) **dies
|
||||||
|
with the tabs**: the band always follows `match.style`; the heard-live
|
||||||
|
LicksStrip's `styleId` falls back to `styles[0]` when nothing is matched;
|
||||||
|
browsing OTHER styles stays reachable via the dock's own style chips
|
||||||
|
(LicksSection and ExploreSection each keep theirs). When no loop is matched
|
||||||
|
the band degrades to the existing "heard live" single gallery for
|
||||||
|
`currentChord`; when nothing is heard, a slim one-line hint (~40px) so it
|
||||||
|
never wastes main-module space.
|
||||||
|
- The band gets a micro-header line (`JAM GUIDE — {matched name} · in C major`,
|
||||||
|
uppercase tracking-widest gray-500 style), not a button.
|
||||||
|
|
||||||
|
Vertical position at 1280×900 (~860px usable): header ≈56 + controls bar ≈52 +
|
||||||
|
banner ≈140 + instrument row ≈300 + margins ≈24 → **the band starts ≈570px
|
||||||
|
down**; its first ≈290px are visible at rest, and one scroll-flick puts the
|
||||||
|
whole rail at the top of the viewport (§4 math). Estimates for the module
|
||||||
|
heights; verified figures for the rail.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The single loop display — ProgressionBanner survives, RoadmapTrack retires
|
||||||
|
|
||||||
|
**Call: ProgressionBanner's loop row is THE loop display. RoadmapTrack is
|
||||||
|
unmounted** (L-40 removes the import/mount; the file stays in place like the
|
||||||
|
EducationPanel precedent — deletion filed to backlog).
|
||||||
|
|
||||||
|
The user told us where the loop lives: *"no need to have this repeated again.
|
||||||
|
as its already in the main module"* — i.e. the banner. The banner is compact,
|
||||||
|
up top, always visible, and **already carries the playhead**: `findLoopPosition`
|
||||||
|
highlights the active loop chip (accent glow + amber rn). There is nothing to
|
||||||
|
absorb on that front — the beat grid's "current beat" was chord-accurate anyway
|
||||||
|
(RoadmapTrack's own comment: "coarse, chord-accurate — matches
|
||||||
|
ProgressionBanner"). **ProgressionBanner.jsx needs zero edits.**
|
||||||
|
|
||||||
|
Honest disposition of RoadmapTrack's unique value:
|
||||||
|
|
||||||
|
| RoadmapTrack feature | Disposition |
|
||||||
|
|---|---|
|
||||||
|
| Playhead (active station) | Already in the banner's loop chips — nothing lost |
|
||||||
|
| Beat grid + progress bar | **Dropped.** It never showed sub-chord progress (chord-accurate by construction); the BPM readout already lives in the controls-bar badge. Loss: the downbeat tick visual. If missed, a slim progress underline beneath the banner's loop chips is a future 1-point polish — explicitly NOT in L-40/D-41 |
|
||||||
|
| Solo-scale label per station ("G mixolydian") | **Folds into each rail row's header** (§4) via `soloScale` from theory.js |
|
||||||
|
| Guide-tone lane (3rd filled / 7th hollow dots) | **Folds into each rail row's header** ("aim" dots, same accent-filled/hollow language) via `guideTones` |
|
||||||
|
| Voice-leading rails (7→3 half-step arrows) | **Folds into a compact transition chip** at the end of each row header: "next: F→E · ½ step down" (via `voiceLeadingPairs`; wrap-around chip on the last row says "loop"). The between-columns arrow SVG dies; the information survives |
|
||||||
|
| Station "next" lookahead glow | Superseded: next row gets a small "next" tag; the whole loop is visible anyway |
|
||||||
|
|
||||||
|
Interim honesty: L-40 unmounts RoadmapTrack **before** D-41 builds the row
|
||||||
|
headers, so for one gate cycle the solo-scale/guide-tone/voice-leading education
|
||||||
|
is absent from the page (the banner still shows the loop; the accordion rail
|
||||||
|
still shows voicings). Accepted — D-41 follows immediately in the same serialized
|
||||||
|
chain; Critic should not flag the gap as a regression.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Instrument threading — one selector, honest bass
|
||||||
|
|
||||||
|
JamGuide's internal `INSTRUMENTS` tabs, `COMPUTED_INSTRUMENTS`,
|
||||||
|
`availableInstruments`, and local `instrument` state (JamGuide.jsx ~42–47, ~178–
|
||||||
|
184, ~208, ~405–434) are **deleted**. App's existing `instrument` flows down as
|
||||||
|
a prop. Full prop paths to every consumer:
|
||||||
|
|
||||||
|
| Consumer | Path |
|
||||||
|
|---|---|
|
||||||
|
| GlanceRail (band) | `App.jsx instrument` (line 58) → `<JamGuide instrument={instrument}>` → `<GlanceRail instrument={instrument}>` (prop already exists; only its source changes) |
|
||||||
|
| VoicingBrowser, heard-live fallback (band) | App → JamGuide → `<VoicingBrowser show={instrument}>` (JamGuide.jsx ~494) |
|
||||||
|
| LicksStrip (band) | App → JamGuide → `<LicksStrip instrument={instrument}>` (both mounts, ~498 and ~757) |
|
||||||
|
| `stationVoicings` memo (band) | App → JamGuide — the memo's `instrument` dependency reads the prop |
|
||||||
|
| VoicingsSection (dock) | App → `<KnowledgeDock instrument={instrument}>` (§5) → `<VoicingsSection instrument={instrument}>` → its `<VoicingBrowser show={instrument}>` (ExplorePanel.jsx:425, today defaulting to `'both'`) |
|
||||||
|
| LicksSection (dock) | **No prop** — LicksSection is a module-local function in **JamGuide.jsx:562** (NOT an ExplorePanel export), and its heading is already statically honest ("Guitar licks · tab reads high e on top…"); licks are guitar-only in the KB (C-20 schema), so an `instrument` prop would be dead on arrival. Recorded so nobody threads it |
|
||||||
|
| ExploreSection (dock) | **No prop needed** — it renders progression chips + famous progressions, no instrument-specific renderer; chord taps open ChordDetailModal, which has its own user-driven Guitar/Piano tabs. Recorded so nobody threads a dead prop |
|
||||||
|
| DebugView | Already receives `instrument` (App.jsx ~676) — untouched |
|
||||||
|
|
||||||
|
The only `ExplorePanel.jsx` signature change is
|
||||||
|
`VoicingsSection({ keyInfo, chordHistory, currentChord, instrument })`
|
||||||
|
(its named exports are LevelChips / ChordPickerToolbar / GuitarGrid / PianoGrid /
|
||||||
|
ExploreSection / VoicingsSection — no LicksSection there).
|
||||||
|
|
||||||
|
### The honest bass state (until C-41/P-41/L-42 land)
|
||||||
|
|
||||||
|
BassFretboard covers the main view; the KB has **zero bass content** and both
|
||||||
|
gallery generators are wrong for bass (guitar shapes are not bass patterns;
|
||||||
|
`pianoVoicing` is piano). Showing guitar diagrams under BASS would break the
|
||||||
|
one-selector promise the user just made. Instead, when `instrument === 'bass'`
|
||||||
|
and a loop is matched:
|
||||||
|
|
||||||
|
- Station rows still render their full headers — chord, rn, solo scale, aim
|
||||||
|
dots. **Guide tones ARE the bassist's target notes**; none of this is
|
||||||
|
instrument-specific.
|
||||||
|
- The gallery slot renders a computed **root · fifth · approach** line per
|
||||||
|
station: root name, fifth name, and the chromatic approach into the NEXT
|
||||||
|
station's root ("approach: G♯ → A", computed from `stations[i+1].rootPc − 1`
|
||||||
|
semitone — pure arithmetic on data the component already has; **no theory.js
|
||||||
|
change**). This is the honest useful minimum, not a placeholder.
|
||||||
|
- ONE notice for the whole rail (not per row): *"Authored bass patterns are on
|
||||||
|
the way (blues first) — meanwhile: roots, fifths, and the approach into the
|
||||||
|
next chord."*
|
||||||
|
- The licks strip hides under bass (guitar tab licks are noise to a bassist
|
||||||
|
mid-jam; under piano the strip keeps today's honest "guitar licks" heading).
|
||||||
|
|
||||||
|
**What L-42 slots in:** a per-station `<BassPatternCard>` (new component,
|
||||||
|
Luthier/Muse per the C-41 schema — 4-string, frets 0–15) mounted in the same
|
||||||
|
gallery slot of each row, replacing the computed line **when the matched style
|
||||||
|
ships a bass cell**; the computed root/fifth/approach line remains the
|
||||||
|
per-station fallback for styles without bass content. The rail's row structure,
|
||||||
|
highlight, and header need zero changes for L-42 — that's the contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The all-stations-expanded rail — vertical rows, playhead highlights
|
||||||
|
|
||||||
|
**Axis call: stations are VERTICAL ROWS** — one row per loop chord, each row =
|
||||||
|
station header + that chord's full horizontal voicing gallery; the page scrolls
|
||||||
|
down. All rows render all cells, always. **The playhead HIGHLIGHTS the active
|
||||||
|
row and never hides, collapses, or reveals content.**
|
||||||
|
|
||||||
|
Why vertical beats all-horizontal: a 4-station piano loop laid out as expanded
|
||||||
|
columns is ≈4 × 1,470px ≈ **5,900px wide** — continuous sideways tracking while
|
||||||
|
playing, on the axis mouse wheels don't scroll. Vertically, the worst piano row
|
||||||
|
is ≈ 284 (threaded cell — an authored LH 3-5-7-9 also crops 2 octaves) + 8 +
|
||||||
|
1,178 (§1 gallery) ≈ **1,470px of cells**, which does NOT fit 1,240px usable —
|
||||||
|
the gallery **flex-wraps to a second cell line** (~300px row height). **No
|
||||||
|
horizontal scroll survives because rows wrap, not because they fit.** Rows stack
|
||||||
|
~170–300px tall and ≥4 chords sit within one to one-and-a-half screenfuls —
|
||||||
|
the "at least 4 chords so you can follow" ask, under the user's scroll license.
|
||||||
|
|
||||||
|
### Row anatomy (canonical KB order, same as the banner's loop after rotation)
|
||||||
|
|
||||||
|
- **Header (~30px):** chord label (gray-100 bold) + rn + "now" badge (accent,
|
||||||
|
when active) / "next" tag · solo-scale label ("solo · G mixolydian") · aim
|
||||||
|
dots (3rd filled accent, 7th hollow — RoadmapTrack's GuideDot language,
|
||||||
|
honest "5th" fallback kept) · transition chip ("next: F→E · ½ step down";
|
||||||
|
last row: "loop"). Header data via theory.js `guideTones` /
|
||||||
|
`voiceLeadingPairs` / `soloScale` — read-only imports.
|
||||||
|
- **Gallery:** first cell = the *station's own* voicing — guitar: the KB play's
|
||||||
|
recommended shape badged "play" (when present); piano: the threaded/authored
|
||||||
|
`stationVoicings[i].voicing` labeled honestly (e.g. "LH 3-5-7-9") — this is
|
||||||
|
where the accordion's collapsed-thumb value survives. Then the full
|
||||||
|
VoicingBrowser gallery (`show={instrument}`, `dense`): every placeable guitar
|
||||||
|
shape / all four piano styles, each with its own ▶ (D-30 one-at-a-time
|
||||||
|
playback contract). Bass: the §3 computed line (later the L-42 pattern card).
|
||||||
|
- **Active row:** `border-accent ring-2 ring-accent bg-accent/10` + "now" badge
|
||||||
|
+ `aria-current="true"`. Inactive rows `border-border bg-surface`, opacity
|
||||||
|
floor 0.85 (never dim below AA). Tokens only; no new colour.
|
||||||
|
- **Mic-feedback microcopy** once below the last row (rail-owned; galleries run
|
||||||
|
`dense`), as today.
|
||||||
|
|
||||||
|
### Focus semantics — pin simplified
|
||||||
|
|
||||||
|
With everything always expanded, the pin has nothing to hold open. It collapses
|
||||||
|
to a **focus toggle**: tapping a row header toggles that station as
|
||||||
|
`focusedStation` (renamed from `pinnedStation`, same JamGuide-owned state, same
|
||||||
|
reset-on-loop/style/instrument-change effect, same `onFocusChord({rootPc,
|
||||||
|
quality})` emission — the D-03 fretboard guide-tone contract is byte-compatible).
|
||||||
|
A focused row shows an "aim on fretboard" chip; tap again (or the loop changes)
|
||||||
|
to clear. `aria-pressed` on the header button, min 32px target,
|
||||||
|
`focus-visible:ring-2 ring-accent` everywhere.
|
||||||
|
|
||||||
|
**No auto-scroll.** The band lives in page flow, so `scrollIntoView` would yank
|
||||||
|
the whole page while the user reads the looper below — the accordion's
|
||||||
|
auto-centre effect is **removed**, not ported (deleted in the same L-40 commit
|
||||||
|
that promotes the band, §6.1 step 2 — today it is contained only by the dock's
|
||||||
|
70vh scroller, which the band no longer has). The playhead highlight travels;
|
||||||
|
the user owns the scrollbar (their explicit preference). On 4-row loops the
|
||||||
|
whole rail is one screenful anyway; on 8-row loops the banner up top always
|
||||||
|
shows the position. (Internal-scroll variant rejected: a nested scrollbar caps
|
||||||
|
the rail at ~62vh ≈ 3 rows — breaks the ≥4-visible goal at 900px.)
|
||||||
|
|
||||||
|
### Licks strip
|
||||||
|
|
||||||
|
Directly below the last station row, unchanged in behaviour: thumb LickCards
|
||||||
|
(~165px wide, strip ≈190px tall with heading), context-sorted to the playhead
|
||||||
|
station via the token-boundary matcher, strip-owned "fits X — now" ring, hides
|
||||||
|
entirely when the style has no licks, hidden under bass (§3). One flick below
|
||||||
|
the rows — consistent with its D-31 position in the scroll order.
|
||||||
|
|
||||||
|
### Space math (verified numbers: guitar cell ~93px, piano cells 160–284px, piano gallery ~1,178px worst — §1, licks thumb ~165px)
|
||||||
|
|
||||||
|
Row heights: guitar row ≈ header 30 + cells ~140 + padding ≈ **185px**; piano
|
||||||
|
row ≈ 30 + one cell line ~125 + padding ≈ **170px** single-line, ≈ **300px**
|
||||||
|
when the gallery wraps to two cell lines. Row widths (cells): guitar ≈ play
|
||||||
|
cell + 3–5 gallery cells ≈ **460–650px**; piano ≈ threaded cell up to ~284 +
|
||||||
|
gallery up to ~1,178 ≈ **~1,470px worst case**. Piano rows rooted above D with
|
||||||
|
a true 7th — i.e. most stations of most jazz/gospel loops — exceed 1,240px and
|
||||||
|
**wrap** (single-line piano rows exist only for roots C–D or triad qualities);
|
||||||
|
rows never scroll horizontally.
|
||||||
|
|
||||||
|
| Scenario | 1280×900 (~1,240×860 usable) | ~640px wide (~576 usable) |
|
||||||
|
|---|---|---|
|
||||||
|
| 4-chord loop, guitar | 4×185 = **740px** — all 4 rows in one screenful (band scrolled to top); +190 licks strip = one flick more | cells fit one line (5×93+gaps ≈ 500); 4 rows ≈ 740px ≈ 1.3 screenfuls |
|
||||||
|
| 4-chord loop, piano | best case (roots C–D / triads) 4×170 = **680px** ≈ one screenful; honest 7th-chord case (the jazz flagship): rows wrap → up to 4×300 ≈ **1,200px ≈ 1.4 screenfuls** — still zero clicks; the scroll license covers it | gallery wraps to 2–3 cell-lines → row ≈ 300–430px; 4 rows ≈ 1,200–1,700px ≈ 2–3 screenfuls, zero clicks |
|
||||||
|
| 8-chord loop (post-L-30) | guitar ≈ 1,480px ≈ **1.7 screenfuls** / piano ≈ up to 2,400px ≈ **2.8 screenfuls** — scroll, highlight travels, banner keeps the position | ≈ 2.5–6 screenfuls; still zero clicks |
|
||||||
|
|
||||||
|
At rest (no scroll) the band's visible ≈290px shows the header line + ~1–1.5
|
||||||
|
rows — enough to see the "now" row when it's early in the loop; one flick brings
|
||||||
|
the rail to the top. The user licensed exactly this trade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. What remains below — the dock slims to three sections, CurrentJamPanel retires
|
||||||
|
|
||||||
|
- **`KnowledgeDock`** (named export from JamGuide.jsx, §6.1): the bottom
|
||||||
|
collapsible keeps **Explore / Voicings / Licks & Techniques** — the browse
|
||||||
|
and study area (chord picker, KB progression browser, full lick grids, the
|
||||||
|
shared level filter). The **jam section is removed** (it IS the band now);
|
||||||
|
the dock's header drops the live match label and becomes static ("Knowledge
|
||||||
|
Center — browse & study"). Level-filter chips stay dock-only; the band shows
|
||||||
|
all levels (a glance surface filters nothing — directive 4).
|
||||||
|
- **`CurrentJamPanel` is unmounted by L-40.** The backlog already flags folding
|
||||||
|
it; the band moving into its exact slot makes its duplication terminal
|
||||||
|
(voicing strips → the rail; similar progressions → Explore; per-chord scale
|
||||||
|
labels → row headers). Honest loss: its mode-level `SCALE_IDEAS` prose and
|
||||||
|
`STYLE_VARIATIONS` cards exist nowhere else — the existing backlog item
|
||||||
|
("fold CurrentJamPanel's unique bits into the Knowledge Center") stays open
|
||||||
|
for Professor/Muse; the file is retired-in-place, deletion filed with
|
||||||
|
EducationPanel's. Note for Maestro: only `RiffDiagram.jsx` goes orphaned with
|
||||||
|
it — `ChordBox.jsx` is still imported by ChordDetailModal.jsx (lines 2, 58)
|
||||||
|
and ExplorePanel.jsx (lines 18, 170) and must **NOT** be deleted.
|
||||||
|
- **LoopStation** does not move — the band replaces `CurrentJamPanel` directly
|
||||||
|
above it, so it ends up right below the jam surface it feeds for free.
|
||||||
|
Debug/Drum/Tuner collapsibles unchanged.
|
||||||
|
- **ChordDetailModal, ProgressionSuggestions, Fretboard/Piano/BassFretboard,
|
||||||
|
ProgressionBanner:** zero edits.
|
||||||
|
|
||||||
|
Resulting App.jsx order: banner → instrument row → **Jam Guide band** →
|
||||||
|
LoopStation → Debug → Drum → Tuner → **KnowledgeDock**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Migration order — bounded scopes, green at every commit
|
||||||
|
|
||||||
|
Serialized: L-40 → D-41 (same surfaces). Each commit: `npm run build` +
|
||||||
|
`node scripts/smoke.mjs` green; Critic gates.
|
||||||
|
|
||||||
|
### 6.1 L-40 (Luthier) — App restructure + instrument threading + single loop display
|
||||||
|
|
||||||
|
**Files:** `src/App.jsx` (mounts + prop pass ONLY — 🚨 audio callbacks, refs,
|
||||||
|
AudioCapture props untouched, grep-gated), `src/components/JamGuide.jsx`,
|
||||||
|
`src/components/ExplorePanel.jsx` (VoicingsSection signature only — LicksSection
|
||||||
|
lives in JamGuide.jsx), `src/components/GlanceRail.jsx` (**one-line effect
|
||||||
|
deletion only**, see step 2 — re-lock at promotion per the ledger row).
|
||||||
|
**Not touched:** RoadmapTrack.jsx / CurrentJamPanel.jsx (unmounted, not edited),
|
||||||
|
ProgressionBanner.jsx, VoicingBrowser.jsx, all `src/lib/**`.
|
||||||
|
|
||||||
|
1. **Thread the instrument.** App passes `instrument` to the JamGuide mount;
|
||||||
|
JamGuide deletes its internal tabs/state/availability gating and consumes
|
||||||
|
the prop everywhere (§3 table); VoicingsSection gains the prop. Guide still
|
||||||
|
at the bottom, four sections intact. Green.
|
||||||
|
2. **Split and promote.** JamGuide.jsx → default export `JamGuide` (the band:
|
||||||
|
micro-header + jam content, always open, no section nav) + named export
|
||||||
|
`KnowledgeDock` (collapsible, 3 sections, level filter). App mounts the band
|
||||||
|
in CurrentJamPanel's slot, retires the CurrentJamPanel mount, keeps the dock
|
||||||
|
last. **RoadmapTrack unmounted** (loop now shown exactly once — the banner).
|
||||||
|
The band body for now = existing GlanceRail accordion + LicksStrip +
|
||||||
|
heard-live/empty fallbacks. **Same commit, mandatory:** delete GlanceRail's
|
||||||
|
auto-centre effect (GlanceRail.jsx:56–67 `scrollIntoView`) — it is contained
|
||||||
|
today only by the dock's 70vh `overflow-auto` body; in page flow the nearest
|
||||||
|
scroller is the DOCUMENT, so every playhead advance would yank the whole
|
||||||
|
page — the exact failure §4 forbids. Promoting the band and deleting the
|
||||||
|
effect must land together. Green.
|
||||||
|
3. **Honest bass state.** `instrument === 'bass'` + loop matched → the §3
|
||||||
|
computed root/fifth/approach rows + single notice (band-level; no further
|
||||||
|
GlanceRail edits). Green.
|
||||||
|
|
||||||
|
### 6.2 D-41 (Muse) — the all-expanded rail
|
||||||
|
|
||||||
|
**Files:** `src/components/GlanceRail.jsx` (rework), `src/components/JamGuide.jsx`
|
||||||
|
(band wiring: pass `keyMode`, `pinnedStation` → `focusedStation` rename,
|
||||||
|
LicksStrip/bass-slot composition into rows), `src/components/VoicingBrowser.jsx`
|
||||||
|
(**optional**, `dense`-path styling only). **Not touched:** App.jsx,
|
||||||
|
RoadmapTrack.jsx, ProgressionBanner.jsx, MiniPiano/ChordDiagram/LickCard, libs.
|
||||||
|
|
||||||
|
1. **Rows.** Accordion → all-expanded vertical rows: every station renders
|
||||||
|
header + full gallery permanently; active row highlight (`aria-current`);
|
||||||
|
pin → focus toggle (same onFocusChord contract). The `scrollIntoView`
|
||||||
|
effect is already gone (L-40 step 2) — verify none is reintroduced. Green.
|
||||||
|
2. **Absorb the roadmap.** Row headers gain solo-scale, aim dots, transition
|
||||||
|
chip (theory.js read-only imports); gallery gains the "play"/threaded first
|
||||||
|
cell. Green.
|
||||||
|
3. **Reflow + verify.** Narrow (~640px) cell-wrap per row; eyeball 1280×900,
|
||||||
|
1280×800, ~640; AA/focus audit; verify **rows wrap and never scroll
|
||||||
|
horizontally** (the piano worst case ≈1,470px of cells does NOT fit 1,240 —
|
||||||
|
§4), and recompute the honest screenful counts against the §4 table. Green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Rejected alternatives
|
||||||
|
|
||||||
|
**Rejected A — the guide inside the main module** (banner absorbs the rail, or
|
||||||
|
the rail replaces the 30% ProgressionSuggestions column). Killed by width math —
|
||||||
|
and the corrected worst case only strengthens it: ~360px column vs a ~1,178px
|
||||||
|
piano gallery (§1); inside the banner it competes with the
|
||||||
|
key/history/now-chord — the one surface that must stay instantly readable. The
|
||||||
|
user's "or maybe right below it" is the version of his own ask that survives
|
||||||
|
arithmetic. Also rejected structurally: ProgressionBanner doing loops + history
|
||||||
|
+ now-chord + galleries is a god-component.
|
||||||
|
|
||||||
|
**Rejected B — all-horizontal expanded rail** (stations as columns, every column
|
||||||
|
expanded). The literal reading of "all voicings at once", and the axis the old
|
||||||
|
accordion already had. With the corrected cell widths it gets worse, not better:
|
||||||
|
4-station piano ≈ 5,900px wide; 8-station ≈ 11,800px; continuous sideways
|
||||||
|
tracking on the axis wheels don't scroll, whole stations hidden off-edge at any
|
||||||
|
moment — the directive's "follow while playing" fails exactly when it matters.
|
||||||
|
Vertical rows carry the same content with zero horizontal scroll (rows wrap,
|
||||||
|
§4).
|
||||||
|
|
||||||
|
**Rejected C — RoadmapTrack survives as the single loop display** (banner's loop
|
||||||
|
row dies instead). Keeps the education-dense artefact, but contradicts the
|
||||||
|
user's own account of where the loop lives ("it's already in the main module"),
|
||||||
|
costs ~230px of premium height above the rail, and the banner would still show
|
||||||
|
near-loop-shaped history chips — the "repeated again" feeling survives its own
|
||||||
|
fix. Folding the education into row headers (§2) keeps ~90% of the value at
|
||||||
|
~30px per row.
|
||||||
|
|
||||||
|
**Rejected D — keep the pin/accordion semantics inside the expanded rail**
|
||||||
|
(pinned row grows a bigger gallery). With everything expanded there is nothing
|
||||||
|
left to reveal; a "bigger on pin" state reintroduces layout shift mid-jam — the
|
||||||
|
exact complaint. The pin's two real jobs (fretboard guide tones, "hold still")
|
||||||
|
survive as the focus toggle.
|
||||||
|
|
||||||
|
**Rejected E — internal max-height scroller for the band** (~50–62vh, contained
|
||||||
|
auto-scroll). Caps visible rows at ~3 (< the user's "at least 4"), adds a nested
|
||||||
|
scrollbar inside the page scroll, and only exists to enable auto-scroll the
|
||||||
|
design doesn't want. Page flow + user-owned scrolling is simpler and matches
|
||||||
|
"scrolling is easier than clicking".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Out of scope / flags for Maestro
|
||||||
|
|
||||||
|
- **ProgressionBanner polish** (progress underline beneath loop chips) — future
|
||||||
|
1-pointer, only if the beat grid is missed. Not in L-40/D-41.
|
||||||
|
- **Cleanup ticket:** RoadmapTrack.jsx, CurrentJamPanel.jsx and its now-orphaned
|
||||||
|
RiffDiagram.jsx join EducationPanel.jsx in the retire-then-delete backlog
|
||||||
|
item. ChordBox.jsx stays — it is live in ChordDetailModal + ExplorePanel (§5).
|
||||||
|
- **`SCALE_IDEAS`/`STYLE_VARIATIONS` prose** (CurrentJamPanel's unique content)
|
||||||
|
— the existing fold-into-Knowledge-Center backlog item stays open.
|
||||||
|
- **L-42 contract** (§3): bass pattern card mounts in the row gallery slot;
|
||||||
|
rail structure frozen for it.
|
||||||
|
- No new tokens, no new dependencies, no KB/theory/audio changes anywhere in
|
||||||
|
this design. All figures either verified (guitar/licks cells from the D-31
|
||||||
|
gate; piano worst case recomputed at the D-40 gate — G7 all-284px, §1) or
|
||||||
|
marked as estimates (module heights).
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Jam Guide — Concept A: "Stage"
|
||||||
|
|
||||||
|
> Design concept for the Jam Guide panel (ledger task **D-00a**). One of three concepts; the user picks. Tokens: `bg-surface` #0f0f0f, `bg-panel` #1a1a1a, `border-border` #2a2a2a, accent #a855f7, amber #f59e0b. Colour language inherited from `Fretboard.jsx`: **chord tone = accent purple**, **now-playing = amber**, scale = gray.
|
||||||
|
|
||||||
|
## 1. Name + thesis
|
||||||
|
|
||||||
|
**Stage** — a stage-monitor for your hands. It optimises for the **half-second glance mid-jam**: the current chord huge and unmistakable, the *next* chord pre-loaded so you can move in time, and exactly **one** big recommended voicing to put your fingers on. Everything a textbook would add collapses away. For the player who is already playing and just needs to be *told the next move*, readable across a rehearsal room.
|
||||||
|
|
||||||
|
## 2. Mockup — realistic panel proportions (full-width, short)
|
||||||
|
|
||||||
|
Real example: **Standard 12-bar blues in A** (`blues-12bar`, detected loop `A7→D7→E7`), play **"Barre-chord shuffle"** from `blues/guitar.js`. The big diagram is the `E_BARRE7` grip — `{ rootStr:6, offsets:[0,2,0,1,0,0] }` — placed in A: root on low E at fret 5, so the barre sits at fret 5.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ JAM GUIDE ──── Blues · 12-bar in A ────────────────── [Guitar▾] [Blues▾] [Stage|Playbook|Roadmap] ⌃collapse ─┐
|
||||||
|
│ │
|
||||||
|
│ NOW ░░░ NEXT ░░░ ── Barre-chord shuffle ── I7 IV7 V7 │
|
||||||
|
│ ┌────────────┐ shuffle · root on 6th str ● ○ ○ ○ │
|
||||||
|
│ │ │ ┌────────┐ bar 5 of 12 ┌──5fr──────────────┐ ○ ○ · · │
|
||||||
|
│ │ A7 │ → │ D7 │ ╾╾╾╾╾╾╾╾╾╾╾○╾╾╾ ── loop ── e│ ● │ ← R ● ○ · ○ │
|
||||||
|
│ │ │ └────────┘ ▮▮▮▮▮▮▮▮▮▮░░░░░ B│ ● │ ← 5 ○ ○ · │
|
||||||
|
│ │ I7 │ IV7 2 bars → change G│ ● │ ← 3 │
|
||||||
|
│ └────────────┘ D│ ● │ ← R │
|
||||||
|
│ ↑ playing now prep this A│ ● │ ← 5 │
|
||||||
|
│ E│ ● (5fr, barre) │ ← R "Strum short │
|
||||||
|
│ ▸ improv: A mixolydian · target the 3 (C♯) └───────────────────┘ — shuffle's in │
|
||||||
|
│ R=purple 3=amber accent the damp hand" │
|
||||||
|
└────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The `NOW` block is the loudest thing on screen — a single giant amber chord name with its Nashville/Roman number under it. `NEXT` is a smaller ghosted card to its right with an arrow and a literal countdown ("2 bars → change") fed by the loop-position logic. The right two-thirds is **one** large `ChordDiagram` (the recommended voicing for the *current* chord) plus a 12-bar position strip (`I7 IV7 V7` mini-map) so the eye can confirm where it is in the form without reading the whole grid.
|
||||||
|
|
||||||
|
## 3. Interaction & live-sync model
|
||||||
|
|
||||||
|
- **Chord change (detection):** when `currentChord` flips, the `NOW` card cross-fades (reuse the 200 ms `scale(0.85)→1` animation already in `ProgressionBanner.jsx`), the old `NOW` slides left into a brief "just played" ghost, and `NEXT` promotes into `NOW`. The big diagram swaps to the new chord's recommended voicing.
|
||||||
|
- **Loop detected:** the position strip (`▮▮▮▮▮░░░`) and "bar N of 12" appear; without a loop, Stage still works — `NEXT` simply shows the most-likely next chord from `ProgressionSuggestions` and the bar-counter hides.
|
||||||
|
- **Position in loop:** drives the `NEXT` card and the countdown ("2 bars → change"). The countdown is the differentiator — it lets the player *prepare the grip* before the change lands, which is the whole point of a stage monitor.
|
||||||
|
- **Animates:** only the `NOW`/`NEXT` swap and the position-strip fill. Deliberately almost nothing else moves, so the panel never competes with playing.
|
||||||
|
- **Taps:** tap `NOW` diagram → cycle to the *next way to play* this chord (the other plays in the same `plays[id]` array, e.g. "9th-chord stabs"). Tap `NEXT` → make it the focus (peek ahead). Tap the improv line → expand the one-line scale/target hint into the lick. Tap the collapse chevron → shrink to a one-line header (chord + next), the always-visible resting state.
|
||||||
|
|
||||||
|
## 4. KB-data mapping (buildable from data we already have — guitar packs exist)
|
||||||
|
|
||||||
|
| Panel part | KB source |
|
||||||
|
|---|---|
|
||||||
|
| `NOW` / `NEXT` chord names + numerals | detected `currentChord` + matched progression `degrees`/`rn` rendered in the live key (rotation-invariant match, L-01 util) |
|
||||||
|
| Which progression + "12-bar in A" label | matched `progressions[].id` + `name` + `keyInfo.root` |
|
||||||
|
| Position strip `I7 IV7 V7` + bar counter | progression `rn` + `bars` arrays; active index from `findLoopPosition` |
|
||||||
|
| "2 bars → change" countdown | sum of `bars` from active step to the next chord change |
|
||||||
|
| Big chord diagram | `plays[id][playIndex].chords[step].shape` — movable `rootStr`+`offsets` placed by key (or open `frets`+`onlyRoot`); rendered by `ChordDiagram.jsx` (D-01) |
|
||||||
|
| "Barre-chord shuffle" label + tip | `plays[id][playIndex].label` + `.tips`; per-chord caption from `chords[step].note` |
|
||||||
|
| Finger dots / R·3·5 labels | `shape.offsets` → pitch classes vs `getChordTones(currentChord)`; root + 3rd get the colour tier |
|
||||||
|
| Improv one-liner | `improv.scales[].scale` matched to current chord + `improv.targetNotes` |
|
||||||
|
| "more ways" on tap | length of `plays[id]` array |
|
||||||
|
|
||||||
|
Every field above exists today in the eight shipped guitar packs. Piano/bass packs don't exist yet; Stage degrades cleanly — the instrument tab only lists instruments present in `kb/index.js`, exactly as L-02 specifies.
|
||||||
|
|
||||||
|
## 5. Pros / cons / sacrifices
|
||||||
|
|
||||||
|
**Pros**
|
||||||
|
- **Fastest possible read** mid-jam: one chord, one next, one grip — no scanning. The thing the user actually needs in the half-second is the biggest thing on screen.
|
||||||
|
- **Preparation, not just status:** the `NEXT` card + bar countdown is unique to this concept — it tells you the move *before* it happens, which is what a live monitor is for.
|
||||||
|
- **Cheapest to build & cheapest to animate:** renders one `ChordDiagram` at a time (vs a full grid), so it's the lightest on the audio thread and the simplest D-01/D-02 surface.
|
||||||
|
|
||||||
|
**Cons**
|
||||||
|
- Shows **one voicing at a time** — you can't compare three ways to play A7 side by side without tapping.
|
||||||
|
- Less of a "study" surface; a player who wants to learn the whole form's options at rest gets less here than in Playbook.
|
||||||
|
|
||||||
|
**Sacrifices:** the full progression grid (all chords × all plays visible at once). Stage trades breadth for legibility — it assumes you'll learn the catalogue at home and use Stage to *perform*.
|
||||||
|
|
||||||
|
```PREVIEW
|
||||||
|
STAGE — one chord, the next, one grip. A stage-monitor for your hands.
|
||||||
|
┌─ JAM GUIDE · Blues 12-bar in A ················· [Guitar][Blues] ─┐
|
||||||
|
│ NOW NEXT Barre-chord shuffle I7 IV7 V7 │
|
||||||
|
│ ┌──────────┐ ┌──────┐ ┌──5fr──────────┐ ▮▮▮▮▮░░░ │
|
||||||
|
│ │ │ → │ D7 │ e│ ● │ bar 5 / 12 │
|
||||||
|
│ │ A7 │ └──────┘ B│ ● │ │
|
||||||
|
│ │ │ 2 bars → G│ ● ←3 │ ↑ amber = now │
|
||||||
|
│ │ I7 │ change D│ ● │ ● purple = root │
|
||||||
|
│ └──────────┘ E│ ● 5fr barre │ │
|
||||||
|
│ ↑ playing prep this └───────────────┘ │
|
||||||
|
│ ▸ A mixolydian · target the 3 (C♯) │
|
||||||
|
└───────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Jam Guide — Concept B: "Playbook"
|
||||||
|
|
||||||
|
> Ledger task **D-00b** · domain: design · author: Muse
|
||||||
|
> Tokens: `bg-surface` #0f0f0f · `bg-panel` #1a1a1a · `border-border` #2a2a2a · `accent` #a855f7 · `amber` #f59e0b
|
||||||
|
|
||||||
|
## 1. Name + thesis
|
||||||
|
|
||||||
|
**"The Playbook"** — the whole loop opened flat like a method-book page: a grid where **columns are the chords of the progression** and **rows are different ways to voice it**, so you can read *across* the loop and *down* the voicings in a single glance and choose how you want to play it.
|
||||||
|
|
||||||
|
This is the **deepest-information** of the three concepts. Stage answers "what do I play *right now*"; Roadmap answers "where am I going". Playbook answers **"how is this whole thing played, and what are my options"** — the panel you open between choruses to study, then internalise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Mockup — Blues 12-bar in A (real KB data, full-width ~70vh)
|
||||||
|
|
||||||
|
Rendered from `kb/blues/progressions.js` (`blues-12bar`) + `kb/blues/guitar.js` (`plays['blues-12bar']`). The 12-bar form collapses to its **3 distinct chords (I7 · IV7 · V7)** as columns — the grid teaches the *vocabulary*, the bar map (top strip) teaches the *form*. Each cell is a `ChordDiagram` (D-01). `●` = fretted/finger, `○` = root, `×` = muted, numbers above = barre fret.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ JAM GUIDE ────────────────────────────── [ Guitar ▸ Piano Bass ] [ Jazz ‖ BLUES ‖ Rock Bossa Funk … ] ──┐
|
||||||
|
│ │
|
||||||
|
│ Matched: Standard 12-bar · A major your loop: A7 → D7 → E7 ♻ bar 5 / 12 · 96 BPM shuffle │
|
||||||
|
│ form: ┃A7┃A7┃A7┃A7┃D7┃D7▸┃A7┃A7┃E7┃D7┃A7┃E7┃ ← active bar pulses amber, sweeps L→R with the band │
|
||||||
|
│ ───────────────────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||||
|
│ I7 · A7 IV7 · D7 ◀ playing now V7 · E7 │
|
||||||
|
│ │
|
||||||
|
│ Barre shuffle ●○ ● ● ● ● fr5 ●× ●○ ● ● ● ● fr5 ◀━━━━━ ●○ ● ● ● ● fr7 │
|
||||||
|
│ ▸ beginner-safe │○│ │ │ │ │ │×│○│ │ │ │ │ ◀ active col │○│ │ │ │ │ │
|
||||||
|
│ R-5-♭7-3 E-shape A7 A-shape D7 (glows) E-shape E7 │
|
||||||
|
│ "root on 6th str" "same fret, root str up" "two frets above IV" │
|
||||||
|
│ ───────────────────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||||
|
│ 9th stabs ×○♭7 ● ● ● fr5 ×○♭7 ● ● ● fr5 ×○♭7 ● ● 13 fr7 (V13) │
|
||||||
|
│ ▸ intermediate │×│○│♭│●│●│●│ │×│○│♭│●│●│●│ │×│○│♭│●│●│●│ pinky → 13 │
|
||||||
|
│ R-3-♭7-9 (Texas) "slide in ½-step below" "IV9" "V13 — horn-section hook" │
|
||||||
|
│ ───────────────────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||||
|
│ Tritone shells ×○ ● ● × × fr5 ×○ ● ● × × fr5 ×○ ● ● × × fr7 │
|
||||||
|
│ ▸ minimal/comp R-♭7-3 only "inner pair drops 1 fret → IV" "Chicago grip" │
|
||||||
|
│ │
|
||||||
|
│ ⌄ more ways (2) — Jimmy Reed boogie · Stormy Monday walk-up │
|
||||||
|
│ ═══════════════════════════════════════════════════════════════════════════════════════════════════════════── │
|
||||||
|
│ IMPROV over I7 → A Mixolydian · over IV7 → D Mixo · target the 3rds C♯→F♯→G♯ · the blues curl: ♭3↗3 │
|
||||||
|
│ lick ▸ B.B. box in C: e|8 10b12 10 8 · D bent to E (the 3rd) tap a cell to hear it ♪ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Header reads tokens directly: panel `bg-panel`, hairlines `border-border`, the matched-style tab and active highlights `accent` (#a855f7), the live bar/now-playing pulse `amber` (#f59e0b) — matching `ProgressionBanner`'s existing "active = accent ring, numeral = amber" language so the two panels feel like one instrument.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Interaction & live-sync model
|
||||||
|
|
||||||
|
**Active-column highlight (the live thread).** The detected `currentChord` maps to a *column*, not a single cell. Reusing `findLoopPosition` (extracted to `src/lib/match.js` per L-01), the panel finds the loop index, looks up which of the 3 chord-columns that bar belongs to, and lights the whole column: an `accent` left-border rail, a faint `bg-accent/8` column wash, and an `◀ playing now` caret over the header. Because Playbook shows the *whole* loop at rest, the highlight is a **moving spotlight over a static page** — your eye already knows where the next column is, so you read ahead in rhythm. The top **bar-map strip** carries the finer-grained pulse (which of 12 bars), sweeping amber L→R; the grid columns carry the coarser "which chord". Two clocks, one glance.
|
||||||
|
|
||||||
|
**"More ways ▾" expansion.** Each cell stack shows the first **2–3 plays** that fit the panel height; remaining plays (the KB has up to 4–5 per progression — Jimmy Reed boogie, Stormy Monday walk-up, etc.) collapse behind a single `⌄ more ways (N)` row at the grid foot, labelled with their play titles so you know what you're expanding. Click → the grid grows new rows in place (panel scrolls internally, header + bar-map stay pinned). Per-row, a small `▸` chevron on the row label toggles that row's **tips** line (the `tips` string) and per-cell `note`/`extensions` annotations from collapsed to shown — dense by default, denser on demand.
|
||||||
|
|
||||||
|
**Instrument / style tabs.** Top-right: style tabs generated from `kb/index.js` keys (`jazz blues rock bossa funk reggae country rnb` today, growing as the loop fills cells) — the matched style is auto-selected and shown `accent`-filled; the others are quiet `text-gray-400` and switch the whole grid (re-voicing the same detected loop in another idiom — the core "Jazz→Bossa over one ii–V–I" payoff). Top-left: instrument segmented control (Guitar live; Piano/Bass dimmed until those packs exist, driven by `instruments` keys present in the registry). Both persist to settings.
|
||||||
|
|
||||||
|
**Scroll vs collapse.** Collapsed, the panel is a **single header strip** (`Matched: … · your loop · ♻ bar n/12`) always visible at the bottom of the app scroll — identical resting footprint to the other two concepts. Click the header (or scroll into it) → expands to ~70vh. Inside, the grid scrolls **vertically** (more rows than fit) with the header, bar-map, and improv footer pinned; on narrow viewports it reflows (see §5) so columns never crush.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. KB-data mapping (proves it's buildable today)
|
||||||
|
|
||||||
|
Every cell is driven by existing fields in the shipped guitar packs — nothing new is invented:
|
||||||
|
|
||||||
|
| UI element | KB source (`kb/blues/…`) |
|
||||||
|
|---|---|
|
||||||
|
| Column headers (I7 · IV7 · V7) + `name`, key chip | `progressions.js` → `rn`, `name`, `mode`; deduped against `degrees`/`qualities` to the distinct chords |
|
||||||
|
| Top bar-map strip `┃A7┃A7┃…┃` | `progressions.js` → full `degrees` × `bars`, rendered absolute in the detected key |
|
||||||
|
| Row labels ("Barre shuffle", "9th stabs", "Tritone shells") | `guitar.js` → `plays['blues-12bar'][].label` |
|
||||||
|
| Row level badge (`▸ intermediate`) | `plays[][].level` |
|
||||||
|
| Each diagram cell | `plays[][].chords[stepForThisColumn].shape` → `ChordDiagram` (D-01); movable `rootStr`+`offsets` placed at the column chord's fret in the detected key; open shapes via `frets`+`onlyRoot` |
|
||||||
|
| Cell colour-tone label (`R-3-♭7-9`), `13`/`♭7` glyphs | `chords[].extensions` + the shape's computed pitch classes (chord-tone tier = `accent`, same as Fretboard) |
|
||||||
|
| Per-cell caption | `chords[].note` ("same fret, root str up", "V13 — pinky reaches the 13") |
|
||||||
|
| Per-row tips (▸ reveal) | `plays[][].tips` |
|
||||||
|
| "more ways (N)" titles | the remaining `plays['blues-12bar']` entries beyond the visible rows |
|
||||||
|
| IMPROV footer | `improv.scales[].over/scale`, `improv.targetNotes`, `improv.licks[].tab/description` |
|
||||||
|
| Style/instrument tabs | `kb/index.js` registry keys + each style's `instruments` keys |
|
||||||
|
|
||||||
|
Because a 12-bar has many bars but few distinct chords, the **column reducer** (distinct `degree`+`quality` pairs, order of first appearance) keeps the grid to 3–4 columns even for a 12-bar — exactly what makes "down the voicings" legible. A 4-chord loop (Axis I–V–vi–IV) yields 4 columns; the same component, no special-casing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Pros / cons / what it sacrifices
|
||||||
|
|
||||||
|
**Pros**
|
||||||
|
- **Most information per screen.** You see the entire loop *and* 3–5 ways to voice each chord at once — the only concept that supports true *comparison* ("shells vs barres vs 9ths") side by side. This is the deepest-study option, the method-book page.
|
||||||
|
- **Teaches vocabulary, not just the moment.** Reading *down* a column is a voicing lesson; reading *across* a row is the form. Level badges (beginner→advanced grips in one stack) let a player climb difficulty in place.
|
||||||
|
- **Reuses every KB field with zero waste** — multiple `plays`, `level`, `tips`, `extensions`, `note`, improv — so the agent-authored content is fully surfaced (Stage/Roadmap show a slice; Playbook shows the book).
|
||||||
|
- **Grid is reflow-friendly:** narrow → one play per row, columns scroll horizontally (the kb-plan §4 "smart fit" fallback) without changing the mental model.
|
||||||
|
|
||||||
|
**Cons**
|
||||||
|
- **Density is the cost.** It is *not* a glance-and-go panel mid-solo — there's a lot on screen, and the moving column-spotlight is the only thing that's instant. A player needs a beat to study it; if they want "just tell me the one chord now," Stage wins.
|
||||||
|
- **Diagram real-estate pressure.** 3–5 rows × 3–4 columns of SVG diagrams is the heaviest render of the three; cells must shrink on small windows (mitigated by collapse + reflow, but a 13" laptop shows ~3 rows before scroll).
|
||||||
|
- **Bar-form abstraction.** Collapsing 12 bars to 3 columns is powerful but hides bar-by-bar order *in the grid* — recovered by the top bar-map strip, but that's a second thing to read.
|
||||||
|
|
||||||
|
**What it sacrifices:** *immediacy*. The whole design trades instant glanceability for completeness — a deliberate inversion of Stage. It assumes the musician has a moment (a turnaround, a verse they know, a teacher's pause) to look down and *study*, then look up and play. The active-column spotlight is the lifeline that keeps it usable even mid-jam, but the panel's centre of gravity is **learning the loop**, not surviving the next bar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
```PREVIEW
|
||||||
|
┌ JAM GUIDE · Playbook ──────────[Guitar][Jazz‖BLUES‖Rock…]┐
|
||||||
|
│ Matched: 12-bar · A maj loop A7→D7→E7 ♻ bar5/12 96BPM │
|
||||||
|
│ form ┃A7┃A7┃A7┃A7┃D7┃D7▸┃A7┃A7┃E7┃D7┃A7┃E7┃ (amber sweep) │
|
||||||
|
│ ──────────── I7·A7 ──────── IV7·D7 ◀now ──── V7·E7 ────── │
|
||||||
|
│ Barre shuffle ●○●●●● fr5 ×●○●●● fr5 ●○●●●● fr7 │
|
||||||
|
│ ▸ beginner E-shape A7 A-shape (glows) E-shape E7 │
|
||||||
|
│ 9th stabs ▸ ×○♭7●●● fr5 ×○♭7●●● fr5 ×○♭7●●13 V13 │
|
||||||
|
│ int (Texas) R-3-♭7-9 "IV9" horn hook │
|
||||||
|
│ Tritone shell ×○●●×× fr5 ×○●●×× fr5 ×○●●×× fr7 │
|
||||||
|
│ minimal/comp R-♭7-3 inner pair −1 Chicago grip │
|
||||||
|
│ ⌄ more ways (2): Jimmy Reed · Stormy │
|
||||||
|
│ ═════════════════════════════════════════════════════════ │
|
||||||
|
│ IMPROV I7→A Mixo · target 3rds C♯→F♯→G♯ · curl ♭3↗3 ♪tap │
|
||||||
|
│ ▲ columns = loop chords · rows = ways to voice · scan ↕ │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Jam Guide — Concept C: "Roadmap"
|
||||||
|
|
||||||
|
> **Thesis:** Don't show chords — show the *journey through the loop* and what to solo over it. A horizontal timeline with a live "you are here" playhead, each station previewing its guide tones (3rds & 7ths) and the scale to blow over, with voice-leading rails drawn *between* the chords so the player sees the next note coming before it arrives.
|
||||||
|
|
||||||
|
Concept C is the **improv-first** option. Where Concept A optimises for *playing the chord in front of you* and Concept B for *comparing voicings at rest*, Concept C optimises for the soloist's question: **"the loop is turning — what do I aim at next, and what scale carries me there?"** The chord *voicing* diagram is present but demoted to a thin strip; the hero is the **improv map**: a lane of guide-tone targets and a lane of voice-leading arrows running left-to-right under a moving playhead.
|
||||||
|
|
||||||
|
It leans hardest into the learning-platform vision in `GOAL.md` Part 2 (L2 "next-chord preview tier", target-note highlighting) and the curriculum's **Tier B — Playing the Changes** (guide tones, the 7th-falls-to-the-3rd rail) and **Tier D — Functional Ears** (seeing the loop as motion, predicting the V).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Name + thesis
|
||||||
|
|
||||||
|
**Roadmap** — the live progression as a horizontal highway: a playhead drives left-to-right through the loop, each chord is a station showing its **target tones + scale**, and **voice-leading rails** connect each station to the next so you read the change one beat early.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Mockup (full-width panel, ii–V–I in C — the gold-standard `jazz-251-major`)
|
||||||
|
|
||||||
|
Detected loop `Dm7 → G7 → Cmaj7`, key C major, mode major. Playhead currently over G7, leaning into Cmaj7. Active station glows accent-purple; the rest are dimmed by age exactly like the existing `ProgressionBanner` opacity tiers.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ JAM GUIDE ───────────────────────────────────────────── [Guitar ▾] · Jazz │ Blues │ Rock │ Bossa … ─┐
|
||||||
|
│ ii–V–I in C major · your loop: Dm7 → G7 → Cmaj7 · ♻ 2 bars/chord · ~132 BPM │
|
||||||
|
│ │
|
||||||
|
│ ┌──── bar 1 ───────────┬──── bar 2 ───────────┬──── bars 3-4 ──────────────┐ ◀ lookahead 1 beat │
|
||||||
|
│ │ Dm7 (ii7) │ ▶ G7 (V7) ◀ now │ Cmaj7 (Imaj7) │ │
|
||||||
|
│ │ D dorian │ G mixolydian │ C major (avoid 4) │ ← SCALE lane │
|
||||||
|
│ ├──────────────────────┼──────────────────────┼────────────────────────────┤ │
|
||||||
|
│ │ guide tones │ guide tones │ guide tones │ │
|
||||||
|
│ │ 3rd ● F 7th ○ C │ 3rd ● B 7th ○ F │ 3rd ● E 7th ○ B │ ← TARGET lane │
|
||||||
|
│ │ ╲ │ ╲ │ │ (land these on 1) │
|
||||||
|
│ │ C ─────────▶ B │ F ─────────▶ E │ ( B holds → next loop ) │ ← VOICE-LEADING │
|
||||||
|
│ │ 7th of ii falls ½ │ 7th of V falls ½ │ resolved — get light │ rails (7→3) │
|
||||||
|
│ ├──────────────────────┼──────────────────────┼────────────────────────────┤ │
|
||||||
|
│ │ ▣▣·▣· shell │ ▣·▣▣· shell │ ▣▣·▣· shell │ ← voicing strip │
|
||||||
|
│ │ R–♭3–♭7 (tap ▸) │ R–3–♭7 (tap ▸) │ R–3–7 (tap ▸) │ (secondary) │
|
||||||
|
│ └──────────────────────┴──────────────────────┴────────────────────────────┘ │
|
||||||
|
│ ◐──────────────────────────────●───────────────────────────────────────── loop playhead │
|
||||||
|
│ │·····│·····│·····│·····│·····│●····│·····│·····│·····│·····│·····│·····│ ← beat grid (you here) │
|
||||||
|
│ │
|
||||||
|
│ TIP In any ii–V–I the 7th of one chord falls a half-step to the 3rd of the next — C→B, F→E. │
|
||||||
|
│ That two-note rail is the whole map. · tap a station → its guide tones light the fretboard ▸ │
|
||||||
|
└───────────────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The same panel over a **minor blues** (`blues-minor`-style, key A minor) reads as a longer highway — 12 stations scroll horizontally under the fixed playhead, the TARGET lane showing the ♭3/♭7 of each i7/iv7/V7 and the VOICE-LEADING rail highlighting the chromatic approach into bar 11's return home. The lookahead flag always sits one chord to the right of the playhead so the soloist sees the *incoming* target before the band gets there.
|
||||||
|
|
||||||
|
**Why this layout:** the three stacked lanes (SCALE / TARGET / VOICE-LEADING) are the literal pedagogy of "playing the changes" turned into a picture you read at a glance. The voicing strip is one row, not the hero — a soloist needs *where to aim*, not *how to grip*, and can tap to expand a grip when comping.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Interaction & live-sync model
|
||||||
|
|
||||||
|
**Playhead = loop position, continuously.** The `●` rides the beat grid driven by the onset/BPM pipeline (`handleOnset` tempo histogram → BPM) and the `findLoopPosition` util (extracted in L-01). Two sync layers:
|
||||||
|
|
||||||
|
- **Coarse (chord-accurate, ships first):** the active *station* is whatever `findLoopPosition(chordHistory, detectedProgression)` returns — exactly how `ProgressionBanner` already highlights the loop. Re-uses proven logic; no new timing risk. The playhead snaps station-to-station on each detected change.
|
||||||
|
- **Fine (beat-interpolated, phase 5 polish):** between detected changes, advance the `●` across the station's beat cells by interpolating `(now − lastOnsetTime) × BPM/60`, clamped to the station's `bars × 4` beats. Purely visual easing — if detection corrects the position, the playhead re-snaps. Degrades gracefully to coarse when BPM is unknown.
|
||||||
|
|
||||||
|
**Lookahead.** The station immediately right of the playhead carries a persistent **lookahead flag** ("◀ lookahead"). Its TARGET lane is pre-lit at ~60% brightness a configurable lead (default 1 beat) before the playhead crosses the barline — this is L2's "next-chord preview tier" and the bassist's walking-line scaffold. The flag answers "what's coming" without the player losing the current station.
|
||||||
|
|
||||||
|
**Tap to drill deeper (progressive disclosure):**
|
||||||
|
- **Tap a station** → emits `onChordClick(chord)` (the prop `ProgressionBanner` already fires) so the existing `Fretboard`/`Piano` views light that chord's tones, *plus* a Roadmap-specific extra: the station's two guide tones render in the accent tier and its scale in the pentatonic/scale tiers — i.e. "show me where to aim on the neck."
|
||||||
|
- **Tap the SCALE label** → toggles the fretboard between the full scale and the chord-tone-only skeleton for that chord (the curriculum's "chord inside the scale" drill).
|
||||||
|
- **Tap the voicing strip `▸`** → expands that one cell into a full `ChordDiagram` (D-01) inline, without leaving the roadmap. This is the only place Concept C surfaces a real grip; it stays out of the way until asked.
|
||||||
|
- **Tap TIP** → expands the progression's `tip` plus any `lick.tab` whose `over` matches this progression id, rendered as monospace tab under the highway.
|
||||||
|
|
||||||
|
**Live behaviour when no loop is matched.** Fallback to a single-station roadmap centred on `currentChord`: its guide tones, a `getCompatibleScales`-derived scale, and the voicing strip — so the panel is never empty (same fallback contract as the kb-plan §4 matching note).
|
||||||
|
|
||||||
|
**Keyboard / a11y.** Left/Right arrows move focus station-to-station (independent of the playhead); Enter = tap; the active station carries `aria-current="true"` and a visible focus ring (`ring-accent`). The playhead has `role="progressbar"` with `aria-valuetext` = current chord + beat. All lane colours meet AA against `bg-panel` (the accent `#a855f7` on `#1a1a1a` and amber `#f59e0b` for the held/secondary tone both clear 4.5:1; dimmed stations never drop below the 0.25 opacity floor the banner already uses for legibility).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. KB-data mapping — what's data vs. what's computed
|
||||||
|
|
||||||
|
| Roadmap element | Source | Data field / helper |
|
||||||
|
|---|---|---|
|
||||||
|
| Station chord names (`Dm7 G7 Cmaj7`) | **derived at runtime** | detected loop → key root + `progression.degrees` + `qualities`, resolved like `getSuggestedProgressions` already does |
|
||||||
|
| Roman numerals (`ii7 V7 Imaj7`) | **data** | `progression.rn` (display) — falls back to computed `toRomanNumeral` for the no-match case |
|
||||||
|
| Bars per station (`bar 1 / bars 3-4`) | **data** | `progression.bars` — drives station width on the highway |
|
||||||
|
| SCALE lane (`D dorian`, `G mixolydian`) | **data, with computed fallback** | KB `improv.scales[{over, scale, why}]` keyed by the chord's `rn`. If a style's pack has no `improv.scales` entry for a degree → **compute** a default from `SCALES`/`getCompatibleScales` (e.g. Mixolydian over a dom7) |
|
||||||
|
| "avoid 4" / scale caveats | **data** | `improv.scales[].why` (the jazz pack literally says "avoid sitting on the 4th over the maj7") |
|
||||||
|
| TARGET lane — 3rd & 7th tones | **fully computed** | `getChordTones(chord)` → index 1 = 3rd, last = 7th (per `CHORD_TYPES.intervals` ordering). *No KB field stores guide tones; theory.js derives them.* |
|
||||||
|
| VOICE-LEADING rails (`C ▶ B`, `F ▶ E`) | **computed** | for adjacent stations, find the 7th of chord *n* and the 3rd of chord *n+1*; draw a rail when they're a half/whole step apart. The "7→3 falls ½" relationship comes from comparing `getChordTones` outputs — pure interval math |
|
||||||
|
| TARGET-lane prose hint | **data** | `improv.targetNotes` ("Land the 3rd of each chord on the downbeat") |
|
||||||
|
| Voicing strip (mini grid + `R–3–♭7`) | **data** | `plays[progId][0].chords[i].shape` (rendered tiny) + `.note` label; full grip on tap via `ChordDiagram` |
|
||||||
|
| TIP line | **data** | `progression.tip`; lick tab from `improv.licks[].tab` filtered by `over === progId` |
|
||||||
|
| Playhead position | **computed (live)** | `findLoopPosition` (L-01) + BPM from `handleOnset`; no KB data |
|
||||||
|
| BPM / feel label | **data + live** | `meta.tempoRange` / `meta.feel` for context; live BPM from the onset pipeline |
|
||||||
|
|
||||||
|
**Honesty note — the load-bearing computed pieces.** Concept C's two hero lanes are *not in the KB at all*:
|
||||||
|
1. **Guide tones** (3rd/7th badges) are computed from `getChordTones`. The KB stores *that* a scale fits and *that* the 7th resolves (prose), but never the literal pitches — those are derived per detected key. This is a feature: it stays key-agnostic (kb-plan principle 1) and works in all 12 keys for free.
|
||||||
|
2. **Voice-leading rails** are computed by diffing consecutive chords' tone sets. No new `theory.js` function is strictly required — `getChordTones` + a small `voiceLeadingPairs(chordA, chordB)` helper (≤15 lines: match each tone of A to its nearest tone in B, keep moves ≤2 semitones) covers it. I'd flag this helper to Maestro as a tiny shared `theory.js`/`match.js` addition Luthier owns; Muse only consumes its output.
|
||||||
|
|
||||||
|
Everything else is straight KB reads. The scale lane degrades gracefully: data-driven where a pack authored `improv.scales`, computed-default where it didn't — so the roadmap renders for *any* style cell, even a minimal one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Pros / cons / what it sacrifices
|
||||||
|
|
||||||
|
**Pros**
|
||||||
|
1. **Directly teaches improv** — guide tones + scale + voice-leading rails are the curriculum's "playing the changes" pillar rendered literally; this is the only concept that answers *"what do I solo with?"* rather than *"what's the chord?"*
|
||||||
|
2. **Lookahead is built in** — the one-beat-early next-target preview is L2's headline learning feature and the thing a soloist physically needs; it's structural here, bolted-on elsewhere.
|
||||||
|
3. **Maximises the KB's prose assets** — `improv.scales[].why`, `targetNotes`, `tip`, and `licks` (rich, sourced text the Professor wrote) get top billing instead of being buried under diagrams.
|
||||||
|
4. **Key-agnostic by construction** — the two hero lanes are computed, so one KB cell drives a correct roadmap in all 12 keys with zero extra data.
|
||||||
|
|
||||||
|
**Cons**
|
||||||
|
1. **Two computed subsystems** (guide-tone extraction, voice-leading pairing) before it looks "real" — more theory.js surface than A or B, which mostly *display* stored data. Mitigated: both are tiny and reuse `getChordTones`.
|
||||||
|
2. **Horizontal scroll on long forms** — a 12-bar blues exceeds one screen; stations must shrink or scroll. The fixed-playhead / scrolling-highway pattern solves it but is more layout work than a static grid.
|
||||||
|
3. **Voicing is genuinely secondary** — a player who opened the panel to *find a grip* has to tap to get it. That's the deliberate trade (see below).
|
||||||
|
|
||||||
|
**What it sacrifices**
|
||||||
|
- **Voicing comparison.** Concept B shows 3 ways to grip every chord side-by-side; Concept C shows *one* grip per station as a thumbnail and makes you tap for the rest. A jammer whose actual need is "give me a chord shape right now" is better served by A or B. Roadmap bets that the higher-value, harder-to-find guidance is *where to aim your solo* — and that voicings, being concrete and well-served elsewhere in the app, can wait behind a tap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
```PREVIEW
|
||||||
|
ROADMAP — improv-first: the loop as a highway you solo across
|
||||||
|
ii–V–I in C Dm7 → G7 → Cmaj7 ♻ 2 bars/chord ~132 BPM
|
||||||
|
┌── Dm7 (ii7) ──┬─▶ G7 (V7) ◀now ─┬── Cmaj7 (Imaj7) ──┐ ◀ look-
|
||||||
|
│ D dorian │ G mixolydian │ C major (avoid 4) │ ahead
|
||||||
|
│ 3rd●F 7th○C │ 3rd●B 7th○F │ 3rd●E 7th○B │ ← targets
|
||||||
|
│ C ──▶ B │ F ──▶ E │ ( B holds → loop )│ ← 7→3 rail
|
||||||
|
│ ▣▣·▣· shell ▸ │ ▣·▣▣· shell ▸ │ ▣▣·▣· shell ▸ │ ← voicing
|
||||||
|
└───────────────┴─────────────────┴────────────────────┘
|
||||||
|
◐────────────────●────────────────────────────────── playhead
|
||||||
|
│··│··│··│··│··│●·│··│··│··│··│··│··│ beat grid (here)
|
||||||
|
TIP 7th of one chord falls ½-step to the 3rd of the next:
|
||||||
|
C→B, F→E — that two-note rail is the whole map.
|
||||||
|
tap a station → its guide tones light up the fretboard ▸
|
||||||
|
```
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
# Jam Roulette — design spec (task D-62)
|
||||||
|
|
||||||
|
> **Thesis:** one button, two clicks, a jam. "Jam roulette" in the controls bar
|
||||||
|
> opens a genre menu (the 10 KB styles + Surprise me); picking one instantly
|
||||||
|
> rolls a random key + an interesting KB progression and **seeds the exact
|
||||||
|
> state live detection already writes** — `lockedKey`, `detectedProgression`,
|
||||||
|
> and a committed-shape `progressionVoteRef` — so the loop strip, the voicing
|
||||||
|
> rail, the licks strip, and the related-progressions card all populate
|
||||||
|
> **exactly as if the loop had been detected**. Musicians read the screen and
|
||||||
|
> start playing; the L-31 commit layer then treats the seed like any committed
|
||||||
|
> loop: agreeing detections confirm it, a genuinely different loop replaces it
|
||||||
|
> after `REPLACE_VOTES`, and New Song clears it. **No new parallel state** —
|
||||||
|
> the seed is a writer into the existing machine, not a second machine.
|
||||||
|
>
|
||||||
|
> User ask (verbatim, sprint goal 2026-07-11): *"one button that would be a
|
||||||
|
> random chord progression selection. a button up top 'jam roulette' and then
|
||||||
|
> select a genre (like we have jazz, blues rock etc) and then have a random key
|
||||||
|
> chosen and an interesting progression show up as a loop in the screen. it
|
||||||
|
> would fill in the loop section and a 4 bar progression or something so that
|
||||||
|
> everyone can see the key and the chords to be played with different voicing
|
||||||
|
> so they can start playing… then musicians can start playing and it continues
|
||||||
|
> from it"*
|
||||||
|
|
||||||
|
**Dependency note:** L-60 is dep-blocked on **C-50** (one-screen close-out).
|
||||||
|
This spec is written against `docs/design/one-screen.md`'s **target** layout
|
||||||
|
(controls bar per §1.1, slim loop strip per §1.2, the two-column dashboard),
|
||||||
|
which L-50 is building concurrently. Everything here that names the controls
|
||||||
|
bar or the strip means the *post-L-50* versions; the seed mechanism (§3) is
|
||||||
|
layout-independent and verified against App.jsx as it stands today (~b753b58).
|
||||||
|
|
||||||
|
Standing principles honoured: reuse, don't duplicate; nothing shown twice;
|
||||||
|
scroll > click; tokens only, no raw hex; the 🚨 App.jsx audio contract
|
||||||
|
(callbacks / ref-sync / AudioCapture untouched).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. UX flow
|
||||||
|
|
||||||
|
### 1.1 The button
|
||||||
|
|
||||||
|
- **Where:** right end of the controls bar, in the global-chrome cluster next
|
||||||
|
to one-screen §1.1's `⛶ Jam view` toggle ("a button up top" — the controls
|
||||||
|
bar is the app's persistent top chrome; the header row stays reserved for
|
||||||
|
Settings / New Song / Start).
|
||||||
|
- **What:** `🎲 Jam roulette` — same button language as the existing controls:
|
||||||
|
`px-3 py-1 rounded-lg border border-border text-sm text-gray-200
|
||||||
|
hover:border-gray-500 focus-visible:ring-2 focus-visible:ring-accent
|
||||||
|
transition-colors`. While a seed is active (unconfirmed, §1.3) the button
|
||||||
|
carries the seeded accent: `border-accent/40 text-accent bg-accent/10` +
|
||||||
|
`aria-pressed="true"` — same active treatment as the locked-key pill.
|
||||||
|
- **Keyboard:** real `<button>`, Tab-reachable, Enter/Space opens the menu.
|
||||||
|
|
||||||
|
### 1.2 The genre menu
|
||||||
|
|
||||||
|
Click → an anchored popover (not a page takeover; the jam never leaves the
|
||||||
|
screen): `absolute` panel under the button, `bg-panel border border-border
|
||||||
|
rounded-xl shadow-lg p-1 z-20`, one column of `<button>` rows
|
||||||
|
(`w-full text-left px-3 py-1.5 rounded-lg text-sm text-gray-200
|
||||||
|
hover:bg-accent/10 focus-visible:ring-2 focus-visible:ring-accent`):
|
||||||
|
|
||||||
|
1. **`✨ Surprise me`** — first row, divider under it (`border-b border-border`)
|
||||||
|
— uniform pick over the 10 styles, then §2 as normal.
|
||||||
|
2. **The 10 KB styles**, straight from the registry:
|
||||||
|
`Object.entries(kb).map(([id, s]) => s.meta.label)` — never a hard-coded
|
||||||
|
list; an 11th style appears here for free.
|
||||||
|
3. When a seed is active, row 0 becomes **`⟳ Re-roll — {styleLabel}`** (same
|
||||||
|
style, fresh key + progression, honouring §2's no-repeat memory) and
|
||||||
|
`✨ Surprise me` moves to row 1.
|
||||||
|
|
||||||
|
Escape / click-outside / picking a row closes it. Focus returns to the button.
|
||||||
|
Picking a genre seeds **instantly** — two clicks from cold to a full jam
|
||||||
|
dashboard. No confirmation step: re-rolling is cheaper than confirming.
|
||||||
|
|
||||||
|
### 1.3 The seeded indicator — honest provenance
|
||||||
|
|
||||||
|
The dashboard must not pretend the roulette loop was *heard*. Until live
|
||||||
|
detection confirms it, the slim loop strip's loop row swaps its chrome:
|
||||||
|
|
||||||
|
- The `♻` glyph becomes `🎲`; the `→ loop` tail becomes an amber provenance
|
||||||
|
chip: **`rolled · {styleLabel} · {progression.name} · {Σbars} bars — play
|
||||||
|
it!`** (`text-amber-400 text-xs` — amber is the app's established secondary
|
||||||
|
accent, the rn colour; no new token). Σbars comes from the KB `bars` array —
|
||||||
|
this is where "a 4 bar progression" is made visible.
|
||||||
|
- The loop chips themselves render **identically** to a detected loop (same
|
||||||
|
chip anatomy, chord + rn) — the chords are real instructions, only the
|
||||||
|
*provenance* differs.
|
||||||
|
- The moment the commit layer confirms the seed (§3.4 — first agreeing live
|
||||||
|
detection), the indicator flips to the normal `♻ … → loop` with no other
|
||||||
|
visual change: the honest signal that "the band is now playing what was
|
||||||
|
rolled". If a *different* loop replaces the seed, the indicator vanishes
|
||||||
|
with the seed (the new loop is detection-owned).
|
||||||
|
|
||||||
|
Empty-state note: pre-first-note the history slot still shows "Start
|
||||||
|
listening…" — correct and honest (nothing has been heard); the key chip shows
|
||||||
|
the rolled key immediately (it's a real `lockedKey`, rendered with the 🔒 pill
|
||||||
|
in the controls bar too, mode dropdown and unlock included — the rolled key is
|
||||||
|
a first-class key lock, not a special display).
|
||||||
|
|
||||||
|
### 1.4 Flow summary
|
||||||
|
|
||||||
|
Cold start: **🎲 → genre → the dashboard fills** (key chip + locked-key pill,
|
||||||
|
loop chips with rn, voicing rail with every station's shapes/voicings, licks
|
||||||
|
strip aimed at station 0, related progressions) → band reads, plays → playhead
|
||||||
|
lights on the first committed loop chord → detection confirms after ~2 cycles
|
||||||
|
→ 🎲 indicator becomes ♻ → the jam continues under pure live detection.
|
||||||
|
Mid-session: 🎲 acts as **New Song + seed** (§3.6) — one gesture, clean slate,
|
||||||
|
new card.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Randomization rules
|
||||||
|
|
||||||
|
All randomness lives in the seed function (plain `Math.random()`; no audio
|
||||||
|
contact). Inputs: the KB registry only.
|
||||||
|
|
||||||
|
### 2.1 Key
|
||||||
|
|
||||||
|
- **Root:** uniform over the 12 pitch classes, spelled from **`NOTES` (sharp
|
||||||
|
spellings)** — mandatory, not stylistic: `matchChordFromChroma` names every
|
||||||
|
committed chord via `noteName(r)` with `preferFlat=false` (theory.js:112,
|
||||||
|
445 — the live naming path), so only sharp spellings string-match detection
|
||||||
|
later (§3.3).
|
||||||
|
- **Mode:** taken directly from the rolled progression's `mode` field —
|
||||||
|
verified: **every KB progression encodes `mode`**, vocabulary
|
||||||
|
`major | minor | dorian | mixolydian` (grep across all 10
|
||||||
|
`progressions.js`), all four already present in the key-lock mode dropdown
|
||||||
|
and `SCALES`. No inference, no default needed.
|
||||||
|
- **Confidence:** `1` (the `applyLock` convention).
|
||||||
|
- Session memory: don't repeat the previous roll's root (re-draw once).
|
||||||
|
|
||||||
|
### 2.2 Progression pool ("interesting" AND seedable)
|
||||||
|
|
||||||
|
Per style, from `kb[style].progressions`, each entry realized + collapsed per
|
||||||
|
§3.2 first. **Pool membership = the hard bounds ∩ the round-trip invariant**
|
||||||
|
— a progression only rolls if the machine can genuinely confirm it:
|
||||||
|
|
||||||
|
- **Hard length bounds: collapsed length must be 2–8.**
|
||||||
|
- `< 2` — unrepresentable as a detected loop (`detectRepeatingProgression`
|
||||||
|
min pattern length 2). Today this excludes **nothing**: even
|
||||||
|
`funk-one-chord` collapses to **two** names (`X7, Xsus4` — its
|
||||||
|
`['dom7','dom7','sus4','dom7']` qualities survive collapse because a
|
||||||
|
quality change on the same root is a different name; wrap-dedupe then
|
||||||
|
drops the trailing `X7`). The rule stays as future-proofing for a true
|
||||||
|
single-name vamp, and the code comment must say so honestly.
|
||||||
|
- `> 8` — the detector's candidate loop only sweeps lengths **2–8**
|
||||||
|
(theory.js:627). A longer seed is not merely unconfirmable: as the band
|
||||||
|
plays it, in-window *fragments* of it become the only detectable
|
||||||
|
structure and **replace the seed after `REPLACE_VOTES` = 3** — the card
|
||||||
|
self-destructs into a sub-loop. Excludes today: `jazz-blues` (collapsed
|
||||||
|
10), `blues-quickchange` (9), `bossa-blue` (10).
|
||||||
|
- **Round-trip invariant (the real gate) — the protocol is load-bearing:**
|
||||||
|
*a progression passes iff, with the 32-commit window filled with
|
||||||
|
repetitions of its seeded canonical form (steady state) and truncated at
|
||||||
|
**every** partial-cycle offset (0…len−1 commits past a cycle boundary),
|
||||||
|
the real `detectRepeatingProgression` returns exactly that form at **all**
|
||||||
|
offsets.* Steady-state-plus-all-offsets is the honest protocol because a
|
||||||
|
jam is sampled mid-cycle, not at cycle boundaries — and a naive "feed 2
|
||||||
|
clean cycles" protocol gets *both* failure modes wrong: it spuriously
|
||||||
|
fails all 8 two-name vamps (2 cycles × 2 names = 4 commits, under
|
||||||
|
theory.js:609's history ≥ 6 gate — a ramp artifact, not a steady-state
|
||||||
|
failure), and it *passes* `blues-8bar`, whose 7-name form actually fails
|
||||||
|
at exactly **1 of its 7 phase offsets** (4 cycles + 4 extra commits → the
|
||||||
|
detector prefers the `[C7, G7]` alternation). Round-trip is *empirical,
|
||||||
|
not constructional* — the detector's recency×occurrence scoring can prefer
|
||||||
|
an internal alternation over the full form at some phase. The invariant is
|
||||||
|
**precomputed over the whole registry** (lazy module-level memo on first
|
||||||
|
roulette open — key-independent per §3.3.3, so one sweep in C covers all
|
||||||
|
keys; an 11th style or new progression joins the pool automatically) and
|
||||||
|
**pinned by the L-60 commit-1 smoke sweep** (same protocol verbatim —
|
||||||
|
§3.4 flag 2).
|
||||||
|
- **Honest numbers today (Critic gate ran the real matcher + detector under
|
||||||
|
the protocol above):** **4/56 fail** — the 3 over-length forms plus
|
||||||
|
`blues-8bar`'s phase-offset failure — so the **expected pool is 52/56**,
|
||||||
|
pinned exactly by the smoke sweep. (Pre-fix-(a), 10/56 additionally
|
||||||
|
returned NO MATCH for their own collapsed form and `country-145` matched a
|
||||||
|
different progression — §3.3.1; all return to the pool with the fix, since
|
||||||
|
matching post-fix is constructional, §3.3.4.)
|
||||||
|
- **Weight = levelW × lenW** (over pool members only):
|
||||||
|
- `levelW`: `level === 'intermediate'` → **2**; foundation/untagged → **1**
|
||||||
|
(the D-20 rule: untagged counts foundation). "Interesting" = intermediate
|
||||||
|
leans in, foundations still roll.
|
||||||
|
- `lenW`: collapsed length **3–7 → 2** (the "4-bar-ish" sweet spot — note
|
||||||
|
a collapsed 12-bar blues is 7 names, so the genre's staple stays strong);
|
||||||
|
**2 and 8 → 1**.
|
||||||
|
- **No-repeat memory:** `rouletteMemoryRef` (plain `useRef([])` in App —
|
||||||
|
UI-state ref, never read by audio code) keeps the last **6** rolled
|
||||||
|
progression ids; they're excluded from the pool. If exclusion leaves the
|
||||||
|
pool empty (small styles — several have 5 entries), fall back to excluding
|
||||||
|
only the immediately previous id. The ref survives New Song deliberately
|
||||||
|
(variety across songs is the point) and dies with the session.
|
||||||
|
- Weighted draw; push id to memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The seed mechanism — spec'd against the real App state
|
||||||
|
|
||||||
|
### 3.1 What the seed writes (exhaustive)
|
||||||
|
|
||||||
|
`rollJam(styleId)` — a plain function in App.jsx, sibling of
|
||||||
|
`quickLock`/`newSong`:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. newSong() // §3.6 — the clean-slate rule
|
||||||
|
2. const info = { root: NOTES[rolledPc], mode: prog.mode, confidence: 1 }
|
||||||
|
setLockedKey(info)
|
||||||
|
effectiveKeyRef.current = info // the quickLock precedent —
|
||||||
|
chordVotesRef.current = [] // detection uses the key NOW
|
||||||
|
3. const loop = seedableLoop(prog, rolledPc) // §3.2 — realize→collapse→canonicalize
|
||||||
|
4. setDetectedProgression(loop)
|
||||||
|
5. progressionVoteRef.current = {
|
||||||
|
committedKey: loop.join(','), // the committed shape, L-31's own
|
||||||
|
candidateKey: null,
|
||||||
|
candidateCount: 0,
|
||||||
|
seeded: true, // §3.4 — the one flag L-60 adds
|
||||||
|
}
|
||||||
|
progressionMissRef.current = 0 // (newSong already did; explicit)
|
||||||
|
6. setSeedInfo({ styleLabel, name, bars }) // §3.5 — display provenance
|
||||||
|
7. rouletteMemoryRef bookkeeping (§2.2)
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing else. **Never touched:** `handleNote` / `handleChroma` / `handleOnset`
|
||||||
|
/ `handleWaveform`, AudioCapture and its props, `noteHistoryRef`,
|
||||||
|
`keyVotesRef`, `chromaRingRef`, the loop engine. The seed is display state +
|
||||||
|
the two progression refs the L-31 effect already owns — exactly the audio
|
||||||
|
contract boundary the L-60 ledger row grep-gates.
|
||||||
|
|
||||||
|
Synergy worth stating: `handleChroma` bails while `effectiveKeyRef.current` is
|
||||||
|
null — a cold session normally can't commit chords until key detection locks.
|
||||||
|
The seed provides the key up front, so **the very first chroma frames can
|
||||||
|
commit chords**, and the diatonic bonus in `matchChordFromChroma` is already
|
||||||
|
biased toward the rolled key. The roulette makes detection *faster*, not
|
||||||
|
just decorated.
|
||||||
|
|
||||||
|
### 3.2 Realizing the loop — `seedableLoop(progression, keyRootPc)`
|
||||||
|
|
||||||
|
The chord names must be **byte-identical** to what detection would commit when
|
||||||
|
the band plays the progression, or the commit layer treats the seed as a rival
|
||||||
|
forever. Three steps, each forced by a verified code path:
|
||||||
|
|
||||||
|
1. **Realize** (the JamGuide `stationVoicings` formula, JamGuide.jsx:262-273):
|
||||||
|
per station `rootPc = (keyRootPc + degrees[i]) % 12`,
|
||||||
|
`name = NOTES[rootPc] + (CHORD_TYPES[qualities[i]]?.suffix ?? '')`.
|
||||||
|
Sharp spellings only (§2.1).
|
||||||
|
2. **Collapse consecutive duplicate names**, then if `first === last` drop the
|
||||||
|
last — mirrors `detectRepeatingProgression`'s collapse of back-to-back
|
||||||
|
commits (theory.js:611-615) *plus* the cyclic wrap (in the live stream the
|
||||||
|
loop's tail flows into its head; `setChordHistory` also dedupes
|
||||||
|
consecutive identical commits, App.jsx:363). E.g. `blues-12bar` degrees
|
||||||
|
`[0,0,0,0,5,5,0,0,7,5,0,7]` → 7 names `[I,IV,I,V,IV,I,V]` realized;
|
||||||
|
`funk-one-chord` keeps its quality changes (same root, different suffix ≠
|
||||||
|
duplicate → `[X7, Xsus4]`). Name-collapse is provably **key-independent**
|
||||||
|
(§3.3.3) — the collapsed *shape* is a property of the progression, which
|
||||||
|
is what makes fix (a) (§3.3.2) computable once, key-free.
|
||||||
|
3. **Canonicalize rotation** with **theory.js's own rule** — lexicographically
|
||||||
|
smallest rotation compared via `join('\0')` (`canonicalize`,
|
||||||
|
theory.js:579-586, currently private). `detectRepeatingProgression`
|
||||||
|
returns *its* canonical rotation; the agreement branch compares exact
|
||||||
|
`join(',')` strings, so the seed must store the same rotation or
|
||||||
|
confirmation is unreachable (§3.4 flag 2).
|
||||||
|
|
||||||
|
**Where it lives:** `seedableLoop` exported from **`src/lib/match.js`**
|
||||||
|
(loop-identity is that file's mandate; it already imports theory.js
|
||||||
|
read-only; L-51 is adding adjacent exports there and L-60 runs after C-50, so
|
||||||
|
no lock overlap). For step 3 it needs `canonicalize`: **preferred — additive
|
||||||
|
`export` keyword on theory.js's `canonicalize`** (one-word diff; shared-file
|
||||||
|
lock, flag to Maestro at L-60 promotion; smoke's `loop-fixtures.mjs` already
|
||||||
|
byte-checks a replica of this function, so a second in-app replica would
|
||||||
|
triple the drift surface). Fallback if Maestro won't open theory.js: replica
|
||||||
|
in match.js with a drift comment + smoke guard, the loop-fixtures precedent.
|
||||||
|
|
||||||
|
Display note: the banner shows the seeded loop in this canonical rotation —
|
||||||
|
**identical to how any live-detected loop displays today**; the JamGuide rail
|
||||||
|
re-rotates to canonical KB order via `match.rotation` regardless, so the
|
||||||
|
learning surface always reads in textbook order (ii first in a ii–V–I).
|
||||||
|
|
||||||
|
### 3.3 Matching + round-trip — constructional after fix (a)
|
||||||
|
|
||||||
|
#### 3.3.1 The bug fix (a) exists for (Critic gate, real matcher run)
|
||||||
|
|
||||||
|
`buildLoopIndex` (match.js:126-141) indexes each progression's **raw,
|
||||||
|
uncollapsed** `degrees` — but the seeded loop (and the *live* commit stream,
|
||||||
|
which dedupes back-to-back chords) is the **collapsed** form, a different
|
||||||
|
sequence length whenever a progression repeats a chord across adjacent slots
|
||||||
|
or across the wrap. The gate ran the real matcher: **10/56 progressions
|
||||||
|
return NO MATCH for their own collapsed form** — `jazz-blues`, `blues-12bar`,
|
||||||
|
`blues-quickchange`, `blues-8bar`, `blues-minor`, `bossa-blue`,
|
||||||
|
`funk-one-chord`, `country-folk-axis`, `gospel-iv-passing-dim`,
|
||||||
|
`gospel-tonicized-amen` — and `country-145` is worse: its raw 8-shape matches
|
||||||
|
a *different* progression. Rolling blues would produce a populated banner and
|
||||||
|
an **empty JamGuide**. This is also a **pre-existing live-detection bug** the
|
||||||
|
roulette work surfaces: a band playing a real 12-bar blues today commits the
|
||||||
|
collapsed stream and gets an empty JamGuide — fix (a) repairs both at once.
|
||||||
|
|
||||||
|
#### 3.3.2 Fix (a) — collapsed-form indexing (Maestro-directed, mandatory)
|
||||||
|
|
||||||
|
`buildLoopIndex` **additionally** indexes each progression's collapsed form:
|
||||||
|
|
||||||
|
- **Collapse rule (key-free):** dedupe adjacent stations whose
|
||||||
|
`(degree, suffix)` pairs are equal, where
|
||||||
|
`suffix = CHORD_TYPES[quality]?.suffix ?? ''` (compare *suffixes*, not raw
|
||||||
|
quality tokens — two out-of-vocab qualities both fall back to `''` and
|
||||||
|
produce equal names, so a token comparison would under-collapse); then
|
||||||
|
**wrap-dedupe**: if the last pair equals the first, drop the last (one
|
||||||
|
check suffices post-collapse — only the boundary pair can merge).
|
||||||
|
- **What's indexed:** `canonicalDegrees(collapsedDegrees)` → an entry whose
|
||||||
|
`progression` is the **collapsed projection** of the authored one:
|
||||||
|
`{ ...progression, degrees, qualities, rn (first-of-run), bars (summed per
|
||||||
|
run), sourceIndex }` where `sourceIndex[i]` = the first raw station index
|
||||||
|
of collapsed station `i`. Same `id`/`name`/`level`/`mode`/`songs`/`tip`.
|
||||||
|
- **Only when it differs:** entries are added only where the collapsed shape
|
||||||
|
≠ the raw shape — **45/56 index once, 11 twice**. The full census (gate,
|
||||||
|
raw → collapsed): `jazz-blues` 12→10, `blues-12bar` 12→7,
|
||||||
|
`blues-quickchange` 12→9, `blues-8bar` 8→7, `blues-minor` 12→5,
|
||||||
|
`bossa-blue` 16→10, `funk-one-chord` 4→2, `country-145` 8→4,
|
||||||
|
`country-folk-axis` 8→7, `gospel-iv-passing-dim` 4→3,
|
||||||
|
`gospel-tonicized-amen` 5→4.
|
||||||
|
- **The true compatibility invariant (stated exactly):** collapsed entries
|
||||||
|
are **appended after all raw entries**, so `matchLoopToProgression`'s
|
||||||
|
strict-`>` disambiguation keeps every previously-matching input's winner
|
||||||
|
**on ties** — but append order never engages on a strict score win, and
|
||||||
|
one real case exists: for input shape `[0,5,0,7]` with
|
||||||
|
`maj/maj/maj/dom7` qualities (e.g. `[C, F, C, G7]`), `country-145`'s
|
||||||
|
collapsed form scores 4×100−4 and **strictly beats** the incumbent
|
||||||
|
`country-bluegrass-cycle` (3×100−4 — its quality set lacks `dom7`).
|
||||||
|
Gate-verified registry-wide (raw + collapsed × 3 keys): this is the
|
||||||
|
**only** winner change, and it is an **improvement** — the progression
|
||||||
|
actually being played now self-attributes (pure-triad `[C,F,C,G]` input
|
||||||
|
still ties and keeps `bluegrass-cycle`). So: **behaviour-preserving except
|
||||||
|
where a collapsed entry strictly dominates the disambiguation score,
|
||||||
|
enumerated: `country-145`, desired direction.** Previously-`NO_MATCH`
|
||||||
|
collapsed streams now match; that is the point.
|
||||||
|
- **Rotation:** for a collapsed-form hit, `rotationToCanonicalOrder` runs
|
||||||
|
against the *collapsed* degrees — equal lengths by construction. (Today's
|
||||||
|
length-mismatch guard, match.js:208, silently returns 0, which is exactly
|
||||||
|
what would break the playhead if the raw progression were returned
|
||||||
|
instead.)
|
||||||
|
|
||||||
|
Downstream, JamGuide renders the projection's stations naturally — a 12-bar
|
||||||
|
renders **7 stations, one per distinct change** (the right glance-rail
|
||||||
|
answer: duplicate consecutive bars add no voicing information; the strip's
|
||||||
|
loop chips carry position). The one alignment consumers need: **every
|
||||||
|
authored-play lookup must remap through `sourceIndex`** — otherwise a
|
||||||
|
collapsed match reads the first N raw play entries against the wrong
|
||||||
|
stations. There are **four** raw-indexed lookups, not two:
|
||||||
|
|
||||||
|
- **guitar** shape path, JamGuide.jsx:280, and **piano** recipe path, :288 —
|
||||||
|
both inside the `stationVoicings` memo: `play.chords[prog.sourceIndex?.[i]
|
||||||
|
?? i]`;
|
||||||
|
- **bass**, inside `BassGuideRows`: `play?.chords?.[i]?.pattern` (:530) and
|
||||||
|
`…?.note` (:533). Mechanism chosen: the `stationVoicings` memo attaches a
|
||||||
|
per-station **`sourceIndex` field** (`stations[i].sourceIndex =
|
||||||
|
prog.sourceIndex?.[i] ?? i`), and the two bass lines read
|
||||||
|
`play?.chords?.[st.sourceIndex ?? i]` — BassGuideRows already receives the
|
||||||
|
stations, so **no new prop**; the heard-live path's synthetic station has
|
||||||
|
no `sourceIndex` and the `?? i` fallback keeps it exact.
|
||||||
|
|
||||||
|
Blues ships guitar, piano *and* bass cells, and its bass pack covers **four
|
||||||
|
collapse-affected progressions** (`blues-12bar`/`quickchange`/`8bar`/
|
||||||
|
`minor`), two of them pool members (`blues-12bar` 7, `blues-minor` 5) — so
|
||||||
|
the bass remap is load-bearing for the roulette itself, not just for live
|
||||||
|
detection. Four bounded lines + the one field attachment; JamGuide.jsx joins
|
||||||
|
L-60's lock (§5).
|
||||||
|
|
||||||
|
#### 3.3.3 Key-independence proof (why the index can be built key-free)
|
||||||
|
|
||||||
|
Realized names are `Nᵢ = NOTES[(k + dᵢ) mod 12] + suffix(qᵢ)` for key root
|
||||||
|
`k`. Adjacent names are equal ⇔ their roots are equal AND their suffixes are
|
||||||
|
equal. Roots: `(k + dᵢ) ≡ (k + dⱼ) (mod 12) ⇔ dᵢ ≡ dⱼ (mod 12)` — `k`
|
||||||
|
cancels, and KB degrees live in 0–11, so this is plain `dᵢ = dⱼ`. Suffixes:
|
||||||
|
the 14 `CHORD_TYPES` suffixes are pairwise distinct (verified by inspection,
|
||||||
|
theory.js:41-55), so equal suffixes ⇔ the same suffix class — identical
|
||||||
|
quality for in-vocab tokens, the shared `''` fallback for out-of-vocab ones
|
||||||
|
(hence §3.3.2 compares suffixes). Therefore **name-collapse ≡
|
||||||
|
(degree, suffix)-pair collapse in every key**: `buildLoopIndex` computes the
|
||||||
|
collapsed shape once with no key in hand, and `seedableLoop`'s per-key name
|
||||||
|
collapse always produces that same shape. The same argument makes the §2.2
|
||||||
|
**round-trip invariant key-independent**: the detector consumes only the name
|
||||||
|
stream's *equality structure*, which is key-invariant; the lexicographic
|
||||||
|
rotation choice may differ per key, but seed and detector canonicalize
|
||||||
|
identically over identical names, so agreement of the two outputs is
|
||||||
|
preserved — one sweep in C covers all 12 keys.
|
||||||
|
|
||||||
|
#### 3.3.4 What is constructional vs what stays empirical
|
||||||
|
|
||||||
|
- **Matching is constructional post-fix-(a):** every rolled loop *is* some
|
||||||
|
progression's collapsed canonical shape, and fix (a) indexes exactly those
|
||||||
|
— `matchLoopToProgression` cannot miss. (It may attribute to a same-shape
|
||||||
|
sibling — the §4 quirk, now including within-style cases like
|
||||||
|
`country-145`-collapsed ≡ `country-bluegrass-cycle`.)
|
||||||
|
- **Detector round-trip stays empirical** — hence §2.2's precomputed
|
||||||
|
registry-wide invariant (steady-state window, all partial-cycle offsets)
|
||||||
|
as the pool gate: today 4/56 fail — 3 over-length + `blues-8bar`'s
|
||||||
|
1-of-7-offsets self-competition; expected pool **52/56**, pinned by smoke.
|
||||||
|
Worked example of a passer: rolled `jazz-251-major` in A → seeded
|
||||||
|
`[Bm7, E7, Amaj7]` (canonical rotation) → band plays it → detection
|
||||||
|
commits the same sharp names from the same `CHORD_TYPES` suffixes → after
|
||||||
|
2 cycles (6 commits, clearing the history ≥ 6 gate) the detector returns
|
||||||
|
the identical canonical form at every subsequent commit, whatever the
|
||||||
|
phase → agreement branch fires. ✓
|
||||||
|
|
||||||
|
#### 3.3.5 Honest limit — qualities detection can't name
|
||||||
|
|
||||||
|
`MATCH_CHORD_TYPES` (theory.js:59-61) is a *subset* — KB qualities `dim7` and
|
||||||
|
`min6` (7 progression slots across the KB, counted) can never be committed by
|
||||||
|
detection under those exact names (it will hear `dim` / `m7`-ish). For such
|
||||||
|
rolls the seed displays the authored chord, and once the band plays,
|
||||||
|
detection consistently returns the *as-heard* loop → the `REPLACE_VOTES`
|
||||||
|
branch swaps in reality after 3 commits. That is the machine working as
|
||||||
|
designed — the card yields to what's actually played, and the re-match still
|
||||||
|
lands on the same KB progression (degree shape unchanged, quality is only a
|
||||||
|
tie-breaker). Same story if the band plays triads where the KB says sevenths.
|
||||||
|
Not a bug; documented behaviour.
|
||||||
|
|
||||||
|
### 3.4 The L-31 commit-layer interplay — branch by branch
|
||||||
|
|
||||||
|
The effect (App.jsx:181-213) runs once per chord commit (`[chordHistory]`
|
||||||
|
deps — seeding itself triggers exactly one run via `newSong`'s
|
||||||
|
`setChordHistory([])`, landing in the null branch at miss 1). With a seeded
|
||||||
|
vote `{committedKey: S, candidateKey: null, candidateCount: 0, seeded: true}`:
|
||||||
|
|
||||||
|
| Branch | Seeded behaviour | Verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| **Agreement** (`vote.committedKey === key`) | Refreshes the loop, drops rivals — **L-60 adds:** `vote.seeded = false` + `setSeedInfo(null)`. The seed is now a normal committed loop; every subsequent rule applies verbatim | works, 2-line addition |
|
||||||
|
| **Rival counting** (`candidateKey` bookkeeping) | A transient sub-cycle detection during ramp-up (real: a 7-name collapsed 12-bar contains `[I,IV]` twice before the full form recurs) starts a candidate; `committedKey` survives the rebuild — **but the rebuild literal at line 207 drops `seeded`** | 🚩 **flag 3 below** |
|
||||||
|
| **Replacement** (`candidateCount ≥ REPLACE_VOTES=3`) | A genuinely different, consistently-detected loop replaces the seed; new vote is detection-owned (no `seeded`) — **L-60 adds** `setSeedInfo(null)` here | correct as-is: rolled cards must yield to a real band playing something else |
|
||||||
|
| **Null branch / miss counter** (`NULL_CLEAR=6`) | **Kills EVERY seed during ramp-up — no length survives.** L-31's "fills never null" reasoning assumed the loop is *in the history window* — a seeded loop isn't yet. Exact timeline: the seed's own effect run (via `newSong`'s history reset) is **miss 1**; commits 1–5 take the counter to 6 → **the card is wiped at commit 5**. But the earliest *any* detection can land is **commit 6** — `detectRepeatingProgression` hard-gates raw history `< 6` (theory.js:609), so even a 2-name loop cannot confirm before then, and a loop of length *len* needs ~2·len commits besides. **Unguarded, every seed dies exactly one commit before the earliest possible confirmation** — the guard is mandatory for every pool member, not a long-loop nicety | 🚩 **flag 1 — the critical fix** |
|
||||||
|
|
||||||
|
**Flags for L-60 (each a 1-line change inside the effect — the one licensed
|
||||||
|
edit in that block):**
|
||||||
|
|
||||||
|
1. **Null-clear guard:** `if (progressionMissRef.current >= NULL_CLEAR &&
|
||||||
|
!progressionVoteRef.current?.seeded) { …clear… }`. Semantics: *a seeded
|
||||||
|
card is an instruction, not an observation — it stays until confirmed
|
||||||
|
(then normal rules resume), replaced by a consistently-detected different
|
||||||
|
loop, re-rolled, or New Song.* A band that noodles structurelessly under a
|
||||||
|
seeded card keeps the card — intended: it's what they were asked to play.
|
||||||
|
2. **Rotation equality is constructional** (§3.2/§3.3) — no branch change,
|
||||||
|
but if `seedableLoop` skipped canonicalization the agreement branch would
|
||||||
|
be unreachable and the seed would churn-replace with itself (rotated) at
|
||||||
|
commit 3. L-60's smoke-visible invariant is §2.2's registry-wide sweep,
|
||||||
|
**protocol verbatim:** *for each of the 56 progressions, fill the
|
||||||
|
32-commit window with repetitions of the seeded canonical form (steady
|
||||||
|
state) and truncate at every partial-cycle offset (0…len−1 commits past a
|
||||||
|
cycle boundary); the progression passes only if
|
||||||
|
`detectRepeatingProgression` returns exactly the seeded canonical form at
|
||||||
|
ALL offsets.* Expected: 52 passers pinned; the 4 named failures
|
||||||
|
(`jazz-blues`, `blues-quickchange`, `bossa-blue`, `blues-8bar`) asserted
|
||||||
|
as excluded from the pool. (A naive 2-clean-cycles feed is wrong in both
|
||||||
|
directions — §2.2.)
|
||||||
|
3. **Preserve `seeded` across candidate rebuilds:** line 207 becomes
|
||||||
|
`{ committedKey, candidateKey: key, candidateCount: 1,
|
||||||
|
seeded: vote?.seeded ?? false }` — otherwise one transient ghost
|
||||||
|
sub-cycle strips the flag and flag 1's guard dies with it, resurrecting
|
||||||
|
the ramp-up kill through the side door.
|
||||||
|
|
||||||
|
Nothing else in the effect treats a seed differently — `COMMIT_VOTES` never
|
||||||
|
applies (a committed key exists), and `progressionMissRef.current = 0` on any
|
||||||
|
detection keeps the counter honest once structure appears.
|
||||||
|
|
||||||
|
### 3.5 The provenance flag — machine truth vs display echo
|
||||||
|
|
||||||
|
Two readers need "seeded, unconfirmed": the effect (guard, flag 1) and the
|
||||||
|
banner/button (indicator, §1.3). The effect reads `vote.seeded` — its own ref,
|
||||||
|
already in hand, mutation-safe. The **display** cannot read a ref reactively,
|
||||||
|
so App gains one small UI-state:
|
||||||
|
`const [seedInfo, setSeedInfo] = useState(null)` —
|
||||||
|
`{ styleLabel, name, bars } | null`, passed to ProgressionBanner as a
|
||||||
|
presentational prop (and read by the roulette button for its active state +
|
||||||
|
re-roll row). This is **provenance metadata that exists nowhere else** (style
|
||||||
|
label, progression name, bar count) — not a duplicate of
|
||||||
|
`detectedProgression`. Invariant, enforced at every transition: `seedInfo !==
|
||||||
|
null ⇔ progressionVoteRef.current?.seeded === true`. Sync points: set
|
||||||
|
together in `rollJam`; cleared together in the agreement branch, the
|
||||||
|
replacement branch, and `newSong` (one added line — `setSeedInfo(null)`).
|
||||||
|
|
||||||
|
### 3.6 New Song & mid-session rolls
|
||||||
|
|
||||||
|
`newSong()` (App.jsx:216-241) already clears the whole seed surface:
|
||||||
|
`progressionVoteRef.current = null`, `progressionMissRef.current = 0`,
|
||||||
|
`setDetectedProgression(null)`, `setLockedKey(null)`,
|
||||||
|
`setChordHistory([])` — verified line by line; L-60 adds only
|
||||||
|
`setSeedInfo(null)`. **The seed clears naturally.** ✓
|
||||||
|
|
||||||
|
**Mid-session rule: roulette = New Song + seed** (`rollJam` step 1). Without
|
||||||
|
it, a 32-commit history still containing the *old* loop keeps re-detecting it
|
||||||
|
and would replace the fresh seed within 3 commits even after the band
|
||||||
|
switches — the stale window poisons the handoff. `newSong()` is the
|
||||||
|
already-tested full reset (history, votes, key, BPM); seeding onto that clean
|
||||||
|
slate makes ramp-up §3.4's only story. The banner history emptying is honest
|
||||||
|
("new song" is literally what the button did), and the loop-station audio is
|
||||||
|
untouched (`newSong` never touched it).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. What fills in — every consumer, verified
|
||||||
|
|
||||||
|
The seed sets `lockedKey` (→ `effectiveKey`) + `detectedProgression` (+ the
|
||||||
|
vote ref). Consumers and their honest pre-first-note state (`chordHistory`
|
||||||
|
empty, `currentChord` undefined):
|
||||||
|
|
||||||
|
| Surface | Consumes | Seeded behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| Slim loop strip — key chip | `keyInfo` (= effectiveKey) | Rolled key + mode render immediately |
|
||||||
|
| Slim loop strip — loop chips | `detectedProgression`, `findLoopPosition(chordHistory, loop)` | All chips render (chord + rn via the rolled key). `findLoopPosition` **returns −1 on empty history** (match.js:75 guard) → **no chip highlighted** — correct: nobody is anywhere yet. 🎲 indicator per §1.3 |
|
||||||
|
| Strip — history row | `chordHistory` | "Start listening…" — honest |
|
||||||
|
| JamGuide match | `matchLoopToProgression(loop, kbIndex)` | **Matched by construction — after §3.3.2's fix (a)** (mandatory L-60 scope): the seeded loop is the progression's collapsed canonical shape, which fix (a) indexes. *Without* the fix, 10/56 rolls (all of blues among them) would show a populated banner and an **empty JamGuide** — and the same fix repairs today's live-detection miss for real 12-bar streams. Header shows the progression name + "in {key}" |
|
||||||
|
| Voicing rail (GlanceRail / BassGuideRows) | `stationVoicings` (match + keyRoot), `activeIndex = canonicalPos` | Every station's shapes/voicings render in canonical KB order — for collapse-affected matches, the **collapsed projection's** stations (a 12-bar renders 7, one per distinct change) with plays remapped via `sourceIndex` (§3.3.2). `canonicalPos` guard (JamGuide.jsx:220-225) → **−1** → **no "now" row** — the rail shows the whole map unhighlighted |
|
||||||
|
| Licks strip | `activeStyle` (= match.style), `contextStation` | Populates; `contextStation` **falls back to station 0** when canonicalPos is −1 (JamGuide.jsx:341-344, existing comment says exactly this) — licks aim at the progression's first chord until the playhead exists |
|
||||||
|
| Related progressions (L-51) | `{ loop, keyInfo, onChordClick }` | Populates for free — it computes its own match from the loop |
|
||||||
|
| Instrument view | `keyInfo`, `currentChord` | Scale + pentatonic tiers light in the rolled key; **no chord-tone tier until the first commit** (currentChord undefined) — honest |
|
||||||
|
| ChordDetailModal | tap any chip | Works — chips pass real chord names |
|
||||||
|
| Chord detection itself | `effectiveKeyRef` | **Enabled immediately** by the seeded key (§3.1 synergy) |
|
||||||
|
|
||||||
|
**The pre-first-note playhead, stated plainly:** there is none — no strip chip
|
||||||
|
glows, no rail row is "now", and that is the design: a playhead claims
|
||||||
|
knowledge of where the band is, and the app doesn't have it yet. It lights on
|
||||||
|
the **first committed chord whose name appears in the loop** (`findLoopPosition`
|
||||||
|
walks back from the last commit; a first chord outside the loop keeps it
|
||||||
|
unlit). The 🎲 chip carries the "we're starting" affordance until then.
|
||||||
|
|
||||||
|
**Style-attribution quirk (documented, accepted):** same-shape progressions
|
||||||
|
exist across styles (`rock-axis` / `reggae-nwnc` / `pop-axis` all
|
||||||
|
`[0,7,9,5]`), and fix (a) adds within-style aliases
|
||||||
|
(`country-145`-collapsed ≡ `country-bluegrass-cycle`).
|
||||||
|
`matchLoopToProgression` disambiguates by quality overlap then length; ties
|
||||||
|
fall to entry order (raw before collapsed, §3.3.2 — incumbents keep winning
|
||||||
|
on ties), and the one strict-win exception is `country-145`'s collapsed form
|
||||||
|
over `bluegrass-cycle` for dom7-V input — the desired direction (§3.3.2's
|
||||||
|
true invariant). So rolling *pop* → Axis may render the rail/licks
|
||||||
|
attributed to **rock**. The chords, key, rn, and voicings are identical;
|
||||||
|
only the style label and lick flavour differ. **Re-judged against Maestro's belt-and-braces
|
||||||
|
offer: with fix (a) mandatory, matching is constructional and a seeded
|
||||||
|
`styleHint` would correct only the label/lick flavour — cosmetic, not
|
||||||
|
load-bearing. Declined for v1** (it would plumb a new prop through JamGuide
|
||||||
|
for no structural gain); noted for Maestro as a user-pulled follow-up if
|
||||||
|
anyone notices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. L-60 scope — exact files, bounded commits
|
||||||
|
|
||||||
|
**The audio-contract boundary (grep-gated, restated):** the seed touches
|
||||||
|
display state (`lockedKey`, `detectedProgression`, `seedInfo`,
|
||||||
|
`chordHistory` via `newSong`) + the progression refs
|
||||||
|
(`progressionVoteRef`, `progressionMissRef`) + UI refs
|
||||||
|
(`rouletteMemoryRef`, `effectiveKeyRef` per the quickLock precedent) —
|
||||||
|
**never** `handleNote`/`handleChroma`/`handleOnset`/`handleWaveform`, their
|
||||||
|
ref-sync effects, or any AudioCapture prop.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
1. `src/lib/theory.js` — **additive only:** `export` keyword on
|
||||||
|
`canonicalize` (shared file — flag lock to Maestro at promotion;
|
||||||
|
Professor+Critic co-review per PROTOCOL §3; fallback per §3.2).
|
||||||
|
2. `src/lib/match.js` — the sprint's real engineering weight, all additive:
|
||||||
|
- **`seedableLoop(progression, keyRootPc) → string[] | null`** (realize →
|
||||||
|
collapse+wrap-dedupe → canonicalize; null for collapsed length < 2);
|
||||||
|
- **fix (a)** per §3.3.2: `buildLoopIndex` collapsed-form entries
|
||||||
|
(collapsed projection with `sourceIndex`, appended after raw entries)
|
||||||
|
+ `matchLoopToProgression` collapsed-hit rotation against collapsed
|
||||||
|
degrees. Behaviour-preserving except the one enumerated strict win
|
||||||
|
(§3.3.2 true invariant: `country-145`, desired direction);
|
||||||
|
previously-`NO_MATCH` collapsed streams now match — this also fixes
|
||||||
|
the pre-existing live 12-bar miss;
|
||||||
|
- the **round-trip pool sweep** helper (lazy module-level memo, §2.2).
|
||||||
|
After C-50, no lock overlap with L-51's exports.
|
||||||
|
3. `src/App.jsx` — `rollJam(styleId)` + `seedInfo` state +
|
||||||
|
`rouletteMemoryRef`; the roulette button + popover menu in the controls
|
||||||
|
bar; the three 1-line commit-layer edits (§3.4 flags 1 & 3 + the two
|
||||||
|
`setSeedInfo(null)` clears); one line in `newSong`.
|
||||||
|
4. `src/components/ProgressionBanner.jsx` — presentational `seedInfo` prop:
|
||||||
|
🎲 glyph + amber provenance chip on the loop row (§1.3).
|
||||||
|
5. `src/components/JamGuide.jsx` — **four bounded lines + one field**
|
||||||
|
(§3.3.2): guitar-shape and piano-recipe lookups remap through
|
||||||
|
`prog.sourceIndex?.[i] ?? i` (JamGuide.jsx:280, :288); `stationVoicings`
|
||||||
|
attaches `stations[i].sourceIndex`; BassGuideRows' two raw-indexed
|
||||||
|
lookups (`pattern` :530, `note` :533) read
|
||||||
|
`play?.chords?.[st.sourceIndex ?? i]` — no new prop, heard-live falls
|
||||||
|
back via `?? i`. Re-lock at promotion — JamGuide is one-screen-locked
|
||||||
|
until C-50, which L-60 already depends on.
|
||||||
|
|
||||||
|
**Suggested commits (green at each):**
|
||||||
|
1. theory.js export + match.js (`seedableLoop` + fix (a)) + the JamGuide
|
||||||
|
`sourceIndex` remaps (all four paths incl. bass) + the smoke sweep: the
|
||||||
|
§2.2 round-trip invariant over **all 56** progressions under the
|
||||||
|
steady-state/all-offsets protocol (§3.4 flag 2 verbatim; expected 52
|
||||||
|
passers pinned; the 4 named failures asserted excluded) **plus** a
|
||||||
|
live-stream fixture proving a clean 12-bar history now matches (the
|
||||||
|
pre-existing bug, pinned fixed).
|
||||||
|
2. App.jsx commit-layer guard lines + `rollJam` + state/refs (mechanism live,
|
||||||
|
invokable from a temporary plain button if needed).
|
||||||
|
3. The controls-bar button + menu + banner indicator (the visible feature).
|
||||||
|
|
||||||
|
**Out of L-60 scope:** GlanceRail/VoicingBrowser (untouched — they render
|
||||||
|
whatever stations JamGuide hands them), JamGuide beyond the four remap lines
|
||||||
|
+ field, KB content, theory beyond the one export, audio, and the
|
||||||
|
`styleHint` disambiguation (declined, §4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rejected alternatives
|
||||||
|
|
||||||
|
**Rejected A — fake `chordHistory` injection** (seed by pushing 2 synthetic
|
||||||
|
cycles into history so the detector "discovers" the roll naturally). Seductive
|
||||||
|
— zero commit-layer changes — and dishonest at every surface: the banner
|
||||||
|
history strip shows eight chords **nobody played**; `findLoopPosition`
|
||||||
|
immediately lights a playhead asserting the band is mid-loop before a note
|
||||||
|
sounds; the L-31 cadence reasoning (thresholds are *per real chord commit*)
|
||||||
|
silently breaks; and the first real commits interleave with the fake tail,
|
||||||
|
producing corrupted-rep flapping (the exact pathology C-30/L-31 spent a sprint
|
||||||
|
taming). The seed must write conclusions, not forge evidence.
|
||||||
|
|
||||||
|
**Rejected B — a separate `seededProgression` state rendered when
|
||||||
|
`detectedProgression` is null.** Duplicates the loop's identity across two
|
||||||
|
states with a priority rule; every consumer (strip, JamGuide, RelatedProgressions,
|
||||||
|
future ones) must learn the fallback or silently miss the seed; and the
|
||||||
|
detection handoff becomes a hard visual swap between two objects instead of
|
||||||
|
the vote ref's smooth confirm/replace. The standing principle exists for
|
||||||
|
exactly this: one loop, one state, one machine.
|
||||||
|
|
||||||
|
**Rejected C — display-only overlay ("ghost card") that never enters the
|
||||||
|
state machine**, cleared on first detection. Honest-looking, but the screen's
|
||||||
|
surfaces don't populate from a ghost: the rail, licks, and related
|
||||||
|
progressions all key off `detectedProgression` — an overlay would either
|
||||||
|
leave them empty (failing "so that everyone can see the chords with different
|
||||||
|
voicings") or need Rejected B's plumbing anyway. Also loses the §3.1 synergy
|
||||||
|
(no `lockedKey` → chord detection stays gated on key detection).
|
||||||
|
|
||||||
|
**Rejected D — neutralize the ramp-up with a miss-counter credit**
|
||||||
|
(`progressionMissRef.current = -(2·len + 4)` at seed time) instead of the
|
||||||
|
`seeded` flag. One line, no flag — but the credit is a magic number that burns
|
||||||
|
in real time: a band that chats for eight commits' worth of noodling before
|
||||||
|
starting spends it on nothing and the card still dies mid-ramp-up; and it
|
||||||
|
leaves no way for the agreement branch to know confirmation happened (the
|
||||||
|
indicator can't flip). The flag has clean semantics; the credit has arithmetic
|
||||||
|
luck.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Flags for Maestro
|
||||||
|
|
||||||
|
- **theory.js shared-file lock** (additive `export` on `canonicalize`) — L-60
|
||||||
|
promotion must note it; Professor co-review per PROTOCOL §3.
|
||||||
|
- **L-60 lock list** per §5 (App.jsx + ProgressionBanner.jsx + match.js +
|
||||||
|
theory.js + JamGuide.jsx, four remap lines + one station field) —
|
||||||
|
supersedes the ledger row's "App.jsx (+ per D-62)".
|
||||||
|
- **Fix (a) repairs a pre-existing LIVE bug** (§3.3.1: real 12-bar/8-bar
|
||||||
|
streams — 10 progressions match nothing in JamGuide today, and
|
||||||
|
`country-145` misattributes; 11 collapse-affected total, §3.3.2 census).
|
||||||
|
Worth a line in the PR body / Herald's changelog: users get it even if
|
||||||
|
they never touch the roulette.
|
||||||
|
- **`styleHint` disambiguation** — re-judged and **declined for v1** (§4:
|
||||||
|
cosmetic post-fix-(a)); user-pulled follow-up only.
|
||||||
|
- **Smoke round-trip sweep** (§5 commit 1, all 56 progressions + the live
|
||||||
|
12-bar fixture) — if Critic prefers it C-owned, it slots into C-61's sweep
|
||||||
|
instead.
|
||||||
|
- No new tokens, no new colours (amber + accent are established), no
|
||||||
|
dependencies, no KB/audio changes anywhere in this design.
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# Knowledge Center — design concept (task D-20)
|
||||||
|
|
||||||
|
> **Thesis:** JamBuddy's knowledge should live in **one bottom dock with four sections**, grown out of the existing Jam Guide dock — not three (actually five) competing collapsible panels. The live Roadmap stays the flagship landing section; Explore, Voicings, and Licks & Techniques become sibling sections inside the same shell. One shared level filter (foundation/intermediate), one visual language (the Roadmap's, per `jam-guide-concept-c.md`), zero new top-level chrome.
|
||||||
|
|
||||||
|
**Decision authority:** per the sprint header (user away 12 h, no user gate), Muse picks the strongest layout and records rationale + rejected alternatives below (§6). Implementation is task **L-22**; this doc is its blueprint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Honest audit — what actually exists today
|
||||||
|
|
||||||
|
Read directly from `src/App.jsx` and the components before designing. The "three disconnected surfaces" from the user directive are real, but the on-disk truth is messier:
|
||||||
|
|
||||||
|
| Surface | File | Mounted in App.jsx? | Content |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Jam Guide (Roadmap) | `src/components/JamGuide.jsx` | **Yes** — last, bottom dock | Live loop → KB match → RoadmapTrack + voicing strip; instrument + style tabs |
|
||||||
|
| Explore panel | `src/components/ExplorePanel.jsx` | **No — orphaned** (verified: no import anywhere in `src/`) | Chord picker (root × quality), guitar voicing grid, piano techniques, famous progressions |
|
||||||
|
| Education panel | `src/components/EducationPanel.jsx` | **No — orphaned** (same verification) | Session snapshot, similar progressions, "play differently" substitutions, progression variations |
|
||||||
|
| Current Jam panel | `src/components/CurrentJamPanel.jsx` | **Yes** — mid-page collapsible | Voicings / scales / style options / **similar progressions** — heavy overlap with EducationPanel |
|
||||||
|
| Chord detail modal | `src/components/ChordDetailModal.jsx` | **Yes** — overlay | 6 tabs: Guitar, Piano, Theory, Learn (playbook + ASCII licks), Progressions, Explore |
|
||||||
|
|
||||||
|
So the fragmentation the user feels is: JamGuide (bottom) + CurrentJamPanel (middle) + ChordDetailModal (overlay), with two *dead* panels duplicating slices of both. The Knowledge Center consolidates the browse/learn material into the bottom dock, reuses the orphans as parts, and leaves CurrentJamPanel and the modal shell untouched this sprint (flagged for follow-up, §10).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Chosen structure — one dock, four sections
|
||||||
|
|
||||||
|
The existing JamGuide dock (collapsed header → ~70vh body) becomes the **Knowledge Center**. Its body gains a section nav; everything else about the dock chrome (collapsed summary bar, live match label, 70vh expand) is preserved.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ KNOWLEDGE CENTER ── ii–V–I in C major · in C major ──────────────────── ▼ ─┐ (collapsed bar,
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘ unchanged behaviour)
|
||||||
|
|
||||||
|
┌─ KNOWLEDGE CENTER ── ii–V–I in C major ──────────────────────────────── ▲ ─┐
|
||||||
|
│ ┌───────────────┬─────────┬──────────┬────────────────────┐ │
|
||||||
|
│ │ ▶ Jam Guide ● │ Explore │ Voicings │ Licks & Techniques │ ← section nav │
|
||||||
|
│ └───────────────┴─────────┴──────────┴────────────────────┘ (● = live) │
|
||||||
|
│ │
|
||||||
|
│ [ Jam Guide (live) — the existing Roadmap body, byte-for-byte: │
|
||||||
|
│ instrument tabs · style tabs · RoadmapTrack · voicing strip ] │
|
||||||
|
│ │
|
||||||
|
│ [ Explore — KB progression browser + famous progressions: │
|
||||||
|
│ style chips · (Foundation)(Intermediate) level chips · │
|
||||||
|
│ progression cards with level badge, chords-in-key, tip, songs ] │
|
||||||
|
│ │
|
||||||
|
│ [ Voicings — chord picker (root × quality, follows the live chord) → │
|
||||||
|
│ VoicingBrowser: guitar shapes + piano voicings, each with ▶ play ] │
|
||||||
|
│ │
|
||||||
|
│ [ Licks & Techniques — style chips · level chips · │
|
||||||
|
│ LickCard grid (tab SVG + technique glyphs) · glyph legend ] │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Section semantics:**
|
||||||
|
|
||||||
|
1. **Jam Guide (live)** — default landing section; answers *"what do I play over this loop right now?"* The current Roadmap body moves in unchanged (RoadmapTrack, playhead, station voicings strip, piano tab from L-11). The `●` live dot on the nav pill pulses only when a loop is matched, so even from another section you see the guide is hot.
|
||||||
|
2. **Explore** — answers *"what could we play?"* A **KB progression browser** (new, simple: style chips from the `kb/index.js` registry → progression cards showing name, rn pattern, chords rendered in the current key, `tip`, level badge) plus the famous-progressions cards reused from ExplorePanel. Any chord chip → `ChordDetailModal` via `onChordClick`.
|
||||||
|
3. **Voicings** — answers *"how do I grip/voice this chord — and what does it sound like?"* ExplorePanel's root × quality picker (defaulting to `currentChord` when live) feeding **VoicingBrowser** (D-21): guitar alternatives via `ChordDiagram`, piano alternatives via `MiniPiano voicing`, each variant with a ▶ button through `src/lib/chordAudio.js`.
|
||||||
|
4. **Licks & Techniques** — answers *"give me a phrase."* Style chips → **LickCard** (D-22) grid of P-21 licks, level chips shared with Explore, technique-glyph legend once per grid (not per card). Styles without authored licks show an honest empty state ("No licks authored for Pop yet — Blues, Jazz and Funk have them").
|
||||||
|
|
||||||
|
**Why the dock and not a new page:** the Knowledge Center must be reachable *mid-jam* without losing the detection UI (key chip, fretboard, banner). The bottom dock already established that contract with the Roadmap; sections extend it. Concept-C's visual language (station cards, accent-on-active, dimmed-by-age tiers, amber for secondary/held tones) carries through all four sections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. IA map — reused / new / retired
|
||||||
|
|
||||||
|
| Piece | Fate | Where it lands |
|
||||||
|
|---|---|---|
|
||||||
|
| `JamGuide.jsx` Roadmap body (RoadmapTrack, voicing strip, instrument/style tabs, L-11 piano tab) | **Reused unchanged** | Section 1 content; JamGuide.jsx becomes the Knowledge Center shell + section 1 |
|
||||||
|
| `ExplorePanel.jsx` chord picker + quick-pick chips | **Reused** (refactored to named exports) | Section 3 toolbar |
|
||||||
|
| `ExplorePanel.jsx` `GuitarGrid` / `PianoGrid` | **Reused as interim**, then superseded by VoicingBrowser | Section 3 body until D-21 merges; kept as the no-audio fallback |
|
||||||
|
| `ExplorePanel.jsx` `ProgressionCards` (famous progressions) | **Reused** | Section 2, below the KB progression browser |
|
||||||
|
| KB progression browser (style → progressions with level badges) | **New** (small, data straight from `kb/index.js` which JamGuide already imports) | Section 2 hero |
|
||||||
|
| `VoicingBrowser.jsx` (D-21) | **New** | Section 3 + `ChordDetailModal` + station-enlarge (see §3) |
|
||||||
|
| `LickCard.jsx` (D-22) | **New** | Section 4 grid |
|
||||||
|
| Level filter chips | **New** (one shared component + one shared state in the shell) | Sections 2 and 4 toolbars (§4) |
|
||||||
|
| `EducationPanel.jsx` | **Retired** — stays unmounted; not edited (not in the L-22 lock). Its unique content (substitutions, variations) already lives in CurrentJamPanel + ChordDetailModal Theory tab. File deletion is a future cleanup task for Maestro to file | — |
|
||||||
|
| `CurrentJamPanel.jsx` | **Untouched this sprint** — overlap with section 2 acknowledged; folding it in is a follow-up (§10) | mid-page, as today |
|
||||||
|
| `ChordDetailModal.jsx` | **Shell untouched**; L-21 swaps only its Guitar/Piano tab grids for VoicingBrowser | overlay, as today |
|
||||||
|
|
||||||
|
Naming: the collapsed bar reads **"Knowledge Center — 〈live match label〉"**; the flagship keeps its name as section 1, "Jam Guide". No behaviour of the collapsed bar changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Where VoicingBrowser and LickCard mount
|
||||||
|
|
||||||
|
**VoicingBrowser** (`src/components/VoicingBrowser.jsx`, D-21) — three mount points, one component, prop-driven `{rootPc, quality}`:
|
||||||
|
|
||||||
|
1. **Knowledge Center → Voicings section** (L-22, via the ExplorePanel.jsx refactor) — the browse-first entry: pick any chord, audition every voicing.
|
||||||
|
2. **ChordDetailModal → Guitar/Piano tabs** (L-21) — replaces/extends the static `GuitarGrid`/`PianoGrid` so the modal's voicings become playable.
|
||||||
|
3. **Jam Guide station-enlarge** (L-21) — tapping a Roadmap station's thumbnail currently enlarges one `ChordDiagram`; it becomes the VoicingBrowser for that station's `{rootPc, quality}`, so mid-jam you can audition alternatives for the chord that's coming.
|
||||||
|
|
||||||
|
Data sources: guitar shapes from `src/lib/voicings.js` `GUITAR_SHAPES` (already the `ChordDiagram`-compatible `rootStr`/`offsets` format); piano voicings computed by `src/lib/piano.js` `pianoVoicing` (root / shell / rootlessA / rootlessB) rendered by `MiniPiano voicing`. Audio via `src/lib/chordAudio.js` (L-20). **Mic-feedback caveat surfaced in UI:** a one-line `text-gray-500` hint near the ▶ buttons — "played through your speakers — the mic may hear it" — mirroring the L-20 documentation.
|
||||||
|
|
||||||
|
**LickCard** (`src/components/LickCard.jsx`, D-22) — one mount point this sprint:
|
||||||
|
|
||||||
|
1. **Knowledge Center → Licks & Techniques section** (L-22): responsive grid, one card per P-21 lick, filtered by style chip + level chips. Card shows name, level badge, `chordContext` chip ("over the V7"), the tab SVG with technique glyphs (h, p, slide ⌒, bend ↑ … per the C-20 vocab), and the technique names it uses. The glyph legend renders once below the grid.
|
||||||
|
|
||||||
|
*Not* mounted in ChordDetailModal's Learn tab this sprint — that tab's `CHORD_PLAYBOOK` ASCII licks are a different corpus (`education.js`); converging them is future work, noted for Maestro (§10).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Level filter — placement and default
|
||||||
|
|
||||||
|
**Form:** two toggle chips, `Foundation` and `Intermediate`, rendered side by side in a section's toolbar. Both **on by default** (= show everything; badges do the signalling). Tapping one off narrows the list; both can't be off (tapping the last active chip is a no-op with a brief tooltip). This is the "easiest possible surface" per the directive: no new panel, no dropdown, one tap to see only intermediate material.
|
||||||
|
|
||||||
|
**State:** one `levels` state in the Knowledge Center shell, shared by both consuming sections — filter once, it holds as you move between Explore and Licks.
|
||||||
|
|
||||||
|
**Placement (exactly two spots):**
|
||||||
|
- **Explore section toolbar**, next to the style chips — filters the KB progression browser (every KB progression carries `level` after P-20).
|
||||||
|
- **Licks & Techniques toolbar** — filters LickCards (`lick.level` from the C-20 schema).
|
||||||
|
|
||||||
|
**Chip styling:** inactive = `bg-surface border-border text-gray-400`; active = `bg-accent/20 border-accent text-accent font-semibold` (the established active-tab treatment). Level *badges* on cards: foundation = `border-border text-gray-400`; intermediate = `border-amber/40 text-amber` (amber is already a token — no new colour needed).
|
||||||
|
|
||||||
|
**Honesty:** `FAMOUS_PROGRESSIONS` in `src/lib/education.js` carries **no** `level` field and `education.js` is in no task's lock — the famous-progressions cards in Explore show no badge and are **not affected by the filter** (they sit under their own "Famous progressions" subheading so the filter's scope reads clearly). Tagging that corpus is an open question for Maestro (§10).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Narrow-viewport plan
|
||||||
|
|
||||||
|
Target checks at implementation: **1280×800** (default Electron window), **~1024×768**, and **≤420px** width (half-snapped window).
|
||||||
|
|
||||||
|
- **Section nav:** horizontal chip row, `overflow-x-auto` + `shrink-0` pills (the pattern ChordDetailModal's tab bar and EducationPanel's nav already use) — never wraps into a tall block, never traps vertical scroll.
|
||||||
|
- **Dock body:** stays ~70vh with internal vertical scrolling; each section is a vertical stack on narrow.
|
||||||
|
- **Jam Guide section:** keeps its existing verified reflow (voicing strip wraps via `flex-wrap sm:flex-nowrap`, RoadmapTrack scrolls horizontally under the fixed playhead).
|
||||||
|
- **Explore:** progression cards are full-width stacked (they already are `flex-col`); chord chips inside cards wrap.
|
||||||
|
- **Voicings:** voicing-variant chips wrap; diagrams grid uses `repeat(auto-fill, minmax(140px, 1fr))` so thumbnails go 2-up at ~360px and never overflow; the enlarged diagram caps at container width (`max-w-full`, SVG scales via viewBox).
|
||||||
|
- **Licks:** grid `repeat(auto-fill, minmax(240px, 1fr))` → single column below ~520px; the LickCard SVG scales to card width (viewBox + `width:100%`), glyphs sized in SVG units so they scale with the tab.
|
||||||
|
- **Toolbars** (style chips + level chips): `flex-wrap` — level chips drop to a second row on narrow rather than shrinking below tap-target size (min 32px height).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rationale + rejected alternatives (no user gate this sprint)
|
||||||
|
|
||||||
|
**Chosen: the four-section bottom dock grown from JamGuide.** Reasons:
|
||||||
|
1. **Minimum migration risk** — JamGuide already owns the dock chrome, the KB registry import, the live-match plumbing, and passed Critic's audio-contract gates repeatedly. Wrapping its body in a section nav is additive; the flagship Roadmap is never rebuilt.
|
||||||
|
2. **Mid-jam continuity** — knowledge is reachable while the detection UI stays on screen; the collapsed bar keeps broadcasting the live match even when closed.
|
||||||
|
3. **One home for the level filter** — shared state across Explore and Licks with zero duplication.
|
||||||
|
4. **App.jsx stays mount-only** — one additive prop (`onChordClick`, §7); no structural change near the audio callbacks.
|
||||||
|
|
||||||
|
**Rejected A — full-screen "Knowledge" mode/route** (top-level toggle Jam ⇄ Knowledge). More room per section, but it hides the detection UI while browsing (breaks the "look at it mid-jam" core promise), needs App.jsx restructuring far beyond mount-only, and adds a navigation concept the app doesn't have. Rejected on risk *and* UX grounds.
|
||||||
|
|
||||||
|
**Rejected B — keep three separate collapsible panels, restyled and cross-linked.** Cheapest to build, but it *is* the problem the user named: three headers competing in the scroll, three places for the level filter, no shared state, and the orphaned panels would be resurrected as-is (including EducationPanel's overlap with CurrentJamPanel). Rejected as fragmentation with a fresh coat of paint.
|
||||||
|
|
||||||
|
**Rejected C — right-side drawer.** Nice on ultrawide, but the Electron window is frequently narrow; a drawer either crushes the fretboard or overlays it (losing the glance-both-at-once value), and it duplicates the dock pattern the Roadmap already established. Rejected for viewport economics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Migration order for L-22 (app green between every commit)
|
||||||
|
|
||||||
|
Precondition: **L-11** (piano in JamGuide) and ideally **L-21** hold locks on `JamGuide.jsx` — Maestro sequences L-22 after those merge. Order within L-22:
|
||||||
|
|
||||||
|
1. **Commit 1 — refactor `ExplorePanel.jsx` into parts.** Split into named exports: `ChordPickerToolbar`, `ExploreSection` (progression browser + famous progressions), `VoicingsSection` (picker + grids), `LevelChips`. Keep the default export as a thin composition of the parts. **Zero user-visible risk — the file is unmounted today.** Build + smoke green trivially.
|
||||||
|
2. **Commit 2 — Knowledge Center shell in `JamGuide.jsx` + App prop.** Add the section nav; move the existing Roadmap body (verbatim) into section 1; mount `ExploreSection` and `VoicingsSection` (with the interim static grids) from ExplorePanel.jsx; `App.jsx` gains exactly one additive prop on the existing JamGuide mount: `onChordClick={setSelectedChord}` (mount-only, nowhere near the audio callbacks/refs). App renders identically until the dock is opened; the Roadmap section is a code *move*, not a change.
|
||||||
|
3. **Commit 3 — level filter live.** `LevelChips` wired to the shared `levels` state; KB progression browser reads `progression.level` (P-20 must be merged); level badges on cards; licksless filter scope documented in the empty states.
|
||||||
|
4. **Commit 4 — Licks & Techniques section.** Mount the LickCard grid (needs D-22 component + P-21 data merged); style chips reuse the registry list section 1 already derives; shared level chips apply; empty state for lick-less styles.
|
||||||
|
5. **Commit 5 (in-sprint upgrade, may fold into L-21's landing) — Voicings section swaps static grids for `VoicingBrowser`** once D-21 merges: a one-line component swap inside `VoicingsSection`, with the static grids kept as the fallback when `chordAudio` is unavailable.
|
||||||
|
|
||||||
|
Every commit: `npm run build` + `node scripts/smoke.mjs` green; at no point does the app lose the Roadmap, the modal, or any mounted panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Tokens, contrast, keyboard
|
||||||
|
|
||||||
|
**Tokens only** — `bg-surface` #0f0f0f, `bg-panel` #1a1a1a, `border-border` #2a2a2a, `accent` #a855f7, `amber` #f59e0b (all already in `tailwind.config.js`). **No new colour is required** for the Knowledge Center; level badges reuse amber, active states reuse the accent tints already shipped in JamGuide.
|
||||||
|
|
||||||
|
**Measured contrast commitments** (computed, not assumed):
|
||||||
|
- `accent` #a855f7 on `panel` #1a1a1a ≈ **4.4:1** — passes AA for large/bold text and UI components, *borderline for small normal text*. Rule: small accent text sits on `bg-surface` cards (**≈4.8:1**, passes) or is ≥ semibold at ≥14px; body copy is never accent.
|
||||||
|
- `gray-400` #9ca3af on panel ≈ **6.9:1** — the floor for any *essential* label.
|
||||||
|
- `gray-500` #6b7280 on panel ≈ **3.6:1** — decoration/microcopy only, never load-bearing text (existing app convention, now explicit).
|
||||||
|
- `amber` #f59e0b on panel ≈ **8.1:1** — level badges and secondary-tone markers are comfortably AA.
|
||||||
|
|
||||||
|
**Keyboard:** every section pill, style chip, level chip, voicing variant, ▶ play button, and LickCard is a real `<button>` in DOM order with `focus-visible:ring-2 ring-accent` (the shipped pattern). Section nav uses `aria-pressed`/`aria-current`; level chips `aria-pressed`; the live dot on the Jam Guide pill is decorative (`aria-hidden`) with the liveness conveyed in the pill's `aria-label`. ▶ buttons get descriptive labels ("Play C maj7 — shell voicing"). The dock's expand/collapse button keeps `aria-expanded`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. What stays untouched (explicitly out of scope)
|
||||||
|
|
||||||
|
- **The audio/detection pipeline** — `AudioCapture.jsx`, `handleNote`/`handleChroma`/`handleOnset`, all refs and `useCallback` contracts in `App.jsx`. The only App.jsx change in this whole plan is one additive prop on an existing mount (§7 commit 2). `chordAudio.js` is a separate, lazily-created context (L-20's contract) and never touches the detection contexts.
|
||||||
|
- **Detection UI** — key chip, `ProgressionBanner`, `ProgressionSuggestions`, main `Fretboard`/`Piano`/`BassFretboard`, the D-03 guide-tone cross-link.
|
||||||
|
- **Tuner, Loop Station, DebugView, DrumView** — not part of the Knowledge Center; their mounts and order in App.jsx are unchanged.
|
||||||
|
- **`CurrentJamPanel`** — remains mounted as-is despite overlapping Explore (§10).
|
||||||
|
- **`ChordDetailModal` shell** and its Theory/Learn/Progressions/Explore tabs — L-21 touches only the Guitar/Piano voicing grids.
|
||||||
|
- **Libraries** — `theory.js`, `education.js`, `voicings.js`, `match.js`, `piano.js`: read-only from this design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Open questions for Maestro
|
||||||
|
|
||||||
|
1. **`FAMOUS_PROGRESSIONS` level tags** — `education.js` is unlocked and untagged; famous-progression cards are exempt from the level filter (§4). File a small Professor task to tag it, or accept the split scope?
|
||||||
|
2. **CurrentJamPanel convergence** — its "Similar Progressions"/"Style Options" views largely duplicate Explore. Recommend a follow-up task (post-sprint) to fold its unique bits (per-chord open-voicing strips, scale diagrams) into the Knowledge Center and retire the panel — reduces the main scroll by one header.
|
||||||
|
3. **EducationPanel.jsx deletion** — confirmed orphaned; retire-in-place this sprint, file a cleanup deletion (Luthier, trivial) later so dead code doesn't confuse future agents.
|
||||||
|
4. **L-21/L-22 sequencing on `JamGuide.jsx`** — both edit it; this doc assumes L-11 → L-21 → L-22 (or L-22 commits 1–4 then the §7 commit-5 swap folded into L-21). Maestro to serialize the lock.
|
||||||
|
5. **Naming check with the user (async, non-blocking):** collapsed bar renamed "Knowledge Center — 〈match〉"; "Jam Guide" lives on as section 1. Cheap to revert if the user prefers the old bar title.
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
# One Screen — the jam dashboard (task D-50)
|
||||||
|
|
||||||
|
> **Thesis:** the page stops being a tall stack of full-width bands and becomes a
|
||||||
|
> **viewport-fitting jam dashboard**: a full-width *slim loop strip* (the banner
|
||||||
|
> minus its big now-playing chord), then LEFT (~744px) the compact instrument
|
||||||
|
> view, the licks strip, and a new loop-relative *Related progressions* card;
|
||||||
|
> RIGHT (~500px) the suggested-voicings rail — the design's ONE justified
|
||||||
|
> internal scroller (§4). The dashboard is a **self-contained unit designed to
|
||||||
|
> fit 100vh at 1280×900**; a **JAM VIEW toggle** in the controls bar (§1.1)
|
||||||
|
> locks the page to exactly that unit (and goes browser-fullscreen,
|
||||||
|
> best-effort) while playing. In normal mode everything that is learning or
|
||||||
|
> behind-the-scenes (LoopStation, Debug, Drum, Tuner, KnowledgeDock) stays
|
||||||
|
> below the dashboard on the ordinary page scroll; in jam view it is hidden.
|
||||||
|
>
|
||||||
|
> User directive (2026-07-11, verbatim): *"the chord view and the chord loop
|
||||||
|
> incl the instrument below is too big, it takes up too much space, i want you
|
||||||
|
> to reconsider the positioning as this main view doesnt really add value:
|
||||||
|
> seeing the big chord 'now playing' in big is also unnecessary as you already
|
||||||
|
> see that one in the chords (loop) part. the suggested progressions on the
|
||||||
|
> right are also almost useless. they are nice but we need to rethink them in
|
||||||
|
> the form of SUGGESTED VOICINGS and the progressions we add relating to the
|
||||||
|
> loop. i'd like to have the suggested voicings on the right side of the screen
|
||||||
|
> (the jam guide). … i wanna see the chords, the loop, the voicings, the
|
||||||
|
> progressions, the licks all on one screen, everything you need to follow the
|
||||||
|
> jam correctly"*
|
||||||
|
>
|
||||||
|
> Refinement (2026-07-11, verbatim): *"it can still have a scroll down, but the
|
||||||
|
> main concept needs to be in one screen: potentially we add a fullscreen
|
||||||
|
> button so we can just show that when we are playing a jam. so that it fits
|
||||||
|
> the entire screen, everything that is learning and behind the scenes etc you
|
||||||
|
> can keep below/scrollable but we need one main view for live jams where we
|
||||||
|
> see everything at once (relating to the jam and the voicings and the licks
|
||||||
|
> and the chords and the loop)"* — the 100vh fit is a **hard design
|
||||||
|
> constraint** for the dashboard, not an aspiration; the fullscreen button is
|
||||||
|
> in scope (L-50 commit 3, §6.1); internal wrap/scroll inside a panel is a
|
||||||
|
> last resort and every instance below is justified.
|
||||||
|
|
||||||
|
**Supersedes** `integrated-glance.md` §1's full-width-band placement (the user
|
||||||
|
tested it and asked for the right column). That doc's **verified geometry stays
|
||||||
|
the arithmetic source here**: guitar cell ~93px, piano cells 160/284px, worst
|
||||||
|
piano gallery ~1,178px of cells, licks thumb card ~165px (in a 220px strip
|
||||||
|
slot), cell line heights ~140px guitar / ~125–130px piano. Standing principles
|
||||||
|
(user memory): scroll > click; nothing shown twice; ONE global instrument
|
||||||
|
selector; the playhead highlights, never hides.
|
||||||
|
|
||||||
|
**Decision authority:** per the sprint header (no user gate), Muse picks the
|
||||||
|
strongest option and records rejected alternatives (§9).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. What exists today (read from the code)
|
||||||
|
|
||||||
|
App.jsx mount order: `ProgressionBanner` (~608) → instrument row (~617–629:
|
||||||
|
`Fretboard`/`BassFretboard`/`Piano` at 70% + `ProgressionSuggestions` at 30%) →
|
||||||
|
`JamGuide` full-width band (~635: GlanceRail all-expanded rows + LicksStrip) →
|
||||||
|
`LoopStation` → Debug/Drum/Tuner collapsibles → `KnowledgeDock` last. The band
|
||||||
|
starts ≈570px down at 1280×900 — voicings never share the screen with the
|
||||||
|
instrument view, and the licks strip sits another rail-height below that. The
|
||||||
|
banner burns its right 30% on a text-6xl "Now Playing" chord that duplicates
|
||||||
|
both the enlarged last history chip and the highlighted loop chip. The
|
||||||
|
directive kills all of that.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The grid
|
||||||
|
|
||||||
|
Assumed usable content box at a 1280×900 window: **~1,256 × ~836px** (Electron
|
||||||
|
title bar ~32px eats into the 900; App's `p-3` eats 24px each axis — heights
|
||||||
|
below are estimates, cell geometry is verified).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐ ─┐
|
||||||
|
│ header (~52) │ │
|
||||||
|
│ controls bar — instrument · key lock · ⛶ JAM VIEW (~52) │ │
|
||||||
|
│ SLIM LOOP STRIP — key · history · ♻ loop chips (~76) │ │ the
|
||||||
|
├───────────────────────────────────────┬──────────────────────┤ │ dashboard
|
||||||
|
│ LEFT ~744px (flex-1) │ RIGHT 500px, │ │ = 100vh
|
||||||
|
│ │ own scroll (§4) │ │ in jam
|
||||||
|
│ compact instrument view (~240) │ │ │ view
|
||||||
|
│ licks strip (~190) │ SUGGESTED VOICINGS │ │
|
||||||
|
│ related progressions (~200) │ rail — GlanceRail / │ │
|
||||||
|
│ │ BassGuideRows rows │ │
|
||||||
|
├───────────────────────────────────────┴──────────────────────┤ ─┘
|
||||||
|
│ LoopStation · Behind the Scenes · Rhythm · Tuner · Knowledge │ normal mode
|
||||||
|
│ (the learning / behind-the-scenes area — page scroll) │ only; hidden
|
||||||
|
└──────────────────────────────────────────────────────────────┘ in JAM VIEW
|
||||||
|
```
|
||||||
|
|
||||||
|
- The **slim strip stays full width** (not inside the left column): it is the
|
||||||
|
status line for the whole dashboard — the rail's playhead and the strip's
|
||||||
|
active loop chip are the same "now", and a full-width one-liner costs only
|
||||||
|
~76px. (Putting it inside the left column would buy the rail ~88px of
|
||||||
|
first-paint height; the rail is internally scrollable anyway, and a
|
||||||
|
~450px-content strip floating in a 744px column looks broken. Not worth it.)
|
||||||
|
- **Grid:** `flex gap-3 items-start` → left `flex-1 min-w-0`, right
|
||||||
|
`w-[500px] shrink-0`, active at `xl:` (≥1280); below that the columns stack
|
||||||
|
(§7). 500px is the top of the licensed 420–520 range because piano cells
|
||||||
|
need it (§4): at 500 the row interior is ~456px, which fits a 284px
|
||||||
|
two-octave cell **plus** a 160px one-octave cell per line (by ~4px — D-51
|
||||||
|
hardens the margin, §4); at 420 every two-octave cell rides alone.
|
||||||
|
- **The rail is height-bounded: `xl:max-h-[calc(100vh-1.5rem)]
|
||||||
|
xl:overflow-y-auto` — and NOT sticky.** (`sticky top-3` here would be inert:
|
||||||
|
inside the flex row the rail is the tallest item, so its containing block
|
||||||
|
equals its own height and there is zero travel to pin against — specifying
|
||||||
|
it would be a lie in class form.) What the bound actually buys, honestly:
|
||||||
|
the rail is the one unbounded surface (§4 math — a jazz piano loop is
|
||||||
|
~3,000px of rows). **(a)** Unbounded, it stretches the *document* to
|
||||||
|
~3,000px+, pushing the below-grid region (LoopStation, the whole learning
|
||||||
|
area) thousands of pixels down instead of one flick below the dashboard.
|
||||||
|
**(b)** The bound is exactly what jam view swaps to `h-full` — an unbounded
|
||||||
|
rail can never fit `h-screen` (§1.1). The rail's depth scrolls *within* its
|
||||||
|
column; the left column stays whole; at rest the rail shows ~644px (the
|
||||||
|
grid height). D-40 Rejected E (internal scroller) was rejected for the
|
||||||
|
*full-width band*, where it capped visible rows below 4 and nested a
|
||||||
|
scrollbar inside the page's only axis; in a two-column dashboard, bounding
|
||||||
|
the one unbounded column is what makes the dashboard a fixed-height unit —
|
||||||
|
the rejection does not carry over. **No auto-scroll in this sprint** (§4).
|
||||||
|
|
||||||
|
Vertical budget, normal mode at rest: 836 − 52 (header) − 52 (controls) − 88
|
||||||
|
(strip + gap) ≈ **644px for the grid**. Left stack ≈ 240 + 190 + ~200 + 24
|
||||||
|
gaps ≈ **654px** — everything on screen except the last ~10–20px of the
|
||||||
|
related progressions card at exactly 900px window height; a micro-flick shows
|
||||||
|
it. In normal mode that residue is acceptable (the page scrolls anyway); **in
|
||||||
|
jam view it is not** — §1.1 makes the fit exact by construction, with the
|
||||||
|
related card as the left column's flex absorber.
|
||||||
|
|
||||||
|
### 1.1 Jam view — the one-screen lock + fullscreen toggle
|
||||||
|
|
||||||
|
The refinement's "fullscreen button". **Mechanism chosen: one button, two
|
||||||
|
layers on one state.**
|
||||||
|
|
||||||
|
- **Layer 1 — CSS jam view (the guarantee).** App gains a `jamView` boolean
|
||||||
|
(pure UI-state — nowhere near the audio callbacks/refs). When true:
|
||||||
|
- the page root becomes `h-screen overflow-hidden flex flex-col` (normal
|
||||||
|
mode: today's `min-h-screen` flow);
|
||||||
|
- **everything below the dashboard is not rendered** — LoopStation, Behind
|
||||||
|
the Scenes, Rhythm Analyser, Tuner, KnowledgeDock (conditional mount, not
|
||||||
|
`hidden`, so collapsed-state chrome can't leak height). Header, controls
|
||||||
|
bar, strip, grid remain — Stop/Start, the instrument selector, and the key
|
||||||
|
lock are jam-relevant chrome;
|
||||||
|
- the grid gets `flex-1 min-h-0`; the rail wrapper swaps its normal-mode
|
||||||
|
`xl:max-h-[calc(100vh-1.5rem)]` bound for `h-full overflow-y-auto` (the
|
||||||
|
column IS the viewport remainder, so the viewport-calc bound gives way to
|
||||||
|
the exact column height);
|
||||||
|
- the left column becomes `flex flex-col min-h-0`: instrument view and
|
||||||
|
licks strip fixed-height, the related progressions card `flex-1 min-h-0
|
||||||
|
overflow-y-auto` — the **one flex absorber** that soaks up the ±20px
|
||||||
|
between window sizes. Internal scroll here is the licensed last resort
|
||||||
|
and only engages below ~950px-tall windowed viewports (numbers below).
|
||||||
|
- **Layer 2 — browser fullscreen (the enhancement, best-effort).** The same
|
||||||
|
toggle calls `document.documentElement.requestFullscreen()` on enter and
|
||||||
|
`document.exitFullscreen()` on exit. Both return **Promises** — a bare
|
||||||
|
try/catch does not swallow the rejection; the calls must be
|
||||||
|
`.catch(() => {})` (or awaited inside try/catch). Electron grants it (plain
|
||||||
|
renderer, no IPC needed); if a browser build ever refuses, jam view still
|
||||||
|
works at layer 1. Fullscreen reclaims the ~32px
|
||||||
|
title bar (and any taskbar), which is exactly what turns "fits minus 20px"
|
||||||
|
into "fits clean" at 900-high screens.
|
||||||
|
- **The button** lives at the right end of the controls bar (the global,
|
||||||
|
always-visible chrome — same reasoning as the instrument selector):
|
||||||
|
`⛶ Jam view` / `✕ Exit` , `aria-pressed`, min-32px target,
|
||||||
|
`focus-visible:ring-2 ring-accent`, tokens only.
|
||||||
|
- **Exit paths, all restoring normal flow:** the button; **Escape** (a
|
||||||
|
`keydown` listener active only while `jamView` — needed because layer 1 can
|
||||||
|
exist without layer 2); and the native fullscreen Esc, synced via a
|
||||||
|
`fullscreenchange` listener (leaving fullscreen by any means switches
|
||||||
|
`jamView` off — one state, never half-exited). Entering does NOT auto-start
|
||||||
|
listening; leaving does NOT stop it — the toggle is layout-only.
|
||||||
|
|
||||||
|
**Jam-view budget (the hard numbers).** Grid height = viewport − p-3 (24) −
|
||||||
|
header 52 − controls 52 − strip + gap 88:
|
||||||
|
|
||||||
|
| Viewport | Grid (= rail height) | Left: fixed 454 (instrument 240 + licks 190 + gaps) → related card gets | Verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1280×900 window (layer 1 only, content ~836) | **644px** | ~190px → heading + ~2.8 of 3 entries | fits; the card's last entry may shave ~20px — its internal scroll absorbs it |
|
||||||
|
| 1280×900 screen, true fullscreen (content ~876) | **684px** | ~230px → 3 entries fully clear, ~20px spare | **fits clean — the ~20px story is gone** |
|
||||||
|
| 1280×960+ screen, true fullscreen (content ≥936) | **≥744px** | ≥290px → up to 5 entries | fits with room |
|
||||||
|
|
||||||
|
The rail's *depth* still exceeds any of these for piano loops (§4 table — a
|
||||||
|
4-chord jazz loop is ~2,600–3,000px of rows). That is the one place "see
|
||||||
|
everything at once" and "every voicing, all levels, zero clicks" (D-30)
|
||||||
|
mathematically collide; the cell strategy compresses first (recommended cell
|
||||||
|
leads; the 284+160 pair-fit halves mixed-station heights, §4) and the
|
||||||
|
remainder falls to the rail's contained scroll — the justified internal
|
||||||
|
scroller. All four *bounded* surfaces (strip, instrument, licks, progressions)
|
||||||
|
fit whole; guitar and bass rails fit ~2.5–3 of 4 rows with the "now" row
|
||||||
|
highlighted wherever it sits.
|
||||||
|
|
||||||
|
### 1.2 The slim loop strip — banner inventory
|
||||||
|
|
||||||
|
What `ProgressionBanner` renders today, and its disposition:
|
||||||
|
|
||||||
|
| Banner element today | Disposition |
|
||||||
|
|---|---|
|
||||||
|
| Key chip (root text-2xl accent + mode + confidence %) | **Survives** — leftmost, unchanged |
|
||||||
|
| Chord history (last 8, age-faded, tap → ChordDetailModal; current chip enlarged text-3xl + amber rn + pop-in animation) | **Survives, capped to last 5** — the enlarged current chip IS the now-playing display now; the pop-in animation moves with it |
|
||||||
|
| Loop row (♻ + chips, chord + rn, active chip accent-glow via `findLoopPosition`, "→ loop" tail) | **Survives, promoted onto the same row** as key + history (divider between) |
|
||||||
|
| `hidden lg:block` divider + right 30% column: "NOW PLAYING" label, **text-6xl amber chord**, rn, "tap for voicings" | **GONE.** The user's words: the loop chips already carry it. The tap-for-voicings affordance is not lost — every chip already opens ChordDetailModal, and the rail shows the loop's voicings permanently |
|
||||||
|
| Empty states ("Detecting key…", "Start listening…", "Play a chord") | First two survive; "Play a chord" dies with its column |
|
||||||
|
|
||||||
|
One row: key chip (~90px) + 5 history chips (~230px) + divider + up to 8 loop
|
||||||
|
chips (~320px) + tail ≈ ~720px — fits 1,256 with room; 12-chord loops (12-bar
|
||||||
|
blues) wrap the loop chips to a second line (strip ~104px, still fine).
|
||||||
|
**Strip chrome trim (part of the slimming):** `p-4 → p-2` and drop the history
|
||||||
|
wrapper's `pb-1` — the enlarged current chip (text-3xl + rn + chip padding) is
|
||||||
|
~58px tall on its own, so with p-4 the strip lands at ~92–96px; the trim is
|
||||||
|
what makes the **~76px** figure true. Goes in L-50 commit 1 with the column
|
||||||
|
removal (§6.1).
|
||||||
|
Duplication audit: the current chord appears twice (last history chip + active
|
||||||
|
loop chip) — pre-existing, and the user's directive explicitly endorses the
|
||||||
|
loop chip as the "now"; the big third copy is what dies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Compact instrument view — the mechanism
|
||||||
|
|
||||||
|
**Chosen: a `compact` prop on Fretboard / Piano / BassFretboard = trimmed card
|
||||||
|
chrome + a natural-width cap on the SVG. No fret reduction, no transform
|
||||||
|
scaling.**
|
||||||
|
|
||||||
|
- **Natural-width cap:** each SVG keeps `width:100%` but gains
|
||||||
|
`max-width: <its viewBox width>px` (Fretboard 674px, Piano 562px), i.e. it
|
||||||
|
never renders *above* scale 1.0. In the 744px left column (≈718px card
|
||||||
|
interior with the compact `p-3`) the fretboard sits at its designed 1.0 scale → SVG height
|
||||||
|
**186px** (today at 70% of 1,256 it renders at ~1.23 scale ≈ 229px + fat
|
||||||
|
chrome ≈ 320px card). Piano: 150px SVG.
|
||||||
|
- **Trimmed chrome:** `p-6 → p-3`, heading `mb-4 → mb-2`, and the legend row
|
||||||
|
(chord tone / pentatonic / scale dots) merges onto the heading line
|
||||||
|
(right-aligned) instead of a separate `mt-3` row. Card totals: fretboard
|
||||||
|
≈ **240px** (was ~320), piano ≈ **205px**.
|
||||||
|
- **Legibility, honestly:** at scale 1.0 the fretboard is at its *designed*
|
||||||
|
size — 20px note dots, 9px note labels, 10px string/fret labels, exactly
|
||||||
|
what ChordDiagram-era eyes were tuned on; nothing shrinks below today's
|
||||||
|
rendering (today it renders *larger* than designed, which is part of "too
|
||||||
|
big"). The compact card is smaller because the chrome and the over-scale go,
|
||||||
|
not because the notes do. Below ~700px column width the SVG scales down with
|
||||||
|
the column; at 0.9 scale (≈606px) labels are ~8px — the floor I'd defend.
|
||||||
|
The narrow plan (§7) gives the view the full window width anyway, so the
|
||||||
|
sub-1.0 zone only exists between ~1280 and ~1140px windows if the grid
|
||||||
|
breakpoint were lower — hence `xl:` for the grid, not `lg:`.
|
||||||
|
- Rejected mechanisms: **fewer frets** (0–7) kills position playing above the
|
||||||
|
7th fret — soloists live there; **CSS `transform: scale()`** blurs text and
|
||||||
|
shrinks hit targets — dishonest compactness; **hard height cap with
|
||||||
|
letterboxing** wastes the saved space as empty gutters.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. What's removed / moved (full inventory)
|
||||||
|
|
||||||
|
| Surface | Disposition |
|
||||||
|
|---|---|
|
||||||
|
| Big "Now Playing" chord (banner right 30% + divider) | **Removed** (§1.2) |
|
||||||
|
| `ProgressionSuggestions` | **Unmounted; file kept in place** (EducationPanel/RoadmapTrack precedent — joins the retire-then-delete backlog item). Its job splits in two per the directive: "what to play on these chords" → the voicings rail; "where these changes can go" → RelatedProgressions (§5). Its generic genre/mood table and circle-of-fifths padding retire with it |
|
||||||
|
| The 70/30 instrument row | **Dissolved** — the instrument view becomes the left column's first block; the 30% slot's replacement is the rail column |
|
||||||
|
| JamGuide band (rail + licks, full width) | **Recomposed** — JamGuide stops rendering a band and renders the grid's jam surfaces: rail into the right column, LicksStrip + a related-progressions slot into the left (§6, component boundary) |
|
||||||
|
| LicksStrip | **Stays module-local in JamGuide.jsx** — no file move. It renders from JamGuide's match/context memos (`activeStyle`, `contextStation`); extracting it to its own file buys nothing and forces prop plumbing for the token-boundary context sort. It just mounts in the left column now |
|
||||||
|
| LoopStation | **Below the grid, first — and hidden in jam view.** Recorded call: the user's enumeration of the live-jam view is "the jam and the voicings and the licks and the chords and the loop", where "the loop" is the *chord* loop (the strip's ♻ chips), not the audio looper. LoopStation is a performance tool, but it is not on the list — it goes below with the learning surfaces, first in line because it feeds the detected loop. If the user misses it mid-jam, admitting it into jam view is a one-line conditional — the reversal path is cheap and noted in §9 |
|
||||||
|
| Behind the Scenes / Rhythm Analyser / Tuner collapsibles | Below LoopStation, unchanged |
|
||||||
|
| KnowledgeDock | **Last, unchanged** — browse & study stays below the fold |
|
||||||
|
| ChordDetailModal, AudioCapture, all `src/lib/**` audio/theory | Zero changes |
|
||||||
|
|
||||||
|
In **normal mode** everything below the grid remains exactly what the
|
||||||
|
refinement asks for — the learning / behind-the-scenes area on the ordinary
|
||||||
|
page scroll ("everything that is learning and behind the scenes etc you can
|
||||||
|
keep below/scrollable"). In **jam view** this whole region is hidden (§1.1) —
|
||||||
|
the dashboard is the entire page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The rail at column width (~500px shell, ~456px row interior)
|
||||||
|
|
||||||
|
Row interior arithmetic: 500 − rail section `p-3` + border (~26) − row `p-2` +
|
||||||
|
border (~18) ≈ **456px** for header + cells. The inherited 93/160/284 figures
|
||||||
|
are already **box values** — integrated-glance's own verified sums prove it
|
||||||
|
(4×284 + gaps + chrome ≈ 1,178 with no extra per-cell padding), so nothing
|
||||||
|
gets padding added on top below. Cell strategy: **recommended-first, gallery
|
||||||
|
wrapped below — all cells always rendered, zero clicks** (D-30's "all at once"
|
||||||
|
directive stands; only the *geometry* adapts).
|
||||||
|
|
||||||
|
- **The recommended cell leads and reads as the answer.** The station's own
|
||||||
|
voicing (guitar: KB play shape badged "play"; piano: the authored/threaded
|
||||||
|
voicing with its honest label) keeps its accent-bordered figure and is
|
||||||
|
always the first cell — at column width it is frequently alone on line one,
|
||||||
|
which makes prominence free. D-51 may add nothing more than a slightly
|
||||||
|
stronger figure treatment (accent/60 border → accent) — no size difference,
|
||||||
|
no new colour.
|
||||||
|
- **Guitar:** cells ~93px (box) → **4 per line** (4×93 + 3×8 gaps = 396 ≤ 456
|
||||||
|
— fits with ~60px to spare, not borderline; a 5th cell at 497 does not).
|
||||||
|
Typical station = play cell + 3–6 placeable shapes = 4–7 cells → 1–2 cell
|
||||||
|
lines (~140px each). Header content (chord + rn + now/next + solo label +
|
||||||
|
aim dots + transition chip ≈ 540px of inline content) **wraps to 2 lines
|
||||||
|
≈ 50px** — fine, it already `flex-wrap`s. Row ≈ **206–350px**.
|
||||||
|
- **Piano:** one-octave cells ~160px (box) → 2 per line (328 ≤ 456);
|
||||||
|
two-octave cells ~284px (box) → alone or, critically, **paired with a
|
||||||
|
one-octave cell: 284 + 160 + 8 = 452 ≤ 456 — the mixed pair FITS today, by
|
||||||
|
~4px**. That 4px is real but fragile (a longer label, a scrollbar gutter, a
|
||||||
|
browser rounding step could break it), so D-51's cell padding/gap shave is
|
||||||
|
**margin-hardening for an already-passing fit**, not a rescue — the pairing
|
||||||
|
is the difference between 5 and 3 cell lines on mixed stations. Worst
|
||||||
|
station (root above D with a true 7th — all five cells two-octave, per the
|
||||||
|
D-40 corrected math; 2×284 + 8 = 576 > 456 so they never pair with each
|
||||||
|
other): **5 cell lines ≈ 650 + header ≈ ~700px per row**. Best (roots C–D /
|
||||||
|
triads): 3 lines ≈ ~490px.
|
||||||
|
- **Bass:** BassGuideRows text rows (~70px) or BassPatternCard rows — narrow
|
||||||
|
by construction; the column fits them with no adaptation beyond wrap, which
|
||||||
|
it inherits.
|
||||||
|
- **Wrap, never horizontal scroll** — unchanged invariant. The only permitted
|
||||||
|
horizontal scroller stays VoicingBrowser's per-cell guard around MiniPiano
|
||||||
|
thumbs on sub-320px pathologies.
|
||||||
|
|
||||||
|
### The honest vertical budget
|
||||||
|
|
||||||
|
| Scenario | Full rail height | Visible: normal rest (~644px) / jam view fullscreen (~684px) |
|
||||||
|
|---|---|---|
|
||||||
|
| 4-chord loop, guitar | ≈ 4×~275 avg + chrome ≈ **~1,150px** | ~2.3 / ~2.5 rows |
|
||||||
|
| 4-chord loop, piano (jazz flagship, mostly worst-case rows) | ≈ **~2,600–3,000px** | ~1 / ~1.1 rows |
|
||||||
|
| 8–12 chord loop (12-bar) | guitar ~2,300 / piano ~5,000+ | scroll; the strip's loop chips always carry the position |
|
||||||
|
|
||||||
|
**What "one screen" honestly means:** all five surfaces are simultaneously
|
||||||
|
present at 1280×900 (whole, in jam view — §1.1) — but the rail's *depth* is a
|
||||||
|
scroll, contained in its column: it is the one panel whose content is
|
||||||
|
unbounded by construction (every voicing of every loop chord, D-30's "all at
|
||||||
|
once"), so it is the design's justified last-resort internal scroller; the
|
||||||
|
cell strategy above compresses first. A 4-chord guitar loop is ~1.4
|
||||||
|
rail-screenfuls; a 4-chord jazz piano loop is ~3.5. The playhead highlight
|
||||||
|
travels whether or not the row is in the scroller's window; the strip up top
|
||||||
|
always shows where you are. **No
|
||||||
|
auto-scroll in D-51** — same reasoning as D-40 §4, plus: contained auto-follow
|
||||||
|
is now *technically safe* (the scroller is no longer the document, so it can't
|
||||||
|
yank the page), so I flag it to Maestro as an optional 1-point follow-up
|
||||||
|
**after** the user has felt the manual version — auto-motion during a jam is
|
||||||
|
exactly the "it changes while I play" complaint in a new hat, so it must be
|
||||||
|
user-pulled, not designer-pushed.
|
||||||
|
|
||||||
|
Flag (not in scope): a **root-aligned MiniPiano crop** would turn most
|
||||||
|
two-octave piano cells into one-octave ones (~160px), roughly halving piano
|
||||||
|
rail heights. It reopens D-24's C-aligned-crop decision and MiniPiano is
|
||||||
|
frozen this sprint — Maestro may file it as a D-52 candidate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Related progressions — the spec for L-51
|
||||||
|
|
||||||
|
**What it is:** 3–5 KB progressions genuinely related to the *detected loop*,
|
||||||
|
replacing ProgressionSuggestions' generic genre table. KB-sourced only
|
||||||
|
(`kb[style].progressions` — id, name, rn, degrees, qualities, level, songs),
|
||||||
|
loop-relative by construction.
|
||||||
|
|
||||||
|
### Ranking (computable today, no new theory)
|
||||||
|
|
||||||
|
Inputs: `detectedProgression` (chord-name strings), the KB registry, and
|
||||||
|
`matchLoopToProgression`'s result. Reuse `src/lib/match.js` machinery —
|
||||||
|
`loopToDegrees` and `canonicalDegrees` are currently module-private and must
|
||||||
|
become **additive exports** (L-51 lock addition; no behaviour change), next to
|
||||||
|
the already-exported `matchLoopToProgression`/`buildLoopIndex`.
|
||||||
|
|
||||||
|
1. `loopDeg = loopToDegrees(loop)`; bail to empty state if null.
|
||||||
|
`loopCanon = canonicalDegrees(loopDeg)`. `match = matchLoopToProgression(…)`
|
||||||
|
(the component computes its own — see §6's parallelism note).
|
||||||
|
2. Build the loop's **transition set** `T(loop)`: for each i (wrap-around),
|
||||||
|
the triple `(Δ = (deg[i+1]−deg[i]) mod 12, q[i], q[i+1])` with qualities
|
||||||
|
from the chord-name suffixes (unmappable suffix → wildcard: match on Δ
|
||||||
|
alone). Same for every KB progression from its `degrees`/`qualities`.
|
||||||
|
|
||||||
|
**Collapse first — mandatory.** The live `detectedProgression` is a
|
||||||
|
*collapsed* form (detection never commits the same chord twice in a row),
|
||||||
|
while KB `degrees` are *raw*, bar-per-bar — blues-12bar is
|
||||||
|
`[0,0,0,0,5,5,0,0,7,5,0,7]`. Compared raw, the +100 same-canonical-shape
|
||||||
|
term would NEVER fire for the ~10 collapse-affected KB progressions —
|
||||||
|
silently killing the flagship "this 12-bar IS their 12-bar" relation (it
|
||||||
|
degrades, never misfires, which is why it would go unnoticed). So: before
|
||||||
|
`canonicalDegrees(p.degrees)` AND before building `T(p)`, **collapse
|
||||||
|
consecutive equal `(degree, quality)` pairs** in `p` (also with the
|
||||||
|
wrap-around pair). This additionally removes the harmless-but-deflating
|
||||||
|
`Δ=0` self-transitions from `T(p)`. Alternative once D-62's fix (a) lands
|
||||||
|
collapsed-form indexing in match.js: consume that shared collapsed form
|
||||||
|
instead of collapsing locally — coordinate via the §9 match.js
|
||||||
|
shared-file note.
|
||||||
|
3. Score every KB progression `p` with `p.id !== match.id`:
|
||||||
|
- **+100** if `canonicalDegrees(p.degrees) === loopCanon` — the same
|
||||||
|
changes in another style/length ("this turnaround IS jazz's I–vi–ii–V");
|
||||||
|
- **+40** if `p.style === match.style` (0 when nothing matched — the
|
||||||
|
ranking still works loop-relative off the raw degrees);
|
||||||
|
- **+12 per shared transition** (distinct triples in `T(loop) ∩ T(p)`),
|
||||||
|
capped at 36 — this is what finds "shares the ii→V" honestly;
|
||||||
|
- **+ up to 16** for rebased degree-set overlap (Jaccard × 16);
|
||||||
|
- **−1 per chord of length difference** (tie-break toward similar-size loops).
|
||||||
|
4. Floor **24** (at least a shared transition + some overlap, or same-style +
|
||||||
|
substance) — never pad with junk; fewer honest entries beat five stretches.
|
||||||
|
Sort desc (stable by style, id), take **max 5; the dashboard budget
|
||||||
|
guarantees 3 visible** (§1.1) — the card is the left column's flex
|
||||||
|
absorber, so entries 4–5 show on tall viewports and scroll within the card
|
||||||
|
on tight ones.
|
||||||
|
|
||||||
|
### Per-entry render (~56px each)
|
||||||
|
|
||||||
|
- Line 1: **name** (gray-100 semibold) · style label (gray-500) · **level
|
||||||
|
chip** (ExplorePanel's `LevelBadge` language — untagged counts foundation) ·
|
||||||
|
annotation (gray-500, [10px]): `same changes` when the +100 fired, else
|
||||||
|
`shares {rn_a}→{rn_b}` naming p's rn at the top shared transition, else
|
||||||
|
`same style`.
|
||||||
|
- Line 2: the **chord chain realized in the current key** — rootPc =
|
||||||
|
`(keyRoot + degree) mod 12` + `CHORD_TYPES[quality].suffix` (the
|
||||||
|
stationVoicings formula), rendered as tappable chips with rn beneath
|
||||||
|
(banner-chip visual language, smaller); 12-bar chains truncate to the first
|
||||||
|
8 + "…". **Tap a chord → `onChordClick` → ChordDetailModal** — the existing
|
||||||
|
per-chord pattern (ExploreSection does exactly this); no new modal, no dock
|
||||||
|
deep-link API invented for v1.
|
||||||
|
|
||||||
|
### Empty states (honest)
|
||||||
|
|
||||||
|
- No loop detected → one-liner (~40px, dashed border):
|
||||||
|
*"Loop a progression — related changes from the songbook land here."*
|
||||||
|
- Loop but nothing ≥ floor → *"Nothing in the songbook genuinely relates to
|
||||||
|
this loop yet."* (never pad).
|
||||||
|
- Loop unmatched but candidates score (shape/transition terms only) → render
|
||||||
|
normally; the annotation still explains *why* each entry is there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Component boundary + migration order (green at every commit)
|
||||||
|
|
||||||
|
**Boundary call:** `JamGuide` (default export) is promoted from "band" to
|
||||||
|
**jam-grid owner**: it renders the two-column `flex` region and takes two JSX
|
||||||
|
slot props from App — `mainView` (the instrument view — App keeps choosing
|
||||||
|
Fretboard/BassFretboard/Piano; JamGuide never imports them) and `relatedSlot`
|
||||||
|
(RelatedProgressions, mounted by App). Left column = `mainView` + LicksStrip +
|
||||||
|
`relatedSlot`; right column = the height-bounded scroller wrapping GlanceRail /
|
||||||
|
BassGuideRows / the heard-live fallback. Rationale: the match / stationVoicings
|
||||||
|
/ canonicalPos / focus machinery stays in ONE component with no context and no
|
||||||
|
logic lifted into App (the 🚨 App.jsx contract); the slots keep App as pure
|
||||||
|
mounts. **RelatedProgressions computes its own match** (memoized on the loop
|
||||||
|
key — `matchLoopToProgression` over a module-level index is trivially cheap);
|
||||||
|
this small duplicate computation is what makes L-51's files disjoint from
|
||||||
|
D-51's (they can run in parallel) and keeps the component pure/prop-driven:
|
||||||
|
`{ loop, keyInfo, onChordClick }`.
|
||||||
|
|
||||||
|
Sequenced: **L-50 → (D-51 ‖ L-51) → C-50.** Critic gates each.
|
||||||
|
|
||||||
|
### 6.1 L-50 (Luthier) — the dashboard restructure
|
||||||
|
|
||||||
|
**Files (re-lock at promotion):** `src/App.jsx` (layout/mounts only, 🚨 audio
|
||||||
|
contract grep-gated), `src/components/ProgressionBanner.jsx`,
|
||||||
|
`src/components/JamGuide.jsx`, `src/components/Fretboard.jsx`,
|
||||||
|
`src/components/Piano.jsx`, `src/components/BassFretboard.jsx` (compact prop —
|
||||||
|
mechanical chrome trim + max-width cap per §2; D-51 polishes visuals if
|
||||||
|
needed). **Not touched:** ProgressionSuggestions.jsx (unmounted, not edited),
|
||||||
|
GlanceRail.jsx, VoicingBrowser.jsx, all `src/lib/**`.
|
||||||
|
|
||||||
|
1. **Commit 1 — slim strip.** ProgressionBanner: delete the right 30% column +
|
||||||
|
divider + its `currentRN`/big-chord code path; merge key + history + loop
|
||||||
|
onto one row; `HISTORY_SHOWN` 8 → 5; chrome trim `p-4 → p-2` + drop the
|
||||||
|
history wrapper's `pb-1` (what makes the ~76px strip true, §1.2).
|
||||||
|
Standalone green.
|
||||||
|
2. **Commit 2 — the grid (atomic).** These land together or the page has a
|
||||||
|
hole: App replaces the 70/30 row + band with the `xl:` two-column grid;
|
||||||
|
**ProgressionSuggestions unmounted** (import + mount deleted); JamGuide
|
||||||
|
gains `mainView`/`relatedSlot` props and renders left/right columns, rail
|
||||||
|
wrapped in the height-bounded scroller (`xl:max-h-[calc(100vh-1.5rem)]
|
||||||
|
xl:overflow-y-auto` — no sticky, §1); instrument views gain
|
||||||
|
`compact` and App passes it; App passes `relatedSlot={null}` (slot renders
|
||||||
|
nothing until L-51). Green; contract grep clean.
|
||||||
|
3. **Commit 3 — jam view (§1.1).** App: `jamView` UI-state + the controls-bar
|
||||||
|
`⛶ Jam view` toggle button; page root `h-screen overflow-hidden flex
|
||||||
|
flex-col` + grid `flex-1 min-h-0` + left-column flex layout while active;
|
||||||
|
below-dashboard region conditionally unmounted; JamGuide's rail wrapper
|
||||||
|
accepts a `fill` (or `jamView`) prop switching the max-h bound → `h-full
|
||||||
|
overflow-y-auto`; best-effort `requestFullscreen()`/`exitFullscreen()`
|
||||||
|
**with `.catch(() => {})`** (they return Promises — a bare try/catch does
|
||||||
|
not swallow the rejection) + `fullscreenchange` sync + Escape keydown
|
||||||
|
(active only while `jamView`).
|
||||||
|
🚨 Pure layout/UI-state — zero contact with audio callbacks, refs, or
|
||||||
|
AudioCapture props; the toggle never starts/stops listening. Standalone
|
||||||
|
green on top of commit 2.
|
||||||
|
|
||||||
|
### 6.2 D-51 (Muse) — rail at column width
|
||||||
|
|
||||||
|
**Files:** `src/components/GlanceRail.jsx`, `src/components/VoicingBrowser.jsx`
|
||||||
|
(dense-path spacing only). Recommended-cell prominence; cell padding/gap shave
|
||||||
|
as **margin-hardening for the ~4px-tight 284+160 piano pair fit** (§4 — it
|
||||||
|
passes today; the shave makes it robust to label length / scrollbar gutters /
|
||||||
|
rounding); header wrap tuning at ~456px; verify wrap-never-scroll, playhead
|
||||||
|
highlight, AA contrast + focus rings; eyeball 1280×900, 1440×900, ~640px
|
||||||
|
stacked; recompute §4's table against the built thing. No auto-scroll
|
||||||
|
introduced.
|
||||||
|
|
||||||
|
### 6.3 L-51 (Luthier) — RelatedProgressions
|
||||||
|
|
||||||
|
**Files:** `src/components/RelatedProgressions.jsx` (new), `src/lib/match.js`
|
||||||
|
(**additive exports only**: `loopToDegrees`, `canonicalDegrees` — flag the
|
||||||
|
shared-file lock to Maestro at promotion), `src/App.jsx` (one-line: pass
|
||||||
|
`relatedSlot={<RelatedProgressions …/>}`). Ranking per §5, exported from the
|
||||||
|
component file for smoke coverage; honest empty states; build + smoke green.
|
||||||
|
Disjoint from D-51 (App.jsx/match.js vs GlanceRail/VoicingBrowser) → may run
|
||||||
|
in parallel after L-50.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Narrow plan (< xl, and the ~640px check)
|
||||||
|
|
||||||
|
Columns stack in the jam-following order: **loop strip → instrument view →
|
||||||
|
voicings rail → licks strip → related progressions** — i.e. the right column
|
||||||
|
tucks between mainView and LicksStrip, matching the user's list order.
|
||||||
|
Implementation: the rail block is simply the second child inside the stacked
|
||||||
|
flow below `xl` (JamGuide reorders its own children with responsive classes or
|
||||||
|
conditional order — no duplicate mounts). The rail **unbounds** below `xl`
|
||||||
|
(the max-h/overflow classes are `xl:`-prefixed) and lays out at natural height
|
||||||
|
in page flow — a nested scroller inside a scrolling page is a trap on touch.
|
||||||
|
Cell wrap at ~576px interior: guitar 5/line, piano cells **pair** (a 284px
|
||||||
|
two-octave + a 160px one-octave = 452, or two two-octaves at the 576 boundary) —
|
||||||
|
the same margin-hardened pairing D-51 verified for the bounded column (§4),
|
||||||
|
with more room here, not the "cells ride alone" of the narrower bounded width. The instrument
|
||||||
|
selector never moves: it lives in the controls bar, global, above everything
|
||||||
|
at every width.
|
||||||
|
|
||||||
|
**Jam view below `xl`:** the toggle still works — below-dashboard surfaces
|
||||||
|
hide and fullscreen still fires — but the stacked dashboard is taller than any
|
||||||
|
narrow viewport, so the page keeps scrolling (`h-screen overflow-hidden`
|
||||||
|
applies only at `xl:`+ alongside the grid). One-screen is a two-column
|
||||||
|
promise; jam view narrows honestly to "distraction-free" rather than
|
||||||
|
pretending the geometry works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Rejected alternatives
|
||||||
|
|
||||||
|
**Rejected A — keep the rail as the full-width band and put licks +
|
||||||
|
progressions in the right column instead.** The rail is the surface the
|
||||||
|
directive names for the right side ("i'd like to have the suggested voicings
|
||||||
|
on the right side of the screen (the jam guide)") — and the math agrees: full
|
||||||
|
width doesn't make the rail *shorter* in any useful way (rows are height-bound
|
||||||
|
by cell-line count, and at 1,240px most piano galleries fit one line, but the
|
||||||
|
band still starts below the instrument view and pushes licks/progressions off
|
||||||
|
screen — today's exact failure). Licks and progressions are short; they fit
|
||||||
|
under the instrument view; the tall thing is what needs the dedicated column.
|
||||||
|
|
||||||
|
**Rejected B — one-screen by truncation: show only the recommended voicing per
|
||||||
|
station, gallery behind a tap.** Fits 4 stations in ~640px with zero scroll —
|
||||||
|
and violates three standing calls at once: scroll > click, D-30's "all at once"
|
||||||
|
gallery directive, and the learning value the user praised ("potentially learn
|
||||||
|
new ways to play it while you are playing"). The gallery stays; its depth
|
||||||
|
becomes a contained scroll, not a click.
|
||||||
|
|
||||||
|
**Rejected C — rail in the LEFT column, detection surfaces right.** Reading
|
||||||
|
order puts the primary, continuously-tracked surfaces (strip, instrument view)
|
||||||
|
top-left where Western eyes rest; the rail is a reference you glance at. Also
|
||||||
|
geometric: the instrument view wants the wide column (674px natural fretboard
|
||||||
|
width > 500px rail), and swapped columns would force the fretboard to ~0.68
|
||||||
|
scale — sub-legible labels.
|
||||||
|
|
||||||
|
**Rejected D — compact the instrument view via CSS `transform: scale()` or a
|
||||||
|
reduced fret range.** Covered in §2: transform blurs text and shrinks targets;
|
||||||
|
frets 0–7 amputates upper-position play. The natural-width cap + chrome trim
|
||||||
|
achieves the same ~80px saving honestly.
|
||||||
|
|
||||||
|
**Rejected E — page-flow rail (no height bound, no contained scroll).** The
|
||||||
|
"pure" reading of the no-nested-scrollbar taste rule — and it structurally
|
||||||
|
fails the refinement: an unbounded rail stretches the *document* to the rail's
|
||||||
|
full height (a jazz piano loop ≈ ~3,000px+), so the below-grid region —
|
||||||
|
LoopStation and the whole learning area — lands thousands of pixels down
|
||||||
|
instead of one flick below the dashboard; and in jam view an unbounded rail
|
||||||
|
can never fit `h-screen`, so the 100vh hard constraint is unmeetable without
|
||||||
|
the bound anyway. (The left column's five surfaces sit at the top either way —
|
||||||
|
the cost is the document's length and the jam-view fit, not their
|
||||||
|
co-presence.) Below `xl` this rejection inverts — §7 — because stacked
|
||||||
|
layouts have no second column to preserve.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Out of scope / flags for Maestro
|
||||||
|
|
||||||
|
- **Contained auto-follow** inside the rail scroller — now technically safe,
|
||||||
|
deliberately deferred; user-pulled follow-up only (§4).
|
||||||
|
- **LoopStation in jam view** — excluded per the user's own enumeration (§3
|
||||||
|
recorded call); if missed mid-jam, admitting it is a one-line conditional.
|
||||||
|
User-pulled follow-up only.
|
||||||
|
- **Root-aligned MiniPiano crop** (halves piano rail height) — reopens D-24;
|
||||||
|
D-52 candidate (§4).
|
||||||
|
- **`src/lib/match.js` additive exports** in L-51 — shared-file lock note at
|
||||||
|
promotion (§6.3). Same coordination point covers D-62: if its fix (a) lands
|
||||||
|
collapsed-form indexing in match.js first, L-51's ranking consumes that
|
||||||
|
shared collapsed form instead of collapsing locally (§5).
|
||||||
|
- **Retire-then-delete backlog** grows by ProgressionSuggestions.jsx
|
||||||
|
(unmounted L-50) alongside RoadmapTrack/CurrentJamPanel/EducationPanel.
|
||||||
|
- KnowledgeDock deep-link ("open Explore at style X") — not invented for v1;
|
||||||
|
RelatedProgressions taps go per-chord to ChordDetailModal (§5).
|
||||||
|
- No new tokens, no new colours, no dependencies, no KB/theory/audio changes
|
||||||
|
anywhere in this design. Geometry figures verified per integrated-glance.md;
|
||||||
|
module heights are estimates and D-51/C-50 re-measure the built thing.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Related-area space layout — Try-this side-by-side + Related 2×2 (task D-75)
|
||||||
|
|
||||||
|
**Sprint:** `sprint-dashboard-polish` · **Owner:** Muse · **Impl tasks:** L-75 (TryThis.jsx + App mount) · L-76 (RelatedProgressions.jsx). Concept doc only — no code here.
|
||||||
|
|
||||||
|
**User ask (2026-07-13, verbatim, after running the working dashboard — happy with it):**
|
||||||
|
> "for the TRY THIS can we also add the little piano/fretboard next to it? now it also has a 1/2 option, but please put them next to each other, we have enough space in that area. also for the suggested progressions underneath there is space to have it cut in half to make it 2x2 in a similar fashion so we use the space and we fit everything in one big screen."
|
||||||
|
|
||||||
|
The user is pointing at the **LEFT column** of the jam dashboard (`flex-1` ≈ **744px** per `one-screen.md` §1 — far wider than the 500px rail) and asking us to **use its horizontal width**: (A) Try-this stops rotating one-at-a-time and shows **all** the current chord's substitutions **side by side**, each with a **mini instrument diagram** of that chord; (B) RelatedProgressions goes from a vertical list to a **2×2 grid**. Both to compress the vertical footprint so more fits on one screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The honest reversal (say it plainly)
|
||||||
|
|
||||||
|
L-74 shipped a **rotating one-at-a-time** Try-this card (`pickSub` / `advanceOnWrap` / `cycle`), because the same-day directive was *"more surprising, more jam-like, keeps offering new ideas."* The user has now **seen** that rotation and prefers **all-visible side-by-side** ("put them next to each other, we have enough space"). This doc **reverses** the rotation decision. That is not churn for its own sake — it is the user refining after seeing it live, exactly the loop the ensemble is built for. The **engine (`suggestSubstitutions`, L-73) and its rules do not change**; only the UI shape does.
|
||||||
|
|
||||||
|
What survives from L-74's `TryThis.jsx`:
|
||||||
|
- `parseChordName(name)` — pure, keep.
|
||||||
|
- `subsForChord(name, pos, loopArr, keyInfo)` — pure, keep.
|
||||||
|
- `pickSubject(loopArr, keyInfo, currentChord)` — pure, keep. This is what keeps the card **live**: it picks (a) the live `currentChord` if it parses and yields ≥1 sub, else (b) the first loop station that yields subs, else (c) `null` (honest empty). The card still follows the playhead — as you play, the subject chord and its ≤4 subs update.
|
||||||
|
|
||||||
|
What is **deleted**:
|
||||||
|
- `useState(cycle)` + the rotation `useEffect`, `advanceOnWrap`, `pickSub`, the `lastPosRef` / `lastNameRef`, the "N of M" indicator, the dot rail. Smoke coupling: **none** — `scripts/smoke.mjs` has zero references to `advanceOnWrap` / `pickSub` / `parseChordName` (grep-confirmed; its "rotation" hits are all progression-match tests). So L-75 can drop the helpers without touching smoke; its lock stays `TryThis.jsx` + `App.jsx`.
|
||||||
|
|
||||||
|
Honest empties preserved: no key / no loop / atonal → `pickSubject` returns null → render nothing (unchanged). A chord with fewer than 4 valid subs simply shows fewer cards (the engine already `.slice(0,4)`; often 1–3 fire).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Try-this — side by side, one card per sub, each with an instrument diagram
|
||||||
|
|
||||||
|
### 1.1 The layout
|
||||||
|
|
||||||
|
Keep the outer section shell (`rounded-2xl border border-border bg-panel p-3`, the micro-header "Try this instead of {chord} · in {key} {mode}"). Below it, replace the single-card body with a **flex-wrap row of sub-cards**, one per `subject.subs` entry (≤4):
|
||||||
|
|
||||||
|
```
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{subject.subs.map(sub => <SubCard … />)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Container adapts to the instrument's cell width** (§3 proves the footprints — the across-count is NOT a fixed 4):
|
||||||
|
- **guitar / bass** — `flex flex-wrap gap-2`, each card `basis-[168px] grow min-w-[152px]`: the small cells (guitar 75px, bass none) let **4 fit one row** in the 720px interior; with 1–3 subs the cards `grow` to fill.
|
||||||
|
- **piano** — a **`grid grid-cols-2 gap-2`** (2×2): the mini keyboard's real footprint is up to **199px** (§3), so 4 piano cards cannot share one 720px row; a clean 2×2 mirrors the rail's own piano idiom (`VoicingBrowser` dense = `grid grid-cols-2`) and avoids the unbalanced 3+1 that a plain `flex-wrap` would produce.
|
||||||
|
- On a narrow/stacked viewport both collapse to **1-per-row** (`grid-cols-1` / `flex-wrap`) — the DoD reflow, for free.
|
||||||
|
|
||||||
|
Each **SubCard** is a vertical stack (`flex flex-col items-center gap-1.5 rounded-lg border border-border bg-border/30 p-2`):
|
||||||
|
|
||||||
|
1. **Chord chip** (top) — the tappable control, reusing L-74's chip idiom exactly:
|
||||||
|
`button` → `onChordClick?.(sub.label)`, classes `rounded-lg border border-border bg-border px-2 py-1 text-sm font-bold text-gray-100 hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent`. `aria-label={`${sub.label} — ${sub.why}`}`. Tap → `ChordDetailModal` (App's `setSelectedChord`), where the **full** why + all voicings live.
|
||||||
|
2. **Mini instrument diagram** (middle) — of `{sub.rootPc, sub.quality}`, following the **global instrument** (§2). This is the "little piano/fretboard next to it" the user asked for.
|
||||||
|
3. **Category tag** — the L-74 language: `relative` / `borrowed` / `colour` / `V7`, with the `↻` accent glyph **only** for `relative` + `secondary_dominant` (circle categories — unchanged rule, `text-accent`).
|
||||||
|
4. **Why** (bottom) — `sub.why`, `text-[11px] leading-snug text-gray-400`, **`line-clamp-3`** with the full text on the chip's `aria-label` + tap→modal. At ~152–168px card width, clamp-3 keeps whys up to ~75 chars whole; longer ones truncate visibly and the full sentence is one tap away. This is the honest trade of side-by-side: 4 whys visible at a glance costs each one its full width — the user chose see-all over the single full-width why.
|
||||||
|
|
||||||
|
### 1.2 Across-count is a function of the diagram footprint, not a fixed "4"
|
||||||
|
|
||||||
|
The user said *"put them next to each other … we have enough space"* and — resolving the piano case directly — *"for the piano's u can take more space in case there is indeed a bigger piano needed"* (2026-07-13). So: spend the **horizontal** budget as far as each instrument's diagram allows, and let piano take the **vertical** room it needs rather than cramming it. Guitar/bass cells are small (75px / none) → 4 subs sit **one row across**. Piano cells are large (up to 199px, §3) → 4 subs form a **2×2** — still "next to each other," still using the width, just a taller block (which the user explicitly OK'd). The count follows the footprint; the layout adapts (§1.1) instead of forcing a uniform grid that would clip the piano or waste the guitar row. **No MiniPiano change** — the keyboard renders at its honest size.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The instrument prop — threading + which resolver draws each diagram
|
||||||
|
|
||||||
|
`TryThis` needs the global `instrument` (`'guitar' | 'piano' | 'bass'`), which lives in `App.jsx` and is already handed to `JamGuide` (line 849). **Prop path:** add `instrument={instrument}` to the `<TryThis … />` mount in the `relatedSlot` (App.jsx ~line 859) and add `instrument` to the component signature: `TryThis({ loop, keyInfo, currentChord, onChordClick, instrument })`. One-line App change, grep-clean (no audio/callback contract touched) — this is the App half of L-75.
|
||||||
|
|
||||||
|
Per-instrument diagram resolver (mirror the established rail idiom in `VoicingBrowser.jsx:288`/`:327`):
|
||||||
|
|
||||||
|
| instrument | diagram | resolver → render |
|
||||||
|
|---|---|---|
|
||||||
|
| `guitar` | `ChordDiagram size="thumb"` (~75px) | `const shape = getGuitarVoicings(sub.label)[0]` → `<ChordDiagram shape={shape} rootPc={sub.rootPc} size="thumb" />`. `getGuitarVoicings` (voicings.js) resolves the first (barre/open) shape to **absolute** low-E-first frets; ChordDiagram's open-shape path windows them (draws a `5fr` label when up-neck). **Omit** ChordDiagram's own `label` prop — the chip above already names the chord (saves ~13px). If `getGuitarVoicings(sub.label)` is empty (rare), render **no** diagram — just chip + why (honest, no crash). |
|
||||||
|
| `piano` | `MiniPiano size="mini"` (107–199px, §3) | `<MiniPiano voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }} size="mini" />`. **Spread `rootPc` back in** — `pianoVoicing()` output carries none, and `VoicingPiano` needs it to badge "R" correctly (the VoicingBrowser:317-319 caveat). `size="mini"` = the dashboard-density scale already used in the rail's 2×2. |
|
||||||
|
| `bass` | **no diagram — honest** | Bass players read a chord symbol + root, not a chord grip; there is no compact bass-chord renderer, and reusing the guitar/piano diagram under a BASS selector would misrepresent the instrument (same honesty call the licks strip makes for bass). SubCard shows the chip + a small caption `root · {NOTES[sub.rootPc]}` (`text-[10px] text-gray-500`) + the why. Compact, correct, no faked shape. |
|
||||||
|
|
||||||
|
All diagrams use the established note-colour tiers already baked into `ChordDiagram`/`MiniPiano` (accent-purple root, light-purple/gray other tones, amber bass ring) — no new colour, no token change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Space math — the LEFT column (~744px), honest
|
||||||
|
|
||||||
|
- Left column `flex-1 min-w-0` ≈ **744px** (one-screen.md §1). The `relatedSlot` is `flex flex-col gap-3` **directly** in it, so the Try-this `section` spans the full 744px. Section `p-3` (12px each side) → **interior ≈ 720px**.
|
||||||
|
- Per-card diagram footprints (recomputed from source geometry):
|
||||||
|
- **Guitar** `ChordDiagram size="thumb"`: `padL 14 + gridW 55 + padR 6 = 75px` wide; `padT 11 + gridH 55 + padB 6 = 72px` tall (label omitted). Root-independent.
|
||||||
|
- **Piano** `MiniPiano size="mini"` — the window is **C-octave-boundary anchored, NOT span-anchored** (MiniPiano.jsx:200-207): `octStart = ⌊minNote/12⌋`, `OCTAVES = max(1, ⌈(maxNote − octStart·12)/12⌉)`, `SVG_W = (22·(7·OCTAVES + 1) + 2)·0.60`. A voicing whose *interval span* is under an octave STILL renders **2 octaves** whenever its notes straddle a C boundary — so "span ≤ 1 octave" does **not** imply "renders 1 octave." Worked: F♯ major triad `pianoVoicing({rootPc:6})` → notes `[6,10,13]` (span 7) → `octStart 0`, `maxNote 13` → `OCTAVES = ⌈13/12⌉ = 2` → `SVG_W = (22·15 + 2)·0.60 = 332·0.60 = **199.2px**`. This fires for **every triad quality at roots pc ≥ 6 (F♯–B)** (maxNote = root + top-interval > 12) and for shells at most roots (the +12 lift pushes the 7th over the next C) — i.e. roughly **half** of all sub roots, and sub roots routinely land upper-half (any G♯/A♯/B relative / borrowed / secondary-dominant candidate). One octave (107px) is the *best* case (triad roots pc 0–5); **199.2px is the planning footprint.** MiniPiano.jsx:29-31's own header documents this "2-octave mini cell (213.2px)". `SVG_H = (60 + 4)·0.60 = **38.4px** — octave-INDEPENDENT`: the window widens, height never does.
|
||||||
|
- **Across-count per instrument** (interior 720px, `gap-2` = 8px; an SVG's hard `width` attr floors the flex item's min-content at ≈ its px, so `basis-[168px]` cannot shrink a piano cell below its keyboard):
|
||||||
|
- **Guitar** — cell 75px, card ~152px min (diagram + `p-2` + chip width) → `4·152 + 3·8 = 632 ≤ 720` → **4-across, one row.**
|
||||||
|
- **Bass** — no diagram, card ~152px → **4-across, one row.**
|
||||||
|
- **Piano** — worst-case cell 199.2px, card ~215px (+ `p-2` 16) → `3·215 + 2·8 = 661 ≤ 720` but `4·215 + 3·8 = 884 > 720`. So **at most 3 share a row**, and 4 can't. Laid out as a **2×2 grid** (§1.1, per user's "take more space"): 2 cells/row, `2·215 + 8 = 438 ≤ 720` (roomy, each keyboard gets its full width) → **2 across × up to 2 rows.**
|
||||||
|
- **Row / block heights** (`SVG_H` is octave-independent, so piano height is driven purely by sub-count, not cell width):
|
||||||
|
- **Guitar** card: chip ~22 + diagram 72 + why (clamp-3 @11px/1.35 ≈ 45) + tag ~12 + `p-2` 16 + inner gaps ~10 ≈ **~177px** → block (1 row + header 20 + `p-3` 24) ≈ **~221px.**
|
||||||
|
- **Piano** card: chip ~22 + diagram 38 + why ~45 + tag ~12 + `p-2` 16 + gaps ~12 ≈ **~145px**. 4 subs → 2×2 = `2·145 + 8 ≈ 298` → block (+ 44 chrome) ≈ **~342px**. ≤3 subs → one row → block ≈ **~189px.**
|
||||||
|
- **Bass** card ≈ chip 22 + root caption 14 + why 45 + tag 12 + `p-2` 16 + gaps 12 ≈ **~121px** → block ≈ **~165px.**
|
||||||
|
- **Headline:** guitar & bass show all ≤4 subs **4-across in one row** (~165–221px block). Piano shows them as a **2×2** taking the full room each 199px keyboard needs — **~189px** block for ≤3 subs, **~342px when all 4 fire** (two rows). Only piano's across-count/row-count vary; height is width-independent (38.4px SVG). This taller piano block is the honest, user-sanctioned cost of "the little piano next to it."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. RelatedProgressions → 2×2 grid (L-76, layout only)
|
||||||
|
|
||||||
|
### 4.1 The change
|
||||||
|
|
||||||
|
Today the populated list is `<ul className="flex flex-col gap-2.5">` of up to `RELATED_MAX_ENTRIES` (5) entries, each = name + meta row (style/level/annotation-or-role) + `ChordChain` chips. **Swap the container** to a 2-column grid:
|
||||||
|
|
||||||
|
```
|
||||||
|
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `<li>` (its inner markup — name row, `LevelBadge`, role/annotation, `ChordChain`) is **untouched**. `grid-cols-1` on narrow (the stacked reflow), `sm:grid-cols-2` at width. No ranking, scoring, `collapseChanges`, `siblingRole`, or annotation logic changes — L-76 is **presentation only**.
|
||||||
|
|
||||||
|
### 4.2 The cap: 5 → display **4** (no ranker change)
|
||||||
|
|
||||||
|
A clean 2×2 wants **4** cells; 5 leaves an orphan in a lonely third row that reads as broken. **Decision:** the ranker keeps `RELATED_MAX_ENTRIES = 5` (untouched — no smoke re-pin, no exported-constant edit); the **component renders `primary.slice(0, 4)`** into the grid. Entries are score-sorted, so this drops only the **lowest-scoring** 5th sibling — honest ("top 4"), and a future "show all" affordance could reveal it. This keeps L-76 strictly layout-scoped.
|
||||||
|
|
||||||
|
### 4.3 Which sections grid
|
||||||
|
|
||||||
|
Per L-72's finding-A, `secondary` (cross-style) is **currently always `[]`**: when a style is locked the panel is same-style-only; when unlocked, everything lands in `primary`. So **only `primary` is ever populated** → only it needs the grid. The optional cross-style section stays dormant; **if** a future change repopulates `secondary`, it gets its own `grid grid-cols-2` block below its own subheading (spec'd, not built). The **idle** dashed card and the two **empty-state sentences** ("You're on the only {style} loop…", "Nothing in the songbook genuinely relates…") stay single-column — they are prose, not a grid.
|
||||||
|
|
||||||
|
### 4.4 Space + height
|
||||||
|
|
||||||
|
- 2-col cell width ≈ `(720 − 10 gap) / 2 ≈ 355px`. A `ChordChain` of up to 8 chips (~40px each) wraps to ~2 rows inside 355px — fine, same chips, just narrower flow.
|
||||||
|
- Cell height ≈ name 18 + meta 16 + chain (2 chip-rows ~30 each) 60 ≈ **~95–100px**. Block = header ~20 + subheader ("Try these in {style}") ~18 + (2 rows × 100 + 10 gap) 210 + `p-3` 24 ≈ **~272px**.
|
||||||
|
- The old vertical **5**-list block ≈ header 20 + subheader 18 + 5 × ~90 + 4 × 10 + 24 ≈ **~552px**. **2×2(4) ≈ 272px → saves ~280px** — the compression the user is after ("cut in half"), almost exactly halved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The whole left column on one screen (honest)
|
||||||
|
|
||||||
|
Usable content box at 1280×900 ≈ **836px** tall (one-screen.md §4). Left stack in jam-view = instrument view + licks strip + `relatedSlot` (`flex-1 min-h-0 overflow-y-auto` absorber = TryThis + RelatedProgressions). Heights are **section-inclusive** (own header + `p-3`); gaps = 3 × `gap-3` = 36px.
|
||||||
|
|
||||||
|
| build / instrument | instrument | licks | try-this | related | +gaps | **total** | vs 836 box |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| **current L-74** (guitar) | 240 | 190 | ~154 (rotating, 1 card) | ~552 (5-list) | 36 | **~1172** | overflow **~336px** |
|
||||||
|
| **D-75 new — guitar** | 240 | 190 | ~221 (4-across, 1 row) | ~272 (2×2) | 36 | **~959** | overflow **~123px** |
|
||||||
|
| **D-75 new — bass** | 240 | 190 | ~165 (4-across, 1 row) | ~272 | 36 | **~903** | overflow **~67px** |
|
||||||
|
| **D-75 new — piano, ≤3 subs** | 240 | 190 | ~189 (one row) | ~272 | 36 | **~927** | overflow **~91px** |
|
||||||
|
| **D-75 new — piano, 4 subs** | 240 | 190 | ~342 (2×2, 2 rows) | ~272 | 36 | **~1080** | overflow **~244px** |
|
||||||
|
|
||||||
|
**Honest headline:** the RelatedProgressions 2×2 halves that block in every case (**~552 → ~272, −280px**) — the compression the user asked for. Guitar and bass compress hard: the fold shrinks from the current build's ~336px to **~67–123px** (a micro-flick). Piano is the honest exception the user pre-approved: because MiniPiano's C-anchored window makes each keyboard up to 199px, four piano subs form a **2×2 (two rows, ~342px)**, so the piano-with-4-subs fold is **~244px** — still ~90px better than the current build, and only in the corner case where all four sub-rules fire *and* the diagram is piano (many chords yield 2–3 subs → piano one row → ~91px fold). The residue is absorbed only by the related area's `overflow-y-auto` (instrument + licks never move); a fold-flick is within the standing scroll license, and the user explicitly OK'd more vertical space for the piano. At **1280×960+** guitar/bass/piano-≤3 clear; piano-4-subs still wants a short flick for the related bottom row.
|
||||||
|
|
||||||
|
(The `flex-1 min-h-0 overflow-y-auto` absorber already exists — jam-view fit is exact by construction; only the related area, never the instrument/licks, absorbs the residue.)
|
||||||
|
|
||||||
|
> **Future option (out of D-75 scope):** the only lever to shrink the piano block further is a **span-anchored** MiniPiano `mini` window (crop to the voicing's actual keys instead of C-boundary octaves) — a MiniPiano code change, option (b), deliberately deferred. Flag for a follow-up task if the piano fold proves annoying in play.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Bounded scopes (disjoint)
|
||||||
|
|
||||||
|
- **L-75** — `src/components/TryThis.jsx` (rewrite: drop the rotation state/effect + `pickSub`/`advanceOnWrap` + indicator; keep `parseChordName`/`subsForChord`/`pickSubject`; render `subject.subs` as a `flex-wrap` row of SubCards, each with the instrument-following diagram per §2) **+** `src/App.jsx` (add `instrument={instrument}` to the TryThis mount — one line, grep-clean). No smoke coupling (§0). Disjoint from L-76.
|
||||||
|
- **L-76** — `src/components/RelatedProgressions.jsx` **only** (`ul` container `flex flex-col` → `grid grid-cols-1 sm:grid-cols-2`; `primary.slice(0, 4)` for display; idle/empty states untouched; **no** ranking/scoring/constant change). Disjoint from L-75.
|
||||||
|
|
||||||
|
Serialise or worktree — the two locks share no file. No `tailwind.config.js` change (no new colour); no `theory.js` / `piano.js` / `voicings.js` change (resolvers consumed as-is).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Rejected alternatives (≥2)
|
||||||
|
|
||||||
|
1. **Keep the rotation AND add a diagram to the single card.** Rejected: the user has seen rotation and explicitly asked for side-by-side ("put them next to each other, we have enough space") — a single card leaves the 744px width the user pointed at mostly empty, and re-litigates a decision the user has already moved past.
|
||||||
|
2. **One big shared diagram for all subs** (e.g. mark all subs on a single fretboard/piano, or a circle-of-fifths mini). Rejected: the subs differ in root, quality, **and** the right instrument grip; a per-sub diagram is exactly what teaches "play *this* shape instead," and it preserves the tap→modal-per-chord affordance. A merged diagram is unreadable and loses per-chord tapping.
|
||||||
|
3. **A uniform 2×2 for try-this across all instruments.** Rejected as the *default*: for guitar/bass (75px / no diagram) a 2×2 wastes half the row and is taller than 4-across for no benefit. Piano adopts a 2×2 only because its ~199px keyboards genuinely can't fit 4-across (§3) and the user OK'd more space for it — an instrument-driven adaptation, not a uniform choice.
|
||||||
|
4. **Cram/shrink the piano keyboards to force 4-across.** Rejected (and the user directly vetoed it, 2026-07-13): the `mini` SVG carries a hard width attr that floors the flex item, so "shrinking" would clip the keyboard; the honest fix is to let piano take a 2×2 and more vertical space. (A genuine width fix = span-anchored MiniPiano window = §5's deferred option b.)
|
||||||
|
4. **Change `RELATED_MAX_ENTRIES` 5→4 in the ranker for the 2×2.** Rejected: that edits an exported constant + forces a smoke re-pin, pulling L-76 out of "layout only." A component-side `slice(0, 4)` gets the clean grid with zero engine/scoring/test churn.
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# Related-area v2 — always-multi-chord voicings rail + stable 3×3 Try-this (task D-76)
|
||||||
|
|
||||||
|
**Sprint:** `sprint-dashboard-polish` · **Owner:** Muse · **Impl tasks:** L-77 (voicings rail: GlanceRail.jsx + JamGuide.jsx) · L-78 (TryThis.jsx). Concept doc only — no code here. Supersedes the relevant parts of `related-area-layout.md` (D-75) that this refines.
|
||||||
|
|
||||||
|
**User ask (2026-07-13, verbatim — "one last change then we push everything"):**
|
||||||
|
> "for the right part 'different voicings' there is currently only one chord visible, we said to show all chords in the loop with the most recent first. at all times we wanna see all different voicings of the different chords. also for the TRY THIS part please always keep the layout the same if it's 2 or 3 chords i want it to stay in place its annoying when the layout changes then u dont know where to look. max it on 3 TRY THIS suggestions, PLUS please add at least 3 ways to play it. i think we have enough space for 3x3 guitar. for piano it can be just one thats okay."
|
||||||
|
|
||||||
|
Two independent surfaces:
|
||||||
|
- **(A) The voicings RAIL** (right column, `JamGuide` `railContent`) must show **multiple chords, most recent first, at all times** — the "only one chord visible" is the no-loop heard-live fallback.
|
||||||
|
- **(B) TRY THIS** (left column) caps at **3** suggestions, holds a **stable fixed layout** (2-vs-3 subs never shifts position), and gives each suggestion **≥3 ways to play it** — a **3×3** guitar grid, **1** piano keyboard, bass root caption.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Where "only one chord" actually comes from (say it plainly)
|
||||||
|
|
||||||
|
`JamGuide` `railContent` (JamGuide.jsx:373-435) has **three** states:
|
||||||
|
|
||||||
|
1. `match.matched` → `<GlanceRail stations={stationVoicings} …/>` — **already** shows every loop chord as a vertical row (canonical KB order, playhead highlight, full per-row gallery). This is NOT the bug.
|
||||||
|
2. `liveChord` (no KB match but a chord is committing, JamGuide.jsx:252) → a **single** `<VoicingBrowser rootPc quality show dense/>` inside a "Heard live · {chord} — every voicing" section (JamGuide.jsx:411-425). **This is the "only one chord."**
|
||||||
|
3. else → the slim idle line.
|
||||||
|
|
||||||
|
So the fix is scoped to state 2 (and the idle-with-history case): replace the single-chord fallback with a **multi-chord, most-recent-first history rail**. State 1 (the loop) already satisfies "all chords of the loop" — the open question is only whether to physically reorder it most-recent-first (§1.3: no, and why).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The voicings rail — always multiple chords, most recent first
|
||||||
|
|
||||||
|
### 1.1 Decision: keep GlanceRail-for-loop + a new history fallback — do NOT fully unify
|
||||||
|
|
||||||
|
**Recommendation: keep the two paths, replace only the single-chord fallback.**
|
||||||
|
|
||||||
|
- **Loop matched** → `GlanceRail` as today (canonical order, playhead, per-row gallery, the D-40 voice-leading education). Untouched.
|
||||||
|
- **No loop** → a **new `HistoryRail`** driven by the recent DISTINCT `chordHistory`, most-recent-first, each rendered as a GlanceRail-style voicing row.
|
||||||
|
|
||||||
|
**Why not unify** (always drive the rail from recent-distinct-history, loop adds highlighting): the loop rail's education is *canonical-order-dependent*. `GlanceRail` computes `voiceLeadingPairs` between **canonically adjacent** stations (GlanceRail.jsx:234-241) and renders per-row `TransitionChip`s ("next F→E · ½ step down") plus the wrap chip ("loop") — a voice-leading **wheel**. Reordering those rows most-recent-first would make every "next …" chip point at the wrong neighbour, and the wrap chip lie. Unifying therefore *destroys* the loop education to satisfy a directive the loop already meets another way (§1.3). So: two paths, one shared row idiom, no loss.
|
||||||
|
|
||||||
|
This is also the **minimal, lowest-regression** change: `StationRow` (loop) stays byte-identical; the new `HistoryRow`/`HistoryRail` is additive.
|
||||||
|
|
||||||
|
### 1.2 The no-loop history rail — data source & most-recent-first
|
||||||
|
|
||||||
|
`chordHistory` is already a `JamGuide` prop (JamGuide.jsx:186; App passes it at App.jsx:846) — **no App change needed.** Build the rail entirely from it:
|
||||||
|
|
||||||
|
```
|
||||||
|
recentDistinct(chordHistory, cap = 6):
|
||||||
|
seen = new Set(); out = []
|
||||||
|
for name from END of chordHistory backwards: // newest first
|
||||||
|
parsed = parseChord(name) // voicings.js — same parser JamGuide already imports (line 12)
|
||||||
|
if (!parsed) continue // unparseable → skip, no crash
|
||||||
|
if (seen.has(name)) continue // DISTINCT by chord name (subsumes consecutive-dedup)
|
||||||
|
seen.add(name); out.push({ ...parsed, label: name })
|
||||||
|
if (out.length === cap) break
|
||||||
|
return out // out[0] = the chord sounding now
|
||||||
|
```
|
||||||
|
|
||||||
|
**"Most recent first" mapping:** walk `chordHistory` from the tail (the newest commit is `chordHistory[len-1]` = `currentChord`, App.jsx:572). `out[0]` is therefore the current chord, `out[1]` the previous distinct chord, etc. **Distinct-by-name** (not merely consecutive-dedup) so "F Am F Am F" yields `[F, Am]` — the user wants the *different chords*, each once, not a ping-pong; the most-recent occurrence fixes each chord's slot. Cap **6** rows.
|
||||||
|
|
||||||
|
Each entry parses to `{ rootPc, type, label }` (voicings.js `parseChord` returns `{ rootPc, type }`). Feed each as a row.
|
||||||
|
|
||||||
|
**Honest edges:**
|
||||||
|
- **1 chord ever played** → 1 row. Unavoidable (there is genuinely one chord) — but it is now a *row in the rail idiom*, not a bespoke single-chord section. Strictly better than today, which also showed one.
|
||||||
|
- **20 chords** → the 6 most-recent distinct, newest first. Older ones roll off (the rail is a live window, not a log — the KnowledgeDock Voicings section is the browse-everything surface).
|
||||||
|
- **All same chord** (F F F F) → 1 row.
|
||||||
|
- **Unparseable name** → skipped; if *nothing* parses → fall through to the existing slim idle line (state 3), unchanged.
|
||||||
|
|
||||||
|
### 1.3 What "most recent first" means for the LOOP (honest)
|
||||||
|
|
||||||
|
For the loop, `GlanceRail` stays **canonical KB order** and the **playhead** (accent ring + "now" badge + `aria-current`, GlanceRail.jsx:184-188) marks the chord you're on. That IS "most recent, unmistakable" — it is exactly the active-chord-at-a-glance the DoD demands — without reordering. Physically reordering the loop rows most-recent-first is **rejected** (§6.1) because it breaks the voice-leading wheel. If the user, after seeing this, still wants the loop *physically* re-sequenced, that is a follow-up that must first rework/retire the `TransitionChip` education — flag to Maestro, don't silently do it.
|
||||||
|
|
||||||
|
### 1.4 The HistoryRail row anatomy (reuse, minus loop chrome)
|
||||||
|
|
||||||
|
A new `HistoryRow` (sibling of `StationRow` in GlanceRail.jsx — keep `StationRow` untouched for the loop). Per row, most-recent-first:
|
||||||
|
|
||||||
|
- **Header line:** `st.label` (plain bold text — no focus-toggle button; history chords aren't stations, so no fretboard-focus wiring → simpler, and the main Fretboard already follows `currentChord`). `SoloLabel` + `AimDots` **kept** (GlanceRail.jsx exported atoms) — those are *per-chord* guide-tone education, correct for any chord, and give the history rail visual parity with the loop rail. **No** `TransitionChip` (no canonical adjacency), **no** "next" tag.
|
||||||
|
- **"now" marker on row 0** (the most-recent chord): the same `border-accent bg-accent/10 ring-2 ring-accent` + "now" badge `StationRow` uses for the playhead — so the active chord is unmistakable (DoD). Rows 1-5 recede to the `opacity: 0.85` floor, exactly as inactive loop rows.
|
||||||
|
- **Gallery — as-is:** `<VoicingBrowser rootPc={st.rootPc} quality={st.type} show={instrument} dense max={4} />`. Guitar ≤4 recommended-first shapes on one line, piano 2×2 mini, **no ▶** — the identical per-row gallery the loop rail uses. `recommended` is **null** (an arbitrary heard chord has no authored KB play), which `VoicingBrowser` already handles (order untouched, no badge).
|
||||||
|
|
||||||
|
`HistoryRail` header (the section h4): `Recent chords · newest first — every voicing`, with the one-line microcopy `Following what you play — the newest chord is up top; no repeating loop yet.` (tokens: `bg-panel`, `border-border`, `text-gray-500`).
|
||||||
|
|
||||||
|
### 1.5 Bass, no loop
|
||||||
|
|
||||||
|
The no-loop bass path today renders `BassGuideRows` with a **single** live station (JamGuide.jsx:404-410). Extend it to the **same recent-distinct stations, most-recent-first**: pass the `recentDistinct` array to `BassGuideRows` with approach suppressed (history is not a loop → no "approach into the next chord" line; reuse the existing `live` gate, which already turns `next` off at BassGuideRows.jsx:557, or add a `history` flag if the "· heard live" header wording should change to "· recent"). Root + fifth per row; row 0 marked "now". This keeps all three instruments honest and multi-chord.
|
||||||
|
|
||||||
|
### 1.6 Rail fit (unchanged geometry)
|
||||||
|
|
||||||
|
The rail column is 500px with the D-51 margin-hardened interior (~451px worst-case; GlanceRail.jsx:5-13). `HistoryRow` reuses the exact `VoicingBrowser dense max={4}` cell math, so guitar 4-across (374 ≤ 451) and the piano 2×2 pair (442 ≤ 451) fits are **byte-for-byte the loop rail's** — no new fit risk. Up to 6 rows stack vertically inside the column's existing `overflow-y-auto` scroller (JamGuide.jsx:493-497); the user owns the scrollbar (no auto-scroll — GlanceRail.jsx:46-49's law holds).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Try this — max 3, stable 3-slot layout, ≥3 ways to play each
|
||||||
|
|
||||||
|
### 2.1 The reshape: from cards-across to rows-of-shapes
|
||||||
|
|
||||||
|
Today `TryThis` (TryThis.jsx:171-189) lays subs **side by side** (columns = subs), each `SubCard` carrying **one** diagram. D-76 turns this **90°**: **rows = subs, columns = shapes.** Each sub becomes a **horizontal row**: an identity block (chord chip + tag + why) on the left, and its **≥3 ways to play it** on the right. Three sub-rows stacked = the "3×3" the user pictured (3 subs × 3 guitar shapes).
|
||||||
|
|
||||||
|
Cap: `subject.subs.slice(0, 3)` (was up to 4 — TryThis.jsx:160 `subs`). The engine (`suggestSubstitutions`, L-73) and `pickSubject`/`subsForChord`/`parseChordName` are **unchanged** — reactivity preserved: subject still follows `currentChord`, subs recompute per chord (TryThis.jsx:79-90, 152-160). Only the render and the cap change.
|
||||||
|
|
||||||
|
### 2.2 The STABLE fixed 3-slot layout (the anti-jump)
|
||||||
|
|
||||||
|
**Always render exactly 3 slots**, in order:
|
||||||
|
|
||||||
|
```
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{[0,1,2].map(i => subs[i] ? <SubRow sub={subs[i]} …/> : <EmptySlot key={i}/>)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- A present sub → `SubRow`. An absent one → `EmptySlot`: a **subtle placeholder** that holds the **exact SubRow height** for the current instrument — `rounded-lg border border-dashed border-border/50 bg-transparent` at low opacity, a centred muted `—` (`text-gray-600 text-[11px]`), `aria-hidden`. It reads as "an intentionally empty slot," not as broken, and — critically — the first two rows **never move** whether the chord yields 2 or 3 subs. This is the exact complaint the user raised ("annoying when the layout changes then u dont know where to look"): with fixed slots, sub #1 and sub #2 are always in the same pixel band.
|
||||||
|
- **Uniform row height per instrument** (not across instruments — the global `instrument` is fixed at any moment, so all 3 slots share one instrument's row height): guitar `min-h-[100px]`, piano `min-h-[72px]`, bass `min-h-[64px]` (§3 math). `EmptySlot` inherits the same `min-h-*`.
|
||||||
|
|
||||||
|
Why 3 slots and not "as many as fire": stability is worth a little reserved whitespace. The user explicitly chose predictable position over density here.
|
||||||
|
|
||||||
|
### 2.3 SubRow — guitar (the 3×3), piano (1), bass (caption)
|
||||||
|
|
||||||
|
Outer row: `flex items-center gap-3 rounded-lg border border-border bg-border/30 p-2 min-h-[…]` (reuses the SubCard token palette — TryThis.jsx:126).
|
||||||
|
|
||||||
|
**Left identity block** (`flex flex-col gap-1 shrink-0 w-[180px]`), all instruments:
|
||||||
|
1. **Chord chip** — the tappable control, reused verbatim from TryThis.jsx:127-134: `button → onChordClick?.(sub.label)`, `rounded-lg border border-border bg-border px-2 py-1 text-sm font-bold text-gray-100 hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent`, `aria-label={`${sub.label} — ${sub.why}`}`. Tap → `ChordDetailModal`. Inline beside it: the **category tag** (`relative`/`borrowed`/`colour`/`V7`) + the `↻` accent glyph for circle categories only (`CIRCLE_CATEGORIES`, TryThis.jsx:48-55 — unchanged rule).
|
||||||
|
2. **Why** — `sub.why`, `text-[11px] leading-snug text-gray-400`, `line-clamp-2` (the identity block is ~180px wide; 2 lines ≈ 50 chars; full text stays on the chip `aria-label` + tap→modal). Wider than the old 152px card, so whys clip less.
|
||||||
|
|
||||||
|
**Right "ways to play" block**, by instrument:
|
||||||
|
|
||||||
|
| instrument | right block |
|
||||||
|
|---|---|
|
||||||
|
| **guitar** | `flex gap-2` of **up to 3** `ChordDiagram size="thumb"` (~75px), from `getGuitarVoicings(sub.label).slice(0, 3)`. Each thumb captioned underneath with its shape name (`text-[9px] text-gray-500` — e.g. "E Barre", "A Barre / 5fr") so the three read as **three genuinely different grips**, not a repeat. **Omit** ChordDiagram's own `label` (the chip names the chord). Fewer than 3 shapes exist (dim/aug, §2.4) → show what exists; the row does not pad with blanks (the shapes left-align, the row height is fixed by `min-h`). |
|
||||||
|
| **piano** | **one** `MiniPiano size="mini"` (per the user: "for piano it can be just one thats okay"): `<MiniPiano voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }} size="mini" />`. Spread `rootPc` back in (the VoicingBrowser.jsx:317-319 caveat) so "R" badges correctly. |
|
||||||
|
| **bass** | no diagram — `root · {NOTES[sub.rootPc]}` caption (`text-[10px] text-gray-500`), the same honesty call TryThis/BassGuideRows already make (there is no compact bass-chord renderer). |
|
||||||
|
|
||||||
|
All diagrams use the established tiers baked into `ChordDiagram`/`MiniPiano` (accent-purple root, gray/light-purple other tones) — **no new colour, no token change.**
|
||||||
|
|
||||||
|
### 2.4 Honest empties
|
||||||
|
|
||||||
|
- **Fewer than 3 subs** for the chord → the missing slot(s) render `EmptySlot` (§2.2). Position of the present subs is unchanged.
|
||||||
|
- **A guitar sub with fewer than 3 shapes**: `getGuitarVoicings` returns all placeable shapes — barre forms are always placeable (any root ≤ fret 15). The engine only ever emits **{min, maj, dom7, maj7, add9, maj6, min7, sus4}** (every `mk()` call in theory.js:801-917 — dim/aug are **never** substitution candidates, so they can't appear here). Of those, **maj/min/dom7/maj7/min7/sus4/maj6** carry ≥3 barre shapes → the full 3×3 (voicings.js:10-53, 76-105). The genuine 2-shape case is **add9**: `getGuitarVoicings` returns only 2 barre shapes (E Barre, A Shape) for **9 of 12 roots**, and 3 only for **C/G/D** (which add an open Cadd9/Gadd9/Dadd9, voicings.js:116-121) → that sub-row honestly shows **2** thumbs, `slice(0,3)` simply yielding two. And add9 is **routinely** emitted — Rule C picks add9 for a major-family chord whose maj7 added tone is non-diatonic (e.g. in D major, the V chord A → **Aadd9**), which is common on major-family V-type chords. So a 2-thumb guitar row is a **regular, honest occurrence**, not a rarity; it never crashes and never pads with fakes.
|
||||||
|
- **No subject** (no key / no loop / atonal) → `pickSubject` returns null → render nothing (TryThis.jsx:157, unchanged).
|
||||||
|
|
||||||
|
### 2.5 Reactivity preserved
|
||||||
|
|
||||||
|
`pickSubject(loopArr, keyInfo, currentChord)` still chooses the live chord first, then the first loop station that yields subs (TryThis.jsx:79-90). As the progression evolves the subject and its (now ≤3) subs recompute every commit — the user confirmed they want it to evolve with the playing. The only behavioural deltas: **cap 3** and **3 shapes each**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Space math — honest
|
||||||
|
|
||||||
|
**Left column** `flex-1 min-w-0` ≈ **744px**, section `p-3` → interior **≈ 720px** (one-screen.md §1; carried from D-75 §3). Footprints from source geometry: `ChordDiagram size="thumb"` = **75×72px** (padL 14 + gridW 55 + padR 6; padT 11 + gridH 55 + padB 6). `MiniPiano size="mini"` = up to **199×38px** (C-anchored window, D-75 §3 — width varies with root, **height is octave-independent 38.4px**).
|
||||||
|
|
||||||
|
**Guitar SubRow.** Right block: 3 thumbs = `3·75 + 2·8 = 241px` (+ 9px shape captions → ~84px tall). Left identity 180px + gaps ≈ `180 + 12 + 241 = 433 ≤ 720` — comfortable, ~287px to spare. Row height = max(thumb 72 + caption 12 = 84, chip 22 + why-2-lines 30 = 52) + `p-2` 16 ≈ **~100px** → `min-h-[100px]`. **3 slots** = `3·100 + 2·8 = 316`; block (+ header 20 + `p-3` 24) ≈ **~360px** — fixed, 2 subs or 3.
|
||||||
|
|
||||||
|
**Piano SubRow.** Right block: one `mini` keyboard ≤199px wide, 38px tall. `180 + 12 + 199 = 391 ≤ 720`. Row = max(38, 52) + 16 ≈ **~72px** → `min-h-[72px]`. **3 slots** = `3·72 + 16` ≈ 232; block ≈ **~276px**.
|
||||||
|
|
||||||
|
**Bass SubRow.** No diagram. Row = 52 + 16 ≈ **~64px** → `min-h-[64px]`. 3 slots ≈ 208; block ≈ **~250px**.
|
||||||
|
|
||||||
|
Note the 3×3 guitar block (~360px) is **taller** than D-75's 4-across single row (~221px) — the honest cost of "at least 3 ways to play each," which the user asked for directly.
|
||||||
|
|
||||||
|
### One-screen budget @ 1280×900 (usable box ≈ 836px, one-screen.md §4)
|
||||||
|
|
||||||
|
Left jam-view stack = instrument view + licks strip + `relatedSlot` (`flex-1 min-h-0 overflow-y-auto` absorber = **new** Try-this + RelatedProgressions 2×2 from L-76); gaps = 3·`gap-3` = 36px.
|
||||||
|
|
||||||
|
| instrument | instrument | licks | try-this (new) | related 2×2 | +gaps | **total** | vs 836 |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| **guitar** | 240 | 190 | ~360 (3×3, 3 slots) | ~272 | 36 | **~1098** | overflow **~262px** |
|
||||||
|
| **piano** | 240 | 190 | ~276 (1 kbd, 3 slots) | ~272 | 36 | **~1014** | overflow **~178px** |
|
||||||
|
| **bass** | 240 | 190 | ~250 (caption, 3 slots) | ~272 | 36 | **~988** | overflow **~152px** |
|
||||||
|
|
||||||
|
**Scroll story (honest):** the instrument view and licks strip stay **pinned** (they are `xl:shrink-0`, JamGuide.jsx:476-481); only the `relatedSlot` — the `flex-1 min-h-0 overflow-y-auto` absorber (JamGuide.jsx:485-489) — scrolls. So the taller 3×3 try-this deepens the fold in the *related area only*: guitar wants a ~262px flick to reach the bottom of the RelatedProgressions grid, piano ~178px, bass ~152px. This is ~139px more than D-75's guitar fold (~123px) — the direct, user-requested trade for 3 grips per sub. Instrument + licks never move; the active chord and the Try-this subjects are always above the fold. At **1280×1080+** guitar clears; piano/bass clear at ~1100. The `overflow-y-auto` absorber makes the fit exact by construction — the residue lands only in the related area, never on the instrument/licks the player watches.
|
||||||
|
|
||||||
|
*(The right rail is independent: `HistoryRail`'s ≤6 rows live in the 500px column's own `overflow-y-auto` scroller — they do not affect the left-column budget.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Bounded scopes (file-disjoint → parallelisable)
|
||||||
|
|
||||||
|
- **L-77 — the voicings rail.** Files: **`src/components/GlanceRail.jsx`** (add `HistoryRow` + exported `HistoryRail`, reusing the `VoicingBrowser dense max={4}` gallery + `SoloLabel`/`AimDots`; `StationRow`/`GlanceRail` for the loop stay byte-identical) **+ `src/components/JamGuide.jsx`** (a `recentDistinct(chordHistory)` memo; the no-loop `railContent` branch → `HistoryRail` for guitar/piano and `BassGuideRows` fed the recent-distinct stations with approach suppressed for bass; the single-`VoicingBrowser` fallback is retired). **No App change** (`chordHistory` already flows in, JamGuide.jsx:186 / App.jsx:846). No `theory.js`/`voicings.js`/`piano.js`/`tailwind.config.js` change (resolvers/tokens consumed as-is).
|
||||||
|
- **L-78 — Try this.** File: **`src/components/TryThis.jsx` only.** Cap `subs.slice(0,3)`; replace the cards-across body with the 3-slot rows-of-shapes (§2); keep `parseChordName`/`subsForChord`/`pickSubject`. **No App change** (`instrument` already threaded by L-75, App.jsx:864). No engine change (§2.5).
|
||||||
|
|
||||||
|
**Disjoint:** L-77 = {GlanceRail.jsx, JamGuide.jsx}; L-78 = {TryThis.jsx}. **Zero file overlap** → they can run in parallel (worktree) or serial, in any order. Neither touches `theory.js`, `voicings.js`, `piano.js`, `App.jsx`, or `tailwind.config.js`. C-70's `VoicingBrowser.jsx` aria fold is disjoint from both.
|
||||||
|
|
||||||
|
*(If a future revision wants the history rail and the loop rail to share one component body, that unification is out of D-76 scope — it would re-touch `StationRow` and risk the loop path; keep it a separate task.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. What does NOT change
|
||||||
|
|
||||||
|
- The engine `suggestSubstitutions` and its rules/why-copy (L-73) — untouched; only the display cap (4→3) and per-sub shape count change.
|
||||||
|
- The loop rail (`GlanceRail`/`StationRow`): canonical order, playhead, voice-leading wheel, focus→fretboard — all byte-identical.
|
||||||
|
- `VoicingBrowser`, `ChordDiagram`, `MiniPiano`, `pianoVoicing`, `getGuitarVoicings` — consumed as-is.
|
||||||
|
- Tokens: `bg-panel`, `bg-surface`, `border-border`, `text-accent`/`bg-accent`, the accent ring, the established note-colour tiers. **No new colour, no `tailwind.config.js` edit.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rejected alternatives (≥2)
|
||||||
|
|
||||||
|
1. **Reorder the LOOP rail most-recent-first (physically re-sequence the rows).** Rejected: `GlanceRail` builds `voiceLeadingPairs` between *canonically adjacent* stations and shows per-row "next F→E · ½ step down" + a "loop" wrap chip (GlanceRail.jsx:118-132, 234-241). Reordering makes every transition chip point at the wrong neighbour and the wrap chip lie — it trades away the loop's whole voice-leading education for a re-sort the playhead highlight already conveys. The current chord is unmistakable *in place* via the "now" ring.
|
||||||
|
2. **Fully unify: always drive the rail from recent-distinct-history, loop merely overlays highlighting.** Rejected: same casualty — the transition/wrap education is canonical-order-only, and a history-ordered rail can't carry it. Unifying is a larger refactor that *reduces* the loop's teaching value. Two paths sharing one row idiom is smaller and loses nothing.
|
||||||
|
3. **Keep Try-this side-by-side (columns = subs) and just add 2 more diagrams per card.** Rejected: 3 subs × 3 guitar thumbs across = `3·(3·75) ≈ 675px` of diagrams fighting for a 720px row with no room for chips/whys, and it re-introduces the 2-vs-3 horizontal jump the user hates (cards grow/reflow). Rows = subs, columns = shapes, fixed 3 slots is the stable-position answer.
|
||||||
|
4. **Let the number of Try-this slots follow the sub count (render 2 rows when 2 subs).** Rejected: that is exactly the "layout changes, you don't know where to look" jump the user called out. Reserving 3 slots with a subtle placeholder costs a little whitespace and buys constant position — the user's explicit priority.
|
||||||
|
5. **Shrink guitar thumbs to force less height / cram the 3×3.** Rejected: 75px is the established `ChordDiagram thumb` size used everywhere (rail, KC, modal); shrinking it below the shared size fragments the visual language and hurts readability mid-jam. The honest cost of 3 grips per sub is ~140px more fold in the *related area only* (§3) — pinned instrument/licks are unaffected, and the user asked for the 3 grips knowing it takes room.
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# Related progressions — same-style-first (D-72)
|
||||||
|
|
||||||
|
Concept doc for **L-72**. Design-only; no code here. Scope is one bounded edit to
|
||||||
|
`src/components/RelatedProgressions.jsx` (match.js untouched — see §6).
|
||||||
|
|
||||||
|
## The user's ask (2026-07-13, verbatim intent)
|
||||||
|
|
||||||
|
> "for the related progressions this is good also, but i would also want
|
||||||
|
> bridge/chorus/modifications in the same style … say i select jam roulette with
|
||||||
|
> blues, then i want for that progression other options and not necessarily go
|
||||||
|
> into other styles."
|
||||||
|
|
||||||
|
**Chosen scope:** reuse the KB's *existing* same-style progressions. No computed
|
||||||
|
modifications, no new authored content, no new KB fields. When a style is active
|
||||||
|
(rolled via Jam Roulette **or** live-detected), the panel leads with the OTHER
|
||||||
|
progressions of that same style, reframed as variations/sections to try — instead
|
||||||
|
of jumping to other styles.
|
||||||
|
|
||||||
|
The component today already computes a match, ranks every *other* KB progression
|
||||||
|
by musical proximity, and prints a flat cross-style list. This doc changes only
|
||||||
|
**how the list is partitioned, floored, ordered, and labelled** once a style is
|
||||||
|
known. When no style is known, behaviour is unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. How the active style is known — reuse the component's own match
|
||||||
|
|
||||||
|
`rankRelatedProgressions(loop)` already calls
|
||||||
|
`matchLoopToProgression(loop, index)`, whose result is
|
||||||
|
`{ matched, id, style, rotation, progression }`. **`match.style` IS the active
|
||||||
|
style** — for both entry points:
|
||||||
|
|
||||||
|
- **Jam Roulette:** `rollJam` seeds `detectedProgression = seedableLoop(prog, key)`
|
||||||
|
(the collapsed canonical form). The L-60 collapsed-form index makes the
|
||||||
|
component re-match that loop back to the rolled progression → `match.style ==
|
||||||
|
the rolled style`, `match.id == the rolled progression's id`. That id is already
|
||||||
|
excluded from `entries` (the `prog.id === match.id` guard), so the surviving
|
||||||
|
same-style progressions are exactly "other options for the style I rolled."
|
||||||
|
- **Live detection:** the detected repeating loop is what App passes as
|
||||||
|
`loop={detectedProgression}`; the same match resolves the live style/id.
|
||||||
|
|
||||||
|
**Recommendation — use the internally-computed `match.style`; do NOT add a prop.**
|
||||||
|
Naming it explicitly: inside `rankRelatedProgressions`, after the existing
|
||||||
|
`const match = matchLoopToProgression(...)`, take
|
||||||
|
|
||||||
|
```
|
||||||
|
const activeStyle = match.matched ? match.style : null
|
||||||
|
```
|
||||||
|
|
||||||
|
Rejected: threading a `matchedStyle` prop down from App (roulette knows it via
|
||||||
|
`lastRolledStyleRef`; live detection could expose its own match). It duplicates
|
||||||
|
state that the component already derives identically from the same `loop`, and
|
||||||
|
introduces a divergence risk (App's match vs the component's match drifting).
|
||||||
|
The single source of truth is the loop → its match. Keep it in one place.
|
||||||
|
|
||||||
|
**No-match fallback.** When `match.matched === false` (the loop matches no KB
|
||||||
|
progression — an off-book jam), `activeStyle` is `null`; the panel renders exactly
|
||||||
|
today's cross-style list (flat, floor `RELATED_SCORE_FLOOR`, existing
|
||||||
|
annotations). Nothing about the current behaviour changes when no style is locked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Same-style-first presentation — two sections
|
||||||
|
|
||||||
|
When `activeStyle != null`, partition the scored candidates by
|
||||||
|
`entry.style === activeStyle`:
|
||||||
|
|
||||||
|
**Primary — "Try these in {styleLabel}"** (same-style siblings).
|
||||||
|
All same-style progressions except the one being played, in scorer order (§4),
|
||||||
|
capped at `RELATED_MAX_ENTRIES` (5). The score floor is **relaxed to 0 for this
|
||||||
|
section** — a sibling of your own style is never "junk"; it is exactly the "other
|
||||||
|
options" the user asked for. Styles hold ≤7 progressions, so this shows all of
|
||||||
|
them (blues → 4 siblings).
|
||||||
|
|
||||||
|
**Secondary — "Same changes, other styles"** (cross-style), demoted, small.
|
||||||
|
Only progressions whose canonical changes are *identical* to the loop
|
||||||
|
(`sameChanges === true`), capped at **2**, floor kept. This preserves a genuinely
|
||||||
|
valuable, rare relative — "this exact turnaround also lives in jazz and gospel" —
|
||||||
|
without "going into other styles" for merely-similar material. If none qualify,
|
||||||
|
the section is omitted entirely.
|
||||||
|
|
||||||
|
**Recommendation: ship both sections (option b).** It honours "not necessarily go
|
||||||
|
into other styles" (same-style leads and dominates the panel) while not hiding an
|
||||||
|
exact-match cousin elsewhere. Dropping the secondary later is a one-line change
|
||||||
|
(don't render it) if the user wants pure same-style — noted as the toggle.
|
||||||
|
|
||||||
|
Rejected: a hard same-style-only filter that *never* shows cross-style. It throws
|
||||||
|
away the exact-match cousin (musically the most useful cross-style pointer we
|
||||||
|
have) and would also have to special-case the no-match path. Kept only as the
|
||||||
|
one-line fallback if the user insists on zero cross-style.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The reframe — "bridge/chorus/modifications" with NO new content
|
||||||
|
|
||||||
|
Same-style siblings must read as *sections/variations to try*, not a flat list.
|
||||||
|
We label each with a short **role phrase** derived only from data already in the
|
||||||
|
KB — comparing the sibling to the active (matched) progression. Fields used:
|
||||||
|
`mode`, `bars` (summed = the form length), `qualities` (the colour set),
|
||||||
|
`name`, `level`. No new fields.
|
||||||
|
|
||||||
|
`siblingRole(sibling, active)` → a short phrase or `null`, first rule that fires:
|
||||||
|
|
||||||
|
1. **mode differs** → `"{mode} version"` — minor→`"minor version"`,
|
||||||
|
major→`"major version"`, else the mode name (`"dorian version"`, …).
|
||||||
|
2. **same mode, fewer total bars** → `"shorter form"`.
|
||||||
|
3. **same mode, more total bars** → `"extended form"`.
|
||||||
|
4. **same mode & length, a quality the active lacks** → `"reharmonized"`.
|
||||||
|
5. **otherwise** → `null` (honest: just the name + level badge, no role line).
|
||||||
|
|
||||||
|
The active progression's `mode`/`bars` come from looking the raw KB entry up by
|
||||||
|
`match.id` in `kbRegistry[activeStyle].progressions` (avoids any collapsed-
|
||||||
|
projection subtlety; the raw entry is authoritative).
|
||||||
|
|
||||||
|
### Concrete — active = blues **Standard 12-bar** (major, 12 bars)
|
||||||
|
|
||||||
|
| sibling | mode | bars | role phrase | reads as |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Quick-change 12-bar | major | 12 | `null` | name + `foundation` (name already says "quick-change") |
|
||||||
|
| 8-bar blues | major | 8 | **shorter form** | "the compact take" |
|
||||||
|
| Minor blues | minor | 12 | **minor version** | "the minor cousin" |
|
||||||
|
| Turnaround cycle | major | 4 | **shorter form** | name already says "Turnaround cycle" |
|
||||||
|
|
||||||
|
Every phrase is honest and re-derivable from `mode`/`bars`/`qualities`. Where no
|
||||||
|
character is derivable (Quick-change: same mode, same length, same all-dom7 colour
|
||||||
|
set) we print **nothing** beyond the name and level badge — the name carries it.
|
||||||
|
The two "shorter form"s are fine: their *names* (`8-bar blues`, `Turnaround
|
||||||
|
cycle`) already distinguish them, and the scorer orders them by proximity (§4).
|
||||||
|
|
||||||
|
This is where the "bridge/chorus/modification" feel comes from: the KB already
|
||||||
|
holds the minor version, the short form, the turnaround, the quick-change — we are
|
||||||
|
just *reframing existing siblings* with a one-line role, not synthesising sections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Ranking within same-style — keep the scorer order
|
||||||
|
|
||||||
|
The existing scorer still runs over every candidate. Within the same-style group
|
||||||
|
the `+40 SCORE_SAME_STYLE` term is constant, so it cancels — ordering is driven by
|
||||||
|
`same changes (+100)` → `shared transitions` → `Jaccard` → `− length`, i.e.
|
||||||
|
**musical proximity to the loop you're playing.** That is more useful mid-jam than
|
||||||
|
alphabetical, so:
|
||||||
|
|
||||||
|
**Recommendation: keep the scorer sort within same-style. Do NOT re-sort by
|
||||||
|
level/name.** The closest variation to what your hands are already doing surfaces
|
||||||
|
first. The only change is relaxing the floor to 0 for this section (§2) so no
|
||||||
|
sibling is silently dropped for being "only" a distant relative — the user
|
||||||
|
explicitly wants *all* the style's options.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Edge / empty states
|
||||||
|
|
||||||
|
- **Style with only 1 progression** (0 same-style siblings after excluding the
|
||||||
|
played one). No current style hits this (all have ≥5), but handle it: render an
|
||||||
|
honest primary line — *"You're on the only {styleLabel} loop in the songbook."* —
|
||||||
|
then fall through to the cross-style secondary (kept at floor). Never pad.
|
||||||
|
- **Secondary empty** (no exact cross-style cousin) → omit the secondary section
|
||||||
|
silently; the primary stands alone.
|
||||||
|
- **No match at all** (`activeStyle == null`) → today's single flat list, unchanged.
|
||||||
|
- **Instrument-agnostic — confirmed.** `RelatedProgressions` reads only
|
||||||
|
`progressions` (`degrees`/`qualities`/`rn`/`level`/`name`/`mode`/`bars`), never
|
||||||
|
the `instruments` cells. It behaves identically under guitar / piano / bass; the
|
||||||
|
global instrument selector does not touch it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. L-72 change list (RelatedProgressions.jsx only)
|
||||||
|
|
||||||
|
`match.js` needs **no change** — `matchLoopToProgression` already returns
|
||||||
|
`{ style, id, progression }`. Reuse it.
|
||||||
|
|
||||||
|
In `rankRelatedProgressions`:
|
||||||
|
1. After computing `match`, derive `const activeStyle = match.matched ? match.style
|
||||||
|
: null` and look up the raw active progression (`kbRegistry[activeStyle]
|
||||||
|
?.progressions.find(p => p.id === match.id)`) for its `mode` + summed `bars`.
|
||||||
|
2. Keep the existing scoring loop. Change the floor test: skip the
|
||||||
|
`score < RELATED_SCORE_FLOOR → continue` **only when** `style === activeStyle`
|
||||||
|
(same-style siblings bypass the floor); cross-style keeps the floor.
|
||||||
|
3. Add a `siblingRole(sibling, active)` helper (§3) and attach `role` to each
|
||||||
|
same-style entry; leave cross-style entries' existing `annotation` intact.
|
||||||
|
4. Partition the sorted entries into `primary` (`style === activeStyle`, cap 5)
|
||||||
|
and `secondary` (`style !== activeStyle && sameChanges`, cap 2). Return
|
||||||
|
`{ match, activeStyle, activeStyleLabel, primary, secondary }`. Keep
|
||||||
|
`entries` (= `primary.concat(secondary)`) on the return so any existing
|
||||||
|
`entries[0]` / `entries.length` reads still resolve during the transition.
|
||||||
|
When `activeStyle == null`, return today's shape (`primary = entries`,
|
||||||
|
`secondary = []`) so the render path collapses to the current flat list.
|
||||||
|
|
||||||
|
In the render:
|
||||||
|
5. Two `<section>`-internal blocks: primary headed *"Try these in
|
||||||
|
{activeStyleLabel}"*, secondary headed *"Same changes, elsewhere"* (rendered
|
||||||
|
only when non-empty). Same-style rows swap the `annotation` line for the `role`
|
||||||
|
phrase (omit the line when `role == null`). Reuse the existing `LevelBadge` /
|
||||||
|
`ChordChain` — no new tokens, no new colours (all within `bg-panel` /
|
||||||
|
`border-border` / `text-gray-*` / `text-amber`, already in use).
|
||||||
|
|
||||||
|
**Smoke pins (C-50, `scripts/smoke.mjs` §8) will shift — re-derive, coordinate
|
||||||
|
with C-70:**
|
||||||
|
- Pin (a) collapsed 12-bar in A: top stays **blues/blues-8bar, score 92**
|
||||||
|
(blues is now the active style; 8-bar is a same-style sibling; the +40 is still
|
||||||
|
in its score, order unchanged). Its **annotation label changes** from
|
||||||
|
`'shares I7→V7'` to the role `'shorter form'` — the assertion must be re-pinned.
|
||||||
|
- Pin (b) ii–V–I in C: top stays **jazz/jazz-251-minor, score 156** (same-style,
|
||||||
|
same changes). Its label changes from `'same changes'` to the role
|
||||||
|
`'minor version'` (mode differs) — re-pin.
|
||||||
|
- Scores and top ids are stable; only the annotation strings and the return shape
|
||||||
|
move. Add coverage for the primary/secondary split and the no-match fallback.
|
||||||
|
|
||||||
|
### Rejected alternatives (recap)
|
||||||
|
- **Computed modifications** (synthesise a bridge/chorus by transposing or
|
||||||
|
reharmonising) — rejected per the user's explicit choice to reuse existing KB
|
||||||
|
content; also risks inventing non-idiomatic changes we can't vouch for.
|
||||||
|
- **Hard same-style-only, no cross-style ever** — rejected: loses the exact-match
|
||||||
|
cousin in another style and complicates the no-match path. Retained only as the
|
||||||
|
one-line "drop the secondary" toggle if the user later wants zero cross-style.
|
||||||
|
- **An explicit `matchedStyle` prop from App** — rejected as redundant (§1): the
|
||||||
|
component derives the same style from the same loop; a prop only adds a
|
||||||
|
divergence surface.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# "Try this" — chord-substitution nudge (task D-73)
|
||||||
|
|
||||||
|
**Sprint:** `sprint-dashboard-polish` · **Owner:** Muse · **Impl tasks:** L-73 (engine, `theory.js`) + L-74 (UI, new `TryThis.jsx`)
|
||||||
|
|
||||||
|
**User ask (2026-07-13, verbatim):** *"imagine we play a simple Am C F then i'd like to have an alternative to that F that would be in the similar style … potentially based on the circle of fifths? i'd like an option that says: 'try this' … i want musicians to learn how they can make the jam more interesting."* Plus the guard-rail: *"we dont have to create something too difficult."*
|
||||||
|
|
||||||
|
So this is a **small, curated, learnable nudge** — not a reharmonisation engine. For the chord under the playhead, in the detected key, show 3–4 alternative chords, each with one plain sentence that *teaches why it works*. Tap a suggestion → the existing `ChordDetailModal` to study it.
|
||||||
|
|
||||||
|
> **Not** the existing `getChordSubstitutions` (education.js). That returns **context-free, same-root colour swaps** (`maj → maj7, add9, maj6…`) and never looks at the key. The new engine is **key-aware** and, crucially, **changes the root** (relative sub, secondary dominant) with a *why* framed against the live key. They coexist; the modal keeps its colour-swap grid, the dashboard gets the new nudge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The reading model — which key/mode the engine trusts
|
||||||
|
|
||||||
|
**Recommendation: take the app's `effectiveKey` (`lockedKey ?? keyInfo`) as the single reading. Do not compute both readings at once.**
|
||||||
|
|
||||||
|
- `effectiveKey` is `{ root, mode, confidence }`. The **mode disambiguates the i-vs-vi ambiguity** that makes `Am C F` read two ways: if the app committed **A minor**, `F` is `♭VI`; if **C major**, `F` is `IV`. The engine frames the *why* against whichever one is live, and gates the moves that need a specific reading (borrowed `iv` needs a major reading — see Rule B).
|
||||||
|
- The user can already flip the mode in the key dropdown (the app's intended workflow, per project memory — e.g. A minor → A Dorian). When they do, the *why* copy and the applicable moves change with it. That is the honest way to "see the other framing" — one reading on screen at a time, driven by the user's own mode choice.
|
||||||
|
- **Edge — no key / atonal / low confidence:** `suggestSubstitutions` returns `[]` when `!keyInfo?.root`. `TryThis` then renders a quiet idle line ("Lock a key to see substitutions") — never a fabricated suggestion. This mirrors `RelatedProgressions`/`CircleOfFifths` idle states.
|
||||||
|
|
||||||
|
The worked examples in §4 show **both** readings only so the gate can verify the arithmetic under each; at runtime exactly one is shown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The curated set — 4 categories, ranked softest → boldest
|
||||||
|
|
||||||
|
All rules operate on `{ rootPc, quality }` (a pitch class 0–11 + a `CHORD_TYPES` key) — the same shape the KB/JamGuide stations already use — and read `keyInfo {root, mode}`. `keyRootPc = noteIndex(keyInfo.root)` (theory.js's own in-module note-name→pc helper, line 133 — **not** match.js's `chordRootPC`, which would make theory.js import from a module that imports it back = circular). Candidates are returned as `{ rootPc, quality, label, why, category }` where `label = NOTES[rootPc] + CHORD_TYPES[quality].suffix`. **Cap the output at 4**, in the order below (softest first, so the glance reads top-down by boldness).
|
||||||
|
|
||||||
|
Notation: pc arithmetic is mod 12. `NOTES = [C,C#,D,D#,E,F,F#,G,G#,A,A#,B]` (C=0 … B=11).
|
||||||
|
|
||||||
|
### A. Relative / diatonic-third sub — *the softest, most universal*
|
||||||
|
Swap a chord for the diatonic chord a third away that **shares two of three tones**.
|
||||||
|
- **major-family chord** (`maj, maj7, maj6, add9`): candidate = `{ (rootPc + 9) % 12, 'min' }` — the **relative minor** (a minor 3rd below). *Shared tones:* the original root and 3rd become the relative's 3rd and 5th.
|
||||||
|
- **minor-family chord** (`min, min7, min6`): candidate = `{ (rootPc + 3) % 12, 'maj' }` — the **relative major** (a minor 3rd above).
|
||||||
|
- **Gate:** emit only if the candidate is **diatonic in `keyInfo`** (`getChordsInKey(root,mode)` contains it). This keeps the swap "safe/soft" and never forces an out-of-key relative. (Non-diatonic relatives are out of MVP scope.)
|
||||||
|
- **Circle tie-in: yes** — the relative minor/major is the circle's *inner ring* (see `CircleOfFifths.jsx`). The *why* may say so.
|
||||||
|
- **Why template:** `"{cand} is {orig}'s relative {minor|major} — shares {t1} & {t2}. In this key it's the {rn(cand)}: {softer|brighter} pull, same family."`
|
||||||
|
|
||||||
|
### B. Borrowed minor colour — *the "blue"/"Creep" move* (conditional)
|
||||||
|
Major `IV → iv` (same root, major → minor) — lowers the 6th of the key to the ♭6.
|
||||||
|
- **Gate (strict, honest):** emit **only** when `keyInfo.mode` is **major-ish** AND the chord is the **IV** (`rootPc === (keyRootPc + 5) % 12`) AND quality is major-family. Under a **minor reading it is suppressed** (in A minor, `F` is a diatonic major `♭VI`; `Fm` would be a chromatic `♭vi` with no honest function — we do not fake it).
|
||||||
|
- Candidate = `{ rootPc, 'min' }`.
|
||||||
|
- **Circle tie-in: no** — this is a modal borrowing, not a circle step. The *why* must not claim the circle.
|
||||||
|
- **Why template:** `"Borrow {cand} (the iv) from the parallel minor — {n6}→{nb6} adds that wistful pull home. The 'Creep' move."` where `nb6 = noteName((keyRootPc + 8) % 12, /*preferFlat*/true)` (the ♭6). **Spell it flat** — this is a flatward modal borrow (A→A♭), never the sharp `NOTES[8]='G#'`. (The *ascending* leading tone in Rule D stays sharp — see §3.)
|
||||||
|
|
||||||
|
### C. Extension / colour — *same function, more colour*
|
||||||
|
Keep the root and function; add one diatonically-honest colour tone.
|
||||||
|
- Pick the extension whose **added tone is diatonic** in `keyInfo` (prefer, in order): major-family → `maj7` if `(rootPc+11)` diatonic, else `add9` if `(rootPc+2)` diatonic, else `maj6`; minor-family → `min7` if `(rootPc+10)` diatonic, else `add9`; `dom7` → `sus4` (the 9sus-ish suspension). Same root, so `label` = `NOTES[rootPc] + suffix`.
|
||||||
|
- **Gate:** the chosen added tone must be in `getScale(root,mode)`; if none qualifies, omit category C rather than add a clashing tone.
|
||||||
|
- **Circle tie-in: no** — vertical colour, not a circle step.
|
||||||
|
- **Why template:** `"Add the {intervalName} ({addedNote}) — same {rn}, lusher. {addedNote} is the key's own {degreeWord}, so it stays in the family."`
|
||||||
|
|
||||||
|
### D. Secondary dominant of the next chord — *the circle move*, boldest (conditional)
|
||||||
|
Approach the **next loop chord** by its own `V7` — the circle-of-fifths, dominant-direction pull.
|
||||||
|
- **Gate:** requires `opts.nextRootPc` (the next station's root pc). Candidate = `{ (nextRootPc + 7) % 12, 'dom7' }`. Emit only when a loop/next chord is known and the candidate root ≠ current root.
|
||||||
|
- **Circle tie-in: yes** — the candidate root is **one wedge clockwise from the next chord** on the circle (its dominant). Its 3rd is the **leading tone** into the next root.
|
||||||
|
- **Why template:** `"Swap for {cand}, the V7 of {next} — its 3rd ({leadingTone}) leans a half-step into {next}, pulling the loop around. One step clockwise on the circle."`
|
||||||
|
|
||||||
|
> **Honest circle summary:** A and D **are** circle relationships (inner ring; dominant step) — name the circle in their copy. B and C are **not** — never claim the circle for them. We do **not** require the D-61 circle widget on the dashboard; the *why* copy carries the lesson.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Worked examples — `Am – C – F`, both readings (gate: recompute me)
|
||||||
|
|
||||||
|
Loop wraps `Am → C → F → Am`. Target = **F** = `{ rootPc: 5, quality: 'maj' }`, tones `{F=5, A=9, C=0}`. Next chord after F = **Am** (`nextRootPc = 9`).
|
||||||
|
|
||||||
|
### Reading (i) — **A minor** (`i · III · ♭VI`) → 3 subs (borrowed iv suppressed)
|
||||||
|
`A-minor scale = {9,11,0,2,4,5,7}` = A B C D E F G. `getChordsInKey(A,minor) = [Am, B°, C, Dm, Em, F, G]`.
|
||||||
|
|
||||||
|
| # | Cat | Candidate (pc) | label | Diatonic check | WHY copy |
|
||||||
|
|---|-----|----------------|-------|----------------|----------|
|
||||||
|
| A | relative | (5+9)=**2**, min | **Dm** | Dm ∈ A-min = `iv` ✓ | "Dm is F's relative minor — shares **F & A**. In A minor it's the **iv**: a darker, more grounded step than the bright ♭VI. (Circle: F's inner-ring relative.)" |
|
||||||
|
| B | borrowed | — | — | mode = minor → **suppressed** | *(not shown — F is ♭VI here, not IV; Fm would be chromatic. Honest omission.)* |
|
||||||
|
| C | extension | 5, add 11→**E(4)** | **Fmaj7** | E ∈ A-min (the 5th) ✓ | "Add the major 7th (**E**) — ♭VI becomes Fmaj7, dreamy and floating. E is A minor's own 5th, so it stays in the family." |
|
||||||
|
| D | 2nd-dom | (9+7)=**4**, dom7 | **E7** | leads to Am | "Swap for **E7**, the V7 of Am — its 3rd (**G♯**) leans a half-step into A, pulling the loop back around. One step clockwise on the circle." |
|
||||||
|
|
||||||
|
*Arithmetic:* Dm={2,5,9}∩F{5,9,0}={5,9}=F,A ✓. Fmaj7={5,9,0,4}, all ∈ A-min ✓. E7={4,8,11,2}; G♯=8→A=9 ✓; E is a fifth above A (9+7=4) ✓.
|
||||||
|
|
||||||
|
### Reading (ii) — **C major** (`vi · I · IV`) → 4 subs (cap)
|
||||||
|
`C-major scale = {0,2,4,5,7,9,11}` = C D E F G A B. `getChordsInKey(C,major) = [C, Dm, Em, F, G, Am, B°]`.
|
||||||
|
|
||||||
|
| # | Cat | Candidate (pc) | label | Diatonic check | WHY copy |
|
||||||
|
|---|-----|----------------|-------|----------------|----------|
|
||||||
|
| A | relative | (5+9)=**2**, min | **Dm** | Dm ∈ C-maj = `ii` ✓ | "Dm is F's relative minor — shares **F & A**. In C it's the **ii**: trades IV's brightness for a softer, more forward pull. (Circle: F's inner-ring relative.)" |
|
||||||
|
| B | borrowed | 5, **min** | **Fm** | mode major **and** F = IV (0+5=5) ✓ | "Borrow **Fm** (the iv) from C minor — lowering A to **A♭** adds that wistful 'Creep' pull home. The classic blue move." |
|
||||||
|
| C | extension | 5, add 11→**E(4)** | **Fmaj7** | E ∈ C-maj (the 3rd) ✓ | "Add the major 7th (**E**) — same IV, lusher and static. E is C's own 3rd (the mediant), so it glues the chord to the key." |
|
||||||
|
| D | 2nd-dom | (9+7)=**4**, dom7 | **E7** | leads to Am | "Swap for **E7**, the V7 of Am — its 3rd (**G♯**) leans into A, pulling the loop around. One step clockwise on the circle." |
|
||||||
|
|
||||||
|
*Arithmetic:* Fm={5,8,0}; A(9)→A♭(8) ✓; A♭=8=(0+8)=♭6 of C ✓. Everything else as above.
|
||||||
|
|
||||||
|
**Payoff:** the *same* candidate chord (Dm, Fmaj7, E7) is right under both readings — only its role-name and *why* change with the mode. Borrowed `Fm` appears **only** under the major reading. That is the honesty the feature promises.
|
||||||
|
|
||||||
|
**Bonus — the same rules over the whole loop** (feeds the smoke truth-table): current **Am**→next C ⇒ D = **G7** (V7/C, B→C); current **C**→next F ⇒ D = **C7** (V7/F, E→F — the classic bluesy `I7→IV`). Both musically gold, both from the one rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Which chord gets suggestions — the playhead chord
|
||||||
|
|
||||||
|
**Decision: one `TryThis` card that follows the playhead — subs for the *currently sounding* chord, updated as the loop turns.**
|
||||||
|
|
||||||
|
- Rejected: one static sub for the whole loop (misses the point — the user asked specifically about *F*), and a per-station sub grid across the rail (too dense, collides with the rail — see §6).
|
||||||
|
- Target selection: `currentChord` when present → fall back to the committed loop's active/first station when silent → else idle. `opts.nextRootPc` = the following loop station's root (so Rule D can fire); when there is no loop, D is simply omitted.
|
||||||
|
- Keep it tiny: **≤4 chips, one row.** It is a nudge, not a panel.
|
||||||
|
- *(Optional nicety, not required):* the UI may drop a candidate that is already a loop chord (e.g. relative of Am = C, which is already in `Am C F`) to avoid a redundant suggestion. Engine stays pure; dedup lives in `TryThis`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The UI surface — new `TryThis.jsx`, left column
|
||||||
|
|
||||||
|
A compact card, visually a sibling of `RelatedProgressions`/`CircleOfFifths` (same micro-header + chip language):
|
||||||
|
|
||||||
|
```
|
||||||
|
Try this instead of F · in A minor ← text-[10px] uppercase tracking-widest text-gray-500
|
||||||
|
[ Dm ] relative minor — softer, same family ← chip + one-line why, per row
|
||||||
|
[ Fmaj7 ] add the maj7 (E) — dreamy, in-key
|
||||||
|
[ E7 ] V7 of Am — pulls the loop around ↻
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Chip = tappable** → `onChordClick(label)` = App's `setSelectedChord` → `ChordDetailModal` (the established tap target; reuse verbatim). Each chip is a `<button>`, keyboard-reachable, `focus-visible:ring-2 focus-visible:ring-accent`.
|
||||||
|
- **Layout:** chip on the left (bold, `text-gray-100`), *why* to the right (`text-[11px] text-gray-400`), category label optional as a faint tag. `Category D` gets a small `↻` glyph hinting the circle (no widget dependency).
|
||||||
|
- **Tokens only** (`tailwind.config.js`): `bg-panel`, `border-border`, `text-accent`, `text-gray-100/400/500`, `hover:border-accent/50 hover:text-accent`. **No new colour** — nothing to flag to Maestro. Accent-on-panel meets AA (same usage cleared in D-61).
|
||||||
|
- **Mount — App.jsx only (file-disjoint from the rail/licks/related chains):** compose `TryThis` **above** `RelatedProgressions` inside App's existing `relatedSlot` prop:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
relatedSlot={
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<TryThis
|
||||||
|
chord={currentChord} {/* App parses to {rootPc,quality} */}
|
||||||
|
nextRootPc={/* next station root of the committed loop, or undefined */}
|
||||||
|
keyInfo={effectiveKey}
|
||||||
|
onChordClick={setSelectedChord}
|
||||||
|
/>
|
||||||
|
<RelatedProgressions loop={detectedProgression} keyInfo={effectiveKey} onChordClick={setSelectedChord} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This edits **App.jsx only** (mount) + the new file — it does **not** touch `JamGuide.jsx` (locked by L-71), `GlanceRail/VoicingBrowser/index.css` (L-70), or `RelatedProgressions.jsx` (L-72). The left column already reflows to one-per-row narrow (JamGuide `order-4` wrapper); `TryThis` inherits that. Glanceable in the ≤4-chip footprint at 1280×900 and stacks cleanly narrow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Circle-of-fifths tie-in — honest
|
||||||
|
|
||||||
|
- **Rule A (relative)** and **Rule D (secondary dominant)** ARE circle relationships — the inner ring and the clockwise/dominant step respectively (both visible in `CircleOfFifths.jsx`). Their *why* copy names the circle.
|
||||||
|
- **Rule B (borrowed iv)** and **Rule C (extension)** are **not** circle-adjacent — modal/vertical colour. Their copy must **never** invoke the circle.
|
||||||
|
- We **do not** require the D-61 circle widget on the dashboard (it lives in the Knowledge Center). The lesson travels entirely in the one-line *why*. Optional future polish: a tiny `↻` affordance on circle-derived chips.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Impl scopes (bounded)
|
||||||
|
|
||||||
|
### L-73 — engine (`src/lib/theory.js`, additive; `scripts/smoke.mjs`)
|
||||||
|
- Add `export function suggestSubstitutions({ rootPc, quality }, keyInfo, opts = {})` → `[{ rootPc, quality, label, why, category }]`, categories in order `relative, borrowed, extension, secondary_dominant`, capped at 4, `[]` when `!keyInfo?.root`. Reuse `NOTES`, `NOTES_FLAT`, `noteName`, `noteIndex`, `CHORD_TYPES`, `getChordsInKey`, `getScale`, `getChordTones`, `intervalName`, `toRomanNumeral` — all **in-module** to theory.js (do **not** import `chordRootPC` from match.js — circular). **Never re-derive** intervals or diatonic sets. `opts.nextRootPc` gates Rule D.
|
||||||
|
- **Smoke truth-table** in `smoke.mjs` (sabotage-proven, like the `resolveDegree` guard): pin the §3 tables — `F/maj` in **A minor** → `[Dm, Fmaj7, E7]` (no Fm); in **C major** → `[Dm, Fm, Fmaj7, E7]`; plus `Am/min`→next C ⇒ D=`G7`, `C/maj`→next F ⇒ D=`C7`; and a no-key case → `[]`. Flip one rule constant → smoke must go red.
|
||||||
|
- **⚠ Sequencing flag for Maestro:** L-73 and **L-72 both lock `scripts/smoke.mjs`** — they cannot be claimed concurrently. Serialize them (L-73 appends a new smoke section to minimise merge friction). `theory.js` is the shared Professor+Luthier file → single-task lock + Critic + non-owning-domain review, per PROTOCOL §3.
|
||||||
|
|
||||||
|
### L-74 — UI (`src/components/TryThis.jsx` new; `src/App.jsx` mount)
|
||||||
|
- Pure/presentational component consuming `suggestSubstitutions`; props `{ chord, nextRootPc?, keyInfo, onChordClick }`; renders header + ≤4 chip/why rows; tap → `onChordClick`. Idle line when the engine returns `[]`.
|
||||||
|
- Mount per §5 (App composes it into `relatedSlot` above `RelatedProgressions`). **Files disjoint** from L-70/L-71/L-72; App.jsx is unlocked by all three. Audio contract untouched (mount/UI-state only — the App.jsx grep gate applies).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Rejected alternatives (≥2)
|
||||||
|
|
||||||
|
1. **Full reharmonisation engine** (chord-scale subs, ii–V insertion, Coltrane changes, tritone-on-everything). **Rejected** per the user's explicit *"we dont have to create something too difficult"* + "not a reharm engine." Un-glanceable mid-jam and un-learnable. The curated 4-move set is the whole point.
|
||||||
|
2. **Blanket tritone sub** (`root+6` dom7 on every chord). **Rejected as a general rule:** the tritone sub is only functionally honest on a *dominant resolving down a fifth*; on a static major IV like `F` it yields `B7` — jarring, no diatonic footing, no *why* that teaches. The **secondary-dominant** move (Rule D) captures the same circle-of-fifths energy but is functionally grounded in the *actual next chord*. (Tritone could return later as a dom7-only opt-in.)
|
||||||
|
3. **Per-station subs plastered across the voicings rail.** **Rejected:** clutters the glance-critical rail (owned by L-70), 3–4 chips × N stations is too dense, and it competes with the voicings the user is reading to *play*. One playhead-following card is the nudge.
|
||||||
|
4. **Show both key readings at once.** **Rejected:** doubles the surface, contradicts "keep it small," and the app already commits to one `effectiveKey` (the mode disambiguates). The user flips the mode dropdown to see the other framing — one reading on screen at a time.
|
||||||
|
</content>
|
||||||
|
</invoke>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# KB Expansion Backlog
|
||||||
|
|
||||||
|
The queue for the `/kb-expand` loop. One cell per session, top-to-bottom. Protocol and schema: [`docs/kb-plan.md`](kb-plan.md). Statuses: `todo` → `in-progress` → `done (YYYY-MM-DD, coverage)`.
|
||||||
|
|
||||||
|
> Ground truth (verified 2026-07-10): `node scripts/validate-kb.mjs` → ✓ KB valid — **10 style(s), 56 progressions, 164 plays, 23 licks**. Registered cells in `src/data/kb/index.js`: all 10 guitar; piano for jazz, blues, gospel, rnb.
|
||||||
|
>
|
||||||
|
> **Next cells:** piano column continues (pop, rock, funk, country, reggae, bossa remain) · **bass column is now active** — blues bass is next (ledger task P-41, pending the C-41 bass play schema), order blues → jazz → funk per plan.
|
||||||
|
|
||||||
|
## Phase 0 — Foundation (must be first)
|
||||||
|
|
||||||
|
| # | Cell | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | Bootstrap: `src/data/kb/` + `SCHEMA.md` + `scripts/validate-kb.mjs` + `kb/index.js` + **jazz/guitar gold standard** | done (2026-06-12, iteration 1) |
|
||||||
|
|
||||||
|
## Guitar
|
||||||
|
|
||||||
|
| # | Style | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Jazz (part of bootstrap) | done (2026-06-12, 5 progressions × 2 plays; +2 intermediate progressions 2026-07-08 → 7 × 2, validator ✓) |
|
||||||
|
| 2 | Blues | done (2026-06-12, 5 progressions × 2 plays, validator ✓) |
|
||||||
|
| 3 | Rock | done (2026-06-12, 5 progressions × 2 plays, validator ✓) |
|
||||||
|
| 4 | Bossa Nova | done (2026-06-12, 5 progressions × 2 plays, validator ✓) |
|
||||||
|
| 5 | Funk | done (2026-06-12, 5 progressions × 2 plays, validator ✓) |
|
||||||
|
| 6 | Reggae | done (2026-06-12, 5 progressions × 2 plays, validator ✓) |
|
||||||
|
| 7 | Country / Folk | done (2026-06-12, 5 progressions × 2 plays, validator ✓) |
|
||||||
|
| 8 | R&B / Neo-soul | done (2026-06-12, 5 progressions × 2 plays; +2 intermediate progressions 2026-07-08 → 7 × 2, validator ✓) |
|
||||||
|
| 9 | Gospel | done (2026-06-15, 5 progressions × 2 plays; +2 intermediate progressions 2026-07-08 → 7 × 2, validator ✓) |
|
||||||
|
| 10 | Pop | done (2026-06-15, 5 progressions × 2 plays, validator ✓) |
|
||||||
|
|
||||||
|
> Guitar licks (not tracked as cells above; C-20 lick schema, 2026-07-08): 23 licks live in 6 guitar cells — blues 4, jazz 4, funk 4 (pack #1, 2026-07-08) + rock 4, country 4, reggae 3 (pack #2, 2026-07-08/09).
|
||||||
|
|
||||||
|
## Piano
|
||||||
|
|
||||||
|
| # | Style | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 11 | Jazz | done (2026-07-08, 7 progressions × 2 plays, validator ✓) |
|
||||||
|
| 12 | Blues | done (2026-07-10, 5 progressions × 2 plays, validator ✓) |
|
||||||
|
| 13 | Bossa Nova | todo |
|
||||||
|
| 14 | Gospel | done (2026-07-09, 7 progressions × 2 plays, validator ✓) |
|
||||||
|
| 15 | R&B / Neo-soul | done (2026-07-10, 7 progressions × 2 plays, validator ✓) |
|
||||||
|
| 16 | Pop | todo — **next piano cell** |
|
||||||
|
| 17 | Rock | todo |
|
||||||
|
| 18 | Funk | todo |
|
||||||
|
| 19 | Country / Folk | todo |
|
||||||
|
| 20 | Reggae | todo |
|
||||||
|
|
||||||
|
## Bass
|
||||||
|
|
||||||
|
| # | Style | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 21 | Blues | todo — **next bass cell** (ledger task P-41; blocked on the C-41 bass play schema) |
|
||||||
|
| 22 | Jazz | todo |
|
||||||
|
| 23 | Funk | todo |
|
||||||
|
| 24 | Reggae | todo |
|
||||||
|
| 25 | Rock | todo |
|
||||||
|
| 26 | Bossa Nova | todo |
|
||||||
|
| 27 | R&B / Neo-soul | todo |
|
||||||
|
| 28 | Country / Folk | todo |
|
||||||
|
| 29 | Gospel | todo |
|
||||||
|
| 30 | Pop | todo |
|
||||||
|
|
||||||
|
## UI milestones (interleave when their data exists)
|
||||||
|
|
||||||
|
| Milestone | Depends on | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| Jam Guide MVP (panel, matching, `ChordDiagram.jsx`, live sync) | cell 0 | done (2026-06-15, sprint-jam-guide, PR #2) |
|
||||||
|
| `MiniPiano.jsx` + recipe resolver | cell 11 | done (2026-06-16 resolver + MiniPiano voicing prop; authored packs render at stations 2026-07-09) |
|
||||||
|
| Bass pattern renderer | cell 21 | todo — activated in sprint-integrated-glance (ledger task L-42, after C-41 + P-41) |
|
||||||
|
| Improv layer (licks/tabs display) | a few guitar cells | done (2026-07-08, `LickCard.jsx` + licks section; 23 licks across 6 styles) |
|
||||||
|
|
||||||
|
> Notes for sessions: piano style order front-loaded the styles where piano voicings differ most (jazz/gospel/neo-soul — all shipped, plus blues); bass order front-loads line-driven styles (blues/jazz/funk). Adjust freely — order is a default, not a rule. Bass cells need the C-41 play schema in `SCHEMA.md` before authoring.
|
||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
# Knowledgebase Plan — Styles × Instruments × Progressions × Voicings
|
||||||
|
|
||||||
|
The plan for building JamBuddy's **jam knowledgebase**: an intermediate guide to the standard progressions of each style (Jazz, Blues, Rock, Bossa Nova, …) and the different ways to *play* them per instrument (guitar first, then piano, then bass) — expandable one session at a time via a repeatable loop, and rendered live in a large **Jam Guide** panel at the bottom of the app.
|
||||||
|
|
||||||
|
Three principles drive everything:
|
||||||
|
|
||||||
|
1. **Key-agnostic data.** Everything is stored as scale degrees and movable shapes, never absolute chords. The app detects the key; one KB entry renders in all 12 keys. This is the same convention `PROGRESSIONS.degrees` and the movable shapes in `voicings.js` already use.
|
||||||
|
2. **Machine-verifiable quality.** A validator proves every voicing actually contains the chord's tones before content lands. That's what makes agent-generated content trustworthy over many loop iterations.
|
||||||
|
3. **One bounded cell per session.** Each expansion session completes exactly one style × instrument cell (researched, authored, validated, committed). Small enough to review, big enough to matter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/data/kb/
|
||||||
|
index.js — registry aggregating all styles (UI reads only this)
|
||||||
|
SCHEMA.md — the authoring contract (formats below, with one full example)
|
||||||
|
jazz/
|
||||||
|
meta.js — { id, label, feel, tempoRange, character }
|
||||||
|
progressions.js — the style's standard progressions (instrument-independent)
|
||||||
|
guitar.js — guitar pack: plays + comping + improv
|
||||||
|
piano.js — piano pack
|
||||||
|
bass.js — bass pack
|
||||||
|
blues/ … — same shape per style
|
||||||
|
scripts/
|
||||||
|
validate-kb.mjs — quality gate, run with `node scripts/validate-kb.mjs`
|
||||||
|
docs/
|
||||||
|
kb-backlog.md — the cell matrix with statuses (the loop's queue)
|
||||||
|
```
|
||||||
|
|
||||||
|
`index.js` imports whatever style folders exist — the Jam Guide's style tabs grow automatically as the loop fills cells. A style is usable for one instrument before the others exist (guitar-first rollout).
|
||||||
|
|
||||||
|
### Progression entry (per style)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// kb/jazz/progressions.js
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
id: 'jazz-251-major',
|
||||||
|
name: 'ii–V–I',
|
||||||
|
rn: ['ii7', 'V7', 'Imaj7'],
|
||||||
|
degrees: [2, 7, 0], // semitone offsets from key root
|
||||||
|
qualities: ['min7', 'dom7', 'maj7'], // keys of CHORD_TYPES in theory.js
|
||||||
|
bars: [1, 1, 2],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Autumn Leaves', 'All The Things You Are'],
|
||||||
|
tip: 'The 7th of each chord resolves down a half-step to the 3rd of the next.',
|
||||||
|
},
|
||||||
|
// … 4-8 progressions per style (see docs/progression-repertoire.md §1 for the lists)
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instrument pack — guitar
|
||||||
|
|
||||||
|
```js
|
||||||
|
// kb/jazz/guitar.js
|
||||||
|
export default {
|
||||||
|
styleIntro: '2-3 sentences on the guitarist's role in this style.',
|
||||||
|
comping: [{ label: 'Four-to-the-bar (Freddie Green)', rhythm: '♩ ♩ ♩ ♩', description: '…' }],
|
||||||
|
plays: {
|
||||||
|
'jazz-251-major': [ // ≥2 "ways to play" per progression
|
||||||
|
{
|
||||||
|
label: 'Shell voicings',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [ // one entry per progression step
|
||||||
|
{ shape: { rootStr: 5, offsets: ['x', 0, 'x', 0, 1, 'x'], fingers: [0,1,0,2,3,0] },
|
||||||
|
note: 'root–♭7–♭3' },
|
||||||
|
// …
|
||||||
|
],
|
||||||
|
tips: 'Stay light; the 3rds and 7ths do all the work.',
|
||||||
|
},
|
||||||
|
{ label: 'Drop-2 on top four strings', /* … */ },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
improv: {
|
||||||
|
scales: [{ over: 'ii7', scale: 'dorian', why: '…' }],
|
||||||
|
targetNotes: 'Land the 3rd of each chord on beat 1.',
|
||||||
|
licks: [{ tab: 'e|---…', description: '…', over: 'jazz-251-major' }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shape format** follows the existing `voicings.js` convention so the renderer is shared: movable shapes use `rootStr` + `offsets` relative to the root fret (renders in any key); open shapes use absolute `frets` + `onlyRoot` (pitch class) and only render when the key matches. Strings are arrays of 6, low-E first, `'x'` = muted.
|
||||||
|
|
||||||
|
### Instrument pack — piano
|
||||||
|
|
||||||
|
Voicings are **interval recipes** resolved per chord quality (no fingering data needed):
|
||||||
|
|
||||||
|
```js
|
||||||
|
plays: {
|
||||||
|
'jazz-251-major': [
|
||||||
|
{
|
||||||
|
label: 'Rootless A/B alternation',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: ['3', '5', '7', '9'] }, note: 'Type A' }, // ii7
|
||||||
|
{ recipe: { LH: ['7', '9', '3', '13'] }, note: 'Type B' }, // V7
|
||||||
|
{ recipe: { LH: ['3', '5', '7', '9'] }, note: 'Type A' }, // Imaj7
|
||||||
|
],
|
||||||
|
register: 'top note between C4 and C5',
|
||||||
|
tips: 'Alternate types so inner voices barely move.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Degrees are chord-degree strings (`'1' '3' 'b7' '9' '13'`); the resolver maps them through the chord quality's intervals (which `theory.js` chord templates already encode).
|
||||||
|
|
||||||
|
### Instrument pack — bass
|
||||||
|
|
||||||
|
Line patterns per progression step, in degrees plus approach annotations:
|
||||||
|
|
||||||
|
```js
|
||||||
|
plays: {
|
||||||
|
'blues-12bar': [
|
||||||
|
{
|
||||||
|
label: 'Walking, chromatic approach',
|
||||||
|
level: 'intermediate',
|
||||||
|
bars: [{ beats: ['R', '3', '5', 'chrom→next'] } /* … per bar */],
|
||||||
|
tips: 'Beat 1 is always the new root; beat 4 walks into it.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Quality gates
|
||||||
|
|
||||||
|
### Mechanical — `scripts/validate-kb.mjs` (must pass before any commit)
|
||||||
|
|
||||||
|
- ids unique; every `plays` key references an existing progression id; `chords`/`bars` length matches the progression length
|
||||||
|
- `degrees` ∈ 0–11; `qualities` are keys of `CHORD_TYPES`; `mode` is a known mode
|
||||||
|
- guitar shapes: 6 entries per array, frets 0–15, **fret span ≤ 4** (intermediate hands), and — the strong check — the shape's computed pitch classes (standard tuning EADGBE) must contain the chord's root and defining tones (3rd/7th or quality equivalent) and contain **no out-of-chord tones**
|
||||||
|
- piano recipes: every degree resolvable for that chord quality
|
||||||
|
- coverage per cell: ≥ 4 progressions, ≥ 2 plays per progression, improv section present (guitar/piano), styleIntro present
|
||||||
|
|
||||||
|
### Musician checklist (human/agent self-review, in SCHEMA.md)
|
||||||
|
|
||||||
|
- Are the plays *idiomatically different* (register, density, difficulty), not just transpositions of each other?
|
||||||
|
- Is each play genuinely intermediate — no 5-fret stretches, no 2-octave rootless clusters?
|
||||||
|
- Does the style actually sound like the style (bossa ≠ jazz with different labels: distinct rhythm descriptions)?
|
||||||
|
- Do tips teach a *transferable* idea (voice leading, register, space), not just "play this"?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The expansion loop
|
||||||
|
|
||||||
|
### The queue
|
||||||
|
|
||||||
|
`docs/kb-backlog.md` holds the matrix of cells with statuses (`todo` / `in-progress` / `done` + date + coverage). Order: **all guitar cells first** (most voicing complexity — it sets the quality bar), then piano, then bass. Style priority within each instrument: jazz → blues → rock → bossa → funk → reggae → country/folk → R&B/neo-soul → gospel → pop.
|
||||||
|
|
||||||
|
### The session protocol (encoded as the `/kb-expand` project skill)
|
||||||
|
|
||||||
|
Each session:
|
||||||
|
|
||||||
|
1. **Orient** — read this plan, `SCHEMA.md`, the backlog, and the gold-standard cell (`kb/jazz/guitar.js`, the first one built).
|
||||||
|
2. **Claim** — take the first `todo` cell, mark it `in-progress`.
|
||||||
|
3. **Research** — dispatch web-research agent(s) for that style × instrument: the style's standard progressions (cross-check against `docs/progression-repertoire.md`), the 2-3 idiomatic intermediate ways to play each, comping rhythms, improv approach. Named sources required.
|
||||||
|
4. **Author** — write `progressions.js` (if the style is new) and the instrument pack, conforming to SCHEMA.md.
|
||||||
|
5. **Validate** — run `node scripts/validate-kb.mjs`; fix until green; run the musician checklist.
|
||||||
|
6. **Integrate** — register the style in `kb/index.js`; `npm run build` must pass.
|
||||||
|
7. **Record** — mark the cell `done` with date + coverage stats in the backlog; commit (`kb: add <style> <instrument> pack`).
|
||||||
|
8. **Report** — summarize what was added and name the next cell.
|
||||||
|
|
||||||
|
**Session 0 (bootstrap):** if `src/data/kb/`, `SCHEMA.md`, or the validator don't exist yet, the first session builds them *plus* the jazz/guitar gold-standard cell. Every later session imitates that exemplar.
|
||||||
|
|
||||||
|
### How to run it
|
||||||
|
|
||||||
|
- One session: type **`/kb-expand`** — does exactly one cell.
|
||||||
|
- Several in a row: `/loop /kb-expand` and let it self-pace, or run `/kb-expand` whenever there's time.
|
||||||
|
- Review cadence: cells are individual commits on a branch — review/merge per instrument tranche if preferred.
|
||||||
|
|
||||||
|
30 cells ≈ 30 short sessions; guitar's 10 cells deliver user-visible value immediately because the Jam Guide reads whatever exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The Jam Guide panel (UI)
|
||||||
|
|
||||||
|
A large panel at the **bottom of the main scroll** — while jamming you scroll down and the current progression's playbook is laid out to fit the screen.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ JAM GUIDE ─────────────────────────────── [Guitar|Piano|Bass] [Jazz][Blues][Rock][Bossa]… ─┐
|
||||||
|
│ Matched: ii–V–I in G major your loop: Am7 → D7 → Gmaj7 │
|
||||||
|
│ │
|
||||||
|
│ Am7 (ii7) D7 (V7) Gmaj7 (Imaj7) │
|
||||||
|
│ ▼ playing now │
|
||||||
|
│ Shells [diagram] [diagram] [diagram] root–3–7, four-to-the-bar │
|
||||||
|
│ Drop-2 [diagram] [diagram] [diagram] top-4 strings, stays high │
|
||||||
|
│ Triads 1-3 [diagram] [diagram] [diagram] fills between vocal lines │
|
||||||
|
│ ───────────────────────────────────────────────────────────────────────────── │
|
||||||
|
│ IMPROV D dorian → G mixo → G major · target the 3rds: C → F# → B · lick ▸ tab… │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Component:** `JamGuide.jsx`, mounted last in `App.jsx`; collapsed header always visible, expands to ~70vh.
|
||||||
|
- **Inputs:** `keyInfo`, `detectedProgression`, `currentChord` — plus instrument + style selection (persisted in settings; style tabs are generated from `kb/index.js`, so the panel grows as the loop runs).
|
||||||
|
- **Matching:** convert the detected loop to degrees relative to the key root, match against the selected style's progressions **rotation-invariantly** (same canonicalization idea as `detectRepeatingProgression`). No match → fallback: per-chord voicing alternatives from `voicings.js`, so the panel is never empty.
|
||||||
|
- **Live sync:** the active chord column highlights using the loop-position logic in `ProgressionBanner.jsx` (`findLoopPosition` — extract it to a shared util). The player reads the *next* voicing in time, in rhythm with the band.
|
||||||
|
- **Diagrams:** new small renderers — `ChordDiagram.jsx` (6-string × 5-fret SVG grid, consumes the shape format), `MiniPiano.jsx` (~2-octave SVG, highlights resolved recipe notes), bass patterns as degree badges (R · 3 · 5 · ♭7) over a mini string diagram. Reuse design tokens (`bg-panel`, `border-border`, accent purple for chord tones).
|
||||||
|
- **Smart fit:** CSS grid — columns = progression chords (4–6), rows = plays; rows beyond what fits collapse behind "more ways ▾"; diagrams scale to column width; on narrow windows the grid flips to one play per row, chords scrolling horizontally.
|
||||||
|
- **Key-aware rendering:** movable shapes get their fret position computed from the detected key; open shapes appear only when the chord's root matches; piano recipes resolve through the chord quality. All 12 keys for free, per principle 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phases
|
||||||
|
|
||||||
|
| Phase | What | Outcome |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | Foundation: `kb/` dirs, `SCHEMA.md`, validator, backlog, `/kb-expand` skill, jazz/guitar gold standard | The loop exists and has an exemplar |
|
||||||
|
| 1 | Jam Guide MVP: panel + matching + guitar `ChordDiagram` + live sync | jazz/guitar visible in the app while jamming |
|
||||||
|
| 2 | Loop guitar cells: blues, rock, bossa, funk, reggae, country, R&B, gospel, pop | Full guitar guide across styles |
|
||||||
|
| 3 | Piano: `MiniPiano` renderer + recipe resolver, loop piano cells | Second instrument live |
|
||||||
|
| 4 | Bass: pattern renderer, loop bass cells | Third instrument live |
|
||||||
|
| 5 | Polish: improv layer with tabs/licks, Progression Builder integration (GOAL G3), ToneGym-style tap-to-hear | Guide ↔ Builder round-trip |
|
||||||
|
|
||||||
|
### Success criteria
|
||||||
|
|
||||||
|
- During a jam, scrolling to the Jam Guide shows ≥ 3 ways to play the detected progression on the selected instrument, in the detected key, with the active chord highlighted in time.
|
||||||
|
- `/kb-expand` completes a cell in one session with the validator green, no hand-holding.
|
||||||
|
- A new style added by the loop appears in the UI with **zero code changes** (data + registry only).
|
||||||
|
- An intermediate player can switch Jazz → Bossa over the same ii–V–I and see *genuinely different* voicings and rhythm guidance.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Learn Curriculum — Intermediate, Jam-Ready Players
|
||||||
|
|
||||||
|
Research-backed training repertoire for the Learn section. Companion to [`GOAL.md`](../GOAL.md) Part 2. Audience: players who know the basics and can survive a jam — the goal is making them better *in* the jam. Not beginner material.
|
||||||
|
|
||||||
|
Across guitar, piano, and bass pedagogy the intermediate→advanced jump converges on four pillars: **instrument liberation** (play anything anywhere), **playing the changes** (chord-tone awareness), **ensemble skills** (space, register, pocket), and **functional ears** (hearing 1-5-6-4). The per-instrument curricula below feed both the Learn UI and the knowledgebase (see `docs/kb-plan.md`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guitar
|
||||||
|
|
||||||
|
### Skill taxonomy (rough order)
|
||||||
|
|
||||||
|
**Tier A — Fretboard liberation**
|
||||||
|
1. CAGED system fluency — locate any chord in 5 places instantly (Fretboard Logic, Pickup Music)
|
||||||
|
2. Triads on string sets — major/minor triads + inversions on strings 1-3, 2-4, 3-5 (Justin Guitar Grade 5, Leavitt Vol. 2)
|
||||||
|
3. Connecting pentatonic boxes — the box 2↔3 seam is the documented weak point
|
||||||
|
4. Scale-over-chord mapping — seeing the chord *inside* the scale shape
|
||||||
|
|
||||||
|
**Tier B — Playing the changes**
|
||||||
|
5. Chord-tone / target-note soloing — land on a chord tone on beat 1 of each change
|
||||||
|
6. Guide tones (3rds & 7ths) — the 7th of one chord resolves to the 3rd of the next
|
||||||
|
7. Phrasing across positions
|
||||||
|
|
||||||
|
**Tier C — Ensemble skills**
|
||||||
|
8. Comping with small triad voicings — stay out of the vocalist's/keys' register
|
||||||
|
9. Internal time — metronome on beats 2 & 4, then no click (Tomo Fujita's core emphasis)
|
||||||
|
10. Ear-led playing — "let your ears lead you instead of your eyes" (Fujita)
|
||||||
|
|
||||||
|
**Tier D — Functional ears**
|
||||||
|
11. Progression recognition by ear — track the bass line first
|
||||||
|
12. Modal awareness — how Dorian/Mixolydian overlap shapes you already know
|
||||||
|
13. Transcribing & daily riff-writing — the recurring plateau fix
|
||||||
|
|
||||||
|
### Top drills
|
||||||
|
1. **Triad voice-leading over 12-bar blues** — only close triads on one string set; on every change move each finger to the *nearest* note of the next triad. Forces inversions + minimal-motion voice leading at once.
|
||||||
|
2. **One-CAGED-position soloing** — improvise using only one grip's chord tones + surrounding scale notes; shift position each chorus. Welds chord, arpeggio, and scale into one visual unit.
|
||||||
|
3. **Target-note drill** — first note after each chord change must be the 3rd (then 7ths, then 7th→3rd resolutions); pentatonic filler in between. Pure pentatonic playing suddenly "follows the changes".
|
||||||
|
4. **Pentatonic seam drill** — ascend box 1, exit through a named seam note into box 2, etc. Shifts become melodic destinations.
|
||||||
|
5. **Metronome on 2 & 4** — click as the snare backbeat; progress to click once per bar, then none.
|
||||||
|
6. **Comping ladder** — comp behind a recorded soloist using only 3-string triads above fret 5, varying rhythm/dynamics, never register-clashing.
|
||||||
|
7. **Daily progression dictation** — 10-15 min naming I-IV-V vs vi-IV-I-V vs ii-V-I from songs, bass line first.
|
||||||
|
|
||||||
|
### Sources
|
||||||
|
Fretboard Logic (Bill Edwards) · Justin Guitar Grades 4–6 · Tomo Fujita *Accelerate Your Guitar Playing* (Berklee) · Leavitt *A Modern Method for Guitar* Vol. 2 · Absolutely Understand Guitar · Pickup Music CAGED pathway · fundamental-changes.com (guide tones) · TrueFire (box connection, plateaus) · Premier Guitar "Rhythm Rules" · zotzinguitarlessons.com (triads in 12 keys) · ToneGym / tonedear.com · stringshock.com & jazzguitartoday.com (jam etiquette)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Piano / Keys
|
||||||
|
|
||||||
|
### Skill taxonomy (rough order)
|
||||||
|
|
||||||
|
**Tier 1 — Harmonic vocabulary**
|
||||||
|
1. Triad inversions in all 12 keys — grab any chord near the current hand position
|
||||||
|
2. Voice leading — minimum-distance inversion choice; the biggest "amateur → pro" jump
|
||||||
|
3. Shell voicings (root–3–7) — light, clear, gateway to comping (Open Studio, PianoGroove)
|
||||||
|
4. Sus2/sus4/add9 colors and slash chords — pop/worship vocabulary
|
||||||
|
5. Rootless voicings — Type A (3-5-7-9), Type B (7-9-3-5); top note between C4–C5
|
||||||
|
|
||||||
|
**Tier 2 — Rhythm & ensemble role**
|
||||||
|
6. Comping rhythms — Charleston, reverse Charleston, Red Garland pattern, anticipations
|
||||||
|
7. Register discipline — LH stays above ~G3 when a bassist is present
|
||||||
|
8. Density discipline ("rule of 1") — in a 5-piece band, play 1/5 of the music
|
||||||
|
9. Hand-role splitting — LH harmony/groove anchor, RH color and answers
|
||||||
|
|
||||||
|
**Tier 3 — Functional/ear skills**
|
||||||
|
10. Thinking in numbers (Nashville Number System / Roman numerals)
|
||||||
|
11. Progression recognition by ear — bass line + emotional flow of each degree
|
||||||
|
12. Transposition fluency — known songs in all 12 keys via the number method
|
||||||
|
13. Sight-comping — realize an unfamiliar lead sheet at tempo (Berklee keyboard method)
|
||||||
|
|
||||||
|
**Tier 4 — Bandstand**
|
||||||
|
14. Form-keeping under pressure — never lose bar 1
|
||||||
|
15. Improvising over changes — chord tones → pentatonics → scale tones
|
||||||
|
16. Repertoire in 2-3 keys from memory
|
||||||
|
|
||||||
|
### Top drills
|
||||||
|
1. **Voice-led progression loop in 12 keys** — I–V–vi–IV with minimum hand movement (C → G/B → Am → F/A), through the circle of fifths. Self-grading: you can see and hear when you jump.
|
||||||
|
2. **Shell ii–V–I cycle** — root+3+7 through all keys, alternating types so 3rds/7ths swap and resolve by half-step. The core voice-leading mechanic made physical.
|
||||||
|
3. **Charleston metronome ladder** — one syncopation pattern to automaticity at 80→160 BPM; comping failure in jams is usually rhythmic, not harmonic.
|
||||||
|
4. **Backing-track subtraction** — chorus 1 whole notes only; chorus 2 LH only above G3, no roots; chorus 3 RH colors only; chorus 4 two hits per bar. Simulates bandmates occupying frequency space.
|
||||||
|
5. **Bass-line ear training** — hum the bass note of each chord in a pop song, convert to numbers, play it.
|
||||||
|
6. **One song, twelve keys** — number-chart a known song, new key daily.
|
||||||
|
7. **Cold lead-sheet sight-comping** — slow metronome, once through the form, never stopping. Rehearses the actual jam failure mode.
|
||||||
|
|
||||||
|
### Sources
|
||||||
|
Berklee Online Keyboard Method · Mark Levine *The Jazz Piano Book* · Open Studio Piano Pathway · PianoGroove (rootless voicings, comping) · The Jazz Piano Site (jam prep) · Piano With Jonny (voicings, transposing) · Jens Larsen (comping rhythms) · Pianote (band guide, NNS) · Worship Online / Musicademy / Sweetwater (band role) · ToneDear / ToneGym / Musical U / trainear.com (ear training)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bass
|
||||||
|
|
||||||
|
### Skill taxonomy (rough order)
|
||||||
|
1. Fretboard zone mastery — every note to fret 12, lines through each zone (Friedland)
|
||||||
|
2. Root-fifth-octave vocabulary — the "safe but musical" jam fallback
|
||||||
|
3. Chord-tone fluency — R-3-5-7 of maj/min/dom/m7♭5 anywhere (TalkingBass: chord tones *before* scales)
|
||||||
|
4. Scale-tone vs chord-tone discrimination — outline on strong beats, connect on weak
|
||||||
|
5. Approach-note technique — chromatic from above/below, W|H|H walkup, dominant approach
|
||||||
|
6. Walking bass construction — root on 1, chord tones on 1 & 3, approach into the next root on 4
|
||||||
|
7. Subdivision command — straight 8ths / swing / shuffle / 16th funk, switching mid-groove
|
||||||
|
8. Pocket / drummer lock — kick matching, ghost notes, dynamic mirroring
|
||||||
|
9. Functional ear training — root movement, I/IV/V/vi by function
|
||||||
|
10. Real-time harmonic prediction — the V "pushes home", reacting within one pass of the form
|
||||||
|
11. Dynamics, touch, space — most cited intermediate→pro separator
|
||||||
|
12. Fills and form awareness — fills at bars 4/8 phrase boundaries
|
||||||
|
|
||||||
|
### Top drills
|
||||||
|
1. **Root-only song stripping** — play only the root of each change by ear; add 5ths and octaves on later passes. Strips songs to harmonic skeleton.
|
||||||
|
2. **Arpeggiate the progression** — R-3-5(-7) over I-V-vi-IV in several keys, then inversions, then other neck zones.
|
||||||
|
3. **W|H|H chromatic walkup** — between chords a 4th apart: root, whole, half, half (C-D-E♭-E→F). Formulaic forward motion that telegraphs the next chord.
|
||||||
|
4. **Walking 12-bar / 1-6-2-5 loop** — a decision every beat about chord vs passing tone (Friedland, SBL 5-step formula).
|
||||||
|
5. **2-bar loop challenge** — one groove for 5+ minutes changing only tone/dynamics/note length. Pocket training; exposes drift.
|
||||||
|
6. **Subdivision switching** — 2 bars 8ths / 2 bars 16ths at 60-80 BPM; click on 2&4 only; mute the click 4 bars and check.
|
||||||
|
7. **Sing-then-play root movement** — sing the roots before touching the bass, then 3rds/5ths/7ths.
|
||||||
|
|
||||||
|
### Sources
|
||||||
|
Scott's Bass Lessons (Players Path, Groove Trainer) · TalkingBass Chord Tone Essentials · Ed Friedland *Building Walking Bass Lines* · Hal Leonard Bass Method · Berklee Practice Method: Bass · Bass Musician Magazine (drummer lock) · Premier Guitar (jam survival) · StudyBass · Learn Jazz Standards · Jazz Night School (chromatic 4) · onlinebasscourses.com · Functional Ear Trainer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the app supports this (detection-powered training)
|
||||||
|
|
||||||
|
These map to GOAL.md L1–L4; the app's unfair advantage is that it *hears* the player.
|
||||||
|
|
||||||
|
1. **Target-note highlighting** — on each detected chord change, flash the new chord's 3rd/7th on the fretboard/piano (guitar drill 3, piano drill 2). Later: score whether the first detected note after the change was a chord tone.
|
||||||
|
2. **Voice-leading coach (piano view)** — highlight the *nearest inversion* to the previous chord, common tones marked "hold"; score total semitone travel per progression.
|
||||||
|
3. **Next-chord preview tier (bass)** — when a loop is detected, highlight the upcoming chord's root plus its chromatic approach notes a half-step above/below — the walking-line scaffold, one beat ahead.
|
||||||
|
4. **Progression ear-trainer on your own jam** — hide the chord banner, ask for the numbers (vi-IV-I-V), reveal. Uses chord history + `toRomanNumeral`; contextual beats abstract drills.
|
||||||
|
5. **Pocket report** — extend the onset/BPM histogram to show beat-phase drift (rushing/dragging), plus a 2-&-4-only click synced to the detected tempo.
|
||||||
|
6. **Mode-difference teaching** — when the user manually switches mode (the K-S limitation), briefly highlight the difference notes (minor → Dorian = raised 6th).
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Chord Progression Repertoire
|
||||||
|
|
||||||
|
Research-backed reference for expanding the progression features. Companion to [`GOAL.md`](../GOAL.md) Part 1. Notation: uppercase = major, lowercase = minor, ° = diminished, 7 = dominant unless marked maj7/m7.
|
||||||
|
|
||||||
|
How this maps to code today:
|
||||||
|
|
||||||
|
- `PROGRESSIONS` in `src/lib/theory.js` — `{ name, rn, degrees }` per progression; `degrees` are semitone offsets from the key root. This is the format new entries should use.
|
||||||
|
- `getSuggestedProgressions(root, mode)` maps degrees → chord names in key; `toRomanNumeral()` converts any chord back to a numeral.
|
||||||
|
- `FAMOUS_PROGRESSIONS` in `src/lib/education.js` — richer entries (songs, tips, style variations) for the Learn side.
|
||||||
|
|
||||||
|
## 1. Genre-by-genre progression tables
|
||||||
|
|
||||||
|
### Pop
|
||||||
|
| Progression | Name / notes |
|
||||||
|
|---|---|
|
||||||
|
| I–V–vi–IV | "Axis of Awesome" — #1 in Hooktheory's corpus of 75k+ analyzed songs |
|
||||||
|
| vi–IV–I–V | Same loop rotated to start on vi ("pessimistic axis") |
|
||||||
|
| I–vi–IV–V | "Doo-wop" / "'50s progression" |
|
||||||
|
| I–IV–vi–V | Common variant (Africa chorus) |
|
||||||
|
| IV–V–iii–vi | "Royal Road" — J-pop/anime staple, spreading into Western pop |
|
||||||
|
|
||||||
|
### Rock
|
||||||
|
| Progression | Name / notes |
|
||||||
|
|---|---|
|
||||||
|
| I–IV–V | Foundation of rock/blues/country |
|
||||||
|
| I–♭VII–IV(–I) | Mixolydian rock cliché (Sweet Home Alabama as V–IV–I rotation) |
|
||||||
|
| i–♭VII–♭VI(–V) | Andalusian-derived minor loop; with V = full Andalusian cadence |
|
||||||
|
| I–♭III–IV | Blues-rock riff progression (borrowed ♭III) |
|
||||||
|
|
||||||
|
### Blues (12-bar family)
|
||||||
|
| Progression | Name / notes |
|
||||||
|
|---|---|
|
||||||
|
| I7×4 / IV7×2, I7×2 / V7, IV7, I7, V7 | Standard 12-bar |
|
||||||
|
| Bar 2 → IV7 | "Quick change" / "quick four" |
|
||||||
|
| ii7–V7 in bars 9–10, turnaround I–VI7–ii–V7 | Jazz blues |
|
||||||
|
| i7–iv7–i7 … ♭VI7–V7–i7 | Minor blues (The Thrill Is Gone) |
|
||||||
|
| I–V–IV–IV–I–V–I–V | 8-bar blues (Key to the Highway) |
|
||||||
|
|
||||||
|
### Jazz
|
||||||
|
| Progression | Name / notes |
|
||||||
|
|---|---|
|
||||||
|
| ii7–V7–Imaj7 | The fundamental cadence |
|
||||||
|
| I–vi–ii–V (also iii–vi–ii–V) | Rhythm changes A / turnaround |
|
||||||
|
| III7–VI7–II7–V7 | Rhythm changes bridge (circle of dominants) |
|
||||||
|
| iim7♭5–V7♭9–i | Minor ii–V–i |
|
||||||
|
| ii7–♭II7–Imaj7 | Tritone-sub cadence |
|
||||||
|
| ivm7–♭VII7–Imaj7 | "Backdoor" progression |
|
||||||
|
|
||||||
|
### Folk / Country
|
||||||
|
| Progression | Name / notes |
|
||||||
|
|---|---|
|
||||||
|
| I–IV–V(–I) | Core of both genres |
|
||||||
|
| I–V–I–IV | Two/three-chord verse pattern |
|
||||||
|
| i–♭VII–♭VI | Minor folk descent (Am–G–F) |
|
||||||
|
| I–V–vi–iii–IV–I–IV–V | Pachelbel progression |
|
||||||
|
| I–II7–V–I | Classic country secondary-dominant (V/V) move |
|
||||||
|
|
||||||
|
### Funk
|
||||||
|
| Progression | Name / notes |
|
||||||
|
|---|---|
|
||||||
|
| I7 vamp | James Brown static dominant, voiced as 9th |
|
||||||
|
| i7–IV7 | Dorian two-chord vamp — the most common funk pair |
|
||||||
|
| i7 / m11 vamp | Minor one-chord groove |
|
||||||
|
| ii7–V7 loop | Funk/disco vamp |
|
||||||
|
|
||||||
|
Design insight: funk needs few *progressions* but rich *chord qualities* (9, 7♯9, m11, 13sus) — colour lives in the voicing, not the changes.
|
||||||
|
|
||||||
|
### Reggae
|
||||||
|
| Progression | Name / notes |
|
||||||
|
|---|---|
|
||||||
|
| I–V or I–IV | Two-chord skank vamps |
|
||||||
|
| I–V–vi–IV | No Woman No Cry |
|
||||||
|
| i–♭VII(–♭VI) | Minor roots-reggae vamp |
|
||||||
|
| I–IV–V | Ska/rocksteady standard |
|
||||||
|
|
||||||
|
### R&B / Neo-soul
|
||||||
|
| Progression | Name / notes |
|
||||||
|
|---|---|
|
||||||
|
| ii7–V7–Imaj7 (with 9/11/13 extensions) | Core cadence |
|
||||||
|
| iii7–vi7–ii7–V7 | Circle movement from the mediant — neo-soul staple |
|
||||||
|
| vi–ii–V–I | "6-2-5-1" cyclical soul loop |
|
||||||
|
| Imaj7–IVmaj7 / Imaj7–iii7 | Two-chord vamps |
|
||||||
|
| i7–iv7 | Dorian D'Angelo-style minor vamp |
|
||||||
|
|
||||||
|
### Gospel
|
||||||
|
| Progression | Name / notes |
|
||||||
|
|---|---|
|
||||||
|
| ii7–V7–I | The gospel "2-5-1", often chained: 6-2-5-1, 3-6-2-5-1 |
|
||||||
|
| I–I7–IV | Tonicizing IV (V7/IV "amen" setup) |
|
||||||
|
| IV–iv–I | Plagal with borrowed iv |
|
||||||
|
| I–♯i°–ii | Chromatic passing-diminished walk-up |
|
||||||
|
|
||||||
|
## 2. Substitution / variation taxonomy
|
||||||
|
|
||||||
|
Progression "families" relate through a small set of transforms — these are the generation rules for variation buttons and the Builder:
|
||||||
|
|
||||||
|
1. **Rotation** — any loop can start on any chord (I–V–vi–IV ≡ vi–IV–I–V). Treat loops as cyclic equivalence classes; display the rotation matching the user's tonic emphasis. (`detectRepeatingProgression` already canonicalizes rotations.)
|
||||||
|
2. **Diatonic (function) substitution** — chords sharing two notes swap: I↔vi↔iii (tonic), IV↔ii (subdominant), V↔vii° (dominant).
|
||||||
|
3. **Modal interchange / borrowing** — take a chord from the parallel mode: iv, ♭VI, ♭VII, ♭III, iim7♭5 in major; major IV (Dorian) in minor.
|
||||||
|
4. **Secondary dominants** — precede any diatonic target with its V7: V/V = II7, V/vi = III7, V/IV = I7, V/ii = VI7.
|
||||||
|
5. **Tritone substitution** — replace any dominant with the dominant a tritone away (V7 → ♭II7). Jazz flavour flag.
|
||||||
|
6. **Backdoor dominant** — ♭VII7 resolving to I, usually as ivm7–♭VII7–I.
|
||||||
|
7. **Quality embellishment** — same root, richer colour: triad → 7th → 9/11/13, sus2/4, add9. The main axis distinguishing genres (pop = triads/sus, jazz/neo-soul/gospel = extensions, funk = dominant 9/♯9). Already partially covered by `CHORD_SUBSTITUTIONS` in `education.js`.
|
||||||
|
8. **Passing/approach chords** — chromatic passing diminished (I–♯i°–ii), bass-line inversions (slash chords).
|
||||||
|
|
||||||
|
## 3. UX patterns worth copying
|
||||||
|
|
||||||
|
- **Hookpad (Hooktheory)** — *key-relative chord palette*: only the diatonic chords of the current key, colour-coded consistently per scale degree (key-agnostic colours). Borrowed chords live in expandable secondary palettes. *Magic Chord* suggests the statistically likeliest next chord. Drag-and-drop onto a timeline. → Direct model for the Progression Builder (GOAL G3).
|
||||||
|
- **Hooktheory TheoryTab** — progressions ranked by real-song frequency; each links to songs using it. "You're playing the Creep progression" is a strong engagement hook (partially exists via `FAMOUS_PROGRESSIONS` song lists).
|
||||||
|
- **Scaler 2/3** — three-zone vertical flow: detection area (top) → suggested chords/scales (middle) → user-built progression (bottom). Maps directly onto this app: live detection → suggestions → builder.
|
||||||
|
- **iReal Pro** — one-tap transposition; per-genre rendering of the same progression.
|
||||||
|
- **ToneGym** — instant audio preview when tapping any chord/progression.
|
||||||
|
|
||||||
|
## 4. Voicing data
|
||||||
|
|
||||||
|
### Guitar
|
||||||
|
Best option found: [`tombatossals/chords-db`](https://github.com/tombatossals/chords-db) (MIT, npm `@tombatossals/chords-db`, prebuilt `lib/guitar.json`):
|
||||||
|
- All 12 keys × large suffix list, **multiple positions per chord** (open + barre + higher CAGED positions).
|
||||||
|
- Per position: `frets` (per string, `x` = mute, low-E first), `fingers`, optional `barres`, `baseFret`. Example: `{ frets: '55775x', fingers: '114310', barres: 5 }`.
|
||||||
|
- Companion renderer: [`tombatossals/react-chords`](https://github.com/tombatossals/react-chords) (React SVG diagrams consuming this format).
|
||||||
|
|
||||||
|
Triads on string-sets (top-3 / middle-3) are *not* in chords-db but are cheap to generate: for each inversion of the triad, map the 3 chord tones onto a chosen string set within a 4-fret window. This complements the existing `GUITAR_SHAPES` in `src/lib/voicings.js`.
|
||||||
|
|
||||||
|
### Piano
|
||||||
|
No canonical open dataset exists. The sane model is **interval recipes resolved per chord quality** (the chord templates in `theory.js` already encode quality → semitone mapping):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// voicing = named recipe of chord degrees, resolved per chord quality
|
||||||
|
{
|
||||||
|
shell: { LH: ['1', '7'], RH: ['3'] },
|
||||||
|
rootPosition: { LH: ['1'], RH: ['1', '3', '5', '7'] },
|
||||||
|
rootlessA: { LH: ['3', '5', '7', '9'] }, // Type A: 3rd on bottom
|
||||||
|
rootlessB: { LH: ['7', '9', '3', '5'] }, // Type B: 7th on bottom
|
||||||
|
guideTones: { LH: ['3', '7'] },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Conventions to encode: rootless voicings keep the top note between C4–C5; alternate Type A/B through a progression so inner voices barely move — i.e. pick the voicing minimizing semitone travel from the previous chord (simple voice-leading distance minimization).
|
||||||
|
|
||||||
|
## 5. Sources
|
||||||
|
|
||||||
|
- Hooktheory corpus & blog: hooktheory.com/blog/i-analyzed-the-chords-of-1300-popular-songs-for-patterns-this-is-what-i-found/ ; hooktheory.com/blog/jazz-chord-progressions/
|
||||||
|
- 12-bar variants: en.wikipedia.org/wiki/Twelve-bar_blues ; happybluesman.com/common-variations-12-bar-blues/
|
||||||
|
- Named progressions: en.wikipedia.org/wiki/%2750s_progression ; piano.org/chord-progressions/ ; supersimplepiano.com/learn/chord-progressions/royal-road
|
||||||
|
- Substitutions: learnjazzstandards.com (chord substitution) ; hub.yamaha.com (beyond diatonic) ; hubguitar.com (tritone subs)
|
||||||
|
- Gospel: gospelmaps.com/top-gospel-chord-progressions/ ; gospel.hearandplay.com (2-5-1)
|
||||||
|
- Genre vamps: orphiq.com (reggae) ; guitar-chord.org/articles/funk.html ; orangecandymusic.com & pickupmusic.com (R&B/neo-soul)
|
||||||
|
- Tools: producelikeapro.com (Scaler review) ; hooktheory.com/hookpad
|
||||||
|
- Voicing data: github.com/tombatossals/chords-db ; github.com/tombatossals/react-chords ; voicinglab.com & pianowithjonny.com & thejazzpianosite.com (rootless voicings)
|
||||||
+1
-1
@@ -34,7 +34,7 @@ function createWindow() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
win.loadURL('http://localhost:5173')
|
win.loadURL('http://127.0.0.1:5173')
|
||||||
win.webContents.openDevTools()
|
win.webContents.openDevTools()
|
||||||
} else {
|
} else {
|
||||||
win.loadFile(path.join(__dirname, '../dist/index.html'))
|
win.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="JamBuddy" />
|
<meta name="apple-mobile-web-app-title" content="JamBuddy" />
|
||||||
<!-- Production CSP. dev HMR runs same-origin so 'self' covers the ws upgrade. -->
|
<!-- Production CSP. dev HMR runs same-origin so 'self' covers the ws upgrade. -->
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' blob:; connect-src 'self';" />
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' http://127.0.0.1:5173 ws://127.0.0.1:5173; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' blob:; connect-src 'self';" />
|
||||||
<title>WhatTheFlat ♭? - JamBuddy</title>
|
<title>WhatTheFlat ♭? - JamBuddy</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"electron:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5173 && electron .\"",
|
"electron:dev": "concurrently -k \"vite\" \"wait-on http://127.0.0.1:5173 && electron .\"",
|
||||||
"electron:build": "vite build && electron-builder",
|
"electron:build": "vite build && electron-builder",
|
||||||
"electron:build:win": "vite build && electron-builder --win --publish never",
|
"electron:build:win": "vite build && electron-builder --win --publish never",
|
||||||
"electron:build:mac": "vite build && electron-builder --mac --publish never",
|
"electron:build:mac": "vite build && electron-builder --mac --publish never",
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Loop-detection truth fixtures (task C-30) — the contract L-30 must satisfy.
|
||||||
|
//
|
||||||
|
// Each fixture is a realistic `chordHistory` as App.jsx commits it (plain chord
|
||||||
|
// name strings from matchChordFromChroma — "C", "Am", "G7", "Cmaj7"…; the commit
|
||||||
|
// layer at App.jsx:363-365 suppresses ADJACENT duplicates, so histories here are
|
||||||
|
// adjacent-dup-free except the one fixture that deliberately tests dups) plus the
|
||||||
|
// loop a musician would say they are playing (`expect`, or null for "no loop").
|
||||||
|
//
|
||||||
|
// Consumed by scripts/smoke.mjs:
|
||||||
|
// · fixture without expectedFail that fails → smoke exits non-zero (regression)
|
||||||
|
// · fixture with expectedFail that fails → annotated expected-fail (printed)
|
||||||
|
// · fixture with expectedFail that PASSES → smoke FAILS: stale marker, flip it
|
||||||
|
// L-30's definition of done = every expectedFail marker removed, all green.
|
||||||
|
//
|
||||||
|
// ─── FAILURE MAP OF THE PRE-L-30 ALGORITHM (historical — all fixed by L-30) ──
|
||||||
|
// (theory.js `detectRepeatingProgression` as of C-30, every fixture RUN against
|
||||||
|
// it 2026-07-10; L-30 redesigned the function and removed every expectedFail
|
||||||
|
// marker — all 17 fixtures now pass as plain regression guards)
|
||||||
|
//
|
||||||
|
// The pre-L-30 matcher: window = last 32 commits; candidate lengths 2–6; EXACT
|
||||||
|
// contiguous occurrence counting (scan advances by len on match, by 1 on miss);
|
||||||
|
// requires reps ≥ 2; score = reps × len²; returns the canonical (lexicographically
|
||||||
|
// smallest) rotation of the best candidate.
|
||||||
|
//
|
||||||
|
// 1. len²-weighting artifact — self-overlaps of a short vamp beat the vamp:
|
||||||
|
// a 2-chord vamp ×4 reports the 3-chord [Am,G,Am] (2 reps × 3² = 18 beats the
|
||||||
|
// true pair's 4 × 2² = 16); a SUSTAINED 2-chord vamp (full 32 window) reports
|
||||||
|
// the bogus 5-chord [Am,Am,G,Am,G] (fixtures: vamp-2x4, vamp-2-sustained).
|
||||||
|
// 2. One SUBSTITUTED misdetection inside one rep of a 3-loop flips the winner to
|
||||||
|
// a wrong 4-pattern: [F,C,Am,F] straddling the noise scores 2 × 4² = 32 and
|
||||||
|
// beats the real [C,Am,F] at 3 × 3² = 27 (fixture: spurious-substitution).
|
||||||
|
// NOTE: one INSERTED extra chord is survivable today — the miss-by-1 scan
|
||||||
|
// realigns after it (fixture spurious-insert-once stays green).
|
||||||
|
// 3. Insertions in 2 of 4 reps → the same wrong-4-pattern failure (spurious-2of4).
|
||||||
|
// 4. Consecutive duplicate commits of one chord create a wrong 4-pattern
|
||||||
|
// [C,Am,F,C] (2 × 16 > 3 × 9) instead of collapsing to the 3-loop
|
||||||
|
// (fixture: dup-commit). Unreachable from TODAY'S commit layer (App.jsx
|
||||||
|
// dedupes adjacent commits) — the fixture makes the detector safe standalone,
|
||||||
|
// per L-30's "collapse consecutive duplicates" requirement.
|
||||||
|
// 5. Loop lengths 7–8 are structurally impossible (len caps at 6): a 7- or
|
||||||
|
// 8-chord loop ×2 returns a truncated 6-chord slice of itself, not the loop
|
||||||
|
// (fixtures: seven-x2, eight-x2). This is the likely reading of the user's
|
||||||
|
// "5 and then 2 others": a 5+2 = 7-chord FORM can never be detected.
|
||||||
|
// 6. No recency weighting: after a section change (loop A ×3 → loop B ×3) the
|
||||||
|
// OLD 4-chord loop A still outscores the current 3-chord loop B
|
||||||
|
// (3 × 4² = 48 > 3 × 3² = 27), so the display is stuck on the previous
|
||||||
|
// section (fixture: section-change).
|
||||||
|
//
|
||||||
|
// What already WORKS at the pure-function level (regression guards — L-30 must
|
||||||
|
// keep these green): clean 2×3 / 3×2 / 3×3 / 4×3 / 6×2 loops; one inserted
|
||||||
|
// spurious chord; a 5-chord loop + 2-chord tag/turnaround, both with and without
|
||||||
|
// the loop resuming (the occurrence scan skips foreign chords). So if the user
|
||||||
|
// sees the 5+tag case fail live, the loss is in the App.jsx commit layer
|
||||||
|
// (miss-4-then-clear / 2-consecutive-identical vote) — that is L-31's territory,
|
||||||
|
// not this function's.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotation-minimal normalization — a LOCAL REPLICA of theory.js's private
|
||||||
|
* `canonicalize` (it is not exported, and theory.js is locked to L-30, so we
|
||||||
|
* must not touch it to export it). Same semantics byte-for-byte: pick the
|
||||||
|
* lexicographically smallest rotation via '\0'-joined comparison, so [C,Am,F],
|
||||||
|
* [Am,F,C] and [F,C,Am] all compare equal as loops.
|
||||||
|
*/
|
||||||
|
export function canonicalLoop(pattern) {
|
||||||
|
let best = pattern
|
||||||
|
for (let i = 1; i < pattern.length; i++) {
|
||||||
|
const rot = [...pattern.slice(i), ...pattern.slice(0, i)]
|
||||||
|
if (rot.join('\0') < best.join('\0')) best = rot
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeat a loop n times into one flat history
|
||||||
|
const reps = (loop, n) => Array.from({ length: n }, () => loop).flat()
|
||||||
|
|
||||||
|
export const LOOP_FIXTURES = [
|
||||||
|
// ── Regression guards: what works today MUST keep working ──────────────────
|
||||||
|
{
|
||||||
|
id: 'clean-2x3',
|
||||||
|
description: 'clean 2-chord vamp played 3× — the minimal detectable loop',
|
||||||
|
history: reps(['Am', 'G'], 3),
|
||||||
|
expect: ['Am', 'G'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clean-3x2',
|
||||||
|
description: 'clean 3-chord loop played exactly 2× (6 commits, the minimum history)',
|
||||||
|
history: reps(['C', 'Am', 'F'], 2),
|
||||||
|
expect: ['C', 'Am', 'F'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clean-3x3',
|
||||||
|
description: 'clean 3-chord loop played 3× — the plain "3 chords return" case',
|
||||||
|
history: reps(['C', 'Am', 'F'], 3),
|
||||||
|
expect: ['C', 'Am', 'F'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clean-4x3',
|
||||||
|
description: 'clean 4-chord axis loop played 3×',
|
||||||
|
history: reps(['C', 'G', 'Am', 'F'], 3),
|
||||||
|
expect: ['C', 'G', 'Am', 'F'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clean-6x2',
|
||||||
|
description: 'clean 6-chord loop played 2× — the top of the current length range',
|
||||||
|
history: reps(['C', 'Am', 'Dm', 'G', 'Em', 'F'], 2),
|
||||||
|
expect: ['C', 'Am', 'Dm', 'G', 'Em', 'F'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'spurious-insert-once',
|
||||||
|
description: '3-chord loop with ONE spurious chord INSERTED mid-rep (C Am F | C Am E7 F | C Am F) — the scan realigns after an insertion',
|
||||||
|
history: ['C', 'Am', 'F', 'C', 'Am', 'E7', 'F', 'C', 'Am', 'F'],
|
||||||
|
expect: ['C', 'Am', 'F'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'five-plus-tag-resumes',
|
||||||
|
description: '5-chord loop ×2, a 2-chord turnaround tag (Dm E7), then the loop resumes — loop must survive the tag',
|
||||||
|
history: [...reps(['Am', 'F', 'C', 'G', 'Em'], 2), 'Dm', 'E7', 'Am', 'F', 'C', 'G', 'Em'],
|
||||||
|
expect: ['Am', 'F', 'C', 'G', 'Em'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'five-plus-tag-at-end',
|
||||||
|
description: '5-chord loop ×2 then 2 foreign chords with the history ending there — the just-played loop must still be reported',
|
||||||
|
history: [...reps(['Am', 'F', 'C', 'G', 'Em'], 2), 'Dm', 'E7'],
|
||||||
|
expect: ['Am', 'F', 'C', 'G', 'Em'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chromatic-null',
|
||||||
|
description: 'non-repeating chromatic walk — no loop exists, must report null',
|
||||||
|
history: ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G'],
|
||||||
|
expect: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Failure modes of the pre-L-30 algorithm — fixed by L-30 ────────────────
|
||||||
|
{
|
||||||
|
id: 'vamp-2x4',
|
||||||
|
description: 'clean 2-chord vamp played 4× — must still report the pair, not a self-overlap',
|
||||||
|
history: reps(['Am', 'G'], 4),
|
||||||
|
expect: ['Am', 'G'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vamp-2-sustained',
|
||||||
|
description: 'sustained 2-chord vamp filling the whole window (×16) — the everyday two-chord jam',
|
||||||
|
history: reps(['Am', 'G'], 16),
|
||||||
|
expect: ['Am', 'G'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'spurious-substitution',
|
||||||
|
description: '3-chord loop ×4 with ONE substituted misdetection (Am read as E7 in the third rep) — must still report the 3-loop',
|
||||||
|
history: ['C', 'Am', 'F', 'C', 'Am', 'F', 'C', 'E7', 'F', 'C', 'Am', 'F'],
|
||||||
|
expect: ['C', 'Am', 'F'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'spurious-2of4',
|
||||||
|
description: '3-chord loop ×4 with an inserted misdetection in two different reps (E7, then Dm7) — realistic sustained noise',
|
||||||
|
history: ['C', 'Am', 'F', 'C', 'Am', 'E7', 'F', 'C', 'Am', 'F', 'C', 'Dm7', 'Am', 'F'],
|
||||||
|
expect: ['C', 'Am', 'F'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dup-commit',
|
||||||
|
description: 'consecutive duplicate commit of the same chord inside an otherwise clean 3-loop (C Am F C C Am F …) — dups must collapse',
|
||||||
|
history: ['C', 'Am', 'F', 'C', 'C', 'Am', 'F', 'C', 'Am', 'F'],
|
||||||
|
expect: ['C', 'Am', 'F'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'seven-x2',
|
||||||
|
description: '7-chord loop played 2× — beyond the pre-L-30 length-6 cap',
|
||||||
|
history: reps(['Em', 'G', 'D', 'A', 'Em', 'C', 'B7'], 2),
|
||||||
|
expect: ['Em', 'G', 'D', 'A', 'Em', 'C', 'B7'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'eight-x2',
|
||||||
|
description: '8-chord loop (extended andalusian form) played 2× — beyond the pre-L-30 cap',
|
||||||
|
history: reps(['Am', 'G', 'F', 'E7', 'Am', 'C', 'Dm', 'E7'], 2),
|
||||||
|
expect: ['Am', 'G', 'F', 'E7', 'Am', 'C', 'Dm', 'E7'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'section-change',
|
||||||
|
description: 'section change: 4-chord loop A ×3, then 3-chord loop B ×3 — must report B, the loop being played NOW',
|
||||||
|
history: [...reps(['C', 'G', 'Am', 'F'], 3), ...reps(['Dm7', 'G7', 'Cmaj7'], 3)],
|
||||||
|
expect: ['Dm7', 'G7', 'Cmaj7'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default LOOP_FIXTURES
|
||||||
+1586
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,539 @@
|
|||||||
|
// KB quality gate — validates src/data/kb/ against the contract in src/data/kb/SCHEMA.md.
|
||||||
|
// Run: node scripts/validate-kb.mjs (exit 1 on any error)
|
||||||
|
//
|
||||||
|
// Lib mode: scripts/smoke.mjs imports this file with KB_VALIDATE_AS_LIB=1 set to
|
||||||
|
// reuse the exported pure checks (checkLick, checkPianoRecipe, checkBassPlay,
|
||||||
|
// checkPianoLick, LEVELS, LICK_TECHNIQUES, PIANO_LICK_TECHNIQUES,
|
||||||
|
// PIANO_LICK_APPROACHES, PIANO_LICK_MAX_OFFSET, MAX_HAND_SPAN, MIN_PLAYS_BASS,
|
||||||
|
// BASS_APPROACHES, BASS_MAX_OFFSET) against in-memory fixtures — same logic,
|
||||||
|
// no copy. When the env var is absent the script runs the full KB validation
|
||||||
|
// as before.
|
||||||
|
import { readdirSync, existsSync } from 'node:fs'
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { CHORD_TYPES } from '../src/lib/theory.js'
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const KB = join(ROOT, 'src', 'data', 'kb')
|
||||||
|
|
||||||
|
const MODES = ['major', 'minor', 'dorian', 'phrygian', 'lydian', 'mixolydian']
|
||||||
|
const OPEN_PC = [4, 9, 2, 7, 11, 4] // EADGBe low-E first
|
||||||
|
const PERFECT_FIFTH = 7
|
||||||
|
const MIN_PROGRESSIONS = 4
|
||||||
|
const MIN_PLAYS = 2
|
||||||
|
// Bass floor is 1, not 2 (SCHEMA.md "Bass play"): the band wants one bassline
|
||||||
|
// at a time, and rule 4's "idiomatically different" bar invites filler at 2.
|
||||||
|
export const MIN_PLAYS_BASS = 1
|
||||||
|
const MAX_SPAN = 4
|
||||||
|
|
||||||
|
// Optional progression/lick difficulty tags (SCHEMA.md — absent = 'foundation').
|
||||||
|
export const LEVELS = ['foundation', 'intermediate']
|
||||||
|
// Fixed technique vocabulary for licks — both the techniques[] summary and each
|
||||||
|
// tab note's optional technique must come from this list (SCHEMA.md).
|
||||||
|
export const LICK_TECHNIQUES = [
|
||||||
|
'hammer-on', 'pull-off', 'slide', 'bend',
|
||||||
|
'double-stop', 'ghost-note', 'chromatic-approach', 'vibrato',
|
||||||
|
]
|
||||||
|
const LICK_MAX_FRET = 15
|
||||||
|
|
||||||
|
const errors = []
|
||||||
|
const err = (where, msg) => errors.push(`${where}: ${msg}`)
|
||||||
|
|
||||||
|
// Resolve a degree string ('3', 'b9', '13'…) to a pitch class relative to the
|
||||||
|
// chord root, through the quality's intervals where the degree is quality-dependent.
|
||||||
|
function resolveDegree(deg, quality) {
|
||||||
|
const iv = CHORD_TYPES[quality].intervals
|
||||||
|
const fixed = { 1: 0, b9: 1, 9: 2, '#9': 3, 11: 5, '#11': 6, b5: 6, b13: 8, 13: 9, 6: 9, b3: 3, b7: 10 }
|
||||||
|
if (deg === '3') return iv.find(i => i === 3 || i === 4) ?? iv.find(i => i === 2 || i === 5) ?? null
|
||||||
|
if (deg === '5') return iv.find(i => i === 6 || i === 7 || i === 8) ?? null
|
||||||
|
if (deg === '7') return iv.find(i => i === 9 || i === 10 || i === 11) ?? null
|
||||||
|
return fixed[deg] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkGuitarShape(where, chordStep, quality) {
|
||||||
|
const { shape, extensions = [] } = chordStep
|
||||||
|
if (!shape) return err(where, 'missing shape')
|
||||||
|
const strings = shape.offsets ?? shape.frets
|
||||||
|
if (!Array.isArray(strings) || strings.length !== 6)
|
||||||
|
return err(where, 'offsets/frets must be an array of 6 (low E first)')
|
||||||
|
const isMovable = !!shape.offsets
|
||||||
|
|
||||||
|
if (isMovable) {
|
||||||
|
if (!(shape.rootStr >= 1 && shape.rootStr <= 6)) return err(where, `bad rootStr ${shape.rootStr}`)
|
||||||
|
if (strings[6 - shape.rootStr] !== 0) return err(where, 'offset on the root string must be 0')
|
||||||
|
} else {
|
||||||
|
if (!(shape.onlyRoot >= 0 && shape.onlyRoot <= 11)) return err(where, 'open shape needs onlyRoot (pc 0-11)')
|
||||||
|
}
|
||||||
|
|
||||||
|
const fretted = strings.filter(f => f !== 'x')
|
||||||
|
if (fretted.some(f => !Number.isInteger(f) || f < -2 || f > 15))
|
||||||
|
return err(where, `bad fret values: ${JSON.stringify(strings)}`)
|
||||||
|
const nonOpen = fretted.filter(f => f !== 0)
|
||||||
|
if (nonOpen.length && Math.max(...nonOpen) - Math.min(...nonOpen) > MAX_SPAN)
|
||||||
|
return err(where, `fret span > ${MAX_SPAN} — not intermediate-friendly`)
|
||||||
|
|
||||||
|
// Pitch-class verification: every sounded note must belong to the chord
|
||||||
|
// (quality intervals + declared extensions); defining tones must be present.
|
||||||
|
const iv = CHORD_TYPES[quality].intervals
|
||||||
|
const allowed = new Set(iv)
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const pc = resolveDegree(ext, quality)
|
||||||
|
if (pc === null) return err(where, `unresolvable extension '${ext}' for ${quality}`)
|
||||||
|
allowed.add(pc)
|
||||||
|
}
|
||||||
|
const rootRel = isMovable ? (12 - OPEN_PC[6 - shape.rootStr]) % 12 : null
|
||||||
|
const sounded = new Set()
|
||||||
|
strings.forEach((f, i) => {
|
||||||
|
if (f === 'x') return
|
||||||
|
const pc = isMovable
|
||||||
|
? (OPEN_PC[i] + rootRel + f + 24) % 12
|
||||||
|
: (OPEN_PC[i] + f - shape.onlyRoot + 24) % 12
|
||||||
|
sounded.add(pc)
|
||||||
|
})
|
||||||
|
for (const pc of sounded)
|
||||||
|
if (!allowed.has(pc)) return err(where, `sounded pc ${pc} is not in ${quality} (+ext) — shape misspells the chord`)
|
||||||
|
const required = iv.filter(i =>
|
||||||
|
i !== PERFECT_FIFTH
|
||||||
|
&& !(chordStep.rootless && i === 0)
|
||||||
|
&& !(chordStep.omit3 && (i === 3 || i === 4)))
|
||||||
|
for (const pc of required)
|
||||||
|
if (!sounded.has(pc)) return err(where, `defining tone pc ${pc} of ${quality} missing from shape`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Piano hand-span rule (SCHEMA.md rule 3: "one hand per recipe stays within a
|
||||||
|
// 10th"). Enforced as ≤ 15 semitones — a minor 10th, the widest reading of
|
||||||
|
// "a 10th" — so the hand-verified 14-semitone ø11 rootless voicing in
|
||||||
|
// jazz/piano.js (P-22) stays legal while anything wider fails. Task C-22.
|
||||||
|
export const MAX_HAND_SPAN = 15
|
||||||
|
|
||||||
|
// Resolve one hand's degree list to stacked absolute semitone offsets per the
|
||||||
|
// documented convention (src/data/kb/jazz/piano.js header, ~line 14): order
|
||||||
|
// inside a hand = voicing order low→high, each note placed in the nearest
|
||||||
|
// position strictly above the previous (a repeated pitch class = octave up).
|
||||||
|
// Returns null if any degree is unresolvable (reported separately by caller).
|
||||||
|
function stackHand(degs, quality) {
|
||||||
|
const notes = []
|
||||||
|
for (const d of degs) {
|
||||||
|
const pc = resolveDegree(d, quality)
|
||||||
|
if (pc === null) return null
|
||||||
|
if (!notes.length) { notes.push(pc); continue }
|
||||||
|
const prev = notes[notes.length - 1]
|
||||||
|
const step = (pc - (prev % 12) + 12) % 12
|
||||||
|
notes.push(prev + (step === 0 ? 12 : step))
|
||||||
|
}
|
||||||
|
return notes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure piano-recipe validation. Returns an array of where-prefixed error
|
||||||
|
// strings (empty = valid). Exported for reuse by scripts/smoke.mjs (lib mode).
|
||||||
|
export function checkPianoRecipe(where, chordStep, quality) {
|
||||||
|
const out = []
|
||||||
|
const e = (msg) => out.push(`${where}: ${msg}`)
|
||||||
|
if (!CHORD_TYPES[quality]) { e(`unknown quality '${quality}'`); return out }
|
||||||
|
const { recipe } = chordStep
|
||||||
|
if (!recipe) { e('missing recipe'); return out }
|
||||||
|
for (const hand of ['LH', 'RH']) {
|
||||||
|
const degs = recipe[hand]
|
||||||
|
if (degs === undefined) continue
|
||||||
|
if (!Array.isArray(degs) || !degs.length) { e(`${hand} must be a non-empty array`); continue }
|
||||||
|
if (degs.length > 5) e(`${hand} has ${degs.length} notes — one hand, max 5`)
|
||||||
|
for (const d of degs)
|
||||||
|
if (resolveDegree(d, quality) === null) e(`unresolvable degree '${d}' for ${quality}`)
|
||||||
|
const stacked = stackHand(degs, quality)
|
||||||
|
if (stacked === null) continue // unresolvable degree already reported
|
||||||
|
const span = stacked[stacked.length - 1] - stacked[0]
|
||||||
|
if (span > MAX_HAND_SPAN)
|
||||||
|
e(`${hand} [${degs.join(' ')}] spans ${span} semitones stacked low→high — max ${MAX_HAND_SPAN} (a minor 10th; SCHEMA rule 3, one hand within a 10th)`)
|
||||||
|
}
|
||||||
|
if (recipe.LH === undefined && recipe.RH === undefined) e('recipe needs LH and/or RH')
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bass plays (SCHEMA.md "Bass play") ────────────────────────────────────────
|
||||||
|
// Degree-based per-station patterns: one chords[] entry per progression step,
|
||||||
|
// each a non-empty ORDERED pattern of {deg,…} chord/color tones (resolved
|
||||||
|
// through the step's quality — a degree can't misspell a pitch class) and
|
||||||
|
// typed {approach,…} notes whose pitch is DERIVED from the next station's
|
||||||
|
// root, so the validator can allow the non-chord tone without blessing
|
||||||
|
// arbitrary chromatics. Data never encodes strings/frets (key-agnostic,
|
||||||
|
// hard rule 1); the renderer places patterns on E–A–D–G, frets 0–15.
|
||||||
|
export const BASS_APPROACHES = ['chrom-below', 'chrom-above', 'fifth-of-next']
|
||||||
|
// Widest legal offset above the root: an octave + a fifth keeps every pattern
|
||||||
|
// placeable on E–A–D–G within frets 0–15 in one position.
|
||||||
|
export const BASS_MAX_OFFSET = 19
|
||||||
|
const BASS_BEATS_PER_BAR = 4 // patterns are notated in 4 — 12/8 is `feel`
|
||||||
|
const BASS_MAX_NOTES_PER_BAR = 8 // straight-8ths density cap (rule 3)
|
||||||
|
|
||||||
|
// Pure bass-play validation. Returns an array of where-prefixed error strings
|
||||||
|
// (empty = valid). Exported for reuse by scripts/smoke.mjs (lib mode).
|
||||||
|
export function checkBassPlay(where, play, prog) {
|
||||||
|
const out = []
|
||||||
|
const e = (msg) => out.push(`${where}: ${msg}`)
|
||||||
|
if (typeof play.feel !== 'string' || !play.feel)
|
||||||
|
e("feel required — the groove in one line (e.g. 'swung 8ths, locked with the kick')")
|
||||||
|
if (play.level !== undefined && !LEVELS.includes(play.level))
|
||||||
|
e(`level must be one of ${LEVELS.join(' | ')}, got '${play.level}'`)
|
||||||
|
if (!Array.isArray(play.chords) || play.chords.length !== prog.degrees.length) {
|
||||||
|
e(`chords length ${play.chords?.length} ≠ progression length ${prog.degrees.length}`)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
play.chords.forEach((step, ci) => {
|
||||||
|
const cw = `chord[${ci}] (${prog.rn?.[ci] ?? ci})`
|
||||||
|
const quality = prog.qualities[ci]
|
||||||
|
if (!CHORD_TYPES[quality]) return e(`${cw}: unknown quality '${quality}'`)
|
||||||
|
const bars = prog.bars?.[ci] ?? 1
|
||||||
|
const pat = step?.pattern
|
||||||
|
if (!Array.isArray(pat) || !pat.length)
|
||||||
|
return e(`${cw}: pattern must be a non-empty ordered array of notes`)
|
||||||
|
if (pat.length > BASS_MAX_NOTES_PER_BAR * bars)
|
||||||
|
e(`${cw}: ${pat.length} notes > ${BASS_MAX_NOTES_PER_BAR * bars} (8ths density cap over ${bars} bar(s)) — not intermediate-friendly`)
|
||||||
|
let approachSeen = false
|
||||||
|
let rootSeen = false
|
||||||
|
let lastBeat = -Infinity
|
||||||
|
pat.forEach((n, ni) => {
|
||||||
|
const nw = `${cw} pattern[${ni}]`
|
||||||
|
if (!n || typeof n !== 'object') return e(`${nw}: note must be an object ({deg,…} or {approach,…})`)
|
||||||
|
const isDeg = n.deg !== undefined
|
||||||
|
const isApproach = n.approach !== undefined
|
||||||
|
if (isDeg === isApproach) return e(`${nw}: exactly one of deg | approach per note`)
|
||||||
|
if (isApproach) {
|
||||||
|
approachSeen = true
|
||||||
|
if (!BASS_APPROACHES.includes(n.approach))
|
||||||
|
e(`${nw}: unknown approach '${n.approach}' — allowed: ${BASS_APPROACHES.join(', ')}`)
|
||||||
|
if (n.octave !== undefined)
|
||||||
|
e(`${nw}: octave applies to deg notes only (the renderer places approaches beside the next root)`)
|
||||||
|
} else {
|
||||||
|
if (approachSeen)
|
||||||
|
e(`${nw}: deg note after an approach — approach notes must close the pattern (they lead into the next chord)`)
|
||||||
|
if (typeof n.deg !== 'string') {
|
||||||
|
e(`${nw}: deg must be a degree STRING ('1', 'b7', …), got ${JSON.stringify(n.deg)}`)
|
||||||
|
} else {
|
||||||
|
const pc = resolveDegree(n.deg, quality)
|
||||||
|
if (pc === null) e(`${nw}: unresolvable degree '${n.deg}' for ${quality}`)
|
||||||
|
if (n.deg === '1') rootSeen = true
|
||||||
|
if (n.octave !== undefined && n.octave !== 0 && n.octave !== 1)
|
||||||
|
e(`${nw}: octave, when present, must be 0 or 1 — got ${JSON.stringify(n.octave)}`)
|
||||||
|
else if (pc !== null && pc + 12 * (n.octave === 1 ? 1 : 0) > BASS_MAX_OFFSET)
|
||||||
|
e(`${nw}: '${n.deg}' octave ${n.octave} sits ${pc + 12} semitones above the root — max ${BASS_MAX_OFFSET} (an octave + a fifth; keeps the pattern in one position on E–A–D–G)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n.technique !== undefined && !LICK_TECHNIQUES.includes(n.technique))
|
||||||
|
e(`${nw}: unknown technique '${n.technique}' — allowed: ${LICK_TECHNIQUES.join(', ')}`)
|
||||||
|
if (n.beat !== undefined) {
|
||||||
|
const maxBeat = BASS_BEATS_PER_BAR * bars
|
||||||
|
if (typeof n.beat !== 'number' || !(n.beat >= 1 && n.beat < maxBeat + 1))
|
||||||
|
e(`${nw}: beat must be a number in [1, ${maxBeat + 1}) for a ${bars}-bar step, got ${JSON.stringify(n.beat)}`)
|
||||||
|
else if (n.beat < lastBeat)
|
||||||
|
e(`${nw}: beat ${n.beat} < previous beat ${lastBeat} — beats must be non-decreasing in pattern order`)
|
||||||
|
else lastBeat = n.beat
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!rootSeen)
|
||||||
|
e(`${cw}: pattern never states the root ('1') — a bassline grounds the chord (SCHEMA "Bass play" root rule)`)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure lick validation (SCHEMA.md "Licks" section). Returns an array of error
|
||||||
|
// strings (already where-prefixed); mutates seenIds by adding the lick's id so
|
||||||
|
// ids stay globally unique across ALL progressions and licks (same rule as
|
||||||
|
// progression ids). Exported for reuse by scripts/smoke.mjs.
|
||||||
|
export function checkLick(where, lick, style, seenIds) {
|
||||||
|
const out = []
|
||||||
|
const e = (msg) => out.push(`${where}: ${msg}`)
|
||||||
|
if (!lick || typeof lick !== 'object') { e('lick must be an object'); return out }
|
||||||
|
if (typeof lick.id !== 'string' || !lick.id.startsWith(`${style}-`))
|
||||||
|
e(`id must be a string starting with '${style}-'`)
|
||||||
|
else if (seenIds.has(lick.id)) e(`duplicate id '${lick.id}' (ids are global across progressions AND licks)`)
|
||||||
|
else seenIds.add(lick.id)
|
||||||
|
if (!lick.name) e('name missing')
|
||||||
|
if (!LEVELS.includes(lick.level)) e(`level must be one of ${LEVELS.join(' | ')}, got '${lick.level}'`)
|
||||||
|
if (typeof lick.chordContext !== 'string' || !lick.chordContext)
|
||||||
|
e("chordContext missing (which chord/station the lick fits, e.g. 'dom7' or 'over the I7')")
|
||||||
|
const summary = new Set()
|
||||||
|
if (!Array.isArray(lick.techniques)) e('techniques must be an array (may be empty for a plain-picked lick)')
|
||||||
|
else for (const t of lick.techniques) {
|
||||||
|
if (!LICK_TECHNIQUES.includes(t)) e(`unknown technique '${t}' — allowed: ${LICK_TECHNIQUES.join(', ')}`)
|
||||||
|
summary.add(t)
|
||||||
|
}
|
||||||
|
if (!Array.isArray(lick.tab) || !lick.tab.length) { e('tab must be a non-empty ordered array of notes'); return out }
|
||||||
|
lick.tab.forEach((note, i) => {
|
||||||
|
const nw = `tab[${i}]`
|
||||||
|
if (!note || typeof note !== 'object') return e(`${nw} must be an object {string, fret, technique?}`)
|
||||||
|
if (!Number.isInteger(note.string) || note.string < 1 || note.string > 6)
|
||||||
|
e(`${nw} string must be an integer 1–6 (1 = high e, 6 = low E), got ${JSON.stringify(note.string)}`)
|
||||||
|
if (!Number.isInteger(note.fret) || note.fret < 0 || note.fret > LICK_MAX_FRET)
|
||||||
|
e(`${nw} fret must be an integer 0–${LICK_MAX_FRET}, got ${JSON.stringify(note.fret)}`)
|
||||||
|
if (note.technique !== undefined) {
|
||||||
|
if (!LICK_TECHNIQUES.includes(note.technique))
|
||||||
|
e(`${nw} unknown technique '${note.technique}' — allowed: ${LICK_TECHNIQUES.join(', ')}`)
|
||||||
|
else if (!summary.has(note.technique))
|
||||||
|
e(`${nw} technique '${note.technique}' must also appear in the lick's techniques[] summary`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Piano licks (SCHEMA.md "Piano licks") ────────────────────────────────────
|
||||||
|
// Degree-based melodic phrases over ONE explicit quality (guitar tab is
|
||||||
|
// instrument-truth and needs no quality; degrees need a context to resolve
|
||||||
|
// through — chordContext stays the human sentence, `quality` is the machine
|
||||||
|
// truth). Approaches are typed and DERIVED: each targets the next deg note in
|
||||||
|
// the lick (there is no "next station" inside a self-contained lick), so the
|
||||||
|
// validator can allow the non-chord tone without blessing arbitrary
|
||||||
|
// chromatics — and an approach can never close a lick (nothing to target).
|
||||||
|
//
|
||||||
|
// Piano technique vocabulary — deliberately NOT LICK_TECHNIQUES: keys don't
|
||||||
|
// bend, hammer, pull off, or sustain vibrato; chromatic-approach is redundant
|
||||||
|
// (approaches are typed notes here). The three shared words keep their
|
||||||
|
// guitar-lick meanings; grace-note (the crushed blues/gospel ornament) is
|
||||||
|
// piano-specific. smoke.mjs guards both lists' consistency.
|
||||||
|
export const PIANO_LICK_TECHNIQUES = ['slide', 'double-stop', 'ghost-note', 'grace-note']
|
||||||
|
export const PIANO_LICK_APPROACHES = ['chrom-below', 'chrom-above']
|
||||||
|
// Range cap on every RESOLVED offset (deg: pc + 12·octave; approach: derived):
|
||||||
|
// [0, 25] semitones above the root. Proof against MiniPiano's render window
|
||||||
|
// (absolute notes [0, 36], 0 = low C): place the root at its pitch class in
|
||||||
|
// the bottom octave (0–11); the highest possible note is then 11 + 25 = 36 —
|
||||||
|
// exactly the window's top key — so every legal lick fits in all 12 keys.
|
||||||
|
export const PIANO_LICK_MAX_OFFSET = 25
|
||||||
|
const PIANO_LICK_MAX_NOTES = 16 // 8ths over the 2-bar beat window (rule 3)
|
||||||
|
const PIANO_LICK_MAX_BEAT = 9 // exclusive: 1 ≤ beat < 9 (two 4/4 bars)
|
||||||
|
|
||||||
|
// Pure piano-lick validation. Returns an array of where-prefixed error strings
|
||||||
|
// (empty = valid); mutates seenIds like checkLick (shared global id
|
||||||
|
// namespace). Exported for reuse by scripts/smoke.mjs (lib mode).
|
||||||
|
export function checkPianoLick(where, lick, style, seenIds) {
|
||||||
|
const out = []
|
||||||
|
const e = (msg) => out.push(`${where}: ${msg}`)
|
||||||
|
if (!lick || typeof lick !== 'object') { e('lick must be an object'); return out }
|
||||||
|
if (typeof lick.id !== 'string' || !lick.id.startsWith(`${style}-`))
|
||||||
|
e(`id must be a string starting with '${style}-'`)
|
||||||
|
else if (seenIds.has(lick.id)) e(`duplicate id '${lick.id}' (ids are global across progressions AND licks)`)
|
||||||
|
else seenIds.add(lick.id)
|
||||||
|
if (!lick.name) e('name missing')
|
||||||
|
if (!LEVELS.includes(lick.level)) e(`level must be one of ${LEVELS.join(' | ')}, got '${lick.level}'`)
|
||||||
|
if (typeof lick.chordContext !== 'string' || !lick.chordContext)
|
||||||
|
e("chordContext missing (which chord/station the lick fits, e.g. 'over the ii7')")
|
||||||
|
const quality = lick.quality
|
||||||
|
if (!CHORD_TYPES[quality]) {
|
||||||
|
e(`quality must be a CHORD_TYPES key (the context every deg resolves through), got '${quality}'`)
|
||||||
|
return out // nothing below is checkable without a quality
|
||||||
|
}
|
||||||
|
const summary = new Set()
|
||||||
|
if (!Array.isArray(lick.techniques)) e('techniques must be an array (may be empty for a plain lick)')
|
||||||
|
else for (const t of lick.techniques) {
|
||||||
|
if (!PIANO_LICK_TECHNIQUES.includes(t))
|
||||||
|
e(`unknown piano technique '${t}' — allowed: ${PIANO_LICK_TECHNIQUES.join(', ')}`)
|
||||||
|
summary.add(t)
|
||||||
|
}
|
||||||
|
const notes = lick.notes
|
||||||
|
if (!Array.isArray(notes) || !notes.length) { e('notes must be a non-empty ordered array'); return out }
|
||||||
|
if (notes.length > PIANO_LICK_MAX_NOTES)
|
||||||
|
e(`${notes.length} notes > ${PIANO_LICK_MAX_NOTES} (8ths over two bars) — not intermediate-friendly`)
|
||||||
|
|
||||||
|
// Pass 1: resolved offset of every deg note (null = unresolvable/malformed),
|
||||||
|
// so approaches can look up their target (the NEXT deg note in order).
|
||||||
|
const degOffsets = notes.map((n) => {
|
||||||
|
if (!n || typeof n !== 'object' || n.deg === undefined || typeof n.deg !== 'string') return null
|
||||||
|
const pc = resolveDegree(n.deg, quality)
|
||||||
|
if (pc === null) return null
|
||||||
|
const oct = n.octave === undefined ? 0 : n.octave
|
||||||
|
return oct === 0 || oct === 1 || oct === 2 ? pc + 12 * oct : null
|
||||||
|
})
|
||||||
|
|
||||||
|
let lastBeat = -Infinity
|
||||||
|
let prevApproach = null // type of the immediately preceding approach note
|
||||||
|
notes.forEach((n, ni) => {
|
||||||
|
const nw = `notes[${ni}]`
|
||||||
|
if (!n || typeof n !== 'object') return e(`${nw}: note must be an object ({deg,…} or {approach,…})`)
|
||||||
|
const isDeg = n.deg !== undefined
|
||||||
|
const isApproach = n.approach !== undefined
|
||||||
|
if (isDeg === isApproach) return e(`${nw}: exactly one of deg | approach per note`)
|
||||||
|
if (isApproach) {
|
||||||
|
if (!PIANO_LICK_APPROACHES.includes(n.approach))
|
||||||
|
e(`${nw}: unknown approach '${n.approach}' — allowed: ${PIANO_LICK_APPROACHES.join(', ')} (piano licks have no next station; 'fifth-of-next' is bass-only)`)
|
||||||
|
if (n.octave !== undefined)
|
||||||
|
e(`${nw}: octave applies to deg notes only (approach pitch is derived from its target)`)
|
||||||
|
if (prevApproach === n.approach)
|
||||||
|
e(`${nw}: two consecutive '${n.approach}' approaches derive the identical pitch — write the note you mean as a deg, or alternate types (the enclosure)`)
|
||||||
|
// Target = the NEXT deg note in order (scan past intervening approaches).
|
||||||
|
const ti = notes.findIndex((m, i) => i > ni && m?.deg !== undefined)
|
||||||
|
if (ti === -1) {
|
||||||
|
e(`${nw}: approach cannot close a piano lick — it targets the NEXT deg note (the final note must be a deg)`)
|
||||||
|
} else if (degOffsets[ti] !== null) {
|
||||||
|
const derived = degOffsets[ti] + (n.approach === 'chrom-below' ? -1 : 1)
|
||||||
|
if (derived < 0)
|
||||||
|
e(`${nw}: chrom-below of a root-position target derives −1 — below the render window; raise the target an octave`)
|
||||||
|
else if (derived > PIANO_LICK_MAX_OFFSET)
|
||||||
|
e(`${nw}: derived pitch sits ${derived} semitones above the root — max ${PIANO_LICK_MAX_OFFSET} (fits MiniPiano's 37-key window for all 12 roots)`)
|
||||||
|
} // target exists but is itself malformed → its own error already reports it
|
||||||
|
prevApproach = n.approach
|
||||||
|
} else {
|
||||||
|
prevApproach = null
|
||||||
|
if (typeof n.deg !== 'string') {
|
||||||
|
e(`${nw}: deg must be a degree STRING ('1', 'b7', …), got ${JSON.stringify(n.deg)}`)
|
||||||
|
} else {
|
||||||
|
const pc = resolveDegree(n.deg, quality)
|
||||||
|
if (pc === null) e(`${nw}: unresolvable degree '${n.deg}' for ${quality}`)
|
||||||
|
if (n.octave !== undefined && n.octave !== 0 && n.octave !== 1 && n.octave !== 2)
|
||||||
|
e(`${nw}: octave, when present, must be 0, 1 or 2 — got ${JSON.stringify(n.octave)}`)
|
||||||
|
else if (pc !== null && degOffsets[ni] !== null && degOffsets[ni] > PIANO_LICK_MAX_OFFSET)
|
||||||
|
e(`${nw}: '${n.deg}' octave ${n.octave} sits ${degOffsets[ni]} semitones above the root — max ${PIANO_LICK_MAX_OFFSET} (fits MiniPiano's 37-key window for all 12 roots)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n.technique !== undefined) {
|
||||||
|
if (!PIANO_LICK_TECHNIQUES.includes(n.technique))
|
||||||
|
e(`${nw}: unknown piano technique '${n.technique}' — allowed: ${PIANO_LICK_TECHNIQUES.join(', ')}`)
|
||||||
|
else if (!summary.has(n.technique))
|
||||||
|
e(`${nw}: technique '${n.technique}' must also appear in the lick's techniques[] summary`)
|
||||||
|
}
|
||||||
|
if (n.beat !== undefined) {
|
||||||
|
if (typeof n.beat !== 'number' || !(n.beat >= 1 && n.beat < PIANO_LICK_MAX_BEAT))
|
||||||
|
e(`${nw}: beat must be a number in [1, ${PIANO_LICK_MAX_BEAT}) — a lick spans at most two 4/4 bars, got ${JSON.stringify(n.beat)}`)
|
||||||
|
else if (n.beat < lastBeat)
|
||||||
|
e(`${nw}: beat ${n.beat} < previous beat ${lastBeat} — beats must be non-decreasing in note order`)
|
||||||
|
else lastBeat = n.beat
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModule(path) {
|
||||||
|
return (await import(pathToFileURL(path).href)).default
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const styleDirs = readdirSync(KB, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name)
|
||||||
|
if (!styleDirs.length) { console.error('No style folders in src/data/kb/'); process.exit(1) }
|
||||||
|
|
||||||
|
const registry = existsSync(join(KB, 'index.js')) ? await loadModule(join(KB, 'index.js')) : null
|
||||||
|
if (!registry) err('kb/index.js', 'registry missing')
|
||||||
|
|
||||||
|
const allIds = new Set()
|
||||||
|
let totals = { styles: 0, progressions: 0, plays: 0, licks: 0 }
|
||||||
|
|
||||||
|
for (const style of styleDirs) {
|
||||||
|
const dir = join(KB, style)
|
||||||
|
const w = `kb/${style}`
|
||||||
|
if (registry && !registry[style]) err('kb/index.js', `style '${style}' not registered`)
|
||||||
|
|
||||||
|
const meta = existsSync(join(dir, 'meta.js')) ? await loadModule(join(dir, 'meta.js')) : null
|
||||||
|
if (!meta) { err(w, 'meta.js missing'); continue }
|
||||||
|
if (meta.id !== style) err(`${w}/meta.js`, `id '${meta.id}' ≠ folder '${style}'`)
|
||||||
|
for (const f of ['label', 'feel', 'character']) if (!meta[f]) err(`${w}/meta.js`, `missing ${f}`)
|
||||||
|
|
||||||
|
const progs = existsSync(join(dir, 'progressions.js')) ? await loadModule(join(dir, 'progressions.js')) : null
|
||||||
|
if (!Array.isArray(progs) || !progs.length) { err(w, 'progressions.js missing/empty'); continue }
|
||||||
|
if (progs.length < MIN_PROGRESSIONS) err(w, `${progs.length} progressions < ${MIN_PROGRESSIONS}`)
|
||||||
|
|
||||||
|
const progById = {}
|
||||||
|
for (const p of progs) {
|
||||||
|
const pw = `${w}/progressions.js [${p.id}]`
|
||||||
|
if (!p.id?.startsWith(`${style}-`)) err(pw, `id must start with '${style}-'`)
|
||||||
|
if (allIds.has(p.id)) err(pw, 'duplicate id'); allIds.add(p.id)
|
||||||
|
progById[p.id] = p
|
||||||
|
const n = p.degrees?.length
|
||||||
|
if (!n) { err(pw, 'degrees missing'); continue }
|
||||||
|
for (const [field, arr] of [['rn', p.rn], ['qualities', p.qualities], ['bars', p.bars]])
|
||||||
|
if (!Array.isArray(arr) || arr.length !== n) err(pw, `${field} length ≠ degrees length`)
|
||||||
|
if (p.degrees.some(d => !Number.isInteger(d) || d < 0 || d > 11)) err(pw, 'degrees must be ints 0-11')
|
||||||
|
for (const q of p.qualities ?? []) if (!CHORD_TYPES[q]) err(pw, `unknown quality '${q}'`)
|
||||||
|
if (!MODES.includes(p.mode)) err(pw, `unknown mode '${p.mode}'`)
|
||||||
|
if (!Array.isArray(p.songs) || !p.songs.length) err(pw, 'songs missing')
|
||||||
|
if (!p.tip) err(pw, 'tip missing')
|
||||||
|
// Optional difficulty tag — absent means 'foundation' (consumer default).
|
||||||
|
if (p.level !== undefined && !LEVELS.includes(p.level))
|
||||||
|
err(pw, `level, when present, must be one of ${LEVELS.join(' | ')} — got '${p.level}'`)
|
||||||
|
}
|
||||||
|
totals.styles++; totals.progressions += progs.length
|
||||||
|
|
||||||
|
for (const inst of ['guitar', 'piano', 'bass']) {
|
||||||
|
const file = join(dir, `${inst}.js`)
|
||||||
|
if (!existsSync(file)) continue
|
||||||
|
const pack = await loadModule(file)
|
||||||
|
const iw = `${w}/${inst}.js`
|
||||||
|
if (!pack.styleIntro) err(iw, 'styleIntro missing')
|
||||||
|
if (!Array.isArray(pack.comping) || !pack.comping.length) err(iw, 'comping missing')
|
||||||
|
if (inst !== 'bass' && (!pack.improv?.scales?.length || !pack.improv?.targetNotes))
|
||||||
|
err(iw, 'improv.scales / improv.targetNotes required')
|
||||||
|
|
||||||
|
const minPlays = inst === 'bass' ? MIN_PLAYS_BASS : MIN_PLAYS
|
||||||
|
for (const p of progs)
|
||||||
|
if ((pack.plays?.[p.id]?.length ?? 0) < minPlays)
|
||||||
|
err(iw, `progression '${p.id}' has < ${minPlays} play(s)`)
|
||||||
|
|
||||||
|
for (const [pid, plays] of Object.entries(pack.plays ?? {})) {
|
||||||
|
const prog = progById[pid]
|
||||||
|
if (!prog) { err(iw, `plays key '${pid}' is not a progression of this style`); continue }
|
||||||
|
plays.forEach((play, pi) => {
|
||||||
|
const lw = `${iw} ${pid} play[${pi}] "${play.label ?? '?'}"`
|
||||||
|
if (!play.label || !play.level || !play.tips) err(lw, 'label/level/tips required')
|
||||||
|
totals.plays++
|
||||||
|
if (inst === 'bass') { for (const m of checkBassPlay(lw, play, prog)) errors.push(m); return }
|
||||||
|
if (!Array.isArray(play.chords) || play.chords.length !== prog.degrees.length)
|
||||||
|
return err(lw, `chords length ≠ progression length ${prog.degrees.length}`)
|
||||||
|
play.chords.forEach((step, ci) => {
|
||||||
|
const cw = `${lw} chord[${ci}] (${prog.rn[ci]})`
|
||||||
|
if (inst === 'guitar') checkGuitarShape(cw, step, prog.qualities[ci])
|
||||||
|
else for (const m of checkPianoRecipe(cw, step, prog.qualities[ci])) errors.push(m)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional structured licks (SCHEMA.md "Licks" / "Piano licks") — a
|
||||||
|
// top-level `licks` key on the instrument pack. Absent is fine; when
|
||||||
|
// present it must validate. Routed by instrument: piano licks are
|
||||||
|
// degree-based (checkPianoLick); guitar licks are tab-based (checkLick).
|
||||||
|
if (pack.licks !== undefined) {
|
||||||
|
if (!Array.isArray(pack.licks) || !pack.licks.length) {
|
||||||
|
err(iw, 'licks, when present, must be a non-empty array')
|
||||||
|
} else {
|
||||||
|
const checkInstLick = inst === 'piano' ? checkPianoLick : checkLick
|
||||||
|
pack.licks.forEach((lick, li) => {
|
||||||
|
const lkw = `${iw} licks[${li}] "${lick?.id ?? '?'}"`
|
||||||
|
for (const m of checkInstLick(lkw, lick, style, allIds)) errors.push(m)
|
||||||
|
})
|
||||||
|
totals.licks += pack.licks.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Global progression-id uniqueness over the REGISTRY (task C-50) ────────────
|
||||||
|
// RelatedProgressions' ranking excludes the matched progression by ID ALONE
|
||||||
|
// (rankRelatedProgressions: `prog.id !== match.id` — no style comparison, task
|
||||||
|
// L-51), so a progression id repeated anywhere in the registry would silently
|
||||||
|
// drop an unrelated style's entry from the related list. The per-style loop
|
||||||
|
// above walks the FILES (kb/*/progressions.js) into `allIds`; this asserts the
|
||||||
|
// same invariant over kb/index.js — the object the components actually
|
||||||
|
// consume — so a registry wiring mistake the file walk never sees (a style
|
||||||
|
// registered against another style's progressions module, a shared/duplicated
|
||||||
|
// array) cannot reintroduce a duplicate.
|
||||||
|
if (registry) {
|
||||||
|
const seenRegIds = new Map() // id → style it first appeared under
|
||||||
|
for (const style of Object.keys(registry)) {
|
||||||
|
const progs = registry[style]?.progressions
|
||||||
|
if (!Array.isArray(progs)) continue
|
||||||
|
for (const p of progs) {
|
||||||
|
const rw = `kb/index.js [${style}]`
|
||||||
|
if (typeof p?.id !== 'string' || !p.id) { err(rw, 'registry progression with missing/non-string id'); continue }
|
||||||
|
if (seenRegIds.has(p.id))
|
||||||
|
err(rw, `progression id '${p.id}' already registered under style '${seenRegIds.get(p.id)}' — ids must be globally unique across the registry (RelatedProgressions excludes the matched progression by id alone)`)
|
||||||
|
else seenRegIds.set(p.id, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
console.error(`✗ KB validation failed — ${errors.length} error(s):\n`)
|
||||||
|
for (const e of errors) console.error(' ' + e)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const lickNote = totals.licks ? `, ${totals.licks} licks` : ''
|
||||||
|
console.log(`✓ KB valid — ${totals.styles} style(s), ${totals.progressions} progressions, ${totals.plays} plays${lickNote}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the full validation unless imported as a library (see header comment).
|
||||||
|
// Safe-by-default: an unset env var always means "run" — the gate can't be
|
||||||
|
// skipped by a path-comparison quirk.
|
||||||
|
if (!process.env.KB_VALIDATE_AS_LIB) await main()
|
||||||
+376
-22
@@ -1,12 +1,19 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, lazy, Suspense } from 'react'
|
import { useState, useCallback, useRef, useEffect, lazy, Suspense } from 'react'
|
||||||
import AudioCapture from './components/AudioCapture'
|
import AudioCapture from './components/AudioCapture'
|
||||||
import ProgressionBanner from './components/ProgressionBanner'
|
import ProgressionBanner from './components/ProgressionBanner'
|
||||||
import ProgressionSuggestions from './components/ProgressionSuggestions'
|
|
||||||
import Fretboard from './components/Fretboard'
|
import Fretboard from './components/Fretboard'
|
||||||
import BassFretboard from './components/BassFretboard'
|
import BassFretboard from './components/BassFretboard'
|
||||||
import Piano from './components/Piano'
|
import Piano from './components/Piano'
|
||||||
import Settings from './components/Settings'
|
import Settings from './components/Settings'
|
||||||
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
|
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
|
||||||
|
import ChordDetailModal from './components/ChordDetailModal'
|
||||||
|
import RelatedProgressions from './components/RelatedProgressions'
|
||||||
|
import TryThis from './components/TryThis'
|
||||||
|
import LoopStation from './components/LoopStation'
|
||||||
|
import JamGuide, { KnowledgeDock } from './components/JamGuide'
|
||||||
|
import { useLoopEngine } from './services/loopEngine'
|
||||||
|
import kb from './data/kb/index.js'
|
||||||
|
import { seedableLoop, buildRoulettePool } from './lib/match'
|
||||||
import settingIcon from './assets/setting-icon.png'
|
import settingIcon from './assets/setting-icon.png'
|
||||||
|
|
||||||
// ponytail: collapsible panels most users never open — lazy-load so the core
|
// ponytail: collapsible panels most users never open — lazy-load so the core
|
||||||
@@ -60,6 +67,43 @@ export default function App() {
|
|||||||
const [showDrumView, setShowDrumView] = useState(false)
|
const [showDrumView, setShowDrumView] = useState(false)
|
||||||
const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false))
|
const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false))
|
||||||
|
|
||||||
|
// ── Jam view (task L-50, one-screen.md §1.1) — pure UI/layout state ──────────
|
||||||
|
// Layer 1: CSS lock — at xl the page root becomes h-screen overflow-hidden and
|
||||||
|
// everything below the dashboard is unmounted. Layer 2: best-effort browser
|
||||||
|
// fullscreen (Promise-caught — a refusal leaves layer 1 fully working). The
|
||||||
|
// toggle never starts/stops listening and never touches audio state.
|
||||||
|
const [jamView, setJamView] = useState(false)
|
||||||
|
|
||||||
|
function enterJamView() {
|
||||||
|
setJamView(true)
|
||||||
|
document.documentElement.requestFullscreen?.()?.catch(() => {})
|
||||||
|
}
|
||||||
|
function exitJamView() {
|
||||||
|
setJamView(false)
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen?.()?.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape and the native fullscreen exit (any means) both restore normal flow —
|
||||||
|
// one state, never half-exited. Listeners active only while jamView.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jamView) return
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') exitJamView() }
|
||||||
|
const onFsChange = () => { if (!document.fullscreenElement) setJamView(false) }
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
document.addEventListener('fullscreenchange', onFsChange)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
document.removeEventListener('fullscreenchange', onFsChange)
|
||||||
|
}
|
||||||
|
}, [jamView]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Jam Guide → Fretboard cross-link (D-03) ──────────────────────────────────
|
||||||
|
// When a Roadmap station is tapped, JamGuide reports its {rootPc, quality}
|
||||||
|
// here and the main Fretboard highlights that chord's guide tones (3rd/7th).
|
||||||
|
// null = no station focused (Fretboard renders normally). Purely UI state —
|
||||||
|
// NOT read by any audio callback, so it stays out of the ref-sync contract.
|
||||||
|
const [jamFocusChord, setJamFocusChord] = useState(null) // { rootPc, quality } | null
|
||||||
|
|
||||||
// ── Mic permission error ──────────────────────────────────────────────────────
|
// ── Mic permission error ──────────────────────────────────────────────────────
|
||||||
const [micError, setMicError] = useState(null)
|
const [micError, setMicError] = useState(null)
|
||||||
|
|
||||||
@@ -84,6 +128,23 @@ export default function App() {
|
|||||||
const onsetTimestampsRef = useRef([])
|
const onsetTimestampsRef = useRef([])
|
||||||
const bpmSmoothRef = useRef(null)
|
const bpmSmoothRef = useRef(null)
|
||||||
|
|
||||||
|
// ── Loop station ─────────────────────────────────────────────────────────────
|
||||||
|
const {
|
||||||
|
slots,
|
||||||
|
masterLen,
|
||||||
|
setStream: loopSetStream,
|
||||||
|
handleSlotClick,
|
||||||
|
commitTrim,
|
||||||
|
cancelRecord,
|
||||||
|
retrimSlot,
|
||||||
|
deleteSlot,
|
||||||
|
setVolume: loopSetVolume,
|
||||||
|
addSlot: loopAddSlot,
|
||||||
|
audioCtxRef: loopAudioCtxRef,
|
||||||
|
masterStartRef: loopMasterStartRef,
|
||||||
|
masterLenRef: loopMasterLenRef,
|
||||||
|
} = useLoopEngine(bpm)
|
||||||
|
|
||||||
// ── Key: auto-detected + optional lock ───────────────────────────────────────
|
// ── Key: auto-detected + optional lock ───────────────────────────────────────
|
||||||
const [keyInfo, setKeyInfo] = useState(null) // auto-detected
|
const [keyInfo, setKeyInfo] = useState(null) // auto-detected
|
||||||
const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null
|
const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null
|
||||||
@@ -96,6 +157,14 @@ export default function App() {
|
|||||||
// ── Chord state ───────────────────────────────────────────────────────────────
|
// ── Chord state ───────────────────────────────────────────────────────────────
|
||||||
const [chordHistory, setChordHistory] = useState([])
|
const [chordHistory, setChordHistory] = useState([])
|
||||||
const [detectedProgression, setDetectedProgression] = useState(null)
|
const [detectedProgression, setDetectedProgression] = useState(null)
|
||||||
|
const [selectedChord, setSelectedChord] = useState(null)
|
||||||
|
|
||||||
|
// ── Jam Roulette (task L-60, jam-roulette.md) — pure UI/display state ─────────
|
||||||
|
// seedInfo: provenance of an unconfirmed rolled loop { styleLabel, name, bars }
|
||||||
|
// (null once live detection confirms or replaces it). Invariant (§3.5):
|
||||||
|
// seedInfo !== null ⇔ progressionVoteRef.current?.seeded === true.
|
||||||
|
const [seedInfo, setSeedInfo] = useState(null)
|
||||||
|
const [rouletteMenuOpen, setRouletteMenuOpen] = useState(false)
|
||||||
|
|
||||||
// ── Top key candidates (shown as quick-lock chips) ────────────────────────────
|
// ── Top key candidates (shown as quick-lock chips) ────────────────────────────
|
||||||
const [topKeyCandidates, setTopKeyCandidates] = useState([])
|
const [topKeyCandidates, setTopKeyCandidates] = useState([])
|
||||||
@@ -112,6 +181,13 @@ export default function App() {
|
|||||||
const progressionVoteRef = useRef(null)
|
const progressionVoteRef = useRef(null)
|
||||||
const progressionMissRef = useRef(0)
|
const progressionMissRef = useRef(0)
|
||||||
const pendingKeyRef = useRef(null)
|
const pendingKeyRef = useRef(null)
|
||||||
|
// Roulette session memory (UI-state refs, never read by audio code): the last
|
||||||
|
// 6 rolled progression ids (no-repeat), the last rolled root pc (§2), and the
|
||||||
|
// last resolved style id (for the "Re-roll — {style}" row).
|
||||||
|
const rouletteMemoryRef = useRef([])
|
||||||
|
const prevRootPcRef = useRef(null)
|
||||||
|
const lastRolledStyleRef = useRef(null)
|
||||||
|
const rouletteRef = useRef(null)
|
||||||
|
|
||||||
// Keep refs in sync
|
// Keep refs in sync
|
||||||
useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey])
|
useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey])
|
||||||
@@ -125,24 +201,78 @@ export default function App() {
|
|||||||
chromaIdxRef.current = 0
|
chromaIdxRef.current = 0
|
||||||
}, [config.chromaSmooth])
|
}, [config.chromaSmooth])
|
||||||
|
|
||||||
// ── Detect progression — require 2 consecutive identical results to commit ────
|
// ── Progression commit layer (task L-31) ─────────────────────────────────────
|
||||||
|
// Runs once per chord commit. progressionVoteRef holds
|
||||||
|
// { committedKey, candidateKey, candidateCount } — null until first evidence.
|
||||||
|
//
|
||||||
|
// Thresholds are in chord-commits (one detector run each):
|
||||||
|
// · COMMIT_VOTES = 2 — first commit needs 2 consecutive identical detections
|
||||||
|
// (≈ one bar). Post-L-30 detection is stable on clean loops so this lands
|
||||||
|
// immediately; a noisy 2-rep history flaps through data-faithful sub-cycles
|
||||||
|
// that never repeat twice in a row, so noise can't commit early.
|
||||||
|
// · REPLACE_VOTES = 3 — replacing a committed loop needs 3 consecutive
|
||||||
|
// detections of the SAME new loop: one transient detection (or an
|
||||||
|
// alternating flap) must never displace the loop the musician is still in;
|
||||||
|
// a genuine section change is detected consistently and just lands one
|
||||||
|
// commit later than a first commit would.
|
||||||
|
// · NULL_CLEAR = 6 — the detector only returns null once the loop has
|
||||||
|
// aged out of its 32-commit window (it needs 2 exact in-window
|
||||||
|
// occurrences): traced, that is ~28 commits of foreign material after
|
||||||
|
// the loop last played. Fills, turnarounds and window-boundary resumes
|
||||||
|
// yield non-null sub-cycle detections rather than nulls (traced), so
|
||||||
|
// they can NEVER clear a committed loop — a new established loop
|
||||||
|
// replaces via REPLACE_VOTES instead. A null run therefore means the
|
||||||
|
// jam truly left loop-land ~30 commits ago; 6 more (≈ two bars of
|
||||||
|
// structureless playing) confirms it wasn't a flicker before the
|
||||||
|
// display goes dark. (The old value 4 was sized as if nulls happened
|
||||||
|
// during fills — post-L-30 they don't.)
|
||||||
|
const COMMIT_VOTES = 2
|
||||||
|
const REPLACE_VOTES = 3
|
||||||
|
const NULL_CLEAR = 6
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const detected = detectRepeatingProgression(chordHistory)
|
const detected = detectRepeatingProgression(chordHistory)
|
||||||
|
const vote = progressionVoteRef.current
|
||||||
if (!detected) {
|
if (!detected) {
|
||||||
progressionMissRef.current++
|
progressionMissRef.current++
|
||||||
// Clear stale loop after 4 chord changes with no pattern found
|
// L-60 flag 1 (§3.4): a SEEDED card is an instruction, not an observation —
|
||||||
if (progressionMissRef.current >= 4) {
|
// ramp-up nulls (every seed's own effect run is miss 1, and no detection can
|
||||||
|
// land before commit 6) must NOT wipe it. It stays until confirmed, replaced
|
||||||
|
// by a consistently-detected different loop, re-rolled, or New Song.
|
||||||
|
if (progressionMissRef.current >= NULL_CLEAR && !vote?.seeded) {
|
||||||
setDetectedProgression(null)
|
setDetectedProgression(null)
|
||||||
progressionVoteRef.current = null
|
progressionVoteRef.current = null
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
progressionMissRef.current = 0
|
progressionMissRef.current = 0 // any detected structure keeps the committed loop alive
|
||||||
const key = detected.join(',')
|
const key = detected.join(',')
|
||||||
if (progressionVoteRef.current === key) {
|
|
||||||
|
if (vote && vote.committedKey === key) {
|
||||||
|
// Agreement with the committed loop — refresh it, drop any pending rival.
|
||||||
setDetectedProgression(detected)
|
setDetectedProgression(detected)
|
||||||
|
vote.candidateKey = null
|
||||||
|
vote.candidateCount = 0
|
||||||
|
// L-60 (§3.4): the seed is now a normal committed loop — the first agreeing
|
||||||
|
// live detection confirms it; drop the provenance flag + chip.
|
||||||
|
if (vote.seeded) { vote.seeded = false; setSeedInfo(null) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const committedKey = vote ? vote.committedKey : null
|
||||||
|
if (vote && vote.candidateKey === key) {
|
||||||
|
vote.candidateCount++
|
||||||
} else {
|
} else {
|
||||||
progressionVoteRef.current = key
|
// L-60 flag 3 (§3.4): preserve `seeded` across candidate rebuilds — a
|
||||||
|
// transient ghost sub-cycle must not strip the flag and resurrect flag 1's
|
||||||
|
// ramp-up kill through the side door.
|
||||||
|
progressionVoteRef.current = { committedKey, candidateKey: key, candidateCount: 1, seeded: vote?.seeded ?? false }
|
||||||
|
}
|
||||||
|
if (progressionVoteRef.current.candidateCount >= (committedKey ? REPLACE_VOTES : COMMIT_VOTES)) {
|
||||||
|
setDetectedProgression(detected)
|
||||||
|
progressionVoteRef.current = { committedKey: key, candidateKey: null, candidateCount: 0 }
|
||||||
|
// L-60 (§3.4/§3.5): a genuinely different loop replaced the seed — the new
|
||||||
|
// vote is detection-owned; end the seed provenance.
|
||||||
|
setSeedInfo(null)
|
||||||
}
|
}
|
||||||
}, [chordHistory])
|
}, [chordHistory])
|
||||||
|
|
||||||
@@ -165,6 +295,7 @@ export default function App() {
|
|||||||
effectiveKeyRef.current = null
|
effectiveKeyRef.current = null
|
||||||
setChordHistory([])
|
setChordHistory([])
|
||||||
setDetectedProgression(null)
|
setDetectedProgression(null)
|
||||||
|
setSeedInfo(null) // L-60 (§3.6): the seed clears with the full reset
|
||||||
setTopKeyCandidates([])
|
setTopKeyCandidates([])
|
||||||
setBpm(null)
|
setBpm(null)
|
||||||
setMicError(null)
|
setMicError(null)
|
||||||
@@ -194,6 +325,94 @@ export default function App() {
|
|||||||
effectiveKeyRef.current = keyInfo
|
effectiveKeyRef.current = keyInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Jam Roulette (task L-60, jam-roulette.md §2/§3.1) ────────────────────────
|
||||||
|
// Rolls a random key + interesting KB progression and seeds the EXACT state
|
||||||
|
// live detection writes (lockedKey + detectedProgression + a committed-shape
|
||||||
|
// progressionVoteRef), so the whole dashboard populates as if detected. All
|
||||||
|
// randomness is here (plain Math.random, no audio contact); inputs = the KB
|
||||||
|
// registry only. `styleId === 'surprise'` picks uniformly over the 10 styles.
|
||||||
|
function rollJam(styleId) {
|
||||||
|
const pool = buildRoulettePool(kb) // lazy memo — round-trip passers, len 2–8
|
||||||
|
const sid = styleId === 'surprise'
|
||||||
|
? Object.keys(kb)[Math.floor(Math.random() * Object.keys(kb).length)]
|
||||||
|
: styleId
|
||||||
|
const members = pool.byStyle.get(sid) ?? []
|
||||||
|
if (!members.length) return
|
||||||
|
|
||||||
|
// No-repeat memory (§2.2): exclude the last 6 rolled ids; if that empties the
|
||||||
|
// pool (small styles), fall back to excluding only the immediately previous.
|
||||||
|
const mem = rouletteMemoryRef.current
|
||||||
|
let candidates = members.filter(m => !mem.includes(m.id))
|
||||||
|
if (!candidates.length) {
|
||||||
|
const prev = mem[mem.length - 1]
|
||||||
|
candidates = members.filter(m => m.id !== prev)
|
||||||
|
if (!candidates.length) candidates = members
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weighted draw: weight = levelW × lenW (§2.2). Intermediate leans in (×2),
|
||||||
|
// the collapsed "4-bar-ish" sweet spot 3–7 leans in (×2).
|
||||||
|
const weightOf = (m) => {
|
||||||
|
const levelW = m.progression.level === 'intermediate' ? 2 : 1
|
||||||
|
const lenW = (m.collapsedLen >= 3 && m.collapsedLen <= 7) ? 2 : 1
|
||||||
|
return levelW * lenW
|
||||||
|
}
|
||||||
|
const totalW = candidates.reduce((s, m) => s + weightOf(m), 0)
|
||||||
|
let r = Math.random() * totalW
|
||||||
|
let picked = candidates[candidates.length - 1]
|
||||||
|
for (const m of candidates) { r -= weightOf(m); if (r <= 0) { picked = m; break } }
|
||||||
|
const prog = picked.progression
|
||||||
|
|
||||||
|
// Root: uniform over 12 pcs, SHARP spelling (§2.1 — only sharp names
|
||||||
|
// string-match live detection); don't repeat the previous roll's root.
|
||||||
|
let rolledPc = Math.floor(Math.random() * 12)
|
||||||
|
if (prevRootPcRef.current != null && rolledPc === prevRootPcRef.current) {
|
||||||
|
rolledPc = Math.floor(Math.random() * 12) // re-draw once
|
||||||
|
}
|
||||||
|
const loop = seedableLoop(prog, rolledPc)
|
||||||
|
if (!loop) return // pool guarantees this, but stay defensive
|
||||||
|
prevRootPcRef.current = rolledPc
|
||||||
|
|
||||||
|
// ── The seed writes (§3.1) — clean slate, then key + loop + committed vote ──
|
||||||
|
newSong() // §3.6: roulette = New Song + seed (a stale window poisons handoff)
|
||||||
|
const info = { root: NOTES[rolledPc], mode: prog.mode, confidence: 1 }
|
||||||
|
setLockedKey(info)
|
||||||
|
effectiveKeyRef.current = info // the quickLock precedent — detection uses it NOW
|
||||||
|
chordVotesRef.current = []
|
||||||
|
setDetectedProgression(loop)
|
||||||
|
progressionVoteRef.current = {
|
||||||
|
committedKey: loop.join(','), // the committed shape, L-31's own key
|
||||||
|
candidateKey: null,
|
||||||
|
candidateCount: 0,
|
||||||
|
seeded: true, // the one flag L-60 adds (§3.4)
|
||||||
|
}
|
||||||
|
progressionMissRef.current = 0
|
||||||
|
setSeedInfo({ styleLabel: kb[sid]?.meta?.label ?? sid, name: prog.name, bars: prog.bars })
|
||||||
|
|
||||||
|
// Session bookkeeping (§2.2) — survives New Song deliberately.
|
||||||
|
lastRolledStyleRef.current = sid
|
||||||
|
mem.push(picked.id)
|
||||||
|
while (mem.length > 6) mem.shift()
|
||||||
|
setRouletteMenuOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape / click-outside closes the roulette menu; focus returns to the button.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rouletteMenuOpen) return
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setRouletteMenuOpen(false)
|
||||||
|
rouletteRef.current?.querySelector('button')?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onDown = (e) => { if (!rouletteRef.current?.contains(e.target)) setRouletteMenuOpen(false) }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
document.removeEventListener('mousedown', onDown)
|
||||||
|
}
|
||||||
|
}, [rouletteMenuOpen])
|
||||||
|
|
||||||
// ── Waveform handler: feeds oscilloscope / drum view ─────────────────────────
|
// ── Waveform handler: feeds oscilloscope / drum view ─────────────────────────
|
||||||
const handleWaveform = useCallback((data) => {
|
const handleWaveform = useCallback((data) => {
|
||||||
if (showDebugRef.current || showDrumViewRef.current) {
|
if (showDebugRef.current || showDrumViewRef.current) {
|
||||||
@@ -370,7 +589,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell min-h-screen bg-surface text-white">
|
<div className={`min-h-screen bg-surface text-white p-3${jamView ? ' xl:h-screen xl:overflow-hidden xl:flex xl:flex-col' : ''}`}>
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<header className="mb-2 flex items-center justify-between gap-2 flex-wrap">
|
<header className="mb-2 flex items-center justify-between gap-2 flex-wrap">
|
||||||
@@ -510,6 +729,77 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Jam roulette (L-60, jam-roulette.md §1.1/§1.2) ── */}
|
||||||
|
<div className="relative ml-auto" ref={rouletteRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRouletteMenuOpen(o => !o)}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={rouletteMenuOpen}
|
||||||
|
aria-pressed={seedInfo ? true : undefined}
|
||||||
|
title="Jam roulette — roll a random key + progression to jam on"
|
||||||
|
className={`min-h-[32px] px-3 py-1 rounded-lg border text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||||
|
seedInfo
|
||||||
|
? 'border-accent/40 text-accent bg-accent/10 font-semibold'
|
||||||
|
: 'border-border text-gray-200 hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🎲 Jam roulette
|
||||||
|
</button>
|
||||||
|
{rouletteMenuOpen && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
aria-label="Pick a genre to roll"
|
||||||
|
className="absolute right-0 mt-1 w-56 bg-panel border border-border rounded-xl shadow-lg p-1 z-20"
|
||||||
|
>
|
||||||
|
{seedInfo && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => rollJam(lastRolledStyleRef.current)}
|
||||||
|
className="w-full text-left px-3 py-1.5 rounded-lg text-sm text-gray-200 hover:bg-accent/10 border-b border-border outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
⟳ Re-roll — {seedInfo.styleLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => rollJam('surprise')}
|
||||||
|
className="w-full text-left px-3 py-1.5 rounded-lg text-sm text-gray-200 hover:bg-accent/10 border-b border-border outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
✨ Surprise me
|
||||||
|
</button>
|
||||||
|
{Object.entries(kb).map(([id, s]) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => rollJam(id)}
|
||||||
|
className="w-full text-left px-3 py-1.5 rounded-lg text-sm text-gray-200 hover:bg-accent/10 outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
{s.meta?.label ?? id}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Jam view toggle (L-50, one-screen.md §1.1) — layout-only ── */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={jamView ? exitJamView : enterJamView}
|
||||||
|
aria-pressed={jamView}
|
||||||
|
title={jamView ? 'Exit jam view' : 'Jam view — the one-screen dashboard, fullscreen'}
|
||||||
|
className={`ml-auto min-h-[32px] px-3 py-1 rounded-lg text-sm border transition-all outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||||
|
jamView
|
||||||
|
? 'bg-accent/20 border-accent text-accent font-semibold'
|
||||||
|
: 'border-border text-gray-400 hover:border-gray-500 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{jamView ? '✕ Exit' : '⛶ Jam view'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AudioCapture
|
<AudioCapture
|
||||||
@@ -525,6 +815,7 @@ export default function App() {
|
|||||||
setMicError(true)
|
setMicError(true)
|
||||||
setIsListening(false)
|
setIsListening(false)
|
||||||
}}
|
}}
|
||||||
|
onStreamReady={loopSetStream}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{micError && (
|
{micError && (
|
||||||
@@ -534,28 +825,80 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Chord detail modal ── */}
|
||||||
|
<ChordDetailModal chord={selectedChord} onClose={() => setSelectedChord(null)} onChordClick={setSelectedChord} keyInfo={effectiveKey} chordHistory={chordHistory} />
|
||||||
|
|
||||||
{/* ── Progression banner ── */}
|
{/* ── Progression banner ── */}
|
||||||
<ProgressionBanner
|
<ProgressionBanner
|
||||||
chordHistory={chordHistory}
|
chordHistory={chordHistory}
|
||||||
keyInfo={effectiveKey}
|
keyInfo={effectiveKey}
|
||||||
detectedProgression={detectedProgression}
|
detectedProgression={detectedProgression}
|
||||||
currentChord={currentChord}
|
seedInfo={seedInfo}
|
||||||
|
onChordClick={setSelectedChord}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Instrument + progressions row ── */}
|
{/* ── The jam dashboard grid (task L-50, one-screen.md §1/§6): JamGuide
|
||||||
<div className="flex flex-col lg:flex-row gap-3 mb-3 items-stretch">
|
owns the two-column layout — LEFT: compact instrument view (chosen
|
||||||
<div className="w-full lg:w-[70%] min-w-0">
|
here, passed as the mainView slot) + licks strip + the related-
|
||||||
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} />}
|
progressions slot (RelatedProgressions, task L-51); RIGHT: the
|
||||||
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
suggested-voicings rail. ProgressionSuggestions is unmounted (file
|
||||||
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
kept) — its job split into the rail + RelatedProgressions per the
|
||||||
</div>
|
user directive. Follows the one global instrument selector. ── */}
|
||||||
|
<JamGuide
|
||||||
<div className="w-full lg:w-[30%] min-w-0 lg:relative">
|
detectedProgression={detectedProgression}
|
||||||
<div className="lg:absolute lg:inset-0">
|
keyInfo={effectiveKey}
|
||||||
<ProgressionSuggestions keyInfo={effectiveKey} currentChord={currentChord} />
|
chordHistory={chordHistory}
|
||||||
|
currentChord={currentChord}
|
||||||
|
onFocusChord={setJamFocusChord}
|
||||||
|
instrument={instrument}
|
||||||
|
mainView={
|
||||||
|
<>
|
||||||
|
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} jamFocusChord={jamFocusChord} compact />}
|
||||||
|
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} compact />}
|
||||||
|
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} compact />}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
relatedSlot={
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<TryThis
|
||||||
|
loop={detectedProgression}
|
||||||
|
keyInfo={effectiveKey}
|
||||||
|
currentChord={currentChord}
|
||||||
|
onChordClick={setSelectedChord}
|
||||||
|
instrument={instrument}
|
||||||
|
/>
|
||||||
|
<RelatedProgressions
|
||||||
|
loop={detectedProgression}
|
||||||
|
keyInfo={effectiveKey}
|
||||||
|
onChordClick={setSelectedChord}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
</div>
|
fill={jamView}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Below the dashboard — the learning / behind-the-scenes area (page
|
||||||
|
scroll in normal mode; UNMOUNTED in jam view, one-screen.md §1.1/§3:
|
||||||
|
conditional mount, not `hidden`, so collapsed chrome can't leak
|
||||||
|
height). ── */}
|
||||||
|
{!jamView && (
|
||||||
|
<>
|
||||||
|
{/* ── Loop station ── */}
|
||||||
|
<LoopStation
|
||||||
|
slots={slots}
|
||||||
|
bpm={bpm}
|
||||||
|
masterLen={masterLen}
|
||||||
|
audioCtxRef={loopAudioCtxRef}
|
||||||
|
masterStartRef={loopMasterStartRef}
|
||||||
|
masterLenRef={loopMasterLenRef}
|
||||||
|
onSlotClick={handleSlotClick}
|
||||||
|
onCommitTrim={commitTrim}
|
||||||
|
onCancelRecord={cancelRecord}
|
||||||
|
onRetrim={retrimSlot}
|
||||||
|
onDelete={deleteSlot}
|
||||||
|
onVolumeChange={loopSetVolume}
|
||||||
|
onAddSlot={loopAddSlot}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ── Behind the scenes — collapsible ── */}
|
{/* ── Behind the scenes — collapsible ── */}
|
||||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
@@ -613,6 +956,17 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
{showTuner && <div className="border-t border-border"><Suspense fallback={null}><Tuner /></Suspense></div>}
|
{showTuner && <div className="border-t border-border"><Suspense fallback={null}><Tuner /></Suspense></div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Knowledge Center — bottom browse & study dock (L-40, D-40 §5) ── */}
|
||||||
|
<KnowledgeDock
|
||||||
|
keyInfo={effectiveKey}
|
||||||
|
chordHistory={chordHistory}
|
||||||
|
currentChord={currentChord}
|
||||||
|
onChordClick={setSelectedChord}
|
||||||
|
instrument={instrument}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function detectBassPC(freqData, sampleRate, fftSize) {
|
|||||||
return ((bestMidi % 12) + 12) % 12
|
return ((bestMidi % 12) + 12) % 12
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError, audioDeviceId = null }) {
|
export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError, audioDeviceId = null, onStreamReady = null }) {
|
||||||
const audioCtxRef = useRef(null)
|
const audioCtxRef = useRef(null)
|
||||||
const timeBufRef = useRef(null)
|
const timeBufRef = useRef(null)
|
||||||
const freqBufRef = useRef(null)
|
const freqBufRef = useRef(null)
|
||||||
@@ -96,6 +96,7 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
|||||||
const onOnsetRef = useRef(onOnset)
|
const onOnsetRef = useRef(onOnset)
|
||||||
const onWaveformRef = useRef(onWaveform)
|
const onWaveformRef = useRef(onWaveform)
|
||||||
const onPermissionErrorRef = useRef(onPermissionError)
|
const onPermissionErrorRef = useRef(onPermissionError)
|
||||||
|
const onStreamReadyRef = useRef(onStreamReady)
|
||||||
const minClarityRef = useRef(minClarity)
|
const minClarityRef = useRef(minClarity)
|
||||||
const minVolumeRef = useRef(minVolume)
|
const minVolumeRef = useRef(minVolume)
|
||||||
const smoothRmsRef = useRef(0)
|
const smoothRmsRef = useRef(0)
|
||||||
@@ -106,6 +107,7 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
|||||||
useEffect(() => { onOnsetRef.current = onOnset }, [onOnset])
|
useEffect(() => { onOnsetRef.current = onOnset }, [onOnset])
|
||||||
useEffect(() => { onWaveformRef.current = onWaveform }, [onWaveform])
|
useEffect(() => { onWaveformRef.current = onWaveform }, [onWaveform])
|
||||||
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
|
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
|
||||||
|
useEffect(() => { onStreamReadyRef.current = onStreamReady }, [onStreamReady])
|
||||||
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
|
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
|
||||||
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
|
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
|
||||||
|
|
||||||
@@ -154,6 +156,7 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
|||||||
}
|
}
|
||||||
streamRef.current = stream
|
streamRef.current = stream
|
||||||
activeRef.current = true
|
activeRef.current = true
|
||||||
|
onStreamReadyRef.current?.(stream)
|
||||||
smoothRmsRef.current = 0
|
smoothRmsRef.current = 0
|
||||||
lastOnsetRef.current = 0
|
lastOnsetRef.current = 0
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ function noteColor(isChordTone, isPenta, isScale, mono = false) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BassFretboard({ keyInfo, currentChord, monoColor = false }) {
|
// `compact` (task L-50, one-screen.md §2): trimmed card chrome (p-3, legend
|
||||||
|
// merged onto the heading line) + a natural-width cap on the SVG (max-width =
|
||||||
|
// its viewBox width, so it never renders above scale 1.0).
|
||||||
|
export default function BassFretboard({ keyInfo, currentChord, monoColor = false, compact = false }) {
|
||||||
const { root, mode } = keyInfo ?? {}
|
const { root, mode } = keyInfo ?? {}
|
||||||
|
|
||||||
if (!root) return null
|
if (!root) return null
|
||||||
@@ -44,19 +47,40 @@ export default function BassFretboard({ keyInfo, currentChord, monoColor = false
|
|||||||
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
|
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
|
||||||
: new Set()
|
: new Set()
|
||||||
|
|
||||||
|
const heading = (
|
||||||
|
<p className={`text-sm text-gray-500 uppercase tracking-widest ${compact ? '' : 'mb-4'}`}>
|
||||||
|
Bass — {root} {mode}
|
||||||
|
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
const legend = (
|
||||||
|
// Critic mechanical fix (L-50 gate): non-compact keeps HEAD's exact class
|
||||||
|
// string so the non-compact render stays byte-identical to the committed one.
|
||||||
|
<div className={compact ? 'flex items-center text-xs text-gray-500 flex-wrap gap-3' : 'mt-3 flex gap-5 text-xs text-gray-500'}>
|
||||||
|
<span><span className="text-accent">●</span> Chord tone</span>
|
||||||
|
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
||||||
|
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
|
||||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
{compact ? (
|
||||||
Bass — {root} {mode}
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
{heading}
|
||||||
</p>
|
{legend}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
heading
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
||||||
width="100%"
|
width="100%"
|
||||||
height="auto"
|
height="auto"
|
||||||
style={{ display: 'block' }}
|
style={{ display: 'block', ...(compact ? { maxWidth: BOARD_W } : null) }}
|
||||||
>
|
>
|
||||||
{/* Fretboard background */}
|
{/* Fretboard background */}
|
||||||
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={3 * STRING_H + 12}
|
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={3 * STRING_H + 12}
|
||||||
@@ -139,11 +163,7 @@ export default function BassFretboard({ keyInfo, currentChord, monoColor = false
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
{!compact && legend}
|
||||||
<span><span className="text-accent">●</span> Chord tone</span>
|
|
||||||
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
|
||||||
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
// BassPatternCard — per-station authored bass pattern as compact 4-string tab
|
||||||
|
// (task L-42, per docs/design/integrated-glance.md §3's gallery-slot contract
|
||||||
|
// and SCHEMA.md "Bass play").
|
||||||
|
//
|
||||||
|
// Given a station's { rootPc, quality }, one play's per-station `pattern`, and
|
||||||
|
// the NEXT station's root (approach pitches derive from it — the loop wraps),
|
||||||
|
// this renders the REALIZED line: the degree/approach data resolved to actual
|
||||||
|
// pitches and placed on the bass. Visually it rhymes with LickCard's tab SVG
|
||||||
|
// (same string-line + surface-pill-number language, same technique-glyph
|
||||||
|
// vocabulary) but with the bass rendering convention from SCHEMA.md:
|
||||||
|
//
|
||||||
|
// standard 4-string tuning E–A–D–G, string 1 = G (HIGHEST) rendered on top,
|
||||||
|
// string 4 = low E at the bottom — the same "1 = highest string" counting as
|
||||||
|
// lick tab and `rootStr`, so no third scheme exists in this codebase.
|
||||||
|
//
|
||||||
|
// ── Realization rules (SCHEMA.md "Bass play") ─────────────────────────────────
|
||||||
|
// Pitch space: semitones above open low E (E1). Open strings sit at 0 (E),
|
||||||
|
// 5 (A), 10 (D), 15 (G); the playable ceiling is 30 (G string, fret 15).
|
||||||
|
//
|
||||||
|
// · The station ROOT lands on its lowest playable position:
|
||||||
|
// rootAbs = (rootPc − 4) mod 12 (open E's pitch class is 4).
|
||||||
|
// · A `deg` note sits at rootAbs + resolveDegree(deg, quality) + 12·octave.
|
||||||
|
// The schema caps the resolved offset at 19 semitones, so every deg note
|
||||||
|
// fits ≤ 30 by construction (11 + 19); anything unresolvable nulls the
|
||||||
|
// whole pattern and the card renders its placeholder instead of lying.
|
||||||
|
// · An `approach` note's PITCH CLASS derives from the next station's root:
|
||||||
|
// chrom-below = next−1 · chrom-above = next+1 · fifth-of-next = next+7
|
||||||
|
// (mod 12). Its OCTAVE is the placement nearest the previous realized note
|
||||||
|
// (approaches are terminal, so a previous note always exists), which keeps
|
||||||
|
// the walkup/walkdown contour the author described — e.g. the 12-bar bar-4
|
||||||
|
// walkup C–D–E♭ lands its E right above the E♭, not two octaves away.
|
||||||
|
// · 🚨 C-41 gate rule — the open-E floor: a chrom-below into a next root at
|
||||||
|
// open E (abs 0) has NO pitch below the instrument (−1 does not exist).
|
||||||
|
// When the nearest placement falls below abs 0 it is OCTAVE-DISPLACED up
|
||||||
|
// (+12): the approach plays a half-step under the OCTAVE root instead —
|
||||||
|
// e.g. D♯ at abs 11 leading into E. The symmetric ceiling guard (> 30 →
|
||||||
|
// −12) exists for completeness. This is the displacement rule the ledger
|
||||||
|
// row mandates; keep it.
|
||||||
|
// · String/fret: each realized pitch takes the HIGHEST-tuned open string at
|
||||||
|
// or below it — the minimum-fret assignment. Because the root starts at
|
||||||
|
// its lowest position (fret ≤ 11 worst case, usually ≤ 8) and strings are
|
||||||
|
// tuned in fourths, the whole pattern lands in one hand position within
|
||||||
|
// frets 0–15 (the schema's ≤ 19-semitone span guarantee).
|
||||||
|
//
|
||||||
|
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||||||
|
// Beat spacing when every note carries a `beat` (columns proportional to beat,
|
||||||
|
// faint beat numbers under the bottom string); plain even columns otherwise.
|
||||||
|
// Approach notes draw AMBER (the established secondary-tone colour) with a
|
||||||
|
// small arrow after the final one — they belong to the NEXT chord. Technique
|
||||||
|
// marks reuse the lick glyph vocabulary in its light form (amber letter/symbol
|
||||||
|
// above the note; ghost notes parenthesise and dim, as in LickCard).
|
||||||
|
//
|
||||||
|
// ── Playback (▶) ──────────────────────────────────────────────────────────────
|
||||||
|
// Sequential single-note scheduling through chordAudio's playVoicing (one call
|
||||||
|
// per note — playVoicing itself sorts/dedupes, which is wrong for a melodic
|
||||||
|
// line, so the ORDER lives here in setTimeout scheduling). Bass register:
|
||||||
|
// realized abs − 20 puts open E at chordAudio note −20 = E1 ≈ 41.2 Hz
|
||||||
|
// (chordAudio's space is 0 = C3; negatives are documented-legal input).
|
||||||
|
// Timing: beats at a fixed preview tempo when present, else even eighths.
|
||||||
|
// One pattern at a time module-wide: each play stops the previous sequence
|
||||||
|
// AND calls stopAll() so it never layers over a VoicingBrowser preview.
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { NOTES } from '../lib/theory'
|
||||||
|
import { resolveDegree } from './JamGuide'
|
||||||
|
import { playVoicing, stopAll } from '../lib/chordAudio'
|
||||||
|
|
||||||
|
// SVG palette — mirrors LickCard's token constants (SVG fills can't read
|
||||||
|
// Tailwind classes): amber = technique/approach, surface = backing pills.
|
||||||
|
const AMBER = '#f59e0b'
|
||||||
|
const FRET_TEXT = '#e5e7eb' // gray-200
|
||||||
|
const GHOST_TEXT = '#9ca3af' // gray-400
|
||||||
|
const STRING_LINE = '#3a3a3a'
|
||||||
|
const STRING_LABEL = '#6b7280' // gray-500
|
||||||
|
const BEAT_LABEL = '#6b7280'
|
||||||
|
const CARD_BG = '#0f0f0f' // token `surface`
|
||||||
|
|
||||||
|
// Strings top → bottom: 1 = G (highest), 4 = low E. `open` = semitones above E1.
|
||||||
|
const STRINGS = [
|
||||||
|
{ n: 1, name: 'G', open: 15 },
|
||||||
|
{ n: 2, name: 'D', open: 10 },
|
||||||
|
{ n: 3, name: 'A', open: 5 },
|
||||||
|
{ n: 4, name: 'E', open: 0 },
|
||||||
|
]
|
||||||
|
const OPEN_E_PC = 4 // pitch class of the low E string
|
||||||
|
const ABS_MAX = 30 // G string fret 15 — the playable ceiling
|
||||||
|
|
||||||
|
// Approach-type → semitone delta from the NEXT station's root (SCHEMA.md).
|
||||||
|
const APPROACH_DELTA = { 'chrom-below': -1, 'chrom-above': 1, 'fifth-of-next': 7 }
|
||||||
|
|
||||||
|
// Technique mark drawn above the note (light form of LickCard's glyphs; the
|
||||||
|
// full vocabulary is legal per SCHEMA.md — ghost-note is handled by the
|
||||||
|
// parenthesised dim label, double-stop by equal beats, chromatic-approach and
|
||||||
|
// unknown words get no mark, exactly like LickCard's graceful default).
|
||||||
|
const TECH_MARK = {
|
||||||
|
'hammer-on': 'h',
|
||||||
|
'pull-off': 'p',
|
||||||
|
slide: '⟋',
|
||||||
|
bend: '↑',
|
||||||
|
vibrato: '~',
|
||||||
|
}
|
||||||
|
|
||||||
|
const mod12 = (n) => ((n % 12) + 12) % 12
|
||||||
|
|
||||||
|
// abs (semitones above open E1 = MIDI 28) → "E1"/"C2"… scientific spelling.
|
||||||
|
function absName(abs) {
|
||||||
|
return `${NOTES[mod12(abs + OPEN_E_PC)]}${Math.floor((abs + 28) / 12) - 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Min-fret string assignment: highest-tuned open string at or below the pitch.
|
||||||
|
function placeOnString(abs) {
|
||||||
|
for (const s of STRINGS) {
|
||||||
|
if (abs >= s.open && abs - s.open <= 15) return { string: s.n, fret: abs - s.open }
|
||||||
|
}
|
||||||
|
return null // abs outside [0, 30] — caller nulls the pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* realizeBassPattern(pattern, rootPc, quality, nextRootPc)
|
||||||
|
* → [{ abs, string, fret, beat?, technique?, approach }] | null
|
||||||
|
*
|
||||||
|
* Pure realization per the header rules. Returns null on ANY malformed or
|
||||||
|
* unplaceable note so the caller can render a placeholder — authored data is
|
||||||
|
* validator-guaranteed, but a renderer must never crash on bad input.
|
||||||
|
*/
|
||||||
|
export function realizeBassPattern(pattern, rootPc, quality, nextRootPc) {
|
||||||
|
if (!Array.isArray(pattern) || pattern.length === 0) return null
|
||||||
|
if (!Number.isFinite(rootPc)) return null
|
||||||
|
const rootAbs = mod12(rootPc - OPEN_E_PC) // lowest playable root position
|
||||||
|
const out = []
|
||||||
|
let prev = rootAbs // approach placement anchor (root until a note lands)
|
||||||
|
for (const note of pattern) {
|
||||||
|
if (!note || typeof note !== 'object') return null
|
||||||
|
let abs
|
||||||
|
if (note.deg !== undefined && note.approach === undefined) {
|
||||||
|
const off = resolveDegree(String(note.deg), quality)
|
||||||
|
if (off === null || off === undefined) return null
|
||||||
|
abs = rootAbs + off + (note.octave === 1 ? 12 : 0)
|
||||||
|
} else if (note.approach !== undefined && note.deg === undefined) {
|
||||||
|
const delta = APPROACH_DELTA[note.approach]
|
||||||
|
if (delta === undefined || !Number.isFinite(nextRootPc)) return null
|
||||||
|
const pc = mod12(nextRootPc + delta)
|
||||||
|
const base = mod12(pc - OPEN_E_PC)
|
||||||
|
// Nearest octave placement to the previous note (ties resolve upward).
|
||||||
|
abs = base + 12 * Math.round((prev - base) / 12)
|
||||||
|
// C-41 open-E floor: chrom-below into a next root at open E resolves to
|
||||||
|
// abs −1, which does not exist on the instrument — octave-displace the
|
||||||
|
// approach up (+12: a half-step under the OCTAVE root). Ceiling mirror.
|
||||||
|
if (abs < 0) abs += 12
|
||||||
|
if (abs > ABS_MAX) abs -= 12
|
||||||
|
} else {
|
||||||
|
return null // exactly one of deg | approach per note (schema)
|
||||||
|
}
|
||||||
|
const placed = placeOnString(abs)
|
||||||
|
if (!placed) return null
|
||||||
|
out.push({
|
||||||
|
abs,
|
||||||
|
string: placed.string,
|
||||||
|
fret: placed.fret,
|
||||||
|
beat: Number.isFinite(note.beat) ? note.beat : undefined,
|
||||||
|
technique: typeof note.technique === 'string' ? note.technique : undefined,
|
||||||
|
approach: note.approach !== undefined,
|
||||||
|
})
|
||||||
|
prev = abs
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sequential playback (module-level: one pattern at a time, app-wide) ─────
|
||||||
|
|
||||||
|
const PREVIEW_BPM = 96 // relaxed shuffle-ish preview tempo (display is tempo-free)
|
||||||
|
|
||||||
|
let currentSeq = null // { timeouts: number[], handles: {stop}[] }
|
||||||
|
|
||||||
|
function stopPattern() {
|
||||||
|
if (!currentSeq) return
|
||||||
|
for (const t of currentSeq.timeouts) clearTimeout(t)
|
||||||
|
for (const h of currentSeq.handles) h.stop()
|
||||||
|
currentSeq = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function playPattern(realized) {
|
||||||
|
stopPattern()
|
||||||
|
stopAll() // never layer over a VoicingBrowser (or any other) preview
|
||||||
|
const beatMs = 60000 / PREVIEW_BPM
|
||||||
|
const hasBeats = realized.every((n) => Number.isFinite(n.beat))
|
||||||
|
// Beats drive onsets when authored; otherwise even eighths.
|
||||||
|
const times = realized.map((n, i) => (hasBeats ? (n.beat - 1) * beatMs : (i * beatMs) / 2))
|
||||||
|
const seq = { timeouts: [], handles: [] }
|
||||||
|
realized.forEach((n, i) => {
|
||||||
|
// Ring until the next distinct onset (equal beats = a dyad, same onset);
|
||||||
|
// the last note gets one beat. Small floor so ghost-short gaps still sound.
|
||||||
|
const nextT = times.slice(i + 1).find((t) => t > times[i])
|
||||||
|
const durMs = Math.max(160, (nextT !== undefined ? nextT - times[i] : beatMs) + 120)
|
||||||
|
seq.timeouts.push(
|
||||||
|
setTimeout(() => {
|
||||||
|
// abs − 20: open E1 in chordAudio's 0 = C3 note space (negatives legal).
|
||||||
|
seq.handles.push(playVoicing([n.abs - 20], { strumMs: 0, durMs, gain: 0.5 }))
|
||||||
|
}, times[i]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
currentSeq = seq
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SVG geometry (LickCard's idiom, 4 strings) ───────────────────────────────
|
||||||
|
|
||||||
|
const STR_GAP = 13
|
||||||
|
const PAD_T = 14
|
||||||
|
const PAD_B = 6
|
||||||
|
const PAD_L = 20
|
||||||
|
const PAD_R = 12
|
||||||
|
const COL_W = 26
|
||||||
|
const BEAT_ROW = 10 // extra bottom room for beat numbers when beats render
|
||||||
|
|
||||||
|
function layoutPattern(realized) {
|
||||||
|
const hasBeats =
|
||||||
|
realized.every((n) => Number.isFinite(n.beat)) &&
|
||||||
|
realized.every((n, i) => i === 0 || n.beat >= realized[i - 1].beat)
|
||||||
|
let xs, width, maxBeat, unit = null
|
||||||
|
if (hasBeats) {
|
||||||
|
// Columns proportional to beat; scale so the tightest gap ≥ one column.
|
||||||
|
const gaps = realized
|
||||||
|
.map((n, i) => (i > 0 ? n.beat - realized[i - 1].beat : 0))
|
||||||
|
.filter((g) => g > 0)
|
||||||
|
const minGap = gaps.length ? Math.min(...gaps) : 1
|
||||||
|
unit = Math.min(COL_W / minGap, COL_W * 4)
|
||||||
|
xs = realized.map((n) => PAD_L + (n.beat - 1) * unit + 10)
|
||||||
|
maxBeat = realized[realized.length - 1].beat
|
||||||
|
width = PAD_L + (maxBeat - 1) * unit + 20 + PAD_R
|
||||||
|
} else {
|
||||||
|
xs = realized.map((_, i) => PAD_L + i * COL_W + COL_W / 2)
|
||||||
|
width = PAD_L + realized.length * COL_W + PAD_R
|
||||||
|
maxBeat = null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hasBeats,
|
||||||
|
maxBeat,
|
||||||
|
unit,
|
||||||
|
width,
|
||||||
|
height: PAD_T + 3 * STR_GAP + PAD_B + (hasBeats ? BEAT_ROW : 0),
|
||||||
|
notes: realized.map((n, i) => ({
|
||||||
|
...n,
|
||||||
|
x: xs[i],
|
||||||
|
y: PAD_T + (n.string - 1) * STR_GAP,
|
||||||
|
ghost: n.technique === 'ghost-note',
|
||||||
|
label: n.technique === 'ghost-note' ? `(${n.fret})` : String(n.fret),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function PatternSvg({ layout, ariaLabel }) {
|
||||||
|
const { notes, width, height, hasBeats, maxBeat, unit } = layout
|
||||||
|
const bottomY = PAD_T + 3 * STR_GAP
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
width="100%"
|
||||||
|
style={{ display: 'block', height: 'auto', maxWidth: width * 1.4 }}
|
||||||
|
role="img"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{/* String lines — string 1 (G) on top, string 4 (low E) at the bottom. */}
|
||||||
|
{STRINGS.map((s, i) => {
|
||||||
|
const y = PAD_T + i * STR_GAP
|
||||||
|
return (
|
||||||
|
<g key={s.name}>
|
||||||
|
<line
|
||||||
|
x1={PAD_L - 6} y1={y} x2={width - PAD_R + 6} y2={y}
|
||||||
|
stroke={STRING_LINE} strokeWidth={i === 3 ? 1.4 : 1}
|
||||||
|
/>
|
||||||
|
<text x={7} y={y + 3} textAnchor="middle" fontSize={7} fill={STRING_LABEL}>
|
||||||
|
{s.name}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Beat numbers under the bottom string (only when beats are authored;
|
||||||
|
x mirrors the note columns: beat 1 at PAD_L + 10, `unit` px per beat). */}
|
||||||
|
{hasBeats &&
|
||||||
|
Array.from({ length: Math.floor(maxBeat + 1e-6) }, (_, b) => (
|
||||||
|
<text
|
||||||
|
key={`b${b}`}
|
||||||
|
x={PAD_L + b * unit + 10}
|
||||||
|
y={bottomY + BEAT_ROW}
|
||||||
|
textAnchor="middle" fontSize={6.5} fill={BEAT_LABEL}
|
||||||
|
>
|
||||||
|
{b + 1}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Technique marks (light lick vocabulary) above their notes. */}
|
||||||
|
{notes.map((n, i) =>
|
||||||
|
TECH_MARK[n.technique] ? (
|
||||||
|
<text
|
||||||
|
key={`t${i}`} x={n.x} y={n.y - 8} textAnchor="middle" fontSize={8}
|
||||||
|
fontStyle="italic" fill={AMBER}
|
||||||
|
>
|
||||||
|
{TECH_MARK[n.technique]}
|
||||||
|
</text>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fret numbers on surface pills; approach notes amber (next chord's). */}
|
||||||
|
{notes.map((n, i) => {
|
||||||
|
const w = n.label.length * 5.2 + 3
|
||||||
|
const isLast = i === notes.length - 1
|
||||||
|
return (
|
||||||
|
<g key={`n${i}`}>
|
||||||
|
<rect x={n.x - w / 2} y={n.y - 5.5} width={w} height={11} rx={2} fill={CARD_BG} />
|
||||||
|
<text
|
||||||
|
x={n.x} y={n.y + 3.2} textAnchor="middle" fontSize={9.5} fontWeight="600"
|
||||||
|
fill={n.approach ? AMBER : n.ghost ? GHOST_TEXT : FRET_TEXT}
|
||||||
|
>
|
||||||
|
{n.label}
|
||||||
|
</text>
|
||||||
|
{/* The final approach points at the next chord. */}
|
||||||
|
{n.approach && isLast && (
|
||||||
|
<text x={n.x + w / 2 + 5} y={n.y + 3} textAnchor="middle" fontSize={8} fill={AMBER}>
|
||||||
|
→
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── The card ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Same ▶ pill as VoicingBrowser's gallery cells (its PlayButton is file-local;
|
||||||
|
// classes mirrored so the two galleries read identically).
|
||||||
|
function PlayButton({ ariaLabel, onClick }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
onClick={onClick}
|
||||||
|
className={
|
||||||
|
'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-accent ' +
|
||||||
|
'bg-surface px-2.5 text-xs font-semibold text-accent outline-none transition ' +
|
||||||
|
'hover:bg-accent hover:text-black focus-visible:ring-2 focus-visible:ring-accent'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 12 12" className="h-3 w-3 fill-current">
|
||||||
|
<path d="M2.5 1.5v9l8-4.5z" />
|
||||||
|
</svg>
|
||||||
|
Play
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <BassPatternCard rootPc quality nextRootPc pattern playLabel feel note chordLabel />
|
||||||
|
*
|
||||||
|
* Pure/presentational gallery cell for one station of one bass play. Renders
|
||||||
|
* the realized 4-string tab + the authored per-station note + its own ▶.
|
||||||
|
* Malformed/unplaceable patterns render an honest placeholder, never crash.
|
||||||
|
*/
|
||||||
|
export default function BassPatternCard({
|
||||||
|
rootPc,
|
||||||
|
quality,
|
||||||
|
nextRootPc,
|
||||||
|
pattern,
|
||||||
|
playLabel,
|
||||||
|
feel,
|
||||||
|
note,
|
||||||
|
chordLabel,
|
||||||
|
}) {
|
||||||
|
const realized = realizeBassPattern(pattern, rootPc, quality, nextRootPc)
|
||||||
|
|
||||||
|
// Unmount (loop/style/instrument change) silences any running sequence —
|
||||||
|
// module-level state, so this is idempotent across sibling cards.
|
||||||
|
useEffect(() => () => stopPattern(), [])
|
||||||
|
|
||||||
|
if (!realized) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={`${playLabel ?? 'bass pattern'} — unavailable`}
|
||||||
|
className="flex min-h-[72px] min-w-[140px] flex-col items-center justify-center rounded-md border border-dashed border-border p-2 text-gray-500"
|
||||||
|
>
|
||||||
|
<span className="text-lg leading-none" aria-hidden="true">—</span>
|
||||||
|
<span className="mt-1 text-[10px]">pattern unavailable</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = layoutPattern(realized)
|
||||||
|
const pitchNames = realized.map((n) => absName(n.abs)).join(', ')
|
||||||
|
const label = playLabel ?? 'Bass pattern'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure className="flex w-[220px] min-w-0 shrink-0 flex-col gap-1.5 rounded-md border border-border bg-surface p-2">
|
||||||
|
<figcaption
|
||||||
|
className="text-[11px] font-medium leading-tight text-gray-300"
|
||||||
|
title={feel ?? undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</figcaption>
|
||||||
|
<div className="max-w-full overflow-x-auto">
|
||||||
|
<PatternSvg
|
||||||
|
layout={layout}
|
||||||
|
ariaLabel={`Bass tab for ${chordLabel ?? 'this chord'} — ${label}: ${pitchNames}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{note && <p className="text-[10px] leading-snug text-gray-500">{note}</p>}
|
||||||
|
<PlayButton
|
||||||
|
ariaLabel={`Play ${chordLabel ?? 'chord'} bass pattern — ${label}`}
|
||||||
|
onClick={() => playPattern(realized)}
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// SVG chord diagram — 6 strings × 5 visible frets
|
||||||
|
// Props:
|
||||||
|
// frets[] — [s6…s1]: fret number or 'x' (muted)
|
||||||
|
// fingers[] — [s6…s1]: finger 1-4, 0 = open/barre indicator
|
||||||
|
// barre — { fret, fromStr, toStr } or null
|
||||||
|
// baseFret — which fret number is at the top of the diagram (1 = standard)
|
||||||
|
// label — caption below the box
|
||||||
|
|
||||||
|
const STRINGS = 6
|
||||||
|
const ROWS = 5 // visible frets
|
||||||
|
const SX = 32 // left margin (open/mute indicators)
|
||||||
|
const SY = 28 // top margin (nut / baseFret label)
|
||||||
|
const GX = 26 // gap between strings
|
||||||
|
const GY = 22 // gap between frets
|
||||||
|
const DOT_R = 9 // dot radius
|
||||||
|
const W = SX + GX * (STRINGS - 1) + 24 // total width
|
||||||
|
const H = SY + GY * ROWS + 20 // total height
|
||||||
|
|
||||||
|
function strX(s) { return SX + (STRINGS - 1 - s) * GX } // s=0 is s6 (low E, leftmost)
|
||||||
|
function fretY(f) { return SY + f * GY } // f=0 is above first fret, f=1…5 are fret centers
|
||||||
|
|
||||||
|
export default function ChordBox({ frets, fingers, barre, baseFret = 1, label }) {
|
||||||
|
const isOpen = baseFret === 1
|
||||||
|
|
||||||
|
// Map fret numbers to diagram row (0-indexed from top)
|
||||||
|
function toRow(absF) {
|
||||||
|
return absF - baseFret + 1 // fret at baseFret → row 1 (center of first fret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Barre bar: draw a rounded rect across strings
|
||||||
|
function renderBarre() {
|
||||||
|
if (!barre) return null
|
||||||
|
const row = toRow(barre.fret)
|
||||||
|
if (row < 1 || row > ROWS) return null
|
||||||
|
const x1 = strX(STRINGS - barre.toStr) // toStr is highest string number = leftmost
|
||||||
|
const x2 = strX(STRINGS - barre.fromStr) // fromStr is lowest string number = rightmost
|
||||||
|
const cy = fretY(row) - GY / 2
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
key="barre"
|
||||||
|
x={x1 - DOT_R}
|
||||||
|
y={cy - DOT_R}
|
||||||
|
width={x2 - x1 + DOT_R * 2}
|
||||||
|
height={DOT_R * 2}
|
||||||
|
rx={DOT_R}
|
||||||
|
fill="#a855f7"
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="overflow-visible">
|
||||||
|
|
||||||
|
{/* ── Nut or baseFret indicator ── */}
|
||||||
|
{isOpen ? (
|
||||||
|
<rect x={SX - 2} y={SY - 4} width={GX * (STRINGS - 1) + 4} height={4} rx={2} fill="#e5e7eb" />
|
||||||
|
) : (
|
||||||
|
<text x={SX - 6} y={SY + GY * 0.5} textAnchor="end" dominantBaseline="middle"
|
||||||
|
fill="#9ca3af" fontSize={10} fontFamily="monospace">
|
||||||
|
{baseFret}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Fret lines ── */}
|
||||||
|
{Array.from({ length: ROWS + 1 }, (_, i) => (
|
||||||
|
<line key={`fl${i}`}
|
||||||
|
x1={SX} y1={fretY(i) - GY / 2}
|
||||||
|
x2={SX + GX * (STRINGS - 1)} y2={fretY(i) - GY / 2}
|
||||||
|
stroke="#374151" strokeWidth={i === 0 && isOpen ? 3 : 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ── String lines ── */}
|
||||||
|
{Array.from({ length: STRINGS }, (_, s) => (
|
||||||
|
<line key={`sl${s}`}
|
||||||
|
x1={strX(s)} y1={SY - GY / 2}
|
||||||
|
x2={strX(s)} y2={fretY(ROWS) - GY / 2}
|
||||||
|
stroke="#4b5563" strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ── Barre ── */}
|
||||||
|
{renderBarre()}
|
||||||
|
|
||||||
|
{/* ── Dots + open/mute indicators ── */}
|
||||||
|
{frets.map((f, s) => {
|
||||||
|
const cx = strX(STRINGS - 1 - s)
|
||||||
|
if (f === 'x') {
|
||||||
|
return (
|
||||||
|
<text key={`m${s}`} x={cx} y={SY - GY / 2 - 7}
|
||||||
|
textAnchor="middle" fill="#6b7280" fontSize={12} fontWeight="bold">
|
||||||
|
×
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (f === 0) {
|
||||||
|
return (
|
||||||
|
<circle key={`o${s}`} cx={cx} cy={SY - GY / 2 - 7}
|
||||||
|
r={5} fill="none" stroke="#6b7280" strokeWidth={1.5} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const row = toRow(f)
|
||||||
|
if (row < 1 || row > ROWS) return null
|
||||||
|
const cy = fretY(row) - GY / 2
|
||||||
|
const finger = fingers?.[s] ?? 0
|
||||||
|
return (
|
||||||
|
<g key={`d${s}`}>
|
||||||
|
<circle cx={cx} cy={cy} r={DOT_R} fill="#a855f7" />
|
||||||
|
{finger > 0 && (
|
||||||
|
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fill="white" fontSize={9} fontWeight="bold">
|
||||||
|
{finger}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* ── String name labels ── */}
|
||||||
|
{['e','B','G','D','A','E'].map((n, i) => (
|
||||||
|
<text key={`sn${i}`}
|
||||||
|
x={strX(i)} y={H - 4}
|
||||||
|
textAnchor="middle" fill="#4b5563" fontSize={8}>
|
||||||
|
{n}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{label && (
|
||||||
|
<p className="text-[11px] text-gray-400 text-center leading-tight max-w-[120px]">{label}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,815 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import ChordBox from './ChordBox'
|
||||||
|
import MiniPiano from './MiniPiano'
|
||||||
|
import VoicingBrowser from './VoicingBrowser'
|
||||||
|
import { getGuitarVoicings, getPianoTechniques, parseChord } from '../lib/voicings'
|
||||||
|
import { CHORD_TYPES, NOTES, getChordsInKey, toRomanNumeral, getSuggestedProgressions } from '../lib/theory'
|
||||||
|
import { FAMOUS_PROGRESSIONS, progressionInKey, getChordSubstitutions, CHORD_PLAYBOOK } from '../lib/education'
|
||||||
|
|
||||||
|
const CHORD_SUFFIX_OPTIONS = [
|
||||||
|
{ key: 'maj', label: 'Major' },
|
||||||
|
{ key: 'min', label: 'Minor' },
|
||||||
|
{ key: 'dom7', label: '7' },
|
||||||
|
{ key: 'maj7', label: 'maj7' },
|
||||||
|
{ key: 'min7', label: 'm7' },
|
||||||
|
{ key: 'dim', label: 'dim' },
|
||||||
|
{ key: 'dim7', label: 'dim7' },
|
||||||
|
{ key: 'half_dim', label: 'm7♭5' },
|
||||||
|
{ key: 'aug', label: 'aug' },
|
||||||
|
{ key: 'sus4', label: 'sus4' },
|
||||||
|
{ key: 'sus2', label: 'sus2' },
|
||||||
|
{ key: 'maj6', label: '6' },
|
||||||
|
{ key: 'min6', label: 'm6' },
|
||||||
|
{ key: 'add9', label: 'add9' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function chordDisplayName(root, typeKey) {
|
||||||
|
const type = CHORD_TYPES[typeKey]
|
||||||
|
if (!type) return root
|
||||||
|
return root + type.suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuitarTab({ chordName }) {
|
||||||
|
// parseChord's `type` is a CHORD_TYPES key — exactly VoicingBrowser's
|
||||||
|
// `quality` prop (same mapping PianoTab/ExploreTab already rely on). L-21.
|
||||||
|
const parsed = parseChord(chordName)
|
||||||
|
const voicings = getGuitarVoicings(chordName)
|
||||||
|
if (!parsed && !voicings.length) {
|
||||||
|
return <p className="text-gray-500 text-sm text-center py-8">No guitar voicings found for {chordName}.</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{/* Playable voicing browser (D-21) — browse + audition shapes.
|
||||||
|
Instrument-scoped to this tab via `show` (task L-25). */}
|
||||||
|
{parsed && <VoicingBrowser rootPc={parsed.rootPc} quality={parsed.type} show="guitar" />}
|
||||||
|
|
||||||
|
{/* Static grid kept as the fingering reference — it carries finger
|
||||||
|
numbers and barre info the browser doesn't show. */}
|
||||||
|
{voicings.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">More grips — fingering reference</p>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
Click any voicing to learn it. Purple = chord tones. Finger numbers inside dots (1=index, 4=pinky).
|
||||||
|
Barre chords show the fret number on the left.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-6 justify-start">
|
||||||
|
{voicings.map((v, i) => (
|
||||||
|
<div key={i} className="flex flex-col items-center gap-1 p-3 rounded-xl bg-surface border border-border hover:border-accent/40 transition-colors">
|
||||||
|
<ChordBox
|
||||||
|
frets={v.frets}
|
||||||
|
fingers={v.fingers}
|
||||||
|
barre={v.barre}
|
||||||
|
baseFret={v.baseFret}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-gray-400 text-center mt-1 max-w-[120px] leading-tight">{v.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 p-3 rounded-lg bg-surface border border-border">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
<span className="text-accent font-semibold">Pro tip:</span> Learn the E-shape and A-shape barres first
|
||||||
|
— they cover all 12 roots. Then add open voicings for the keys you play in most.
|
||||||
|
High-fret voicings (above fret 7) work great as jazz comping shapes in a band mix.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PianoTab({ chordName }) {
|
||||||
|
const parsed = parseChord(chordName)
|
||||||
|
const techniques = getPianoTechniques(chordName)
|
||||||
|
const rootPc = parsed?.rootPc ?? 0
|
||||||
|
|
||||||
|
if (!parsed && !techniques.length) {
|
||||||
|
return <p className="text-gray-500 text-sm text-center py-8">No piano techniques for {chordName}.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Playable voicing browser (D-21) — parseChord's `type` maps 1:1 to the
|
||||||
|
browser's `quality` (CHORD_TYPES key). L-21. Piano-scoped via `show` (L-25). */}
|
||||||
|
{parsed && <VoicingBrowser rootPc={parsed.rootPc} quality={parsed.type} show="piano" />}
|
||||||
|
|
||||||
|
{/* Technique cards kept below — the name/desc/tip text and LH/RH note
|
||||||
|
breakdown are not covered by the browser. */}
|
||||||
|
{techniques.length > 0 && (
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mt-1">Techniques — how to use it at the keys</p>
|
||||||
|
)}
|
||||||
|
{techniques.length > 0 && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
<span className="text-blue-400 font-semibold">Blue = Left hand</span> ·
|
||||||
|
<span className="text-accent font-semibold">Purple = Right hand</span> ·
|
||||||
|
R marks the root.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{techniques.map((t, i) => (
|
||||||
|
<div key={i} className="p-4 rounded-xl bg-surface border border-border hover:border-accent/30 transition-colors">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4 items-start">
|
||||||
|
<div className="shrink-0 overflow-x-auto">
|
||||||
|
<MiniPiano rootPc={rootPc} lh={t.lh} rh={t.rh} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
|
<h3 className="font-bold text-white text-sm">{t.name}</h3>
|
||||||
|
<p className="text-gray-400 text-xs">{t.desc}</p>
|
||||||
|
<p className="text-xs text-amber-400/80 mt-1">
|
||||||
|
<span className="text-amber-400 font-semibold">Tip:</span> {t.tip}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 mt-1 text-xs text-gray-600">
|
||||||
|
{t.lh.length > 0 && (
|
||||||
|
<span className="text-blue-400">LH: {t.lh.map(iv => {
|
||||||
|
const n = NOTES[(rootPc + iv) % 12]
|
||||||
|
return iv === 0 ? `${n} (root)` : n
|
||||||
|
}).join(', ')}</span>
|
||||||
|
)}
|
||||||
|
{t.rh.length > 0 && (
|
||||||
|
<span className="text-accent">RH: {t.rh.map(iv => {
|
||||||
|
const n = NOTES[(rootPc + iv) % 12]
|
||||||
|
return n
|
||||||
|
}).join(', ')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChordQuickPick({ label, chords, active, keyInfo, onSelect }) {
|
||||||
|
if (!chords.length) return null
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-[11px] text-gray-600 uppercase tracking-wider shrink-0 w-20">{label}</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{chords.map(chord => {
|
||||||
|
const rn = keyInfo?.root ? toRomanNumeral(chord, keyInfo.root, keyInfo.mode) : ''
|
||||||
|
return (
|
||||||
|
<button key={chord}
|
||||||
|
onClick={() => onSelect(chord)}
|
||||||
|
className={`flex flex-col items-center px-2.5 py-1 rounded-lg border text-xs font-bold transition-all ${
|
||||||
|
active === chord
|
||||||
|
? 'bg-accent border-accent text-white'
|
||||||
|
: 'bg-surface border-border text-gray-300 hover:border-accent/50 hover:text-white'
|
||||||
|
}`}>
|
||||||
|
<span>{chord}</span>
|
||||||
|
{rn && <span className="text-[9px] font-normal opacity-60 leading-none">{rn}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Progressions sub-tab ─────────────────────────────────────────────────────
|
||||||
|
// Determine whether a chord type is major-ish or minor-ish for matching
|
||||||
|
const MAJOR_TYPES = new Set(['maj','maj7','maj6','add9','sus4','sus2','aug','dom7'])
|
||||||
|
const MINOR_TYPES = new Set(['min','min7','min6','half_dim','dim','dim7'])
|
||||||
|
|
||||||
|
function isMajorType(t) { return MAJOR_TYPES.has(t) }
|
||||||
|
function isMinorType(t) { return MINOR_TYPES.has(t) }
|
||||||
|
|
||||||
|
function ProgressionsSubTab({ chordName, onChordClick }) {
|
||||||
|
const parsed = parseChord(chordName)
|
||||||
|
if (!parsed) return null
|
||||||
|
const { rootPc, type } = parsed
|
||||||
|
const root = NOTES[rootPc]
|
||||||
|
|
||||||
|
// Famous progressions where this chord can be the tonic (degree 0)
|
||||||
|
const isMajor = isMajorType(type)
|
||||||
|
const isMinor = isMinorType(type)
|
||||||
|
const tonicProgs = FAMOUS_PROGRESSIONS.filter(p => {
|
||||||
|
const q0 = p.qualities[0]
|
||||||
|
if (isMajor && isMajorType(q0)) return true
|
||||||
|
if (isMinor && isMinorType(q0)) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Genre-based suggestions from theory.js
|
||||||
|
const genreProgs = getSuggestedProgressions(root, isMajor ? 'major' : 'minor')
|
||||||
|
|
||||||
|
// Roles this chord plays in other keys
|
||||||
|
const ROLES = []
|
||||||
|
for (let keyPc = 0; keyPc < 12; keyPc++) {
|
||||||
|
for (const mode of ['major', 'minor']) {
|
||||||
|
const diatonicChords = getChordsInKey(NOTES[keyPc], mode)
|
||||||
|
const idx = diatonicChords.indexOf(chordName)
|
||||||
|
if (idx !== -1) {
|
||||||
|
const rn = toRomanNumeral(chordName, NOTES[keyPc], mode)
|
||||||
|
ROLES.push({ keyRoot: NOTES[keyPc], mode, rn, diatonicChords })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
|
{/* ── Famous progressions starting from this chord ── */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">
|
||||||
|
Famous progressions — {chordName} as tonic
|
||||||
|
</p>
|
||||||
|
{tonicProgs.length === 0 && (
|
||||||
|
<p className="text-gray-600 text-sm">No exact matches — try a major or minor chord.</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{tonicProgs.slice(0, 6).map(prog => {
|
||||||
|
const chordsHere = progressionInKey(prog, root)
|
||||||
|
return (
|
||||||
|
<div key={prog.id} className="p-3 bg-surface border border-border rounded-xl hover:border-accent/30 transition-colors">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||||
|
<span className="font-bold text-white text-sm">{prog.name}</span>
|
||||||
|
<span className="text-[10px] font-mono text-gray-500">{prog.pattern}</span>
|
||||||
|
{prog.genre.map(g => (
|
||||||
|
<span key={g} className="px-1.5 py-0.5 bg-accent/10 border border-accent/20 rounded text-[10px] text-accent">{g}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Chord sequence */}
|
||||||
|
<div className="flex flex-wrap gap-1.5 items-center mb-2">
|
||||||
|
{chordsHere.map((c, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onChordClick?.(c)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg font-bold text-sm border transition-all ${
|
||||||
|
i === 0
|
||||||
|
? 'bg-accent border-accent text-white'
|
||||||
|
: 'bg-panel border-border text-gray-200 hover:border-accent/50 hover:text-accent'
|
||||||
|
}`}
|
||||||
|
title={`Voicings for ${c}`}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
{i < chordsHere.length - 1 && <span className="text-gray-700 text-xs">→</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 leading-snug">{prog.description}</p>
|
||||||
|
{prog.songs[0] && (
|
||||||
|
<p className="text-[11px] text-gray-600 mt-1">e.g. {prog.songs.slice(0, 3).join(' · ')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Genre-based next-chord suggestions ── */}
|
||||||
|
{genreProgs.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">
|
||||||
|
Genre suggestions — starting from {chordName}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{genreProgs.slice(0, 6).map((prog, pi) => (
|
||||||
|
<div key={pi} className="flex items-center gap-2 p-2 bg-surface border border-border rounded-lg flex-wrap">
|
||||||
|
<span className="text-[10px] font-bold text-gray-500 w-14 shrink-0">{prog.genre}</span>
|
||||||
|
<div className="flex gap-1.5 flex-wrap items-center">
|
||||||
|
{prog.chords.map((c, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onChordClick?.(c)}
|
||||||
|
className="px-2 py-0.5 bg-panel border border-border hover:border-accent/50 rounded text-xs font-bold text-gray-200 hover:text-accent transition-all"
|
||||||
|
title={`Voicings for ${c}`}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
{i < prog.chords.length - 1 && <span className="text-gray-700 text-[10px]">→</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-gray-600 font-mono ml-1">{prog.rn?.join(' – ')}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Roles this chord plays ── */}
|
||||||
|
{ROLES.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">
|
||||||
|
{chordName} appears in these keys
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ROLES.slice(0, 8).map(({ keyRoot, mode, rn, diatonicChords }) => (
|
||||||
|
<div key={`${keyRoot}-${mode}`}
|
||||||
|
className="px-3 py-2 bg-surface border border-border rounded-xl text-xs flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-bold text-white">{keyRoot}</span>
|
||||||
|
<span className="text-gray-500 capitalize">{mode}</span>
|
||||||
|
<span className="text-amber-400 font-bold">{rn}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{diatonicChords.map((c, i) => (
|
||||||
|
<button key={i}
|
||||||
|
onClick={() => onChordClick?.(c)}
|
||||||
|
className={`px-1.5 py-0.5 rounded text-[10px] font-bold transition-all ${
|
||||||
|
c === chordName
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'text-gray-500 hover:text-gray-300'
|
||||||
|
}`}>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Theory tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CHORD_THEORY = {
|
||||||
|
maj: {
|
||||||
|
name: 'Major',
|
||||||
|
formula: 'Root + Major 3rd (4 semitones) + Perfect 5th (7 semitones)',
|
||||||
|
vibe: 'Bright, happy, resolved. The most "complete" sound in Western music.',
|
||||||
|
beginner: 'Major chords are the foundation of almost every song you know. They feel stable and uplifting — like a musical full stop.',
|
||||||
|
tension: 'Low — very stable',
|
||||||
|
color: 'text-yellow-400',
|
||||||
|
},
|
||||||
|
min: {
|
||||||
|
name: 'Minor',
|
||||||
|
formula: 'Root + Minor 3rd (3 semitones) + Perfect 5th (7 semitones)',
|
||||||
|
vibe: 'Dark, melancholic, introspective. The 3rd is lowered by just one semitone — that one note changes everything.',
|
||||||
|
beginner: 'One note separates major from minor. Minor chords carry emotion and depth — sadness, mystery, tension.',
|
||||||
|
tension: 'Low-medium — stable but moody',
|
||||||
|
color: 'text-blue-400',
|
||||||
|
},
|
||||||
|
dom7: {
|
||||||
|
name: 'Dominant 7th',
|
||||||
|
formula: 'Major triad + Minor 7th (10 semitones)',
|
||||||
|
vibe: 'Tense, bluesy, urgent. Wants desperately to resolve to a chord a 5th lower.',
|
||||||
|
beginner: 'The 7th chord is the engine of blues and jazz. It creates tension that begs to resolve — like holding your breath. Play G7 then C to feel it.',
|
||||||
|
tension: 'High — strongly pulls to resolution',
|
||||||
|
color: 'text-red-400',
|
||||||
|
},
|
||||||
|
maj7: {
|
||||||
|
name: 'Major 7th',
|
||||||
|
formula: 'Major triad + Major 7th (11 semitones)',
|
||||||
|
vibe: 'Dreamy, lush, sophisticated. Jazz-infused warmth without the tension of a dominant 7th.',
|
||||||
|
beginner: 'The major 7th is the note just below the octave. Adding it to a major chord gives you that smooth jazz-bossa nova sound — think "Autumn Leaves".',
|
||||||
|
tension: 'Very low — ethereal and floating',
|
||||||
|
color: 'text-purple-400',
|
||||||
|
},
|
||||||
|
min7: {
|
||||||
|
name: 'Minor 7th',
|
||||||
|
formula: 'Minor triad + Minor 7th (10 semitones)',
|
||||||
|
vibe: 'Smooth, soulful, relaxed. Darker than major 7th but less tense than a dominant 7th.',
|
||||||
|
beginner: 'Minor 7ths are everywhere in soul, R&B, and jazz. They\'re minor chords with added warmth — moody but not harsh.',
|
||||||
|
tension: 'Low-medium — smooth and flowing',
|
||||||
|
color: 'text-indigo-400',
|
||||||
|
},
|
||||||
|
dim: {
|
||||||
|
name: 'Diminished',
|
||||||
|
formula: 'Root + Minor 3rd (3 semitones) + Diminished 5th (6 semitones)',
|
||||||
|
vibe: 'Dark, tense, unstable. The flattened 5th creates a tritone interval — historically called "diabolus in musica" (the devil in music).',
|
||||||
|
beginner: 'Diminished chords are passing chords — they create maximum tension so the next chord feels like a huge relief. Like a musical cliffhanger.',
|
||||||
|
tension: 'Very high — wants to resolve immediately',
|
||||||
|
color: 'text-orange-400',
|
||||||
|
},
|
||||||
|
dim7: {
|
||||||
|
name: 'Diminished 7th',
|
||||||
|
formula: 'Diminished triad + Diminished 7th (9 semitones) — fully symmetric, all minor 3rds',
|
||||||
|
vibe: 'Extremely tense and dramatic. Used in horror film scores and dramatic classical passages.',
|
||||||
|
beginner: 'All four notes are equally spaced (all minor 3rds apart), making it the most symmetrical and unstable chord. Classic "villain arrives" sound.',
|
||||||
|
tension: 'Extreme — maximum instability',
|
||||||
|
color: 'text-red-600',
|
||||||
|
},
|
||||||
|
half_dim: {
|
||||||
|
name: 'Half-Diminished (m7♭5)',
|
||||||
|
formula: 'Diminished triad + Minor 7th (10 semitones)',
|
||||||
|
vibe: 'Dark and tense but with slightly more resolution than full dim7. The "ii" chord in minor ii–V–i jazz progressions.',
|
||||||
|
beginner: 'Half-diminished sits between a minor 7th and a fully diminished chord. It\'s the moody jazz workhorse — think the intro to "Autumn Leaves".',
|
||||||
|
tension: 'High — tense but musical',
|
||||||
|
color: 'text-orange-500',
|
||||||
|
},
|
||||||
|
aug: {
|
||||||
|
name: 'Augmented',
|
||||||
|
formula: 'Root + Major 3rd (4 semitones) + Augmented 5th (8 semitones) — all major 3rds',
|
||||||
|
vibe: 'Eerie, floating, dreamlike. The raised 5th creates instability that can resolve either up or down.',
|
||||||
|
beginner: 'Augmented chords sound like something is about to happen. They\'re often used as a passing chord between major and minor — the 5th feels like it\'s "reaching" upward.',
|
||||||
|
tension: 'High — ambiguous direction',
|
||||||
|
color: 'text-emerald-400',
|
||||||
|
},
|
||||||
|
sus4: {
|
||||||
|
name: 'Suspended 4th',
|
||||||
|
formula: 'Root + Perfect 4th (5 semitones) + Perfect 5th (7 semitones)',
|
||||||
|
vibe: 'Open, unresolved, expectant. The 3rd is replaced by a 4th — neither major nor minor, just floating.',
|
||||||
|
beginner: '"Sus" means suspended — the 3rd is suspended in mid-air. It wants to drop down to a major or minor chord. Classic rock move: sus4 → major.',
|
||||||
|
tension: 'Medium — pleasant tension, easy on the ear',
|
||||||
|
color: 'text-cyan-400',
|
||||||
|
},
|
||||||
|
sus2: {
|
||||||
|
name: 'Suspended 2nd',
|
||||||
|
formula: 'Root + Major 2nd (2 semitones) + Perfect 5th (7 semitones)',
|
||||||
|
vibe: 'Airy, spacious, ambiguous. Like sus4 but lighter — the 2nd sits high above the root.',
|
||||||
|
beginner: 'Sus2 is a favourite of modern pop and ambient music. Without a 3rd, it has no major/minor quality — it just floats. Think Sting, U2, Coldplay.',
|
||||||
|
tension: 'Low-medium — open and spacious',
|
||||||
|
color: 'text-teal-400',
|
||||||
|
},
|
||||||
|
maj6: {
|
||||||
|
name: 'Major 6th',
|
||||||
|
formula: 'Major triad + Major 6th (9 semitones)',
|
||||||
|
vibe: 'Sweet, vintage, nostalgic. The 6th adds a note from the scale without the tension of a 7th.',
|
||||||
|
beginner: "The 6th is a colour tone that sweetens a major chord. Common in jazz, bossa nova, and 50s pop — \"Misty\" and \"Fly Me To The Moon\" territory.",
|
||||||
|
tension: 'Very low — sweeter than major triad',
|
||||||
|
color: 'text-amber-300',
|
||||||
|
},
|
||||||
|
min6: {
|
||||||
|
name: 'Minor 6th',
|
||||||
|
formula: 'Minor triad + Major 6th (9 semitones)',
|
||||||
|
vibe: 'Bittersweet, exotic, dramatic. A major 6th over a minor chord creates a striking contrast.',
|
||||||
|
beginner: 'Minor 6ths have a flamenco/tango feel. The bright 6th sitting on top of a dark minor chord creates a sophisticated tension — think Django Reinhardt.',
|
||||||
|
tension: 'Medium — intriguing contrast',
|
||||||
|
color: 'text-amber-400',
|
||||||
|
},
|
||||||
|
add9: {
|
||||||
|
name: 'Add 9',
|
||||||
|
formula: 'Major triad + Major 9th (14 semitones = octave + 2)',
|
||||||
|
vibe: 'Open, modern, slightly epic. The 9th adds colour without the smoothness of a 7th.',
|
||||||
|
beginner: 'Add9 is the chord of modern rock and pop. Unlike maj9 (which also has a 7th), add9 keeps things clean and direct. Coldplay, Radiohead, and U2 love it.',
|
||||||
|
tension: 'Very low — bright and open',
|
||||||
|
color: 'text-lime-400',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTERVAL_NAMES = {
|
||||||
|
0: 'Root', 2: 'Major 2nd', 3: 'Minor 3rd', 4: 'Major 3rd',
|
||||||
|
5: 'Perfect 4th', 6: 'Tritone (♭5)', 7: 'Perfect 5th',
|
||||||
|
8: 'Aug 5th', 9: 'Major 6th', 10: 'Minor 7th', 11: 'Major 7th',
|
||||||
|
14: 'Major 9th',
|
||||||
|
}
|
||||||
|
|
||||||
|
function TheoryTab({ chordName }) {
|
||||||
|
const parsed = parseChord(chordName)
|
||||||
|
if (!parsed) return <p className="text-gray-500 text-sm text-center py-8">Could not parse chord.</p>
|
||||||
|
|
||||||
|
const { rootPc, type } = parsed
|
||||||
|
const typeInfo = CHORD_TYPES[type]
|
||||||
|
const theory = CHORD_THEORY[type]
|
||||||
|
const subs = getChordSubstitutions(chordName)
|
||||||
|
|
||||||
|
// Actual note names
|
||||||
|
const noteNames = (typeInfo?.intervals ?? []).map(iv => NOTES[(rootPc + iv) % 12])
|
||||||
|
|
||||||
|
// Roles this chord can play
|
||||||
|
const ROLES = []
|
||||||
|
for (let keyPc = 0; keyPc < 12; keyPc++) {
|
||||||
|
for (const mode of ['major', 'minor']) {
|
||||||
|
const diatonicChords = getChordsInKey(NOTES[keyPc], mode)
|
||||||
|
const idx = diatonicChords.indexOf(chordName)
|
||||||
|
if (idx !== -1) {
|
||||||
|
const rn = toRomanNumeral(chordName, NOTES[keyPc], mode)
|
||||||
|
ROLES.push({ keyRoot: NOTES[keyPc], mode, rn })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
|
{/* ── What is this chord? ── */}
|
||||||
|
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||||
|
<div className="flex items-baseline gap-3 mb-3">
|
||||||
|
<span className={`text-lg font-black ${theory?.color ?? 'text-accent'}`}>{chordName}</span>
|
||||||
|
<span className="text-sm text-gray-400">{theory?.name ?? type}</span>
|
||||||
|
</div>
|
||||||
|
{theory && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-gray-200 leading-relaxed mb-2">{theory.beginner}</p>
|
||||||
|
<p className="text-xs text-gray-500 italic leading-relaxed">{theory.vibe}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Notes & Formula ── */}
|
||||||
|
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">Notes in this chord</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{(typeInfo?.intervals ?? []).map((iv, i) => (
|
||||||
|
<div key={i} className={`flex flex-col items-center px-3 py-2 rounded-xl border ${
|
||||||
|
i === 0 ? 'bg-accent/20 border-accent text-accent' : 'bg-panel border-border text-gray-300'
|
||||||
|
}`}>
|
||||||
|
<span className="text-base font-black">{noteNames[i]}</span>
|
||||||
|
<span className="text-[10px] text-gray-500 leading-none mt-0.5">{INTERVAL_NAMES[iv] ?? `+${iv}`}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{theory && (
|
||||||
|
<div className="text-xs text-gray-600 font-mono bg-panel/50 rounded-lg px-3 py-2 border border-border">
|
||||||
|
{theory.formula}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{theory && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-gray-600">Tension:</span>
|
||||||
|
<span className="text-xs text-gray-400">{theory.tension}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Chord substitutions ── */}
|
||||||
|
{subs.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">Colour swaps — try these instead</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{subs.map((sub, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 p-3 bg-surface border border-border rounded-xl hover:border-accent/30 transition-colors">
|
||||||
|
<span className="text-sm font-black text-accent shrink-0 w-16">{sub.chord}</span>
|
||||||
|
<p className="text-xs text-gray-400 leading-snug">{sub.tip}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Keys this chord belongs to ── */}
|
||||||
|
{ROLES.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">{chordName} appears in these keys</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ROLES.slice(0, 10).map(({ keyRoot, mode, rn }) => (
|
||||||
|
<div key={`${keyRoot}-${mode}`}
|
||||||
|
className="px-3 py-2 bg-surface border border-border rounded-xl text-xs flex items-center gap-2">
|
||||||
|
<span className="font-bold text-white">{keyRoot}</span>
|
||||||
|
<span className="text-gray-500 capitalize">{mode}</span>
|
||||||
|
<span className="text-amber-400 font-bold">{rn}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-gray-700 mt-2">
|
||||||
|
Roman numerals show the chord's role: I/i = home, IV = subdominant, V = dominant tension, vi/♭VI = relative minor/major, etc.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Learn tab ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LearnTab({ chordName }) {
|
||||||
|
const parsed = parseChord(chordName)
|
||||||
|
const playbook = parsed ? CHORD_PLAYBOOK[parsed.type] : null
|
||||||
|
|
||||||
|
if (!playbook) {
|
||||||
|
return <p className="text-gray-500 text-sm text-center py-8">No jam content for {chordName} yet.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
|
{/* ── Jam role ── */}
|
||||||
|
<div className="px-4 py-3 bg-accent/10 border border-accent/20 rounded-xl">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-accent/60 mb-1">Your role in the jam</p>
|
||||||
|
<p className="text-sm text-white leading-relaxed">{playbook.jamRole}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Voicings for jamming ── */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Voicings — when to use which</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{playbook.voicings.map((v, i) => (
|
||||||
|
<div key={i} className="flex gap-3 p-3 bg-surface border border-border rounded-xl hover:border-accent/20 transition-colors">
|
||||||
|
<span className="text-accent font-black text-lg shrink-0 leading-none mt-0.5">{i + 1}</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-white mb-0.5">{v.name}</p>
|
||||||
|
<p className="text-xs text-gray-400 leading-relaxed">{v.use}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-700 mt-2">See the Guitar tab for the actual fingerings of each shape.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Licks & fills ── */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Licks & fills</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{playbook.licks.map((l, i) => (
|
||||||
|
<div key={i} className="p-4 bg-surface border border-border rounded-xl hover:border-accent/30 transition-colors">
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<p className="text-sm font-bold text-white">{l.title}</p>
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-accent/10 border border-accent/20 text-accent">{l.style}</span>
|
||||||
|
</div>
|
||||||
|
<pre className="text-[10px] font-mono text-accent/70 bg-black/40 border border-border rounded-lg px-3 py-2 overflow-x-auto leading-relaxed whitespace-pre mb-2">{l.tab}</pre>
|
||||||
|
<p className="text-xs text-amber-400/80">
|
||||||
|
<span className="font-semibold text-amber-400">Key insight: </span>{l.tip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Jam tips ── */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Jam tips</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{playbook.jamTips.map((tip, i) => (
|
||||||
|
<div key={i} className="flex gap-2.5 text-xs text-gray-300 leading-relaxed p-2.5 rounded-lg bg-surface border border-border">
|
||||||
|
<span className="text-accent shrink-0 font-bold mt-0.5">→</span>
|
||||||
|
<p>{tip}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Loop station practice ── */}
|
||||||
|
{playbook.loopPractice?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Loop station practice</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{playbook.loopPractice.map((lp, i) => (
|
||||||
|
<div key={i} className="p-3 bg-surface border border-border rounded-xl border-l-2 border-l-accent/40">
|
||||||
|
<p className="text-xs font-bold text-white mb-1">🔁 {lp.title}</p>
|
||||||
|
<p className="text-xs text-gray-400 leading-relaxed">{lp.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Explore tab ──────────────────────────────────────────────────────────────
|
||||||
|
function ExploreTab({ initialChord, keyInfo, chordHistory }) {
|
||||||
|
const parsed = parseChord(initialChord)
|
||||||
|
const [root, setRoot] = useState(parsed ? NOTES[parsed.rootPc] : 'C')
|
||||||
|
const [typeKey, setTypeKey] = useState(parsed?.type ?? 'maj')
|
||||||
|
const [subTab, setSubTab] = useState('guitar')
|
||||||
|
const [active, setActive] = useState(initialChord ?? '')
|
||||||
|
|
||||||
|
const chordName = chordDisplayName(root, typeKey)
|
||||||
|
|
||||||
|
function selectChord(chord) {
|
||||||
|
setActive(chord)
|
||||||
|
const p = parseChord(chord)
|
||||||
|
if (p) { setRoot(NOTES[p.rootPc]); setTypeKey(p.type) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentChords = [...new Set([...(chordHistory ?? [])].reverse())].slice(0, 12)
|
||||||
|
const keyChords = keyInfo?.root ? getChordsInKey(keyInfo.root, keyInfo.mode ?? 'major') : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
|
||||||
|
{/* ── Contextual quick-picks ── */}
|
||||||
|
{(recentChords.length > 0 || keyChords.length > 0) && (
|
||||||
|
<div className="flex flex-col gap-3 p-3 bg-surface border border-border rounded-xl">
|
||||||
|
<ChordQuickPick label="History" chords={recentChords} active={active} keyInfo={keyInfo} onSelect={selectChord} />
|
||||||
|
{keyChords.length > 0 && (
|
||||||
|
<>
|
||||||
|
{recentChords.length > 0 && <div className="h-px bg-border" />}
|
||||||
|
<ChordQuickPick
|
||||||
|
label={`${keyInfo.root} ${keyInfo.mode ?? ''}`}
|
||||||
|
chords={keyChords} active={active} keyInfo={keyInfo} onSelect={selectChord}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Manual picker ── */}
|
||||||
|
<div className="flex flex-wrap gap-2 items-center p-3 bg-surface border border-border rounded-xl">
|
||||||
|
<span className="text-xs text-gray-500 shrink-0">Root:</span>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{NOTES.map(n => (
|
||||||
|
<button key={n}
|
||||||
|
onClick={() => { setRoot(n); setActive('') }}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs font-bold transition-all ${
|
||||||
|
root === n ? 'bg-accent text-white' : 'bg-border text-gray-400 hover:text-white'
|
||||||
|
}`}>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-4 bg-border shrink-0" />
|
||||||
|
<span className="text-xs text-gray-500 shrink-0">Type:</span>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={typeKey}
|
||||||
|
onChange={e => { setTypeKey(e.target.value); setActive('') }}
|
||||||
|
className="appearance-none bg-panel border border-border rounded-lg pl-2 pr-6 py-1 text-xs text-gray-200 cursor-pointer focus:outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{CHORD_SUFFIX_OPTIONS.map(o => (
|
||||||
|
<option key={o.key} value={o.key}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="pointer-events-none absolute right-1.5 top-1/2 -translate-y-1/2 text-gray-500 text-xs">▾</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto text-xl font-black text-accent">{chordName}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Sub-tabs ── */}
|
||||||
|
<div className="flex gap-1 bg-surface border border-border rounded-xl p-1 overflow-x-auto">
|
||||||
|
{[
|
||||||
|
{ key: 'guitar', label: '🎸 Guitar' },
|
||||||
|
{ key: 'piano', label: '🎹 Piano' },
|
||||||
|
].map(t => (
|
||||||
|
<button key={t.key}
|
||||||
|
onClick={() => setSubTab(t.key)}
|
||||||
|
className={`px-4 py-1.5 rounded-lg text-sm font-semibold transition-all whitespace-nowrap ${
|
||||||
|
subTab === t.key ? 'bg-accent text-white' : 'text-gray-400 hover:text-white'
|
||||||
|
}`}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subTab === 'guitar' && <GuitarTab chordName={chordName} />}
|
||||||
|
{subTab === 'piano' && <PianoTab chordName={chordName} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main modal ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function ChordDetailModal({ chord, onClose, onChordClick, keyInfo, chordHistory }) {
|
||||||
|
const [tab, setTab] = useState('guitar')
|
||||||
|
|
||||||
|
// Reset tab when chord changes
|
||||||
|
useEffect(() => { setTab('guitar') }, [chord])
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e) { if (e.key === 'Escape') onClose() }
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
if (!chord) return null
|
||||||
|
|
||||||
|
const parsed = parseChord(chord)
|
||||||
|
const typeName = parsed ? (CHORD_SUFFIX_OPTIONS.find(o => o.key === parsed.type)?.label ?? parsed.type) : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center bg-black/70 backdrop-blur-sm p-4 overflow-y-auto"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-3xl bg-panel border border-border rounded-2xl shadow-2xl mt-8 mb-8">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-black text-accent leading-none">{chord}</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{typeName} chord · tap a voicing to study it</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-gray-500 hover:text-white transition-colors text-xl leading-none"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex gap-1 px-6 pt-4 overflow-x-auto">
|
||||||
|
{[
|
||||||
|
{ key: 'guitar', label: '🎸 Guitar' },
|
||||||
|
{ key: 'piano', label: '🎹 Piano' },
|
||||||
|
{ key: 'theory', label: '📚 Theory' },
|
||||||
|
{ key: 'learn', label: '🎓 Learn' },
|
||||||
|
{ key: 'progressions', label: '🎵 Progressions' },
|
||||||
|
{ key: 'explore', label: '🔍 Explore' },
|
||||||
|
].map(t => (
|
||||||
|
<button key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={`px-4 py-2 rounded-t-xl text-sm font-semibold transition-all border-b-2 whitespace-nowrap ${
|
||||||
|
tab === t.key
|
||||||
|
? 'text-accent border-accent bg-accent/10'
|
||||||
|
: 'text-gray-500 border-transparent hover:text-gray-300'
|
||||||
|
}`}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
{tab === 'guitar' && <GuitarTab chordName={chord} />}
|
||||||
|
{tab === 'piano' && <PianoTab chordName={chord} />}
|
||||||
|
{tab === 'theory' && <TheoryTab chordName={chord} />}
|
||||||
|
{tab === 'learn' && <LearnTab chordName={chord} />}
|
||||||
|
{tab === 'progressions' && <ProgressionsSubTab chordName={chord} onChordClick={c => { onChordClick?.(c) }} />}
|
||||||
|
{tab === 'explore' && <ExploreTab initialChord={chord} keyInfo={keyInfo} chordHistory={chordHistory} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
// Compact SVG guitar chord-diagram. Renders the KB guitar shape format (D-01b).
|
||||||
|
// Secondary per-station voicing thumbnail in the Roadmap panel.
|
||||||
|
//
|
||||||
|
// Two shape forms (see src/data/kb/SCHEMA.md):
|
||||||
|
// movable: { rootStr, offsets, fingers }
|
||||||
|
// - offsets: 6 entries low-E→high-E, integers = fret offset from the barre
|
||||||
|
// (base) fret, 'x' = muted. The base fret is derived from where the chord
|
||||||
|
// root (rootPc) sits on rootStr in standard tuning.
|
||||||
|
// open: { frets, onlyRoot, fingers }
|
||||||
|
// - frets: 6 entries low-E→high-E, 0 = open, 'x' = muted, integers = absolute.
|
||||||
|
//
|
||||||
|
// Props:
|
||||||
|
// shape — movable or open form above
|
||||||
|
// keyRoot — tonic pitch class 0–11 (accepted; see note below)
|
||||||
|
// rootPc — chord root pitch class 0–11 (drives movable placement)
|
||||||
|
// size — 'thumb' (compact ~64px grid) | 'full' (enlarged + finger #s + label)
|
||||||
|
// label — optional chord label shown under the grid
|
||||||
|
//
|
||||||
|
// Note on key-awareness: the SCHEMA derives the movable base fret from where the
|
||||||
|
// root note sits "for the current key". In practice the absolute fret depends only
|
||||||
|
// on the chord root pitch class on rootStr, which is supplied directly as `rootPc`.
|
||||||
|
// keyRoot is accepted for contract compatibility and used as a fallback for rootPc.
|
||||||
|
|
||||||
|
// Standard tuning open-string pitch classes, indexed low-E (0) → high-E (5).
|
||||||
|
const OPEN_PCS = [4, 9, 2, 7, 11, 4] // E A D G B E
|
||||||
|
|
||||||
|
// We render strings top→bottom as high-E first (matches Fretboard.jsx idiom),
|
||||||
|
// so display index 0 = high E, 5 = low E. Data arrays are low-E first, so the
|
||||||
|
// data index for display row `di` is `5 - di`.
|
||||||
|
|
||||||
|
const ACCENT = '#a855f7' // chord-tone tier (root highlight)
|
||||||
|
const DOT = '#e5e7eb' // non-root finger dots (light gray, AA on dark board)
|
||||||
|
const DOT_TEXT_DARK = '#1a1a1a'
|
||||||
|
const BOARD = '#1a120b' // matches Fretboard board fill
|
||||||
|
const FRET_LINE = '#4a3a2a'
|
||||||
|
const NUT_COL = '#c0b090'
|
||||||
|
const STRING_COL = '#9ca3af'
|
||||||
|
const MUTE_OPEN = '#9ca3af'
|
||||||
|
const FRET_LABEL = '#9ca3af'
|
||||||
|
|
||||||
|
const NUM_STRINGS = 6
|
||||||
|
const NUM_FRETS = 5 // visible fret rows in the grid
|
||||||
|
|
||||||
|
// ── Fret resolution ──────────────────────────────────────────────────────────
|
||||||
|
// Returns { frets: number|'x' per display row (high-E first), baseFret, rootRow }
|
||||||
|
// where baseFret is the absolute fret of the top visible grid line (1 = nut shown).
|
||||||
|
function resolveShape(shape, rootPc, keyRoot) {
|
||||||
|
if (!shape) return null
|
||||||
|
|
||||||
|
// Open shape: absolute frets, low-E first.
|
||||||
|
if (Array.isArray(shape.frets)) {
|
||||||
|
const abs = shape.frets // low-E first
|
||||||
|
const fretted = abs.filter(f => typeof f === 'number' && f > 0)
|
||||||
|
const minFret = fretted.length ? Math.min(...fretted) : 0
|
||||||
|
const maxFret = fretted.length ? Math.max(...fretted) : 0
|
||||||
|
// Show the nut (baseFret 1) when the shape reaches up to fret ~4 from the nut.
|
||||||
|
const baseFret = maxFret <= NUM_FRETS ? 1 : minFret
|
||||||
|
return {
|
||||||
|
open: true,
|
||||||
|
absLowE: abs,
|
||||||
|
baseFret,
|
||||||
|
rootPc: typeof shape.onlyRoot === 'number' ? shape.onlyRoot : rootPc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Movable shape: offsets relative to a base (barre) fret on rootStr.
|
||||||
|
if (Array.isArray(shape.offsets)) {
|
||||||
|
const rootStr = shape.rootStr // 6 = low E … 1 = high E
|
||||||
|
const rootStrIdx = 6 - rootStr // → low-E-first array index
|
||||||
|
const targetPc = typeof rootPc === 'number' ? rootPc
|
||||||
|
: typeof keyRoot === 'number' ? keyRoot : 0
|
||||||
|
const openPc = OPEN_PCS[rootStrIdx] ?? 4
|
||||||
|
// Smallest fret >= 1 where the root pc lands on rootStr.
|
||||||
|
let baseFret = ((targetPc - openPc) % 12 + 12) % 12
|
||||||
|
if (baseFret === 0) baseFret = 12 // root at open string → use the octave barre
|
||||||
|
return {
|
||||||
|
open: false,
|
||||||
|
offsets: shape.offsets, // low-E first
|
||||||
|
baseFret,
|
||||||
|
rootStrIdx,
|
||||||
|
rootPc: targetPc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build per-display-row absolute fret + root flag from a resolved shape.
|
||||||
|
// Returns null if the shape cannot be placed gracefully (root above ~fret 12).
|
||||||
|
function buildRows(resolved) {
|
||||||
|
if (!resolved) return null
|
||||||
|
|
||||||
|
// Absolute fret per low-E-first data index.
|
||||||
|
let absLowE
|
||||||
|
if (resolved.open) {
|
||||||
|
absLowE = resolved.absLowE
|
||||||
|
} else {
|
||||||
|
absLowE = resolved.offsets.map(o =>
|
||||||
|
o === 'x' || o == null ? 'x' : resolved.baseFret + o
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highest fretted note — degrade if unplayably high.
|
||||||
|
const fretted = absLowE.filter(f => typeof f === 'number' && f > 0)
|
||||||
|
const maxFret = fretted.length ? Math.max(...fretted) : 0
|
||||||
|
if (maxFret > 15) return null
|
||||||
|
|
||||||
|
// Window: lowest visible fret of the grid.
|
||||||
|
// Show the nut if everything fits within NUM_FRETS of it; else start at the
|
||||||
|
// lowest fretted note so the grip sits at the top of the window.
|
||||||
|
const minFret = fretted.length ? Math.min(...fretted) : 0
|
||||||
|
const startFret = maxFret <= NUM_FRETS ? 1 : minFret
|
||||||
|
|
||||||
|
// Root pitch class for colouring.
|
||||||
|
const rootPc = resolved.rootPc
|
||||||
|
|
||||||
|
// Convert to display rows (high-E first → reverse of low-E-first).
|
||||||
|
const rows = []
|
||||||
|
for (let di = 0; di < NUM_STRINGS; di++) {
|
||||||
|
const dataIdx = NUM_STRINGS - 1 - di
|
||||||
|
const f = absLowE[dataIdx]
|
||||||
|
const stringPc = (OPEN_PCS[dataIdx] + (typeof f === 'number' ? f : 0)) % 12
|
||||||
|
const isRoot = typeof f === 'number' && f >= 0 && stringPc === rootPc
|
||||||
|
rows.push({ fret: f, isRoot, stringPc })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows, startFret, showNut: startFret === 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChordDiagram({
|
||||||
|
shape,
|
||||||
|
keyRoot,
|
||||||
|
rootPc,
|
||||||
|
size = 'thumb',
|
||||||
|
label,
|
||||||
|
}) {
|
||||||
|
const resolved = resolveShape(shape, rootPc, keyRoot)
|
||||||
|
const built = buildRows(resolved)
|
||||||
|
|
||||||
|
const full = size === 'full'
|
||||||
|
|
||||||
|
// Geometry. thumb grid ~64px wide; full ~2x.
|
||||||
|
const scale = full ? 2 : 1
|
||||||
|
const cell = 11 * scale // px per fret row (vertical)
|
||||||
|
const sw = 11 * scale // px per string gap (horizontal)
|
||||||
|
const padL = 14 * scale // left pad (mute/open markers + start-fret label)
|
||||||
|
const padR = 6 * scale
|
||||||
|
const padT = 11 * scale // top pad (mute/open marker row)
|
||||||
|
const padB = (full ? 16 : 6) * scale // bottom pad (finger numbers / breathing room)
|
||||||
|
|
||||||
|
const gridW = (NUM_STRINGS - 1) * sw
|
||||||
|
const gridH = NUM_FRETS * cell
|
||||||
|
const svgW = padL + gridW + padR
|
||||||
|
const svgH = padT + gridH + padB
|
||||||
|
|
||||||
|
const stringX = si => padL + si * sw // si: 0 = high E (left) … 5 = low E
|
||||||
|
const fretY = fi => padT + fi * cell // fi: 0 = top line … NUM_FRETS
|
||||||
|
|
||||||
|
if (!built) {
|
||||||
|
// Graceful degradation: shape can't be placed.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="inline-flex flex-col items-center justify-center bg-panel border border-border rounded-lg text-gray-500"
|
||||||
|
style={{ width: svgW, minHeight: svgH }}
|
||||||
|
role="img"
|
||||||
|
aria-label={label ? `${label}: voicing unavailable` : 'voicing unavailable'}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 9 * scale }}>—</span>
|
||||||
|
{label && full && <span style={{ fontSize: 8 * scale }} className="mt-1">{label}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows, startFret, showNut } = built
|
||||||
|
|
||||||
|
const fingers = shape?.fingers // low-E first, optional
|
||||||
|
|
||||||
|
const ariaLabel = label
|
||||||
|
? `${label} guitar chord diagram`
|
||||||
|
: 'guitar chord diagram'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex flex-col items-center" role="img" aria-label={ariaLabel}>
|
||||||
|
<svg
|
||||||
|
width={svgW}
|
||||||
|
height={svgH}
|
||||||
|
viewBox={`0 0 ${svgW} ${svgH}`}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
>
|
||||||
|
{/* Board background */}
|
||||||
|
<rect
|
||||||
|
x={padL - 1}
|
||||||
|
y={padT - 1}
|
||||||
|
width={gridW + 2}
|
||||||
|
height={gridH + 2}
|
||||||
|
fill={BOARD}
|
||||||
|
rx={2 * scale}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Start-fret indicator ("5fr") when the grid begins above the nut */}
|
||||||
|
{!showNut && (
|
||||||
|
<text
|
||||||
|
x={padL - 4 * scale}
|
||||||
|
y={fretY(0) + cell * 0.62}
|
||||||
|
textAnchor="end"
|
||||||
|
fontSize={7 * scale}
|
||||||
|
fill={FRET_LABEL}
|
||||||
|
>
|
||||||
|
{startFret}fr
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Frets (horizontal lines) */}
|
||||||
|
{Array.from({ length: NUM_FRETS + 1 }, (_, fi) => fi).map(fi => {
|
||||||
|
const topNut = showNut && fi === 0
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={fi}
|
||||||
|
x1={stringX(0)}
|
||||||
|
y1={fretY(fi)}
|
||||||
|
x2={stringX(NUM_STRINGS - 1)}
|
||||||
|
y2={fretY(fi)}
|
||||||
|
stroke={topNut ? NUT_COL : FRET_LINE}
|
||||||
|
strokeWidth={topNut ? 3 * scale : 1 * scale}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Strings (vertical lines) */}
|
||||||
|
{rows.map((_, si) => (
|
||||||
|
<line
|
||||||
|
key={si}
|
||||||
|
x1={stringX(si)}
|
||||||
|
y1={fretY(0)}
|
||||||
|
x2={stringX(si)}
|
||||||
|
y2={fretY(NUM_FRETS)}
|
||||||
|
stroke={STRING_COL}
|
||||||
|
strokeWidth={(si >= 4 ? 1.4 : si >= 2 ? 1.1 : 0.8) * scale}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Per-string markers: mute ✕ / open ○ above the nut, dots on the grid */}
|
||||||
|
{rows.map((row, si) => {
|
||||||
|
const x = stringX(si)
|
||||||
|
const dataIdx = NUM_STRINGS - 1 - si
|
||||||
|
const finger = fingers ? fingers[dataIdx] : 0
|
||||||
|
|
||||||
|
// Muted string → ✕ above the board.
|
||||||
|
if (row.fret === 'x' || row.fret == null) {
|
||||||
|
const my = padT - 4 * scale
|
||||||
|
const r = 3 * scale
|
||||||
|
return (
|
||||||
|
<g key={si} stroke={MUTE_OPEN} strokeWidth={1 * scale} strokeLinecap="round">
|
||||||
|
<line x1={x - r} y1={my - r} x2={x + r} y2={my + r} />
|
||||||
|
<line x1={x - r} y1={my + r} x2={x + r} y2={my - r} />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open string (absolute fret 0, only meaningful when nut is shown) → ○.
|
||||||
|
if (row.fret === 0) {
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={si}
|
||||||
|
cx={x}
|
||||||
|
cy={padT - 4 * scale}
|
||||||
|
r={3 * scale}
|
||||||
|
fill="none"
|
||||||
|
stroke={MUTE_OPEN}
|
||||||
|
strokeWidth={1 * scale}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fretted note → dot, positioned in its fret row within the window.
|
||||||
|
const rowInWindow = row.fret - startFret // 0-based row from top
|
||||||
|
if (rowInWindow < 0 || rowInWindow >= NUM_FRETS) return null
|
||||||
|
const cy = fretY(rowInWindow) + cell / 2
|
||||||
|
const r = (full ? 4 : 3.5) * scale
|
||||||
|
const fill = row.isRoot ? ACCENT : DOT
|
||||||
|
const showFinger = full && finger > 0
|
||||||
|
return (
|
||||||
|
<g key={si}>
|
||||||
|
<circle cx={x} cy={cy} r={r} fill={fill} />
|
||||||
|
{showFinger && (
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={cy + 3 * scale}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={7 * scale}
|
||||||
|
fontWeight="700"
|
||||||
|
fill={row.isRoot ? '#fff' : DOT_TEXT_DARK}
|
||||||
|
>
|
||||||
|
{finger}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
className="text-gray-300 leading-none mt-1"
|
||||||
|
style={{ fontSize: full ? 12 : 9 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
// CircleOfFifths — the Knowledge Center's live key map (task D-61).
|
||||||
|
//
|
||||||
|
// A read-only SVG circle of fifths for a jamming musician, not a textbook
|
||||||
|
// poster:
|
||||||
|
//
|
||||||
|
// · Outer ring: the 12 major keys, C at 12 o'clock, fifths clockwise.
|
||||||
|
// Each wedge carries its key-signature glyph (♮ / n♯ / n♭).
|
||||||
|
// · Inner ring: the relative minors, aligned with their majors.
|
||||||
|
// · LIVE: the detected/locked key (keyInfo = App's effectiveKey, flowing
|
||||||
|
// through KnowledgeDock → ExploreSection) lights its wedge in accent; its
|
||||||
|
// fifths neighbours (subdominant, dominant) and its relative get a soft
|
||||||
|
// accent tier — the "safe keys to wander to" story with zero interaction.
|
||||||
|
// Modal keys pick their ring by the mode's third (dorian/phrygian → the
|
||||||
|
// minor ring), derived from theory.js SCALES — never re-derived here.
|
||||||
|
// · The highlighted key's diatonic chords (theory.js getChordsInKey) are
|
||||||
|
// listed beside the circle inline — no hover, no click required.
|
||||||
|
// · OPTIONAL tap: any wedge previews that key's diatonics in the side panel
|
||||||
|
// (dashed outline marks the previewed wedge; the live highlight never
|
||||||
|
// moves). Tapping NEVER changes app key state — this surface is read-only.
|
||||||
|
// · No key detected → neutral circle with honest microcopy.
|
||||||
|
//
|
||||||
|
// Purely presentational: props in ({ keyInfo, onChordClick }), nothing out.
|
||||||
|
// Keyboard: every wedge is a focusable button (Enter/Space previews); focus
|
||||||
|
// draws an explicit accent-soft stroke (SVG-safe — no reliance on box-shadow).
|
||||||
|
//
|
||||||
|
// Design tokens (tailwind.config.js) — literal here because SVG paint
|
||||||
|
// attributes can't read Tailwind classes (MiniPiano/Fretboard precedent):
|
||||||
|
// accent #a855f7, surface #0f0f0f, panel #1a1a1a, border #2a2a2a. No new
|
||||||
|
// colours: #c084fc is MiniPiano's established soft accent; greys are the
|
||||||
|
// Tailwind gray-300/400 already used across the app's SVGs.
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { NOTES, SCALES, getChordsInKey, toRomanNumeral } from '../lib/theory'
|
||||||
|
import { parseChord } from '../lib/voicings'
|
||||||
|
|
||||||
|
// ─── Token literals (SVG paint attrs; see header) ─────────────────────────────
|
||||||
|
const ACCENT = '#a855f7' // bg-accent — the live key wedge
|
||||||
|
const ACCENT_SOFT = '#c084fc' // MiniPiano's soft accent — neighbour-tier text
|
||||||
|
const SURFACE = '#0f0f0f' // bg-surface — wedge gaps + text on accent (AA 4.84:1)
|
||||||
|
const PANEL = '#1a1a1a' // bg-panel — idle wedge fill
|
||||||
|
const BORDER = '#2a2a2a' // border-border — centre hub stroke
|
||||||
|
const TEXT_MAIN = '#d1d5db' // gray-300 — idle key names (11.4:1 on panel)
|
||||||
|
const TEXT_DIM = '#9ca3af' // gray-400 — signature glyphs, microcopy (6.4:1)
|
||||||
|
|
||||||
|
// ─── The circle, index 0 = 12 o'clock, fifths clockwise ───────────────────────
|
||||||
|
// Display spelling is the conventional poster mix (flats on the flat side);
|
||||||
|
// all LOGIC runs on pitch classes so detection's sharp spellings match fine.
|
||||||
|
const MAJOR_LABELS = ['C','G','D','A','E','B','F♯','D♭','A♭','E♭','B♭','F']
|
||||||
|
const MINOR_LABELS = ['Am','Em','Bm','F♯m','C♯m','G♯m','E♭m','B♭m','Fm','Cm','Gm','Dm']
|
||||||
|
const SIG_GLYPHS = ['♮','1♯','2♯','3♯','4♯','5♯','6♯','5♭','4♭','3♭','2♭','1♭']
|
||||||
|
|
||||||
|
const majorPcAt = (i) => (i * 7) % 12 // wedge index → major tonic pc
|
||||||
|
const minorPcAt = (i) => (i * 7 + 9) % 12 // wedge index → relative minor pc
|
||||||
|
const majorIdxOf = (pc) => (pc * 7) % 12 // 7·7 ≡ 1 (mod 12): self-inverse
|
||||||
|
const minorIdxOf = (pc) => majorIdxOf((pc + 3) % 12)
|
||||||
|
|
||||||
|
const sigWords = (i) =>
|
||||||
|
i === 0 ? 'no sharps or flats' : i <= 6 ? `${i} sharp${i > 1 ? 's' : ''}` : `${12 - i} flat${12 - i > 1 ? 's' : ''}`
|
||||||
|
|
||||||
|
// The MODE's parent major: the unique major scale whose pc-set equals the
|
||||||
|
// mode's pc-set — ITS signature is the mode's true signature (A dorian =
|
||||||
|
// G major's notes = 1♯, not A major's 3♯, and not the tonic wedge's glyph).
|
||||||
|
// Uniqueness proof: the diatonic pc-set has no transpositional symmetry, so
|
||||||
|
// its 12 transpositions are 12 DISTINCT 7-note sets — a given 7-note set can
|
||||||
|
// therefore equal AT MOST one of them. Every key-dropdown mode (major, minor,
|
||||||
|
// dorian, phrygian, lydian, mixolydian) is by definition a rotation of the
|
||||||
|
// diatonic set, so for all 6 modes × 12 roots exactly one parent major exists
|
||||||
|
// (existence: rotating the mode back to its parent). Non-heptatonic scales
|
||||||
|
// (pentatonics, blues) match none — we return null and the hub omits the
|
||||||
|
// signature line rather than guessing.
|
||||||
|
function parentMajorPc(tonicPc, scale) {
|
||||||
|
const pcs = new Set(scale.map((s) => (tonicPc + s) % 12))
|
||||||
|
if (pcs.size !== 7) return null
|
||||||
|
const hits = []
|
||||||
|
for (let p = 0; p < 12; p++) {
|
||||||
|
if (SCALES.major.every((s) => pcs.has((p + s) % 12))) hits.push(p)
|
||||||
|
}
|
||||||
|
return hits.length === 1 ? hits[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Geometry (viewBox 0 0 300 300, centre 150) ───────────────────────────────
|
||||||
|
const CX = 150, CY = 150
|
||||||
|
const R_OUT = 142, R_MID = 96, R_IN = 58, R_HUB = 54
|
||||||
|
|
||||||
|
function pt(r, deg) {
|
||||||
|
const t = (deg * Math.PI) / 180 // 0° = 12 o'clock, clockwise
|
||||||
|
return `${(CX + r * Math.sin(t)).toFixed(2)},${(CY - r * Math.cos(t)).toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function wedgePath(i, r0, r1) {
|
||||||
|
const a0 = i * 30 - 15, a1 = i * 30 + 15
|
||||||
|
return `M ${pt(r1, a0)} A ${r1},${r1} 0 0 1 ${pt(r1, a1)} L ${pt(r0, a1)} A ${r0},${r0} 0 0 0 ${pt(r0, a0)} Z`
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelXY(i, r) {
|
||||||
|
const t = (i * 30 * Math.PI) / 180
|
||||||
|
return { x: CX + r * Math.sin(t), y: CY - r * Math.cos(t) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Diatonic chord chips (inline — the core, zero-click payload) ─────────────
|
||||||
|
function ChordChips({ root, mode, onChordClick }) {
|
||||||
|
const chords = getChordsInKey(root, mode)
|
||||||
|
if (!chords.length) return null
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{chords.map((c) => {
|
||||||
|
const rn = toRomanNumeral(c, root, mode)
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<span>{c}</span>
|
||||||
|
{rn && rn !== '?' && (
|
||||||
|
<span className="text-[9px] font-normal opacity-60 leading-none mt-0.5">{rn}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return onChordClick ? (
|
||||||
|
<button
|
||||||
|
key={c} type="button" onClick={() => onChordClick(c)}
|
||||||
|
title={`Open ${c} voicings`}
|
||||||
|
className="flex flex-col items-center px-2.5 py-1 rounded-lg border text-xs font-bold bg-panel border-border text-gray-300 transition-all outline-none hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span key={c} className="flex flex-col items-center px-2.5 py-1 rounded-lg border text-xs font-bold bg-panel border-border text-gray-300">
|
||||||
|
{inner}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── The component ────────────────────────────────────────────────────────────
|
||||||
|
export default function CircleOfFifths({ keyInfo, onChordClick }) {
|
||||||
|
const [preview, setPreview] = useState(null) // { ring, idx } | null — local only
|
||||||
|
const [hot, setHot] = useState(null) // 'ring-idx' hovered/focused wedge
|
||||||
|
|
||||||
|
// Live key → ring + wedge index. Ring by the MODE'S THIRD (SCALES, theory.js):
|
||||||
|
// minor/dorian/phrygian sit on the minor ring at their tonic — A minor (or
|
||||||
|
// A dorian) lights the inner "Am" wedge, NOT outer C.
|
||||||
|
const live = useMemo(() => {
|
||||||
|
if (!keyInfo?.root) return null
|
||||||
|
const pc = parseChord(keyInfo.root)?.rootPc
|
||||||
|
if (pc == null) return null
|
||||||
|
const mode = keyInfo.mode ?? 'major'
|
||||||
|
const scale = SCALES[mode] ?? SCALES.major
|
||||||
|
// Minor third AND no major third — a bare scale[2] === 3 check would
|
||||||
|
// misfile the hexatonic blues scale [0,3,5,6,7,10] (carries both colours)
|
||||||
|
// if it ever reached the key dropdown.
|
||||||
|
const minorish = scale.includes(3) && !scale.includes(4)
|
||||||
|
const parentPc = parentMajorPc(pc, scale)
|
||||||
|
return {
|
||||||
|
pc, mode,
|
||||||
|
ring: minorish ? 'minor' : 'major',
|
||||||
|
idx: minorish ? minorIdxOf(pc) : majorIdxOf(pc),
|
||||||
|
// circle position of the mode's PARENT MAJOR → the true signature glyph
|
||||||
|
sigIdx: parentPc == null ? null : majorIdxOf(parentPc),
|
||||||
|
}
|
||||||
|
}, [keyInfo])
|
||||||
|
|
||||||
|
// Tier per wedge: 2 = the live key (accent), 1 = its safe neighbours
|
||||||
|
// (subdominant · dominant on the same ring, relative on the other), 0 = idle.
|
||||||
|
function tierOf(ring, idx) {
|
||||||
|
if (!live) return 0
|
||||||
|
if (live.ring === ring) {
|
||||||
|
if (idx === live.idx) return 2
|
||||||
|
if (idx === (live.idx + 1) % 12 || idx === (live.idx + 11) % 12) return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return idx === live.idx ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLiveWedge = (ring, idx) => live && live.ring === ring && live.idx === idx
|
||||||
|
|
||||||
|
function tapWedge(ring, idx) {
|
||||||
|
if (isLiveWedge(ring, idx)) { setPreview(null); return } // tapping home = back to live
|
||||||
|
setPreview((p) => (p && p.ring === ring && p.idx === idx ? null : { ring, idx }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build both rings' wedges; paint order = idle → neighbours → live → preview
|
||||||
|
// → hot, so highlight strokes always win the shared edges.
|
||||||
|
const wedges = []
|
||||||
|
for (const ring of ['major', 'minor']) {
|
||||||
|
const outer = ring === 'major'
|
||||||
|
for (let idx = 0; idx < 12; idx++) {
|
||||||
|
const tier = tierOf(ring, idx)
|
||||||
|
const previewed = preview && preview.ring === ring && preview.idx === idx
|
||||||
|
const id = `${ring}-${idx}`
|
||||||
|
const name = outer ? MAJOR_LABELS[idx] : MINOR_LABELS[idx]
|
||||||
|
const aria = outer
|
||||||
|
? `${name} major, ${sigWords(idx)}${tier === 2 ? ' — the live key' : ''}. Preview its chords.`
|
||||||
|
: `${MINOR_LABELS[idx].replace(/m$/, '')} minor, relative of ${MAJOR_LABELS[idx]} major${tier === 2 ? ' — the live key' : ''}. Preview its chords.`
|
||||||
|
wedges.push({
|
||||||
|
ring, idx, id, tier, previewed, outer, aria,
|
||||||
|
z: hot === id ? 5 : previewed ? 4 : tier + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wedges.sort((a, b) => a.z - b.z)
|
||||||
|
|
||||||
|
// Side panel: the previewed key wins the panel; the LIVE view stays complete
|
||||||
|
// without any tap (live highlight + its chords render by default).
|
||||||
|
// Preview title is spelled SHARP-side from NOTES, matching its chips:
|
||||||
|
// getChordsInKey emits the app-wide sharp spelling (theory.js noteName), and
|
||||||
|
// those names flow into onChordClick → ChordDetailModal, so re-spelling the
|
||||||
|
// chips flat would add a divergent naming layer in front of the modal. One
|
||||||
|
// spelling authority (NOTES) for title + chips + modal; the WEDGE keeps its
|
||||||
|
// poster label (E♭) for at-a-glance reading.
|
||||||
|
let shown = null
|
||||||
|
if (preview) {
|
||||||
|
const root = NOTES[preview.ring === 'major' ? majorPcAt(preview.idx) : minorPcAt(preview.idx)]
|
||||||
|
const mode = preview.ring === 'major' ? 'major' : 'minor'
|
||||||
|
shown = { root, mode, title: `${root} ${mode}`, isPreview: true }
|
||||||
|
} else if (live) {
|
||||||
|
shown = { root: keyInfo.root, mode: live.mode, title: `${keyInfo.root} ${live.mode}`, isPreview: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neighbour names for the live key (the tier-1 wedges, spelled out).
|
||||||
|
const neighbours = live && {
|
||||||
|
sub: (live.ring === 'major' ? MAJOR_LABELS : MINOR_LABELS)[(live.idx + 11) % 12],
|
||||||
|
dom: (live.ring === 'major' ? MAJOR_LABELS : MINOR_LABELS)[(live.idx + 1) % 12],
|
||||||
|
rel: (live.ring === 'major' ? MINOR_LABELS : MAJOR_LABELS)[live.idx],
|
||||||
|
relWord: live.ring === 'major' ? 'relative minor' : 'relative major',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Circle of fifths"
|
||||||
|
className="p-3 bg-surface border border-border rounded-xl flex flex-wrap gap-x-5 gap-y-3 items-start"
|
||||||
|
>
|
||||||
|
{/* ── The circle ── */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 300 300"
|
||||||
|
role="group"
|
||||||
|
aria-label={live
|
||||||
|
? `Circle of fifths, live key ${keyInfo.root} ${live.mode}`
|
||||||
|
: 'Circle of fifths, no key detected yet'}
|
||||||
|
className="w-[248px] max-w-full h-auto shrink-0 mx-auto select-none"
|
||||||
|
>
|
||||||
|
{/* wedges (paint-ordered) */}
|
||||||
|
{wedges.map((w) => {
|
||||||
|
const [r0, r1] = w.outer ? [R_MID, R_OUT] : [R_IN, R_MID]
|
||||||
|
const stroke = hot === w.id ? ACCENT_SOFT : w.previewed ? ACCENT : SURFACE
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={w.id}
|
||||||
|
d={wedgePath(w.idx, r0, r1)}
|
||||||
|
fill={w.tier === 0 ? PANEL : ACCENT}
|
||||||
|
fillOpacity={w.tier === 1 ? 0.22 : 1}
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth={hot === w.id || w.previewed ? 2 : 1.25}
|
||||||
|
strokeDasharray={w.previewed && hot !== w.id ? '4 3' : undefined}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={w.aria}
|
||||||
|
aria-pressed={!!w.previewed}
|
||||||
|
className="cursor-pointer outline-none"
|
||||||
|
onClick={() => tapWedge(w.ring, w.idx)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tapWedge(w.ring, w.idx) }
|
||||||
|
}}
|
||||||
|
onPointerEnter={() => setHot(w.id)}
|
||||||
|
onPointerLeave={() => setHot((h) => (h === w.id ? null : h))}
|
||||||
|
onFocus={() => setHot(w.id)}
|
||||||
|
onBlur={() => setHot((h) => (h === w.id ? null : h))}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* labels (own layer, never swallow clicks) */}
|
||||||
|
{Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const tMaj = tierOf('major', i), tMin = tierOf('minor', i)
|
||||||
|
const name = labelXY(i, 123)
|
||||||
|
const glyph = labelXY(i, 106)
|
||||||
|
const minor = labelXY(i, 77)
|
||||||
|
return (
|
||||||
|
<g key={i} pointerEvents="none">
|
||||||
|
<text x={name.x} y={name.y} textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fontSize="13" fontWeight="700"
|
||||||
|
fill={tMaj === 2 ? SURFACE : tMaj === 1 ? ACCENT_SOFT : TEXT_MAIN}>
|
||||||
|
{MAJOR_LABELS[i]}
|
||||||
|
</text>
|
||||||
|
<text x={glyph.x} y={glyph.y} textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fontSize="8.5"
|
||||||
|
fill={tMaj === 2 ? SURFACE : tMaj === 1 ? ACCENT_SOFT : TEXT_DIM}>
|
||||||
|
{SIG_GLYPHS[i]}
|
||||||
|
</text>
|
||||||
|
<text x={minor.x} y={minor.y} textAnchor="middle" dominantBaseline="middle"
|
||||||
|
fontSize="11" fontWeight={tMin === 2 ? '700' : '600'}
|
||||||
|
fill={tMin === 2 ? SURFACE : tMin === 1 ? ACCENT_SOFT : TEXT_DIM}>
|
||||||
|
{MINOR_LABELS[i]}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* centre hub — always the LIVE state (the glance anchor) */}
|
||||||
|
<circle cx={CX} cy={CY} r={R_HUB} fill={SURFACE} stroke={BORDER} strokeWidth="1" />
|
||||||
|
{live ? (
|
||||||
|
<g pointerEvents="none">
|
||||||
|
<text x={CX} y={CY - 10} textAnchor="middle" fontSize="14" fontWeight="800" fill={ACCENT}>
|
||||||
|
{keyInfo.root} {live.mode}
|
||||||
|
</text>
|
||||||
|
{/* The MODE's signature (parent major), not the wedge's — A dorian
|
||||||
|
reads "1♯ · G major's notes", matching the F♯ in its chips. */}
|
||||||
|
{live.sigIdx != null && (
|
||||||
|
<text x={CX} y={CY + 6} textAnchor="middle" fontSize="8.5" fill={TEXT_DIM}>
|
||||||
|
{SIG_GLYPHS[live.sigIdx]} · {live.mode === 'major'
|
||||||
|
? sigWords(live.sigIdx)
|
||||||
|
: `${MAJOR_LABELS[live.sigIdx]} major's notes`}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<text x={CX} y={CY + 21} textAnchor="middle" fontSize="8" fontWeight="700"
|
||||||
|
letterSpacing="1.5" fill={ACCENT_SOFT}>
|
||||||
|
LIVE
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
) : (
|
||||||
|
<g pointerEvents="none">
|
||||||
|
<text x={CX} y={CY - 8} textAnchor="middle" fontSize="11" fontWeight="700" fill={TEXT_MAIN}>
|
||||||
|
No key yet
|
||||||
|
</text>
|
||||||
|
<text x={CX} y={CY + 7} textAnchor="middle" fontSize="8.5" fill={TEXT_DIM}>
|
||||||
|
play a few chords —
|
||||||
|
</text>
|
||||||
|
<text x={CX} y={CY + 18} textAnchor="middle" fontSize="8.5" fill={TEXT_DIM}>
|
||||||
|
the circle lights up
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* ── Beside the circle: the highlighted key's diatonics, inline ── */}
|
||||||
|
<div className="flex-1 min-w-[220px] flex flex-col gap-2.5">
|
||||||
|
<h4 className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
|
Circle of fifths
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{shown ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center flex-wrap gap-2">
|
||||||
|
<span className="font-bold text-white text-sm">{shown.title}</span>
|
||||||
|
{shown.isPreview ? (
|
||||||
|
<>
|
||||||
|
<span className="text-[9px] uppercase tracking-wide font-semibold text-amber border border-amber/40 rounded px-1.5 py-px">
|
||||||
|
preview
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPreview(null)}
|
||||||
|
className="text-[11px] text-accent underline underline-offset-2 outline-none hover:text-white focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||||
|
>
|
||||||
|
back to live
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-[9px] uppercase tracking-wide font-semibold text-accent border border-accent/40 rounded px-1.5 py-px">
|
||||||
|
live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChordChips root={shown.root} mode={shown.mode} onChordClick={onChordClick} />
|
||||||
|
|
||||||
|
{!shown.isPreview && neighbours && (
|
||||||
|
<p className="text-[11px] text-gray-400">
|
||||||
|
Safe keys to wander to:{' '}
|
||||||
|
<span className="text-accent font-semibold">{neighbours.sub}</span> (subdominant) ·{' '}
|
||||||
|
<span className="text-accent font-semibold">{neighbours.dom}</span> (dominant) ·{' '}
|
||||||
|
<span className="text-accent font-semibold">{neighbours.rel}</span> ({neighbours.relWord})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
When a key is detected (or locked), its wedge lights up here with the chords that
|
||||||
|
live in it. Tap any wedge to peek at another key meanwhile.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-400 leading-snug">
|
||||||
|
Keys next to each other on the circle share six of their seven notes, so sliding one
|
||||||
|
step — clockwise to the dominant, counter-clockwise to the subdominant — barely moves
|
||||||
|
the ground under the band. The inner ring is each key's relative minor: the same
|
||||||
|
notes with a darker home base. The further apart two keys sit, the bolder the jump sounds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import ChordBox from './ChordBox'
|
||||||
|
import RiffDiagram from './RiffDiagram'
|
||||||
|
import { getGuitarVoicings, parseChord } from '../lib/voicings'
|
||||||
|
import { CHORD_TYPES, NOTES, toRomanNumeral } from '../lib/theory'
|
||||||
|
import { findSimilarProgressions, progressionInKey } from '../lib/education'
|
||||||
|
|
||||||
|
// ─── Scale ideas per mode ─────────────────────────────────────────────────────
|
||||||
|
const SCALE_IDEAS = {
|
||||||
|
major: [
|
||||||
|
{ name: 'Major Pentatonic', intervals: '1–2–3–5–6', scaleIntervals: [0,2,4,7,9], desc: 'Safe and bright. Everything you play will land. Start on the root, end on the root.' },
|
||||||
|
{ name: 'Mixolydian', intervals: '1–2–3–4–5–6–♭7', scaleIntervals: [0,2,4,5,7,9,10], desc: 'Major with a bluesy ♭7. The defining sound of classic rock — Sweet Home Alabama lives here.' },
|
||||||
|
{ name: 'Lydian', intervals: '1–2–3–♯4–5–6–7', scaleIntervals: [0,2,4,6,7,9,11], desc: 'Dreamy and floating. The ♯4 is the magic note — use it on long sustained notes for instant wonder.' },
|
||||||
|
],
|
||||||
|
minor: [
|
||||||
|
{ name: 'Minor Pentatonic', intervals: '1–♭3–4–5–♭7', scaleIntervals: [0,3,5,7,10], desc: 'The blues box. Bends on ♭3 and slides to 5 are gold. Start here every time.' },
|
||||||
|
{ name: 'Natural Minor', intervals: '1–2–♭3–4–5–♭6–♭7', scaleIntervals: [0,2,3,5,7,8,10], desc: 'Full Aeolian scale. Melodic and dark. The ♭6 gives it a cinematic quality.' },
|
||||||
|
{ name: 'Dorian', intervals: '1–2–♭3–4–5–6–♭7', scaleIntervals: [0,2,3,5,7,9,10], desc: "Minor with a raised 6th — smooth and soulful. Santana's go-to. That major 6th is everything." },
|
||||||
|
],
|
||||||
|
dorian: [
|
||||||
|
{ name: 'Dorian Mode', intervals: '1–2–♭3–4–5–6–♭7', scaleIntervals: [0,2,3,5,7,9,10], desc: "The raised 6th over minor is the colour. Mix freely with minor pentatonic and touch that 6th note." },
|
||||||
|
{ name: 'Minor Pentatonic', intervals: '1–♭3–4–5–♭7', scaleIntervals: [0,3,5,7,10], desc: 'Safe backbone in Dorian. You can ignore the 6th — or highlight it for that Dorian sparkle.' },
|
||||||
|
{ name: 'Blues Scale', intervals: '1–♭3–4–♭5–5–♭7', scaleIntervals: [0,3,5,6,7,10], desc: 'Add the ♭5 passing tone through the 5 — that slide is the essence of blues expression.' },
|
||||||
|
],
|
||||||
|
mixolydian: [
|
||||||
|
{ name: 'Mixolydian Mode', intervals: '1–2–3–4–5–6–♭7', scaleIntervals: [0,2,4,5,7,9,10], desc: 'The ♭7 is your signature note. Hit it and slide down — instant swagger.' },
|
||||||
|
{ name: 'Major Pentatonic', intervals: '1–2–3–5–6', scaleIntervals: [0,2,4,7,9], desc: 'Works beautifully over the I chord. Clean and reliable when you need to land safely.' },
|
||||||
|
{ name: 'Blues Scale', intervals: '1–♭3–3–4–5–♭7', scaleIntervals: [0,3,4,5,7,10], desc: 'The hybrid blues scale. Bend the ♭3 up to the 3 — that tension and release is everything.' },
|
||||||
|
],
|
||||||
|
phrygian: [
|
||||||
|
{ name: 'Phrygian Mode', intervals: '1–♭2–♭3–4–5–♭6–♭7', scaleIntervals: [0,1,3,5,7,8,10], desc: 'That ♭2 is the spine-chilling note. Lean on it. Spanish fire and metal darkness in one scale.' },
|
||||||
|
{ name: 'Phrygian Dominant',intervals: '1–♭2–3–4–5–♭6–♭7', scaleIntervals: [0,1,4,5,7,8,10], desc: 'Raise the ♭3 to a major 3rd. Flamenco and Middle-Eastern intensity. Dramatic every time.' },
|
||||||
|
{ name: 'Minor Pentatonic', intervals: '1–♭3–4–5–♭7', scaleIntervals: [0,3,5,7,10], desc: 'Avoid the ♭2 and play safe pentatonic runs — then hit the ♭2 as a surprise.' },
|
||||||
|
],
|
||||||
|
lydian: [
|
||||||
|
{ name: 'Lydian Mode', intervals: '1–2–3–♯4–5–6–7', scaleIntervals: [0,2,4,6,7,9,11], desc: 'Float on the ♯4. John Williams writes entire film scores in Lydian. Sustain everything.' },
|
||||||
|
{ name: 'Major Pentatonic', intervals: '1–2–3–5–6', scaleIntervals: [0,2,4,7,9], desc: 'The reliable base. Use Lydian mode sparingly on top for colour.' },
|
||||||
|
{ name: 'Lydian Dominant', intervals: '1–2–3–♯4–5–6–♭7', scaleIntervals: [0,2,4,6,7,9,10], desc: 'Lydian with a ♭7 — the jazz/fusion ♯4 chord sound. Herbie Hancock territory.' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
const SCALE_FALLBACK = SCALE_IDEAS.major
|
||||||
|
|
||||||
|
// ─── Style variations for a progression ──────────────────────────────────────
|
||||||
|
const STYLE_VARIATIONS = [
|
||||||
|
{
|
||||||
|
key: 'open',
|
||||||
|
label: 'Open & Spacious',
|
||||||
|
desc: 'Sus2 and add9 voicings — airy, gentle. Great for quiet intros and ambient sections.',
|
||||||
|
typeMap: { maj: 'sus2', min: 'sus2', dom7: 'sus4', maj7: 'add9', min7: 'sus2', add9: 'sus2', sus4: 'sus4', sus2: 'sus2', dim: 'dim', aug: 'aug', half_dim: 'half_dim', maj6: 'sus2', min6: 'sus2' },
|
||||||
|
color: 'text-blue-400',
|
||||||
|
border: 'border-blue-900/40',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'jazz',
|
||||||
|
label: 'Jazz Upgrade',
|
||||||
|
desc: 'Triads → 7ths — instant sophistication. Works at any tempo, in any band context.',
|
||||||
|
typeMap: { maj: 'maj7', min: 'min7', dom7: 'dom7', add9: 'maj7', sus2: 'sus2', sus4: 'sus4', dim: 'dim7', aug: 'aug', half_dim: 'half_dim', maj6: 'maj6', min6: 'min6' },
|
||||||
|
color: 'text-amber-400',
|
||||||
|
border: 'border-amber-900/40',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'blues',
|
||||||
|
label: 'Blues Stomp',
|
||||||
|
desc: 'Everything → dom7. Gritty, raw, powerful. All three chords want to slide and bend.',
|
||||||
|
typeMap: { maj: 'dom7', min: 'dom7', maj7: 'dom7', min7: 'dom7', add9: 'dom7', sus2: 'dom7', sus4: 'dom7', dim: 'dim7', aug: 'aug', half_dim: 'dom7', maj6: 'dom7', min6: 'dom7' },
|
||||||
|
color: 'text-red-400',
|
||||||
|
border: 'border-red-900/40',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'modern',
|
||||||
|
label: 'Neo-Soul / Modern',
|
||||||
|
desc: "Add9 on majors, m7 on minors. D'Angelo, Thundercat, Childish Gambino territory.",
|
||||||
|
typeMap: { maj: 'add9', min: 'min7', dom7: 'dom7', maj7: 'add9', min7: 'min7', add9: 'add9', sus2: 'sus2', sus4: 'sus4', dim: 'dim', aug: 'aug', half_dim: 'half_dim', maj6: 'add9', min6: 'min7' },
|
||||||
|
color: 'text-purple-400',
|
||||||
|
border: 'border-purple-900/40',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Transform a chord via a type map
|
||||||
|
function transformChord(chordStr, typeMap) {
|
||||||
|
const p = parseChord(chordStr)
|
||||||
|
if (!p) return chordStr
|
||||||
|
const newType = typeMap[p.type] ?? p.type
|
||||||
|
return NOTES[p.rootPc] + (CHORD_TYPES[newType]?.suffix ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get best voicings for a chord — prefer open shapes, then low-fret barre
|
||||||
|
function getBestVoicings(chordStr, max = 4) {
|
||||||
|
const all = getGuitarVoicings(chordStr)
|
||||||
|
// Sort: open shapes first (label contains "Open"), then barre
|
||||||
|
const open = all.filter(v => v.label.includes('Open'))
|
||||||
|
const barre = all.filter(v => !v.label.includes('Open'))
|
||||||
|
return [...open, ...barre].slice(0, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Per-chord voicing strip ──────────────────────────────────────────────────
|
||||||
|
function ChordStrip({ chordStr, keyInfo, onChordClick }) {
|
||||||
|
const voicings = getBestVoicings(chordStr, 4)
|
||||||
|
const rn = keyInfo?.root ? toRomanNumeral(chordStr, keyInfo.root, keyInfo.mode) : ''
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 bg-surface border border-border rounded-xl">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => onChordClick?.(chordStr)}
|
||||||
|
className="px-3 py-1 bg-accent/10 border border-accent/40 rounded-lg font-black text-lg text-accent hover:bg-accent/20 transition-colors">
|
||||||
|
{chordStr}
|
||||||
|
</button>
|
||||||
|
{rn && <span className="text-amber-400 text-sm font-semibold">{rn}</span>}
|
||||||
|
<span className="text-[11px] text-gray-600 ml-auto">click for all voicings</span>
|
||||||
|
</div>
|
||||||
|
{voicings.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{voicings.map((v, i) => (
|
||||||
|
<div key={i} className="flex flex-col items-center">
|
||||||
|
<ChordBox frets={v.frets} fingers={v.fingers} barre={v.barre} baseFret={v.baseFret} />
|
||||||
|
<p className="text-[10px] text-gray-600 text-center mt-1 max-w-[100px]">{v.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600 text-xs">No voicings available.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Style variation section ──────────────────────────────────────────────────
|
||||||
|
function StyleSection({ progression, onChordClick }) {
|
||||||
|
const [expanded, setExpanded] = useState(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{STYLE_VARIATIONS.map(style => {
|
||||||
|
const isOpen = expanded === style.key
|
||||||
|
const transformed = progression.map(c => transformChord(c, style.typeMap))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={style.key} className={`border rounded-xl overflow-hidden transition-colors ${style.border} hover:border-opacity-70`}>
|
||||||
|
<button onClick={() => setExpanded(isOpen ? null : style.key)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 text-left">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`font-bold text-sm ${style.color}`}>{style.label}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{transformed.map((c, i) => (
|
||||||
|
<span key={i} className="text-xs font-bold text-gray-300">{c}{i < transformed.length - 1 ? ' →' : ''}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-gray-500">{style.desc}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-600 shrink-0 ml-3">{isOpen ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t border-border/50 px-4 py-4">
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{transformed.map((c, i) => {
|
||||||
|
const voicings = getBestVoicings(c, 2)
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex flex-col items-center gap-2">
|
||||||
|
<button onClick={() => onChordClick?.(c)}
|
||||||
|
className="px-2 py-0.5 bg-panel border border-border hover:border-accent/50 rounded-lg font-bold text-sm text-gray-200 hover:text-accent transition-all">
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{voicings.map((v, vi) => (
|
||||||
|
<div key={vi} className="flex flex-col items-center">
|
||||||
|
<ChordBox frets={v.frets} fingers={v.fingers} barre={v.barre} baseFret={v.baseFret} />
|
||||||
|
<p className="text-[9px] text-gray-700 text-center mt-0.5 max-w-[90px]">{v.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Similar famous progressions ─────────────────────────────────────────────
|
||||||
|
function SimilarSection({ progression, keyInfo, onChordClick }) {
|
||||||
|
const similar = findSimilarProgressions(progression, keyInfo)
|
||||||
|
if (!similar.length) return (
|
||||||
|
<p className="text-gray-600 text-sm text-center py-3">Play more and lock a key — similar progressions will appear here.</p>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{similar.slice(0, 3).map(prog => {
|
||||||
|
const chordsHere = keyInfo?.root ? progressionInKey(prog, keyInfo.root) : []
|
||||||
|
return (
|
||||||
|
<div key={prog.id} className="p-3 bg-surface border border-border rounded-xl">
|
||||||
|
<div className="flex items-center flex-wrap gap-2 mb-2">
|
||||||
|
<span className="font-bold text-white text-sm">{prog.name}</span>
|
||||||
|
<span className="text-[10px] font-mono text-gray-600">{prog.pattern}</span>
|
||||||
|
<span className="text-xs text-gray-600 ml-auto">{Math.round(prog.score * 100)}% match</span>
|
||||||
|
</div>
|
||||||
|
{chordsHere.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 items-center mb-2">
|
||||||
|
{chordsHere.map((c, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
<button onClick={() => onChordClick?.(c)}
|
||||||
|
className={`px-2 py-0.5 rounded-lg font-bold text-xs border transition-all ${
|
||||||
|
i === 0 ? 'bg-accent border-accent text-white' : 'bg-panel border-border text-gray-300 hover:border-accent/50 hover:text-accent'
|
||||||
|
}`}>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
{i < chordsHere.length - 1 && <span className="text-gray-700 text-xs">→</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="text-[10px] text-gray-600 ml-1">in {keyInfo?.root} {keyInfo?.mode}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[11px] text-gray-600">{prog.songs.slice(0, 3).join(' · ')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main panel ───────────────────────────────────────────────────────────────
|
||||||
|
export default function CurrentJamPanel({ keyInfo, chordHistory, detectedProgression, onChordClick }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [view, setView] = useState('voicings') // voicings | scales | styles | similar
|
||||||
|
|
||||||
|
const { root, mode } = keyInfo ?? {}
|
||||||
|
|
||||||
|
// Working progression: detected loop or last 4 unique chords
|
||||||
|
const workingProgression = detectedProgression?.length
|
||||||
|
? detectedProgression
|
||||||
|
: [...new Set([...chordHistory].reverse())].reverse().slice(-4)
|
||||||
|
|
||||||
|
const scaleIdeas = SCALE_IDEAS[mode] ?? SCALE_FALLBACK
|
||||||
|
const rootPc = root ? NOTES.indexOf(root) : null
|
||||||
|
const hasSession = workingProgression.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
|
<button onClick={() => setOpen(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span>CURRENT JAM</span>
|
||||||
|
{root && (
|
||||||
|
<span className="text-[10px] px-2 py-0.5 bg-accent/10 border border-accent/30 rounded text-accent">
|
||||||
|
{root} {mode} {detectedProgression?.length ? `· ${workingProgression.join(' → ')}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span>{open ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-border p-4 flex flex-col gap-4">
|
||||||
|
|
||||||
|
{!hasSession ? (
|
||||||
|
<p className="text-gray-600 text-sm text-center py-6">Start listening and play some chords — your jam will appear here.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* ── Progression summary ── */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 px-3 py-2 bg-surface border border-border rounded-xl">
|
||||||
|
{root ? (
|
||||||
|
<span className="text-accent font-bold text-sm">{root} {mode}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600 text-sm">Key detecting…</span>
|
||||||
|
)}
|
||||||
|
{workingProgression.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-700">·</span>
|
||||||
|
{workingProgression.map((c, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
<span className="text-gray-300 font-bold text-sm">{c}</span>
|
||||||
|
{root && <span className="text-amber-400/60 text-[10px]">{toRomanNumeral(c, root, mode)}</span>}
|
||||||
|
{i < workingProgression.length - 1 && <span className="text-gray-700">→</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── View tabs ── */}
|
||||||
|
<div className="flex gap-1 bg-surface border border-border rounded-xl p-1 overflow-x-auto">
|
||||||
|
{[
|
||||||
|
{ key: 'voicings', label: '🎸 Open Voicings' },
|
||||||
|
{ key: 'scales', label: '🎵 Scales to Solo' },
|
||||||
|
{ key: 'styles', label: '🎨 Style Options' },
|
||||||
|
{ key: 'similar', label: '🔗 Similar Progressions' },
|
||||||
|
].map(t => (
|
||||||
|
<button key={t.key} onClick={() => setView(t.key)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all whitespace-nowrap ${
|
||||||
|
view === t.key ? 'bg-accent text-white' : 'text-gray-400 hover:text-white'
|
||||||
|
}`}>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Voicings: per chord open shapes ── */}
|
||||||
|
{view === 'voicings' && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Best open and barre voicings for each chord in your jam. Click a chord name to see all its voicings.
|
||||||
|
</p>
|
||||||
|
{workingProgression.map(chord => (
|
||||||
|
<ChordStrip key={chord} chordStr={chord} keyInfo={keyInfo} onChordClick={onChordClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Scales ── */}
|
||||||
|
{view === 'scales' && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Scales and modes that fit {root ? `${root} ${mode}` : 'your current key'}.
|
||||||
|
Start with the pentatonic — add the extra notes once you feel comfortable.
|
||||||
|
</p>
|
||||||
|
{scaleIdeas.map(idea => (
|
||||||
|
<div key={idea.name} className="p-3 bg-surface border border-border rounded-xl">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<span className="font-bold text-white text-sm">{idea.name}</span>
|
||||||
|
<span className="font-mono text-xs text-accent">{idea.intervals}</span>
|
||||||
|
</div>
|
||||||
|
{rootPc !== null && idea.scaleIntervals && (
|
||||||
|
<div className="mb-2 overflow-x-auto">
|
||||||
|
<RiffDiagram rootPc={rootPc} scaleIntervals={idea.scaleIntervals} />
|
||||||
|
<p className="text-[10px] text-gray-600 mt-1">
|
||||||
|
Purple = root · Grey = scale tone · Fret numbers above
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-400 leading-snug">{idea.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-[11px] text-gray-700 text-center">
|
||||||
|
Pro tip: always resolve to a chord tone at the end of a phrase — ♭7 leading to root, or 3rd landing on the 1.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Style options ── */}
|
||||||
|
{view === 'styles' && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Your progression re-voiced four ways. Expand any style to see the chord boxes.
|
||||||
|
</p>
|
||||||
|
<StyleSection progression={workingProgression} onChordClick={onChordClick} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Similar progressions ── */}
|
||||||
|
{view === 'similar' && (
|
||||||
|
<SimilarSection progression={workingProgression} keyInfo={keyInfo} onChordClick={onChordClick} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { toRomanNumeral, CHORD_TYPES, NOTES } from '../lib/theory'
|
||||||
|
import {
|
||||||
|
findSimilarProgressions,
|
||||||
|
getChordSubstitutions,
|
||||||
|
progressionInKey,
|
||||||
|
styleVariationInKey,
|
||||||
|
parseChord,
|
||||||
|
} from '../lib/education'
|
||||||
|
|
||||||
|
// ─── Session Snapshot ─────────────────────────────────────────────────────────
|
||||||
|
function SessionSnapshot({ keyInfo, detectedProgression, chordHistory }) {
|
||||||
|
const { root, mode, confidence } = keyInfo ?? {}
|
||||||
|
const uniqueChords = [...new Set(chordHistory)]
|
||||||
|
const totalPlayed = chordHistory.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-4 p-4 bg-surface border border-border rounded-xl">
|
||||||
|
<div className="flex flex-col gap-1 min-w-[120px]">
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600">Key</p>
|
||||||
|
{root ? (
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="text-2xl font-black text-accent">{root}</span>
|
||||||
|
<span className="text-sm text-gray-400 capitalize">{mode}</span>
|
||||||
|
{confidence && <span className="text-xs text-gray-600">{Math.round(confidence * 100)}%</span>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600 text-sm">Detecting…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px bg-border shrink-0" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600">Detected Loop</p>
|
||||||
|
{detectedProgression?.length ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{detectedProgression.map((chord, i) => (
|
||||||
|
<span key={i} className="px-2 py-0.5 bg-accent/10 border border-accent/30 rounded text-xs font-bold text-accent">
|
||||||
|
{chord}
|
||||||
|
{root && <span className="text-amber-400/70 ml-1 font-normal text-[10px]">
|
||||||
|
{toRomanNumeral(chord, root, mode)}
|
||||||
|
</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600 text-sm">None yet — keep playing!</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px bg-border shrink-0" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600">Session</p>
|
||||||
|
<p className="text-sm text-gray-300">
|
||||||
|
<span className="font-bold text-white">{totalPlayed}</span> chords ·
|
||||||
|
<span className="font-bold text-white">{uniqueChords.length}</span> unique
|
||||||
|
</p>
|
||||||
|
{uniqueChords.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||||
|
{uniqueChords.slice(0, 10).map(c => (
|
||||||
|
<span key={c} className="text-[10px] text-gray-500 bg-border px-1.5 py-0.5 rounded">{c}</span>
|
||||||
|
))}
|
||||||
|
{uniqueChords.length > 10 && <span className="text-[10px] text-gray-600">+{uniqueChords.length - 10}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Similar Progressions ─────────────────────────────────────────────────────
|
||||||
|
function SimilarProgressions({ similar, keyInfo, onChordClick }) {
|
||||||
|
const [expanded, setExpanded] = useState(null)
|
||||||
|
if (!similar.length) return (
|
||||||
|
<p className="text-gray-600 text-sm text-center py-4">
|
||||||
|
Play more chords and lock a key to find similar famous progressions.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{similar.map(prog => {
|
||||||
|
const isOpen = expanded === prog.id
|
||||||
|
const chordsInKey = keyInfo?.root ? progressionInKey(prog, keyInfo.root) : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={prog.id}
|
||||||
|
className="border border-border rounded-xl overflow-hidden hover:border-accent/30 transition-colors">
|
||||||
|
|
||||||
|
{/* Header row */}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(isOpen ? null : prog.id)}
|
||||||
|
className="w-full flex items-start justify-between gap-3 px-4 py-3 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-bold text-white text-sm">{prog.name}</span>
|
||||||
|
{prog.genre.map(g => (
|
||||||
|
<span key={g} className="px-1.5 py-0.5 bg-accent/10 border border-accent/20 rounded text-[10px] text-accent">{g}</span>
|
||||||
|
))}
|
||||||
|
<span className="text-[11px] text-gray-500 font-mono">{prog.pattern}</span>
|
||||||
|
<span className="ml-auto text-xs text-gray-600">{Math.round(prog.score * 100)}% match</span>
|
||||||
|
</div>
|
||||||
|
{/* Chords in current key */}
|
||||||
|
{chordsInKey.length > 0 && (
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{chordsInKey.map((c, i) => (
|
||||||
|
<button key={i}
|
||||||
|
onClick={e => { e.stopPropagation(); onChordClick?.(c) }}
|
||||||
|
className="px-2 py-0.5 bg-surface border border-border hover:border-accent/50 rounded text-xs font-bold text-gray-200 hover:text-accent transition-colors"
|
||||||
|
title={`See voicings for ${c}`}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="text-[10px] text-gray-600 self-center ml-1">in {keyInfo.root} {keyInfo.mode}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-600 shrink-0 text-sm mt-0.5">{isOpen ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded detail */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="border-t border-border px-4 py-4 flex flex-col gap-4">
|
||||||
|
<p className="text-sm text-gray-400">{prog.description}</p>
|
||||||
|
{prog.tip && (
|
||||||
|
<p className="text-xs text-amber-400/80">
|
||||||
|
<span className="text-amber-400 font-semibold">Insight:</span> {prog.tip}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Song examples */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Famous examples</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{prog.songs.map(s => (
|
||||||
|
<span key={s} className="px-2 py-1 bg-surface border border-border rounded-lg text-xs text-gray-400">{s}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Style variations */}
|
||||||
|
{prog.styleVariations.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Style variations</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{prog.styleVariations.map(sv => {
|
||||||
|
const svChords = keyInfo?.root ? styleVariationInKey(sv, prog, keyInfo.root) : []
|
||||||
|
return (
|
||||||
|
<div key={sv.label} className="flex items-start gap-3 p-2 bg-surface rounded-lg border border-border">
|
||||||
|
<span className="text-xs font-bold text-accent shrink-0 w-16">{sv.label}</span>
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<span className="text-xs text-gray-500 font-mono">{sv.pattern}</span>
|
||||||
|
{svChords.length > 0 && (
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{svChords.map((c, i) => (
|
||||||
|
<button key={i}
|
||||||
|
onClick={() => onChordClick?.(c)}
|
||||||
|
className="px-1.5 py-0.5 bg-accent/10 border border-accent/20 hover:border-accent rounded text-[11px] font-bold text-accent/90 hover:text-accent transition-colors"
|
||||||
|
title={`See voicings for ${c}`}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="text-[10px] text-gray-600 self-center ml-1">in {keyInfo.root}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Play It Differently ──────────────────────────────────────────────────────
|
||||||
|
function PlayDifferently({ progression, onChordClick }) {
|
||||||
|
if (!progression?.length) return (
|
||||||
|
<p className="text-gray-600 text-sm text-center py-4">
|
||||||
|
Keep playing — a repeating progression will appear here with substitution ideas.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Tap any substitution to see how to play it. These are harmonic replacements — same role, different colour.
|
||||||
|
</p>
|
||||||
|
{progression.map(chord => {
|
||||||
|
const subs = getChordSubstitutions(chord)
|
||||||
|
return (
|
||||||
|
<div key={chord} className="flex flex-wrap items-start gap-3 p-3 bg-surface border border-border rounded-xl">
|
||||||
|
{/* Original chord */}
|
||||||
|
<button
|
||||||
|
onClick={() => onChordClick?.(chord)}
|
||||||
|
className="px-3 py-1.5 bg-accent text-white font-black rounded-lg text-sm shrink-0 hover:bg-purple-600 transition-colors"
|
||||||
|
title="See voicings"
|
||||||
|
>
|
||||||
|
{chord}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-gray-700 self-center">→</span>
|
||||||
|
|
||||||
|
{/* Substitutions */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{subs.map(sub => (
|
||||||
|
<div key={sub.chord} className="relative group">
|
||||||
|
<button
|
||||||
|
onClick={() => onChordClick?.(sub.chord)}
|
||||||
|
className="px-2.5 py-1.5 bg-panel border border-border hover:border-accent/50 hover:text-accent rounded-lg text-sm font-bold text-gray-300 transition-all"
|
||||||
|
>
|
||||||
|
{sub.chord}
|
||||||
|
</button>
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 w-48 px-2 py-1.5 bg-gray-900 border border-border rounded-lg text-[11px] text-gray-300 leading-snug opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-xl">
|
||||||
|
{sub.tip}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<p className="text-[11px] text-gray-700 text-center">
|
||||||
|
Hover substitutions to see what they change · click to see voicings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chord Variation Ideas ────────────────────────────────────────────────────
|
||||||
|
const VARIATION_ROWS = [
|
||||||
|
{
|
||||||
|
label: '7th Upgrade',
|
||||||
|
desc: 'Add 7ths throughout — jazz and soul texture',
|
||||||
|
typeMap: { maj: 'maj7', min: 'min7', dom7: 'dom7', maj7: 'maj7', min7: 'min7', dim: 'dim7', add9: 'maj7', sus2: 'sus2', sus4: 'sus4', aug: 'aug', half_dim: 'half_dim', maj6: 'maj6', min6: 'min6' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sus2 Wash',
|
||||||
|
desc: 'Replace triads with sus2 — ambient and spacious',
|
||||||
|
typeMap: { maj: 'sus2', min: 'sus2', dom7: 'sus4', maj7: 'sus2', min7: 'sus2', add9: 'sus2', dim: 'dim', aug: 'aug', sus4: 'sus4', sus2: 'sus2', half_dim: 'half_dim', maj6: 'sus2', min6: 'sus2' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Add9 Modern',
|
||||||
|
desc: 'Add9 on majors, m7 on minors — indie and neo-soul',
|
||||||
|
typeMap: { maj: 'add9', min: 'min7', dom7: 'dom7', maj7: 'add9', min7: 'min7', add9: 'add9', sus2: 'sus2', sus4: 'sus4', dim: 'dim', aug: 'aug', half_dim: 'half_dim', maj6: 'add9', min6: 'min7' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Blues Dominant',
|
||||||
|
desc: 'All chords → dom7 — instant 12-bar blues energy',
|
||||||
|
typeMap: { maj: 'dom7', min: 'dom7', dom7: 'dom7', maj7: 'dom7', min7: 'dom7', add9: 'dom7', sus2: 'dom7', sus4: 'dom7', dim: 'dim7', aug: 'aug', half_dim: 'dom7', maj6: 'dom7', min6: 'dom7' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function ProgressionVariationIdeas({ progression, onChordClick }) {
|
||||||
|
if (!progression?.length) return (
|
||||||
|
<p className="text-gray-600 text-sm text-center py-4">
|
||||||
|
Keep playing — your progression will appear here.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Your progression re-harmonised four ways. Click any chord to open its voicing explorer.
|
||||||
|
</p>
|
||||||
|
{VARIATION_ROWS.map(row => {
|
||||||
|
const transformed = progression.map(chord => {
|
||||||
|
const p = parseChord(chord)
|
||||||
|
if (!p) return chord
|
||||||
|
const newType = row.typeMap[p.type] ?? p.type
|
||||||
|
return NOTES[p.rootPc] + (CHORD_TYPES[newType]?.suffix ?? '')
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<div key={row.label} className="p-3 bg-surface border border-border rounded-xl">
|
||||||
|
<div className="flex items-center gap-2 mb-2.5">
|
||||||
|
<span className="text-xs font-bold text-accent">{row.label}</span>
|
||||||
|
<span className="text-[11px] text-gray-500">{row.desc}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
{progression.map((orig, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] text-gray-600">{orig}</span>
|
||||||
|
<span className="text-gray-700 text-xs">→</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onChordClick?.(transformed[i])}
|
||||||
|
className="px-2.5 py-1 bg-panel border border-border hover:border-accent/50 hover:text-accent rounded-lg font-bold text-sm text-gray-200 transition-all"
|
||||||
|
>
|
||||||
|
{transformed[i]}
|
||||||
|
</button>
|
||||||
|
{i < progression.length - 1 && <span className="text-gray-700">·</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
export default function EducationPanel({ chordHistory, keyInfo, detectedProgression, onChordClick }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [section, setSection] = useState('similar')
|
||||||
|
|
||||||
|
// Use detected progression if available, else last 4 unique chords from history
|
||||||
|
const workingProgression = detectedProgression?.length
|
||||||
|
? detectedProgression
|
||||||
|
: [...new Set([...chordHistory].reverse())].reverse().slice(-4)
|
||||||
|
|
||||||
|
const similar = findSimilarProgressions(workingProgression, keyInfo)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span>EDUCATION</span>
|
||||||
|
{similar.length > 0 && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 bg-accent/20 border border-accent/30 rounded text-accent">
|
||||||
|
{similar.length} match{similar.length !== 1 ? 'es' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span>{open ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-border">
|
||||||
|
|
||||||
|
{/* Section nav */}
|
||||||
|
<div className="flex gap-1 px-4 pt-4 pb-0 border-b border-border overflow-x-auto">
|
||||||
|
{[
|
||||||
|
{ key: 'snapshot', label: '📊 Session' },
|
||||||
|
{ key: 'similar', label: `🎵 Similar Progressions${similar.length ? ` (${similar.length})` : ''}` },
|
||||||
|
{ key: 'play', label: '🎨 Play Differently' },
|
||||||
|
{ key: 'variations',label: '🔀 Progression Variations' },
|
||||||
|
].map(s => (
|
||||||
|
<button key={s.key}
|
||||||
|
onClick={() => setSection(s.key)}
|
||||||
|
className={`px-3 py-2 text-xs font-semibold whitespace-nowrap border-b-2 transition-all shrink-0 ${
|
||||||
|
section === s.key
|
||||||
|
? 'border-accent text-accent'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||||
|
}`}>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
{section === 'snapshot' && (
|
||||||
|
<SessionSnapshot
|
||||||
|
keyInfo={keyInfo}
|
||||||
|
detectedProgression={detectedProgression}
|
||||||
|
chordHistory={chordHistory}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{section === 'similar' && (
|
||||||
|
<SimilarProgressions
|
||||||
|
similar={similar}
|
||||||
|
keyInfo={keyInfo}
|
||||||
|
onChordClick={onChordClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{section === 'play' && (
|
||||||
|
<PlayDifferently
|
||||||
|
progression={workingProgression}
|
||||||
|
onChordClick={onChordClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{section === 'variations' && (
|
||||||
|
<ProgressionVariationIdeas
|
||||||
|
progression={workingProgression}
|
||||||
|
onChordClick={onChordClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
// ExplorePanel — refactored into Knowledge Center parts (task L-22, per
|
||||||
|
// docs/design/knowledge-center.md §7 step 1).
|
||||||
|
//
|
||||||
|
// This file now exports the named building blocks the Knowledge Center shell
|
||||||
|
// (JamGuide.jsx) composes:
|
||||||
|
//
|
||||||
|
// <LevelChips levels onToggle/> — the shared foundation/intermediate filter
|
||||||
|
// <ChordPickerToolbar …/> — controlled root × quality picker row
|
||||||
|
// <ExploreSection …/> — KB progression browser + famous progressions
|
||||||
|
// <VoicingsSection …/> — picker (follows the live chord) → VoicingBrowser
|
||||||
|
//
|
||||||
|
// The default export remains a thin standalone composition of the parts (the
|
||||||
|
// panel is verified-orphaned — no importer — so it exists only so the file
|
||||||
|
// stays a complete, mountable component). GuitarGrid/PianoGrid are kept as
|
||||||
|
// exported no-audio fallbacks per the D-20 IA map (§2).
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import ChordBox from './ChordBox'
|
||||||
|
import CircleOfFifths from './CircleOfFifths'
|
||||||
|
import MiniPiano from './MiniPiano'
|
||||||
|
import VoicingBrowser from './VoicingBrowser'
|
||||||
|
import kb from '../data/kb/index.js'
|
||||||
|
import { getGuitarVoicings, getPianoTechniques, parseChord } from '../lib/voicings'
|
||||||
|
import { CHORD_TYPES, NOTES, getChordsInKey, toRomanNumeral } from '../lib/theory'
|
||||||
|
import { FAMOUS_PROGRESSIONS, progressionInKey } from '../lib/education'
|
||||||
|
|
||||||
|
const CHORD_TYPE_OPTIONS = [
|
||||||
|
{ key: 'maj', label: 'Major' },
|
||||||
|
{ key: 'min', label: 'Minor' },
|
||||||
|
{ key: 'dom7', label: '7' },
|
||||||
|
{ key: 'maj7', label: 'maj7' },
|
||||||
|
{ key: 'min7', label: 'm7' },
|
||||||
|
{ key: 'dim', label: 'dim' },
|
||||||
|
{ key: 'dim7', label: 'dim7' },
|
||||||
|
{ key: 'half_dim', label: 'm7♭5' },
|
||||||
|
{ key: 'aug', label: 'aug' },
|
||||||
|
{ key: 'sus4', label: 'sus4' },
|
||||||
|
{ key: 'sus2', label: 'sus2' },
|
||||||
|
{ key: 'maj6', label: '6' },
|
||||||
|
{ key: 'min6', label: 'm6' },
|
||||||
|
{ key: 'add9', label: 'add9' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MAJOR_TYPES = new Set(['maj','maj7','maj6','add9','sus4','sus2','aug','dom7'])
|
||||||
|
|
||||||
|
// Progressions/licks without a `level` count as foundation (D-20 §4).
|
||||||
|
const levelOf = (item) => (item?.level === 'intermediate' ? 'intermediate' : 'foundation')
|
||||||
|
|
||||||
|
// ─── Level filter chips (shared by Explore + Licks toolbars) ──────────────────
|
||||||
|
// Two toggle chips, both on by default. The SHELL owns the `levels` state
|
||||||
|
// ({foundation, intermediate}) and enforces "both can't be off"; the chip for
|
||||||
|
// the last active level advertises the no-op via its title.
|
||||||
|
export function LevelChips({ levels = {}, onToggle }) {
|
||||||
|
const defs = [
|
||||||
|
{ key: 'foundation', label: 'Foundation' },
|
||||||
|
{ key: 'intermediate', label: 'Intermediate' },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1" role="group" aria-label="Level filter">
|
||||||
|
{defs.map(d => {
|
||||||
|
const active = !!levels[d.key]
|
||||||
|
const lastActive = active && !defs.some(o => o.key !== d.key && levels[o.key])
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.key}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => onToggle?.(d.key)}
|
||||||
|
title={lastActive
|
||||||
|
? 'At least one level stays on'
|
||||||
|
: `${active ? 'Hide' : 'Show'} ${d.label.toLowerCase()} material`}
|
||||||
|
className={`min-h-[32px] px-2.5 py-1 rounded-lg border text-xs transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||||
|
active
|
||||||
|
? 'bg-accent/20 border-accent text-accent font-semibold'
|
||||||
|
: 'bg-surface border-border text-gray-400 hover:text-gray-200 hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level badge on cards — mirrors LickCard's badge treatment (amber = the
|
||||||
|
// existing secondary-tone token; foundation stays quiet).
|
||||||
|
function LevelBadge({ level }) {
|
||||||
|
if (level === 'intermediate') {
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 text-[9px] uppercase tracking-wide font-semibold text-amber border border-amber/40 rounded px-1.5 py-px">
|
||||||
|
intermediate
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (level === 'foundation') {
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 text-[9px] uppercase tracking-wide font-semibold text-gray-400 border border-border rounded px-1.5 py-px">
|
||||||
|
foundation
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Quick-pick chip row ──────────────────────────────────────────────────────
|
||||||
|
function ChipRow({ label, chords, active, keyInfo, onSelect }) {
|
||||||
|
if (!chords?.length) return null
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 flex-wrap">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-gray-600 w-16 pt-1 shrink-0">{label}</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{chords.map(chord => {
|
||||||
|
const rn = keyInfo?.root ? toRomanNumeral(chord, keyInfo.root, keyInfo.mode) : ''
|
||||||
|
return (
|
||||||
|
<button key={chord} onClick={() => onSelect(chord)}
|
||||||
|
className={`flex flex-col items-center px-2.5 py-1 rounded-lg border text-xs font-bold transition-all outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||||
|
active === chord
|
||||||
|
? 'bg-accent border-accent text-white'
|
||||||
|
: 'bg-surface border-border text-gray-300 hover:border-accent/50 hover:text-accent'
|
||||||
|
}`}>
|
||||||
|
<span>{chord}</span>
|
||||||
|
{rn && <span className="text-[9px] font-normal opacity-60 leading-none mt-0.5">{rn}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chord picker toolbar (controlled: root × quality) ───────────────────────
|
||||||
|
export function ChordPickerToolbar({ root, typeKey, onRootChange, onTypeChange }) {
|
||||||
|
const chordName = root + (CHORD_TYPES[typeKey]?.suffix ?? '')
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center p-3 bg-surface border border-border rounded-xl">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{NOTES.map(n => (
|
||||||
|
<button key={n} type="button" onClick={() => onRootChange?.(n)}
|
||||||
|
aria-pressed={root === n}
|
||||||
|
className={`px-2 py-0.5 rounded text-xs font-bold transition-all outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||||
|
root === n ? 'bg-accent text-white' : 'bg-border text-gray-400 hover:text-white'
|
||||||
|
}`}>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-5 bg-border shrink-0" />
|
||||||
|
<div className="relative">
|
||||||
|
<select value={typeKey} onChange={e => onTypeChange?.(e.target.value)}
|
||||||
|
aria-label="Chord quality"
|
||||||
|
className="appearance-none bg-panel border border-border rounded-lg pl-2 pr-6 py-1 text-xs text-gray-200 cursor-pointer focus:outline-none focus:border-accent">
|
||||||
|
{CHORD_TYPE_OPTIONS.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<span className="pointer-events-none absolute right-1.5 top-1/2 -translate-y-1/2 text-gray-500 text-xs">▾</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-black text-accent ml-2">{chordName}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Guitar voicings grid (no-audio fallback; superseded by VoicingBrowser) ──
|
||||||
|
export function GuitarGrid({ chordName }) {
|
||||||
|
const voicings = getGuitarVoicings(chordName)
|
||||||
|
if (!voicings.length) return <p className="text-gray-600 text-sm py-4">No voicings for {chordName}.</p>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{voicings.map((v, i) => (
|
||||||
|
<div key={i} className="flex flex-col items-center p-3 rounded-xl bg-surface border border-border hover:border-accent/30 transition-colors">
|
||||||
|
<ChordBox frets={v.frets} fingers={v.fingers} barre={v.barre} baseFret={v.baseFret} />
|
||||||
|
<p className="text-[11px] text-gray-500 text-center mt-1 max-w-[110px] leading-tight">{v.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-gray-700 mt-3">
|
||||||
|
Purple = chord tone · finger numbers inside dots (1=index 4=pinky) · fret number on left if not starting at fret 1
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Piano techniques grid (no-audio fallback; superseded by VoicingBrowser) ─
|
||||||
|
export function PianoGrid({ chordName }) {
|
||||||
|
const parsed = parseChord(chordName)
|
||||||
|
const techniques = getPianoTechniques(chordName)
|
||||||
|
const rootPc = parsed?.rootPc ?? 0
|
||||||
|
if (!techniques.length) return <p className="text-gray-600 text-sm py-4">No techniques for {chordName}.</p>
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{techniques.map((t, i) => (
|
||||||
|
<div key={i} className="flex flex-col lg:flex-row gap-3 p-3 bg-surface border border-border rounded-xl hover:border-accent/30 transition-colors">
|
||||||
|
<div className="shrink-0 overflow-x-auto">
|
||||||
|
<MiniPiano rootPc={rootPc} lh={t.lh} rh={t.rh} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 min-w-0 justify-center">
|
||||||
|
<p className="font-bold text-white text-sm">{t.name}</p>
|
||||||
|
<p className="text-gray-400 text-xs">{t.desc}</p>
|
||||||
|
<p className="text-xs text-amber-400/80 mt-0.5">
|
||||||
|
<span className="text-amber-400 font-semibold">Tip:</span> {t.tip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Famous progressions using this chord as tonic ───────────────────────────
|
||||||
|
// NOTE (D-20 §4, recorded Maestro call): FAMOUS_PROGRESSIONS carries no `level`
|
||||||
|
// field — these cards show no badge and are EXEMPT from the level filter.
|
||||||
|
function ProgressionCards({ chordName, onChordClick }) {
|
||||||
|
const parsed = parseChord(chordName)
|
||||||
|
if (!parsed) return null
|
||||||
|
const { rootPc, type } = parsed
|
||||||
|
const root = NOTES[rootPc]
|
||||||
|
const isMajor = MAJOR_TYPES.has(type)
|
||||||
|
|
||||||
|
const matching = FAMOUS_PROGRESSIONS.filter(p => {
|
||||||
|
const q0 = p.qualities[0]
|
||||||
|
return isMajor ? MAJOR_TYPES.has(q0) : !MAJOR_TYPES.has(q0)
|
||||||
|
}).slice(0, 6)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Famous progressions with <span className="text-accent font-bold">{chordName}</span> as the tonic.
|
||||||
|
Click any chord to see its voicings.
|
||||||
|
</p>
|
||||||
|
{matching.map(prog => {
|
||||||
|
const chordsHere = progressionInKey(prog, root)
|
||||||
|
return (
|
||||||
|
<div key={prog.id} className="p-3 bg-surface border border-border rounded-xl">
|
||||||
|
<div className="flex items-center flex-wrap gap-2 mb-2">
|
||||||
|
<span className="font-bold text-white text-sm">{prog.name}</span>
|
||||||
|
<span className="text-[10px] font-mono text-gray-600">{prog.pattern}</span>
|
||||||
|
{prog.genre.slice(0, 2).map(g => (
|
||||||
|
<span key={g} className="px-1.5 py-0.5 bg-accent/10 border border-accent/20 rounded text-[10px] text-accent">{g}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 items-center mb-2">
|
||||||
|
{chordsHere.map((c, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
<button onClick={() => onChordClick?.(c)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg font-bold text-sm border transition-all outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||||
|
i === 0
|
||||||
|
? 'bg-accent border-accent text-white'
|
||||||
|
: 'bg-panel border-border text-gray-200 hover:border-accent/50 hover:text-accent'
|
||||||
|
}`}>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
{i < chordsHere.length - 1 && <span className="text-gray-700 text-xs">→</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 leading-snug">{prog.description}</p>
|
||||||
|
{prog.songs.length > 0 && (
|
||||||
|
<p className="text-[11px] text-gray-700 mt-1">{prog.songs.slice(0, 3).join(' · ')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── One KB progression card (the Explore browser hero) ──────────────────────
|
||||||
|
function KbProgressionCard({ prog, keyRootPc, onChordClick }) {
|
||||||
|
const degrees = prog?.degrees ?? []
|
||||||
|
const qualities = prog?.qualities ?? []
|
||||||
|
const chords = degrees.map((deg, i) => {
|
||||||
|
const pc = (((keyRootPc + deg) % 12) + 12) % 12
|
||||||
|
return `${NOTES[pc]}${CHORD_TYPES[qualities[i]]?.suffix ?? ''}`
|
||||||
|
})
|
||||||
|
const songs = Array.isArray(prog?.songs) ? prog.songs : []
|
||||||
|
return (
|
||||||
|
<div className="p-3 bg-surface border border-border rounded-xl">
|
||||||
|
<div className="flex items-center flex-wrap gap-2 mb-2">
|
||||||
|
<span className="font-bold text-white text-sm">{prog?.name ?? prog?.id ?? 'Untitled'}</span>
|
||||||
|
{Array.isArray(prog?.rn) && prog.rn.length > 0 && (
|
||||||
|
<span className="text-[10px] font-mono text-gray-600">{prog.rn.join(' – ')}</span>
|
||||||
|
)}
|
||||||
|
<LevelBadge level={levelOf(prog)} />
|
||||||
|
</div>
|
||||||
|
{chords.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 items-center mb-2">
|
||||||
|
{chords.map((c, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
<button type="button" onClick={() => onChordClick?.(c)}
|
||||||
|
title={`Open ${c} details`}
|
||||||
|
className="px-2.5 py-1 rounded-lg font-bold text-sm border bg-panel border-border text-gray-200 transition-all outline-none hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent">
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
{i < chords.length - 1 && <span className="text-gray-700 text-xs">→</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{prog?.tip && <p className="text-xs text-gray-400 leading-snug">{prog.tip}</p>}
|
||||||
|
{songs.length > 0 && (
|
||||||
|
<p className="text-[11px] text-gray-500 mt-1">{songs.slice(0, 3).join(' · ')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Explore section — KB progression browser + famous progressions ──────────
|
||||||
|
// Props: keyInfo (chords render in the detected key; C until one is known),
|
||||||
|
// levels + onToggleLevel (shell-owned shared filter), onChordClick (chord name
|
||||||
|
// string → ChordDetailModal).
|
||||||
|
export function ExploreSection({ keyInfo, levels, onToggleLevel, onChordClick }) {
|
||||||
|
const styles = useMemo(
|
||||||
|
() => Object.entries(kb ?? {}).map(([id, s]) => ({ id, label: s?.meta?.label ?? id })),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
const [styleOverride, setStyleOverride] = useState(null)
|
||||||
|
const activeStyle = styleOverride ?? styles[0]?.id
|
||||||
|
|
||||||
|
const keyRootPc = parseChord(keyInfo?.root ?? '')?.rootPc ?? 0
|
||||||
|
const keyMode = keyInfo?.mode === 'minor' ? 'minor' : 'major'
|
||||||
|
const tonicName = `${NOTES[keyRootPc]}${keyMode === 'minor' ? 'm' : ''}`
|
||||||
|
|
||||||
|
const progressions = kb?.[activeStyle]?.progressions ?? []
|
||||||
|
const visible = progressions.filter(p => levels?.[levelOf(p)])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Toolbar: style chips + shared level filter */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{styles.map(s => {
|
||||||
|
const active = s.id === activeStyle
|
||||||
|
return (
|
||||||
|
<button key={s.id} type="button" aria-pressed={active}
|
||||||
|
onClick={() => setStyleOverride(s.id)}
|
||||||
|
className={`px-2.5 py-1 min-h-[32px] rounded-lg text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||||
|
active
|
||||||
|
? 'bg-accent/20 border border-accent text-accent font-semibold'
|
||||||
|
: 'border border-transparent text-gray-400 hover:text-gray-200 hover:border-border'
|
||||||
|
}`}>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-5 bg-border shrink-0" />
|
||||||
|
<LevelChips levels={levels} onToggle={onToggleLevel} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Circle of fifths — live key map with inline diatonics (task D-61).
|
||||||
|
keyInfo here IS App's effectiveKey (App → KnowledgeDock → this section);
|
||||||
|
the circle is read-only — tapping wedges never touches key state. */}
|
||||||
|
<CircleOfFifths keyInfo={keyInfo} onChordClick={onChordClick} />
|
||||||
|
|
||||||
|
<p className="text-[11px] text-gray-500">
|
||||||
|
Chords shown in {NOTES[keyRootPc]} {keyMode}{keyInfo?.root ? '' : ' (no key detected yet)'} · tap any chord for voicings
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* KB progression cards */}
|
||||||
|
{visible.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{visible.map((p, i) => (
|
||||||
|
<KbProgressionCard key={p?.id ?? i} prog={p} keyRootPc={keyRootPc} onChordClick={onChordClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 py-2">
|
||||||
|
{progressions.length === 0
|
||||||
|
? 'No progressions authored for this style yet.'
|
||||||
|
: 'Nothing at the selected level for this style — flip the level filter back on.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Famous progressions (exempt from the level filter — untagged corpus) */}
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||||
|
<h4 className="text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
|
Famous progressions <span className="normal-case tracking-normal font-normal">· not affected by the level filter</span>
|
||||||
|
</h4>
|
||||||
|
<ProgressionCards chordName={tonicName} onChordClick={onChordClick} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Voicings section — picker (follows the live chord) → VoicingBrowser ─────
|
||||||
|
// Props: keyInfo + chordHistory feed the quick-pick chips; currentChord re-aims
|
||||||
|
// the picker whenever a new chord commits (manual picks hold until then).
|
||||||
|
// `instrument` (L-40, D-40 §3) scopes the browser to App's global selector —
|
||||||
|
// omitted (the orphaned standalone panel below) it falls back to 'both' via
|
||||||
|
// VoicingBrowser's own `show` default.
|
||||||
|
export function VoicingsSection({ keyInfo, chordHistory, currentChord, instrument }) {
|
||||||
|
const [root, setRoot] = useState('C')
|
||||||
|
const [typeKey, setTypeKey] = useState('maj')
|
||||||
|
const [active, setActive] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentChord) return
|
||||||
|
const p = parseChord(currentChord)
|
||||||
|
if (p) { setRoot(NOTES[p.rootPc]); setTypeKey(p.type); setActive(currentChord) }
|
||||||
|
}, [currentChord])
|
||||||
|
|
||||||
|
function selectChord(chord) {
|
||||||
|
setActive(chord)
|
||||||
|
const p = parseChord(chord)
|
||||||
|
if (p) { setRoot(NOTES[p.rootPc]); setTypeKey(p.type) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentChords = [...new Set([...(chordHistory ?? [])].reverse())].slice(0, 12)
|
||||||
|
const keyChords = keyInfo?.root ? getChordsInKey(keyInfo.root, keyInfo.mode ?? 'major') : []
|
||||||
|
const rootPc = parseChord(root)?.rootPc ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{(recentChords.length > 0 || keyChords.length > 0) && (
|
||||||
|
<div className="flex flex-col gap-2.5 p-3 bg-surface border border-border rounded-xl">
|
||||||
|
<ChipRow label="History" chords={recentChords} active={active} keyInfo={keyInfo} onSelect={selectChord} />
|
||||||
|
{keyChords.length > 0 && recentChords.length > 0 && <div className="h-px bg-border" />}
|
||||||
|
{keyChords.length > 0 && (
|
||||||
|
<ChipRow
|
||||||
|
label={keyInfo.root + ' ' + (keyInfo.mode ?? '')}
|
||||||
|
chords={keyChords} active={active} keyInfo={keyInfo} onSelect={selectChord}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChordPickerToolbar
|
||||||
|
root={root}
|
||||||
|
typeKey={typeKey}
|
||||||
|
onRootChange={n => { setRoot(n); setActive('') }}
|
||||||
|
onTypeChange={k => { setTypeKey(k); setActive('') }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VoicingBrowser rootPc={rootPc} quality={typeKey} show={instrument} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Standalone panel (thin composition; orphaned — kept mountable) ──────────
|
||||||
|
export default function ExplorePanel({ keyInfo, chordHistory, currentChord, onChordClick }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [levels, setLevels] = useState({ foundation: true, intermediate: true })
|
||||||
|
const toggleLevel = (key) => setLevels(prev => {
|
||||||
|
const next = { ...prev, [key]: !prev[key] }
|
||||||
|
return (next.foundation || next.intermediate) ? next : prev // both can't be off
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
|
<button onClick={() => setOpen(v => !v)} aria-expanded={open}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all">
|
||||||
|
<span>EXPLORE ANY CHORD</span>
|
||||||
|
<span>{open ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-border p-4 flex flex-col gap-6">
|
||||||
|
<ExploreSection keyInfo={keyInfo} levels={levels} onToggleLevel={toggleLevel} onChordClick={onChordClick} />
|
||||||
|
<VoicingsSection keyInfo={keyInfo} chordHistory={chordHistory} currentChord={currentChord} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+102
-16
@@ -1,4 +1,4 @@
|
|||||||
import { getPentatonicScale, getFullScale, getChordTones, NOTES } from '../lib/theory'
|
import { getPentatonicScale, getFullScale, getChordTones, guideTones, NOTES } from '../lib/theory'
|
||||||
|
|
||||||
// Standard tuning: pitch classes of open strings, high-E first (top of diagram)
|
// Standard tuning: pitch classes of open strings, high-E first (top of diagram)
|
||||||
const STRINGS = [
|
const STRINGS = [
|
||||||
@@ -37,7 +37,11 @@ function noteColor(isChordTone, isPenta, isScale, mono = false) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false }) {
|
// `compact` (task L-50, one-screen.md §2): trimmed card chrome (p-3, legend
|
||||||
|
// merged onto the heading line) + a natural-width cap on the SVG (max-width =
|
||||||
|
// its viewBox width, so it never renders above scale 1.0). No fret reduction,
|
||||||
|
// no transform scaling — the notes stay at their designed size.
|
||||||
|
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false, jamFocusChord = null, compact = false }) {
|
||||||
const { root, mode } = keyInfo ?? {}
|
const { root, mode } = keyInfo ?? {}
|
||||||
|
|
||||||
if (!root) return null
|
if (!root) return null
|
||||||
@@ -50,19 +54,77 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
|||||||
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
|
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
|
||||||
: new Set()
|
: new Set()
|
||||||
|
|
||||||
|
// ── Jam Guide focus: guide tones of the tapped Roadmap station ──────────────
|
||||||
|
// `guideTones` returns { third, seventh, hasSeventh }. We emphasise the 3rd
|
||||||
|
// (the quality-defining tone) and the secondary anchor — the 7th when present,
|
||||||
|
// else the 5th for a triad (hasSeventh:false). These pitch classes get a halo
|
||||||
|
// ring + a small tag so they read as a distinct "target" tier on top of the
|
||||||
|
// normal chord/penta/scale colouring.
|
||||||
|
let focusThird = -1, focusSeventh = -1, focusRootPc = 0
|
||||||
|
if (jamFocusChord && typeof jamFocusChord.rootPc === 'number') {
|
||||||
|
const gt = guideTones(jamFocusChord.rootPc, jamFocusChord.quality)
|
||||||
|
focusThird = gt.third
|
||||||
|
focusSeventh = gt.seventh
|
||||||
|
focusRootPc = gt.root
|
||||||
|
}
|
||||||
|
const hasFocus = focusThird >= 0
|
||||||
|
// Defense-in-depth: label the secondary anchor from its ACTUAL interval above
|
||||||
|
// the chord root, so a wrong `hasSeventh` boolean could never mislabel a 5th
|
||||||
|
// or 6th as a "7". 10/11 → "7", 9 → "6", 8 → "♭6"(#5), 7 → "5", 6 → "♭5".
|
||||||
|
const focusSeventhLabel = (() => {
|
||||||
|
const iv = ((focusSeventh - focusRootPc) % 12 + 12) % 12
|
||||||
|
if (iv === 10 || iv === 11) return '7'
|
||||||
|
if (iv === 9) return '6'
|
||||||
|
if (iv === 8) return '♭6'
|
||||||
|
if (iv === 6) return '♭5'
|
||||||
|
return '5'
|
||||||
|
})()
|
||||||
|
const focusLabel = pc =>
|
||||||
|
pc === focusThird ? '3' : pc === focusSeventh ? focusSeventhLabel : null
|
||||||
|
|
||||||
|
const heading = (
|
||||||
|
<p className={`text-sm text-gray-500 uppercase tracking-widest ${compact ? '' : 'mb-4'}`}>
|
||||||
|
Fretboard — {root} {mode}
|
||||||
|
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||||
|
{hasFocus && <span className="text-accent ml-2">◎ guide tones</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
const legend = (
|
||||||
|
// Critic mechanical fix (L-50 gate): non-compact keeps HEAD's exact class
|
||||||
|
// string so the non-compact render stays byte-identical to the committed one.
|
||||||
|
<div className={compact ? 'flex flex-wrap items-center text-xs text-gray-500 gap-3' : 'mt-3 flex flex-wrap gap-5 text-xs text-gray-500'}>
|
||||||
|
<span><span className="text-accent">●</span> Chord tone</span>
|
||||||
|
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
||||||
|
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
||||||
|
{hasFocus && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="inline-block w-3 h-3 rounded-full border-2 border-accent"
|
||||||
|
/>
|
||||||
|
Guide tones (3 / {focusSeventhLabel})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
|
||||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
{compact ? (
|
||||||
Fretboard — {root} {mode}
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
{heading}
|
||||||
</p>
|
{legend}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
heading
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
||||||
width="100%"
|
width="100%"
|
||||||
height="auto"
|
height="auto"
|
||||||
style={{ display: 'block' }}
|
style={{ display: 'block', ...(compact ? { maxWidth: BOARD_W } : null) }}
|
||||||
>
|
>
|
||||||
{/* Fretboard background */}
|
{/* Fretboard background */}
|
||||||
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={5 * STRING_H + 12}
|
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={5 * STRING_H + 12}
|
||||||
@@ -121,23 +183,51 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
|||||||
Array.from({ length: NUM_FRETS }, (_, fi) => {
|
Array.from({ length: NUM_FRETS }, (_, fi) => {
|
||||||
const pc = (str.root + fi) % 12
|
const pc = (str.root + fi) % 12
|
||||||
const color = noteColor(chordSet.has(pc), pentaSet.has(pc), scaleSet.has(pc), monoColor)
|
const color = noteColor(chordSet.has(pc), pentaSet.has(pc), scaleSet.has(pc), monoColor)
|
||||||
if (!color) return null
|
const tag = hasFocus ? focusLabel(pc) : null
|
||||||
|
// A guide tone outside the current scale still gets emphasised:
|
||||||
|
// draw a faint base dot so the halo has something to sit on.
|
||||||
|
if (!color && !tag) return null
|
||||||
|
|
||||||
const cx = fi === 0 ? OPEN_X : fretX(fi)
|
const cx = fi === 0 ? OPEN_X : fretX(fi)
|
||||||
const cy = stringY(si)
|
const cy = stringY(si)
|
||||||
|
const baseFill = color ? color.fill : '#2a2a2a'
|
||||||
|
const baseText = color ? color.text : '#a855f7'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={`${si}-${fi}`}>
|
<g key={`${si}-${fi}`}>
|
||||||
<circle cx={cx} cy={cy} r={DOT_R} fill={color.fill} />
|
{/* Guide-tone halo: a purple ring around the dot, clearly
|
||||||
|
distinct from the solid chord-tone fill (a "target" marker). */}
|
||||||
|
{tag && (
|
||||||
|
<circle
|
||||||
|
cx={cx} cy={cy} r={DOT_R + 3}
|
||||||
|
fill="none" stroke="#a855f7" strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<circle cx={cx} cy={cy} r={DOT_R} fill={baseFill} />
|
||||||
<text
|
<text
|
||||||
x={cx} y={cy + 4}
|
x={cx} y={cy + 4}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fontSize={9}
|
fontSize={9}
|
||||||
fontWeight="600"
|
fontWeight="600"
|
||||||
fill={color.text}
|
fill={baseText}
|
||||||
>
|
>
|
||||||
{NOTES[pc]}
|
{NOTES[pc]}
|
||||||
</text>
|
</text>
|
||||||
|
{/* Degree badge (3 / 7 / 5) on the halo's upper-right. */}
|
||||||
|
{tag && (
|
||||||
|
<>
|
||||||
|
<circle cx={cx + DOT_R} cy={cy - DOT_R} r={6} fill="#a855f7" />
|
||||||
|
<text
|
||||||
|
x={cx + DOT_R} y={cy - DOT_R + 3}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={8}
|
||||||
|
fontWeight="700"
|
||||||
|
fill="#fff"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -145,11 +235,7 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
{!compact && legend}
|
||||||
<span><span className="text-accent">●</span> Chord tone</span>
|
|
||||||
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
|
||||||
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
// GlanceRail — ALL loop stations expanded, always (task D-41, per
|
||||||
|
// docs/design/integrated-glance.md §4; supersedes the L-33 playhead accordion).
|
||||||
|
//
|
||||||
|
// D-51 (docs/design/one-screen.md §4/§6.2) adapts the rail to the dashboard's
|
||||||
|
// 500px right column. Margin-hardening arithmetic, worst case = a classic
|
||||||
|
// Windows scrollbar (~17px) inside the column's overflow-y-auto wrapper:
|
||||||
|
// row interior = 500 − 17 (scrollbar) − 18 (section border + p-2)
|
||||||
|
// − 14 (row border + p-1.5) = 451px
|
||||||
|
// guitar cell 89 (75 SVG + p-1.5 + border) → 4/line (374 ≤ 451; a 5th = 469 ✗)
|
||||||
|
// piano 1-octave 156.4 / 2-octave 279.6 → the mixed pair 279.6+6+156.4 = 442
|
||||||
|
// fits with ~9px margin even under the scrollbar (26px without) — the fit the
|
||||||
|
// doc calls fragile at the old paddings is now robust. Without the scrollbar
|
||||||
|
// the interior is 468px; every count above is unchanged.
|
||||||
|
//
|
||||||
|
// One VERTICAL ROW per loop station, canonical KB order (the same order the
|
||||||
|
// banner's loop shows after rotation). Every row renders its full voicing
|
||||||
|
// gallery PERMANENTLY — the playhead HIGHLIGHTS the active row (accent ring +
|
||||||
|
// "now" badge + aria-current) and never hides, collapses, or reveals content.
|
||||||
|
// User directive 2026-07-10: "i'd like to see all the chords and their
|
||||||
|
// voicings … so you can follow and potentially learn new ways to play it while
|
||||||
|
// you are playing the loop. scrolling is easier then clicking."
|
||||||
|
//
|
||||||
|
// Row anatomy (D-40 §4):
|
||||||
|
// header — chord label + rn (the focus toggle) · "now" badge / "next" tag ·
|
||||||
|
// solo-scale label · aim dots (3rd filled accent, 7th hollow —
|
||||||
|
// RoadmapTrack's GuideDot language, honest "5th" fallback kept) ·
|
||||||
|
// transition chip ("next F→E · ½ step down"; the last row wraps:
|
||||||
|
// "loop"). This is where the retired RoadmapTrack's education
|
||||||
|
// folds in (D-40 §2) — theory.js `guideTones` / `voiceLeadingPairs`
|
||||||
|
// / `soloScale`, read-only imports.
|
||||||
|
// gallery — the full VoicingBrowser (show={instrument}, dense) with the
|
||||||
|
// station's OWN voicing passed as `recommended` so it renders as the
|
||||||
|
// badged, accent-bordered first cell INSIDE the browser (no separate
|
||||||
|
// own-cell, no dupe — dashboard-polish.md §1.1/§2.1). Guitar caps at
|
||||||
|
// 4 recommended-first shapes on one line (§1); piano is a 2×2 of
|
||||||
|
// `size="mini"` keyboards (§2). No ▶ anywhere (§3).
|
||||||
|
//
|
||||||
|
// Focus semantics (D-40 §4 — the pin, simplified): with everything always
|
||||||
|
// expanded there is nothing left to hold open, so tapping a row header TOGGLES
|
||||||
|
// that station as focused. The PARENT owns the state and the onFocusChord
|
||||||
|
// emission (the D-03 fretboard guide-tone contract, byte-compatible); a focused
|
||||||
|
// row shows an "aim on fretboard" chip; tap again (or the loop changes) to
|
||||||
|
// clear. This component never emits focus-chord itself and never triggers audio
|
||||||
|
// — the ▶ previews were removed everywhere (dashboard-polish.md §3).
|
||||||
|
//
|
||||||
|
// NO auto-scroll (D-40 §4/§6.2 step 1): the band lives in page flow, where
|
||||||
|
// scrollIntoView's nearest scroller is the DOCUMENT — it would yank the whole
|
||||||
|
// page mid-jam. The L-33 auto-centre effect was deleted in L-40 and must never
|
||||||
|
// return; the highlight travels, the user owns the scrollbar.
|
||||||
|
//
|
||||||
|
// HYBRID rail reuse (task L-77, refines D-76; user directive 2026-07-13 — "highlight
|
||||||
|
// the loop chords when it finds a loop but also add the other chords underneath"):
|
||||||
|
// JamGuide now renders GlanceRail TWICE — once for the canonical LOOP group (the
|
||||||
|
// original call, byte-unchanged) and once for the "also played" recent-history
|
||||||
|
// group. The history group passes `showTransitions={false}` (see the prop below)
|
||||||
|
// because history order is NOT canonical: the between-adjacent voice-leading chips
|
||||||
|
// and the "next" tag are only true for the loop's canonical wheel, so they are
|
||||||
|
// suppressed for the history rail. Everything else (per-row gallery, "now" via
|
||||||
|
// activeIndex, the SoloLabel/AimDots guide-tone education) is correct for any chord
|
||||||
|
// and stays. Default (`showTransitions` absent) is byte-compatible with the loop.
|
||||||
|
//
|
||||||
|
// Pure presentational. Props:
|
||||||
|
// stations — [{ shape, voicing, rootPc, quality, label, rn }] canonical order
|
||||||
|
// activeIndex — playhead station (canonicalPos); -1 = loop known, playhead
|
||||||
|
// not — no row is marked "now" (content never changes either way).
|
||||||
|
// The history group passes -1 (no playhead — see JamGuide).
|
||||||
|
// focusedIndex — the focused station index, or null (nothing focused)
|
||||||
|
// onFocus — fn(index|null): toggle a station's focus
|
||||||
|
// instrument — 'guitar' | 'piano' (VoicingBrowser `show`; bass never mounts
|
||||||
|
// this rail — JamGuide renders BassGuideRows instead, D-40 §3)
|
||||||
|
// keyRoot — key tonic pitch class 0–11 (ChordDiagram fret placement)
|
||||||
|
// keyMode — key mode name (soloScale's minor-key dominant nudge)
|
||||||
|
// showTransitions — default true (the loop caller is unchanged). false → the
|
||||||
|
// voice-leading TransitionChips and the "next" tag are suppressed
|
||||||
|
// (history order is not canonically adjacent, task L-77).
|
||||||
|
|
||||||
|
import { NOTES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory'
|
||||||
|
import VoicingBrowser from './VoicingBrowser'
|
||||||
|
|
||||||
|
const pcName = (pc) => NOTES[((pc % 12) + 12) % 12]
|
||||||
|
|
||||||
|
// ─── Header atoms (exported — BassGuideRows in JamGuide.jsx composes the same
|
||||||
|
// anatomy for visual parity across instruments, D-40 §3) ──────────────────
|
||||||
|
|
||||||
|
// Solo-scale label: "solo · G mixolydian" (theory.js snake_case → spaces).
|
||||||
|
export function SoloLabel({ rootPc, quality, keyMode }) {
|
||||||
|
const { name } = soloScale(quality, keyMode)
|
||||||
|
return (
|
||||||
|
<span className="text-xs leading-none text-gray-400">
|
||||||
|
<span className="text-[9px] uppercase tracking-widest text-gray-500">solo · </span>
|
||||||
|
{pcName(rootPc)} {name.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One guide-tone dot: note name in a small circle + its honest kind label.
|
||||||
|
// Filled accent = the 3rd; hollow = the 7th (or the "5th" fallback — never
|
||||||
|
// badge a 5th as a 7th). Filled text is BLACK on accent (#a855f7 vs black
|
||||||
|
// ≈5.3:1 — AA; white would be ~4.0), matching VoicingBrowser's ▶ hover.
|
||||||
|
function GuideDot({ pc, kind, filled }) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'flex h-5 w-5 items-center justify-center rounded-full text-[9px] font-bold leading-none ' +
|
||||||
|
(filled ? 'bg-accent text-black' : 'border-2 border-accent text-purple-300')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pcName(pc)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">{kind}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "aim" pair — the RoadmapTrack TARGET lane, folded to one header line.
|
||||||
|
export function AimDots({ rootPc, quality }) {
|
||||||
|
const g = guideTones(rootPc, quality)
|
||||||
|
const seventhKind = g.hasSeventh ? '7th' : '5th'
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-[9px] uppercase tracking-widest text-gray-500">aim</span>
|
||||||
|
<GuideDot pc={g.third} kind="3rd" filled />
|
||||||
|
<GuideDot pc={g.seventh} kind={seventhKind} filled={false} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The voice-leading rail, folded to a compact chip: "next F→E · ½ step down";
|
||||||
|
// a held common tone reads "B holds · common tone"; the last row's chip is the
|
||||||
|
// wrap-around and says "loop" (D-40 §2). No smooth rail (≤2 semitones) → the
|
||||||
|
// caller passes null and no chip renders.
|
||||||
|
function TransitionChip({ pair, wraps }) {
|
||||||
|
if (!pair) return null
|
||||||
|
const held = pair.semitones === 0
|
||||||
|
const label = held ? `${pcName(pair.from)} holds` : `${pcName(pair.from)}→${pcName(pair.to)}`
|
||||||
|
const motion = held
|
||||||
|
? 'common tone'
|
||||||
|
: `${Math.abs(pair.semitones) === 1 ? '½' : Math.abs(pair.semitones)} step ${pair.semitones < 0 ? 'down' : 'up'}`
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1 rounded border border-border bg-surface px-1.5 py-1 text-[10px] leading-none">
|
||||||
|
<span className="font-medium uppercase tracking-wide text-gray-500">{wraps ? 'loop' : 'next'}</span>
|
||||||
|
<span className="font-semibold text-accent">{label}</span>
|
||||||
|
<span className="text-gray-400">· {motion}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── One always-expanded station row ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function StationRow({
|
||||||
|
st, isNow, isNext, isFocused, onToggleFocus, instrument, keyRoot, keyMode, rail, wraps,
|
||||||
|
}) {
|
||||||
|
// The station's own voicing — passed to VoicingBrowser as `recommended` so it
|
||||||
|
// renders as the badged, accent-bordered first cell inside the gallery (guitar:
|
||||||
|
// a shape; piano: a voicing matched by style). The separate own-cell is gone
|
||||||
|
// (dashboard-polish.md §1.1/§2.1 — it centralises the ≤4 rule and kills the old
|
||||||
|
// recommended/gallery duplicate).
|
||||||
|
const recommended = instrument === 'guitar' ? (st.shape ?? null) : (st.voicing ?? null)
|
||||||
|
|
||||||
|
// Active row: unmistakable (accent ring + tint). Focused-but-not-now rows get
|
||||||
|
// the softer accent border; everything else recedes to the 0.85 opacity floor
|
||||||
|
// (never below AA legibility).
|
||||||
|
const stateClass = isNow
|
||||||
|
? 'border-accent bg-accent/10 ring-2 ring-accent'
|
||||||
|
: isFocused
|
||||||
|
? 'border-accent/60 bg-accent/5'
|
||||||
|
: 'border-border bg-surface'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="listitem"
|
||||||
|
aria-current={isNow ? 'true' : undefined}
|
||||||
|
aria-label={
|
||||||
|
`${st.label}${st.rn ? ` (${st.rn})` : ''} — every ${instrument} voicing` +
|
||||||
|
`${isNow ? ', now playing' : ''}${isNext ? ', up next' : ''}`
|
||||||
|
}
|
||||||
|
className={`rounded-lg border p-1.5 ${stateClass}`}
|
||||||
|
style={{ opacity: isNow || isFocused ? 1 : 0.85 }}
|
||||||
|
>
|
||||||
|
{/* ── Header line: identity + the folded roadmap education ── */}
|
||||||
|
{/* Loop rows (onToggleFocus provided) keep the focus-toggle button — the
|
||||||
|
D-03 fretboard guide-tone contract, byte-unchanged. History rows pass
|
||||||
|
no toggle → a plain, non-interactive identity (no inert button /
|
||||||
|
misleading "focus" tooltip / stray focus ring), L-77. */}
|
||||||
|
<div className="mb-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
{onToggleFocus ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={isFocused}
|
||||||
|
onClick={onToggleFocus}
|
||||||
|
title={isFocused
|
||||||
|
? `Unfocus ${st.label} — clear its guide tones from the fretboard`
|
||||||
|
: `Focus ${st.label} — light its guide tones on the fretboard`}
|
||||||
|
className="flex min-h-[32px] items-center gap-2 rounded px-1 outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-bold leading-none text-gray-100">{st.label}</span>
|
||||||
|
{st.rn && (
|
||||||
|
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
|
||||||
|
{st.rn}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-[32px] items-center gap-2 px-1">
|
||||||
|
<span className="text-sm font-bold leading-none text-gray-100">{st.label}</span>
|
||||||
|
{st.rn && (
|
||||||
|
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">
|
||||||
|
{st.rn}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isNow && (
|
||||||
|
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent">
|
||||||
|
now
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isNext && (
|
||||||
|
<span className="rounded border border-accent/60 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-accent">
|
||||||
|
next
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isFocused && (
|
||||||
|
<span className="rounded border border-accent bg-accent/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-accent">
|
||||||
|
aim on fretboard
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<SoloLabel rootPc={st.rootPc} quality={st.quality} keyMode={keyMode} />
|
||||||
|
<AimDots rootPc={st.rootPc} quality={st.quality} />
|
||||||
|
<TransitionChip pair={rail} wraps={wraps} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Gallery (dashboard-polish.md §1/§2): the recommended voicing is now
|
||||||
|
cell #1 INSIDE the browser (badged "play", accent border — "the
|
||||||
|
answer" prominence the old own-cell had, no dupe). Guitar caps at 4
|
||||||
|
recommended-first cells on one line; piano is a 2×2 of mini
|
||||||
|
keyboards. Full row interior, no separate own-cell. ── */}
|
||||||
|
<VoicingBrowser
|
||||||
|
rootPc={st.rootPc}
|
||||||
|
quality={st.quality}
|
||||||
|
show={instrument}
|
||||||
|
dense
|
||||||
|
recommended={recommended}
|
||||||
|
max={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── The rail ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function GlanceRail({
|
||||||
|
stations = [], activeIndex = -1, focusedIndex = null, onFocus, instrument, keyRoot, keyMode,
|
||||||
|
showTransitions = true,
|
||||||
|
}) {
|
||||||
|
const n = stations.length
|
||||||
|
if (n === 0) return null
|
||||||
|
// The "next" tag is a loop-adjacency claim → suppressed for the history group.
|
||||||
|
const nextIndex = showTransitions && activeIndex >= 0 && n > 1 ? (activeIndex + 1) % n : -1
|
||||||
|
|
||||||
|
// Voice-leading rails: rail i leaves station i for station (i+1) mod n — the
|
||||||
|
// last rail wraps back to station 0 (the loop is a wheel). The headline rail
|
||||||
|
// is the 7→3 (voiceLeadingPairs lists the 7th first); a one-chord loop has
|
||||||
|
// no transition to speak of. Suppressed entirely for the history group
|
||||||
|
// (showTransitions=false) — its rows are recent-first, not canonically
|
||||||
|
// adjacent, so a "next F→E" chip would point at the wrong neighbour (L-77).
|
||||||
|
const rails = stations.map((st, i) => {
|
||||||
|
if (!showTransitions || n < 2) return null
|
||||||
|
const next = stations[(i + 1) % n]
|
||||||
|
return voiceLeadingPairs(
|
||||||
|
{ root: st.rootPc, quality: st.quality },
|
||||||
|
{ root: next.rootPc, quality: next.quality },
|
||||||
|
)[0] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Section framing (L-77 honesty fix): showTransitions === true ⟺ the canonical
|
||||||
|
// LOOP group; the "also played" history group (showTransitions=false) is recent-
|
||||||
|
// first with no playhead, so it must NOT claim "the loop" / "the playhead". Rows
|
||||||
|
// in the history group are also non-focusable (no onFocus → no inert header
|
||||||
|
// button); the loop group's function keeps its focus toggle byte-unchanged.
|
||||||
|
const isLoop = showTransitions
|
||||||
|
const focusable = typeof onFocus === 'function'
|
||||||
|
const sectionAria = isLoop
|
||||||
|
? 'Voicing variations — every chord of the loop, all expanded'
|
||||||
|
: 'Voicing variations — every recently played chord, all expanded'
|
||||||
|
const sectionTitle = isLoop
|
||||||
|
? 'Variations · every chord, every voicing — the playhead highlights'
|
||||||
|
: 'Variations · every chord, every voicing'
|
||||||
|
const sectionFoot = isLoop
|
||||||
|
? "Voicings follow the loop — the playhead highlights the chord you're on."
|
||||||
|
: 'Recent chords — newest first; every voicing of each.'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="rounded-2xl border border-border bg-panel p-2"
|
||||||
|
aria-label={sectionAria}
|
||||||
|
>
|
||||||
|
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
|
{sectionTitle}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2" role="list">
|
||||||
|
{stations.map((st, i) => (
|
||||||
|
<StationRow
|
||||||
|
key={i}
|
||||||
|
st={st}
|
||||||
|
isNow={i === activeIndex}
|
||||||
|
isNext={i === nextIndex}
|
||||||
|
isFocused={focusedIndex === i}
|
||||||
|
onToggleFocus={focusable ? (() => onFocus(focusedIndex === i ? null : i)) : null}
|
||||||
|
instrument={instrument}
|
||||||
|
keyRoot={keyRoot}
|
||||||
|
keyMode={keyMode}
|
||||||
|
rail={rails[i]}
|
||||||
|
wraps={i === n - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* No ▶ anywhere anymore (dashboard-polish.md §3 — "leave them off,
|
||||||
|
better not"); the rail is purely visual. */}
|
||||||
|
<p className="mt-2 text-[11px] text-gray-500">
|
||||||
|
{sectionFoot}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,475 @@
|
|||||||
|
// LickCard — tab-style SVG lick renderer (task D-22).
|
||||||
|
//
|
||||||
|
// Renders one structured lick from the KB `licks` schema (src/data/kb/SCHEMA.md):
|
||||||
|
// { id, name, level, chordContext, techniques[], source?,
|
||||||
|
// tab: [{ string: 1–6, fret: 0–15, technique? }] } // ordered, first → last
|
||||||
|
//
|
||||||
|
// Tab convention (per SCHEMA.md): string 1 = high e rendered on TOP,
|
||||||
|
// string 6 = low E on the bottom — standard guitar tab. NOTE: this is the
|
||||||
|
// REVERSE of RiffDiagram.jsx's row order (that diagram puts low E on top).
|
||||||
|
//
|
||||||
|
// There is no rhythm information in the schema, so notes are simply evenly
|
||||||
|
// spaced columns in `tab` order — no bars, beams or durations are invented.
|
||||||
|
//
|
||||||
|
// Column rule: each note takes the next column, EXCEPT a note tagged
|
||||||
|
// `double-stop`, which stacks into the PREVIOUS note's column — unless ANY note
|
||||||
|
// already placed in that column is on the same string (stacking would overlap
|
||||||
|
// exactly), in which case it takes a new column.
|
||||||
|
//
|
||||||
|
// Technique glyphs (amber, the established secondary-tone colour):
|
||||||
|
// hammer-on → slur arc from the previous note + italic "h" above
|
||||||
|
// pull-off → slur arc from the previous note + italic "p" above
|
||||||
|
// slide → short diagonal segment into the note (rises toward higher frets)
|
||||||
|
// bend → curved arrow rising from the note
|
||||||
|
// vibrato → small ~ wave above the note
|
||||||
|
// ghost-note → fret number in parentheses, dimmed
|
||||||
|
// double-stop→ no glyph; renders as a stacked column (see above)
|
||||||
|
// chromatic-approach → no glyph (melodic content — the tag chip covers it)
|
||||||
|
// unknown strings → no glyph, note still renders (graceful)
|
||||||
|
//
|
||||||
|
// Exports:
|
||||||
|
// default <LickCard lick={…} size="thumb"|"full" /> (production API)
|
||||||
|
// <TechniqueLegend /> — the glyph key, rendered ONCE per grid (per D-20 §3)
|
||||||
|
// layoutTab(tab) — pure layout helper (returns null on empty/invalid)
|
||||||
|
// DEMO_LICK — SCHEMA.md's worked B.B.-box example, dev fixture only
|
||||||
|
//
|
||||||
|
// Design tokens (tailwind.config.js) — SVG fills can't read Tailwind classes,
|
||||||
|
// so the constants below mirror the tokens (same convention as MiniPiano /
|
||||||
|
// ChordDiagram): accent #a855f7, amber #f59e0b, surface #0f0f0f.
|
||||||
|
|
||||||
|
const AMBER = '#f59e0b' // token `amber` — technique glyphs
|
||||||
|
const FRET_TEXT = '#e5e7eb' // gray-200 — fret numbers (≈15:1 on surface)
|
||||||
|
const GHOST_TEXT = '#9ca3af' // gray-400 — ghost notes, quieter but AA (≈7:1)
|
||||||
|
const STRING_LINE = '#3a3a3a' // string lines (decorative, RiffDiagram idiom)
|
||||||
|
const STRING_LABEL = '#6b7280' // gray-500 — string-name microcopy (decorative)
|
||||||
|
const CARD_BG = '#0f0f0f' // token `surface` — backing pill behind fret numbers
|
||||||
|
|
||||||
|
// Fixed technique vocabulary (C-20 schema) — anything else gets no glyph.
|
||||||
|
export const TECHNIQUE_VOCAB = [
|
||||||
|
'hammer-on', 'pull-off', 'slide', 'bend',
|
||||||
|
'double-stop', 'ghost-note', 'chromatic-approach', 'vibrato',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Chip symbol per technique (shown in the technique-tag chips).
|
||||||
|
const TECH_SYMBOL = {
|
||||||
|
'hammer-on': 'h',
|
||||||
|
'pull-off': 'p',
|
||||||
|
slide: '⟋',
|
||||||
|
bend: '↑',
|
||||||
|
vibrato: '~',
|
||||||
|
'ghost-note': '( )',
|
||||||
|
'double-stop': '⋮',
|
||||||
|
'chromatic-approach': null, // tag only — no mark on the tab
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Geometry (SVG user units; the svg scales to card width via viewBox) ──────
|
||||||
|
const STR_GAP = 14 // vertical gap between string lines
|
||||||
|
const PAD_T = 17 // headroom for bend arrows / vibrato above string 1
|
||||||
|
const PAD_B = 9
|
||||||
|
const PAD_L = 20 // room for string-name labels
|
||||||
|
const PAD_R = 12
|
||||||
|
const COL_W = 26 // horizontal pitch per note column
|
||||||
|
|
||||||
|
const STRING_NAMES = ['e', 'B', 'G', 'D', 'A', 'E'] // index = string − 1 (top → bottom)
|
||||||
|
|
||||||
|
function isValidNote(n) {
|
||||||
|
return (
|
||||||
|
n && typeof n === 'object' &&
|
||||||
|
Number.isInteger(n.string) && n.string >= 1 && n.string <= 6 &&
|
||||||
|
Number.isInteger(n.fret) && n.fret >= 0 && n.fret <= 15
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure layout: tab array → positioned notes.
|
||||||
|
* Returns null when there is nothing renderable (not an array / no valid note).
|
||||||
|
* Otherwise: { notes: [{string,fret,technique?,col,x,y,label,ghost}], nCols, width, height }
|
||||||
|
* Columns are monotonically non-decreasing; y grows with string number
|
||||||
|
* (string 1 = smallest y = top line).
|
||||||
|
*/
|
||||||
|
export function layoutTab(tab) {
|
||||||
|
if (!Array.isArray(tab)) return null
|
||||||
|
const clean = tab.filter(isValidNote)
|
||||||
|
if (clean.length === 0) return null
|
||||||
|
|
||||||
|
const notes = []
|
||||||
|
let col = -1
|
||||||
|
for (let i = 0; i < clean.length; i++) {
|
||||||
|
const n = clean[i]
|
||||||
|
// double-stop stacks into the previous column — unless ANY note already
|
||||||
|
// placed there shares this string (a ≥3-note stack can repeat the string of
|
||||||
|
// a non-adjacent same-column note, which would overlap exactly — D-23).
|
||||||
|
const stacks =
|
||||||
|
i > 0 &&
|
||||||
|
n.technique === 'double-stop' &&
|
||||||
|
!notes.some((m) => m.col === col && m.string === n.string)
|
||||||
|
if (!stacks) col++
|
||||||
|
const ghost = n.technique === 'ghost-note'
|
||||||
|
notes.push({
|
||||||
|
string: n.string,
|
||||||
|
fret: n.fret,
|
||||||
|
technique: typeof n.technique === 'string' ? n.technique : undefined,
|
||||||
|
col,
|
||||||
|
x: PAD_L + col * COL_W + COL_W / 2,
|
||||||
|
y: PAD_T + (n.string - 1) * STR_GAP,
|
||||||
|
label: ghost ? `(${n.fret})` : String(n.fret),
|
||||||
|
ghost,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const nCols = col + 1
|
||||||
|
return {
|
||||||
|
notes,
|
||||||
|
nCols,
|
||||||
|
width: PAD_L + nCols * COL_W + PAD_R,
|
||||||
|
height: PAD_T + 5 * STR_GAP + PAD_B,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Glyph fragments (pure SVG, all in user units so they scale with the tab) ─
|
||||||
|
|
||||||
|
function SlurGlyph({ note, prev, letter }) {
|
||||||
|
const fs = 8
|
||||||
|
if (!prev || prev.col === note.col) {
|
||||||
|
// No source note to slur from — letter alone, just before the note.
|
||||||
|
return (
|
||||||
|
<text x={note.x - 10} y={note.y - 7} textAnchor="middle" fontSize={fs}
|
||||||
|
fontStyle="italic" fill={AMBER}>{letter}</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const midX = (prev.x + note.x) / 2
|
||||||
|
const topY = Math.min(prev.y, note.y)
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d={`M ${prev.x + 5} ${prev.y - 4} Q ${midX} ${topY - 13} ${note.x - 5} ${note.y - 4}`}
|
||||||
|
fill="none" stroke={AMBER} strokeWidth={1}
|
||||||
|
/>
|
||||||
|
<text x={midX} y={topY - 11} textAnchor="middle" fontSize={fs}
|
||||||
|
fontStyle="italic" fill={AMBER}>{letter}</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlideGlyph({ note, prev }) {
|
||||||
|
// Rises toward the higher fret (up-slide ⟋), falls for a down-slide (⟍).
|
||||||
|
if (prev && prev.col !== note.col) {
|
||||||
|
const up = note.fret >= prev.fret
|
||||||
|
const midY = (prev.y + note.y) / 2
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
x1={prev.x + 7} y1={up ? midY + 3 : midY - 3}
|
||||||
|
x2={note.x - 7} y2={up ? midY - 3 : midY + 3}
|
||||||
|
stroke={AMBER} strokeWidth={1.2} strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Slide-in from nowhere: short lead-in segment.
|
||||||
|
return (
|
||||||
|
<line x1={note.x - 14} y1={note.y + 4} x2={note.x - 7} y2={note.y - 1}
|
||||||
|
stroke={AMBER} strokeWidth={1.2} strokeLinecap="round" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BendGlyph({ note }) {
|
||||||
|
const { x, y } = note
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<path d={`M ${x + 4} ${y - 4} Q ${x + 10} ${y - 6} ${x + 10} ${y - 11}`}
|
||||||
|
fill="none" stroke={AMBER} strokeWidth={1.2} />
|
||||||
|
<polygon
|
||||||
|
points={`${x + 7.8},${y - 10} ${x + 12.2},${y - 10} ${x + 10},${y - 14.5}`}
|
||||||
|
fill={AMBER}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VibratoGlyph({ note }) {
|
||||||
|
const { x, y } = note
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
d={`M ${x - 7} ${y - 9} q 2.3 -3.5 4.6 0 t 4.6 0 t 4.6 0`}
|
||||||
|
fill="none" stroke={AMBER} strokeWidth={1.1} strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoteGlyph({ note, prev }) {
|
||||||
|
switch (note.technique) {
|
||||||
|
case 'hammer-on': return <SlurGlyph note={note} prev={prev} letter="h" />
|
||||||
|
case 'pull-off': return <SlurGlyph note={note} prev={prev} letter="p" />
|
||||||
|
case 'slide': return <SlideGlyph note={note} prev={prev} />
|
||||||
|
case 'bend': return <BendGlyph note={note} />
|
||||||
|
case 'vibrato': return <VibratoGlyph note={note} />
|
||||||
|
// ghost-note is handled by the parenthesised label; double-stop by the
|
||||||
|
// column stacking; chromatic-approach and unknown strings get no mark.
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The tab SVG ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TabSvg({ layout, name, size }) {
|
||||||
|
const { notes, width, height } = layout
|
||||||
|
const full = size === 'full'
|
||||||
|
return (
|
||||||
|
<div className="w-full" style={{ maxWidth: width * (full ? 2 : 1.3) }}>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
width="100%"
|
||||||
|
style={{ display: 'block', height: 'auto' }}
|
||||||
|
role="img"
|
||||||
|
aria-label={`Tab for ${name}: ${notes.length} note${notes.length === 1 ? '' : 's'}`}
|
||||||
|
>
|
||||||
|
{/* String lines — string 1 (high e) on top, string 6 (low E) at bottom */}
|
||||||
|
{STRING_NAMES.map((label, i) => {
|
||||||
|
const y = PAD_T + i * STR_GAP
|
||||||
|
return (
|
||||||
|
<g key={label + i}>
|
||||||
|
<line x1={PAD_L - 6} y1={y} x2={width - PAD_R + 6} y2={y}
|
||||||
|
stroke={STRING_LINE} strokeWidth={i === 5 ? 1.4 : 1} />
|
||||||
|
<text x={7} y={y + 3} textAnchor="middle" fontSize={7}
|
||||||
|
fill={STRING_LABEL}>{label}</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Technique glyphs (under the numbers so pills stay readable) */}
|
||||||
|
{notes.map((n, i) => (
|
||||||
|
<NoteGlyph key={`g${i}`} note={n} prev={notes[i - 1]} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Fret numbers on their strings, backed by a surface pill so the
|
||||||
|
number interrupts the string line like printed tab */}
|
||||||
|
{notes.map((n, i) => {
|
||||||
|
const w = n.label.length * 5.2 + 3
|
||||||
|
return (
|
||||||
|
<g key={`n${i}`}>
|
||||||
|
<rect x={n.x - w / 2} y={n.y - 5.5} width={w} height={11}
|
||||||
|
rx={2} fill={CARD_BG} />
|
||||||
|
<text x={n.x} y={n.y + 3.2} textAnchor="middle" fontSize={9.5}
|
||||||
|
fontWeight="600" fill={n.ghost ? GHOST_TEXT : FRET_TEXT}>
|
||||||
|
{n.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card chrome ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LevelBadge({ level }) {
|
||||||
|
if (level === 'intermediate') {
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 text-[9px] uppercase tracking-wide font-semibold text-amber border border-amber/40 rounded px-1.5 py-px">
|
||||||
|
intermediate
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (level === 'foundation') {
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 text-[9px] uppercase tracking-wide font-semibold text-gray-400 border border-border rounded px-1.5 py-px">
|
||||||
|
foundation
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null // unknown/missing level → no badge, never a wrong claim
|
||||||
|
}
|
||||||
|
|
||||||
|
function TechniqueChip({ tech }) {
|
||||||
|
const symbol = TECH_SYMBOL[tech]
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] text-gray-400 border border-border rounded-full px-1.5 py-px">
|
||||||
|
{symbol && <span className="text-amber font-semibold" aria-hidden="true">{symbol}</span>}
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceholderCard({ name, size }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-surface border border-border rounded-lg flex flex-col items-center justify-center text-gray-500 ${size === 'thumb' ? 'p-2 min-h-[72px]' : 'p-4 min-h-[110px]'}`}
|
||||||
|
role="group"
|
||||||
|
aria-label={name ? `${name}: lick unavailable` : 'lick unavailable'}
|
||||||
|
>
|
||||||
|
<span className="text-lg leading-none" aria-hidden="true">—</span>
|
||||||
|
<span className="text-[10px] mt-1">
|
||||||
|
{name ? `${name} — tab unavailable` : 'lick unavailable'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <LickCard lick={…} size="thumb"|"full" />
|
||||||
|
* Pure/presentational — renders one KB lick object; never crashes on bad data.
|
||||||
|
*/
|
||||||
|
export default function LickCard({ lick, size = 'full' }) {
|
||||||
|
const layout = layoutTab(lick?.tab)
|
||||||
|
const name = typeof lick?.name === 'string' && lick.name.trim() ? lick.name : 'Untitled lick'
|
||||||
|
|
||||||
|
if (!lick || !layout) {
|
||||||
|
return <PlaceholderCard name={lick ? name : null} size={size} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const full = size === 'full'
|
||||||
|
const chordContext =
|
||||||
|
typeof lick.chordContext === 'string' && lick.chordContext.trim()
|
||||||
|
? lick.chordContext
|
||||||
|
: null
|
||||||
|
const techniques = Array.isArray(lick.techniques)
|
||||||
|
? lick.techniques.filter(t => typeof t === 'string' && t.trim())
|
||||||
|
: []
|
||||||
|
const source = typeof lick.source === 'string' && lick.source.trim() ? lick.source : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-surface border border-border rounded-lg flex flex-col ${full ? 'p-3 gap-2' : 'p-2 gap-1.5'}`}
|
||||||
|
role="group"
|
||||||
|
aria-label={chordContext ? `${name} — ${chordContext}` : name}
|
||||||
|
>
|
||||||
|
{/* Header: name + level badge */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className={`text-gray-200 font-semibold leading-tight ${full ? 'text-sm' : 'text-[11px]'}`}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<LevelBadge level={lick.level} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Where it lands */}
|
||||||
|
{full && chordContext && (
|
||||||
|
<div>
|
||||||
|
<span className="inline-block text-[10px] font-medium text-accent bg-accent/10 border border-accent/40 rounded-full px-2 py-px">
|
||||||
|
{chordContext}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The tab */}
|
||||||
|
<TabSvg layout={layout} name={name} size={size} />
|
||||||
|
|
||||||
|
{/* Technique tags */}
|
||||||
|
{full && techniques.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{techniques.map(t => <TechniqueChip key={t} tech={t} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attribution */}
|
||||||
|
{full && source && (
|
||||||
|
<span className="text-[10px] text-gray-500 italic leading-snug">{source}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Glyph legend — render ONCE per lick grid (D-20 §3), not per card ─────────
|
||||||
|
|
||||||
|
function LegendSample({ children, w = 22 }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${w} 18`} width={w} height={18} aria-hidden="true"
|
||||||
|
style={{ display: 'inline-block', verticalAlign: 'middle' }}>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TechniqueLegend() {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: 'hammer-on', label: 'hammer-on',
|
||||||
|
sample: (
|
||||||
|
<LegendSample>
|
||||||
|
<path d="M 3 14 Q 11 4 19 14" fill="none" stroke={AMBER} strokeWidth={1} />
|
||||||
|
<text x={11} y={9} textAnchor="middle" fontSize={8} fontStyle="italic" fill={AMBER}>h</text>
|
||||||
|
</LegendSample>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pull-off', label: 'pull-off',
|
||||||
|
sample: (
|
||||||
|
<LegendSample>
|
||||||
|
<path d="M 3 14 Q 11 4 19 14" fill="none" stroke={AMBER} strokeWidth={1} />
|
||||||
|
<text x={11} y={9} textAnchor="middle" fontSize={8} fontStyle="italic" fill={AMBER}>p</text>
|
||||||
|
</LegendSample>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'slide', label: 'slide',
|
||||||
|
sample: (
|
||||||
|
<LegendSample>
|
||||||
|
<line x1={4} y1={13} x2={18} y2={5} stroke={AMBER} strokeWidth={1.2} strokeLinecap="round" />
|
||||||
|
</LegendSample>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bend', label: 'bend',
|
||||||
|
sample: (
|
||||||
|
<LegendSample>
|
||||||
|
<path d="M 5 14 Q 12 12 12 7" fill="none" stroke={AMBER} strokeWidth={1.2} />
|
||||||
|
<polygon points="9.8,8 14.2,8 12,3.5" fill={AMBER} />
|
||||||
|
</LegendSample>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vibrato', label: 'vibrato',
|
||||||
|
sample: (
|
||||||
|
<LegendSample>
|
||||||
|
<path d="M 3 10 q 2.3 -3.5 4.6 0 t 4.6 0 t 4.6 0" fill="none" stroke={AMBER} strokeWidth={1.1} strokeLinecap="round" />
|
||||||
|
</LegendSample>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ghost-note', label: 'ghost note',
|
||||||
|
sample: (
|
||||||
|
<LegendSample>
|
||||||
|
<text x={11} y={12} textAnchor="middle" fontSize={9} fontWeight="600" fill={GHOST_TEXT}>(5)</text>
|
||||||
|
</LegendSample>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'double-stop', label: 'double-stop (stacked)',
|
||||||
|
sample: (
|
||||||
|
<LegendSample>
|
||||||
|
<text x={11} y={8} textAnchor="middle" fontSize={8} fontWeight="600" fill={FRET_TEXT}>5</text>
|
||||||
|
<text x={11} y={17} textAnchor="middle" fontSize={8} fontWeight="600" fill={FRET_TEXT}>7</text>
|
||||||
|
</LegendSample>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[11px] text-gray-400">
|
||||||
|
{items.map(it => (
|
||||||
|
<span key={it.key} className="inline-flex items-center gap-1.5">
|
||||||
|
{it.sample}
|
||||||
|
{it.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="text-gray-500">chromatic-approach: tag only, no mark</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dev fixture — SCHEMA.md's worked example (P-21 real data replaces this in
|
||||||
|
// the app; this export exists so the card can be exercised before P-21) ────
|
||||||
|
|
||||||
|
export const DEMO_LICK = {
|
||||||
|
id: 'blues-box1-bb-answer',
|
||||||
|
name: 'B.B. box answer phrase',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'over the I7',
|
||||||
|
techniques: ['bend', 'vibrato'],
|
||||||
|
source: 'the B.B. King box, e.g. "The Thrill Is Gone" fills',
|
||||||
|
tab: [
|
||||||
|
{ string: 2, fret: 8 },
|
||||||
|
{ string: 1, fret: 8, technique: 'bend' },
|
||||||
|
{ string: 1, fret: 10, technique: 'vibrato' },
|
||||||
|
{ string: 2, fret: 8 },
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
const STYLE = {
|
||||||
|
empty: { border: 'border-border', bg: 'bg-surface', icon: '●', iconColor: 'text-gray-700' },
|
||||||
|
recording: { border: 'border-red-500', bg: 'bg-red-950/20', icon: '⏺', iconColor: 'text-red-400' },
|
||||||
|
trimming: { border: 'border-amber-400', bg: 'bg-amber-950/20', icon: '✂', iconColor: 'text-amber-400'},
|
||||||
|
playing: { border: 'border-accent', bg: 'bg-accent/10', icon: '▶', iconColor: 'text-accent' },
|
||||||
|
muted: { border: 'border-border', bg: 'bg-surface', icon: '⏸', iconColor: 'text-gray-500' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL = {
|
||||||
|
empty: 'tap to rec',
|
||||||
|
recording: 'tap to stop',
|
||||||
|
trimming: 'trimming…',
|
||||||
|
playing: 'tap to mute',
|
||||||
|
muted: 'tap to play',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoopSlot({ slot, slotIdx, audioCtxRef, masterStartRef, masterLenRef, onClick, onRetrim, onDelete, onVolumeChange }) {
|
||||||
|
const progressRef = useRef(null)
|
||||||
|
const rafRef = useRef(null)
|
||||||
|
const [showVol, setShowVol] = useState(false)
|
||||||
|
const [holdTimer, setHoldTimer] = useState(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const { status, recordingDuration, volume, originalBuffer } = slot
|
||||||
|
const style = STYLE[status] ?? STYLE.empty
|
||||||
|
const isActive = status === 'playing' || status === 'muted'
|
||||||
|
|
||||||
|
// ── Progress bar via rAF ─────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) {
|
||||||
|
if (progressRef.current) progressRef.current.style.width = '0%'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
function tick() {
|
||||||
|
const ctx = audioCtxRef.current
|
||||||
|
const mStart = masterStartRef.current
|
||||||
|
const mLen = masterLenRef.current
|
||||||
|
if (ctx && mStart !== null && mLen && progressRef.current) {
|
||||||
|
const pos = ((ctx.currentTime - mStart) % mLen) / mLen * 100
|
||||||
|
progressRef.current.style.width = `${pos}%`
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
|
||||||
|
}, [isActive, audioCtxRef, masterStartRef, masterLenRef])
|
||||||
|
|
||||||
|
// ── Long-press to delete ──────────────────────────────────────────────────
|
||||||
|
const onPointerDown = useCallback((e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const t = setTimeout(() => setDeleting(true), 500)
|
||||||
|
setHoldTimer(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onPointerUp = useCallback(() => {
|
||||||
|
if (holdTimer) { clearTimeout(holdTimer); setHoldTimer(null) }
|
||||||
|
if (!deleting) onClick(slotIdx)
|
||||||
|
}, [holdTimer, deleting, onClick, slotIdx])
|
||||||
|
|
||||||
|
const onPointerLeave = useCallback(() => {
|
||||||
|
if (holdTimer) { clearTimeout(holdTimer); setHoldTimer(null) }
|
||||||
|
}, [holdTimer])
|
||||||
|
|
||||||
|
const confirmDelete = useCallback(() => {
|
||||||
|
setDeleting(false)
|
||||||
|
onDelete(slotIdx)
|
||||||
|
}, [onDelete, slotIdx])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-1 select-none relative">
|
||||||
|
|
||||||
|
{/* Delete confirmation overlay (long-press) */}
|
||||||
|
{deleting && (
|
||||||
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 rounded-xl bg-black/90 border border-red-500">
|
||||||
|
<button
|
||||||
|
className="text-[10px] font-bold text-red-400 px-2 py-0.5 rounded bg-red-900/50 hover:bg-red-900"
|
||||||
|
onClick={confirmDelete}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-[10px] text-gray-500 hover:text-gray-300"
|
||||||
|
onClick={() => setDeleting(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick clear button — visible on non-empty slots */}
|
||||||
|
{status !== 'empty' && !deleting && (
|
||||||
|
<button
|
||||||
|
className="absolute -top-1.5 -right-1.5 z-10 w-4 h-4 rounded-full bg-gray-800 border border-border text-gray-500 hover:bg-red-900/60 hover:text-red-400 hover:border-red-700 text-[9px] leading-none flex items-center justify-center transition-colors"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete(slotIdx) }}
|
||||||
|
title="Clear slot"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main button */}
|
||||||
|
<button
|
||||||
|
className={`relative w-[76px] h-[54px] rounded-xl border-2 overflow-hidden flex flex-col items-center justify-center gap-0.5 transition-colors cursor-pointer ${style.bg} ${style.border} ${status === 'recording' ? 'animate-pulse' : ''}`}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerLeave={onPointerLeave}
|
||||||
|
>
|
||||||
|
<span className={`text-lg leading-none ${style.iconColor}`}>
|
||||||
|
{style.icon}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[9px] font-bold uppercase tracking-widest leading-none ${style.iconColor} opacity-70`}>
|
||||||
|
{status === 'recording'
|
||||||
|
? `${(recordingDuration ?? 0).toFixed(1)}s`
|
||||||
|
: `Loop ${slotIdx + 1}`}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-border">
|
||||||
|
<div
|
||||||
|
ref={progressRef}
|
||||||
|
className="h-full bg-accent"
|
||||||
|
style={{ width: '0%', transition: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Controls row (playing/muted only) */}
|
||||||
|
{isActive && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* Volume toggle */}
|
||||||
|
<button
|
||||||
|
className={`text-[11px] transition-colors ${showVol ? 'text-accent' : 'text-gray-600 hover:text-gray-400'}`}
|
||||||
|
onClick={() => setShowVol(v => !v)}
|
||||||
|
title="Volume"
|
||||||
|
>
|
||||||
|
🔊
|
||||||
|
</button>
|
||||||
|
{showVol && (
|
||||||
|
<input
|
||||||
|
type="range" min={0} max={1} step={0.05}
|
||||||
|
value={volume}
|
||||||
|
onChange={e => onVolumeChange(slotIdx, parseFloat(e.target.value))}
|
||||||
|
className="w-12 h-1 cursor-pointer accent-purple-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Re-trim button — only when original recording exists */}
|
||||||
|
{originalBuffer && (
|
||||||
|
<button
|
||||||
|
className="text-[11px] text-gray-600 hover:text-amber-400 transition-colors"
|
||||||
|
onClick={() => onRetrim(slotIdx)}
|
||||||
|
title="Re-trim this loop"
|
||||||
|
>
|
||||||
|
✂
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status label */}
|
||||||
|
<span className={`text-[9px] uppercase tracking-wider leading-none ${style.iconColor} opacity-50`}>
|
||||||
|
{LABEL[status] ?? ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import LoopTrimmer from './LoopTrimmer'
|
||||||
|
|
||||||
|
const H_TRACK = 56 // track canvas height px
|
||||||
|
const H_MASTER = 26 // master timeline height px
|
||||||
|
|
||||||
|
// ── Canvas draw helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function drawGrid(canvas, totalSec, bpm) {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
if (!rect.width || !rect.height) return
|
||||||
|
const dpr = window.devicePixelRatio ?? 1
|
||||||
|
canvas.width = rect.width * dpr
|
||||||
|
canvas.height = rect.height * dpr
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
const W = rect.width, H = rect.height
|
||||||
|
|
||||||
|
ctx.fillStyle = '#0f0f0f'
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
if (!bpm || !totalSec) return
|
||||||
|
|
||||||
|
const beatSec = 60 / bpm
|
||||||
|
const barSec = beatSec * 4
|
||||||
|
|
||||||
|
// Beat lines
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
for (let t = beatSec; t < totalSec; t += beatSec) {
|
||||||
|
if ((t % barSec) < beatSec * 0.4) continue
|
||||||
|
const x = (t / totalSec) * W
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
}
|
||||||
|
// Bar lines + numbers
|
||||||
|
for (let t = 0; t <= totalSec; t += barSec) {
|
||||||
|
ctx.strokeStyle = 'rgba(168,85,247,0.45)'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
const x = (t / totalSec) * W
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
const n = Math.round(t / barSec)
|
||||||
|
if (n > 0) {
|
||||||
|
ctx.fillStyle = 'rgba(168,85,247,0.55)'
|
||||||
|
ctx.font = '9px monospace'
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.fillText(String(n), x + 3, H - 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWaveform(canvas, waveform, muted) {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
if (!rect.width || !rect.height) return
|
||||||
|
const dpr = window.devicePixelRatio ?? 1
|
||||||
|
canvas.width = rect.width * dpr
|
||||||
|
canvas.height = rect.height * dpr
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
const W = rect.width, H = rect.height
|
||||||
|
|
||||||
|
ctx.fillStyle = '#0f0f0f'
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
|
||||||
|
const N = waveform.length
|
||||||
|
const mid = H / 2
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const x = (i / N) * W
|
||||||
|
const barW = Math.max(1, W / N - 0.3)
|
||||||
|
ctx.fillStyle = muted ? '#3b1f55' : '#a855f7'
|
||||||
|
const h = waveform[i] * mid * 0.85
|
||||||
|
ctx.fillRect(x, mid - h, barW, h * 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRecording(canvas, duration, bpm) {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
if (!rect.width || !rect.height) return
|
||||||
|
const dpr = window.devicePixelRatio ?? 1
|
||||||
|
canvas.width = rect.width * dpr
|
||||||
|
canvas.height = rect.height * dpr
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
const W = rect.width, H = rect.height
|
||||||
|
|
||||||
|
ctx.fillStyle = '#0f0f0f'
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
|
||||||
|
const beatSec = bpm ? 60 / bpm : null
|
||||||
|
const barSec = beatSec ? beatSec * 4 : null
|
||||||
|
const viewDur = barSec
|
||||||
|
? Math.max(barSec * 4, Math.ceil(duration / barSec + 1) * barSec)
|
||||||
|
: Math.max(8, duration * 1.4)
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
if (beatSec) {
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
for (let t = beatSec; t < viewDur; t += beatSec) {
|
||||||
|
if (barSec && (t % barSec) < beatSec * 0.4) continue
|
||||||
|
const x = (t / viewDur) * W
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
}
|
||||||
|
if (barSec) {
|
||||||
|
for (let t = barSec; t <= viewDur; t += barSec) {
|
||||||
|
ctx.strokeStyle = 'rgba(239,68,68,0.3)'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
const x = (t / viewDur) * W
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
const n = Math.round(t / barSec)
|
||||||
|
ctx.fillStyle = 'rgba(239,68,68,0.45)'
|
||||||
|
ctx.font = '9px monospace'
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.fillText(String(n), x + 2, H - 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Growing fill + cursor
|
||||||
|
const fillX = (duration / viewDur) * W
|
||||||
|
ctx.fillStyle = 'rgba(239,68,68,0.18)'
|
||||||
|
ctx.fillRect(0, 0, fillX, H)
|
||||||
|
ctx.strokeStyle = 'rgba(239,68,68,0.85)'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.beginPath(); ctx.moveTo(fillX, 0); ctx.lineTo(fillX, H); ctx.stroke()
|
||||||
|
|
||||||
|
// Counter label
|
||||||
|
const bars = barSec ? Math.floor(duration / barSec) + 1 : null
|
||||||
|
const label = bars !== null
|
||||||
|
? `● REC BAR ${bars} ${duration.toFixed(1)}s — tap to stop`
|
||||||
|
: `● REC ${duration.toFixed(1)}s — tap to stop`
|
||||||
|
ctx.fillStyle = 'rgba(239,68,68,0.9)'
|
||||||
|
ctx.font = 'bold 11px monospace'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
ctx.fillText(label, W / 2, H / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Master Timeline ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MasterTimeline({ masterStartRef, masterLenRef, audioCtxRef, bpm, masterLen }) {
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const playheadRef = useRef(null)
|
||||||
|
const rafRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const id = requestAnimationFrame(() => drawGrid(canvas, masterLen, bpm))
|
||||||
|
return () => cancelAnimationFrame(id)
|
||||||
|
}, [masterLen, bpm])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!masterLen) { cancelAnimationFrame(rafRef.current); return }
|
||||||
|
function tick() {
|
||||||
|
const ac = audioCtxRef.current
|
||||||
|
const t0 = masterStartRef.current
|
||||||
|
const len = masterLenRef.current
|
||||||
|
if (ac && t0 !== null && len && playheadRef.current) {
|
||||||
|
const pos = ((ac.currentTime - t0) % len) / len
|
||||||
|
playheadRef.current.style.left = `${pos * 100}%`
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(rafRef.current)
|
||||||
|
}, [masterLen, audioCtxRef, masterStartRef, masterLenRef])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mx-4 mb-3 rounded overflow-hidden bg-surface border border-border"
|
||||||
|
style={{ height: `${H_MASTER}px` }}>
|
||||||
|
<canvas ref={canvasRef} className="w-full h-full block" />
|
||||||
|
{!masterLen && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<span className="text-[10px] text-gray-700">
|
||||||
|
record first loop to set master length
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{masterLen && (
|
||||||
|
<div
|
||||||
|
ref={playheadRef}
|
||||||
|
className="absolute top-0 bottom-0 w-px bg-white/50 pointer-events-none"
|
||||||
|
style={{ left: '0%' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Track Row ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TrackRow({ slot, slotIdx, bpm, audioCtxRef, masterStartRef, masterLenRef,
|
||||||
|
onSlotClick, onRetrim, onDelete, onVolumeChange }) {
|
||||||
|
const [showVol, setShowVol] = useState(false)
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const playheadRef = useRef(null)
|
||||||
|
const rafRef = useRef(null)
|
||||||
|
|
||||||
|
const DOT_CLASS = {
|
||||||
|
empty: 'bg-gray-700',
|
||||||
|
recording: 'bg-red-500 animate-pulse',
|
||||||
|
trimming: 'bg-amber-500',
|
||||||
|
playing: 'bg-accent',
|
||||||
|
muted: 'bg-gray-500',
|
||||||
|
}
|
||||||
|
const BORDER_CLASS = {
|
||||||
|
empty: 'border-border',
|
||||||
|
recording: 'border-red-800',
|
||||||
|
trimming: 'border-amber-800/60',
|
||||||
|
playing: 'border-accent/40',
|
||||||
|
muted: 'border-border',
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotClass = DOT_CLASS[slot.status] ?? 'bg-gray-700'
|
||||||
|
const borderClass = BORDER_CLASS[slot.status] ?? 'border-border'
|
||||||
|
|
||||||
|
// Draw waveform when data arrives or mute state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slot.waveform) return
|
||||||
|
if (slot.status === 'recording' || slot.status === 'trimming') return
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const id = requestAnimationFrame(() =>
|
||||||
|
drawWaveform(canvas, slot.waveform, slot.status === 'muted')
|
||||||
|
)
|
||||||
|
return () => cancelAnimationFrame(id)
|
||||||
|
}, [slot.waveform, slot.status])
|
||||||
|
|
||||||
|
// Draw recording progress on each duration tick
|
||||||
|
useEffect(() => {
|
||||||
|
if (slot.status !== 'recording') return
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
drawRecording(canvas, slot.recordingDuration, bpm)
|
||||||
|
}, [slot.recordingDuration, slot.status, bpm])
|
||||||
|
|
||||||
|
// Playhead animation
|
||||||
|
useEffect(() => {
|
||||||
|
const active = slot.status === 'playing' || slot.status === 'muted'
|
||||||
|
if (!active) {
|
||||||
|
cancelAnimationFrame(rafRef.current)
|
||||||
|
if (playheadRef.current) playheadRef.current.style.left = '-2px'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
function tick() {
|
||||||
|
const ac = audioCtxRef.current
|
||||||
|
const t0 = masterStartRef.current
|
||||||
|
const len = masterLenRef.current
|
||||||
|
if (ac && t0 !== null && len && playheadRef.current) {
|
||||||
|
const pos = ((ac.currentTime - t0) % len) / len
|
||||||
|
playheadRef.current.style.left = `${pos * 100}%`
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(rafRef.current)
|
||||||
|
}, [slot.status, audioCtxRef, masterStartRef, masterLenRef])
|
||||||
|
|
||||||
|
const isClickable = slot.status !== 'trimming'
|
||||||
|
const isActive = slot.status === 'playing' || slot.status === 'muted'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-stretch border rounded-lg mb-1.5 overflow-hidden transition-colors ${borderClass}`}>
|
||||||
|
|
||||||
|
{/* Left: tap button (state dot + track number) */}
|
||||||
|
<button
|
||||||
|
onClick={() => isClickable && onSlotClick(slotIdx)}
|
||||||
|
disabled={!isClickable}
|
||||||
|
title={
|
||||||
|
slot.status === 'empty' ? 'Tap to record' :
|
||||||
|
slot.status === 'recording' ? 'Tap to stop' :
|
||||||
|
slot.status === 'playing' ? 'Tap to mute' :
|
||||||
|
slot.status === 'muted' ? 'Tap to unmute' : ''
|
||||||
|
}
|
||||||
|
className="flex flex-col items-center justify-center gap-1 px-3 bg-surface border-r border-border shrink-0 hover:bg-white/5 transition-colors disabled:cursor-default"
|
||||||
|
style={{ width: '44px' }}
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||||
|
<span className="text-[10px] text-gray-600 font-mono">{slotIdx + 1}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Canvas: waveform / recording / empty */}
|
||||||
|
<div
|
||||||
|
className="relative flex-1 cursor-pointer"
|
||||||
|
style={{ height: `${H_TRACK}px` }}
|
||||||
|
onClick={() => isClickable && onSlotClick(slotIdx)}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} className="w-full h-full block" />
|
||||||
|
|
||||||
|
{slot.status === 'empty' && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<span className="text-xs text-gray-700">tap to record</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{slot.status === 'trimming' && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-amber-950/20 pointer-events-none">
|
||||||
|
<span className="text-xs text-amber-500/60">trimming ↓</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Moving playhead */}
|
||||||
|
{isActive && (
|
||||||
|
<div
|
||||||
|
ref={playheadRef}
|
||||||
|
className="absolute top-0 bottom-0 w-px bg-white/40 pointer-events-none"
|
||||||
|
style={{ left: '-2px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: controls */}
|
||||||
|
<div className="flex items-center gap-1 px-2 bg-surface border-l border-border shrink-0">
|
||||||
|
{showVol && (
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0} max={1} step={0.01}
|
||||||
|
value={slot.volume}
|
||||||
|
onChange={e => onVolumeChange(slotIdx, parseFloat(e.target.value))}
|
||||||
|
className="w-14 accent-purple-500"
|
||||||
|
title="Volume"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowVol(v => !v)}
|
||||||
|
className={`w-7 h-7 flex items-center justify-center rounded text-base transition-colors ${
|
||||||
|
showVol ? 'text-accent' : 'text-gray-600 hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
title="Volume"
|
||||||
|
>
|
||||||
|
{slot.status === 'muted' ? '🔇' : '🔊'}
|
||||||
|
</button>
|
||||||
|
{isActive && slot.originalBuffer && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRetrim(slotIdx)}
|
||||||
|
className="w-7 h-7 flex items-center justify-center rounded text-gray-600 hover:text-amber-400 transition-colors text-sm"
|
||||||
|
title="Re-trim loop"
|
||||||
|
>
|
||||||
|
✂
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(slotIdx)}
|
||||||
|
className="w-7 h-7 flex items-center justify-center rounded text-gray-700 hover:text-red-400 transition-colors text-sm"
|
||||||
|
title="Clear track"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function LoopStation({
|
||||||
|
slots,
|
||||||
|
bpm,
|
||||||
|
masterLen,
|
||||||
|
audioCtxRef,
|
||||||
|
masterStartRef,
|
||||||
|
masterLenRef,
|
||||||
|
onSlotClick,
|
||||||
|
onCommitTrim,
|
||||||
|
onCancelRecord,
|
||||||
|
onRetrim,
|
||||||
|
onDelete,
|
||||||
|
onVolumeChange,
|
||||||
|
onAddSlot,
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [gridBpm, setGridBpm] = useState(bpm ?? '')
|
||||||
|
|
||||||
|
// Pre-fill BPM when detection arrives
|
||||||
|
useEffect(() => {
|
||||||
|
if (bpm && !gridBpm) setGridBpm(bpm)
|
||||||
|
}, [bpm]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const gridBpmNum = parseFloat(gridBpm) || null
|
||||||
|
const trimmingIdx = slots.findIndex(s => s.status === 'trimming')
|
||||||
|
|
||||||
|
const recordingCount = slots.filter(s => s.status === 'recording').length
|
||||||
|
const playingCount = slots.filter(s => s.status === 'playing').length
|
||||||
|
|
||||||
|
const dotClass = recordingCount > 0
|
||||||
|
? 'bg-red-500 animate-pulse'
|
||||||
|
: playingCount > 0
|
||||||
|
? 'bg-accent'
|
||||||
|
: 'bg-gray-700'
|
||||||
|
|
||||||
|
const masterLabel = (() => {
|
||||||
|
if (!masterLen) return null
|
||||||
|
if (gridBpmNum) {
|
||||||
|
const bars = Math.round(masterLen / ((60 / gridBpmNum) * 4))
|
||||||
|
return `${bars} bar${bars !== 1 ? 's' : ''} · ${masterLen.toFixed(2)}s`
|
||||||
|
}
|
||||||
|
return masterLen.toFixed(2) + 's'
|
||||||
|
})()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
|
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-400">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="flex items-center gap-2 hover:text-gray-200 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||||
|
<span>LOOP STATION</span>
|
||||||
|
{masterLabel && (
|
||||||
|
<span className="text-[11px] text-gray-600 font-mono">{masterLabel}</span>
|
||||||
|
)}
|
||||||
|
{recordingCount > 0 && (
|
||||||
|
<span className="text-[11px] text-red-400">● rec</span>
|
||||||
|
)}
|
||||||
|
{playingCount > 0 && recordingCount === 0 && (
|
||||||
|
<span className="text-[11px] text-accent">
|
||||||
|
{playingCount} loop{playingCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1 bg-surface border border-border rounded-lg px-2 py-1">
|
||||||
|
<span className="text-[10px] text-gray-600 uppercase tracking-wider">BPM</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={40} max={300} step={1}
|
||||||
|
value={gridBpm}
|
||||||
|
onChange={e => setGridBpm(e.target.value)}
|
||||||
|
placeholder={bpm ? String(Math.round(bpm)) : '—'}
|
||||||
|
className="w-10 bg-transparent text-xs text-gray-300 text-center focus:outline-none focus:text-white"
|
||||||
|
style={{ MozAppearance: 'textfield' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-all"
|
||||||
|
title={open ? 'Collapse' : 'Expand'}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"
|
||||||
|
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{open
|
||||||
|
? <polyline points="2,8 6,4 10,8" />
|
||||||
|
: <polyline points="2,4 6,8 10,4" />
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Body ────────────────────────────────────────────────────────────── */}
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-border pt-3">
|
||||||
|
|
||||||
|
{/* Master timeline */}
|
||||||
|
<MasterTimeline
|
||||||
|
masterStartRef={masterStartRef}
|
||||||
|
masterLenRef={masterLenRef}
|
||||||
|
audioCtxRef={audioCtxRef}
|
||||||
|
bpm={gridBpmNum}
|
||||||
|
masterLen={masterLen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Track rows */}
|
||||||
|
<div className="px-4">
|
||||||
|
{slots.map((slot, i) => (
|
||||||
|
<TrackRow
|
||||||
|
key={i}
|
||||||
|
slot={slot}
|
||||||
|
slotIdx={i}
|
||||||
|
bpm={gridBpmNum}
|
||||||
|
audioCtxRef={audioCtxRef}
|
||||||
|
masterStartRef={masterStartRef}
|
||||||
|
masterLenRef={masterLenRef}
|
||||||
|
onSlotClick={onSlotClick}
|
||||||
|
onRetrim={onRetrim}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onVolumeChange={onVolumeChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add track */}
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<button
|
||||||
|
onClick={onAddSlot}
|
||||||
|
className="w-full py-1.5 rounded-lg border border-dashed border-border text-gray-700 hover:border-accent/40 hover:text-accent/60 transition-colors text-xs flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">+</span>
|
||||||
|
<span>Add Track</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LoopTrimmer — shown below tracks when trimming */}
|
||||||
|
{trimmingIdx !== -1 && (
|
||||||
|
<LoopTrimmer
|
||||||
|
slot={slots[trimmingIdx]}
|
||||||
|
slotIdx={trimmingIdx}
|
||||||
|
bpm={gridBpmNum}
|
||||||
|
audioCtxRef={audioCtxRef}
|
||||||
|
onCommit={onCommitTrim}
|
||||||
|
onCancel={onCancelRecord}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
function fmtMs(sec) {
|
||||||
|
return `${(sec * 1000).toFixed(0)}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSec(sec) {
|
||||||
|
return sec < 10 ? `${sec.toFixed(2)}s` : `${sec.toFixed(1)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoopTrimmer({ slot, slotIdx, bpm, audioCtxRef, onCommit, onCancel }) {
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const previewRef = useRef(null) // AudioBufferSourceNode for preview
|
||||||
|
|
||||||
|
const [trimStart, setTrimStart] = useState(slot.trimStart)
|
||||||
|
const [trimEnd, setTrimEnd] = useState(slot.trimEnd)
|
||||||
|
const [previewing, setPreviewing] = useState(false)
|
||||||
|
|
||||||
|
// Refs so drag closures always have current values
|
||||||
|
const trimStartRef = useRef(trimStart)
|
||||||
|
const trimEndRef = useRef(trimEnd)
|
||||||
|
useEffect(() => { trimStartRef.current = trimStart }, [trimStart])
|
||||||
|
useEffect(() => { trimEndRef.current = trimEnd }, [trimEnd])
|
||||||
|
|
||||||
|
const duration = slot.audioBuffer?.duration ?? 0
|
||||||
|
const startSec = trimStart * duration
|
||||||
|
const endSec = trimEnd * duration
|
||||||
|
const selectedSec = endSec - startSec
|
||||||
|
|
||||||
|
// Beat grid info — visual reference only, no snapping
|
||||||
|
const beatSec = bpm ? 60 / bpm : null
|
||||||
|
const barSec = beatSec ? beatSec * 4 : null
|
||||||
|
|
||||||
|
// Stop preview when handles change
|
||||||
|
useEffect(() => {
|
||||||
|
if (previewing) stopPreview()
|
||||||
|
}, [trimStart, trimEnd]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => stopPreview()
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
function stopPreview() {
|
||||||
|
try { previewRef.current?.stop() } catch {}
|
||||||
|
previewRef.current = null
|
||||||
|
setPreviewing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePreview() {
|
||||||
|
if (previewing) { stopPreview(); return }
|
||||||
|
const ctx = audioCtxRef?.current
|
||||||
|
const buf = slot.audioBuffer
|
||||||
|
if (!ctx || !buf) return
|
||||||
|
|
||||||
|
// Resume context if suspended
|
||||||
|
if (ctx.state === 'suspended') ctx.resume().catch(() => {})
|
||||||
|
|
||||||
|
const sr = buf.sampleRate
|
||||||
|
const startSample = Math.floor(trimStartRef.current * buf.length)
|
||||||
|
const endSample = Math.ceil(trimEndRef.current * buf.length)
|
||||||
|
const len = Math.max(1, endSample - startSample)
|
||||||
|
const data = buf.getChannelData(0).slice(startSample, endSample)
|
||||||
|
|
||||||
|
const previewBuf = ctx.createBuffer(1, len, sr)
|
||||||
|
previewBuf.copyToChannel(data, 0)
|
||||||
|
|
||||||
|
const node = ctx.createBufferSource()
|
||||||
|
node.buffer = previewBuf
|
||||||
|
node.loop = true
|
||||||
|
node.loopStart = 0
|
||||||
|
node.loopEnd = len / sr
|
||||||
|
node.connect(ctx.destination)
|
||||||
|
node.start()
|
||||||
|
node.onended = () => { previewRef.current = null; setPreviewing(false) }
|
||||||
|
|
||||||
|
previewRef.current = node
|
||||||
|
setPreviewing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snap end handle to N bars from current start
|
||||||
|
function snapBars(n) {
|
||||||
|
if (!barSec || !duration) return
|
||||||
|
const newEnd = Math.min(1, trimStartRef.current + (n * barSec) / duration)
|
||||||
|
setTrimEnd(newEnd)
|
||||||
|
trimEndRef.current = newEnd
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Canvas draw ─────────────────────────────────────────────────────────────
|
||||||
|
const draw = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas || !slot.waveform) return
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
if (rect.width === 0) return
|
||||||
|
const dpr = window.devicePixelRatio ?? 1
|
||||||
|
canvas.width = rect.width * dpr
|
||||||
|
canvas.height = rect.height * dpr
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
const W = rect.width
|
||||||
|
const H = rect.height
|
||||||
|
const wf = slot.waveform
|
||||||
|
const N = wf.length
|
||||||
|
const ts = trimStartRef.current
|
||||||
|
const te = trimEndRef.current
|
||||||
|
|
||||||
|
// Background
|
||||||
|
ctx.fillStyle = '#0f0f0f'
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
|
||||||
|
// Dim regions outside selection
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.6)'
|
||||||
|
ctx.fillRect(0, 0, ts * W, H)
|
||||||
|
ctx.fillRect(te * W, 0, W - te * W, H)
|
||||||
|
|
||||||
|
// Beat grid — visual only, beat lines then bar lines (bars on top)
|
||||||
|
if (beatSec && duration) {
|
||||||
|
// Beat lines
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.10)'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
for (let t = 0; t <= duration; t += beatSec) {
|
||||||
|
const isBar = barSec ? (t % barSec) < beatSec * 0.4 : false
|
||||||
|
if (!isBar) {
|
||||||
|
const x = (t / duration) * W
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bar lines (brighter, thicker)
|
||||||
|
if (barSec) {
|
||||||
|
ctx.strokeStyle = 'rgba(168,85,247,0.55)'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
for (let t = 0; t <= duration; t += barSec) {
|
||||||
|
const x = (t / duration) * W
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
// Bar number label
|
||||||
|
const barNum = Math.round(t / barSec)
|
||||||
|
if (barNum > 0) {
|
||||||
|
ctx.fillStyle = 'rgba(168,85,247,0.5)'
|
||||||
|
ctx.font = `${9 * dpr / dpr}px monospace`
|
||||||
|
ctx.fillText(`${barNum}`, x + 3, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waveform bars
|
||||||
|
const mid = H / 2
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const x = (i / N) * W
|
||||||
|
const barW = Math.max(1, W / N - 0.5)
|
||||||
|
const inSel = (i / N) >= ts && (i / N) <= te
|
||||||
|
ctx.fillStyle = inSel ? '#a855f7' : '#3b0764'
|
||||||
|
const h = wf[i] * mid * 0.88
|
||||||
|
ctx.fillRect(x, mid - h, barW, h * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle lines
|
||||||
|
ctx.strokeStyle = '#a855f7'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.beginPath(); ctx.moveTo(ts * W, 0); ctx.lineTo(ts * W, H); ctx.stroke()
|
||||||
|
ctx.beginPath(); ctx.moveTo(te * W, 0); ctx.lineTo(te * W, H); ctx.stroke()
|
||||||
|
}, [slot.waveform, beatSec, barSec, duration])
|
||||||
|
|
||||||
|
useEffect(() => { draw() }, [draw, trimStart, trimEnd])
|
||||||
|
useEffect(() => {
|
||||||
|
const id = requestAnimationFrame(() => draw())
|
||||||
|
return () => cancelAnimationFrame(id)
|
||||||
|
}, [draw])
|
||||||
|
|
||||||
|
// ── Pointer → fraction ──────────────────────────────────────────────────────
|
||||||
|
function fracFromClientX(clientX) {
|
||||||
|
const el = containerRef.current
|
||||||
|
if (!el) return 0
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag handles — free movement, no snapping ───────────────────────────────
|
||||||
|
function handleMouseDown(handle) {
|
||||||
|
return (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
function onMove(ev) {
|
||||||
|
const raw = fracFromClientX(ev.clientX)
|
||||||
|
if (handle === 'start') {
|
||||||
|
const c = Math.max(0, Math.min(raw, trimEndRef.current - 0.01))
|
||||||
|
setTrimStart(c); trimStartRef.current = c
|
||||||
|
} else {
|
||||||
|
const c = Math.max(trimStartRef.current + 0.01, Math.min(1, raw))
|
||||||
|
setTrimEnd(c); trimEndRef.current = c
|
||||||
|
}
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
window.removeEventListener('mousemove', onMove)
|
||||||
|
window.removeEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
window.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click canvas to move nearest handle
|
||||||
|
function handleCanvasClick(e) {
|
||||||
|
if (e.target !== canvasRef.current) return
|
||||||
|
const raw = fracFromClientX(e.clientX)
|
||||||
|
if (Math.abs(raw - trimStart) <= Math.abs(raw - trimEnd)) {
|
||||||
|
const c = Math.max(0, Math.min(raw, trimEndRef.current - 0.01))
|
||||||
|
setTrimStart(c); trimStartRef.current = c
|
||||||
|
} else {
|
||||||
|
const c = Math.max(trimStartRef.current + 0.01, Math.min(1, raw))
|
||||||
|
setTrimEnd(c); trimEndRef.current = c
|
||||||
|
}
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border">
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center justify-between px-4 pt-3 pb-1">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-gray-500">
|
||||||
|
Trim — Loop {slotIdx + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-500 font-mono">
|
||||||
|
<span className="text-gray-600">{fmtMs(startSec)} → {fmtMs(endSec)}</span>
|
||||||
|
<span className="text-accent font-semibold">{fmtSec(selectedSec)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Waveform + handles */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative mx-4 h-20 rounded-lg overflow-visible cursor-crosshair select-none"
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} className="w-full h-full block rounded-lg" />
|
||||||
|
|
||||||
|
{/* Left handle */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-5 -translate-x-1/2 cursor-ew-resize flex items-center justify-center group z-10"
|
||||||
|
style={{ left: `${trimStart * 100}%` }}
|
||||||
|
onMouseDown={handleMouseDown('start')}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="w-0.5 h-full bg-accent/70 group-hover:bg-accent group-hover:w-1 transition-all" />
|
||||||
|
<div className="absolute w-3.5 h-3.5 rounded-full bg-accent border-2 border-white/20 shadow-lg top-1/2 -translate-y-1/2" />
|
||||||
|
<div className="absolute bottom-full mb-1 text-[9px] font-mono text-accent bg-panel border border-border rounded px-1 py-0.5 whitespace-nowrap pointer-events-none">
|
||||||
|
{fmtMs(startSec)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right handle */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-5 -translate-x-1/2 cursor-ew-resize flex items-center justify-center group z-10"
|
||||||
|
style={{ left: `${trimEnd * 100}%` }}
|
||||||
|
onMouseDown={handleMouseDown('end')}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="w-0.5 h-full bg-accent/70 group-hover:bg-accent group-hover:w-1 transition-all" />
|
||||||
|
<div className="absolute w-3.5 h-3.5 rounded-full bg-accent border-2 border-white/20 shadow-lg top-1/2 -translate-y-1/2" />
|
||||||
|
<div className="absolute bottom-full mb-1 text-[9px] font-mono text-accent bg-panel border border-border rounded px-1 py-0.5 whitespace-nowrap pointer-events-none">
|
||||||
|
{fmtMs(endSec)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar: preview + bar snap + hint */}
|
||||||
|
<div className="flex items-center gap-2 px-4 pt-2 pb-1 flex-wrap">
|
||||||
|
{/* Preview play/stop */}
|
||||||
|
<button
|
||||||
|
onClick={togglePreview}
|
||||||
|
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg border text-xs font-medium transition-all ${
|
||||||
|
previewing
|
||||||
|
? 'bg-accent/20 border-accent text-accent'
|
||||||
|
: 'bg-surface border-border text-gray-400 hover:text-white hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
title="Preview loop selection"
|
||||||
|
>
|
||||||
|
{previewing
|
||||||
|
? <><span>⏹</span><span>Stop</span></>
|
||||||
|
: <><span>▶</span><span>Preview</span></>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Bar snap buttons — only if BPM is set */}
|
||||||
|
{barSec && duration && (
|
||||||
|
<div className="flex items-center gap-1 ml-1">
|
||||||
|
<span className="text-[10px] text-gray-600 uppercase tracking-wider mr-0.5">snap end →</span>
|
||||||
|
{[1, 2, 4].map(n => {
|
||||||
|
const endFrac = trimStart + (n * barSec) / duration
|
||||||
|
const fits = endFrac <= 1.02
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => snapBars(n)}
|
||||||
|
disabled={!fits}
|
||||||
|
className="px-2 py-0.5 rounded border border-border text-[10px] text-gray-400 hover:text-accent hover:border-accent/50 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
title={`Set end to ${n} bar${n > 1 ? 's' : ''} from start`}
|
||||||
|
>
|
||||||
|
{n} bar{n > 1 ? 's' : ''}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="ml-auto text-[10px] text-gray-700">
|
||||||
|
{bpm ? `${bpm} BPM grid` : 'no BPM — trim freely'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex gap-2 px-4 pb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { stopPreview(); onCommit(slotIdx, trimStart, trimEnd) }}
|
||||||
|
className="px-5 py-1.5 bg-accent text-white text-sm font-bold rounded-lg hover:bg-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
Set Loop ▶
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { stopPreview(); onCommit(slotIdx, 0, 1) }}
|
||||||
|
className="px-4 py-1.5 bg-surface border border-border text-gray-400 text-sm rounded-lg hover:text-white hover:border-gray-500 transition-colors"
|
||||||
|
title="Use the full recording without trimming"
|
||||||
|
>
|
||||||
|
Use Full
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { stopPreview(); onCancel(slotIdx) }}
|
||||||
|
className="px-4 py-1.5 bg-surface border border-border text-gray-500 text-sm rounded-lg hover:text-red-400 hover:border-red-800 transition-colors ml-auto"
|
||||||
|
>
|
||||||
|
Re-record
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
// Mini piano keyboard.
|
||||||
|
//
|
||||||
|
// TWO render modes, chosen by props (additive — legacy path is the default):
|
||||||
|
//
|
||||||
|
// 1. LEGACY <MiniPiano rootPc={..} lh={[..]} rh={[..]} />
|
||||||
|
// Used by ChordDetailModal + ExplorePanel. `lh`/`rh` are semitone intervals
|
||||||
|
// ABOVE the root (left hand shown blue, right hand shown purple), spanning a
|
||||||
|
// 2-octave keyboard. Behaviour here is UNCHANGED — byte-for-byte the same
|
||||||
|
// output the existing consumers have always rendered.
|
||||||
|
//
|
||||||
|
// 2. VOICING <MiniPiano voicing={pianoVoicing(...)} size="thumb|full" />
|
||||||
|
// Renders the output of src/lib/piano.js `pianoVoicing({rootPc,quality},opts)`:
|
||||||
|
// voicing.notes — ABSOLUTE semitone key positions, 0 = C of the low octave,
|
||||||
|
// range [0,36]. A note value `n` maps to the key `n`
|
||||||
|
// semitones above the low C (octave = ⌊n/12⌋, pc = n%12).
|
||||||
|
// voicing.pcs — pitch classes sounding.
|
||||||
|
// voicing.bass — lowest absolute note (the LH anchor) — marked distinctly.
|
||||||
|
// voicing.style / voicing.label — captions (shown in `full` size).
|
||||||
|
// The root pitch class lights in accent purple; the bass key is ringed as the
|
||||||
|
// LH anchor; the other voicing tones light in a lighter purple.
|
||||||
|
//
|
||||||
|
// Keyboard window (D-24): `size="thumb"` crops to the voicing's whole-octave
|
||||||
|
// span — from the octave-start at-or-below the lowest sounded key to the
|
||||||
|
// octave-end at-or-above the highest (plus the closing high C) — so a
|
||||||
|
// ≤1-octave shell renders ONE octave (~140px) instead of 2–3 (~266–390px)
|
||||||
|
// and Jam Guide station thumbs sit level with guitar ChordDiagram thumbs.
|
||||||
|
// `size="mini"` (L-70, dashboard-polish.md §2) is the SAME cropped window at
|
||||||
|
// scale 0.60 (vs thumb 0.8) so two cells fit one grid column of the rail's
|
||||||
|
// ~451px interior (2-octave cell 213.2px < 222.5px); it bumps the in-SVG "R"
|
||||||
|
// glyph (8→10 vu) and the bass-ring stroke (2→2.5) so both stay legible at
|
||||||
|
// the smaller render. `thumb`/`full` are byte-untouched — `mini` is additive.
|
||||||
|
// `size="full"` keeps the wide C-anchored 2–3-octave window: the enlarged
|
||||||
|
// view benefits from register context, and its output stays byte-identical
|
||||||
|
// to the D-10 behaviour.
|
||||||
|
//
|
||||||
|
// Design tokens (tailwind.config.js): accent #a855f7. The SVG also uses the
|
||||||
|
// established Piano/Fretboard note language (accent purple for the focal tone).
|
||||||
|
|
||||||
|
const WW = 22 // white key width
|
||||||
|
const WH = 60 // white key height
|
||||||
|
const BW = 14 // black key width
|
||||||
|
const BH = 38 // black key height
|
||||||
|
|
||||||
|
// White key pitch classes within an octave, in order
|
||||||
|
const WHITE_PCS = [0, 2, 4, 5, 7, 9, 11] // C D E F G A B
|
||||||
|
const WHITE_NAMES = ['C','D','E','F','G','A','B']
|
||||||
|
// Black key offsets (x position relative to white key 0) and pitch classes
|
||||||
|
const BLACK_OFFSETS = [
|
||||||
|
{ pc: 1, afterWhite: 0 }, // C#
|
||||||
|
{ pc: 3, afterWhite: 1 }, // D#
|
||||||
|
{ pc: 6, afterWhite: 3 }, // F#
|
||||||
|
{ pc: 8, afterWhite: 4 }, // G#
|
||||||
|
{ pc: 10, afterWhite: 5 }, // A#
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── Design tokens (literal — SVG fills can't read Tailwind classes) ───────────
|
||||||
|
const ACCENT = '#a855f7' // text-accent — root pitch class (focal tone)
|
||||||
|
const ACCENT_SOFT = '#c084fc' // lighter accent — non-root voicing tones
|
||||||
|
const LH_BLUE = '#3b82f6' // legacy left-hand colour (unchanged)
|
||||||
|
const WHITE_FILL = '#f5f5f5'
|
||||||
|
const BLACK_FILL = '#1f2937'
|
||||||
|
const WHITE_STROKE = '#374151'
|
||||||
|
const BLACK_STROKE = '#111827'
|
||||||
|
const BASS_RING = '#fbbf24' // amber ring marking the LH bass anchor (AA on keys)
|
||||||
|
|
||||||
|
function legacyNoteColor(hand) {
|
||||||
|
return hand === 'L' ? LH_BLUE : ACCENT
|
||||||
|
}
|
||||||
|
|
||||||
|
function handLabel(hand) {
|
||||||
|
return hand === 'L' ? 'LH' : 'RH'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// LEGACY render path — {rootPc, lh, rh}. UNCHANGED from the original component.
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
function LegacyPiano({ rootPc, lh = [], rh = [] }) {
|
||||||
|
const OCTAVES = 2
|
||||||
|
const TOTAL_WHITES = WHITE_PCS.length * OCTAVES // 14
|
||||||
|
const SVG_W = WW * TOTAL_WHITES + 2
|
||||||
|
const SVG_H = WH + 24
|
||||||
|
|
||||||
|
// Build a set of highlighted notes: pc → { hand, interval }
|
||||||
|
// We span 2 octaves (semitones 0…23 above root), mapped to absolute pitch classes
|
||||||
|
const highlights = new Map() // absIdx → { color, label }
|
||||||
|
|
||||||
|
function addNotes(intervals, hand) {
|
||||||
|
for (const iv of intervals) {
|
||||||
|
const octave = Math.floor(iv / 12)
|
||||||
|
const pc = (rootPc + iv) % 12
|
||||||
|
highlights.set(`${octave}-${pc}`, { color: legacyNoteColor(hand), label: handLabel(hand) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addNotes(lh, 'L')
|
||||||
|
addNotes(rh, 'R')
|
||||||
|
|
||||||
|
function isHighlighted(octave, pc) {
|
||||||
|
return highlights.get(`${octave}-${pc}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// White keys
|
||||||
|
const whites = []
|
||||||
|
for (let oct = 0; oct < OCTAVES; oct++) {
|
||||||
|
for (let wi = 0; wi < WHITE_PCS.length; wi++) {
|
||||||
|
const pc = WHITE_PCS[wi]
|
||||||
|
const absWi = oct * WHITE_PCS.length + wi
|
||||||
|
const x = absWi * WW + 1
|
||||||
|
const hl = isHighlighted(oct, pc)
|
||||||
|
whites.push({ x, pc, oct, wi, absWi, hl, name: WHITE_NAMES[wi] + (oct + 4) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Black keys
|
||||||
|
const blacks = []
|
||||||
|
for (let oct = 0; oct < OCTAVES; oct++) {
|
||||||
|
for (const { pc, afterWhite } of BLACK_OFFSETS) {
|
||||||
|
const absWi = oct * WHITE_PCS.length + afterWhite
|
||||||
|
const x = absWi * WW + WW - BW / 2
|
||||||
|
const hl = isHighlighted(oct, pc)
|
||||||
|
blacks.push({ x, pc, oct, hl })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={SVG_W} height={SVG_H} viewBox={`0 0 ${SVG_W} ${SVG_H}`} className="overflow-visible">
|
||||||
|
{/* White keys */}
|
||||||
|
{whites.map(({ x, hl, name, absWi }) => (
|
||||||
|
<g key={`w${absWi}`}>
|
||||||
|
<rect
|
||||||
|
x={x} y={1} width={WW - 1} height={WH}
|
||||||
|
rx={2}
|
||||||
|
fill={hl ? hl.color : WHITE_FILL}
|
||||||
|
stroke={WHITE_STROKE}
|
||||||
|
strokeWidth={0.5}
|
||||||
|
/>
|
||||||
|
{hl && (
|
||||||
|
<text x={x + (WW - 1) / 2} y={WH - 8}
|
||||||
|
textAnchor="middle" fill="white" fontSize={7} fontWeight="bold">
|
||||||
|
{hl.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Black keys */}
|
||||||
|
{blacks.map(({ x, pc, oct, hl }, i) => (
|
||||||
|
<g key={`b${oct}-${pc}`}>
|
||||||
|
<rect
|
||||||
|
x={x} y={1} width={BW} height={BH}
|
||||||
|
rx={2}
|
||||||
|
fill={hl ? hl.color : BLACK_FILL}
|
||||||
|
stroke={BLACK_STROKE}
|
||||||
|
strokeWidth={0.5}
|
||||||
|
/>
|
||||||
|
{hl && (
|
||||||
|
<text x={x + BW / 2} y={BH - 5}
|
||||||
|
textAnchor="middle" fill="white" fontSize={6} fontWeight="bold">
|
||||||
|
{hl.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Root label at bottom */}
|
||||||
|
{whites.map(({ x, pc, oct, name, absWi }) => {
|
||||||
|
const isRoot = pc === rootPc && oct === 0
|
||||||
|
if (!isRoot) return null
|
||||||
|
return (
|
||||||
|
<text key={`lbl${absWi}`} x={x + (WW - 1) / 2} y={WH + 14}
|
||||||
|
textAnchor="middle" fill={ACCENT} fontSize={8} fontWeight="bold">
|
||||||
|
R
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// VOICING render path — renders a pianoVoicing({notes,pcs,bass,style,label}).
|
||||||
|
// `notes` are absolute semitone positions, 0 = C of the low octave, range [0,36].
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
function VoicingPiano({ voicing, size }) {
|
||||||
|
const isFull = size === 'full'
|
||||||
|
const isMini = size === 'mini' // L-70: cropped-thumb window at 0.60 for the dashboard 2×2 grid
|
||||||
|
const notes = Array.isArray(voicing?.notes) ? voicing.notes : []
|
||||||
|
const rootPc = ((voicing?.rootPc ?? (notes.length ? notes[0] : 0)) % 12 + 12) % 12
|
||||||
|
const bass = typeof voicing?.bass === 'number' ? voicing.bass : (notes.length ? Math.min(...notes) : null)
|
||||||
|
|
||||||
|
// ── Keyboard window (D-24) ──────────────────────────────────────────────────
|
||||||
|
// `full`: the original wide window — C-anchored, 2–3 octaves, enough to contain
|
||||||
|
// the highest note (byte-identical to the pre-D-24 output).
|
||||||
|
// `thumb`: crop to the voicing's whole-octave span. The window starts at the
|
||||||
|
// octave C at-or-below the lowest sounded key (octStart) and runs whole
|
||||||
|
// octaves until the closing C at-or-above the highest key, so white-key
|
||||||
|
// geometry stays simple and a ≤1-octave voicing renders ONE octave.
|
||||||
|
// The trailing high C is always drawn (window note octStart*12 +
|
||||||
|
// OCTAVES*12), so a top note landing exactly on the octave boundary
|
||||||
|
// still shows without adding a whole extra octave.
|
||||||
|
const maxNote = notes.length ? Math.max(...notes) : 0
|
||||||
|
const minNote = notes.length ? Math.min(...notes) : 0
|
||||||
|
const octStart = isFull ? 0 : Math.floor(minNote / 12)
|
||||||
|
const OCTAVES = isFull
|
||||||
|
? Math.min(3, Math.max(2, Math.ceil((maxNote + 1) / 12)))
|
||||||
|
: Math.max(1, Math.ceil((maxNote - octStart * 12) / 12))
|
||||||
|
// White keys: OCTAVES full octaves + 1 trailing C closing the top octave.
|
||||||
|
const TOTAL_WHITES = WHITE_PCS.length * OCTAVES + 1
|
||||||
|
const scale = isFull ? 1 : isMini ? 0.6 : 0.8
|
||||||
|
const baseW = WW * TOTAL_WHITES + 2
|
||||||
|
const SVG_W = baseW * scale
|
||||||
|
const SVG_H = (WH + (isFull ? 26 : 4)) * scale
|
||||||
|
|
||||||
|
// A highlighted absolute note → its render style. Keyed by absolute note value.
|
||||||
|
const noteSet = new Set(notes)
|
||||||
|
function styleFor(absNote) {
|
||||||
|
if (!noteSet.has(absNote)) return null
|
||||||
|
const pc = ((absNote % 12) + 12) % 12
|
||||||
|
return {
|
||||||
|
isRoot: pc === rootPc,
|
||||||
|
isBass: absNote === bass,
|
||||||
|
color: pc === rootPc ? ACCENT : ACCENT_SOFT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// White keys across OCTAVES octaves + trailing C. `oct` is window-relative
|
||||||
|
// (drives x geometry); absNote adds octStart so highlights land on the right
|
||||||
|
// keys inside a cropped window.
|
||||||
|
const whites = []
|
||||||
|
for (let oct = 0; oct < OCTAVES; oct++) {
|
||||||
|
for (let wi = 0; wi < WHITE_PCS.length; wi++) {
|
||||||
|
const pc = WHITE_PCS[wi]
|
||||||
|
const absWi = oct * WHITE_PCS.length + wi
|
||||||
|
const absNote = (octStart + oct) * 12 + pc // absolute semitone of this white key
|
||||||
|
const x = absWi * WW + 1
|
||||||
|
whites.push({ x, absWi, absNote, hl: styleFor(absNote) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trailing high C (top of the renderable window, e.g. note 36 when OCTAVES=3).
|
||||||
|
{
|
||||||
|
const absWi = OCTAVES * WHITE_PCS.length
|
||||||
|
const absNote = (octStart + OCTAVES) * 12
|
||||||
|
whites.push({ x: absWi * WW + 1, absWi, absNote, hl: styleFor(absNote) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Black keys across OCTAVES octaves.
|
||||||
|
const blacks = []
|
||||||
|
for (let oct = 0; oct < OCTAVES; oct++) {
|
||||||
|
for (const { pc, afterWhite } of BLACK_OFFSETS) {
|
||||||
|
const absWi = oct * WHITE_PCS.length + afterWhite
|
||||||
|
const absNote = (octStart + oct) * 12 + pc
|
||||||
|
const x = absWi * WW + WW - BW / 2
|
||||||
|
blacks.push({ x, pc, oct, absNote, hl: styleFor(absNote) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ariaLabel = `Piano voicing${voicing?.label ? `: ${voicing.label}` : ''}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={SVG_W} height={SVG_H} viewBox={`0 0 ${baseW} ${WH + (isFull ? 26 : 4)}`}
|
||||||
|
className="overflow-visible" role="img" aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{/* White keys */}
|
||||||
|
{whites.map(({ x, hl, absWi }) => (
|
||||||
|
<g key={`w${absWi}`}>
|
||||||
|
<rect
|
||||||
|
x={x} y={1} width={WW - 1} height={WH}
|
||||||
|
rx={2}
|
||||||
|
fill={hl ? hl.color : WHITE_FILL}
|
||||||
|
stroke={WHITE_STROKE}
|
||||||
|
strokeWidth={0.5}
|
||||||
|
/>
|
||||||
|
{hl?.isBass && (
|
||||||
|
<rect
|
||||||
|
x={x + 1} y={2} width={WW - 3} height={WH - 2}
|
||||||
|
rx={2} fill="none" stroke={BASS_RING} strokeWidth={isMini ? 2.5 : 2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hl?.isRoot && (
|
||||||
|
<text x={x + (WW - 1) / 2} y={WH - 8}
|
||||||
|
textAnchor="middle" fill="white" fontSize={isMini ? 10 : 8} fontWeight="bold">
|
||||||
|
R
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Black keys */}
|
||||||
|
{blacks.map(({ x, pc, oct, hl }) => (
|
||||||
|
<g key={`b${oct}-${pc}`}>
|
||||||
|
<rect
|
||||||
|
x={x} y={1} width={BW} height={BH}
|
||||||
|
rx={2}
|
||||||
|
fill={hl ? hl.color : BLACK_FILL}
|
||||||
|
stroke={BLACK_STROKE}
|
||||||
|
strokeWidth={0.5}
|
||||||
|
/>
|
||||||
|
{hl?.isBass && (
|
||||||
|
<rect
|
||||||
|
x={x + 1} y={2} width={BW - 2} height={BH - 2}
|
||||||
|
rx={2} fill="none" stroke={BASS_RING} strokeWidth={isMini ? 2.5 : 2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hl?.isRoot && (
|
||||||
|
<text x={x + BW / 2} y={BH - 5}
|
||||||
|
textAnchor="middle" fill="white" fontSize={isMini ? 8 : 6} fontWeight="bold">
|
||||||
|
R
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Caption (full size only): label + style */}
|
||||||
|
{isFull && voicing?.label && (
|
||||||
|
<text x={baseW / 2} y={WH + 20}
|
||||||
|
textAnchor="middle" fill={ACCENT} fontSize={9} fontWeight="bold">
|
||||||
|
{voicing.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
// Public component — dispatches on whether `voicing` is supplied (additive).
|
||||||
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
export default function MiniPiano({ rootPc, lh = [], rh = [], voicing, size = 'thumb' }) {
|
||||||
|
if (voicing) {
|
||||||
|
return <VoicingPiano voicing={voicing} size={size} />
|
||||||
|
}
|
||||||
|
return <LegacyPiano rootPc={rootPc} lh={lh} rh={rh} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
const MODEL = 'claude-sonnet-4-6'
|
||||||
|
const API_URL = 'https://api.anthropic.com/v1/messages'
|
||||||
|
const LS_KEY = 'wtf_teacher_key'
|
||||||
|
|
||||||
|
// ── System prompt — rebuilt with live session context on every request ────────
|
||||||
|
function buildSystemPrompt({ keyInfo, currentChord, bpm, chordHistory }) {
|
||||||
|
const keyStr = keyInfo ? `${keyInfo.root} ${keyInfo.mode}` : 'not detected yet'
|
||||||
|
const chordStr = currentChord?.name ?? 'none detected'
|
||||||
|
const bpmStr = bpm ? `${Math.round(bpm)} BPM` : 'not detected'
|
||||||
|
const histStr = chordHistory?.length
|
||||||
|
? chordHistory.map(c => c.name).join(' → ')
|
||||||
|
: 'none yet'
|
||||||
|
|
||||||
|
return `You are an expert music teacher and session musician embedded in JamBuddy, a real-time chord and key detection app for guitarists and keyboard players at live jam sessions.
|
||||||
|
|
||||||
|
LIVE SESSION CONTEXT (updated in real time):
|
||||||
|
• Detected key: ${keyStr}
|
||||||
|
• Current chord: ${chordStr}
|
||||||
|
• BPM: ${bpmStr}
|
||||||
|
• Recent chord history: ${histStr}
|
||||||
|
|
||||||
|
YOUR ROLE:
|
||||||
|
- Explain chords, scales, and music theory in plain, friendly language
|
||||||
|
- Suggest what to practice based on the current key and chord progression
|
||||||
|
- Teach playing techniques: fretting, strumming patterns, chord voicings, fingerpicking
|
||||||
|
- Help musicians understand WHY things sound the way they do
|
||||||
|
- Suggest progressions that work with whatever the user is currently playing
|
||||||
|
- Adjust depth to the user — explain basics if they seem new, go deep if they ask for it
|
||||||
|
- Point out interesting connections: "that Dm7 works here because it's the ii chord in C major"
|
||||||
|
|
||||||
|
STYLE:
|
||||||
|
- Keep responses focused and practical — this is a live jam, not a classroom
|
||||||
|
- Use plain text, not markdown. Short paragraphs. Bullet points with "-" are fine.
|
||||||
|
- If someone asks about the current chord or key, use the live context above
|
||||||
|
- Max ~150 words unless someone asks for a deep dive`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quick-action chips ────────────────────────────────────────────────────────
|
||||||
|
const CHIPS = [
|
||||||
|
{ label: 'What should I practice?', msg: 'Based on what I\'m playing right now, what\'s the most useful thing I could practice?' },
|
||||||
|
{ label: 'Explain current chord', msg: 'Explain the current chord I\'m playing — what it is, why it sounds the way it does, and where it tends to appear.' },
|
||||||
|
{ label: 'Scales that work here', msg: 'What scales work over the current key and chord? Which notes sound best to improvise with?' },
|
||||||
|
{ label: 'Suggest a progression', msg: 'Suggest a chord progression that fits the current key. Give me something interesting to try.' },
|
||||||
|
{ label: 'Technique tip', msg: 'Give me one technique tip — something I can work on in the next few minutes to sound better.' },
|
||||||
|
{ label: 'Why does this sound good?', msg: 'Looking at my recent chord history, why do these chords sound good together? What\'s the music theory behind it?' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Simple text renderer (bold + line breaks) ─────────────────────────────────
|
||||||
|
function MessageText({ text }) {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
if (!line.trim()) return <div key={i} className="h-1" />
|
||||||
|
// Bold: **text**
|
||||||
|
const parts = line.split(/(\*\*[^*]+\*\*)/)
|
||||||
|
return (
|
||||||
|
<p key={i} className="leading-relaxed">
|
||||||
|
{parts.map((part, j) =>
|
||||||
|
part.startsWith('**') && part.endsWith('**')
|
||||||
|
? <strong key={j} className="text-white font-semibold">{part.slice(2, -2)}</strong>
|
||||||
|
: part
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
export default function MusicTeacher({ keyInfo, currentChord, bpm, chordHistory }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [apiKey, setApiKey] = useState(() => localStorage.getItem(LS_KEY) ?? '')
|
||||||
|
const [showKeyInput, setShowKeyInput] = useState(false)
|
||||||
|
const [messages, setMessages] = useState([]) // [{role, content}]
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [streaming, setStreaming] = useState('') // partial response being streamed
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const scrollRef = useRef(null)
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
const abortRef = useRef(null)
|
||||||
|
|
||||||
|
// Always scroll to bottom on new content
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [messages, streaming])
|
||||||
|
|
||||||
|
// Focus input when panel opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && apiKey && inputRef.current) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50)
|
||||||
|
}
|
||||||
|
}, [open, apiKey])
|
||||||
|
|
||||||
|
function saveKey(k) {
|
||||||
|
setApiKey(k)
|
||||||
|
localStorage.setItem(LS_KEY, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearKey() {
|
||||||
|
setApiKey('')
|
||||||
|
localStorage.removeItem(LS_KEY)
|
||||||
|
setShowKeyInput(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage = useCallback(async (userText) => {
|
||||||
|
if (!userText.trim() || loading || !apiKey) return
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
const userMsg = { role: 'user', content: userText.trim() }
|
||||||
|
const nextMessages = [...messages, userMsg]
|
||||||
|
setMessages(nextMessages)
|
||||||
|
setInput('')
|
||||||
|
setLoading(true)
|
||||||
|
setStreaming('')
|
||||||
|
|
||||||
|
const context = { keyInfo, currentChord, bpm, chordHistory }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
abortRef.current = ctrl
|
||||||
|
|
||||||
|
const res = await fetch(API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
signal: ctrl.signal,
|
||||||
|
headers: {
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'anthropic-dangerous-direct-browser-access': 'true',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: MODEL,
|
||||||
|
max_tokens: 1024,
|
||||||
|
stream: true,
|
||||||
|
system: buildSystemPrompt(context),
|
||||||
|
messages: nextMessages,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(body?.error?.message ?? `API error ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let full = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
const chunk = decoder.decode(value, { stream: true })
|
||||||
|
for (const line of chunk.split('\n')) {
|
||||||
|
if (!line.startsWith('data: ')) continue
|
||||||
|
const data = line.slice(6).trim()
|
||||||
|
if (data === '[DONE]' || !data) continue
|
||||||
|
try {
|
||||||
|
const ev = JSON.parse(data)
|
||||||
|
if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
|
||||||
|
full += ev.delta.text
|
||||||
|
setStreaming(full)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: full }])
|
||||||
|
setStreaming('')
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
abortRef.current = null
|
||||||
|
}
|
||||||
|
}, [messages, loading, apiKey, keyInfo, currentChord, bpm, chordHistory])
|
||||||
|
|
||||||
|
function stopGeneration() {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
if (streaming) {
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: streaming }])
|
||||||
|
setStreaming('')
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
sendMessage(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasKey = apiKey.trim().length > 0
|
||||||
|
|
||||||
|
// Dot: purple when API key set, gray otherwise
|
||||||
|
const dotClass = hasKey ? 'bg-accent' : 'bg-gray-700'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
|
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-400">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="flex items-center gap-2 hover:text-gray-200 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||||
|
<span>MUSIC TEACHER</span>
|
||||||
|
<span className="text-[11px] text-gray-600">AI · Claude</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Key indicator */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowKeyInput(v => !v)}
|
||||||
|
className="text-[10px] text-gray-600 hover:text-gray-400 transition-colors px-1.5 py-0.5 rounded border border-transparent hover:border-border"
|
||||||
|
title={hasKey ? 'API key set — click to change' : 'Set API key'}
|
||||||
|
>
|
||||||
|
{hasKey ? '🔑 key set' : '🔑 add key'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{open
|
||||||
|
? <polyline points="2,8 6,4 10,8" />
|
||||||
|
: <polyline points="2,4 6,8 10,4" />
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Body ───────────────────────────────────────────────────────────── */}
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-border">
|
||||||
|
|
||||||
|
{/* API key input (shown when no key or user wants to change) */}
|
||||||
|
{(!hasKey || showKeyInput) && (
|
||||||
|
<div className="px-4 py-3 bg-surface/50 border-b border-border">
|
||||||
|
<p className="text-xs text-gray-500 mb-2">
|
||||||
|
Enter your <a className="text-accent underline" href="https://console.anthropic.com/keys" target="_blank" rel="noreferrer">Anthropic API key</a> to enable the music teacher. Stored locally on your device only.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={e => setApiKey(e.target.value)}
|
||||||
|
placeholder="sk-ant-..."
|
||||||
|
className="flex-1 px-2.5 py-1.5 bg-surface border border-border rounded-lg text-xs text-gray-300 focus:outline-none focus:border-accent font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => { saveKey(apiKey); setShowKeyInput(false) }}
|
||||||
|
disabled={!apiKey.trim()}
|
||||||
|
className="px-3 py-1.5 bg-accent text-white text-xs font-bold rounded-lg hover:bg-accent/80 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{hasKey && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowKeyInput(false)}
|
||||||
|
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasKey && (
|
||||||
|
<>
|
||||||
|
{/* Live session context strip */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 border-b border-border text-[10px] font-mono">
|
||||||
|
<span className="text-gray-600 uppercase tracking-wider">Now:</span>
|
||||||
|
{keyInfo ? (
|
||||||
|
<span className="text-accent">{keyInfo.root} {keyInfo.mode}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-700">no key</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-800">·</span>
|
||||||
|
{currentChord ? (
|
||||||
|
<span className="text-white">{currentChord.name}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-700">no chord</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-800">·</span>
|
||||||
|
<span className="text-gray-500">{bpm ? `${Math.round(bpm)} bpm` : '— bpm'}</span>
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setMessages([]); setError(null) }}
|
||||||
|
className="ml-auto text-gray-700 hover:text-gray-400 transition-colors"
|
||||||
|
title="Clear conversation"
|
||||||
|
>
|
||||||
|
clear chat
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat messages */}
|
||||||
|
{messages.length > 0 || streaming ? (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="max-h-72 overflow-y-auto px-4 py-3 space-y-3 text-xs"
|
||||||
|
>
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<div key={i} className={m.role === 'user' ? 'flex justify-end' : ''}>
|
||||||
|
{m.role === 'user' ? (
|
||||||
|
<div className="max-w-[80%] bg-accent/20 border border-accent/30 rounded-xl rounded-tr-sm px-3 py-2 text-gray-200">
|
||||||
|
{m.content}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-300 leading-relaxed">
|
||||||
|
<MessageText text={m.content} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{streaming && (
|
||||||
|
<div className="text-gray-300 text-xs leading-relaxed">
|
||||||
|
<MessageText text={streaming} />
|
||||||
|
<span className="inline-block w-1.5 h-3.5 bg-accent/70 animate-pulse ml-0.5 align-middle" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-400 text-xs bg-red-950/30 border border-red-900/50 rounded-lg px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Quick-action chips (shown when no chat history yet) */
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-[10px] text-gray-700 mb-2 uppercase tracking-wider">Ask something</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{CHIPS.map(chip => (
|
||||||
|
<button
|
||||||
|
key={chip.label}
|
||||||
|
onClick={() => sendMessage(chip.msg)}
|
||||||
|
className="px-2.5 py-1 bg-surface border border-border rounded-full text-[10px] text-gray-400 hover:text-white hover:border-accent/50 hover:bg-accent/10 transition-all"
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input row */}
|
||||||
|
<div className="px-4 py-3 border-t border-border flex gap-2 items-end">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Ask anything about music theory, technique, or what to play…"
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 px-3 py-2 bg-surface border border-border rounded-xl text-xs text-gray-300 placeholder-gray-700 focus:outline-none focus:border-accent resize-none leading-relaxed"
|
||||||
|
style={{ maxHeight: '80px', overflowY: 'auto' }}
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<button
|
||||||
|
onClick={stopGeneration}
|
||||||
|
className="px-3 py-2 bg-surface border border-border text-gray-500 hover:text-red-400 hover:border-red-800 text-xs rounded-xl transition-colors shrink-0"
|
||||||
|
title="Stop"
|
||||||
|
>
|
||||||
|
⏹
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => sendMessage(input)}
|
||||||
|
disabled={!input.trim()}
|
||||||
|
className="px-3 py-2 bg-accent text-white text-xs font-bold rounded-xl hover:bg-accent/80 transition-colors disabled:opacity-40 shrink-0"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick chips after first message */}
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<div className="px-4 pb-3 flex flex-wrap gap-1.5">
|
||||||
|
{CHIPS.slice(0, 4).map(chip => (
|
||||||
|
<button
|
||||||
|
key={chip.label}
|
||||||
|
onClick={() => sendMessage(chip.msg)}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-2 py-0.5 bg-surface border border-border rounded-full text-[9px] text-gray-600 hover:text-gray-300 hover:border-accent/30 transition-all disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+32
-12
@@ -37,7 +37,10 @@ function keyColor(isChordTone, isPenta, isScale, isBlack, mono = false) {
|
|||||||
: { fill: '#f5f5f5', text: '#6b7280' }
|
: { fill: '#f5f5f5', text: '#6b7280' }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Piano({ keyInfo, currentChord, monoColor = false }) {
|
// `compact` (task L-50, one-screen.md §2): trimmed card chrome (p-3, legend
|
||||||
|
// merged onto the heading line) + a natural-width cap on the SVG (max-width =
|
||||||
|
// its viewBox width, so it never renders above scale 1.0).
|
||||||
|
export default function Piano({ keyInfo, currentChord, monoColor = false, compact = false }) {
|
||||||
const { root, mode } = keyInfo ?? {}
|
const { root, mode } = keyInfo ?? {}
|
||||||
if (!root) return null
|
if (!root) return null
|
||||||
|
|
||||||
@@ -51,15 +54,36 @@ export default function Piano({ keyInfo, currentChord, monoColor = false }) {
|
|||||||
const svgW = totalWhite * KEY_W + 2
|
const svgW = totalWhite * KEY_W + 2
|
||||||
const svgH = KEY_H + 20 // +20 for octave labels
|
const svgH = KEY_H + 20 // +20 for octave labels
|
||||||
|
|
||||||
|
const heading = (
|
||||||
|
<p className={`text-sm text-gray-500 uppercase tracking-widest ${compact ? '' : 'mb-4'}`}>
|
||||||
|
Piano — {root} {mode}
|
||||||
|
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
const legend = (
|
||||||
|
// Critic mechanical fix (L-50 gate): non-compact keeps HEAD's exact class
|
||||||
|
// string so the non-compact render stays byte-identical to the committed one.
|
||||||
|
<div className={compact ? 'flex items-center text-xs text-gray-500 flex-wrap gap-3' : 'mt-3 flex gap-5 text-xs text-gray-500'}>
|
||||||
|
<span><span className="text-accent">●</span> Chord tone</span>
|
||||||
|
<span><span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span> Pentatonic</span>
|
||||||
|
<span><span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span> Scale</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
<div className={`bg-panel border border-border rounded-2xl ${compact ? 'p-3' : 'p-6'}`}>
|
||||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
{compact ? (
|
||||||
Piano — {root} {mode}
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
|
||||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
{heading}
|
||||||
</p>
|
{legend}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
heading
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<svg viewBox={`0 0 ${svgW} ${svgH}`} width="100%" height="auto" style={{ display: 'block' }}>
|
<svg viewBox={`0 0 ${svgW} ${svgH}`} width="100%" height="auto" style={{ display: 'block', ...(compact ? { maxWidth: svgW } : null) }}>
|
||||||
|
|
||||||
{/* White keys */}
|
{/* White keys */}
|
||||||
{Array.from({ length: OCTAVES }, (_, oct) =>
|
{Array.from({ length: OCTAVES }, (_, oct) =>
|
||||||
@@ -132,11 +156,7 @@ export default function Piano({ keyInfo, currentChord, monoColor = false }) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
{!compact && legend}
|
||||||
<span><span className="text-accent">●</span> Chord tone</span>
|
|
||||||
<span><span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span> Pentatonic</span>
|
|
||||||
<span><span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span> Scale</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,624 @@
|
|||||||
|
// PianoLickCard — degree-based piano lick renderer (task D-60).
|
||||||
|
//
|
||||||
|
// Renders one structured piano lick from the KB `licks` schema for piano packs
|
||||||
|
// (src/data/kb/SCHEMA.md "Piano licks"): degree-language notes realized over a
|
||||||
|
// LIVE root — the same reasoning as BassPatternCard: the data transposes, the
|
||||||
|
// card shows the actual keys under your hands right now.
|
||||||
|
//
|
||||||
|
// { id, name, level, chordContext, quality, techniques[], source?, tips?,
|
||||||
|
// notes: [{ deg|approach, octave?, beat?, technique? }] } // ordered
|
||||||
|
//
|
||||||
|
// ── Realization (SCHEMA.md rules) ─────────────────────────────────────────────
|
||||||
|
// Pitch space: MiniPiano's absolute-note window, 0 = C3, range [0, 36] — which
|
||||||
|
// is ALSO chordAudio's playVoicing space (its doc: "0 = the C of the low
|
||||||
|
// displayed octave = C3"), so realized notes drop straight into playback.
|
||||||
|
// · The ROOT sits at its pitch class in the bottom octave: rootAbs = rootPc
|
||||||
|
// (0–11). The schema caps every resolved offset at 25 semitones, so the
|
||||||
|
// highest legal note is 11 + 25 = 36 — every lick fits in all 12 keys.
|
||||||
|
// · A `deg` note: abs = rootAbs + resolveDegree(deg, lick.quality) + 12·octave.
|
||||||
|
// `quality` is the lick's own machine truth (NOT the live chord's quality —
|
||||||
|
// the lick states what it resolves through; the caller picks licks that fit).
|
||||||
|
// · An `approach` note derives from the NEXT `deg` note in `notes[]` order
|
||||||
|
// (scanning past intervening approaches): chrom-below = target − 1,
|
||||||
|
// chrom-above = target + 1. Approaches are non-terminal by schema; a lick
|
||||||
|
// ending on an approach (or any unresolvable note) nulls the realization and
|
||||||
|
// the card renders an honest placeholder instead of lying.
|
||||||
|
//
|
||||||
|
// ── Visual design (thumb): pitch-timeline ─────────────────────────────────────
|
||||||
|
// x = beat (proportional columns when every note carries a beat, LickCard-style
|
||||||
|
// even columns otherwise), y = pitch — a piano-roll miniature. Chosen over
|
||||||
|
// order-numbered keyboard dots for the thumb because a lick IS contour + rhythm:
|
||||||
|
// the line shape reads at strip size without decoding numbers. Note markers are
|
||||||
|
// LickCard's surface-pill idiom carrying NOTE NAMES (the actionable info for a
|
||||||
|
// keyboard player), connected by a faint contour line; dashed accent lines mark
|
||||||
|
// the root pitch ("R" gutter label) so "home" is visible at a glance.
|
||||||
|
//
|
||||||
|
// Colour tiers (established language, Fretboard/Piano/BassPatternCard):
|
||||||
|
// chord tone → purple (ACCENT_SOFT text; root itself full ACCENT)
|
||||||
|
// other scale degrees/extensions → gray-200
|
||||||
|
// approaches (borrowed chromatics) → amber, like BassPatternCard
|
||||||
|
// ghost-note → parenthesised, dimmed (LickCard idiom)
|
||||||
|
//
|
||||||
|
// Technique glyphs (amber): grace-note = slashed mini-note crushed before its
|
||||||
|
// target · slide = thicker amber glissando segment replacing that contour hop ·
|
||||||
|
// double-stop = stacked pills at one x (equal beats; or LickCard's column rule
|
||||||
|
// beatless) · ghost-note = the parenthesised dim label. <PianoTechniqueLegend/>
|
||||||
|
// renders the key ONCE per grid (LickCard's TechniqueLegend contract).
|
||||||
|
//
|
||||||
|
// `size="full"` adds: chordContext chip, an order-numbered keyboard view
|
||||||
|
// (MiniPiano's cropped-window geometry), technique chips, tips, source —
|
||||||
|
// mirroring LickCard's full-size behaviour.
|
||||||
|
//
|
||||||
|
// ── No playback (D-70 §3) ─────────────────────────────────────────────────────
|
||||||
|
// The strip is purely visual — glance over audio (the user's settled call, "then
|
||||||
|
// leave them off, better not"). The ▶ play path (sequencer + PlayButton) was
|
||||||
|
// removed with the rest of the ▶s across the app; the card only renders now.
|
||||||
|
//
|
||||||
|
// ── Wiring contract (LicksStrip integration — Luthier, L-71) ──────────────────
|
||||||
|
// <PianoLickCard lick={…} rootPc={0–11} chordLabel="Dm7" size="thumb|full" />
|
||||||
|
// lick — one entry of a piano pack's top-level `licks` array
|
||||||
|
// rootPc — the LIVE chord root pitch class (from the loop station)
|
||||||
|
// chordLabel — optional display name for aria/captions (falls back to
|
||||||
|
// NOTES[rootPc] + quality)
|
||||||
|
// Pure/presentational; never crashes on bad data (placeholder instead).
|
||||||
|
//
|
||||||
|
// Design tokens (tailwind.config.js) — SVG fills can't read Tailwind classes,
|
||||||
|
// so the constants below mirror the tokens (LickCard/MiniPiano convention).
|
||||||
|
|
||||||
|
import { NOTES, CHORD_TYPES } from '../lib/theory'
|
||||||
|
import { resolveDegree } from './JamGuide'
|
||||||
|
|
||||||
|
// ─── Vocabulary (hand-synced with validate-kb.mjs PIANO_LICK_TECHNIQUES; the
|
||||||
|
// smoke §7b-piano guard enforces set-equality — add to BOTH or neither) ────
|
||||||
|
export const PIANO_TECHNIQUE_VOCAB = ['slide', 'double-stop', 'ghost-note', 'grace-note']
|
||||||
|
|
||||||
|
// Chip symbol per technique (technique-tag chips, full size).
|
||||||
|
const TECH_SYMBOL = {
|
||||||
|
slide: '⟋',
|
||||||
|
'double-stop': '⋮',
|
||||||
|
'ghost-note': '( )',
|
||||||
|
'grace-note': '♪',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Palette (mirrors tailwind.config.js tokens + MiniPiano's key colours) ────
|
||||||
|
const ACCENT = '#a855f7' // token `accent` — the root (focal tone)
|
||||||
|
const ACCENT_SOFT = '#c084fc' // lighter accent — chord tones (MiniPiano tier)
|
||||||
|
const AMBER = '#f59e0b' // token `amber` — approaches + technique glyphs
|
||||||
|
const NOTE_TEXT = '#e5e7eb' // gray-200 — non-chord-tone degrees
|
||||||
|
const GHOST_TEXT = '#9ca3af' // gray-400 — ghost notes (dim but AA)
|
||||||
|
const CONTOUR = '#3a3a3a' // contour line (decorative, LickCard string idiom)
|
||||||
|
const LABEL = '#6b7280' // gray-500 — beat numbers / gutter microcopy
|
||||||
|
const CARD_BG = '#0f0f0f' // token `surface` — backing pills
|
||||||
|
const WHITE_FILL = '#f5f5f5' // MiniPiano key colours (keyboard view)
|
||||||
|
const BLACK_FILL = '#1f2937'
|
||||||
|
const WHITE_STROKE = '#374151'
|
||||||
|
const BLACK_STROKE = '#111827'
|
||||||
|
|
||||||
|
const APPROACH_DELTA = { 'chrom-below': -1, 'chrom-above': 1 }
|
||||||
|
const ABS_MAX = 36 // MiniPiano window top key
|
||||||
|
|
||||||
|
const mod12 = (n) => ((n % 12) + 12) % 12
|
||||||
|
|
||||||
|
// abs (0 = C3) → "A3"/"F4"… scientific spelling, for aria pitch lists.
|
||||||
|
const absName = (abs) => `${NOTES[mod12(abs)]}${Math.floor(abs / 12) + 3}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* realizePianoLick(lick, rootPc)
|
||||||
|
* → [{ abs, name, beat?, technique?, approach, chordTone, isRoot }] | null
|
||||||
|
*
|
||||||
|
* Pure realization per the header rules. Returns null on ANY malformed or
|
||||||
|
* out-of-window note so the caller renders a placeholder — authored data is
|
||||||
|
* validator-guaranteed, but a renderer must never crash on bad input.
|
||||||
|
*/
|
||||||
|
export function realizePianoLick(lick, rootPc) {
|
||||||
|
if (!lick || typeof lick !== 'object' || !Number.isFinite(rootPc)) return null
|
||||||
|
const notes = lick.notes
|
||||||
|
if (!Array.isArray(notes) || notes.length === 0) return null
|
||||||
|
const quality = lick.quality
|
||||||
|
const intervals = CHORD_TYPES[quality]?.intervals
|
||||||
|
if (!intervals) return null // quality is required machine truth (schema)
|
||||||
|
const root = mod12(rootPc)
|
||||||
|
const chordPcs = new Set(intervals.map(mod12))
|
||||||
|
|
||||||
|
// Pass 1: resolve every deg note's absolute pitch.
|
||||||
|
const abs = new Array(notes.length).fill(null)
|
||||||
|
for (let i = 0; i < notes.length; i++) {
|
||||||
|
const n = notes[i]
|
||||||
|
if (!n || typeof n !== 'object') return null
|
||||||
|
const isDeg = n.deg !== undefined
|
||||||
|
const isApp = n.approach !== undefined
|
||||||
|
if (isDeg === isApp) return null // exactly one of deg | approach (schema)
|
||||||
|
if (isDeg) {
|
||||||
|
const off = resolveDegree(String(n.deg), quality)
|
||||||
|
if (off === null || off === undefined) return null
|
||||||
|
const oct = n.octave === undefined ? 0 : n.octave
|
||||||
|
if (![0, 1, 2].includes(oct)) return null
|
||||||
|
abs[i] = root + off + 12 * oct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pass 2: approaches derive from the NEXT deg note (schema: never terminal).
|
||||||
|
for (let i = 0; i < notes.length; i++) {
|
||||||
|
if (abs[i] !== null) continue
|
||||||
|
const delta = APPROACH_DELTA[notes[i].approach]
|
||||||
|
if (delta === undefined) return null
|
||||||
|
const target = abs.slice(i + 1).find((a) => a !== null)
|
||||||
|
if (target === undefined) return null // approach with nothing to target
|
||||||
|
abs[i] = target + delta
|
||||||
|
}
|
||||||
|
if (abs.some((a) => a < 0 || a > ABS_MAX)) return null // outside the window
|
||||||
|
|
||||||
|
return notes.map((n, i) => ({
|
||||||
|
abs: abs[i],
|
||||||
|
name: NOTES[mod12(abs[i])],
|
||||||
|
beat: Number.isFinite(n.beat) ? n.beat : undefined,
|
||||||
|
technique: typeof n.technique === 'string' ? n.technique : undefined,
|
||||||
|
approach: n.approach !== undefined,
|
||||||
|
chordTone: !(n.approach !== undefined) && chordPcs.has(mod12(abs[i] - root)),
|
||||||
|
isRoot: !(n.approach !== undefined) && mod12(abs[i] - root) === 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Timeline layout (x = beat/column, y = pitch) ─────────────────────────────
|
||||||
|
|
||||||
|
const PAD_T = 14 // headroom for grace glyphs above the top pill
|
||||||
|
const PAD_B = 8
|
||||||
|
const PAD_L = 16 // gutter for the "R" root-line label
|
||||||
|
const PAD_R = 12
|
||||||
|
const COL_W = 26 // beatless column pitch (LickCard's)
|
||||||
|
const BEAT_ROW = 10
|
||||||
|
const SEMI_MIN = 3 // px per semitone bounds — wide licks compress,
|
||||||
|
const SEMI_MAX = 7 // narrow licks stretch, so contour stays legible
|
||||||
|
|
||||||
|
function layoutLick(realized, rootPc) {
|
||||||
|
const hasBeats =
|
||||||
|
realized.every((n) => Number.isFinite(n.beat)) &&
|
||||||
|
realized.every((n, i) => i === 0 || n.beat >= realized[i - 1].beat)
|
||||||
|
|
||||||
|
// x: proportional to beat when authored (BassPatternCard), else LickCard
|
||||||
|
// columns — where a double-stop stacks into the previous column.
|
||||||
|
let xs, width, maxBeat = null, unit = null
|
||||||
|
if (hasBeats) {
|
||||||
|
const gaps = realized
|
||||||
|
.map((n, i) => (i > 0 ? n.beat - realized[i - 1].beat : 0))
|
||||||
|
.filter((g) => g > 0)
|
||||||
|
const minGap = gaps.length ? Math.min(...gaps) : 1
|
||||||
|
unit = Math.min(COL_W / minGap, COL_W * 4)
|
||||||
|
xs = realized.map((n) => PAD_L + (n.beat - 1) * unit + 10)
|
||||||
|
maxBeat = realized[realized.length - 1].beat
|
||||||
|
width = PAD_L + (maxBeat - 1) * unit + 20 + PAD_R
|
||||||
|
} else {
|
||||||
|
let col = -1
|
||||||
|
xs = realized.map((n, i) => {
|
||||||
|
const stacks = i > 0 && n.technique === 'double-stop'
|
||||||
|
if (!stacks) col++
|
||||||
|
return PAD_L + col * COL_W + COL_W / 2
|
||||||
|
})
|
||||||
|
width = PAD_L + (col + 1) * COL_W + PAD_R
|
||||||
|
}
|
||||||
|
|
||||||
|
// y: linear pitch axis over the lick's own range.
|
||||||
|
const lo = Math.min(...realized.map((n) => n.abs))
|
||||||
|
const hi = Math.max(...realized.map((n) => n.abs))
|
||||||
|
const range = Math.max(1, hi - lo)
|
||||||
|
const semi = Math.min(SEMI_MAX, Math.max(SEMI_MIN, 66 / range))
|
||||||
|
const plotH = Math.max(26, range * semi)
|
||||||
|
const yFor = (a) => PAD_T + plotH - (a - lo) * semi
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasBeats,
|
||||||
|
maxBeat,
|
||||||
|
unit,
|
||||||
|
width,
|
||||||
|
height: PAD_T + plotH + PAD_B + (hasBeats ? BEAT_ROW : 0),
|
||||||
|
plotBottom: PAD_T + plotH,
|
||||||
|
// Root reference lines: every root-pc pitch inside the plotted range.
|
||||||
|
rootYs: Array.from({ length: hi - lo + 1 }, (_, k) => lo + k)
|
||||||
|
.filter((a) => mod12(a) === mod12(rootPc))
|
||||||
|
.map(yFor),
|
||||||
|
notes: realized.map((n, i) => ({
|
||||||
|
...n,
|
||||||
|
x: xs[i],
|
||||||
|
y: yFor(n.abs),
|
||||||
|
label: n.technique === 'ghost-note' ? `(${n.name})` : n.name,
|
||||||
|
ghost: n.technique === 'ghost-note',
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pillColor(n) {
|
||||||
|
if (n.approach) return AMBER
|
||||||
|
if (n.ghost) return GHOST_TEXT
|
||||||
|
if (n.isRoot) return ACCENT
|
||||||
|
if (n.chordTone) return ACCENT_SOFT
|
||||||
|
return NOTE_TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grace ornament: slashed mini-note crushed just before its target pill.
|
||||||
|
function GraceGlyph({ note }) {
|
||||||
|
const cx = note.x - 12
|
||||||
|
const cy = note.y - 8
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<circle cx={cx} cy={cy} r={2} fill={AMBER} />
|
||||||
|
<line x1={cx - 3} y1={cy + 3} x2={cx + 3} y2={cy - 3}
|
||||||
|
stroke={AMBER} strokeWidth={1} strokeLinecap="round" />
|
||||||
|
<path d={`M ${cx + 2.5} ${cy} Q ${(cx + note.x) / 2} ${cy - 4} ${note.x - 6} ${note.y - 4}`}
|
||||||
|
fill="none" stroke={AMBER} strokeWidth={0.9} />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineSvg({ layout, ariaLabel }) {
|
||||||
|
const { notes, width, height, hasBeats, maxBeat, unit, plotBottom, rootYs } = layout
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
width="100%"
|
||||||
|
style={{ display: 'block', height: 'auto', maxWidth: width * 1.4 }}
|
||||||
|
role="img"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{/* Root ("home") reference lines + gutter label */}
|
||||||
|
{rootYs.map((y, i) => (
|
||||||
|
<g key={`r${i}`}>
|
||||||
|
<line x1={PAD_L - 4} y1={y} x2={width - PAD_R + 4} y2={y}
|
||||||
|
stroke={ACCENT} strokeOpacity={0.3} strokeWidth={1} strokeDasharray="3 3" />
|
||||||
|
<text x={6} y={y + 2.5} textAnchor="middle" fontSize={6.5} fill={ACCENT} fillOpacity={0.75}>
|
||||||
|
R
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Beat numbers (only when beats are authored; x mirrors note columns) */}
|
||||||
|
{hasBeats &&
|
||||||
|
Array.from({ length: Math.floor(maxBeat + 1e-6) }, (_, b) => (
|
||||||
|
<text key={`b${b}`} x={PAD_L + b * unit + 10} y={plotBottom + BEAT_ROW}
|
||||||
|
textAnchor="middle" fontSize={6.5} fill={LABEL}>
|
||||||
|
{b + 1}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Contour: connect consecutive notes when x advances (dyad partners
|
||||||
|
float stacked); a slide hop redraws its segment as a thicker amber
|
||||||
|
glissando line. */}
|
||||||
|
{notes.map((n, i) => {
|
||||||
|
const prev = notes[i - 1]
|
||||||
|
if (!prev || n.x <= prev.x) return null
|
||||||
|
const slide = n.technique === 'slide'
|
||||||
|
return (
|
||||||
|
<line key={`c${i}`}
|
||||||
|
x1={prev.x} y1={prev.y} x2={n.x} y2={n.y}
|
||||||
|
stroke={slide ? AMBER : CONTOUR}
|
||||||
|
strokeWidth={slide ? 1.6 : 1}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Grace ornaments (above the pills) */}
|
||||||
|
{notes.map((n, i) => (n.technique === 'grace-note' ? <GraceGlyph key={`g${i}`} note={n} /> : null))}
|
||||||
|
|
||||||
|
{/* Note-name pills at pitch height — LickCard's fret-pill idiom */}
|
||||||
|
{notes.map((n, i) => {
|
||||||
|
const w = n.label.length * 5.2 + 4
|
||||||
|
return (
|
||||||
|
<g key={`n${i}`}>
|
||||||
|
<rect x={n.x - w / 2} y={n.y - 5.5} width={w} height={11} rx={2} fill={CARD_BG} />
|
||||||
|
<text x={n.x} y={n.y + 3.2} textAnchor="middle" fontSize={9}
|
||||||
|
fontWeight="600" fill={pillColor(n)}>
|
||||||
|
{n.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Keyboard view (full size): which keys, in what order ────────────────────
|
||||||
|
// MiniPiano's cropped-window geometry (whole-octave span + trailing high C),
|
||||||
|
// replicated here because order badges aren't part of MiniPiano's contract.
|
||||||
|
|
||||||
|
const WW = 22
|
||||||
|
const WH = 60
|
||||||
|
const BW = 14
|
||||||
|
const BH = 38
|
||||||
|
const WHITE_PCS = [0, 2, 4, 5, 7, 9, 11]
|
||||||
|
const BLACK_OFFSETS = [
|
||||||
|
{ pc: 1, afterWhite: 0 }, { pc: 3, afterWhite: 1 }, { pc: 6, afterWhite: 3 },
|
||||||
|
{ pc: 8, afterWhite: 4 }, { pc: 10, afterWhite: 5 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function LickKeyboard({ realized, ariaLabel }) {
|
||||||
|
// abs → { orders: [1-based strike order…], tier }
|
||||||
|
const hits = new Map()
|
||||||
|
realized.forEach((n, i) => {
|
||||||
|
const h = hits.get(n.abs) ?? { orders: [], isRoot: false, deg: false, approach: false }
|
||||||
|
h.orders.push(i + 1)
|
||||||
|
h.isRoot ||= n.isRoot
|
||||||
|
h.deg ||= !n.approach
|
||||||
|
h.approach ||= n.approach
|
||||||
|
hits.set(n.abs, h)
|
||||||
|
})
|
||||||
|
const lo = Math.min(...realized.map((n) => n.abs))
|
||||||
|
const hi = Math.max(...realized.map((n) => n.abs))
|
||||||
|
const octStart = Math.floor(lo / 12)
|
||||||
|
const OCTAVES = Math.max(1, Math.ceil((hi - octStart * 12) / 12))
|
||||||
|
const TOTAL_WHITES = WHITE_PCS.length * OCTAVES + 1
|
||||||
|
const baseW = WW * TOTAL_WHITES + 2
|
||||||
|
const scale = 0.85
|
||||||
|
|
||||||
|
const fillFor = (h) => (h.deg ? (h.isRoot ? ACCENT : ACCENT_SOFT) : AMBER)
|
||||||
|
|
||||||
|
const whites = []
|
||||||
|
for (let oct = 0; oct < OCTAVES; oct++) {
|
||||||
|
for (let wi = 0; wi < WHITE_PCS.length; wi++) {
|
||||||
|
const absWi = oct * WHITE_PCS.length + wi
|
||||||
|
whites.push({ x: absWi * WW + 1, absWi, absNote: (octStart + oct) * 12 + WHITE_PCS[wi] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
whites.push({
|
||||||
|
x: OCTAVES * WHITE_PCS.length * WW + 1,
|
||||||
|
absWi: OCTAVES * WHITE_PCS.length,
|
||||||
|
absNote: (octStart + OCTAVES) * 12,
|
||||||
|
})
|
||||||
|
const blacks = []
|
||||||
|
for (let oct = 0; oct < OCTAVES; oct++) {
|
||||||
|
for (const { pc, afterWhite } of BLACK_OFFSETS) {
|
||||||
|
blacks.push({
|
||||||
|
x: (oct * WHITE_PCS.length + afterWhite) * WW + WW - BW / 2,
|
||||||
|
key: `${oct}-${pc}`,
|
||||||
|
absNote: (octStart + oct) * 12 + pc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={baseW * scale} height={(WH + 4) * scale} viewBox={`0 0 ${baseW} ${WH + 4}`}
|
||||||
|
className="overflow-visible" role="img" aria-label={ariaLabel}>
|
||||||
|
{whites.map(({ x, absWi, absNote }) => {
|
||||||
|
const h = hits.get(absNote)
|
||||||
|
return (
|
||||||
|
<g key={`w${absWi}`}>
|
||||||
|
<rect x={x} y={1} width={WW - 1} height={WH} rx={2}
|
||||||
|
fill={h ? fillFor(h) : WHITE_FILL} stroke={WHITE_STROKE} strokeWidth={0.5} />
|
||||||
|
{h && (
|
||||||
|
<text x={x + (WW - 1) / 2} y={WH - 6} textAnchor="middle"
|
||||||
|
fontSize={7} fontWeight="bold" fill={h.deg ? 'white' : 'black'}>
|
||||||
|
{h.orders.join('·')}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{blacks.map(({ x, key, absNote }) => {
|
||||||
|
const h = hits.get(absNote)
|
||||||
|
return (
|
||||||
|
<g key={`b${key}`}>
|
||||||
|
<rect x={x} y={1} width={BW} height={BH} rx={2}
|
||||||
|
fill={h ? fillFor(h) : BLACK_FILL} stroke={BLACK_STROKE} strokeWidth={0.5} />
|
||||||
|
{h && (
|
||||||
|
<text x={x + BW / 2} y={BH - 5} textAnchor="middle"
|
||||||
|
fontSize={6} fontWeight="bold" fill={h.deg ? 'white' : 'black'}>
|
||||||
|
{h.orders.join('·')}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card chrome (LickCard's idiom) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function LevelBadge({ level }) {
|
||||||
|
if (level === 'intermediate') {
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 text-[9px] uppercase tracking-wide font-semibold text-amber border border-amber/40 rounded px-1.5 py-px">
|
||||||
|
intermediate
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (level === 'foundation') {
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 text-[9px] uppercase tracking-wide font-semibold text-gray-400 border border-border rounded px-1.5 py-px">
|
||||||
|
foundation
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function TechniqueChip({ tech }) {
|
||||||
|
const symbol = TECH_SYMBOL[tech]
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] text-gray-400 border border-border rounded-full px-1.5 py-px">
|
||||||
|
{symbol && <span className="text-amber font-semibold" aria-hidden="true">{symbol}</span>}
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceholderCard({ name, size }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-surface border border-border rounded-lg flex flex-col items-center justify-center text-gray-500 ${size === 'thumb' ? 'p-2 min-h-[72px]' : 'p-4 min-h-[110px]'}`}
|
||||||
|
role="group"
|
||||||
|
aria-label={name ? `${name}: lick unavailable` : 'lick unavailable'}
|
||||||
|
>
|
||||||
|
<span className="text-lg leading-none" aria-hidden="true">—</span>
|
||||||
|
<span className="text-[10px] mt-1">
|
||||||
|
{name ? `${name} — lick unavailable` : 'lick unavailable'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <PianoLickCard lick={…} rootPc={0–11} chordLabel? size="thumb"|"full" />
|
||||||
|
* Pure/presentational — realizes one KB piano lick over the live chord root;
|
||||||
|
* never crashes on bad data. See the header for the full wiring contract.
|
||||||
|
*/
|
||||||
|
export default function PianoLickCard({ lick, rootPc, chordLabel, size = 'full' }) {
|
||||||
|
const realized = realizePianoLick(lick, rootPc)
|
||||||
|
const name = typeof lick?.name === 'string' && lick.name.trim() ? lick.name : 'Untitled lick'
|
||||||
|
|
||||||
|
if (!realized) {
|
||||||
|
return <PlaceholderCard name={lick ? name : null} size={size} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const full = size === 'full'
|
||||||
|
const chordName =
|
||||||
|
typeof chordLabel === 'string' && chordLabel.trim()
|
||||||
|
? chordLabel
|
||||||
|
: `${NOTES[mod12(rootPc)]}${CHORD_TYPES[lick.quality]?.suffix ?? lick.quality}`
|
||||||
|
const chordContext =
|
||||||
|
typeof lick.chordContext === 'string' && lick.chordContext.trim() ? lick.chordContext : null
|
||||||
|
const techniques = Array.isArray(lick.techniques)
|
||||||
|
? lick.techniques.filter((t) => typeof t === 'string' && t.trim())
|
||||||
|
: []
|
||||||
|
const source = typeof lick.source === 'string' && lick.source.trim() ? lick.source : null
|
||||||
|
const tips = typeof lick.tips === 'string' && lick.tips.trim() ? lick.tips : null
|
||||||
|
|
||||||
|
const layout = layoutLick(realized, rootPc)
|
||||||
|
const pitchNames = realized.map((n) => absName(n.abs)).join(', ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-surface border border-border rounded-lg flex flex-col ${full ? 'p-3 gap-2' : 'p-2 gap-1.5'}`}
|
||||||
|
role="group"
|
||||||
|
aria-label={`${name} — over ${chordName}${chordContext ? ` (${chordContext})` : ''}`}
|
||||||
|
>
|
||||||
|
{/* Header: name + level badge */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className={`text-gray-200 font-semibold leading-tight ${full ? 'text-sm' : 'text-[11px]'}`}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<LevelBadge level={lick.level} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Where it lands */}
|
||||||
|
{full && chordContext && (
|
||||||
|
<div>
|
||||||
|
<span className="inline-block text-[10px] font-medium text-accent bg-accent/10 border border-accent/40 rounded-full px-2 py-px">
|
||||||
|
{chordContext} · here: {chordName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The pitch timeline */}
|
||||||
|
<div className="max-w-full overflow-x-auto dark-scroll">
|
||||||
|
<TimelineSvg
|
||||||
|
layout={layout}
|
||||||
|
ariaLabel={`Melody for ${name} over ${chordName}: ${pitchNames}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard view (full): the actual keys, numbered in strike order */}
|
||||||
|
{full && (
|
||||||
|
<div className="max-w-full overflow-x-auto dark-scroll">
|
||||||
|
<LickKeyboard
|
||||||
|
realized={realized}
|
||||||
|
ariaLabel={`Keys for ${name} over ${chordName}, numbered in playing order`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Technique tags */}
|
||||||
|
{full && techniques.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{techniques.map((t) => <TechniqueChip key={t} tech={t} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transferable idea + attribution */}
|
||||||
|
{full && tips && <p className="text-[10px] leading-snug text-gray-500">{tips}</p>}
|
||||||
|
{full && source && (
|
||||||
|
<span className="text-[10px] text-gray-500 italic leading-snug">{source}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Glyph legend — render ONCE per lick grid (LickCard contract) ─────────────
|
||||||
|
|
||||||
|
function LegendSample({ children, w = 22 }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${w} 18`} width={w} height={18} aria-hidden="true"
|
||||||
|
style={{ display: 'inline-block', verticalAlign: 'middle' }}>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PianoTechniqueLegend() {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: 'grace-note', label: 'grace note',
|
||||||
|
sample: (
|
||||||
|
<LegendSample>
|
||||||
|
<circle cx={5} cy={7} r={2} fill={AMBER} />
|
||||||
|
<line x1={2} y1={10} x2={8} y2={4} stroke={AMBER} strokeWidth={1} strokeLinecap="round" />
|
||||||
|
<path d="M 7 8 Q 12 5 16 10" fill="none" stroke={AMBER} strokeWidth={0.9} />
|
||||||
|
<circle cx={17} cy={12} r={3} fill={NOTE_TEXT} />
|
||||||
|
</LegendSample>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'slide', label: 'slide (key-slip)',
|
||||||
|
sample: (
|
||||||
|
<LegendSample>
|
||||||
|
<line x1={4} y1={13} x2={18} y2={5} stroke={AMBER} strokeWidth={1.6} strokeLinecap="round" />
|
||||||
|
</LegendSample>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'double-stop', label: 'double-stop (stacked)',
|
||||||
|
sample: (
|
||||||
|
<LegendSample>
|
||||||
|
<text x={11} y={8} textAnchor="middle" fontSize={8} fontWeight="600" fill={NOTE_TEXT}>G</text>
|
||||||
|
<text x={11} y={17} textAnchor="middle" fontSize={8} fontWeight="600" fill={NOTE_TEXT}>D</text>
|
||||||
|
</LegendSample>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ghost-note', label: 'ghost note',
|
||||||
|
sample: (
|
||||||
|
<LegendSample>
|
||||||
|
<text x={11} y={12} textAnchor="middle" fontSize={9} fontWeight="600" fill={GHOST_TEXT}>(E)</text>
|
||||||
|
</LegendSample>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[11px] text-gray-400">
|
||||||
|
{items.map((it) => (
|
||||||
|
<span key={it.key} className="inline-flex items-center gap-1.5">
|
||||||
|
{it.sample}
|
||||||
|
{it.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="text-gray-500">amber = chromatic approach · purple = chord tone · R line = the root</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dev fixture — SCHEMA.md's worked enclosure (P-60 real data replaces this
|
||||||
|
// in the app; exported so the card can be exercised before P-60 lands) ─────
|
||||||
|
|
||||||
|
export const DEMO_PIANO_LICK = {
|
||||||
|
id: 'jazz-enclosure-into-3',
|
||||||
|
name: 'Bebop enclosure into the 3rd',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'over the ii7',
|
||||||
|
quality: 'min7',
|
||||||
|
techniques: ['grace-note'],
|
||||||
|
source: 'Barry Harris workshop vocabulary',
|
||||||
|
notes: [
|
||||||
|
{ deg: '5', octave: 1, beat: 1 },
|
||||||
|
{ approach: 'chrom-above', beat: 2 },
|
||||||
|
{ approach: 'chrom-below', beat: 2.5 },
|
||||||
|
{ deg: '3', octave: 1, beat: 3, technique: 'grace-note' },
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -1,29 +1,30 @@
|
|||||||
import { useRef, useEffect } from 'react'
|
import { useRef, useEffect } from 'react'
|
||||||
import { toRomanNumeral } from '../lib/theory'
|
import { toRomanNumeral } from '../lib/theory'
|
||||||
|
import { findLoopPosition } from '../lib/match'
|
||||||
|
|
||||||
const HISTORY_SHOWN = 8
|
// ─── ProgressionBanner — the SLIM LOOP STRIP (task L-50, one-screen.md §1.2) ──
|
||||||
|
//
|
||||||
|
// One full-width status row: key chip · chord history (last 5, age-faded) ·
|
||||||
|
// ♻ loop chips. The old right-30% "Now Playing" text-6xl chord + its divider
|
||||||
|
// are GONE per the user directive — the enlarged current history chip and the
|
||||||
|
// accent-glowing active loop chip ARE the now-playing display. Chrome trimmed
|
||||||
|
// (p-4 → p-2, history pb-1 dropped) so the strip lands at ~76px.
|
||||||
|
const HISTORY_SHOWN = 5
|
||||||
|
|
||||||
function findLoopPosition(chordHistory, progression) {
|
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, seedInfo, onChordClick }) {
|
||||||
if (!progression?.length || !chordHistory.length) return -1
|
|
||||||
const last = chordHistory[chordHistory.length - 1]
|
|
||||||
for (let p = progression.length - 1; p >= 0; p--) {
|
|
||||||
if (progression[p] !== last) continue
|
|
||||||
let match = true
|
|
||||||
for (let i = 1; i < Math.min(p + 1, chordHistory.length); i++) {
|
|
||||||
if (progression[p - i] !== chordHistory[chordHistory.length - 1 - i]) { match = false; break }
|
|
||||||
}
|
|
||||||
if (match) return p
|
|
||||||
}
|
|
||||||
return progression.indexOf(last)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, currentChord }) {
|
|
||||||
const { root, mode, confidence } = keyInfo ?? {}
|
const { root, mode, confidence } = keyInfo ?? {}
|
||||||
|
|
||||||
|
// Jam-roulette provenance (task L-60, jam-roulette.md §1.3): while a rolled
|
||||||
|
// loop is unconfirmed the loop row swaps its ♻ chrome for 🎲 + an amber chip
|
||||||
|
// naming the source; it flips back to normal the moment live detection
|
||||||
|
// confirms it (App clears seedInfo). The loop chips themselves are identical.
|
||||||
|
const seedBars = Array.isArray(seedInfo?.bars)
|
||||||
|
? seedInfo.bars.reduce((a, b) => a + (b ?? 0), 0)
|
||||||
|
: null
|
||||||
|
|
||||||
const visible = chordHistory.slice(-HISTORY_SHOWN)
|
const visible = chordHistory.slice(-HISTORY_SHOWN)
|
||||||
const current = visible[visible.length - 1]
|
const current = visible[visible.length - 1]
|
||||||
const loopPos = findLoopPosition(chordHistory, detectedProgression)
|
const loopPos = findLoopPosition(chordHistory, detectedProgression)
|
||||||
const currentRN = root && current ? toRomanNumeral(current, root, mode) : ''
|
|
||||||
|
|
||||||
const currentRef = useRef(null)
|
const currentRef = useRef(null)
|
||||||
const prevChord = useRef(null)
|
const prevChord = useRef(null)
|
||||||
@@ -38,80 +39,80 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
|||||||
}, [current])
|
}, [current])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-panel border border-border rounded-2xl p-4 mb-3 flex flex-col-reverse lg:flex-row gap-4">
|
<div className="bg-panel border border-border rounded-2xl p-2 mb-3 flex flex-wrap items-end gap-x-3 gap-y-1">
|
||||||
|
|
||||||
{/* ── Left: key + chord history + loop ── */}
|
{/* ── Key chip ── */}
|
||||||
<div className="w-full lg:w-[70%] min-w-0 flex flex-col gap-2">
|
<div className="shrink-0 flex items-baseline gap-1.5 self-center">
|
||||||
|
{root ? (
|
||||||
{/* Key + history on one row */}
|
<>
|
||||||
<div className="flex items-end gap-3">
|
<span className="text-2xl font-bold text-accent">{root}</span>
|
||||||
<div className="shrink-0 flex items-baseline gap-1.5">
|
<span className="text-gray-400 text-sm">{mode}</span>
|
||||||
{root ? (
|
{confidence && (
|
||||||
<>
|
<span className="text-xs text-gray-600">{Math.round(confidence * 100)}%</span>
|
||||||
<span className="text-2xl font-bold text-accent">{root}</span>
|
|
||||||
<span className="text-gray-400 text-sm">{mode}</span>
|
|
||||||
{confidence && (
|
|
||||||
<span className="text-xs text-gray-600">{Math.round(confidence * 100)}%</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-600 text-sm">Detecting key…</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600 text-sm">Detecting key…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="w-px h-6 bg-border shrink-0" />
|
<div className="w-px h-6 bg-border shrink-0 self-center" />
|
||||||
|
|
||||||
{!chordHistory.length ? (
|
{/* ── Chord history (the enlarged current chip is the "now") ── */}
|
||||||
<p className="text-gray-600 text-sm">Start listening…</p>
|
{!chordHistory.length ? (
|
||||||
) : (
|
<p className="text-gray-600 text-sm self-center">Start listening…</p>
|
||||||
<div className="flex items-end gap-1 overflow-x-auto pb-1">
|
) : (
|
||||||
{visible.map((chord, i) => {
|
<div className="flex items-end gap-1 overflow-x-auto">
|
||||||
const isCurrent = i === visible.length - 1
|
{visible.map((chord, i) => {
|
||||||
const age = visible.length - 1 - i
|
const isCurrent = i === visible.length - 1
|
||||||
const opacity = Math.max(0.25, 1 - age * 0.09)
|
const age = visible.length - 1 - i
|
||||||
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
const opacity = Math.max(0.25, 1 - age * 0.09)
|
||||||
return (
|
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
||||||
<div
|
return (
|
||||||
key={i}
|
<div
|
||||||
ref={isCurrent ? currentRef : null}
|
key={i}
|
||||||
style={{ opacity }}
|
ref={isCurrent ? currentRef : null}
|
||||||
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 ${
|
style={{ opacity }}
|
||||||
isCurrent
|
onClick={() => onChordClick?.(chord)}
|
||||||
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20'
|
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 cursor-pointer ${
|
||||||
: 'border border-transparent'
|
isCurrent
|
||||||
}`}
|
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20 hover:bg-accent/20'
|
||||||
>
|
: 'border border-transparent hover:border-border hover:bg-panel'
|
||||||
<span className={`font-black leading-none tracking-tight ${
|
}`}
|
||||||
isCurrent ? 'text-3xl text-accent' : 'text-xl text-gray-200'
|
>
|
||||||
}`}>
|
<span className={`font-black leading-none tracking-tight ${
|
||||||
{chord}
|
isCurrent ? 'text-3xl text-accent' : 'text-xl text-gray-200'
|
||||||
</span>
|
}`}>
|
||||||
<span className={`text-xs font-semibold mt-0.5 ${
|
{chord}
|
||||||
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
</span>
|
||||||
}`}>
|
<span className={`text-xs font-semibold mt-0.5 ${
|
||||||
{rn || '\u00A0'}
|
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
||||||
</span>
|
}`}>
|
||||||
</div>
|
{rn || ' '}
|
||||||
)
|
</span>
|
||||||
})}
|
</div>
|
||||||
</div>
|
)
|
||||||
)}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Loop */}
|
{/* ── Loop — on the same row (divider between); playhead chip = the now ── */}
|
||||||
{detectedProgression && (
|
{detectedProgression && (
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<>
|
||||||
<span className="text-xs text-gray-500">♻</span>
|
<div className="w-px h-6 bg-border shrink-0 self-center" />
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap self-center">
|
||||||
|
<span className="text-xs text-gray-500">{seedInfo ? '🎲' : '♻'}</span>
|
||||||
{detectedProgression.map((chord, i) => {
|
{detectedProgression.map((chord, i) => {
|
||||||
const isActive = i === loopPos
|
const isActive = i === loopPos
|
||||||
const rn = root ? toRomanNumeral(chord, root, mode) : chord
|
const rn = root ? toRomanNumeral(chord, root, mode) : chord
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`flex flex-col items-center px-2 py-0.5 rounded-lg border transition-all duration-200 ${
|
onClick={() => onChordClick?.(chord)}
|
||||||
|
className={`flex flex-col items-center px-2 py-0.5 rounded-lg border transition-all duration-200 cursor-pointer ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-accent/20 border-accent shadow-[0_0_10px_rgba(168,85,247,0.3)]'
|
? 'bg-accent/20 border-accent shadow-[0_0_10px_rgba(168,85,247,0.3)] hover:bg-accent/30'
|
||||||
: 'bg-border border-border'
|
: 'bg-border border-border hover:border-gray-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className={`text-sm font-bold leading-none ${isActive ? 'text-accent' : 'text-gray-300'}`}>
|
<span className={`text-sm font-bold leading-none ${isActive ? 'text-accent' : 'text-gray-300'}`}>
|
||||||
@@ -121,26 +122,17 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<span className="text-gray-600 text-xs">→ loop</span>
|
{seedInfo ? (
|
||||||
|
<span className="text-amber-400 text-xs">
|
||||||
|
rolled · {seedInfo.styleLabel} · {seedInfo.name}
|
||||||
|
{seedBars != null ? ` · ${seedBars} bars` : ''} — play it!
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600 text-xs">→ loop</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* ── Divider ── */}
|
|
||||||
<div className="hidden lg:block w-px bg-border shrink-0" />
|
|
||||||
|
|
||||||
{/* ── Right: big chord ── */}
|
|
||||||
<div className="flex w-full lg:w-[30%] flex-col items-center justify-center gap-1 py-2 lg:py-0">
|
|
||||||
{current ? (
|
|
||||||
<>
|
|
||||||
<p className="text-xs text-gray-600 uppercase tracking-widest">Now Playing</p>
|
|
||||||
<div className="text-6xl font-black text-amber-400 leading-none">{current}</div>
|
|
||||||
<div className="text-sm text-gray-500">{currentRN}</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-gray-600 text-xs text-center">Play a chord</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import kb from '../data/kb/index.js'
|
||||||
|
import { NOTES, CHORD_TYPES } from '../lib/theory'
|
||||||
|
import {
|
||||||
|
buildLoopIndex,
|
||||||
|
matchLoopToProgression,
|
||||||
|
loopToDegrees,
|
||||||
|
canonicalDegrees,
|
||||||
|
chordRootPC,
|
||||||
|
} from '../lib/match'
|
||||||
|
|
||||||
|
// ─── RelatedProgressions — loop-relative songbook relatives (task L-51) ───────
|
||||||
|
//
|
||||||
|
// 3–5 KB progressions genuinely related to the DETECTED loop, replacing the
|
||||||
|
// retired generic ProgressionSuggestions table. KB-sourced only, loop-relative
|
||||||
|
// by construction. Spec: docs/design/one-screen.md §5 (ranking + render + empty
|
||||||
|
// states) and §6 (component boundary: pure/prop-driven, computes its own match
|
||||||
|
// so it stays file-disjoint from D-51's rail work).
|
||||||
|
//
|
||||||
|
// Props:
|
||||||
|
// loop : string[] | null — the detected repeating progression (chord names)
|
||||||
|
// keyInfo : { root, mode, confidence } | null — effective key (realizes chips)
|
||||||
|
// onChordClick : fn(chordName) — opens ChordDetailModal (App's setSelectedChord)
|
||||||
|
//
|
||||||
|
// The ranking (rankRelatedProgressions) is exported for smoke coverage.
|
||||||
|
|
||||||
|
// Suffix → KB quality token, derived from CHORD_TYPES (the vocabulary
|
||||||
|
// authority — same inverse mapping as match.js's private suffixToQuality; all
|
||||||
|
// 14 suffixes are unique). Unknown suffixes stay unmapped → wildcard (§5:
|
||||||
|
// match on Δ alone).
|
||||||
|
const SUFFIX_TO_QUALITY = Object.fromEntries(
|
||||||
|
Object.entries(CHORD_TYPES).map(([quality, def]) => [def.suffix, quality])
|
||||||
|
)
|
||||||
|
|
||||||
|
// §5 scoring constants.
|
||||||
|
const SCORE_SAME_CHANGES = 100 // same canonical degree shape
|
||||||
|
const SCORE_SAME_STYLE = 40 // same style as the matched progression
|
||||||
|
const SCORE_PER_TRANSITION = 12 // per shared (Δ, quality→quality) transition…
|
||||||
|
const TRANSITION_CAP = 36 // …capped (= 3 distinct transitions)
|
||||||
|
const SCORE_JACCARD = 16 // × rebased degree-set Jaccard
|
||||||
|
export const RELATED_SCORE_FLOOR = 24
|
||||||
|
export const RELATED_MAX_ENTRIES = 5
|
||||||
|
|
||||||
|
// The module-level index (§6: "matchLoopToProgression over a module-level
|
||||||
|
// index is trivially cheap") — built once, shared across renders.
|
||||||
|
const DEFAULT_INDEX = buildLoopIndex(kb)
|
||||||
|
|
||||||
|
// ─── The MANDATORY collapse step (§5 step 2) ──────────────────────────────────
|
||||||
|
// The live detected loop is a COLLAPSED form (detection never commits the same
|
||||||
|
// chord twice in a row), while KB `degrees` are raw, bar-per-bar — blues-12bar
|
||||||
|
// is [0,0,0,0,5,5,0,0,7,5,0,7]. Compared raw, the +100 same-shape term would
|
||||||
|
// never fire for collapse-affected KB progressions. So before
|
||||||
|
// canonicalDegrees(p.degrees) AND before building T(p): collapse consecutive
|
||||||
|
// equal (degree, quality) pairs — including the wrap-around pair (last === first
|
||||||
|
// as a cycle). Each surviving unit keeps the rn of its first bar for the
|
||||||
|
// "shares {rn}→{rn}" annotation.
|
||||||
|
// (L-60 will land a shared collapsed-form index in match.js; this local
|
||||||
|
// collapse is deliberately component-scoped until then — one-screen.md §9.)
|
||||||
|
export function collapseChanges(progression) {
|
||||||
|
const degrees = Array.isArray(progression?.degrees) ? progression.degrees : []
|
||||||
|
const qualities = Array.isArray(progression?.qualities) ? progression.qualities : []
|
||||||
|
const rn = Array.isArray(progression?.rn) ? progression.rn : []
|
||||||
|
const out = []
|
||||||
|
for (let i = 0; i < degrees.length; i++) {
|
||||||
|
const q = qualities[i] ?? null
|
||||||
|
const prev = out[out.length - 1]
|
||||||
|
if (prev && prev.deg === degrees[i] && prev.quality === q) continue
|
||||||
|
out.push({ deg: degrees[i], quality: q, rn: rn[i] ?? '' })
|
||||||
|
}
|
||||||
|
if (out.length > 1) {
|
||||||
|
const first = out[0]
|
||||||
|
const last = out[out.length - 1]
|
||||||
|
if (first.deg === last.deg && first.quality === last.quality) out.pop()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition set over units [{deg, quality, rn?}], wrap-around included (§5):
|
||||||
|
// triples (Δ = (deg[i+1] − deg[i]) mod 12, q[i], q[i+1]); rn labels ride along
|
||||||
|
// for the annotation.
|
||||||
|
function transitionsOf(units) {
|
||||||
|
const n = units.length
|
||||||
|
if (n < 2) return []
|
||||||
|
const out = []
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = units[i]
|
||||||
|
const b = units[(i + 1) % n]
|
||||||
|
out.push({
|
||||||
|
d: (((b.deg - a.deg) % 12) + 12) % 12,
|
||||||
|
qa: a.quality,
|
||||||
|
qb: b.quality,
|
||||||
|
rnFrom: a.rn ?? '',
|
||||||
|
rnTo: b.rn ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live-loop units: degree from loopToDegrees, quality from the chord-name
|
||||||
|
// suffix (unmappable → null = wildcard, matches on Δ alone).
|
||||||
|
function loopUnitsOf(loop, loopDeg) {
|
||||||
|
return loop.map((name, i) => {
|
||||||
|
const m = typeof name === 'string' ? name.match(/^[A-G][b#]?(.*)$/) : null
|
||||||
|
const suffix = m ? m[1] : null
|
||||||
|
return { deg: loopDeg[i], quality: suffix != null ? SUFFIX_TO_QUALITY[suffix] ?? null : null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct triples of T(p) matched by some loop transition (wildcard-aware),
|
||||||
|
// in p's canonical order — shared[0] names the annotation.
|
||||||
|
function sharedTransitions(loopT, pT) {
|
||||||
|
const seen = new Set()
|
||||||
|
const shared = []
|
||||||
|
for (const t of pT) {
|
||||||
|
const key = `${t.d}|${t.qa}|${t.qb}`
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
const hit = loopT.some(
|
||||||
|
l =>
|
||||||
|
l.d === t.d &&
|
||||||
|
(l.qa == null || t.qa == null || l.qa === t.qa) &&
|
||||||
|
(l.qb == null || t.qb == null || l.qb === t.qb)
|
||||||
|
)
|
||||||
|
if (hit) shared.push(t)
|
||||||
|
}
|
||||||
|
return shared
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebased degree-set Jaccard over the canonical rotations (both sides rebased
|
||||||
|
// so their canonical first element is 0 — rotation-invariant by construction).
|
||||||
|
function degreeSetJaccard(aCanon, bCanon) {
|
||||||
|
const a = new Set(aCanon.split(','))
|
||||||
|
const b = new Set(bCanon.split(','))
|
||||||
|
let inter = 0
|
||||||
|
for (const x of a) if (b.has(x)) inter++
|
||||||
|
const union = a.size + b.size - inter
|
||||||
|
return union ? inter / union : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summed bar count of a progression (the total form length), or null when the
|
||||||
|
// KB entry carries no `bars` array. Used only by siblingRole (§3 rules 2–4).
|
||||||
|
function totalBars(prog) {
|
||||||
|
const bars = Array.isArray(prog?.bars) ? prog.bars : null
|
||||||
|
if (!bars) return null
|
||||||
|
return bars.reduce((sum, n) => sum + (typeof n === 'number' ? n : 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* siblingRole(sibling, active) → role phrase | null (same-style-first §3)
|
||||||
|
*
|
||||||
|
* A short character phrase framing a same-style `sibling` against the `active`
|
||||||
|
* (matched) progression, derived ONLY from KB `mode` / `bars` / `qualities`.
|
||||||
|
* First rule that fires:
|
||||||
|
* 1. mode differs → "{mode} version" (minor/major, else the name)
|
||||||
|
* 2. same mode, fewer bars → "shorter form"
|
||||||
|
* 3. same mode, more bars → "extended form"
|
||||||
|
* 4. same mode & length, a quality the active lacks → "reharmonized"
|
||||||
|
* 5. otherwise → null (honest: name + level only)
|
||||||
|
* Returns null when `active` is unresolved. The finding-B gate (a sibling that
|
||||||
|
* shares NO genuine relationship with the played loop) is applied at the call
|
||||||
|
* site — see the `genuine` guard in rankRelatedProgressions.
|
||||||
|
*/
|
||||||
|
export function siblingRole(sibling, active) {
|
||||||
|
if (!sibling || !active) return null
|
||||||
|
const sMode = sibling.mode ?? null
|
||||||
|
const aMode = active.mode ?? null
|
||||||
|
// 1. mode differs → the mode-flavoured version.
|
||||||
|
if (sMode && aMode && sMode !== aMode) {
|
||||||
|
if (sMode === 'minor') return 'minor version'
|
||||||
|
if (sMode === 'major') return 'major version'
|
||||||
|
return `${sMode} version`
|
||||||
|
}
|
||||||
|
// 2–4 only compare within a shared mode (or when both modes are absent).
|
||||||
|
if (sMode !== aMode) return null
|
||||||
|
const sBars = totalBars(sibling)
|
||||||
|
const aBars = totalBars(active)
|
||||||
|
if (sBars != null && aBars != null) {
|
||||||
|
if (sBars < aBars) return 'shorter form'
|
||||||
|
if (sBars > aBars) return 'extended form'
|
||||||
|
}
|
||||||
|
// 4. same mode & length: a colour the active progression lacks.
|
||||||
|
const aQ = new Set(Array.isArray(active.qualities) ? active.qualities : [])
|
||||||
|
const sQ = Array.isArray(sibling.qualities) ? sibling.qualities : []
|
||||||
|
if (aQ.size && sQ.some(q => !aQ.has(q))) return 'reharmonized'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* rankRelatedProgressions(loop, kbRegistry?) →
|
||||||
|
* { match, activeStyle, activeStyleLabel, primary, secondary, entries } | null
|
||||||
|
*
|
||||||
|
* The §5 ranking, extended for same-style-first (L-72, docs/design/related-
|
||||||
|
* same-style.md). Returns null when the loop yields no degrees. When the loop
|
||||||
|
* matches a KB progression, `activeStyle = match.style` and the panel leads with
|
||||||
|
* that style's OTHER progressions (same-style siblings) — floor relaxed to 0,
|
||||||
|
* scorer order kept, each carrying a `role` phrase (siblingRole, §3). Per the
|
||||||
|
* Maestro finding-A resolution, when a style is locked the panel shows SAME-STYLE
|
||||||
|
* ONLY (`secondary` stays empty — no cross-style section). When `!match.matched`
|
||||||
|
* (`activeStyle == null`) the pre-existing cross-style flat list is returned
|
||||||
|
* unchanged (floor RELATED_SCORE_FLOOR, annotations). `entries` =
|
||||||
|
* `primary.concat(secondary)` for back-compat with `entries[0]` reads.
|
||||||
|
* Exported for smoke coverage.
|
||||||
|
*/
|
||||||
|
export function rankRelatedProgressions(loop, kbRegistry = kb) {
|
||||||
|
const loopDeg = loopToDegrees(loop)
|
||||||
|
if (!loopDeg || !loopDeg.length) return null
|
||||||
|
const loopCanon = canonicalDegrees(loopDeg)
|
||||||
|
const index = kbRegistry === kb ? DEFAULT_INDEX : buildLoopIndex(kbRegistry)
|
||||||
|
const match = matchLoopToProgression(loop, index)
|
||||||
|
const loopT = transitionsOf(loopUnitsOf(loop, loopDeg))
|
||||||
|
|
||||||
|
// §1: the active style IS the component's own match (roulette seed + live
|
||||||
|
// detection both route through the L-60 collapsed index). No prop needed.
|
||||||
|
const activeStyle = match.matched ? match.style : null
|
||||||
|
const activeStyleLabel = activeStyle
|
||||||
|
? kbRegistry[activeStyle]?.meta?.label ?? activeStyle
|
||||||
|
: null
|
||||||
|
// The raw active KB entry — authoritative mode/bars for siblingRole (§3).
|
||||||
|
const activeProg = activeStyle
|
||||||
|
? kbRegistry[activeStyle]?.progressions?.find(p => p.id === match.id) ?? null
|
||||||
|
: null
|
||||||
|
|
||||||
|
const entries = []
|
||||||
|
for (const style of Object.keys(kbRegistry)) {
|
||||||
|
const styleLabel = kbRegistry[style]?.meta?.label ?? style
|
||||||
|
const progs = kbRegistry[style]?.progressions
|
||||||
|
if (!Array.isArray(progs)) continue
|
||||||
|
for (const prog of progs) {
|
||||||
|
if (!Array.isArray(prog.degrees) || !prog.degrees.length) continue
|
||||||
|
if (match.matched && prog.id === match.id) continue // §5: every p ≠ the matched one
|
||||||
|
const collapsed = collapseChanges(prog)
|
||||||
|
if (!collapsed.length) continue
|
||||||
|
const pCanon = canonicalDegrees(collapsed.map(u => u.deg))
|
||||||
|
const shared = sharedTransitions(loopT, transitionsOf(collapsed))
|
||||||
|
const sameChanges = pCanon === loopCanon
|
||||||
|
const sameStyle = activeStyle != null && style === activeStyle
|
||||||
|
|
||||||
|
let score = 0
|
||||||
|
if (sameChanges) score += SCORE_SAME_CHANGES
|
||||||
|
if (sameStyle) score += SCORE_SAME_STYLE
|
||||||
|
score += Math.min(shared.length * SCORE_PER_TRANSITION, TRANSITION_CAP)
|
||||||
|
score += degreeSetJaccard(loopCanon, pCanon) * SCORE_JACCARD
|
||||||
|
score -= Math.abs(loop.length - collapsed.length)
|
||||||
|
// §2/§4: same-style siblings bypass the floor (never "junk" — they are the
|
||||||
|
// "other options" the user asked for); cross-style keeps the floor.
|
||||||
|
const floor = sameStyle ? 0 : RELATED_SCORE_FLOOR
|
||||||
|
if (score < floor) continue
|
||||||
|
|
||||||
|
// §5 annotation (cross-style rows): why this entry is here.
|
||||||
|
const annotation = sameChanges
|
||||||
|
? 'same changes'
|
||||||
|
: shared.length
|
||||||
|
? `shares ${shared[0].rnFrom || '?'}→${shared[0].rnTo || '?'}`
|
||||||
|
: 'same style'
|
||||||
|
|
||||||
|
// §3 role (same-style rows only). finding-B gate: emit a phrase only when
|
||||||
|
// there is a genuine relationship to the played loop — identical changes
|
||||||
|
// OR at least one shared (Δ, quality→quality) transition. A distantly-
|
||||||
|
// related same-style sibling (neither) gets no false "extended form" /
|
||||||
|
// "reharmonized" label — just its name + level.
|
||||||
|
const genuine = sameChanges || shared.length > 0
|
||||||
|
const role = sameStyle && genuine ? siblingRole(prog, activeProg) : null
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
style,
|
||||||
|
styleLabel,
|
||||||
|
id: prog.id,
|
||||||
|
name: prog.name,
|
||||||
|
level: prog.level === 'intermediate' ? 'intermediate' : 'foundation', // untagged counts foundation
|
||||||
|
score,
|
||||||
|
sameChanges,
|
||||||
|
sameStyle,
|
||||||
|
annotation,
|
||||||
|
role,
|
||||||
|
progression: prog,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort(
|
||||||
|
(a, b) => b.score - a.score || a.style.localeCompare(b.style) || a.id.localeCompare(b.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
// §2 + finding-A: with a style locked, primary = same-style siblings only
|
||||||
|
// (cap 5), secondary dropped. Without a lock, the flat cross-style list.
|
||||||
|
let primary, secondary
|
||||||
|
if (activeStyle != null) {
|
||||||
|
primary = entries.filter(e => e.sameStyle).slice(0, RELATED_MAX_ENTRIES)
|
||||||
|
secondary = [] // finding-A: no cross-style section when a style is locked
|
||||||
|
} else {
|
||||||
|
primary = entries.slice(0, RELATED_MAX_ENTRIES)
|
||||||
|
secondary = []
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
activeStyle,
|
||||||
|
activeStyleLabel,
|
||||||
|
primary,
|
||||||
|
secondary,
|
||||||
|
entries: primary.concat(secondary),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Presentational bits ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Level chip — ExplorePanel's LevelBadge language (amber = the existing
|
||||||
|
// secondary-tone token; foundation stays quiet; untagged counts foundation).
|
||||||
|
function LevelBadge({ level }) {
|
||||||
|
if (level === 'intermediate') {
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 text-[9px] uppercase tracking-wide font-semibold text-amber border border-amber/40 rounded px-1.5 py-px">
|
||||||
|
intermediate
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 text-[9px] uppercase tracking-wide font-semibold text-gray-400 border border-border rounded px-1.5 py-px">
|
||||||
|
foundation
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chord chain realized in the current key — the raw (bar-per-bar) KB form,
|
||||||
|
// truncated to the first 8 + "…" (§5); each chip taps through to
|
||||||
|
// ChordDetailModal via onChordClick (the banner-chip pattern, smaller).
|
||||||
|
const CHAIN_SHOWN = 8
|
||||||
|
|
||||||
|
function ChordChain({ progression, keyRoot, onChordClick }) {
|
||||||
|
const degrees = progression.degrees ?? []
|
||||||
|
const qualities = progression.qualities ?? []
|
||||||
|
const rn = progression.rn ?? []
|
||||||
|
const shown = degrees.slice(0, CHAIN_SHOWN)
|
||||||
|
return (
|
||||||
|
<div className="mt-1 flex items-end gap-1 flex-wrap">
|
||||||
|
{shown.map((deg, i) => {
|
||||||
|
const rootPc = (((keyRoot + deg) % 12) + 12) % 12
|
||||||
|
const label = `${NOTES[rootPc]}${CHORD_TYPES[qualities[i]]?.suffix ?? ''}`
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChordClick?.(label)}
|
||||||
|
aria-label={`${label} — open chord detail`}
|
||||||
|
className="flex flex-col items-center px-1.5 py-0.5 rounded-lg border border-border bg-border hover:border-gray-500 transition-all cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-bold leading-none text-gray-300">{label}</span>
|
||||||
|
<span className="text-[10px] text-gray-500">{rn[i] ?? ''}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{degrees.length > CHAIN_SHOWN && <span className="self-center text-xs text-gray-500">…</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RelatedProgressions({ loop, keyInfo, onChordClick }) {
|
||||||
|
const loopKey = Array.isArray(loop) ? loop.join(',') : ''
|
||||||
|
const ranked = useMemo(
|
||||||
|
() => (loopKey ? rankRelatedProgressions(loop) : null),
|
||||||
|
[loopKey] // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyInfo.root is a note NAME; chips want a pitch class. Default to C (0)
|
||||||
|
// until a key is known, same fallback JamGuide's stations use.
|
||||||
|
const keyRoot = useMemo(() => {
|
||||||
|
const pc = chordRootPC(keyInfo?.root)
|
||||||
|
return pc >= 0 ? pc : 0
|
||||||
|
}, [keyInfo?.root])
|
||||||
|
|
||||||
|
// Idle — no loop (or unparseable chord names, which rank as no loop).
|
||||||
|
if (!ranked) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="rounded-2xl border border-dashed border-border p-3"
|
||||||
|
aria-label="Related progressions"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Loop a progression — related changes from the songbook land here.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { activeStyle, activeStyleLabel, primary } = ranked
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="rounded-2xl border border-border bg-panel p-3"
|
||||||
|
aria-label="Related progressions"
|
||||||
|
>
|
||||||
|
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
|
Related progressions · from the songbook
|
||||||
|
{keyInfo?.root ? ` · in ${keyInfo.root}` : ''}
|
||||||
|
</h4>
|
||||||
|
{primary.length === 0 ? (
|
||||||
|
activeStyle != null ? (
|
||||||
|
// Locked to a style with no siblings (§5 edge). Honest, never padded.
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
You’re on the only {activeStyleLabel} loop in the songbook.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
// Loop, but nothing clears the floor — honest, never padded (§5).
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Nothing in the songbook genuinely relates to this loop yet.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* §2/§3: when a style is locked, lead with its OTHER progressions
|
||||||
|
reframed as variations to try — same-style only (finding-A). */}
|
||||||
|
{activeStyle != null && (
|
||||||
|
<p className="mb-2 text-xs font-medium text-gray-300">
|
||||||
|
Try these in {activeStyleLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* D-75 §4: 2-col grid to use the left column's width; display cap
|
||||||
|
4 for a clean 2×2 (render-time slice — RELATED_MAX_ENTRIES and the
|
||||||
|
ranker are untouched). Stacks to 1-col on narrow. */}
|
||||||
|
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||||
|
{primary.slice(0, 4).map(entry => (
|
||||||
|
<li key={`${entry.style}-${entry.id}`} className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5">
|
||||||
|
<span className="text-sm font-semibold text-gray-100">{entry.name}</span>
|
||||||
|
{/* Same-style rows share the header's style — hide the redundant
|
||||||
|
label; cross-style rows keep it. */}
|
||||||
|
{activeStyle == null && (
|
||||||
|
<span className="text-xs text-gray-500">{entry.styleLabel}</span>
|
||||||
|
)}
|
||||||
|
<LevelBadge level={entry.level} />
|
||||||
|
{/* Same-style rows show the role phrase (omitted when null,
|
||||||
|
finding-B); cross-style rows keep the §5 annotation. */}
|
||||||
|
{activeStyle != null
|
||||||
|
? entry.role && (
|
||||||
|
<span className="text-[10px] text-gray-500">{entry.role}</span>
|
||||||
|
)
|
||||||
|
: <span className="text-[10px] text-gray-500">{entry.annotation}</span>}
|
||||||
|
</div>
|
||||||
|
<ChordChain
|
||||||
|
progression={entry.progression}
|
||||||
|
keyRoot={keyRoot}
|
||||||
|
onChordClick={onChordClick}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// ─── Mini fretboard scale diagram ─────────────────────────────────────────────
|
||||||
|
// Shows a 6-string × 5-fret window of scale tones.
|
||||||
|
// Root notes → purple fill. Scale tones → dark grey fill.
|
||||||
|
// Strings: top = s6 (low E), bottom = s1 (high e).
|
||||||
|
// Window starts at the root fret on string 6.
|
||||||
|
|
||||||
|
const OPEN_PITCHES = [4, 9, 2, 7, 11, 4] // E A D G B e (s6 … s1)
|
||||||
|
const FRETS = 5
|
||||||
|
|
||||||
|
export default function RiffDiagram({ rootPc, scaleIntervals = [0, 3, 5, 7, 10] }) {
|
||||||
|
if (rootPc === undefined || rootPc === null) return null
|
||||||
|
|
||||||
|
// Fret window starts where the root lands on s6 (low E)
|
||||||
|
const startFret = (rootPc - OPEN_PITCHES[0] + 12) % 12
|
||||||
|
|
||||||
|
// Which pitch classes are in the scale?
|
||||||
|
const scaleSet = new Set(scaleIntervals.map(i => (rootPc + i) % 12))
|
||||||
|
|
||||||
|
// Collect dots: { s (0=s6…5=s1), f (0-4 within window), isRoot }
|
||||||
|
const dots = []
|
||||||
|
for (let s = 0; s < 6; s++) {
|
||||||
|
for (let f = 0; f < FRETS; f++) {
|
||||||
|
const pc = (OPEN_PITCHES[s] + startFret + f) % 12
|
||||||
|
if (scaleSet.has(pc)) {
|
||||||
|
dots.push({ s, f, isRoot: pc === rootPc })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG layout
|
||||||
|
const W = 152, H = 70
|
||||||
|
const mL = 6, mT = 14, mR = 6, mB = 4
|
||||||
|
const innerW = W - mL - mR // 140
|
||||||
|
const innerH = H - mT - mB // 52
|
||||||
|
|
||||||
|
const cellW = innerW / FRETS // 28
|
||||||
|
const strGap = innerH / 5 // gap between 6 strings (5 gaps)
|
||||||
|
|
||||||
|
const sx = (f) => mL + f * cellW // left edge of fret cell
|
||||||
|
const cx = (f) => mL + (f + 0.5) * cellW // centre of fret cell
|
||||||
|
const sy = (s) => mT + s * strGap // y of string s
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={W} height={H} className="shrink-0 overflow-visible">
|
||||||
|
{/* Fret separators (vertical lines) */}
|
||||||
|
{Array.from({ length: FRETS + 1 }, (_, f) => (
|
||||||
|
<line key={f}
|
||||||
|
x1={sx(f)} y1={mT - 2}
|
||||||
|
x2={sx(f)} y2={H - mB}
|
||||||
|
stroke={f === 0 ? '#555' : '#2a2a2a'}
|
||||||
|
strokeWidth={f === 0 ? 2 : 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* String lines (horizontal) */}
|
||||||
|
{Array.from({ length: 6 }, (_, s) => (
|
||||||
|
<line key={s}
|
||||||
|
x1={mL} y1={sy(s)}
|
||||||
|
x2={W - mR} y2={sy(s)}
|
||||||
|
stroke="#3a3a3a"
|
||||||
|
strokeWidth={s === 0 ? 1.5 : 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Fret numbers above */}
|
||||||
|
{Array.from({ length: FRETS }, (_, f) => (
|
||||||
|
<text key={f}
|
||||||
|
x={cx(f)} y={9}
|
||||||
|
textAnchor="middle" fontSize={8}
|
||||||
|
fill={f === 0 && startFret > 0 ? '#a855f7' : '#555'}
|
||||||
|
fontWeight={f === 0 && startFret > 0 ? 'bold' : 'normal'}>
|
||||||
|
{startFret + f === 0 ? 'O' : startFret + f}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Scale dots */}
|
||||||
|
{dots.map((d, i) => (
|
||||||
|
<circle key={i}
|
||||||
|
cx={cx(d.f)} cy={sy(d.s)}
|
||||||
|
r={4.5}
|
||||||
|
fill={d.isRoot ? '#a855f7' : '#3d3d3d'}
|
||||||
|
stroke={d.isRoot ? '#c084fc' : '#606060'}
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Root labels */}
|
||||||
|
{dots.filter(d => d.isRoot).map((d, i) => (
|
||||||
|
<text key={i}
|
||||||
|
x={cx(d.f)} y={sy(d.s) + 3.5}
|
||||||
|
textAnchor="middle" fontSize={6}
|
||||||
|
fill="white" fontWeight="bold">
|
||||||
|
R
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { NOTES, CHORD_TYPES, guideTones, voiceLeadingPairs, soloScale } from '../lib/theory'
|
||||||
|
|
||||||
|
// ─── RoadmapTrack (D-01) ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The heart of the "Roadmap" Jam Guide concept (docs/design/jam-guide-concept-c.md):
|
||||||
|
// the live loop rendered as a horizontal improv highway. Each KB progression
|
||||||
|
// station carries a chord name + Roman numeral + solo-scale label, a guide-tone
|
||||||
|
// lane (3rd/7th dots), and voice-leading rails drawn *between* adjacent stations
|
||||||
|
// (the 7→3 falls-a-half-step thread). A playhead + beat grid sit underneath; the
|
||||||
|
// station at `position` is "now", the next gets a subtle lookahead glow.
|
||||||
|
//
|
||||||
|
// Pure / presentational: no audio, no data fetching. Everything derives from
|
||||||
|
// props + theory.js. Default-exported. Luthier (D-02) wires it into JamGuide.jsx.
|
||||||
|
//
|
||||||
|
// Prop contract (honoured exactly — other agents build against it):
|
||||||
|
// progression KB progression object { id, name, rn, degrees, qualities, bars, mode, ... }
|
||||||
|
// keyRoot tonic pitch class 0–11
|
||||||
|
// keyMode 'major' | 'minor'
|
||||||
|
// position index of the current station (playhead); -1 if none
|
||||||
|
// bpm optional, for the beat grid; tolerate undefined
|
||||||
|
|
||||||
|
// Pitch class → note name. Sharps via NOTES (the app's canonical spelling, and
|
||||||
|
// what Fretboard.jsx uses); kept to one source so the roadmap matches the neck.
|
||||||
|
const pcName = pc => NOTES[((pc % 12) + 12) % 12]
|
||||||
|
|
||||||
|
// Build the full display chord name from a pitch class + a CHORD_TYPES quality
|
||||||
|
// key (e.g. 7 + 'dom7' → "G7", 2 + 'min7' → "Dm7"). Falls back to a bare major
|
||||||
|
// triad spelling if the quality is unknown, so the panel never renders blank.
|
||||||
|
const chordName = (rootPc, quality) =>
|
||||||
|
pcName(rootPc) + (CHORD_TYPES[quality]?.suffix ?? '')
|
||||||
|
|
||||||
|
// A readable mode word for the SCALE lane: theory.js returns snake_case names
|
||||||
|
// ('phrygian_dominant'); the design wants "G mixolydian".
|
||||||
|
const prettyScale = (rootPc, quality, keyMode) => {
|
||||||
|
const { name } = soloScale(quality, keyMode)
|
||||||
|
return `${pcName(rootPc)} ${name.replace(/_/g, ' ')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Layout constants (px in the SVG-free flex layout) ────────────────────────
|
||||||
|
const STATION_MIN_W = 168 // each station's min width; loops longer than the
|
||||||
|
// viewport scroll horizontally (12-bar blues etc.)
|
||||||
|
const RAIL_W = 34 // width of the gap a voice-leading rail bridges
|
||||||
|
const RAIL_H = 40 // rail SVG height
|
||||||
|
|
||||||
|
// Render the small arrow rail between two stations. `pair` is one entry from
|
||||||
|
// voiceLeadingPairs: { from, to, semitones }. We emphasise the half-step motion
|
||||||
|
// — a 0-semitone move is a held common tone ("holds"), ±1 a half-step, ±2 a
|
||||||
|
// whole step. Drawn in accent purple to match the guide-tone dots it connects.
|
||||||
|
function Rail({ pair }) {
|
||||||
|
if (!pair) return null
|
||||||
|
const { from, to, semitones } = pair
|
||||||
|
const held = semitones === 0
|
||||||
|
const dir = semitones < 0 ? 'down' : semitones > 0 ? 'up' : 'hold'
|
||||||
|
const label = held
|
||||||
|
? `${pcName(from)} holds`
|
||||||
|
: `${pcName(from)}→${pcName(to)}` // C→B
|
||||||
|
const motion = held
|
||||||
|
? 'common tone'
|
||||||
|
: `${Math.abs(semitones) === 1 ? '½' : Math.abs(semitones)} step ${dir === 'down' ? 'down' : 'up'}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 flex-col items-center justify-center select-none"
|
||||||
|
style={{ width: RAIL_W }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<svg width={RAIL_W} height={RAIL_H} viewBox={`0 0 ${RAIL_W} ${RAIL_H}`}>
|
||||||
|
{/* the rail line */}
|
||||||
|
<line
|
||||||
|
x1={2} y1={RAIL_H / 2} x2={RAIL_W - 8} y2={RAIL_H / 2}
|
||||||
|
stroke="#a855f7" strokeWidth={held ? 1.5 : 2}
|
||||||
|
strokeDasharray={held ? '3 3' : undefined}
|
||||||
|
/>
|
||||||
|
{/* arrowhead (omitted for a held common tone) */}
|
||||||
|
{!held && (
|
||||||
|
<path
|
||||||
|
d={`M ${RAIL_W - 8} ${RAIL_H / 2 - 4} L ${RAIL_W - 2} ${RAIL_H / 2} L ${RAIL_W - 8} ${RAIL_H / 2 + 4} Z`}
|
||||||
|
fill="#a855f7"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<span className="mt-0.5 text-[10px] font-semibold leading-none text-accent">{label}</span>
|
||||||
|
<span className="text-[9px] leading-tight text-gray-400">{motion}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single guide-tone dot with its honest label. `kind` is '3rd' / '7th' / '5th'.
|
||||||
|
function GuideDot({ pc, kind, filled }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
|
<span
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold"
|
||||||
|
style={
|
||||||
|
filled
|
||||||
|
? { backgroundColor: '#a855f7', color: '#fff' } // 3rd: solid accent
|
||||||
|
: { border: '2px solid #a855f7', color: '#d8b4fe' } // 7th: hollow accent
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pcName(pc)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-400">{kind}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single station on the highway.
|
||||||
|
function Station({
|
||||||
|
index, rootPc, quality, rn, scaleLabel, isNow, isNext, width,
|
||||||
|
}) {
|
||||||
|
const g = guideTones(rootPc, quality)
|
||||||
|
// hasSeventh:false → label the fallback honestly ("5th"), never call a 5th a 7th.
|
||||||
|
const seventhKind = g.hasSeventh ? '7th' : '5th'
|
||||||
|
|
||||||
|
// Tier the dimming exactly like ProgressionBanner: "now" is full accent, the
|
||||||
|
// lookahead "next" is a softer glow, everything else recedes — but never below
|
||||||
|
// a legibility floor (AA contrast on bg-panel).
|
||||||
|
const stateClass = isNow
|
||||||
|
? 'border-accent bg-accent/10 ring-2 ring-accent'
|
||||||
|
: isNext
|
||||||
|
? 'border-accent/50 bg-accent/5'
|
||||||
|
: 'border-border bg-surface'
|
||||||
|
const opacity = isNow ? 1 : isNext ? 0.92 : 0.7
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="listitem"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-current={isNow ? 'true' : undefined}
|
||||||
|
aria-label={
|
||||||
|
`Station ${index + 1}: ${chordName(rootPc, quality)}, ${rn}, ` +
|
||||||
|
`solo scale ${scaleLabel}, third ${pcName(g.third)}, ` +
|
||||||
|
`${seventhKind} ${pcName(g.seventh)}${isNow ? ', now playing' : ''}${isNext ? ', up next' : ''}`
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
`flex shrink-0 flex-col gap-2 rounded-xl border p-3 outline-none transition ` +
|
||||||
|
`focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ` +
|
||||||
|
`focus-visible:ring-offset-surface ${stateClass}`
|
||||||
|
}
|
||||||
|
style={{ minWidth: width, opacity }}
|
||||||
|
>
|
||||||
|
{/* Header: chord name + Roman numeral, with the lookahead flag */}
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-lg font-bold leading-none text-gray-100">
|
||||||
|
{chordName(rootPc, quality)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-gray-400">{rn}</span>
|
||||||
|
</div>
|
||||||
|
{isNow && (
|
||||||
|
<span className="rounded bg-accent px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-white">
|
||||||
|
now
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isNext && (
|
||||||
|
<span className="rounded border border-accent/60 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-accent">
|
||||||
|
next
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SCALE lane: the scale to blow over (muted, per the design) */}
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
<span className="text-[9px] uppercase tracking-widest text-gray-500">solo </span>
|
||||||
|
{scaleLabel}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TARGET lane: the 3rd & 7th guide-tone dots */}
|
||||||
|
<div className="mt-1 flex items-start gap-4 border-t border-border pt-2">
|
||||||
|
<span className="mt-1 text-[9px] uppercase tracking-widest text-gray-500">aim</span>
|
||||||
|
<GuideDot pc={g.third} kind="3rd" filled />
|
||||||
|
<GuideDot pc={g.seventh} kind={seventhKind} filled={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The playhead + beat grid under the whole track. Total beats = Σ bars × 4.
|
||||||
|
// The current beat is the start of the active station (coarse, chord-accurate —
|
||||||
|
// matches ProgressionBanner; fine beat interpolation is a later D-02 polish).
|
||||||
|
function BeatGrid({ bars, position, bpm }) {
|
||||||
|
const beatsPerStation = bars.map(b => (b || 1) * 4)
|
||||||
|
const totalBeats = beatsPerStation.reduce((s, n) => s + n, 0)
|
||||||
|
// first beat index of each station
|
||||||
|
const stationStart = []
|
||||||
|
let acc = 0
|
||||||
|
for (const n of beatsPerStation) { stationStart.push(acc); acc += n }
|
||||||
|
const nowBeat = position >= 0 && position < stationStart.length ? stationStart[position] : -1
|
||||||
|
const pct = nowBeat >= 0 && totalBeats > 0 ? (nowBeat + 0.5) / totalBeats : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
{/* playhead track */}
|
||||||
|
<div
|
||||||
|
className="relative h-1.5 w-full rounded-full bg-border"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={totalBeats}
|
||||||
|
aria-valuenow={Math.max(0, nowBeat)}
|
||||||
|
aria-valuetext={
|
||||||
|
nowBeat >= 0 ? `Beat ${nowBeat + 1} of ${totalBeats}` : 'Loop not playing'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 h-1.5 rounded-full bg-accent/60"
|
||||||
|
style={{ width: `${Math.max(0, pct * 100)}%` }}
|
||||||
|
/>
|
||||||
|
{nowBeat >= 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute -top-1 h-3.5 w-3.5 -translate-x-1/2 rounded-full border-2 border-surface bg-accent"
|
||||||
|
style={{ left: `${pct * 100}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* beat cells */}
|
||||||
|
<div className="mt-1 flex w-full gap-px" aria-hidden="true">
|
||||||
|
{Array.from({ length: totalBeats }, (_, i) => {
|
||||||
|
const isNow = i === nowBeat
|
||||||
|
// downbeat (beat 1 of a bar) gets a brighter tick
|
||||||
|
const isDownbeat = i % 4 === 0
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-2 flex-1 rounded-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isNow ? '#a855f7' : isDownbeat ? '#2a2a2a' : '#1a1a1a',
|
||||||
|
opacity: isNow ? 1 : isDownbeat ? 1 : 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bpm ? (
|
||||||
|
<p className="mt-1 text-right text-[10px] text-gray-500">~{Math.round(bpm)} BPM</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoadmapTrack({
|
||||||
|
progression,
|
||||||
|
keyRoot = 0,
|
||||||
|
keyMode = 'major',
|
||||||
|
position = -1,
|
||||||
|
bpm,
|
||||||
|
}) {
|
||||||
|
// Tolerate a missing / malformed progression — the panel is never empty-crashed.
|
||||||
|
if (!progression || !Array.isArray(progression.degrees) || progression.degrees.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border bg-panel p-4 text-sm text-gray-500">
|
||||||
|
No loop to map yet — play a progression.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { degrees, qualities = [], rn = [], bars = [], name, id } = progression
|
||||||
|
const n = degrees.length
|
||||||
|
|
||||||
|
// Resolve each station to an absolute chord in the current key.
|
||||||
|
const stations = degrees.map((deg, i) => {
|
||||||
|
const rootPc = (((keyRoot + deg) % 12) + 12) % 12
|
||||||
|
const quality = qualities[i] ?? 'maj'
|
||||||
|
return {
|
||||||
|
rootPc,
|
||||||
|
quality,
|
||||||
|
rn: rn[i] ?? '',
|
||||||
|
scaleLabel: prettyScale(rootPc, quality, keyMode),
|
||||||
|
bars: bars[i] ?? 1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Voice-leading rails between adjacent stations, plus a wrap-around rail from
|
||||||
|
// the last station back to the first (the loop is a wheel — a nice touch the
|
||||||
|
// design calls for: "B holds → next loop"). Index i = rail leaving station i.
|
||||||
|
const rails = stations.map((s, i) => {
|
||||||
|
const next = stations[(i + 1) % n]
|
||||||
|
return voiceLeadingPairs(
|
||||||
|
{ root: s.rootPc, quality: s.quality },
|
||||||
|
{ root: next.rootPc, quality: next.quality },
|
||||||
|
)[0] ?? null // the headline rail is the 7→3 (voiceLeadingPairs lists 7th first)
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextPos = position >= 0 ? (position + 1) % n : -1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="rounded-2xl border border-border bg-panel p-4"
|
||||||
|
aria-label={`Roadmap for ${name ?? id ?? 'loop'}`}
|
||||||
|
>
|
||||||
|
{/* Header strip: loop name + station chord summary */}
|
||||||
|
<header className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-widest text-gray-300">
|
||||||
|
{name ?? 'Loop'}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{stations.map(s => chordName(s.rootPc, s.quality)).join(' → ')}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* The highway: stations interleaved with voice-leading rails. Scrolls
|
||||||
|
horizontally when the loop is longer than the viewport. */}
|
||||||
|
<div className="overflow-x-auto pb-1">
|
||||||
|
<div role="list" aria-label="Loop stations" className="flex min-w-min items-stretch">
|
||||||
|
{stations.map((s, i) => (
|
||||||
|
<div key={i} className="flex items-center">
|
||||||
|
<Station
|
||||||
|
index={i}
|
||||||
|
rootPc={s.rootPc}
|
||||||
|
quality={s.quality}
|
||||||
|
rn={s.rn}
|
||||||
|
scaleLabel={s.scaleLabel}
|
||||||
|
isNow={i === position}
|
||||||
|
isNext={i === nextPos}
|
||||||
|
width={STATION_MIN_W}
|
||||||
|
/>
|
||||||
|
{/* rail to the next station (inter-station rails only; the
|
||||||
|
wrap-around rail is drawn separately after the last station) */}
|
||||||
|
{i < n - 1 && <Rail pair={rails[i]} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* wrap-around rail back to station 1, rendered after the last station */}
|
||||||
|
{n > 1 && (
|
||||||
|
<div className="flex items-center" aria-hidden="true">
|
||||||
|
<Rail pair={rails[n - 1]} />
|
||||||
|
<span className="ml-0.5 text-[9px] uppercase tracking-wider text-gray-500">
|
||||||
|
loop
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Playhead + beat grid */}
|
||||||
|
<BeatGrid bars={stations.map(s => s.bars)} position={position} bpm={bpm} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { CHORD_TYPES, NOTES, suggestSubstitutions } from '../lib/theory'
|
||||||
|
import { chordRootPC } from '../lib/match'
|
||||||
|
import { getGuitarVoicings } from '../lib/voicings'
|
||||||
|
import { pianoVoicing } from '../lib/piano'
|
||||||
|
import ChordDiagram from './ChordDiagram'
|
||||||
|
import MiniPiano from './MiniPiano'
|
||||||
|
|
||||||
|
// ─── TryThis — stable 3-slot rows-of-shapes substitution rail (L-78 / D-76) ─────
|
||||||
|
//
|
||||||
|
// For the chord under the playhead, in the detected key, show up to THREE curated
|
||||||
|
// substitutions (from theory.js `suggestSubstitutions`, the L-73 engine) as
|
||||||
|
// stacked ROWS — turned 90° from L-75's cards-across. Each row = a left identity
|
||||||
|
// block (tappable chord chip + category tag + why) and a right "ways to play it"
|
||||||
|
// block that follows the global instrument:
|
||||||
|
// guitar → up to 3 ChordDiagram thumbs (the 3×3 grid), the genuinely different
|
||||||
|
// grips from getGuitarVoicings(label).slice(0, 3)
|
||||||
|
// piano → ONE MiniPiano ("for piano it can be just one thats okay")
|
||||||
|
// bass → a root · {note} caption (no honest compact bass-chord renderer)
|
||||||
|
//
|
||||||
|
// The layout is a FIXED 3-slot frame: present subs render SubRow, absent slots
|
||||||
|
// render a subtle EmptySlot that holds the exact row height — so sub #1 and #2
|
||||||
|
// never shift position whether the chord yields 2 or 3 subs (the user's anti-jump
|
||||||
|
// ask: "annoying when the layout changes then u dont know where to look").
|
||||||
|
// Spec: docs/design/related-area-v2.md §2. Sibling of RelatedProgressions.
|
||||||
|
//
|
||||||
|
// Props:
|
||||||
|
// loop : string[] | null — the detected repeating progression (chord names)
|
||||||
|
// keyInfo : { root, mode, confidence } | null — effective key (frames the why)
|
||||||
|
// currentChord : string — the chord sounding NOW ("F", "Dm7")
|
||||||
|
// onChordClick : fn(label) — opens ChordDetailModal (App's setSelectedChord)
|
||||||
|
// instrument : 'guitar' | 'piano' | 'bass' — which mini diagram to draw
|
||||||
|
|
||||||
|
// Invert CHORD_TYPES suffix → quality — the app idiom (mirrors
|
||||||
|
// RelatedProgressions' SUFFIX_TO_QUALITY). All 14 suffixes are unique.
|
||||||
|
const SUFFIX_TO_QUALITY = Object.fromEntries(
|
||||||
|
Object.entries(CHORD_TYPES).map(([quality, def]) => [def.suffix, quality])
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse a chord-name string → { rootPc, quality } (a CHORD_TYPES key) using the
|
||||||
|
// established helpers — chordRootPC for the root pc, the CHORD_TYPES suffix
|
||||||
|
// inversion for the quality. Never re-derived. Returns null when unparseable.
|
||||||
|
export function parseChordName(name) {
|
||||||
|
if (typeof name !== 'string') return null
|
||||||
|
const rootPc = chordRootPC(name)
|
||||||
|
if (rootPc < 0) return null
|
||||||
|
const m = name.match(/^[A-G][b#]?(.*)$/)
|
||||||
|
const quality = m ? SUFFIX_TO_QUALITY[m[1]] : undefined
|
||||||
|
if (!quality) return null
|
||||||
|
return { rootPc, quality }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Circle-of-fifths categories (relative = inner ring, secondary_dominant =
|
||||||
|
// clockwise step). Only these earn the ↻ glyph — borrowed/extension are modal /
|
||||||
|
// vertical colour and must NOT claim the circle (docs §6).
|
||||||
|
const CIRCLE_CATEGORIES = new Set(['relative', 'secondary_dominant'])
|
||||||
|
|
||||||
|
const CATEGORY_TAG = {
|
||||||
|
relative: 'relative',
|
||||||
|
borrowed: 'borrowed',
|
||||||
|
extension: 'colour',
|
||||||
|
secondary_dominant: 'V7',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-instrument uniform row height (§2.2/§3). All 3 slots — filled or empty —
|
||||||
|
// share the current instrument's height so the frame never reflows.
|
||||||
|
const ROW_MIN_H = {
|
||||||
|
guitar: 'min-h-[100px]',
|
||||||
|
piano: 'min-h-[72px]',
|
||||||
|
bass: 'min-h-[64px]',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the substitution set for a chord NAME at loop index `pos` (−1 when the
|
||||||
|
// chord is not a loop station). The next station's root pc gates Rule D (secondary
|
||||||
|
// dominant of the next chord). Returns { name, pos, subs } or null when the name
|
||||||
|
// won't parse. Reuses parseChordName / chordRootPC / suggestSubstitutions — never
|
||||||
|
// re-derives theory.
|
||||||
|
function subsForChord(name, pos, loopArr, keyInfo) {
|
||||||
|
const parsed = parseChordName(name)
|
||||||
|
if (!parsed) return null
|
||||||
|
let nextRootPc
|
||||||
|
if (loopArr && pos >= 0) {
|
||||||
|
const pc = chordRootPC(loopArr[(pos + 1) % loopArr.length])
|
||||||
|
if (pc >= 0) nextRootPc = pc
|
||||||
|
}
|
||||||
|
return { name, pos, subs: suggestSubstitutions(parsed, keyInfo, { nextRootPc }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose the SUBJECT chord the card speaks about (pure — no hooks):
|
||||||
|
// (a) the live currentChord, if it parses and yields ≥1 sub (the playing case —
|
||||||
|
// whether or not it's a loop station; keeps the card live as you play);
|
||||||
|
// (b) else the FIRST loop chord that yields ≥1 sub — so a rolled/detected loop
|
||||||
|
// in a locked key shows the card immediately, with no live input;
|
||||||
|
// (c) else null — no loop and no valid live chord → honest empty.
|
||||||
|
function pickSubject(loopArr, keyInfo, currentChord) {
|
||||||
|
const pos = loopArr ? loopArr.indexOf(currentChord) : -1
|
||||||
|
const live = subsForChord(currentChord, pos, loopArr, keyInfo)
|
||||||
|
if (live && live.subs.length) return live
|
||||||
|
if (loopArr) {
|
||||||
|
for (let i = 0; i < loopArr.length; i++) {
|
||||||
|
const cand = subsForChord(loopArr[i], i, loopArr, keyInfo)
|
||||||
|
if (cand && cand.subs.length) return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "ways to play" block on the right of a SubRow, chosen by the global
|
||||||
|
// instrument (§2.3):
|
||||||
|
// guitar → up to 3 ChordDiagram thumbs (the 3×3), each captioned with its shape
|
||||||
|
// name so the three read as genuinely different grips. Fewer than 3
|
||||||
|
// shapes exist (e.g. add9 → 2 for most roots) → show what exists, never
|
||||||
|
// pad with fakes (honest, §2.4). None → nothing (chip + why carry it).
|
||||||
|
// piano → ONE MiniPiano; rootPc spread back in so the "R" badge lands right.
|
||||||
|
// bass → root · {note} caption (no honest compact bass-chord renderer).
|
||||||
|
function WaysToPlay({ sub, instrument }) {
|
||||||
|
if (instrument === 'piano') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<MiniPiano
|
||||||
|
voicing={{ ...pianoVoicing({ rootPc: sub.rootPc, quality: sub.quality }), rootPc: sub.rootPc }}
|
||||||
|
size="mini"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instrument === 'bass') {
|
||||||
|
return (
|
||||||
|
<span className="text-[10px] text-gray-500">
|
||||||
|
root · {NOTES[((sub.rootPc % 12) + 12) % 12]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// guitar (default): up to 3 genuinely different grips, left-aligned. Omit the
|
||||||
|
// ChordDiagram label (the chip names the chord) — caption the shape name below.
|
||||||
|
const shapes = getGuitarVoicings(sub.label).slice(0, 3)
|
||||||
|
if (!shapes.length) return null
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{shapes.map((shape, i) => (
|
||||||
|
<div key={`${shape.label}-${i}`} className="flex flex-col items-center gap-0.5">
|
||||||
|
<ChordDiagram shape={shape} rootPc={sub.rootPc} size="thumb" />
|
||||||
|
<span className="max-w-[75px] truncate text-center text-[9px] leading-none text-gray-500">
|
||||||
|
{shape.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One substitution ROW — left identity (chip → modal + tag + why) | right ways.
|
||||||
|
function SubRow({ sub, instrument, onChordClick }) {
|
||||||
|
const isCircle = CIRCLE_CATEGORIES.has(sub.category)
|
||||||
|
const tag = CATEGORY_TAG[sub.category] ?? sub.category
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 rounded-lg border border-border bg-border/30 p-2 ${ROW_MIN_H[instrument] ?? ROW_MIN_H.guitar}`}
|
||||||
|
>
|
||||||
|
{/* Left identity block (~180px) */}
|
||||||
|
<div className="flex w-[180px] shrink-0 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChordClick?.(sub.label)}
|
||||||
|
aria-label={`${sub.label} — ${sub.why}`}
|
||||||
|
className="shrink-0 rounded-lg border border-border bg-border px-2 py-1 text-sm font-bold leading-none text-gray-100 outline-none transition-all cursor-pointer hover:border-accent/50 hover:text-accent focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
{sub.label}
|
||||||
|
</button>
|
||||||
|
<span className="text-[9px] uppercase tracking-wide text-gray-500">
|
||||||
|
{tag}
|
||||||
|
{isCircle && <span className="ml-0.5 text-accent" aria-hidden="true">↻</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="line-clamp-2 text-[11px] leading-snug text-gray-400">
|
||||||
|
{sub.why}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right "ways to play" block */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<WaysToPlay sub={sub} instrument={instrument} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reserved-but-empty slot — holds the exact SubRow height so the present subs
|
||||||
|
// never move when the chord yields fewer than 3 (§2.2, the anti-jump).
|
||||||
|
function EmptySlot({ instrument }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`flex items-center justify-center rounded-lg border border-dashed border-border/50 bg-transparent ${ROW_MIN_H[instrument] ?? ROW_MIN_H.guitar}`}
|
||||||
|
>
|
||||||
|
<span className="text-[11px] text-gray-600">—</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TryThis({ loop, keyInfo, currentChord, onChordClick, instrument = 'guitar' }) {
|
||||||
|
const loopArr = Array.isArray(loop) && loop.length ? loop : null
|
||||||
|
const subject = pickSubject(loopArr, keyInfo, currentChord)
|
||||||
|
|
||||||
|
// No subject (no loop + no valid live chord) or 0 subs → no card.
|
||||||
|
if (!subject) return null
|
||||||
|
|
||||||
|
const subjectChord = subject.name
|
||||||
|
const subs = subject.subs.slice(0, 3) // cap at 3 (§2.1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="rounded-2xl border border-border bg-panel p-3"
|
||||||
|
aria-label={`Try this instead of ${subjectChord}`}
|
||||||
|
>
|
||||||
|
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||||
|
Try this instead of {subjectChord}
|
||||||
|
{keyInfo?.root ? ` · in ${keyInfo.root} ${keyInfo.mode ?? 'major'}` : ''}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Fixed 3-slot frame — a present sub → SubRow, an absent one → EmptySlot,
|
||||||
|
so sub #1/#2 hold their position whether there are 2 or 3 subs. */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{[0, 1, 2].map(i =>
|
||||||
|
subs[i]
|
||||||
|
? <SubRow key={`${subs[i].label}-${i}`} sub={subs[i]} instrument={instrument} onChordClick={onChordClick} />
|
||||||
|
: <EmptySlot key={`empty-${i}`} instrument={instrument} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
// VoicingBrowser — a playable voicing GALLERY for ONE chord (task D-30; was the
|
||||||
|
// chip-switched browser of D-21/D-23).
|
||||||
|
//
|
||||||
|
// For a given { rootPc, quality } it shows every way the KB knows to voice that
|
||||||
|
// chord — ALL AT ONCE, no chips, no selection state (user directive 2026-07-10:
|
||||||
|
// "see all the variations G shape, C shape, etc in one view without having to
|
||||||
|
// push a button. so they all line up next to each other"):
|
||||||
|
//
|
||||||
|
// Guitar section — every placeable `GUITAR_SHAPES[quality]` entry from
|
||||||
|
// src/lib/voicings.js (open shapes only in their native key, movable shapes
|
||||||
|
// only when the whole grip fits under fret 15), each cell = shape label +
|
||||||
|
// <ChordDiagram size="thumb"/>.
|
||||||
|
// Piano section — all four src/lib/piano.js `pianoVoicing` styles
|
||||||
|
// (root / shell / rootlessA / rootlessB), each cell = the voicing's honest
|
||||||
|
// label (e.g. "rootless A (3-5-7-9)") + <MiniPiano voicing/>.
|
||||||
|
//
|
||||||
|
// No playback (L-70 / dashboard-polish.md §3): the per-cell ▶ preview was removed
|
||||||
|
// from EVERY mount — the user settled "leave them off, better not." The component
|
||||||
|
// is now purely visual; src/lib/chordAudio.js is no longer imported here.
|
||||||
|
//
|
||||||
|
// Mount points (wired by L-21/L-22, NOT here): Knowledge Center Voicings
|
||||||
|
// section, ChordDetailModal Guitar/Piano tabs (show="guitar"/"piano", L-25),
|
||||||
|
// and the Jam Guide station rail / heard-live view (dense). This component stays
|
||||||
|
// pure & prop-driven.
|
||||||
|
//
|
||||||
|
// Layout: each instrument section is a flex-wrap gallery of fixed-content-width
|
||||||
|
// cells, so it reflows to fewer columns (down to one cell per row) inside a
|
||||||
|
// narrow modal or the Jam Guide dock — no horizontal scroll needed except the
|
||||||
|
// per-cell guard around the widest MiniPiano thumbs (~266px for 2-octave
|
||||||
|
// rootless voicings).
|
||||||
|
//
|
||||||
|
// Props:
|
||||||
|
// rootPc — chord root pitch class 0–11 (default 0 = C)
|
||||||
|
// quality — CHORD_TYPES key; unknown values fall back to 'maj'
|
||||||
|
// (matching voicings.js / piano.js behaviour)
|
||||||
|
// show — 'guitar' | 'piano' | 'both' (default 'both', task D-23): which
|
||||||
|
// instrument section(s) to render. 'bass' (task D-41, D-40 §3)
|
||||||
|
// renders NEITHER gallery — guitar shapes are not bass patterns and
|
||||||
|
// pianoVoicing is piano, so showing either under the global BASS
|
||||||
|
// selector would lie; an honest one-liner renders instead. Any
|
||||||
|
// OTHER value still falls back to both, so every pre-existing
|
||||||
|
// mount renders identically with no prop.
|
||||||
|
// dense — boolean (default false, task L-33 — additive per D-31 §5; D-41
|
||||||
|
// restyled it for the all-expanded GlanceRail rows): drops the
|
||||||
|
// section chrome (border/panel background/heading — the row header
|
||||||
|
// already names the chord) and suppresses the per-mount
|
||||||
|
// mic-feedback microcopy (the rail shows it ONCE for the whole
|
||||||
|
// rail, D-31 §2.5). D-51 (one-screen.md §4) additionally tightens
|
||||||
|
// the DENSE cell spacing for the 500px right column — cell p-2 →
|
||||||
|
// p-1.5 and gallery gap-2 → gap-1.5 — which hardens the column's
|
||||||
|
// two-octave + one-octave piano pair fit (279.6 + 6 + 156.4 = 442
|
||||||
|
// ≤ 451px row interior even with a classic Windows scrollbar in
|
||||||
|
// the rail's scroller; it was 452 vs 456, a ~4px squeak, before).
|
||||||
|
// L-70 (dashboard-polish.md §2) additionally renders the piano
|
||||||
|
// styles as a 2×2 grid of `size="mini"` keyboards under dense, and
|
||||||
|
// applies the `.dark-scroll` utility to each cell's scroller. Every
|
||||||
|
// pre-existing mount renders identically with no prop.
|
||||||
|
// recommended — a shape object (guitar) or voicing object (piano) marking the
|
||||||
|
// KB play's own voicing (dashboard rail only). Guitar: matched into
|
||||||
|
// the list by `label`, floated to cell #1, badged "play" + accent
|
||||||
|
// border, and the list reordered recommended-first (§1.2). Piano:
|
||||||
|
// matched by `voicing.style`, that 2×2 cell gets the accent border
|
||||||
|
// (§2.1). Default null → no highlight, order untouched.
|
||||||
|
// max — cap the guitar list to this many cells (dashboard rail passes 4).
|
||||||
|
// Undefined → no cap (all placeable shapes). Off-rail mounts pass
|
||||||
|
// neither `recommended` nor `max`, so the guitar list keeps its raw
|
||||||
|
// `matchingShapes` declared order — byte-identical to today.
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import ChordDiagram from './ChordDiagram'
|
||||||
|
import MiniPiano from './MiniPiano'
|
||||||
|
import { GUITAR_SHAPES } from '../lib/voicings'
|
||||||
|
import { pianoVoicing } from '../lib/piano'
|
||||||
|
import { NOTES, CHORD_TYPES } from '../lib/theory'
|
||||||
|
|
||||||
|
// Standard-tuning open-string pitch classes, low-E first (mirrors ChordDiagram).
|
||||||
|
const OPEN_PCS = [4, 9, 2, 7, 11, 4]
|
||||||
|
const PIANO_STYLES = ['root', 'shell', 'rootlessA', 'rootlessB']
|
||||||
|
|
||||||
|
const mod12 = (n) => ((n % 12) + 12) % 12
|
||||||
|
|
||||||
|
// The shapes of `quality` that can actually be shown for this root:
|
||||||
|
// - open shapes only when their native root matches (onlyRoot === rootPc);
|
||||||
|
// - movable shapes only when every fretted string lands in 0–15 under
|
||||||
|
// ChordDiagram's placement convention (root-at-open-string → fret-12 barre).
|
||||||
|
// Unknown quality falls back to maj (same fallback voicings.js itself uses).
|
||||||
|
function matchingShapes(quality, rootPc) {
|
||||||
|
const shapes = GUITAR_SHAPES[quality] ?? GUITAR_SHAPES.maj
|
||||||
|
return shapes.filter((shape) => {
|
||||||
|
if (Array.isArray(shape.frets)) {
|
||||||
|
// Open shape: fixed grip, valid only in its native key.
|
||||||
|
return shape.onlyRoot === undefined || shape.onlyRoot === rootPc
|
||||||
|
}
|
||||||
|
if (!Array.isArray(shape.offsets) || !Number.isFinite(shape.rootStr)) return false
|
||||||
|
const idx = 6 - shape.rootStr // rootStr 6 = low E → low-E-first index 0
|
||||||
|
let baseFret = mod12(rootPc - (OPEN_PCS[idx] ?? 4))
|
||||||
|
if (baseFret === 0) baseFret = 12 // ChordDiagram's octave-barre placement
|
||||||
|
const abs = shape.offsets.filter((o) => typeof o === 'number').map((o) => baseFret + o)
|
||||||
|
if (abs.length === 0) return false
|
||||||
|
return Math.min(...abs) >= 0 && Math.max(...abs) <= 15
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lowest base fret of a movable shape under ChordDiagram's placement convention
|
||||||
|
// (root-at-open-string → fret-12 barre). Open shapes sit at the nut (0).
|
||||||
|
function baseFretOf(shape, rootPc) {
|
||||||
|
if (Array.isArray(shape.frets)) return 0
|
||||||
|
const idx = 6 - shape.rootStr
|
||||||
|
let bf = mod12(rootPc - (OPEN_PCS[idx] ?? 4))
|
||||||
|
if (bf === 0) bf = 12
|
||||||
|
return bf
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count of muted strings in a shape's grip (fewer = a fuller voicing wins ties).
|
||||||
|
function mutedCount(shape) {
|
||||||
|
const arr = Array.isArray(shape.frets) ? shape.frets : shape.offsets
|
||||||
|
return Array.isArray(arr) ? arr.filter((v) => v === 'x').length : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rail's ≤max, recommended-first ordering (dashboard-polish.md §1.2). Only
|
||||||
|
// invoked when `recommended` and/or `max` are supplied (the dashboard rail) —
|
||||||
|
// non-dense mounts skip it entirely and keep `matchingShapes` declared order,
|
||||||
|
// so VoicingsSection/ChordDetailModal stay byte-identical.
|
||||||
|
// 1. recommended first (matched into the placeable list by label; prepended
|
||||||
|
// even if it was filtered out as unplaceable);
|
||||||
|
// 2. open-position forms (Array.isArray(frets));
|
||||||
|
// 3. movable/barre by lowest base fret ascending;
|
||||||
|
// 4. tiebreak: fewer muted strings, then declared order in GUITAR_SHAPES.
|
||||||
|
// Then slice to `max` (recommended deduped so it never repeats). <max placeable
|
||||||
|
// shapes just show what exist — the rule caps, never pads.
|
||||||
|
function orderGuitarShapes(shapes, rootPc, recommended, max) {
|
||||||
|
const declaredIndex = new Map(shapes.map((s, i) => [s, i]))
|
||||||
|
let rec = null
|
||||||
|
if (recommended?.label) {
|
||||||
|
rec = shapes.find((s) => s.label === recommended.label) ?? recommended
|
||||||
|
}
|
||||||
|
const rest = shapes.filter((s) => s.label !== rec?.label)
|
||||||
|
rest.sort((a, b) => {
|
||||||
|
const aOpen = Array.isArray(a.frets) ? 0 : 1
|
||||||
|
const bOpen = Array.isArray(b.frets) ? 0 : 1
|
||||||
|
if (aOpen !== bOpen) return aOpen - bOpen
|
||||||
|
if (aOpen === 1) {
|
||||||
|
const bf = baseFretOf(a, rootPc) - baseFretOf(b, rootPc)
|
||||||
|
if (bf !== 0) return bf
|
||||||
|
}
|
||||||
|
const mc = mutedCount(a) - mutedCount(b)
|
||||||
|
if (mc !== 0) return mc
|
||||||
|
return (declaredIndex.get(a) ?? 0) - (declaredIndex.get(b) ?? 0)
|
||||||
|
})
|
||||||
|
const ordered = rec ? [rec, ...rest] : rest
|
||||||
|
const list = Number.isFinite(max) ? ordered.slice(0, max) : ordered
|
||||||
|
return { list, recLabel: rec?.label ?? null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// "C", "Cm7", "Cmaj7"… — display name from the app's canonical chord model.
|
||||||
|
function chordName(rootPc, quality) {
|
||||||
|
const q = CHORD_TYPES[quality] ? quality : 'maj'
|
||||||
|
return `${NOTES[mod12(rootPc)]}${CHORD_TYPES[q].suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Small presentational atoms ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SectionHeading({ children }) {
|
||||||
|
return (
|
||||||
|
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
|
||||||
|
{children}
|
||||||
|
</h4>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One gallery cell: label on top, diagram/keyboard thumb underneath.
|
||||||
|
// bg-surface inside the bg-panel section gives the cells a quiet inlay border;
|
||||||
|
// label is gray-300 on surface (AA comfortable at 11px semibold).
|
||||||
|
// `dense` (D-51): p-1.5 instead of p-2 — 4px off each cell's box width, part of
|
||||||
|
// the right-column margin-hardening. Non-dense output is byte-identical.
|
||||||
|
// `recommended` (L-70, dashboard-polish.md §1.2): the KB play's shape/style —
|
||||||
|
// accent border + a "play" badge (finger-this, not audio; ▶ is gone), the
|
||||||
|
// prominence the old GlanceRail own-cell carried. Only ever set under the
|
||||||
|
// dashboard rail, so non-recommended cells stay byte-identical.
|
||||||
|
function GalleryCell({ label, recommended = false, dense = false, children }) {
|
||||||
|
return (
|
||||||
|
<figure className={`flex min-w-0 flex-col items-center gap-1.5 rounded-md border ${recommended ? 'border-accent' : 'border-border'} bg-surface ${dense ? 'p-1.5' : 'p-2'}`}>
|
||||||
|
{recommended ? (
|
||||||
|
<figcaption
|
||||||
|
className="flex max-w-full items-center gap-1.5 text-center text-[11px] font-medium leading-tight text-gray-300"
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<span className="rounded bg-accent px-1 py-0.5 text-[9px] font-bold uppercase tracking-wider text-black">
|
||||||
|
play
|
||||||
|
</span>
|
||||||
|
<span className="break-words">{label}</span>
|
||||||
|
</figcaption>
|
||||||
|
) : (
|
||||||
|
<figcaption
|
||||||
|
className="max-w-full break-words text-center text-[11px] font-medium leading-tight text-gray-300"
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</figcaption>
|
||||||
|
)}
|
||||||
|
{/* Scroll guard: MiniPiano's SVG has a fixed pixel width (up to ~266px
|
||||||
|
for a 2-octave thumb window); scroll inside the cell on very narrow
|
||||||
|
viewports rather than letting it break the wrap layout. `dark-scroll`
|
||||||
|
(L-70 §4) dresses this scroller on the dashboard rail only (dense) —
|
||||||
|
the KC/modal keep the OS default, so non-dense stays byte-identical. */}
|
||||||
|
<div className={`max-w-full overflow-x-auto${dense ? ' dark-scroll' : ''}`}>{children}</div>
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── The gallery ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function VoicingBrowser({
|
||||||
|
rootPc = 0, quality = 'maj', show = 'both', dense = false, recommended = null, max,
|
||||||
|
}) {
|
||||||
|
const pc = mod12(Number.isFinite(rootPc) ? rootPc : 0)
|
||||||
|
const name = chordName(pc, quality)
|
||||||
|
|
||||||
|
// Section gating (D-23; 'bass' added by D-41 per D-40 §3). 'guitar' hides
|
||||||
|
// the piano section, 'piano' hides the guitar section, 'bass' hides BOTH
|
||||||
|
// (neither gallery is honest for a bassist — the one-liner below renders
|
||||||
|
// instead, so the dock's VoicingsSection under the global BASS selector
|
||||||
|
// stops showing guitar+piano). Anything else (incl. the 'both' default)
|
||||||
|
// shows both. Hooks stay unconditional.
|
||||||
|
const showGuitar = show !== 'piano' && show !== 'bass'
|
||||||
|
const showPiano = show !== 'guitar' && show !== 'bass'
|
||||||
|
|
||||||
|
const allGuitarShapes = useMemo(() => matchingShapes(quality, pc), [quality, pc])
|
||||||
|
|
||||||
|
// Guitar list: the dashboard rail (recommended and/or max supplied) reorders
|
||||||
|
// recommended-first + caps to `max` (§1.2); every other mount keeps the raw
|
||||||
|
// `matchingShapes` declared order untouched — byte-identical to today.
|
||||||
|
const guitarView = useMemo(() => {
|
||||||
|
if (!recommended && !Number.isFinite(max)) {
|
||||||
|
return { list: allGuitarShapes, recLabel: null }
|
||||||
|
}
|
||||||
|
return orderGuitarShapes(allGuitarShapes, pc, recommended, max)
|
||||||
|
}, [allGuitarShapes, pc, recommended, max])
|
||||||
|
const guitarShapes = guitarView.list
|
||||||
|
|
||||||
|
const pianoOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
PIANO_STYLES.map((style) => ({
|
||||||
|
style,
|
||||||
|
voicing: pianoVoicing({ rootPc: pc, quality }, { style }),
|
||||||
|
})),
|
||||||
|
[pc, quality],
|
||||||
|
)
|
||||||
|
// Piano "recommended" (dashboard rail): the station's authored voicing matches
|
||||||
|
// one of the four styles → that 2×2 cell gets the accent border (§2.1). Guitar-
|
||||||
|
// shape `recommended` objects have no `.style`, so this stays null off-rail.
|
||||||
|
const recStyle = recommended && typeof recommended.style === 'string' ? recommended.style : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full min-w-0 flex-wrap gap-2">
|
||||||
|
{/* ── Guitar section: every placeable shape, side by side ── */}
|
||||||
|
{showGuitar && (
|
||||||
|
<section
|
||||||
|
aria-label={`Guitar voicings for ${name}`}
|
||||||
|
className={`min-w-[240px] flex-1 basis-[300px] ${dense ? '' : 'rounded-lg border border-border bg-panel p-3'}`}
|
||||||
|
>
|
||||||
|
{/* dense (a GlanceRail row): the row header already names the chord and
|
||||||
|
the global selector names the instrument — no repeated heading. */}
|
||||||
|
{!dense && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<SectionHeading>Guitar · {name}</SectionHeading>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{guitarShapes.length === 0 ? (
|
||||||
|
// Graceful: nothing placeable for this root/quality — say so, no crash.
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
No guitar shape sits comfortably for {name} — try the piano voicings.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={`${name} guitar shapes — every shape shown, each playable`}
|
||||||
|
className={`flex flex-wrap items-stretch ${dense ? 'gap-1.5' : 'gap-2'}`}
|
||||||
|
>
|
||||||
|
{guitarShapes.map((shape, i) => (
|
||||||
|
<GalleryCell
|
||||||
|
key={`${shape.label}-${i}`}
|
||||||
|
label={shape.label}
|
||||||
|
recommended={guitarView.recLabel !== null && shape.label === guitarView.recLabel}
|
||||||
|
dense={dense}
|
||||||
|
>
|
||||||
|
<ChordDiagram shape={shape} rootPc={pc} size="thumb" />
|
||||||
|
</GalleryCell>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Piano section: all four voicing styles, side by side ── */}
|
||||||
|
{showPiano && (
|
||||||
|
<section
|
||||||
|
aria-label={`Piano voicings for ${name}`}
|
||||||
|
className={`min-w-[240px] flex-1 basis-[300px] ${dense ? '' : 'rounded-lg border border-border bg-panel p-3'}`}
|
||||||
|
>
|
||||||
|
{!dense && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<SectionHeading>Piano · {name}</SectionHeading>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* dense (dashboard rail, §2.1): the four styles render as a tidy 2×2
|
||||||
|
grid of compact `size="mini"` keyboards (two per column fit the
|
||||||
|
~451px row interior). Non-dense keeps today's flex-wrap gallery at
|
||||||
|
size="thumb" — byte-identical. */}
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={`${name} piano voicings — every style shown, each playable`}
|
||||||
|
className={dense ? 'grid grid-cols-2 gap-1.5' : 'flex flex-wrap items-stretch gap-2'}
|
||||||
|
>
|
||||||
|
{/* pianoVoicing() output carries no rootPc, and without it VoicingPiano
|
||||||
|
falls back to the LOWEST voice for its "R" badge — wrong for rootless
|
||||||
|
voicings, whose bass is the 3rd (A) or 7th (B). Supply the chord root. */}
|
||||||
|
{pianoOptions.map(({ style, voicing }) => (
|
||||||
|
<GalleryCell
|
||||||
|
key={style}
|
||||||
|
label={voicing.label}
|
||||||
|
recommended={recStyle !== null && style === recStyle}
|
||||||
|
dense={dense}
|
||||||
|
>
|
||||||
|
<MiniPiano voicing={{ ...voicing, rootPc: pc }} size={dense ? 'mini' : 'thumb'} />
|
||||||
|
</GalleryCell>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── show='bass' (D-41, D-40 §3): no gallery would be honest — say so
|
||||||
|
in one line instead of rendering guitar+piano under BASS. ── */}
|
||||||
|
{!showGuitar && !showPiano && (
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
No bass voicings for {name} yet — authored bass patterns are on the way
|
||||||
|
(blues first). Guitar and piano voicings live under those instruments.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
# KB Authoring Contract
|
||||||
|
|
||||||
|
Every knowledgebase cell must conform to this schema and pass `node scripts/validate-kb.mjs`. The gold-standard exemplar is `jazz/` — imitate it. Background and rationale: `docs/kb-plan.md`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/data/kb/<style>/
|
||||||
|
meta.js — style identity
|
||||||
|
progressions.js — the style's standard progressions (instrument-independent)
|
||||||
|
guitar.js — instrument packs (piano.js, bass.js as cells are completed)
|
||||||
|
```
|
||||||
|
|
||||||
|
Register each style in `src/data/kb/index.js`. The UI reads only the registry.
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
1. **Key-agnostic.** Degrees and movable shapes only — never absolute chord names in data. Open guitar shapes are the one exception (they declare `onlyRoot`, a pitch class, and render only in matching keys).
|
||||||
|
2. **Qualities** must be keys of `CHORD_TYPES` in `src/lib/theory.js` (`maj`, `min`, `dom7`, `maj7`, `min7`, `dim`, `dim7`, `half_dim`, `aug`, `sus4`, `sus2`, `maj6`, `min6`, `add9`).
|
||||||
|
3. **Intermediate level.** Guitar: fret span ≤ 4 within a shape. Piano: one hand per recipe stays within a 10th. If a play is harder, provide an easier alternative in the same play set.
|
||||||
|
4. Plays for the same progression must be **idiomatically different** (register, density, technique) — not transpositions of each other.
|
||||||
|
|
||||||
|
## meta.js
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
id: 'jazz', // folder name
|
||||||
|
label: 'Jazz',
|
||||||
|
feel: 'swing', // swing | straight | shuffle | 16th | bossa…
|
||||||
|
tempoRange: [110, 230],
|
||||||
|
character: 'One sentence on what makes the style sound like itself.',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## progressions.js
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
id: 'jazz-251-major', // '<style>-<slug>', globally unique
|
||||||
|
name: 'ii–V–I',
|
||||||
|
rn: ['ii7', 'V7', 'Imaj7'], // display numerals
|
||||||
|
degrees: [2, 7, 0], // semitone offsets from key root, 0–11
|
||||||
|
qualities: ['min7', 'dom7', 'maj7'],
|
||||||
|
bars: [1, 1, 2], // same length as degrees
|
||||||
|
mode: 'major', // major | minor | dorian | mixolydian | …
|
||||||
|
songs: ['Autumn Leaves'],
|
||||||
|
tip: 'One transferable idea.',
|
||||||
|
level: 'intermediate', // OPTIONAL: 'foundation' | 'intermediate'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
4–8 progressions per style. Cross-check `docs/progression-repertoire.md` §1.
|
||||||
|
|
||||||
|
**`level` (optional).** Tags the progression's difficulty for the in-app level
|
||||||
|
filter. Allowed values: `'foundation'` (the style's bread-and-butter loops) or
|
||||||
|
`'intermediate'` (secondary dominants, chained ii–Vs, backdoor, borrowed
|
||||||
|
chords…). **Omitting the field is fine and means `'foundation'`** — consumers
|
||||||
|
treat an absent `level` as foundation, so existing styles need no edits. The
|
||||||
|
validator only checks the value when the field is present.
|
||||||
|
|
||||||
|
## Instrument packs
|
||||||
|
|
||||||
|
Common envelope:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
styleIntro: '2-3 sentences on this instrument's role in the style.',
|
||||||
|
comping: [{ label, rhythm, description }], // ≥1 named rhythm
|
||||||
|
plays: { '<progression-id>': [ <play>, <play> ] }, // ≥2 plays per progression (bass: ≥1 — see Bass play)
|
||||||
|
improv: { // guitar/piano; optional for bass
|
||||||
|
scales: [{ over: 'ii7', scale: 'dorian', why }],
|
||||||
|
targetNotes: '…',
|
||||||
|
licks: [{ tab/notation, description, over: '<progression-id>', source }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Guitar play
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
label: 'Shell voicings',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [ // one per progression step
|
||||||
|
{
|
||||||
|
shape: {
|
||||||
|
// movable: fret offsets relative to the root fret; 'x' = muted
|
||||||
|
rootStr: 6, // string carrying the root, 6 = low E
|
||||||
|
offsets: [0, 'x', 0, 0, 'x', 'x'], // ALWAYS 6 entries, low E first
|
||||||
|
fingers: [1, 0, 2, 3, 0, 0],
|
||||||
|
// open shapes instead use: frets: [...absolute], onlyRoot: <pc 0-11>
|
||||||
|
},
|
||||||
|
extensions: ['9'], // declared color tones beyond the quality (validator allows only these)
|
||||||
|
// declared omissions (honest data, surfaced by the UI):
|
||||||
|
// rootless: true — shape omits the root (e.g. guide-tone grips)
|
||||||
|
// omit3: true — shape omits the 3rd (e.g. power chords; works over major or minor)
|
||||||
|
note: 'root–♭7–♭3',
|
||||||
|
},
|
||||||
|
// …
|
||||||
|
],
|
||||||
|
tips: 'Voice-leading or ensemble advice.',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Piano play
|
||||||
|
|
||||||
|
Voicings are degree recipes resolved through the chord quality. Degrees: `'1' '3' '5' '7'` resolve per quality (e.g. `'3'` → ♭3 for min7); altered/extended degrees are explicit: `'b9' '9' '#9' '11' '#11' 'b13' '13' '6'`.
|
||||||
|
|
||||||
|
> **Resolver quirk (both hand-synced `resolveDegree` copies —
|
||||||
|
> `scripts/validate-kb.mjs` and `src/components/JamGuide.jsx`):** on
|
||||||
|
> `maj6`/`min6` the degree `'7'` resolves to **9 semitones, i.e. the 6th**
|
||||||
|
> (those qualities have no 7th in their interval set, and the resolver takes
|
||||||
|
> the top stack tone as the "7 slot"). It never misspells the chord, but when
|
||||||
|
> you mean the 6th, write `'6'` explicitly — don't lean on `'7'`. This applies
|
||||||
|
> equally to bass patterns below, which share the same resolver.
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
label: 'Rootless A/B alternation',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: ['3', '5', '7', '9'] }, note: 'Type A' },
|
||||||
|
{ recipe: { LH: ['7', '9', '3', '13'] }, note: 'Type B' },
|
||||||
|
],
|
||||||
|
register: 'top note between C4 and C5',
|
||||||
|
tips: '…',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bass play
|
||||||
|
|
||||||
|
A bass play is per-station patterns: `chords` has **one entry per progression
|
||||||
|
step** (like guitar/piano plays), each carrying the ordered notes the bassist
|
||||||
|
plays over that chord.
|
||||||
|
|
||||||
|
Patterns are **degree-based** — the piano-recipe language — not string/fret
|
||||||
|
tab. Why: hard rule 1. A bass play renders over every station of a *detected*
|
||||||
|
loop in the *detected* key, so the data must transpose automatically; and a
|
||||||
|
degree either resolves through the chord quality or it doesn't — you cannot
|
||||||
|
misspell a pitch class, only mislabel your intent. Fret tab was considered and
|
||||||
|
rejected: absolute frets are key-specific (licks are the one documented
|
||||||
|
exception, and they say so), and a movable-fret variant breaks at the nut —
|
||||||
|
exactly where bass lives, on open strings. The open-string/position idiom
|
||||||
|
belongs in authoring prose: the optional `position` hint.
|
||||||
|
|
||||||
|
Rendering convention (for the per-station pattern card): standard 4-string
|
||||||
|
tuning **E–A–D–G**, strings numbered **1 = G (highest) … 4 = E (lowest)** —
|
||||||
|
the same "1 = highest string" convention as lick tab and `rootStr`, so no
|
||||||
|
third counting scheme exists in this codebase. The renderer places each
|
||||||
|
pattern in the lowest playable position within frets 0–15; the data itself
|
||||||
|
never encodes strings or frets.
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
label: 'Boogie cell',
|
||||||
|
level: 'foundation',
|
||||||
|
feel: 'swung 8ths, locked with the kick', // REQUIRED — the groove in one line
|
||||||
|
chords: [ // one per progression step
|
||||||
|
{
|
||||||
|
pattern: [ // the ORDERED notes (played first → last)
|
||||||
|
{ deg: '1', beat: 1 }, // chord/color tone, resolved through the quality
|
||||||
|
{ deg: '3', beat: 2 },
|
||||||
|
{ deg: '5', beat: 3, technique: 'ghost-note' }, // technique optional (lick vocabulary)
|
||||||
|
{ approach: 'chrom-below', beat: 4 }, // leads into the NEXT station's root
|
||||||
|
],
|
||||||
|
note: 'walk up into the IV', // optional, as in guitar/piano plays
|
||||||
|
},
|
||||||
|
// …
|
||||||
|
],
|
||||||
|
position: 'first five frets; open E and A when the key allows', // OPTIONAL prose hint
|
||||||
|
tips: 'Transferable idea.',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field rules (all enforced by `node scripts/validate-kb.mjs` when a `bass.js`
|
||||||
|
pack exists — a style without one is complete and valid):
|
||||||
|
|
||||||
|
| Field | Rule |
|
||||||
|
|---|---|
|
||||||
|
| `feel` | required non-empty string — bass is a rhythm role; the pattern alone doesn't say swung vs straight |
|
||||||
|
| `chords` | array, exactly one entry per progression step |
|
||||||
|
| `pattern` | non-empty ordered array; ≤ 8 notes per bar of its step (straight-8ths density cap — rule 3) |
|
||||||
|
| note shape | exactly one of `deg` \| `approach` per note |
|
||||||
|
| `deg` | a degree **string** from the piano-recipe language above, resolved through the step's quality (`'1' '3' '5' '7'` quality-resolved; `'b3' '6' 'b7' 'b9' '9' '#9' '11' '#11' 'b5' 'b13' '13'` explicit). The maj6/min6 `'7'` quirk above applies — write `'6'` |
|
||||||
|
| root rule | every pattern states `'1'` at least once — a bassline grounds the chord (a deliberately rootless play needs a schema change, not silence) |
|
||||||
|
| `octave` | optional on `deg` notes, `0` (default) or `1`; the resolved offset `deg + 12·octave` must stay **≤ 19 semitones** (an octave + a fifth) so every pattern sits on E–A–D–G within frets 0–15 in one position |
|
||||||
|
| `approach` | `'chrom-below'` (next root − 1 semitone) · `'chrom-above'` (next root + 1) · `'fifth-of-next'` (next root + 7). The pitch is **derived from the next station's root** (last step wraps to the first), never authored — that's why the validator can allow a non-chord tone here while untyped chromatics stay illegal. Approach notes must be the **final note(s)** of the pattern — they lead into the next chord |
|
||||||
|
| `beat` | optional number, `1 ≤ beat < 4·bars + 1` for that step (patterns are notated in 4 — a 12/8 shuffle is `feel`, not extra beats); non-decreasing in pattern order (equal beats = a dyad) |
|
||||||
|
| `technique` | optional per note, from the fixed lick technique vocabulary (see Licks) |
|
||||||
|
|
||||||
|
**Coverage: every progression of the style needs ≥ 1 bass play** — deliberately
|
||||||
|
1, not the guitar/piano 2. The band wants ONE bassline at a time, and rule 4's
|
||||||
|
"idiomatically different" bar is hard to clear twice per progression without
|
||||||
|
filler. A second play is welcome where a genuinely different lane exists
|
||||||
|
(two-feel vs walking, say) — the validator sets a floor, not a ceiling.
|
||||||
|
|
||||||
|
## Licks (optional)
|
||||||
|
|
||||||
|
A style's instrument pack may also teach short, named licks — the ordered-note
|
||||||
|
phrases the Licks & Techniques cards render. **The whole section is optional**:
|
||||||
|
a pack without licks is complete and valid. Guitar licks are tab-based (this
|
||||||
|
section); piano licks are degree-based (next section).
|
||||||
|
|
||||||
|
**How to register licks:** add a `licks` array as one more top-level key on the
|
||||||
|
instrument pack's default export (next to `styleIntro`/`comping`/`plays`/`improv`
|
||||||
|
in e.g. `src/data/kb/blues/guitar.js`). No change to `src/data/kb/index.js` is
|
||||||
|
needed — the registry already exposes the whole pack, and the UI reads
|
||||||
|
`kb[style].instruments.guitar.licks ?? []`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
styleIntro: '…',
|
||||||
|
comping: [ /* … */ ],
|
||||||
|
plays: { /* … */ },
|
||||||
|
improv: { /* … */ },
|
||||||
|
|
||||||
|
// OPTIONAL — structured licks (this section):
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
id: 'blues-box1-bb-answer', // '<style>-<slug>', globally unique
|
||||||
|
// (shares ONE id namespace with progression ids)
|
||||||
|
name: 'B.B. box answer phrase',
|
||||||
|
level: 'foundation', // 'foundation' | 'intermediate' (required)
|
||||||
|
chordContext: 'over the I7', // which chord/station it fits — free text,
|
||||||
|
// e.g. 'dom7' or 'over the V7 turnaround'
|
||||||
|
techniques: ['bend', 'vibrato'], // summary tags, from the fixed vocabulary below
|
||||||
|
source: 'the B.B. King box, e.g. "The Thrill Is Gone" fills', // recommended attribution
|
||||||
|
tab: [ // the ORDERED note sequence (played first → last)
|
||||||
|
{ string: 2, fret: 8 }, // string 1 = high e … 6 = low E
|
||||||
|
{ string: 1, fret: 8, technique: 'bend' }, // fret 0 (open) … 15
|
||||||
|
{ string: 1, fret: 10, technique: 'vibrato' }, // technique is optional per note
|
||||||
|
{ string: 2, fret: 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field rules (all enforced by `node scripts/validate-kb.mjs` when `licks` is present):
|
||||||
|
|
||||||
|
| Field | Rule |
|
||||||
|
|---|---|
|
||||||
|
| `id` | starts with `'<style>-'`; globally unique across **all** progression and lick ids in the whole KB |
|
||||||
|
| `name` | required, non-empty |
|
||||||
|
| `level` | `'foundation'` or `'intermediate'` — nothing else |
|
||||||
|
| `chordContext` | required, non-empty string — tells the player *where* the lick lands |
|
||||||
|
| `techniques` | array; every entry from the fixed vocabulary below (empty array = plain-picked) |
|
||||||
|
| `tab` | non-empty ordered array of `{string, fret, technique?}` |
|
||||||
|
| `tab[].string` | integer 1–6 (**1 = high e, 6 = low E** — standard tab convention; note `rootStr` in shapes counts the same way) |
|
||||||
|
| `tab[].fret` | integer 0–15 (0 = open string) |
|
||||||
|
| `tab[].technique` | optional; from the vocabulary; must **also** appear in the lick's `techniques[]` summary so card tags stay honest |
|
||||||
|
| `source` | optional but recommended — name where the lick comes from (checklist: nothing invented) |
|
||||||
|
|
||||||
|
**Fixed technique vocabulary** (both for `techniques[]` and per-note `technique`
|
||||||
|
— the validator rejects anything else):
|
||||||
|
|
||||||
|
`hammer-on` · `pull-off` · `slide` · `bend` · `double-stop` · `ghost-note` · `chromatic-approach` · `vibrato`
|
||||||
|
|
||||||
|
Like shapes, licks are **key-agnostic in spirit**: write them where they sit in
|
||||||
|
the style's home position and say in `chordContext` which chord they fit; the
|
||||||
|
tab renders as absolute string/fret positions.
|
||||||
|
|
||||||
|
Note: the older freeform `improv.licks` (prose `{tab/notation, description,
|
||||||
|
over, source}`) is unchanged and still welcome — it feeds the improv text
|
||||||
|
section. This top-level `licks` array is the *structured* shape that the lick
|
||||||
|
cards render and the validator checks.
|
||||||
|
|
||||||
|
## Piano licks (optional, degree-based)
|
||||||
|
|
||||||
|
A piano pack's `licks` array uses the **degree language**, not tab — the same
|
||||||
|
reasoning as bass patterns (hard rule 1): a lick renders over a *detected*
|
||||||
|
chord in the *detected* key, so the data must transpose automatically, and a
|
||||||
|
degree either resolves through the stated quality or it doesn't — you cannot
|
||||||
|
misspell a pitch, only mislabel your intent. Registration is identical to
|
||||||
|
guitar licks: a top-level `licks` key on `<style>/piano.js`; no registry
|
||||||
|
change; the section is optional.
|
||||||
|
|
||||||
|
Unlike guitar licks (whose tab is instrument-truth and needs no quality),
|
||||||
|
degree-based licks **require an explicit `quality`** — a `CHORD_TYPES` key —
|
||||||
|
so the validator can resolve every degree. `chordContext` stays the human
|
||||||
|
sentence; `quality` is the machine truth. They must agree by eye ("over the
|
||||||
|
ii7" → `min7`); the validator can only check the machine half.
|
||||||
|
|
||||||
|
```js
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
id: 'jazz-enclosure-into-3', // '<style>-<slug>', same global id namespace
|
||||||
|
name: 'Bebop enclosure into the 3rd',
|
||||||
|
level: 'intermediate', // 'foundation' | 'intermediate'
|
||||||
|
chordContext: 'over the ii7', // human text, as in guitar licks
|
||||||
|
quality: 'min7', // REQUIRED — CHORD_TYPES key every deg resolves through
|
||||||
|
techniques: ['grace-note'], // summary tags, PIANO vocabulary (below)
|
||||||
|
source: 'Barry Harris workshop vocabulary', // recommended attribution
|
||||||
|
notes: [ // the ORDERED melodic line (played first → last)
|
||||||
|
{ deg: '5', octave: 1, beat: 1 },
|
||||||
|
{ approach: 'chrom-above', beat: 2 }, // targets the NEXT deg note
|
||||||
|
{ approach: 'chrom-below', beat: 2.5 }, // enclosure: above, below…
|
||||||
|
{ deg: '3', octave: 1, beat: 3, technique: 'grace-note' }, // …target
|
||||||
|
],
|
||||||
|
tips: 'Optional transferable idea.', // optional, unlike guitar licks
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Field rules (enforced by `node scripts/validate-kb.mjs` when a piano pack has
|
||||||
|
`licks`; guitar packs keep the tab rules above — the validator routes by
|
||||||
|
instrument):
|
||||||
|
|
||||||
|
| Field | Rule |
|
||||||
|
|---|---|
|
||||||
|
| `id` `name` `level` `chordContext` | exactly as guitar licks (shared global id namespace) |
|
||||||
|
| `quality` | required, a `CHORD_TYPES` key — the context every `deg` resolves through |
|
||||||
|
| `techniques` | array; every entry from the **piano vocabulary** below (empty = plain) |
|
||||||
|
| `notes` | non-empty ordered array; ≤ 16 notes (8ths over the 2-bar beat window — rule 3); the **final note must be a `deg` note** |
|
||||||
|
| note shape | exactly one of `deg` \| `approach` per note |
|
||||||
|
| `deg` | a degree **string** from the piano-recipe language, resolved through `quality` (the maj6/min6 `'7'` quirk applies — write `'6'`) |
|
||||||
|
| `octave` | optional on `deg` notes, `0` (default) \| `1` \| `2` |
|
||||||
|
| range cap | every resolved offset — `deg`: `pc + 12·octave`; `approach`: derived (below) — must sit in **[0, 25]** semitones above the root. Proof: MiniPiano's render window is absolute notes [0, 36] (0 = low C); placing the root at its pitch class in the bottom octave (0–11), the highest possible note is 11 + 25 = 36 — the window's top key — so **every lick fits the window in all 12 keys**. (Note `octave: 2` is only legal where the cap allows — e.g. ending on the high root `'1'`.) |
|
||||||
|
| `approach` | `'chrom-below'` (target − 1 semitone) · `'chrom-above'` (target + 1). The **target is the next `deg` note** in `notes[]` order, scanning past intervening approaches — so `chrom-above, chrom-below, deg` is the classic enclosure and both approaches frame the same target. The pitch is always **derived**, never authored (that's why a non-chord tone is legal here while untyped chromatics stay illegal). Guitar licks have no "next station", so unlike bass approaches these target a note *inside* the lick — and therefore **cannot close it** (nothing to target). Consecutive approaches must **differ in type** (two of the same type would derive the identical pitch — write the note you mean as a `deg` instead). No `octave` on approach notes. |
|
||||||
|
| `beat` | optional number, `1 ≤ beat < 9` (a lick spans at most two 4/4 bars); non-decreasing in order (equal beats = grace-note placement or a dyad) |
|
||||||
|
| `technique` | optional per note, from the piano vocabulary; must also appear in `techniques[]` (same honesty rule as guitar) |
|
||||||
|
| `source` | optional but recommended (checklist: nothing invented) |
|
||||||
|
| `tips` | optional string |
|
||||||
|
|
||||||
|
No root rule (unlike bass): a melodic line targets 3rds and 7ths; grounding
|
||||||
|
the root is the bassist's job.
|
||||||
|
|
||||||
|
**Piano technique vocabulary** (a distinct list from the guitar vocabulary —
|
||||||
|
keys don't bend, hammer, or sustain vibrato; the shared words keep their
|
||||||
|
guitar-lick meanings):
|
||||||
|
|
||||||
|
`slide` (the blues key-slip, off a black key onto its neighbour) ·
|
||||||
|
`double-stop` (two keys struck together) · `ghost-note` (barely-voiced filler)
|
||||||
|
· `grace-note` (crushed ornament into the main note — piano-specific)
|
||||||
|
|
||||||
|
The first three are shared with the guitar vocabulary and mean the same thing;
|
||||||
|
`grace-note` exists only here. The smoke test guards both lists (and, once
|
||||||
|
`PianoLickCard.jsx` exists, its exported `PIANO_TECHNIQUE_VOCAB` must be
|
||||||
|
set-equal to the validator's `PIANO_LICK_TECHNIQUES` — same hand-sync rule as
|
||||||
|
LickCard).
|
||||||
|
|
||||||
|
## Musician checklist (self-review before committing)
|
||||||
|
|
||||||
|
- [ ] Plays per progression genuinely differ in register/density/technique
|
||||||
|
- [ ] Every shape/recipe is playable by intermediate hands (rule 3)
|
||||||
|
- [ ] The style is recognizable from the rhythm descriptions alone (bossa ≠ jazz with new labels)
|
||||||
|
- [ ] Every tip teaches a transferable idea (voice leading, register, space), not just "play this"
|
||||||
|
- [ ] Songs/licks have sources; nothing invented
|
||||||
|
- [ ] Lick techniques use only the fixed vocabulary; every lick is playable as written (strings 1–6, frets 0–15, in order)
|
||||||
|
- [ ] Piano licks state their `quality`, keep approaches typed and non-terminal, and stay within [0, 25] semitones of the root
|
||||||
|
- [ ] Bass patterns state the root, keep approaches typed and terminal, and stay within an octave + a fifth of the root
|
||||||
|
- [ ] `node scripts/validate-kb.mjs` green; `npm run build` green
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
// Blues bass pack (task P-41) — the first bass cell. Patterns are degree-based
|
||||||
|
// per SCHEMA.md "Bass play": every note is either a `deg` resolved through the
|
||||||
|
// step's quality or a typed terminal `approach` whose pitch derives from the
|
||||||
|
// NEXT station's root (chrom-below = next−1, chrom-above = next+1,
|
||||||
|
// fifth-of-next = next+7). Every pitch-class and interval claim in the notes,
|
||||||
|
// feels and tips below was computed in a reference key (C) before writing:
|
||||||
|
// I=C7, IV=F7, V=G7; minor form Cm7/Fm7/A♭7/G7; turnaround C7/A7/Dm7/G7.
|
||||||
|
//
|
||||||
|
// Lines and treatments sourced from:
|
||||||
|
// Ed Friedland, "Blues Bass — A Guide to the Essential Styles and Techniques"
|
||||||
|
// (Hal Leonard Bass Method Stylistic Supplement, 2005) — the boogie/shuffle
|
||||||
|
// cells (R–3–5–6, R–5–6), two-feel, walkups, and the turnaround treatments;
|
||||||
|
// its studied repertoire includes this cell's progression songs ("Sweet Home
|
||||||
|
// Chicago", "Pride and Joy", "Hide Away", "The Thrill Is Gone");
|
||||||
|
// Ed Friedland, "Building Walking Bass Lines" (Hal Leonard) — the approach-note
|
||||||
|
// method this schema's typed approaches encode (chromatic below/above,
|
||||||
|
// dominant/fifth approach into the next root);
|
||||||
|
// Jerry Jemmott (bass on B.B. King's "Completely Well" sessions, 1969 — "The
|
||||||
|
// Thrill Is Gone"): Jemmott describes his defining move on that record as the
|
||||||
|
// half-step-below approach slid into the tonic (Guitar World bassist
|
||||||
|
// interviews) — the minor play's approach language;
|
||||||
|
// Willie Dixon — the Chess Records lane (upright behind Muddy Waters and on
|
||||||
|
// Chuck Berry sessions) that the quarter-note and eighth-note boogie lines
|
||||||
|
// double; the ramble figure itself is the boogie-woogie piano left hand
|
||||||
|
// (Pinetop Smith lineage — see blues/piano.js) moved to the bass;
|
||||||
|
// Tommy Shannon (Double Trouble) — the driving-shuffle lane the eighth-note
|
||||||
|
// ramble lives in on "Pride and Joy"-style Texas blues.
|
||||||
|
// Pedagogy frame: docs/learn-curriculum.md — lock with the kick, target the
|
||||||
|
// changes, walk as a signal rather than a default.
|
||||||
|
|
||||||
|
// Reusable cells (degree strings; quarters unless noted). Reference key C.
|
||||||
|
const BOOGIE = [ // 1–3–5–6: C E G A — the major-triad walkup plus the 6th
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 2 }, { deg: '5', beat: 3 }, { deg: '6', beat: 4 },
|
||||||
|
]
|
||||||
|
const CHI_CELL = [ // 1–5–6–5: C G A G — the bass half of the Jimmy Reed dyad rock
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '5', beat: 2 }, { deg: '6', beat: 3 }, { deg: '5', beat: 4 },
|
||||||
|
]
|
||||||
|
const RAMBLE = [ // 1–3–5–6–♭7–6–5–3 in swung 8ths: C E G A B♭ A G E
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 1.5 }, { deg: '5', beat: 2 }, { deg: '6', beat: 2.5 },
|
||||||
|
{ deg: 'b7', beat: 3 }, { deg: '6', beat: 3.5 }, { deg: '5', beat: 4 }, { deg: '3', beat: 4.5 },
|
||||||
|
]
|
||||||
|
const R5_OCT = [ // slow 12/8 pillar: root, fifth, octave (offsets 0, 7, 12)
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '5', beat: 3 }, { deg: '1', octave: 1, beat: 4 },
|
||||||
|
]
|
||||||
|
const BOX_RIFF = [ // 1–3–4–5, quality-resolved: Cm7 → C E♭ F G; G7 → G B C D
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 2 }, { deg: '11', beat: 3 }, { deg: '5', beat: 4 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default {
|
||||||
|
styleIntro:
|
||||||
|
'Blues bass is the handshake between the kick drum and the chord: roots on the strong beats, everything else in service of the next change. The craft is targeting — knowing which note pulls the band into bar 5, bar 9, and the top of the next chorus — and the discipline is repetition: pick a cell, lock it with the drummer, and let the form (not your fingers) provide the variety.',
|
||||||
|
|
||||||
|
comping: [
|
||||||
|
{
|
||||||
|
label: 'Two-feel (root and fifth)',
|
||||||
|
rhythm: 'half-note pulse: root on 1, fifth on 3',
|
||||||
|
description:
|
||||||
|
'The oldest blues bass job: two notes a bar, fat and unhurried. The fifth can sit above or below the root (same pitch class); the groove lives in note length, not note count. Default lane for slow blues and country blues.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Quarter-note boogie',
|
||||||
|
rhythm: 'four swung quarters, locked with the kick',
|
||||||
|
description:
|
||||||
|
'One note per beat walking the chord — 1–3–5–6 or 1–5–6–5 cells transposed to each station. The shuffle swing lives in the drummer; the bass plays even quarters and lets note placement carry the lean.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '12/8 slow blues',
|
||||||
|
rhythm: 'beats 1 and 3 weighted; triplet air between',
|
||||||
|
description:
|
||||||
|
'At slow-blues tempo every beat splits in three. The bass states less, not more: root and fifth as pillars, then a single pickup note — usually a half-step under the next root — sliding the band into the change. The Jerry Jemmott "Thrill Is Gone" lane.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plays: {
|
||||||
|
'blues-12bar': [
|
||||||
|
{
|
||||||
|
label: 'Quarter-note boogie with walking seams',
|
||||||
|
level: 'foundation',
|
||||||
|
feel: 'medium shuffle — even quarters locked with the kick; the walkups do the leaning',
|
||||||
|
chords: [
|
||||||
|
{ pattern: BOOGIE, note: '1–3–5–6 — the triad plus the 6th; home base' },
|
||||||
|
{ pattern: BOOGIE },
|
||||||
|
{ pattern: BOOGIE },
|
||||||
|
{
|
||||||
|
// C D E♭ E → F: whole step then three half-steps, the classic blue-note walkup.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '9', beat: 2 }, { deg: 'b3', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'walk up into the IV: 1–2–♭3, then the half-step under its root',
|
||||||
|
},
|
||||||
|
{ pattern: BOOGIE, note: 'same cell, new root — the boogie transposes, it never changes' },
|
||||||
|
{
|
||||||
|
// F G A B → C: 1–2–3 of the IV, then the half-step under home.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '9', beat: 2 }, { deg: '3', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'walk home: 1–2–3, then a half-step under the I',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// C A B♭ B → C: the 6–♭7–7–1 climb. The chord's 7th degree is out of the
|
||||||
|
// degree vocabulary over dom7, so the final half-step is TYPED — the
|
||||||
|
// approach derives its pitch from bar 8's root, which is the same I.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '6', beat: 2 }, { deg: 'b7', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'the boogie climb: 6–♭7, then chromatically under the octave root of bar 8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// C D E F♯ → G: same 1–2–3 walkup aimed at the V; F♯ is the key's ♯4.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '9', beat: 2 }, { deg: '3', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'bar 8 aims at the V: 1–2–3, then the half-step under its root',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// G B D E → F: up the V triad; E is the half-step under the IV's root.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 2 }, { deg: '5', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'up the triad, then duck a half-step under the IV',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// F A C B → C: up the IV triad to the key's root, dip under it, resolve.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 2 }, { deg: '5', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'the IV\'s 5th IS the key\'s root — touch it, dip under, land',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// C B♭ A A♭ → G: the turnaround descent 1–♭7–6–♭6, the ♭6 typed as the
|
||||||
|
// approach from above (V root + 1). Four consecutive tones: whole step,
|
||||||
|
// half step, half step, then the half-step sink onto the V.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: 'b7', beat: 2 }, { deg: '6', beat: 3 },
|
||||||
|
{ approach: 'chrom-above', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'the turnaround descent: 1–♭7–6–♭6 sinking onto the V',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// G G A B → C: pedal the V, then walk the key's 6th and 7th home.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '1', beat: 2 }, { deg: '9', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'double the root, then climb the last two steps into the next chorus',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
position: 'one position around the key root; open E and A do the low roots when the key allows',
|
||||||
|
tips: 'Approach-note targeting is the whole craft: beats 1–3 belong to THIS chord, beat 4 belongs to the NEXT one. Every seam here is the same move — walk toward the coming root and place a half-step under it — so once your ear owns bar 4, bars 6, 8, 9 and 10 are free. The one seam that differs is bar 11: the classic descent (1–♭7–6–♭6) approaches the V from a half-step ABOVE, because the ♭6 of the key already lives there.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Eighth-note ramble (jump / rock\'n\'roll)',
|
||||||
|
level: 'intermediate',
|
||||||
|
feel: 'driving swung 8ths — the boogie-woogie piano left hand on four strings, doubling the guitar\'s ramble',
|
||||||
|
chords: [
|
||||||
|
{ pattern: RAMBLE, note: 'up 1–3–5–6–♭7 and back down — peaks a minor 7th above the root' },
|
||||||
|
{ pattern: RAMBLE }, { pattern: RAMBLE },
|
||||||
|
{ pattern: RAMBLE, note: 'the last 8th (the 3rd) is already a half-step under the IV — the figure walks itself into bar 5' },
|
||||||
|
{ pattern: RAMBLE }, { pattern: RAMBLE },
|
||||||
|
{ pattern: RAMBLE }, { pattern: RAMBLE },
|
||||||
|
{
|
||||||
|
// G B D E F E D E → F: bar 9's tail bends — the V's closing 3rd (B) sits a
|
||||||
|
// tritone from the IV's root (F), so the last 8th becomes the chromatic
|
||||||
|
// approach (E) and the bar ends walking 5–6 → root of the IV.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 1.5 }, { deg: '5', beat: 2 }, { deg: '6', beat: 2.5 },
|
||||||
|
{ deg: 'b7', beat: 3 }, { deg: '6', beat: 3.5 }, { deg: '5', beat: 4 },
|
||||||
|
{ approach: 'chrom-below', beat: 4.5 },
|
||||||
|
],
|
||||||
|
note: 'V into IV is the figure\'s one rough seam (a tritone) — swap the last 8th for the half-step under the IV',
|
||||||
|
},
|
||||||
|
{ pattern: RAMBLE },
|
||||||
|
{ pattern: RAMBLE },
|
||||||
|
{ pattern: RAMBLE, note: 'bar 12 needs no edit: the closing 3rd of the V is a half-step under the returning tonic — the push is built in' },
|
||||||
|
],
|
||||||
|
position: 'one position; the figure spans root to ♭7, so it sits on two strings',
|
||||||
|
tips: 'The ramble is an agreement, not a line — guitar, piano left hand and bass all play it in octaves and nobody varies it (that IS the sound: Chess-session discipline, Willie Dixon behind Chuck Berry). Note what the figure buys you for free: it closes every bar on the chord\'s 3rd, which into the IV — and from the V back home — is already the chromatic approach. Only bar 9\'s V-to-IV seam needs a repair, and one eighth note fixes it.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'blues-quickchange': [
|
||||||
|
{
|
||||||
|
label: 'Chicago shuffle cell (R–5–6–5)',
|
||||||
|
level: 'foundation',
|
||||||
|
feel: 'Chicago shuffle — fat quarters, the 6th answering the 5th; the bass half of the Jimmy Reed rock',
|
||||||
|
chords: [
|
||||||
|
{
|
||||||
|
// C G A E → F: the cell with its 4th beat swapped for the approach —
|
||||||
|
// E, a half-step under the IV's root, is the I's own major 3rd.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '5', beat: 2 }, { deg: '6', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'beat 4 announces the quick change — and that approach note is the I\'s own 3rd',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// F C D D♭ → C: 6th, then chromatic slide D–D♭–C back home from above.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '5', beat: 2 }, { deg: '6', beat: 3 },
|
||||||
|
{ approach: 'chrom-above', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'exit by half-steps: the IV\'s 6th slides 6–♭6 onto the I\'s root',
|
||||||
|
},
|
||||||
|
{ pattern: CHI_CELL, note: 'home — root, fifth, sixth, fifth' },
|
||||||
|
{ pattern: CHI_CELL, note: 'plain into bar 5: the cell\'s last 5th falls a whole step onto the IV\'s root' },
|
||||||
|
{ pattern: CHI_CELL },
|
||||||
|
{
|
||||||
|
// F C D D♭ → C — the same exit as bar 2.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '5', beat: 2 }, { deg: '6', beat: 3 },
|
||||||
|
{ approach: 'chrom-above', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'the bar-2 exit again — the quick change taught you this one early',
|
||||||
|
},
|
||||||
|
{ pattern: CHI_CELL },
|
||||||
|
{
|
||||||
|
// C G A F♯ → G: cell into the half-step under the V.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '5', beat: 2 }, { deg: '6', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: '6 down to ♯4, half-step under the V',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// G B D E → F: up the V triad, then a half-step under the IV.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 2 }, { deg: '5', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'V to IV: up the triad, duck under',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// F A C B → C: up the IV triad, dip under home, land.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 2 }, { deg: '5', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'touch the key\'s root (the IV\'s 5th), dip a half-step, resolve',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// C G A F♯ → G — the bar-8 walkup again; bar 11 aims at the V.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '5', beat: 2 }, { deg: '6', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'same aim as bar 8 — the turnaround\'s V arrives on schedule',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// G F E D → C: 1–♭7–6–5 of the V = the key's 5–4–3–2 walking down to home.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: 'b7', beat: 2 }, { deg: '6', beat: 3 },
|
||||||
|
{ deg: '5', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'walk straight down the scale — the key\'s 5–4–3–2 — into the next chorus',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
position: 'first five frets; the R–5–6 cell sits across two strings in one hand position',
|
||||||
|
tips: 'The R–5–6–5 rock is the same figure the rhythm guitarist plays as dyads (the Jimmy Reed shuffle) — you are the bottom note of that conversation, so match the guitarist\'s swing exactly or the whole band flams. The quick change is a hearing exam: beat 4 of bar 1 is where you tell the band you knew it was coming. And notice the economy — two approach flavors (half-step from below, half-step from above) cover every seam this play marks; the two it leaves plain (bars 4 and 12) already move by step on their own.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'blues-8bar': [
|
||||||
|
{
|
||||||
|
label: 'Two-feel with walking seams (Key to the Highway)',
|
||||||
|
level: 'intermediate',
|
||||||
|
feel: 'easy shuffle two-feel — roots and fifths as the default, walking only where the form moves',
|
||||||
|
chords: [
|
||||||
|
{
|
||||||
|
// C E F F♯ → G: the 1–3–4–♯4 walkup, ♯4 typed as the approach under the V.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 2 }, { deg: '11', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'the V is already in bar 2 — walk 1–3–4–♯4 straight at it',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// G B D E → F: up the V triad, half-step under the IV.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 2 }, { deg: '5', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'and immediately down to the IV — up the triad, duck under',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: [{ deg: '1', beat: 1 }, { deg: '5', beat: 3 }],
|
||||||
|
note: 'two-feel — the form finally sits still for a bar',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// F A B♭ B → C: the SAME 1–3–4–♯4 walkup — IV to I is also root motion
|
||||||
|
// up a perfect fifth, so the shape transfers note-for-note.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 2 }, { deg: '11', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'the bar-1 walkup again: IV→I is the same fifth-up motion as I→V',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: [{ deg: '1', beat: 1 }, { deg: '5', beat: 3 }],
|
||||||
|
note: 'two-feel — and beat 3 (the I\'s 5th) is already the V\'s root, naming what comes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// G D B → C: root, fifth, then the half-step under home.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '5', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'stay in the two-feel and add one pickup note under the I',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// C B♭ A A♭ → G: the turnaround descent, ♭6 typed as the approach from above.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: 'b7', beat: 2 }, { deg: '6', beat: 3 },
|
||||||
|
{ approach: 'chrom-above', beat: 4 },
|
||||||
|
],
|
||||||
|
note: '1–♭7–6–♭6 sinking onto the V — the classic descent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// G A B → C: 1–2, then the half-step under the wrap.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '9', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'push: walk 1–2–3-of-the-V shape up into the next chorus',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
position: 'first position; the walkups stay within a hand span',
|
||||||
|
tips: 'The 8-bar form punishes autopilot — the V lands in bar 2, where a 12-bar reflex expects home. So invert the usual economy: sit in the two-feel and spend motion ONLY at the seams, which makes every walkup an announcement the band can steer by. The transferable prize is the 1–3–4–♯4 walkup: it targets any root a fifth above where you stand, which is why bars 1 (I→V) and 4 (IV→I) are the identical shape on different roots.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'blues-minor': [
|
||||||
|
{
|
||||||
|
label: 'Slow-burn 12/8 (root, fifth, octave)',
|
||||||
|
level: 'foundation',
|
||||||
|
feel: '12/8 slow burn — pillars on 1 and 3, one pickup note per seam; whole-bar patience',
|
||||||
|
chords: [
|
||||||
|
{ pattern: R5_OCT, note: 'root, fifth, octave — three pillars, then air' },
|
||||||
|
{ pattern: R5_OCT },
|
||||||
|
{ pattern: R5_OCT },
|
||||||
|
{
|
||||||
|
// C G E → F: the pickup under the iv is E — outside C minor, borrowed
|
||||||
|
// for one beat as a leading tone; typed as the approach, it can't misspell.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '5', beat: 3 },
|
||||||
|
{ approach: 'chrom-below', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'the Jemmott move: a half-step under the iv, slid into its root',
|
||||||
|
},
|
||||||
|
{ pattern: R5_OCT },
|
||||||
|
{
|
||||||
|
// F C D♭ → C: approach from above — a half-step sigh onto the tonic.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '5', beat: 3 },
|
||||||
|
{ approach: 'chrom-above', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'come home from above — the ♭2 sighing a half-step onto the i',
|
||||||
|
},
|
||||||
|
{ pattern: R5_OCT },
|
||||||
|
{
|
||||||
|
// C E♭ G → A♭: spell the minor triad; its 5th already sits a half-step
|
||||||
|
// under the ♭VI's root, so the approach into bar 9 is free.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 3 }, { deg: '5', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'the i\'s own 5th is a half-step under the ♭VI — beat 4 is already the approach',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A♭ C E♭ A♭ → G: outline the new dominant, then restate the root as the
|
||||||
|
// typed approach (V root + 1 = this chord's own root) and let it sink.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 2 }, { deg: '5', beat: 3 },
|
||||||
|
{ approach: 'chrom-above', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'the drama bar: the ♭VI\'s root IS the half-step above the V — restate it and sink',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// G B D → C: the V with its leading-tone 3rd; the 5th lands home from a
|
||||||
|
// whole step above.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 3 }, { deg: '5', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'the V\'s major 3rd is the key\'s leading tone — one bar of light, then dark',
|
||||||
|
},
|
||||||
|
{ pattern: R5_OCT },
|
||||||
|
{
|
||||||
|
pattern: [{ deg: '1', beat: 1 }],
|
||||||
|
note: 'one note, whole bar — no push; the minor chorus ends at home',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
position: 'low and open; let every note ring into the triplet space',
|
||||||
|
tips: 'Minor blues is won by subtraction: three pillars a bar, and one pickup note per seam does all the storytelling. Bars 8–10 are the lesson in root motion — the i\'s 5th sits a half-step under the ♭VI, and the ♭VI\'s root sits a half-step above the V, so the form\'s whole climax is two half-steps you barely have to reach for. Jerry Jemmott built "The Thrill Is Gone" on exactly this economy: the half-step-below approach, slid — not hammered — into the target.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Minor box riff (1–♭3–4–5)',
|
||||||
|
level: 'intermediate',
|
||||||
|
feel: 'hypnotic medium groove — one four-note riff, unbroken, transposed by the form',
|
||||||
|
chords: [
|
||||||
|
{ pattern: BOX_RIFF, note: 'the first four notes of the minor pentatonic, as a bassline' },
|
||||||
|
{ pattern: BOX_RIFF }, { pattern: BOX_RIFF }, { pattern: BOX_RIFF },
|
||||||
|
{ pattern: BOX_RIFF, note: 'on the iv the riff\'s top note is the key\'s tonic — the loop points home' },
|
||||||
|
{ pattern: BOX_RIFF },
|
||||||
|
{ pattern: BOX_RIFF },
|
||||||
|
{ pattern: BOX_RIFF, note: 'ends on the 5th, a half-step under the ♭VI — the approach comes free' },
|
||||||
|
{ pattern: BOX_RIFF, note: 'the 3rd majorizes by itself on the dominant — same degrees, new quality' },
|
||||||
|
{ pattern: BOX_RIFF, note: 'and again on the V: 1–3–4–5 with the leading tone in it' },
|
||||||
|
{ pattern: BOX_RIFF },
|
||||||
|
{ pattern: BOX_RIFF, note: 'the closing 5th drops a fifth onto the wrap — the strongest landing there is' },
|
||||||
|
],
|
||||||
|
position: 'one box position — the riff never leaves a four-fret window',
|
||||||
|
tips: 'The riff lane: pick a cell and refuse to leave it — the hypnosis is the point, and the form does the arranging. Because the degrees resolve through each chord\'s quality, the identical fingering thinks for you: ♭3 on the minor chords becomes a major 3rd on the ♭VI7 and V7 without you deciding anything. That is the deepest habit this pack can teach — think in degrees, not notes, and every riff you own transposes to every chord you meet.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'blues-turnaround': [
|
||||||
|
{
|
||||||
|
label: 'Walking the cycle',
|
||||||
|
level: 'intermediate',
|
||||||
|
feel: 'swinging quarters — a jazz walk squeezed into the blues\' back door',
|
||||||
|
chords: [
|
||||||
|
{
|
||||||
|
// C E G B♭ → A: up the I7 arpeggio; the ♭7 is ALSO the half-step above
|
||||||
|
// the VI's root, so the approach note is a chord tone wearing two hats.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 2 }, { deg: '5', beat: 3 },
|
||||||
|
{ approach: 'chrom-above', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'the approach into the VI is the I\'s own ♭7 — one note, two jobs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A C♯ E G → D: the full dominant arpeggio; the ♭7 falls a perfect
|
||||||
|
// fourth onto the ii's root.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 2 }, { deg: '5', beat: 3 },
|
||||||
|
{ deg: 'b7', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'straight up the VI7 — the ♭7 lands on the ii from a fourth above',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// D F A A♭ → G: minor arpeggio, then A–A♭–G — a pure chromatic walkdown.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '3', beat: 2 }, { deg: '5', beat: 3 },
|
||||||
|
{ approach: 'chrom-above', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'the ii\'s 5th starts a chromatic slide: 5, then the half-step above the V, then the V',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// G A B G → C: walk 1–2–3, then drop to the fifth of the TARGET — which
|
||||||
|
// on this wrap is the V's own root: the V–I cadence inside one bar.
|
||||||
|
pattern: [
|
||||||
|
{ deg: '1', beat: 1 }, { deg: '9', beat: 2 }, { deg: '3', beat: 3 },
|
||||||
|
{ approach: 'fifth-of-next', beat: 4 },
|
||||||
|
],
|
||||||
|
note: 'climb 1–2–3, then drop to the 5th of the next root — here, the V root itself',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
position: 'one position; the arpeggios alternate strings on their own as the roots fall in fifths',
|
||||||
|
tips: 'The jazz handshake, learned as three approach flavors in four bars: half-step from above (twice — and the first one is a chord tone already), a fall from the fourth above, and the fifth-of-the-target, which is the bass player\'s strongest word because it plays a V–I cadence into whatever comes next. Loop this at a jam and you\'ve pre-learned bars 11–12 of every uptown blues — the same cycle the guitar pack shells through.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Two-beat kicks (roots and approaches)',
|
||||||
|
level: 'foundation',
|
||||||
|
feel: 'jump two-beat — root on the kick, approach right behind it; often two beats per chord',
|
||||||
|
chords: [
|
||||||
|
{
|
||||||
|
// C B♭ → A: root, then the half-step above the VI (again the I's ♭7).
|
||||||
|
pattern: [{ deg: '1', beat: 1 }, { approach: 'chrom-above', beat: 3 }],
|
||||||
|
note: 'root, then lean on the ♭7 — it drops you onto the VI',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A C♯ → D: root, then the half-step under the ii — the VI's own 3rd.
|
||||||
|
pattern: [{ deg: '1', beat: 1 }, { approach: 'chrom-below', beat: 3 }],
|
||||||
|
note: 'the VI\'s major 3rd pushes up a half-step onto the ii',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// D A → G: root and fifth; the fifth steps down a whole step onto the V.
|
||||||
|
pattern: [{ deg: '1', beat: 1 }, { deg: '5', beat: 3 }],
|
||||||
|
note: 'root–fifth — the 5th falls a whole step onto the V',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// G B → C: root, then the leading tone (the V's 3rd) under home.
|
||||||
|
pattern: [{ deg: '1', beat: 1 }, { approach: 'chrom-below', beat: 3 }],
|
||||||
|
note: 'the V\'s 3rd is the key\'s leading tone — it hands you bar 1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
position: 'low register, short notes — leave the triplet air to the pianist',
|
||||||
|
tips: 'The minimum viable turnaround: state the root, then play ONE note that belongs to the next chord more than this one. When the band squeezes the cycle into two bars (two beats per chord — the usual jazz-blues bars 11–12), this is the same pattern with the beats halved: root on the front beat, approach on the back. Notice that every back-beat note here is a chord tone of its own bar that also targets the next root — good targeting usually costs nothing.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
// Blues guitar pack. Shapes verified by note-spelling against: guitarworld.com
|
||||||
|
// (13th chords, Jimmy Reed rhythm), fundamental-changes.com (SRV/Freddie King 9ths,
|
||||||
|
// turnarounds), truefire.com (Texas comping, chord-tone targeting), jazzguitar.be
|
||||||
|
// (tritone shells), guitarplayer.com (B.B. box, turnarounds).
|
||||||
|
|
||||||
|
// Big barre grips — full-band downbeat hits.
|
||||||
|
const E_BARRE7 = { rootStr: 6, offsets: [0, 2, 0, 1, 0, 0], fingers: [1, 3, 1, 2, 1, 1] } // R-5-♭7-3-5-R
|
||||||
|
const A_BARRE7 = { rootStr: 5, offsets: ['x', 0, 2, 0, 2, 0], fingers: [0, 1, 3, 1, 4, 1] } // R-5-♭7-3-5
|
||||||
|
const MIN7_BARRE_6 = { rootStr: 6, offsets: [0, 2, 0, 0, 0, 0], fingers: [1, 3, 1, 1, 1, 1] } // Em-shape m7
|
||||||
|
const MIN7_BARRE_5 = { rootStr: 5, offsets: ['x', 0, 2, 0, 1, 0], fingers: [0, 1, 3, 1, 2, 1] } // Am-shape m7
|
||||||
|
|
||||||
|
// The blues colour chords — root on the A string.
|
||||||
|
const NINTH = { rootStr: 5, offsets: ['x', 0, -1, 0, 0, 0], fingers: [0, 2, 1, 3, 3, 3] } // R-3-♭7-9-5
|
||||||
|
const THIRTEEN = { rootStr: 5, offsets: ['x', 0, -1, 0, 0, 2], fingers: [0, 2, 1, 3, 3, 4] } // R-3-♭7-9-13
|
||||||
|
const THIRTEEN_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, 2, 'x'], fingers: [1, 0, 2, 3, 4, 0] } // R-♭7-3-13 (T-Bone register)
|
||||||
|
const HENDRIX = { rootStr: 5, offsets: ['x', 0, -1, 0, 1, 'x'], fingers: [0, 2, 1, 3, 4, 0] } // 7#9 — ♭3 vs 3 in one grip
|
||||||
|
|
||||||
|
// Two-note-tritone shells — the Chicago comping grips.
|
||||||
|
const SHELL7_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, 'x', 'x'], fingers: [1, 0, 2, 3, 0, 0] } // R-♭7-3
|
||||||
|
const SHELL7_5 = { rootStr: 5, offsets: ['x', 0, 'x', 0, 2, 'x'], fingers: [0, 1, 0, 2, 4, 0] } // R-♭7-3
|
||||||
|
const SHELL_M7_5 = { rootStr: 5, offsets: ['x', 0, -2, 0, 'x', 'x'], fingers: [0, 3, 1, 4, 0, 0] } // R-♭3-♭7
|
||||||
|
|
||||||
|
export default {
|
||||||
|
styleIntro:
|
||||||
|
'Blues rhythm guitar is a drum kit with pitch: the shuffle is the job, the chord is the decoration. Pick a lane — low boogie locked with the bass, or high 9th-chord stabs answering the vocal — and never both at once.',
|
||||||
|
|
||||||
|
comping: [
|
||||||
|
{
|
||||||
|
label: 'Jimmy Reed boogie shuffle',
|
||||||
|
rhythm: 'swung 8ths: R+5 / R+6 alternating',
|
||||||
|
description: 'Two-note dyads on the bottom strings, alternating the 5th and 6th above the root in swung eighths; move the same cell to the IV and V strings. Low register, dense — doubles the bass. The "second guitar" role Reed pioneered.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '9th-chord stabs (Texas / SRV)',
|
||||||
|
rhythm: 'staccato hits, slide in from a half-step below',
|
||||||
|
description: 'Short muted stabs of the 9th grip, approached from one fret under (B♭9→B9). Mid-high register, sparse — leaves the low end to the bass. The Freddie King "Hide Away" sound.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Slow blues 12/8',
|
||||||
|
rhythm: 'rolled chords on a triplet grid',
|
||||||
|
description: 'At ~60 BPM everything subdivides into triplets: arpeggiated 9ths, the 6↔9 rock on the top strings, fills answering the vocal. Density drops; space is the instrument.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Stormy Monday walk-up',
|
||||||
|
rhythm: 'one chord per walking step, bars 7–8',
|
||||||
|
description: 'Diatonic chord climb I7→ii7→iii7 then chromatic back down — a bassline played as chords. Canonical on the Allman Brothers\' At Fillmore East.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plays: {
|
||||||
|
'blues-12bar': [
|
||||||
|
{
|
||||||
|
label: 'Barre-chord shuffle',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: E_BARRE7, note: 'I7 — root on the 6th string' },
|
||||||
|
{ shape: E_BARRE7, note: '' }, { shape: E_BARRE7, note: '' }, { shape: E_BARRE7, note: '' },
|
||||||
|
{ shape: A_BARRE7, note: 'IV7 — same fret, root string up' },
|
||||||
|
{ shape: A_BARRE7, note: '' },
|
||||||
|
{ shape: E_BARRE7, note: '' }, { shape: E_BARRE7, note: '' },
|
||||||
|
{ shape: A_BARRE7, note: 'V7 — two frets above the IV grip' },
|
||||||
|
{ shape: A_BARRE7, note: 'IV7' },
|
||||||
|
{ shape: E_BARRE7, note: '' },
|
||||||
|
{ shape: A_BARRE7, note: 'V7 — push into the next chorus' },
|
||||||
|
],
|
||||||
|
tips: 'I, IV and V all live within two frets: 6th-string root, then 5th-string root at the same fret (IV) and two up (V). Strum short — the shuffle lives in the damping hand.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '9th-chord stabs (Texas)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: NINTH, extensions: ['9'], note: 'slide in from one fret below' },
|
||||||
|
{ shape: NINTH, extensions: ['9'], note: '' }, { shape: NINTH, extensions: ['9'], note: '' }, { shape: NINTH, extensions: ['9'], note: '' },
|
||||||
|
{ shape: NINTH, extensions: ['9'], note: 'IV9' }, { shape: NINTH, extensions: ['9'], note: '' },
|
||||||
|
{ shape: NINTH, extensions: ['9'], note: '' }, { shape: NINTH, extensions: ['9'], note: '' },
|
||||||
|
{ shape: THIRTEEN, extensions: ['9', '13'], note: 'V13 — pinky reaches the 13' },
|
||||||
|
{ shape: NINTH, extensions: ['9'], note: 'IV9' },
|
||||||
|
{ shape: NINTH, extensions: ['9'], note: '' },
|
||||||
|
{ shape: THIRTEEN, extensions: ['9', '13'], note: 'V13' },
|
||||||
|
],
|
||||||
|
tips: 'Stab, mute, wait. The 13↔9 drop on the top string is a free melodic hook — comping that sounds like a horn section.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'blues-quickchange': [
|
||||||
|
{
|
||||||
|
label: 'Tritone shells (Chicago)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL7_6, note: 'I7' },
|
||||||
|
{ shape: SHELL7_5, note: 'quick IV — only the inner pair moves' },
|
||||||
|
{ shape: SHELL7_6, note: '' }, { shape: SHELL7_6, note: '' },
|
||||||
|
{ shape: SHELL7_5, note: '' }, { shape: SHELL7_5, note: '' },
|
||||||
|
{ shape: SHELL7_6, note: '' }, { shape: SHELL7_6, note: '' },
|
||||||
|
{ shape: SHELL7_5, note: 'V7' },
|
||||||
|
{ shape: SHELL7_5, note: 'IV7' },
|
||||||
|
{ shape: SHELL7_6, note: '' },
|
||||||
|
{ shape: SHELL7_5, note: 'V7' },
|
||||||
|
],
|
||||||
|
tips: 'Three strings, two of them the chord-defining tritone. Drop the I7\'s inner pair one fret and you\'re already playing the IV7\'s guide tones — the quick change costs one finger.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Big barres, quick four',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: E_BARRE7, note: '' },
|
||||||
|
{ shape: A_BARRE7, note: 'the quick change — bar 2' },
|
||||||
|
{ shape: E_BARRE7, note: '' }, { shape: E_BARRE7, note: '' },
|
||||||
|
{ shape: A_BARRE7, note: '' }, { shape: A_BARRE7, note: '' },
|
||||||
|
{ shape: E_BARRE7, note: '' }, { shape: E_BARRE7, note: '' },
|
||||||
|
{ shape: A_BARRE7, note: 'V7' },
|
||||||
|
{ shape: A_BARRE7, note: 'IV7' },
|
||||||
|
{ shape: E_BARRE7, note: '' },
|
||||||
|
{ shape: A_BARRE7, note: 'V7' },
|
||||||
|
],
|
||||||
|
tips: 'Accent bar 2 slightly — telegraphing the quick change keeps the whole jam from splitting between the two 12-bar variants.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'blues-8bar': [
|
||||||
|
{
|
||||||
|
label: 'Barres through the 8-bar form',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: E_BARRE7, note: 'I7' },
|
||||||
|
{ shape: A_BARRE7, note: 'V7 already — count!' },
|
||||||
|
{ shape: A_BARRE7, note: 'IV7' }, { shape: A_BARRE7, note: '' },
|
||||||
|
{ shape: E_BARRE7, note: '' },
|
||||||
|
{ shape: A_BARRE7, note: 'V7' },
|
||||||
|
{ shape: E_BARRE7, note: '' },
|
||||||
|
{ shape: A_BARRE7, note: 'V7 — turnaround' },
|
||||||
|
],
|
||||||
|
tips: 'Half the length, twice the changes per chorus. Lock the form before decorating it.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '9ths and 13ths, uptown',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: NINTH, extensions: ['9'], note: '' },
|
||||||
|
{ shape: THIRTEEN, extensions: ['9', '13'], note: 'V13' },
|
||||||
|
{ shape: NINTH, extensions: ['9'], note: 'IV9' }, { shape: NINTH, extensions: ['9'], note: '' },
|
||||||
|
{ shape: NINTH, extensions: ['9'], note: '' },
|
||||||
|
{ shape: THIRTEEN, extensions: ['9', '13'], note: '' },
|
||||||
|
{ shape: NINTH, extensions: ['9'], note: '' },
|
||||||
|
{ shape: THIRTEEN, extensions: ['9', '13'], note: '' },
|
||||||
|
],
|
||||||
|
tips: 'The Key-to-the-Highway feel is gentle — roll the chords instead of stabbing them, triplet feel even at medium tempo.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'blues-minor': [
|
||||||
|
{
|
||||||
|
label: 'm7 barres with the ♯9 climax',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: MIN7_BARRE_6, note: 'i7' },
|
||||||
|
{ shape: MIN7_BARRE_6, note: '' }, { shape: MIN7_BARRE_6, note: '' }, { shape: MIN7_BARRE_6, note: '' },
|
||||||
|
{ shape: MIN7_BARRE_5, note: 'iv7' }, { shape: MIN7_BARRE_5, note: '' },
|
||||||
|
{ shape: MIN7_BARRE_6, note: '' }, { shape: MIN7_BARRE_6, note: '' },
|
||||||
|
{ shape: A_BARRE7, note: '♭VI7 — the drama bar' },
|
||||||
|
{ shape: HENDRIX, extensions: ['#9'], note: 'V7♯9 — the slow-blues scream' },
|
||||||
|
{ shape: MIN7_BARRE_6, note: '' }, { shape: MIN7_BARRE_6, note: '' },
|
||||||
|
],
|
||||||
|
tips: 'Save your dynamics for bars 9–10: the ♭VI7→V7♯9 half-step drop is the whole emotional payload of the form. Everything before it is patience.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Upper-register minor comping',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: MIN7_BARRE_5, note: 'i7 — A-string root, above the bass' },
|
||||||
|
{ shape: MIN7_BARRE_5, note: '' }, { shape: MIN7_BARRE_5, note: '' }, { shape: MIN7_BARRE_5, note: '' },
|
||||||
|
{ shape: SHELL_M7_5, note: 'iv7 — thin out, the singer is working' },
|
||||||
|
{ shape: SHELL_M7_5, note: '' },
|
||||||
|
{ shape: MIN7_BARRE_5, note: '' }, { shape: MIN7_BARRE_5, note: '' },
|
||||||
|
{ shape: THIRTEEN_6, extensions: ['13'], note: '♭VI13' },
|
||||||
|
{ shape: NINTH, extensions: ['9'], note: 'V9' },
|
||||||
|
{ shape: MIN7_BARRE_5, note: '' }, { shape: SHELL_M7_5, note: 'fade to the turnaround' },
|
||||||
|
],
|
||||||
|
tips: 'Minor blues is usually slow — 12/8 triplet grid, rolled chords, and at least one full bar per chorus where you play nothing at all.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'blues-turnaround': [
|
||||||
|
{
|
||||||
|
label: 'Shell cycle',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL7_6, note: 'I7' },
|
||||||
|
{ shape: SHELL7_5, note: 'VI7' },
|
||||||
|
{ shape: SHELL_M7_5, note: 'ii7' },
|
||||||
|
{ shape: SHELL7_6, note: 'V7' },
|
||||||
|
],
|
||||||
|
tips: 'Often two beats per chord, not a bar — practise it at both speeds. The roots fall in fifths from the VI on, so the grips alternate strings on their own.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Uptown 9ths (T-Bone)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: NINTH, extensions: ['9'], note: 'I9' },
|
||||||
|
{ shape: NINTH, extensions: ['9'], note: 'VI9' },
|
||||||
|
{ shape: MIN7_BARRE_5, note: 'ii7' },
|
||||||
|
{ shape: THIRTEEN, extensions: ['9', '13'], note: 'V13 — hold, then slide down a fret into the next chorus' },
|
||||||
|
],
|
||||||
|
tips: 'This is the Stormy Monday sound: every dominant becomes a 9th or 13th, approached chromatically. Roll them lazily on the triplet grid.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
improv: {
|
||||||
|
scales: [
|
||||||
|
{ over: 'I7', scale: 'mixolydian', why: 'Major pentatonic and Mixolydian shine over the I — the sweet B.B. King side of the coin.' },
|
||||||
|
{ over: 'IV7', scale: 'mixolydian', why: 'Switch to minor pentatonic (or think the IV\'s own Mixolydian) when the IV arrives — the key\'s major 3rd clashes with its ♭7.' },
|
||||||
|
{ over: 'V7', scale: 'mixolydian', why: 'Each chord gets its own Mixolydian; adjacent ones differ by one note, so really it\'s "follow the chord tones."' },
|
||||||
|
{ over: 'i7 (minor blues)', scale: 'minor', why: 'Minor pentatonic + the natural 6 over the iv; harmonic minor colour over the V7.' },
|
||||||
|
],
|
||||||
|
targetNotes:
|
||||||
|
'The 3rd of the current chord at every change is the whole game; hit the ♭7 of the I in bar 4 to announce the IV. The blues curl — a quarter-step bend of the ♭3 toward the major 3 — is the signature ornament.',
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
over: 'blues-12bar',
|
||||||
|
description: 'Classic descending turnaround in E (Robert Johnson "Kind Hearted Woman" lineage): ♭7–6–♭6–5 under a high-E pedal, swung triplets, resolving to B7.',
|
||||||
|
tab: 'e|--0---0---0---0--------2--\nB|--3---2---1---0--------0--\nG|-----------------------2--\nD|-----------------------1--\nA|-----------------------2--\nE|--------------------------\n D C# C B → B7',
|
||||||
|
source: 'GuitarPlayer "Blues Turnarounds Pt 1"; Fundamental Changes "Blues Turnarounds for Guitar"',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'blues-12bar',
|
||||||
|
description: 'B.B. King box lick in C: major-pentatonic box around frets 8–10 with the signature 2→3 whole-step bend (D bent to E, the 3rd of C7).',
|
||||||
|
tab: 'e|--8--10b12--10--8---------------\nB|------------------10--8---------\nG|------------------------9-------\n C D→E D C A G E',
|
||||||
|
source: 'GuitarPlayer "12 Killer Blues Licks"; Guitar World (B.B. box, R-2-4-5-6)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Structured licks (SCHEMA.md "Licks", task P-21). Written in a home key each
|
||||||
|
// (noted per lick) but key-agnostic in spirit — chordContext names the station.
|
||||||
|
// Every pitch hand-verified: s6=E s5=A s4=D s3=G s2=B s1=e (+fret, mod 12).
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
// In C (box at fret 8): G C D→E C A G = 5 R 9→3 R 13 5, C major pentatonic
|
||||||
|
// over the I7 — the sweet B.B. side of the coin.
|
||||||
|
id: 'blues-bb-box-sweet',
|
||||||
|
name: 'B.B. box sweet-spot phrase',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'over the I7',
|
||||||
|
techniques: ['bend', 'vibrato'],
|
||||||
|
source: 'in the style of B.B. King\'s box-position fills; Guitar World "B.B. box" lessons (R-2-4-5-6 grid)',
|
||||||
|
tab: [
|
||||||
|
{ string: 2, fret: 8 }, // G — the 5th
|
||||||
|
{ string: 1, fret: 8 }, // C — root
|
||||||
|
{ string: 1, fret: 10, technique: 'bend' }, // D bent a whole step to E, the 3rd — the signature move
|
||||||
|
{ string: 1, fret: 8 }, // C
|
||||||
|
{ string: 2, fret: 10 }, // A — the 13
|
||||||
|
{ string: 2, fret: 8, technique: 'vibrato' }, // G — settle on the 5th
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In E, open position: ♭7–6–♭6–5 (D C♯ C B) descending on the B string
|
||||||
|
// under a high-e root pedal, resolving to the V7's root (B).
|
||||||
|
id: 'blues-open-turnaround',
|
||||||
|
name: 'Descending turnaround under a root pedal',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'bars 11–12: I7 walking down to the V7',
|
||||||
|
techniques: ['double-stop', 'vibrato'],
|
||||||
|
source: 'the Robert Johnson-lineage turnaround (e.g. "Kind Hearted Woman"); Fundamental Changes "Blues Turnarounds for Guitar"',
|
||||||
|
tab: [
|
||||||
|
{ string: 2, fret: 3 }, // D — ♭7
|
||||||
|
{ string: 1, fret: 0, technique: 'double-stop' }, // E pedal on top
|
||||||
|
{ string: 2, fret: 2 }, // C♯ — 6
|
||||||
|
{ string: 1, fret: 0, technique: 'double-stop' },
|
||||||
|
{ string: 2, fret: 1 }, // C — ♭6
|
||||||
|
{ string: 1, fret: 0, technique: 'double-stop' },
|
||||||
|
{ string: 2, fret: 0 }, // B — 5
|
||||||
|
{ string: 1, fret: 0, technique: 'double-stop' },
|
||||||
|
{ string: 5, fret: 2, technique: 'vibrato' }, // B — root of the V7: the landing
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In A, box 1: C→C♯ (♭3 hammered to the major 3) into the E+A double-stop,
|
||||||
|
// ♭7 bend, settle on the 5th — the 12/8 slow-blues answer phrase.
|
||||||
|
id: 'blues-slow-curl',
|
||||||
|
name: 'Slow-blues curl into the root double-stop',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'over the I7, 12/8 slow blues',
|
||||||
|
techniques: ['hammer-on', 'double-stop', 'bend', 'vibrato'],
|
||||||
|
source: 'stock box-1 slow-blues vocabulary (the ♭3→3 curl); TrueFire Texas-blues chord-tone lessons',
|
||||||
|
tab: [
|
||||||
|
{ string: 3, fret: 5 }, // C — ♭3
|
||||||
|
{ string: 3, fret: 6, technique: 'hammer-on' }, // C♯ — the major 3rd; the blues curl made explicit
|
||||||
|
{ string: 2, fret: 5 }, // E — 5th
|
||||||
|
{ string: 1, fret: 5, technique: 'double-stop' }, // A on top — root+5th dyad
|
||||||
|
{ string: 2, fret: 8, technique: 'bend' }, // G (♭7) bent a whole step toward the root
|
||||||
|
{ string: 2, fret: 5, technique: 'vibrato' }, // E — resolve on the 5th
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In A (IV = D9): the IV7's 3+♭7 tritone pair (F♯+C) slid in from one fret
|
||||||
|
// below (F+B), then the 5th on top — the Texas comping move as a lick.
|
||||||
|
id: 'blues-iv9-slide',
|
||||||
|
name: 'Tritone slide into the IV9',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'bar 5: landing on the IV7',
|
||||||
|
techniques: ['chromatic-approach', 'double-stop', 'slide', 'vibrato'],
|
||||||
|
source: 'the Texas/Freddie King 9th-grip slide-in ("Hide Away" comping vocabulary); Fundamental Changes SRV/Freddie King 9ths',
|
||||||
|
tab: [
|
||||||
|
{ string: 4, fret: 3, technique: 'chromatic-approach' }, // F — one fret below the 3rd
|
||||||
|
{ string: 3, fret: 4, technique: 'double-stop' }, // B — one fret below the ♭7
|
||||||
|
{ string: 4, fret: 4, technique: 'slide' }, // F♯ — the IV7's 3rd
|
||||||
|
{ string: 3, fret: 5, technique: 'double-stop' }, // C — the IV7's ♭7: the tritone pair lands
|
||||||
|
{ string: 1, fret: 5, technique: 'vibrato' }, // A — the IV7's 5th (the key's root)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
id: 'blues',
|
||||||
|
label: 'Blues',
|
||||||
|
feel: 'shuffle',
|
||||||
|
tempoRange: [60, 180],
|
||||||
|
character: 'Dominant 7ths on every chord, swung eighths or 12/8 triplets, and the ♭3-against-3 tension that makes it talk — form is sacred, everything else is conversation.',
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
// Blues piano pack (task P-31). Recipes verified by degree-spelling against
|
||||||
|
// CHORD_TYPES (src/lib/theory.js) and the validator's stacking convention
|
||||||
|
// (order inside each hand = voicing order low → high, nearest strictly above —
|
||||||
|
// documented at jazz/piano.js header). Every voice-leading/interval statement
|
||||||
|
// in notes/tips is pitch-class arithmetic, computed against the realized
|
||||||
|
// voicings (reference key C / C minor) before writing. Combined LH+RH stacks
|
||||||
|
// are kept ≤ 23 semitones so every recipe renders inside JamGuide's 36-key
|
||||||
|
// window for all 12 roots (same budget as rnb/piano.js).
|
||||||
|
// Voicings and treatments sourced from:
|
||||||
|
// pianogroove.com — Chicago Blues course ("Chicago Blues Hand Independence":
|
||||||
|
// LH shell / RH chord two-job split), "How To Count Slow Blues" & "12/8 Feel
|
||||||
|
// For Slow Blues" (four beats each split in three; rolled arrivals), "Blues
|
||||||
|
// Piano Comping Patterns & Rhythms" and "Basic Blues Voicings & Patterns"
|
||||||
|
// (shell + guide-tone comping, 9th/13th dominant colour);
|
||||||
|
// pianowithjonny.com — "Blues Chords for Piano: The Complete Guide" (9ths and
|
||||||
|
// 13ths as the bright blues extensions, ♭9/♯9/♭13 as the crunch) and "Slow
|
||||||
|
// Blues Piano for Beginners";
|
||||||
|
// piano-ology.com — "Dominant 7(♯9) Voicings" (the no-5th grip: 3–♭7–♯9,
|
||||||
|
// major-against-minor-third tension) and "Killer Blues Piano Grace Notes"
|
||||||
|
// (the ♭3→3 crush as the pianist's bend);
|
||||||
|
// Wikipedia, "Dominant seventh sharp ninth chord" (the 7♯9 codifies the blues
|
||||||
|
// scale's ♭3 sounding over a major-third dominant);
|
||||||
|
// Otis Spann (Blues Hall of Fame) — the postwar Chicago model this pack's
|
||||||
|
// two-fisted play imitates: piano as the only harmony behind voice and guitar;
|
||||||
|
// Pinetop Smith, "Pinetop's Boogie Woogie" — the rocking 5th↔6th shuffle
|
||||||
|
// left hand; pianote.com "How to Play Blues Piano" (right-hand harmony in
|
||||||
|
// sixths); Mark Levine, The Jazz Piano Book (rootless Type A/B dominants,
|
||||||
|
// 13 replacing the 5) — the uptown/band grips blues borrows from jazz.
|
||||||
|
// Pedagogy frame: docs/learn-curriculum.md — Piano pillar (shells → rootless,
|
||||||
|
// register discipline, voice-leading as hand economy).
|
||||||
|
|
||||||
|
// Reusable degree recipes. Order inside each hand = voicing order, low → high.
|
||||||
|
const LH_R = ['1'] // single bass root
|
||||||
|
const LH_R5 = ['1', '5'] // root + fifth anchor
|
||||||
|
const LH_R7 = ['1', '7'] // root + ♭7 shell — the Chicago left hand
|
||||||
|
const RH_TRIAD = ['1', '3', '5'] // plain root-position triad (the ♭7 lives downstairs)
|
||||||
|
const GT_37 = ['3', '7'] // guide tones, 3rd on the bottom
|
||||||
|
const GT_73 = ['7', '3'] // guide tones flipped, ♭7 on the bottom
|
||||||
|
const RH_9 = ['9', '3', '7'] // 9th spread: the 9 tucked under the guide tones
|
||||||
|
const RH_7TH = ['3', '5', '7'] // full seventh chord stacked above the anchor
|
||||||
|
const RH_SHARP9 = ['3', '7', '#9'] // the crying chord: 3–♭7–♯9, no 5th (piano-ology grip)
|
||||||
|
const RH_13 = ['3', '13', '7'] // 13th cluster: the 13 crushed under the ♭7
|
||||||
|
const A_DOM = ['3', '13', '7', '9'] // rootless Type A dominant (Levine: 13 for 5)
|
||||||
|
const B_DOM = ['7', '9', '3', '13'] // rootless Type B dominant
|
||||||
|
|
||||||
|
export default {
|
||||||
|
styleIntro:
|
||||||
|
'Blues piano is two instruments in one player: a left hand that keeps the shuffle honest (roots, shells, the boogie rock) and a right hand that talks back — stabs, sixths, tremolos, the crush of ♭3 into 3. Harmony is three dominant chords and everything expressive happens in how little you move between them: the whole form voice-leads by half-steps and whole steps if you let it.',
|
||||||
|
|
||||||
|
comping: [
|
||||||
|
{
|
||||||
|
label: 'Shuffle backbeat stabs',
|
||||||
|
rhythm: 'swung 8ths; right-hand chips on 2 and 4',
|
||||||
|
description:
|
||||||
|
'The Chicago comp: the left hand holds the shell (or rocks the boogie) while the right hand stabs with the snare on 2 and 4. Keep the stabs short and identical — in a Chicago band the piano is felt more than heard until the fill.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Slow-blues 12/8 rolls',
|
||||||
|
rhythm: 'four beats to the bar, each split in three',
|
||||||
|
description:
|
||||||
|
'The slow-blues pulse: block or roll the chords into beats 1 and 3 and let the triplet grid breathe underneath. Tremolo the top of the hand at phrase ends — on a slow blues the tremolo is the horn section.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'The Pinetop rock (5–6 left hand)',
|
||||||
|
rhythm: 'left hand swung 8ths, rocking 5th ↔ 6th',
|
||||||
|
description:
|
||||||
|
'The boogie cell from "Pinetop\'s Boogie Woogie": the left hand rocks a whole step between the chord\'s 5th and 6th on every swung 8th, on every chord of the form. It is a drum pattern played on pitches — once it runs itself, the right hand is free.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plays: {
|
||||||
|
'blues-12bar': [
|
||||||
|
{
|
||||||
|
label: 'Horn-section shells (jump-blues guide tones)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: '3rd under ♭7 — the two notes that make it a dominant' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'hold — the stabs are rhythm, not new notes' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'still home; leave holes for the singer' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'last bar of home — lean on the and-of-4 into the IV' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_73 }, note: 'both voices fall a half-step: the I\'s 3rd onto this ♭7, its ♭7 onto this 3rd' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_73 }, note: 'sit' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'home — the same two half-steps, rising this time' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'hold' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_73 }, note: 'bar 9: both voices rise a half-step — the I\'s 3rd becomes this ♭7, its ♭7 this 3rd' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_73 }, note: 'V to IV: the pair slides down a whole step in parallel' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'home on half-steps again' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_73 }, note: 'bar 12 pushes: a half-step up in both voices, and the next chorus starts' },
|
||||||
|
],
|
||||||
|
register: 'LH single roots around C3; RH dyads just above middle C — horn-section territory',
|
||||||
|
tips: 'Two right-hand fingers cover the whole form, and no change moves either of them more than a whole step: into the IV both guide tones fall a half-step, into the V both rise a half-step, and V-to-IV slides a whole step in parallel. This is the jump-blues horn pad translated to the keys — play it on the shuffle backbeat and you are the brass section. When the band thins out, add the root back with the left hand; when a soloist rips, this is already everything they need from you.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Slow blues, full hands (12/8)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'a two-hand 9th spread — root and 5th below, the 9 tucked under the guide tones above' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'hold — roll it into beat 3, or tremolo the top two notes' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'still home; answer an imaginary singer in the gaps' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_SHARP9 }, note: 'I7♯9 — the 9 sharpens a half-step into the ♯9, which is the key\'s own ♭3: the blue note leaning into the IV' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: B_DOM }, note: 'the ♯9 holds over as this chord\'s ♭7 (same key); the I\'s ♭7 falls a half-step onto this 3rd' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: B_DOM }, note: 'sit on the 13 — it is the key\'s own 9th, sweet against the IV' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'the IV\'s 13 holds as this 9; its guide tones each rise a half-step home' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'hold — or thin out before the V arrives' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_SHARP9 }, note: 'the crying chord: the key\'s ♭7 refuses to move — it was the I\'s ♭7 and stays on as this ♯9' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'the ♯9 slips a half-step onto this 3rd; the V\'s ♭7 lands in your left hand as the new root' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'the guide tones trade one last time — two half-step rises' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_13 }, note: 'V13 out: the I\'s 3rd holds as this 13 while its ♭7 pushes up a half-step onto the 3rd' },
|
||||||
|
],
|
||||||
|
register: 'LH around C3, RH stacks around middle C; 12/8 — every beat divides in three',
|
||||||
|
tips: 'The slow blues legitimizes both blue notes with chords: the key\'s ♭3 becomes the I7\'s ♯9 in bar 4 and then holds over as the IV7\'s ♭7 in bar 5; the key\'s ♭7 becomes the V7\'s ♯9 in bar 9. The 7♯9 grip here is the classic three-note voicing — 3rd, ♭7, ♯9, no 5th — the "minor third" slung up high, a major seventh above the major third — the whole blues argument in one hand. Roll the big chords into the beat and save the tremolo for the ends of phrases.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'blues-quickchange': [
|
||||||
|
{
|
||||||
|
label: 'Two-fisted Chicago (Otis Spann lane)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R7, RH: RH_TRIAD }, note: 'root + ♭7 shell below, plain triad above — the ♭7 lives downstairs' },
|
||||||
|
{ recipe: { LH: LH_R7, RH: RH_TRIAD }, note: 'quick change: the I\'s root holds as this chord\'s 5th; its 3rd climbs a half-step onto the new root' },
|
||||||
|
{ recipe: { LH: LH_R7, RH: RH_TRIAD }, note: 'home in bar 3 — the IV\'s root falls the same half-step back onto the 3rd' },
|
||||||
|
{ recipe: { LH: LH_R7, RH: RH_TRIAD }, note: 'hold; rock the right hand in swung 8ths' },
|
||||||
|
{ recipe: { LH: LH_R7, RH: RH_TRIAD }, note: 'IV again — same half-step climb as bar 2' },
|
||||||
|
{ recipe: { LH: LH_R7, RH: RH_TRIAD }, note: 'sit' },
|
||||||
|
{ recipe: { LH: LH_R7, RH: RH_TRIAD }, note: 'home' },
|
||||||
|
{ recipe: { LH: LH_R7, RH: RH_TRIAD }, note: 'hold — bar 8 sets up the V' },
|
||||||
|
{ recipe: { LH: LH_R7, RH: RH_TRIAD }, note: 'the I\'s 5th holds as the new root; the old root slips a half-step down onto the V\'s 3rd' },
|
||||||
|
{ recipe: { LH: LH_R7, RH: RH_TRIAD }, note: 'the triad slides down a whole step in parallel — V to IV is the blues\' one pure parallel move' },
|
||||||
|
{ recipe: { LH: LH_R7, RH: RH_TRIAD }, note: 'home' },
|
||||||
|
{ recipe: { LH: LH_R7, RH: RH_TRIAD }, note: 'V7 — kick the next chorus' },
|
||||||
|
],
|
||||||
|
register: 'LH shell around C2–C3, RH triads mid-keyboard — two hands, two jobs',
|
||||||
|
tips: 'The postwar Chicago model (Otis Spann behind Muddy Waters): the piano is the only harmony instrument, so the left hand is the bass player (root + ♭7 shell, or the Pinetop 5–6 rock) and the right hand is the section. The right-hand triad carries no ♭7 on purpose — the shell already supplies it, so together the hands spell the full dominant without mud. The drill this progression exists for: hear bar 2 coming — the quick change is the same one-half-step move as bar 5, just three bars early.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Uptown rootless (9s and 13s behind a band)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: A_DOM }, note: 'Type A dominant: 3–13–♭7–9 — two colour tones per chord' },
|
||||||
|
{ recipe: { LH: B_DOM }, note: 'quick change: two names hold (the I\'s 13 and 9 become this 3rd and 13); the 3rd falls a half-step onto this ♭7, the ♭7 drops a minor third onto the 9' },
|
||||||
|
{ recipe: { LH: A_DOM }, note: 'home — the same two fingers walk back' },
|
||||||
|
{ recipe: { LH: A_DOM }, note: 'hold' },
|
||||||
|
{ recipe: { LH: B_DOM }, note: 'the bar-2 seam again — spot it faster this time' },
|
||||||
|
{ recipe: { LH: B_DOM }, note: 'sit' },
|
||||||
|
{ recipe: { LH: A_DOM }, note: 'home' },
|
||||||
|
{ recipe: { LH: A_DOM }, note: 'hold — or thin to bare 3 + ♭7 if the guitarist is busy' },
|
||||||
|
{ recipe: { LH: A_DOM }, note: 'two names hold again (the I\'s 3rd becomes this 13, its 13 this 9); the ♭7 rises a half-step onto the V\'s 3rd' },
|
||||||
|
{ recipe: { LH: B_DOM }, note: 'the V\'s 9 holds as this 3rd; its 13 falls a half-step onto this ♭7' },
|
||||||
|
{ recipe: { LH: A_DOM }, note: 'the IV\'s ♭7 rises a half-step onto this 3rd; its 3rd and 13 stay put as the 13 and 9' },
|
||||||
|
{ recipe: { LH: A_DOM }, note: 'bar 12 — same grip as bar 9; the form is a wheel' },
|
||||||
|
],
|
||||||
|
register: 'left hand alone, top note between C4 and C5 — the bassist owns the roots',
|
||||||
|
tips: 'The Levine rootless dominants moved into a blues — the uptown sound of a jump band with a bassist, where doubling roots just muddies the bottom. Every I-to-IV seam is the same physical fact: two fingers freeze while two slide, so the quick change costs exactly as much in bar 2 as in bar 5. Comp these on the backbeat and keep the top note in one narrow lane; the colour (9s and 13s everywhere) does the rest.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'blues-8bar': [
|
||||||
|
{
|
||||||
|
label: 'Front-porch pillars (rolled 12/8)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'root and 5th below, the full seventh chord stacked above' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'the V in bar 2 — every voice by step: the 3rd and 5th fall a whole step (onto the V\'s 5th and ♭7), the ♭7 rises a half-step onto its 3rd' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'V to IV: the entire two-hand grip — roots included — slides down a whole step in parallel' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'sit on the IV — roll it' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'home: the IV\'s ♭7 rises a half-step onto the 3rd; its 3rd and 5th each fall a whole step' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'V again — bar 6, not bar 9; count the form out loud' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'home for one bar' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'V out — it hands you bar 1' },
|
||||||
|
],
|
||||||
|
register: 'LH open fifths around C3, RH sevenths around middle C; roll into beats 1 and 3',
|
||||||
|
tips: 'One two-hand shape planted on three roots — the front-porch reading of "Key to the Highway". Because the right hand is the plain 3–5–♭7 stack, no change in the form moves any right-hand finger more than a whole step (the left hand\'s roots do the jumping), and V-to-IV slides both hands down a whole step in parallel. That makes this the form-learning play: your hands physically feel that the V arrives in bar 2, which is exactly where 12-bar reflexes get wrong-footed.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Parallel sixths (the fills lane)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R, RH: ['5', '3'] }, note: 'a bare sixth — the 5th with the 3rd singing a major sixth above it' },
|
||||||
|
{ recipe: { LH: LH_R, RH: ['7', '5'] }, note: 'both voices fall a whole step — the dyad is now ♭7 under 5th, still a sixth wide' },
|
||||||
|
{ recipe: { LH: LH_R, RH: ['1', '13'] }, note: 'the dyad does not move: the V\'s ♭7 and 5th are already this chord\'s root and 13 — only the bass falls' },
|
||||||
|
{ recipe: { LH: LH_R, RH: ['1', '13'] }, note: 'sit — tremolo the sixth' },
|
||||||
|
{ recipe: { LH: LH_R, RH: ['5', '3'] }, note: 'both voices climb a whole step home' },
|
||||||
|
{ recipe: { LH: LH_R, RH: ['7', '5'] }, note: 'down a whole step again — the early V, second lap' },
|
||||||
|
{ recipe: { LH: LH_R, RH: ['5', '3'] }, note: 'home' },
|
||||||
|
{ recipe: { LH: LH_R, RH: ['7', '5'] }, note: 'out — the sixth rocks down one more time and the form comes around' },
|
||||||
|
],
|
||||||
|
register: 'LH roots low; the sixths sit where a singer would — the middle of the keyboard',
|
||||||
|
tips: 'Right-hand sixths are blues piano\'s harmonized-vocal sound — two notes that move like one singer with a shadow. The whole eight bars is one dyad rocking between just two positions a whole step apart, plus one free change (into the IV, where the notes stay and only the bass moves). Decorate it the blues way: tremolo the dyad, and crush into its top note from the key a half-step below — the ♭3-into-3 crush is the pianist\'s string bend.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'blues-minor': [
|
||||||
|
{
|
||||||
|
label: 'The Thrill pads (m9 spreads)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'm9 — the 9 tucked under the ♭3, a slow-burn spread' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'hold — tremolo, or answer yourself with a fill' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'still home; minor blues burns slower than major' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'lean into the iv' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'two names hold — the i\'s ♭3 is this ♭7, its 5th this 9; the i\'s ♭7 and 9 each fall a whole step (onto this ♭3 and 5th)' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'sit' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'the mirror: the iv\'s ♭3 and 5th climb a whole step back; its ♭7 and 9 hold' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'hold — bar 9 is coming' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'the drama: the i\'s ♭7 holds on as this chord\'s 9, and the bass 5th climbs a half-step onto the new root' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'all five voices sink a half-step together — the ♭VI was the V a half-step early' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'resolve dark: the V\'s ♭7 falls a whole step onto the ♭3; its 3rd slips a half-step back onto the ♭7' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'sit — no push; the minor blues ends its chorus at home' },
|
||||||
|
],
|
||||||
|
register: 'LH fifths around C3, RH spreads just above middle C; long tones, B.B.-band tempo',
|
||||||
|
tips: 'The lane B.B. King\'s bands live in on "The Thrill Is Gone": sustained 9th spreads, one shape for the whole form. The payoff bar is 9-into-10 — keep the identical grip on the ♭VI7 and V7 and every voice, bass included, sinks exactly a half-step: the strongest move in the style is also the easiest. (B.B.\'s recording colours the ♭VI as a maj7, as the progression card notes — this play keeps it dominant so the plane stays pure; try both.)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'After-hours dyads (two fingers and smoke)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: '♭3 under ♭7 — a bare fifth; minor blues needs almost nothing' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'hold' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'space — the drummer is playing; you don\'t have to' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'a small push into the iv' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'the i\'s ♭3 holds as this ♭7; its ♭7 falls a whole step onto this ♭3' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'sit' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'the same two names trade straight back' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'hold' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'suddenly a tritone — the form\'s first true dominant crunch; the i\'s ♭7 climbed a whole step onto this 3rd' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_SHARP9 }, note: 'both dyad voices fall a half-step, and the ♯9 lands on top — the i\'s own ♭7, back from its one-bar detour' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'the ♯9 keeps its key and goes back to being the ♭7; the V\'s ♭7 falls a whole step onto the ♭3' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'sit in the dark' },
|
||||||
|
],
|
||||||
|
register: 'one bass note, two (then three) right-hand notes mid-keyboard; late-night volume',
|
||||||
|
tips: 'Minor blues by subtraction: on the minor chords the 3-and-7 dyad is a hollow perfect fifth, and the moment the ♭VI7 arrives the same two degrees snap into a tritone — the form\'s harmonic drama is audible in one interval changing shape under two fingers. The V7♯9 is the only three-note chord of the night, and its ♯9 is the note the night was built on: the key\'s ♭7, recast as the scream. If it sounds empty, good — push the placement, not the note count.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'blues-turnaround': [
|
||||||
|
{
|
||||||
|
label: 'Stormy Monday pillars (slow 12/8)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: '9th spread over root and 5th — uptown from the first beat' },
|
||||||
|
{ recipe: { LH: LH_R, RH: ['3', '7', 'b9'] }, note: 'VI7♭9: the I\'s ♭7 holds on as this ♭9; its 9 falls a half-step onto this 3rd; the bass drops a minor third' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9 }, note: 'the ♭9 sighs a half-step down onto this chord\'s 5th; the VI\'s ♭7 falls a whole step onto this ♭3' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_13 }, note: 'V13: the ii\'s 9 and ♭3 hold (as this 13 and ♭7); its ♭7 falls a half-step onto this 3rd — the ii–V law' },
|
||||||
|
],
|
||||||
|
register: 'LH low anchors, RH stacks around middle C; slow 12/8, roll the big ones',
|
||||||
|
tips: 'The uptown turnaround under one thread: a single key does three jobs in three bars — it starts as the I\'s ♭7, holds on as the VI7\'s ♭9, then sighs a half-step onto the ii\'s 5th. Back into bar 1 both of the V\'s guide tones fall a half-step (♭7 onto the I\'s 3rd, 3rd onto the I\'s ♭7), so the wheel never clunks. T-Bone Walker\'s "Stormy Monday" intro walks a close cousin of this cycle, as the progression card says — learn it here as hand motion and the uptown blues stops being mysterious.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Jump kicks (two-beat shells)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'guide tones only — this cycle usually gets two beats per chord' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_73 }, note: 'the one athletic move: I and VI share no guide tones, so both voices hop up a minor third' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_37 }, note: 'the VI\'s 3rd falls a half-step onto this ♭7; its ♭7 a whole step onto this ♭3' },
|
||||||
|
{ recipe: { LH: LH_R, RH: GT_73 }, note: 'the ii\'s ♭3 holds as this ♭7; its ♭7 falls a half-step onto this 3rd — then both voices sigh a half-step into bar 1' },
|
||||||
|
],
|
||||||
|
register: 'short stabs either side of middle C; two-beat changes, land them with the drummer',
|
||||||
|
tips: 'The turnaround at jump tempo: two-note kicks placed with the drummer, two beats per chord when the band squeezes the cycle into two bars. One lap costs one athletic move — the minor-third hop into the VI, the only seam in the loop where a voice has to leap — and after that every voice holds or resolves by half-step or whole step back to the top. Drill the lap until the hop is automatic; it is the same physical seam as bar 8 of a jazz blues.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
improv: {
|
||||||
|
scales: [
|
||||||
|
{
|
||||||
|
over: 'the whole form (I7–IV7–V7)',
|
||||||
|
scale: 'the key\'s blues scale',
|
||||||
|
why: 'One scale over all three chords is the genre\'s licence, and the chords themselves pre-clear the "wrong" notes: the scale\'s ♭3 is the I7\'s ♯9 and the IV7\'s ♭7, and its ♭7 is the V7\'s ♯9.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'I7',
|
||||||
|
scale: 'mixolydian, mixed with the blues scale',
|
||||||
|
why: 'Mixolydian supplies the chord, the blues scale the attitude — switch phrase by phrase. The ♭3→3 crush (a half-step, played as a grace note) is the pianist\'s string bend: piano can\'t bend, so it crushes.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'IV7',
|
||||||
|
scale: 'mixolydian from the IV',
|
||||||
|
why: 'Its ♭7 is the key\'s ♭3 — bar 5 re-tunes the blue note into a chord tone. Target it on the downbeat of the change and the room hears you hear the form.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'V7',
|
||||||
|
scale: 'mixolydian from the V',
|
||||||
|
why: 'And keep the key\'s ♭7 on call: over the V it becomes the ♯9, the crying note. Let it melt a half-step down onto the natural 9 — the classic slow-blues resolution.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'i7 (minor blues)',
|
||||||
|
scale: 'minor pentatonic of the key',
|
||||||
|
why: 'The minor form pre-clears everything, and it mostly survives bar 9: three of the ♭VI7\'s four chord tones already sit inside the key\'s blues scale. The one outside note is the ♭VI\'s own root — lean on it only if you want the dark.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
targetNotes:
|
||||||
|
'Land the 3rd of each chord on the downbeat of the change — bars 5, 9 and 10 are the exam. Treat the two blue notes as a budget with a schedule: the key\'s ♭3 crushes into 3 over the I and sits still as the ♭7 over the IV; the key\'s ♭7 is home over the I and becomes the ♯9 the moment the V arrives. Between phrases, silence — a blues chorus is a conversation, and the piano already talked.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Structured piano licks (SCHEMA.md "Piano licks", task P-60). Degree-based
|
||||||
|
// and key-agnostic: every deg resolves through the stated quality (all four
|
||||||
|
// licks sit on dominant stations — the blues' native chord; the pentatonic
|
||||||
|
// fall's tip notes where it survives the minor form too). Realized offsets
|
||||||
|
// in the comments use a C-rooted chord; every interval claim in notes/tips
|
||||||
|
// was recomputed from those offsets before writing (P-41 bar).
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
// Over C7: (E♭4)E4 G4 C5 B♭4 G4 (E♭4)E4 — offsets [3]4 7 12 10 7 [3]4.
|
||||||
|
// Both crushes are the ♭3 (3) into the 3rd (4), same beat.
|
||||||
|
id: 'blues-b3-crush',
|
||||||
|
name: 'The ♭3 crush',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'over the I7',
|
||||||
|
quality: 'dom7',
|
||||||
|
techniques: ['grace-note'],
|
||||||
|
source: 'the ♭3→3 crush as the pianist\'s string bend — piano-ology "Killer Blues Piano Grace Notes"',
|
||||||
|
notes: [
|
||||||
|
{ deg: 'b3', beat: 1, technique: 'grace-note' },
|
||||||
|
{ deg: '3', beat: 1 },
|
||||||
|
{ deg: '5', beat: 1.5 },
|
||||||
|
{ deg: '1', octave: 1, beat: 2 },
|
||||||
|
{ deg: '7', beat: 2.5 },
|
||||||
|
{ deg: '5', beat: 3 },
|
||||||
|
{ deg: 'b3', beat: 3.5, technique: 'grace-note' },
|
||||||
|
{ deg: '3', beat: 3.5 },
|
||||||
|
],
|
||||||
|
tips: 'Strings bend; the piano crushes. Flick the ♭3 into the 3rd almost as one gesture, walk straight up the chord (3, 5, octave root), answer back down (♭7, 5), and close with the same crush that opened the phrase — one blue note, spent twice.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Over C7: C5 B♭4 G4 F4 E♭4 C4 — offsets 12 10 7 5 3 0: the full
|
||||||
|
// minor pentatonic (1 ♭7 5 4 ♭3 1) falling top to bottom on the
|
||||||
|
// triplet grid, landing the low root on beat 3.
|
||||||
|
id: 'blues-penta-fall-turnaround',
|
||||||
|
name: 'Pentatonic fall (turnaround run)',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'bar 11, over the I7, setting up the turnaround',
|
||||||
|
quality: 'dom7',
|
||||||
|
techniques: [],
|
||||||
|
source: 'the descending minor-pentatonic run every blues pianist keeps — stock vocabulary; see pianogroove.com\'s Chicago Blues course',
|
||||||
|
notes: [
|
||||||
|
{ deg: '1', octave: 1, beat: 1 },
|
||||||
|
{ deg: '7', beat: 1.33 },
|
||||||
|
{ deg: '5', beat: 1.67 },
|
||||||
|
{ deg: '11', beat: 2 },
|
||||||
|
{ deg: 'b3', beat: 2.33 },
|
||||||
|
{ deg: '1', beat: 3 },
|
||||||
|
],
|
||||||
|
tips: 'Top to bottom of the minor pentatonic — root, ♭7, 5, 4, ♭3, root — in one triplet gesture, with a breath before the low root lands on beat 3 so the turnaround has somewhere to kick from. Over the I7 the run\'s ♭3 is the chord\'s ♯9 and the 4 passes between the 3rd and the 5th; the same six keys survive untouched over the i7 of a minor blues.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Over C7, high dyads: E5+G5 ×4 (offsets 16+19, a minor third apart)
|
||||||
|
// hammered on the triplet grid, then peeling down the chord:
|
||||||
|
// C5(12) B♭4(10) G4(7).
|
||||||
|
id: 'blues-spann-hammer-dyads',
|
||||||
|
name: 'Spann hammer dyads',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'over the I7 — the high fill that cuts through a Chicago band',
|
||||||
|
quality: 'dom7',
|
||||||
|
techniques: ['double-stop'],
|
||||||
|
source: 'in the Otis Spann lane — hammered right-hand triplet dyads, the postwar Chicago fill (Blues Hall of Fame)',
|
||||||
|
notes: [
|
||||||
|
{ deg: '3', octave: 1, beat: 1 },
|
||||||
|
{ deg: '5', octave: 1, beat: 1, technique: 'double-stop' },
|
||||||
|
{ deg: '3', octave: 1, beat: 1.33 },
|
||||||
|
{ deg: '5', octave: 1, beat: 1.33, technique: 'double-stop' },
|
||||||
|
{ deg: '3', octave: 1, beat: 1.67 },
|
||||||
|
{ deg: '5', octave: 1, beat: 1.67, technique: 'double-stop' },
|
||||||
|
{ deg: '3', octave: 1, beat: 2 },
|
||||||
|
{ deg: '5', octave: 1, beat: 2, technique: 'double-stop' },
|
||||||
|
{ deg: '1', octave: 1, beat: 2.33 },
|
||||||
|
{ deg: '7', beat: 2.67 },
|
||||||
|
{ deg: '5', beat: 3 },
|
||||||
|
],
|
||||||
|
tips: 'One dyad — the 3rd with the 5th a minor third above it — hammered four times on the triplet grid like a snare press, then the line peels off down the chord: octave root, ♭7, settling on the 5th. The repetition IS the lick; on a slow blues, stretch it toward a tremolo.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Over C7 as the V7 (an F blues): E♭5(15) held, D5(14), B♭4(10),
|
||||||
|
// G4(7), then the crush (E♭4=3)E4(4). The ♯9→9 melt is 15→14.
|
||||||
|
id: 'blues-cry-melt-v7',
|
||||||
|
name: 'The cry and melt (V7 fill)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'over the V7, bar 9 of a slow blues',
|
||||||
|
quality: 'dom7',
|
||||||
|
techniques: ['grace-note'],
|
||||||
|
source: 'the ♯9→9 "crying" resolution — pianogroove.com slow-blues lessons; the no-5th grip per piano-ology "Dominant 7(♯9) Voicings"',
|
||||||
|
notes: [
|
||||||
|
{ deg: '#9', octave: 1, beat: 1 },
|
||||||
|
{ deg: '9', octave: 1, beat: 2 },
|
||||||
|
{ deg: '7', beat: 2.33 },
|
||||||
|
{ deg: '5', beat: 2.67 },
|
||||||
|
{ deg: 'b3', beat: 3, technique: 'grace-note' },
|
||||||
|
{ deg: '3', beat: 3 },
|
||||||
|
],
|
||||||
|
tips: 'Lean on the ♯9 for a full beat — over the V7 it is the key\'s own ♭7, the crying note — then let it melt a half-step onto the natural 9, fall through the ♭7 and 5th, and land a ♭3 crush on the 3rd. Placement is the whole trick: bar 9 is the emotional peak of the form.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export default [
|
||||||
|
{
|
||||||
|
id: 'blues-12bar',
|
||||||
|
name: 'Standard 12-bar',
|
||||||
|
rn: ['I7', 'I7', 'I7', 'I7', 'IV7', 'IV7', 'I7', 'I7', 'V7', 'IV7', 'I7', 'V7'],
|
||||||
|
degrees: [0, 0, 0, 0, 5, 5, 0, 0, 7, 5, 0, 7],
|
||||||
|
qualities: ['dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7'],
|
||||||
|
bars: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Sweet Home Chicago', 'Pride and Joy — Stevie Ray Vaughan', 'Johnny B. Goode — Chuck Berry'],
|
||||||
|
tip: 'Bar 12 is the V7 launching the next chorus — never let it resolve flat. The form is a wheel, and bar 12 is where you push it.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blues-quickchange',
|
||||||
|
name: 'Quick-change 12-bar',
|
||||||
|
rn: ['I7', 'IV7', 'I7', 'I7', 'IV7', 'IV7', 'I7', 'I7', 'V7', 'IV7', 'I7', 'V7'],
|
||||||
|
degrees: [0, 5, 0, 0, 5, 5, 0, 0, 7, 5, 0, 7],
|
||||||
|
qualities: ['dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7'],
|
||||||
|
bars: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Dust My Broom — Elmore James', 'Hide Away — Freddie King', 'Crossroads — Cream'],
|
||||||
|
tip: 'One bar of IV in bar 2, then home. Listen for it in the first seconds of a jam — guessing wrong here is the most common train wreck in blues.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blues-8bar',
|
||||||
|
name: '8-bar blues',
|
||||||
|
rn: ['I7', 'V7', 'IV7', 'IV7', 'I7', 'V7', 'I7', 'V7'],
|
||||||
|
degrees: [0, 7, 5, 5, 0, 7, 0, 7],
|
||||||
|
qualities: ['dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7'],
|
||||||
|
bars: [1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Key to the Highway — Big Bill Broonzy', 'It Hurts Me Too — Elmore James'],
|
||||||
|
tip: 'The V arrives in bar 2 — much sooner than a 12-bar. Count the form out loud the first chorus; 8-bar tunes wrong-foot 12-bar reflexes.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blues-minor',
|
||||||
|
name: 'Minor blues',
|
||||||
|
rn: ['i7', 'i7', 'i7', 'i7', 'iv7', 'iv7', 'i7', 'i7', '♭VI7', 'V7', 'i7', 'i7'],
|
||||||
|
degrees: [0, 0, 0, 0, 5, 5, 0, 0, 8, 7, 0, 0],
|
||||||
|
qualities: ['min7', 'min7', 'min7', 'min7', 'min7', 'min7', 'min7', 'min7', 'dom7', 'dom7', 'min7', 'min7'],
|
||||||
|
bars: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
mode: 'minor',
|
||||||
|
songs: ['The Thrill Is Gone — B.B. King', 'As the Years Go Passing By — Albert King'],
|
||||||
|
tip: 'Bars 9–10 are the whole drama: ♭VI sliding down a half-step to V7. (B.B.\'s recording makes the ♭VI a maj7 — try both colours.)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blues-turnaround',
|
||||||
|
name: 'Turnaround cycle (I–VI–ii–V)',
|
||||||
|
rn: ['I7', 'VI7', 'ii7', 'V7'],
|
||||||
|
degrees: [0, 9, 2, 7],
|
||||||
|
qualities: ['dom7', 'dom7', 'min7', 'dom7'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Call It Stormy Monday — T-Bone Walker (intro)', 'jazz-blues bars 11–12 everywhere'],
|
||||||
|
tip: 'The jazz handshake inside the blues — often squeezed into two bars (two beats per chord). Loop it as a vamp and you\'ve learned bars 11–12 of every uptown blues.',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Bossa nova guitar pack. Grips verified by note-spelling against: jazzguitar.be
|
||||||
|
// (Ipanema chords), jenslarsen.nl (bossa patterns, 5 levels), thejazzpianosite.com
|
||||||
|
// (rhythm layers), Nelson Faria "The Brazilian Guitar Book" (canonical grip source),
|
||||||
|
// mdecksmusic.com (Ipanema analysis), jazz-circle.com (Blue Bossa, Black Orpheus).
|
||||||
|
//
|
||||||
|
// Construction: thumb takes the root on string 6 or 5; fingers take 3-4 notes on
|
||||||
|
// D-G-B(-e). Every grip movable. Two plays per progression = the two root-string
|
||||||
|
// sets, because that's how bossa voice-leads: adjacent chords trade root strings
|
||||||
|
// so inner voices move by one fret ("two fingers move, the chord transforms").
|
||||||
|
|
||||||
|
// Root on the low E string (thumb).
|
||||||
|
const M7_6 = { rootStr: 6, offsets: [0, 'x', 0, 0, 0, 'x'], fingers: [1, 0, 2, 3, 4, 0] } // R-♭7-♭3-5
|
||||||
|
const MAJ7_6 = { rootStr: 6, offsets: [0, 'x', 1, 1, 0, 'x'], fingers: [1, 0, 3, 4, 2, 0] } // R-7-3-5
|
||||||
|
const DOM7_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, 0, 'x'], fingers: [1, 0, 2, 3, 4, 0] } // R-♭7-3-5
|
||||||
|
const DOM13_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, 2, 'x'], fingers: [1, 0, 2, 3, 4, 0] } // R-♭7-3-13
|
||||||
|
const DOM7B9_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, 0, 1], fingers: [1, 0, 2, 3, 1, 4] } // R-♭7-3-5-♭9
|
||||||
|
const M7B5_6 = { rootStr: 6, offsets: [0, 'x', 0, 0, -1, 'x'], fingers: [2, 0, 3, 4, 1, 0] } // R-♭7-♭3-♭5
|
||||||
|
const M6_6 = { rootStr: 6, offsets: [0, 'x', -1, 0, 0, 'x'], fingers: [2, 0, 1, 3, 4, 0] } // R-6-♭3-5
|
||||||
|
const DIM7_6 = { rootStr: 6, offsets: [0, 'x', -1, 0, -1, 'x'], fingers: [2, 0, 1, 3, 1, 0] } // R-♭♭7-♭3-♭5
|
||||||
|
const DOM7S11_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, -1, 'x'], fingers: [2, 0, 3, 4, 1, 0] } // R-♭7-3-♯11 (the tritone-sub grip)
|
||||||
|
|
||||||
|
// Root on the A string (thumb).
|
||||||
|
const M7_5 = { rootStr: 5, offsets: ['x', 0, 2, 0, 1, 'x'], fingers: [0, 1, 3, 2, 4, 0] } // R-5-♭7-♭3
|
||||||
|
const M7_5C = { rootStr: 5, offsets: ['x', 0, -2, 0, 1, 'x'], fingers: [0, 2, 1, 3, 4, 0] } // R-♭3-♭7-♭3 compact grab
|
||||||
|
const MAJ9_5 = { rootStr: 5, offsets: ['x', 0, -1, 1, 0, 'x'], fingers: [0, 2, 1, 4, 3, 0] } // R-3-7-9
|
||||||
|
const DOM9_5 = { rootStr: 5, offsets: ['x', 0, -1, 0, 0, 'x'], fingers: [0, 2, 1, 3, 4, 0] } // R-3-♭7-9
|
||||||
|
const DOM7B9_5 = { rootStr: 5, offsets: ['x', 0, -1, 0, -1, 'x'], fingers: [0, 3, 1, 4, 2, 0] } // R-3-♭7-♭9
|
||||||
|
const M7B5_5 = { rootStr: 5, offsets: ['x', 0, 1, 0, 1, 'x'], fingers: [0, 1, 3, 2, 4, 0] } // R-♭5-♭7-♭3
|
||||||
|
const M6_5 = { rootStr: 5, offsets: ['x', 0, -2, -1, -2, 'x'], fingers: [0, 4, 1, 3, 2, 0] } // R-♭3-6-R
|
||||||
|
const DIM7_5 = { rootStr: 5, offsets: ['x', 0, 1, -1, 1, 'x'], fingers: [0, 2, 3, 1, 4, 0] } // R-♭5-♭♭7-♭3
|
||||||
|
|
||||||
|
export default {
|
||||||
|
styleIntro:
|
||||||
|
'The bossa guitarist is the whole rhythm section: thumb plays the surdo drum (root on 1, fifth on 3, never syncopated), fingers play the chord block on the anticipations. Quiet is louder — the genre was invented at apartment volume, and intensity comes from rhythmic placement and harmonic colour, never from strumming harder.',
|
||||||
|
|
||||||
|
comping: [
|
||||||
|
{
|
||||||
|
label: 'Thumb bass (the surdo)',
|
||||||
|
rhythm: 'B . . . B . . . — root on 1, fifth on 3',
|
||||||
|
description: 'Metronomic, soft, every bar, under everything. The one layer that is never syncopated. With a bassist: drop it entirely and play only the upper notes.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'One-bar starter pattern',
|
||||||
|
rhythm: 'X . . X . . X . — hits on 1, and-of-2, 4',
|
||||||
|
description: 'The training-wheels comp: chord block on 1, the and-of-2, and 4 over the steady thumb. Master this before the two-bar pattern.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Two-bar João Gilberto pattern',
|
||||||
|
rhythm: 'X . . X . . . X~ | . . . X . . X . — the 4& ties over the barline',
|
||||||
|
description: 'Bar 2 has no downbeat chord — the tied and-of-4 carries across. The anticipation is the hardest and most essential bossa skill. Gilberto drifted between patterns freely; treat it as a motif, not a loop.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Partido alto (the samba cousin — for contrast)',
|
||||||
|
rhythm: '. X . X X . . X — lands HARD on beat 3',
|
||||||
|
description: 'Percussive, chopped, with muted ghost-strums — the opposite aesthetic. Bossa never accents beat 3: that beat belongs to the bass register (the surdo). Hammering it squares the lilt into a polka.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plays: {
|
||||||
|
'bossa-ipanema': [
|
||||||
|
{
|
||||||
|
label: 'Low-E roots (the João position)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: MAJ7_6, note: 'Imaj7' },
|
||||||
|
{ shape: DOM13_6, extensions: ['13'], note: 'II7(13) — the Lydian ♭7 colour' },
|
||||||
|
{ shape: M7_6, note: 'ii7' },
|
||||||
|
{ shape: DOM7S11_6, extensions: ['#11'], note: '♭II7(♯11) — tritone sub of V' },
|
||||||
|
{ shape: MAJ7_6, note: 'home' },
|
||||||
|
{ shape: DOM7S11_6, extensions: ['#11'], note: 'and the ♭II7 again — Jobim never quite lets go' },
|
||||||
|
],
|
||||||
|
tips: 'The whole A-section lives in a four-fret window: each change moves the thumb a fret or two and one or two fingers inside the grip. If a finger jumps more than two frets, you took a wrong turn.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'A-string roots, colour-tone set',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: MAJ9_5, extensions: ['9'], note: 'Imaj9' },
|
||||||
|
{ shape: DOM9_5, extensions: ['9'], note: 'II9' },
|
||||||
|
{ shape: M7_5, note: 'ii7' },
|
||||||
|
{ shape: DOM9_5, extensions: ['9'], note: '♭II9' },
|
||||||
|
{ shape: MAJ9_5, extensions: ['9'], note: '' },
|
||||||
|
{ shape: DOM9_5, extensions: ['9'], note: '' },
|
||||||
|
],
|
||||||
|
tips: 'Same progression, one string set higher and sweeter — 9ths everywhere. Use this set when another guitarist or pianist already owns the low-E register. (If the band plays a plain major tonic, the 6/9 grab — drop the 7th for the 6 — is the classic bossa colour.)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'bossa-minor-251': [
|
||||||
|
{
|
||||||
|
label: 'Low-E roots with the ♭9',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: M7_6, note: 'i7' },
|
||||||
|
{ shape: M7B5_6, note: 'iiø7 — the ♭5 on the B string is the saudade note' },
|
||||||
|
{ shape: DOM7B9_6, extensions: ['b9'], note: 'V7♭9 — the ♭5 you just played, reinterpreted' },
|
||||||
|
{ shape: M6_6, note: 'i6 — resolve to the sixth, not the seventh' },
|
||||||
|
],
|
||||||
|
tips: 'One pitch threads the middle of the progression: the iiø7\'s ♭5 IS the V7\'s ♭9. Find it, hold it, let the thumb do the moving.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'A-string roots, compact grabs',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: M7_5C, note: 'compact i7 — no 5th, pure bossa economy' },
|
||||||
|
{ shape: M7B5_5, note: 'iiø7' },
|
||||||
|
{ shape: DOM7B9_5, extensions: ['b9'], note: 'V7♭9' },
|
||||||
|
{ shape: M6_5, note: 'i6' },
|
||||||
|
],
|
||||||
|
tips: 'Black Orpheus oscillates between this cell and the relative major\'s ii–V–I — learn both as one hand pattern and the whole tune is two moves.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'bossa-blue': [
|
||||||
|
{
|
||||||
|
label: 'Thumb-bass through the form',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: M7_6, note: 'i7' }, { shape: M7_6, note: '' },
|
||||||
|
{ shape: M7_5, note: 'iv7 — A-string root, same fret region' }, { shape: M7_5, note: '' },
|
||||||
|
{ shape: M7B5_5, note: 'iiø7' },
|
||||||
|
{ shape: DOM7B9_6, extensions: ['b9'], note: 'V7♭9' },
|
||||||
|
{ shape: M7_6, note: '' }, { shape: M7_6, note: '' },
|
||||||
|
{ shape: M7_6, note: '♭iii7 — the excursion begins' },
|
||||||
|
{ shape: DOM9_5, extensions: ['9'], note: '♭VI9' },
|
||||||
|
{ shape: MAJ9_5, extensions: ['9'], note: '♭IImaj9 — a major-key vacation' },
|
||||||
|
{ shape: MAJ9_5, extensions: ['9'], note: '' },
|
||||||
|
{ shape: M7B5_5, note: 'iiø7 — back to reality' },
|
||||||
|
{ shape: DOM7B9_6, extensions: ['b9'], note: 'V7♭9' },
|
||||||
|
{ shape: M7_6, note: '' }, { shape: M7_6, note: '' },
|
||||||
|
],
|
||||||
|
tips: 'i and iv sit on adjacent root strings in one position, like a blues. The bars 9–12 excursion is a normal major ii–V–I — play it sweeter, then darken again for the iiø7.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Colour set (9ths and the 6/9 cadence)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: M7_5C, note: '' }, { shape: M7_5C, note: '' },
|
||||||
|
{ shape: M7_6, note: 'iv7 low' }, { shape: M7_6, note: '' },
|
||||||
|
{ shape: M7B5_6, note: '' },
|
||||||
|
{ shape: DOM7B9_5, extensions: ['b9'], note: '' },
|
||||||
|
{ shape: M7_5C, note: '' }, { shape: M7_5C, note: '' },
|
||||||
|
{ shape: M7_5C, note: '' },
|
||||||
|
{ shape: DOM13_6, extensions: ['13'], note: '♭VI13' },
|
||||||
|
{ shape: MAJ9_5, extensions: ['9'], note: '♭IImaj9' },
|
||||||
|
{ shape: MAJ9_5, extensions: ['9'], note: '' },
|
||||||
|
{ shape: M7B5_6, note: '' },
|
||||||
|
{ shape: DOM7B9_5, extensions: ['b9'], note: '' },
|
||||||
|
{ shape: M7_5C, note: '' }, { shape: M7_5C, note: '' },
|
||||||
|
],
|
||||||
|
tips: 'Blue Bossa\'s tonic is a true m7 — save the m6 colour for tunes that ask for it (see the minor ii–V–i cell). Keep all of it at whisper volume.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'bossa-one-note': [
|
||||||
|
{
|
||||||
|
label: 'Two grips falling by half-steps (A-string roots)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: M7_5, note: 'iii7' },
|
||||||
|
{ shape: DOM9_5, extensions: ['9'], note: '♭III9 — same fret region, one finger reshapes' },
|
||||||
|
{ shape: M7_5, note: 'ii7 — whole grip slides down' },
|
||||||
|
{ shape: DOM9_5, extensions: ['9'], note: '♭II9' },
|
||||||
|
],
|
||||||
|
tips: 'The entire progression is two grips alternating while the thumb walks down chromatically. Hold one melody note on top if you can reach it — that\'s the whole point of the tune.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Low-E roots with 13s',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: M7_6, note: '' },
|
||||||
|
{ shape: DOM13_6, extensions: ['13'], note: '♭III13' },
|
||||||
|
{ shape: M7_6, note: '' },
|
||||||
|
{ shape: DOM13_6, extensions: ['13'], note: '♭II13' },
|
||||||
|
],
|
||||||
|
tips: 'The 13 on top of each dominant descends in parallel with the bass — two chromatic lines moving in lockstep, which is why this progression sounds inevitable.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'bossa-corcovado': [
|
||||||
|
{
|
||||||
|
label: 'Chromatic staircase, low-E roots',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: M6_6, note: 'iii6' },
|
||||||
|
{ shape: DIM7_6, note: 'passing °7 — one fret down' },
|
||||||
|
{ shape: M7_6, note: 'ii7 — one more' },
|
||||||
|
{ shape: DOM7S11_6, extensions: ['#11'], note: '♭II7(♯11) — tritone sub' },
|
||||||
|
{ shape: MAJ7_6, note: 'Imaj7 — arrival' },
|
||||||
|
],
|
||||||
|
tips: 'The low E string walks down one fret per bar — let that bassline sing through the grips. This is the same passing-diminished device as How Insensitive; learn it once, hear it everywhere in Jobim.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'A-string set with the 6/9 landing',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: M6_5, note: 'iii6' },
|
||||||
|
{ shape: DIM7_5, note: '°7' },
|
||||||
|
{ shape: M7_5C, note: 'ii7' },
|
||||||
|
{ shape: DOM7B9_5, extensions: ['b9'], note: '♭II7(♭9)' },
|
||||||
|
{ shape: MAJ9_5, extensions: ['9'], note: 'Imaj9 — arrival' },
|
||||||
|
],
|
||||||
|
tips: 'When the singer holds the tonic, swap the maj9 for a 6/9 grab (7th down to the 6) — no leading tone to fight them. Over a detected maj7, stay with the maj9.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
improv: {
|
||||||
|
scales: [
|
||||||
|
{ over: 'ii7 / i7 / iv7', scale: 'dorian', why: 'All the minor 7ths take Dorian — bossa is jazz harmony in a swimsuit.' },
|
||||||
|
{ over: 'V7 → major I', scale: 'mixolydian', why: 'Plain Mixolydian when resolving to major; add the 13 — it\'s the genre\'s favourite colour.' },
|
||||||
|
{ over: 'II7 / ♭II7 (tritone subs)', scale: 'lydian', why: 'Lydian dominant (melodic minor from the 5th) — the ♯11 is already in the chord grip.' },
|
||||||
|
{ over: 'V7♭9 → minor i', scale: 'phrygian', why: 'Phrygian dominant (harmonic minor from the V) for the ♭9; the altered scale if you want more trouble.' },
|
||||||
|
{ over: 'iiø7', scale: 'locrian', why: 'Locrian, or raise the 2 (melodic-minor mode 6) for a smoother colour.' },
|
||||||
|
],
|
||||||
|
targetNotes:
|
||||||
|
'Bossa solos are melody-first: hold or repeat a small cell and let the CHORDS recontextualise it — One Note Samba is the method stated as a song title. Target the colour tones (9, 13, ♯11) and the 3rd/7th guide-tone line; avoid sitting on roots. Phrase behind the beat and leave bar-length gaps.',
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
over: 'bossa-ipanema',
|
||||||
|
description: 'The Ipanema opening cell: the melody sits on the 9th and major 7th of the Imaj7 — never the root. The identical two notes work over the II7 bars, where they become root and 13.',
|
||||||
|
tab: 'e|--3--------------3-----------\nB|------5--5--3--------5--5----\n G E E D G E E\n (9) (7)(7)(6) (9) (7)(7) over Fmaj7',
|
||||||
|
source: '"Garota de Ipanema" — Jobim/de Moraes (Real Book lead sheet; mDecks harmonic analysis)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
id: 'bossa',
|
||||||
|
label: 'Bossa Nova',
|
||||||
|
feel: 'bossa',
|
||||||
|
tempoRange: [110, 160],
|
||||||
|
character: 'A whole samba band condensed into one quiet guitar: metronomic thumb bass, syncopated chord block that never accents beat 3, and jazz harmony at conversation volume.',
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export default [
|
||||||
|
{
|
||||||
|
id: 'bossa-ipanema',
|
||||||
|
name: 'Ipanema A-section (Imaj7–II7–ii7–♭II7)',
|
||||||
|
rn: ['Imaj7', 'II7', 'ii7', '♭II7', 'Imaj7', '♭II7'],
|
||||||
|
degrees: [0, 2, 2, 1, 0, 1],
|
||||||
|
qualities: ['maj7', 'dom7', 'min7', 'dom7', 'maj7', 'dom7'],
|
||||||
|
bars: [2, 2, 1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['The Girl from Ipanema — Jobim', 'Desafinado (same II7 colour)'],
|
||||||
|
tip: 'The II7 is a true dominant-II (Lydian ♭7 colour), and the ♭II7 is the tritone sub of V — the bass slides home by half-step instead of jumping a fifth.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bossa-minor-251',
|
||||||
|
name: 'Minor ii–V–i (Black Orpheus)',
|
||||||
|
rn: ['i7', 'iiø7', 'V7♭9', 'i6'],
|
||||||
|
degrees: [0, 2, 7, 0],
|
||||||
|
qualities: ['min7', 'half_dim', 'dom7', 'min6'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'minor',
|
||||||
|
songs: ['Manhã de Carnaval (Black Orpheus) — Luiz Bonfá', 'How Insensitive — Jobim (same engine)'],
|
||||||
|
tip: 'The bossa minor tonic is the m6, not the m7 — that major sixth is the saudade. The ♭5 of the iiø7 returns as the ♭9 of the V7.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bossa-blue',
|
||||||
|
name: 'Blue Bossa (16-bar form)',
|
||||||
|
rn: ['i7', 'i7', 'iv7', 'iv7', 'iiø7', 'V7', 'i7', 'i7', '♭iii7', '♭VI7', '♭IImaj7', '♭IImaj7', 'iiø7', 'V7', 'i7', 'i7'],
|
||||||
|
degrees: [0, 0, 5, 5, 2, 7, 0, 0, 3, 8, 1, 1, 2, 7, 0, 0],
|
||||||
|
qualities: ['min7', 'min7', 'min7', 'min7', 'half_dim', 'dom7', 'min7', 'min7', 'min7', 'dom7', 'maj7', 'maj7', 'half_dim', 'dom7', 'min7', 'min7'],
|
||||||
|
bars: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
mode: 'minor',
|
||||||
|
songs: ['Blue Bossa — Kenny Dorham'],
|
||||||
|
tip: 'i–iv–iiø–V in minor, then a complete ii–V–I excursion a half-step up (♭II major) in bars 9–12. The jam-session bossa: everyone knows it, nobody minds another chorus.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bossa-one-note',
|
||||||
|
name: 'Chromatic ii–V chain (One Note Samba)',
|
||||||
|
rn: ['iii7', '♭III7', 'ii7', '♭II7'],
|
||||||
|
degrees: [4, 3, 2, 1],
|
||||||
|
qualities: ['min7', 'dom7', 'min7', 'dom7'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['One Note Samba — Jobim'],
|
||||||
|
tip: 'iii–VI–ii–V with both dominants tritone-subbed: the bass walks straight down by half-steps while one melody note holds on top. Two grips, alternating, falling.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bossa-corcovado',
|
||||||
|
name: 'Corcovado descent',
|
||||||
|
rn: ['iii6', '♭iii°7', 'ii7', '♭II7', 'Imaj7'],
|
||||||
|
degrees: [4, 3, 2, 1, 0],
|
||||||
|
qualities: ['min6', 'dim7', 'min7', 'dom7', 'maj7'],
|
||||||
|
bars: [1, 1, 1, 1, 2],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Corcovado (Quiet Nights) — Jobim', 'How Insensitive — Jobim (same passing-dim device)'],
|
||||||
|
tip: 'A chromatic staircase into the I: m6 → passing diminished → ii → tritone sub. (Charts vary on bar 2 — some write a II7(13) instead of the ♭iii°7; both are in circulation.)',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
// Country/folk guitar pack. Shapes verified by note-spelling against:
|
||||||
|
// acousticguitar.com (boom-chuck, bass runs, Travis picking), fretjam.com
|
||||||
|
// (alternate bass map), hvbluegrass.org (the Lester Flatt G-run), premierguitar.com
|
||||||
|
// (double-stops), wernickmethod.org & drbanjo.com (jam etiquette), andyguitar.co.uk
|
||||||
|
// (train beat), untidymusic.com (anchor-finger folk chords).
|
||||||
|
|
||||||
|
// Open shapes — the genre's home. Render only when the chord root matches.
|
||||||
|
const OPEN_G_FOLK = { onlyRoot: 7, frets: [3, 2, 0, 0, 3, 3], fingers: [2, 1, 0, 0, 3, 4] } // big ringing folk G
|
||||||
|
const OPEN_C = { onlyRoot: 0, frets: ['x', 3, 2, 0, 1, 0], fingers: [0, 3, 2, 0, 1, 0] }
|
||||||
|
const OPEN_CADD9 = { onlyRoot: 0, frets: ['x', 3, 2, 0, 3, 3], fingers: [0, 2, 1, 0, 3, 4] }
|
||||||
|
const OPEN_D = { onlyRoot: 2, frets: ['x', 'x', 0, 2, 3, 2], fingers: [0, 0, 0, 1, 3, 2] }
|
||||||
|
const OPEN_D7 = { onlyRoot: 2, frets: ['x', 'x', 0, 2, 1, 2], fingers: [0, 0, 0, 2, 1, 3] }
|
||||||
|
const OPEN_A7 = { onlyRoot: 9, frets: ['x', 0, 2, 0, 2, 0], fingers: [0, 0, 1, 0, 2, 0] }
|
||||||
|
const OPEN_G7 = { onlyRoot: 7, frets: [3, 2, 0, 0, 0, 1], fingers: [3, 2, 0, 0, 0, 1] }
|
||||||
|
const OPEN_E = { onlyRoot: 4, frets: [0, 2, 2, 1, 0, 0], fingers: [0, 2, 3, 1, 0, 0] }
|
||||||
|
const OPEN_EM7 = { onlyRoot: 4, frets: [0, 2, 2, 0, 3, 3], fingers: [0, 1, 2, 0, 3, 4] }
|
||||||
|
const OPEN_AM = { onlyRoot: 9, frets: ['x', 0, 2, 2, 1, 0], fingers: [0, 0, 2, 3, 1, 0] }
|
||||||
|
const OPEN_F = { onlyRoot: 5, frets: ['x', 'x', 3, 2, 1, 1], fingers: [0, 0, 3, 2, 1, 1] }
|
||||||
|
|
||||||
|
// Movable barres — for when the capo can't save you.
|
||||||
|
const BARRE_MAJ_6 = { rootStr: 6, offsets: [0, 2, 2, 1, 0, 0], fingers: [1, 3, 4, 2, 1, 1] }
|
||||||
|
const BARRE_MAJ_5 = { rootStr: 5, offsets: ['x', 0, 2, 2, 2, 0], fingers: [0, 1, 2, 3, 4, 1] }
|
||||||
|
const BARRE_MIN_6 = { rootStr: 6, offsets: [0, 2, 2, 0, 0, 0], fingers: [1, 3, 4, 1, 1, 1] }
|
||||||
|
const BARRE_DOM7_6 = { rootStr: 6, offsets: [0, 2, 0, 1, 0, 0], fingers: [1, 3, 1, 2, 1, 1] }
|
||||||
|
const BARRE_DOM7_5 = { rootStr: 5, offsets: ['x', 0, 2, 0, 2, 0], fingers: [0, 1, 3, 1, 4, 1] }
|
||||||
|
|
||||||
|
export default {
|
||||||
|
styleIntro:
|
||||||
|
'In a folk circle you accompany the singer; in a bluegrass jam you ARE the drums — bass notes on 1 and 3 (kick), crisp strums on 2 and 4 (snare), bass runs announcing every change, and the capo moving your open shapes to whatever key the singer holds up fingers for.',
|
||||||
|
|
||||||
|
comping: [
|
||||||
|
{
|
||||||
|
label: 'Boom-chick (alternating bass)',
|
||||||
|
rhythm: 'B . X . B . X . — root bass, strum, 5th bass, strum',
|
||||||
|
description: 'The engine: picked bass note (root on 1, fifth on 3 — each open chord has its alternation map), down-strum chick between. Add an up-strum after each chick for "boom chick-a".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Carter bass runs',
|
||||||
|
rhythm: 'runs replace beats 3–4 before a change',
|
||||||
|
description: 'G→C: walk G–A–B into the C. C→G: walk back down. G→D: G–A–B–C♯. The run tells the whole circle the change is coming — in bluegrass it\'s practically mandatory.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Travis picking',
|
||||||
|
rhythm: 'thumb: steady quarters on alternating bass; fingers: syncopated treble',
|
||||||
|
description: 'Thumb never stops (the §boom-chick map), index/middle pick G and B strings between, pinch on beat 1. Freight Train is the curriculum.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Train beat (Cash)',
|
||||||
|
rhythm: 'D D U D U D U with accents on 2 & 4, half-muted',
|
||||||
|
description: 'Strings damped just enough to fake a snare; keep the boom note clean, mute only the chicks. Folsom Prison at any tempo.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Waltz boom-chick-chick',
|
||||||
|
rhythm: '3/4: B X X — bass, strum, strum',
|
||||||
|
description: 'Bass note on 1, two strums after, alternating root/5th by bar. Tennessee Waltz, Amazing Grace — every jam has them.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plays: {
|
||||||
|
'country-145': [
|
||||||
|
{
|
||||||
|
label: 'Open G-family, boom-chick',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: OPEN_G_FOLK, note: 'I — bass alternates low-E G / open-D D' },
|
||||||
|
{ shape: OPEN_G_FOLK, note: '' },
|
||||||
|
{ shape: OPEN_C, extensions: [], note: 'IV — bass: A-string C / low-E G' },
|
||||||
|
{ shape: OPEN_G_FOLK, note: 'walk back down C–B–A–G' },
|
||||||
|
{ shape: OPEN_G_FOLK, note: '' },
|
||||||
|
{ shape: OPEN_G_FOLK, note: '' },
|
||||||
|
{ shape: OPEN_D7, note: 'V7 — bass: open D / open A' },
|
||||||
|
{ shape: OPEN_G_FOLK, note: 'home, G-run on the phrase end' },
|
||||||
|
],
|
||||||
|
tips: 'This is the key-of-G home position; for the singer\'s key, move the capo, not the shapes (A = capo 2, B♭ = capo 3, B = capo 4 — the G-run survives the capo, a barre kills it).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Barre shapes (capo-proof)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: BARRE_MAJ_6, note: 'I' },
|
||||||
|
{ shape: BARRE_MAJ_6, note: '' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: 'IV — same fret, next string' },
|
||||||
|
{ shape: BARRE_MAJ_6, note: '' },
|
||||||
|
{ shape: BARRE_MAJ_6, note: '' },
|
||||||
|
{ shape: BARRE_MAJ_6, note: '' },
|
||||||
|
{ shape: BARRE_DOM7_5, note: 'V7 — two frets up from the IV' },
|
||||||
|
{ shape: BARRE_MAJ_6, note: '' },
|
||||||
|
],
|
||||||
|
tips: 'For keys where no capo position gives you open strings. You lose the ringing folk voice — keep the alternating-bass right hand so you don\'t lose the genre.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'country-folk-axis': [
|
||||||
|
{
|
||||||
|
label: 'Anchor-finger folk set',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: OPEN_G_FOLK, note: 'I — ring+pinky stay planted on the top two strings' },
|
||||||
|
{ shape: OPEN_D, note: 'V' },
|
||||||
|
{ shape: OPEN_EM7, extensions: ['b7'], note: 'vi as Em7 — two fingers move, anchors hold' },
|
||||||
|
{ shape: OPEN_CADD9, extensions: ['9'], note: 'IV as Cadd9 — same anchors again' },
|
||||||
|
{ shape: OPEN_G_FOLK, note: '' },
|
||||||
|
{ shape: OPEN_D, note: '' },
|
||||||
|
{ shape: OPEN_CADD9, extensions: ['9'], note: '' },
|
||||||
|
{ shape: OPEN_CADD9, extensions: ['9'], note: 'two bars of IV — let it ring' },
|
||||||
|
],
|
||||||
|
tips: 'The modern-folk G-family sound: the top two strings drone through every chord while two fingers do the changes. Wagon Wheel is capo 2 with exactly these grips.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Barre version',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: BARRE_MAJ_6, note: 'I' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: 'V' },
|
||||||
|
{ shape: BARRE_MIN_6, note: 'vi' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: 'IV' },
|
||||||
|
{ shape: BARRE_MAJ_6, note: '' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: '' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: '' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: '' },
|
||||||
|
],
|
||||||
|
tips: 'When the song lands in a capo-hostile key. Lighten the left hand between strums — folk barres should breathe, not sustain like rock.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'country-ragtime': [
|
||||||
|
{
|
||||||
|
label: 'Open C-family (Alice\'s Restaurant grips)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: OPEN_C, note: 'I' },
|
||||||
|
{ shape: OPEN_A7, note: 'VI7 — the ragtime surprise' },
|
||||||
|
{ shape: OPEN_D7, note: 'II7' },
|
||||||
|
{ shape: OPEN_G7, note: 'V7 — and the sled arrives home' },
|
||||||
|
],
|
||||||
|
tips: 'Each dominant pulls into the next — lean on the bass notes (C→A→D→G is itself a circle of fifths) and the progression plays itself. Capo 2 = the Alice\'s Restaurant recording.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Barre circle',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: BARRE_MAJ_6, note: 'I' },
|
||||||
|
{ shape: BARRE_DOM7_5, note: 'VI7' },
|
||||||
|
{ shape: BARRE_DOM7_6, note: 'II7' },
|
||||||
|
{ shape: BARRE_DOM7_5, note: 'V7' },
|
||||||
|
],
|
||||||
|
tips: 'Roots alternate 6th and 5th strings around the circle, so the hand barely travels. Swing the strums — this family is ragtime\'s grandchild.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'country-rising-sun': [
|
||||||
|
{
|
||||||
|
label: 'The Animals grips (6/8 arpeggios)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: OPEN_AM, note: 'i — arpeggiate low to high, one sweep per bar' },
|
||||||
|
{ shape: OPEN_C, note: 'III' },
|
||||||
|
{ shape: OPEN_D, note: 'IV — the borrowed Dorian colour' },
|
||||||
|
{ shape: OPEN_F, note: 'VI' },
|
||||||
|
{ shape: OPEN_AM, note: '' },
|
||||||
|
{ shape: OPEN_E, note: 'V — the harmonic-minor pull home' },
|
||||||
|
],
|
||||||
|
tips: 'Six chords, one arpeggio pattern: bass note then climb the strings in 6/8. The D major is the chord that makes it haunting — don\'t flatten it to Dm.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Barre version',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: BARRE_MIN_6, note: 'i' },
|
||||||
|
{ shape: BARRE_MAJ_6, note: 'III' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: 'IV' },
|
||||||
|
{ shape: BARRE_MAJ_6, note: 'VI' },
|
||||||
|
{ shape: BARRE_MIN_6, note: '' },
|
||||||
|
{ shape: BARRE_MAJ_6, note: 'V' },
|
||||||
|
],
|
||||||
|
tips: 'Keeps the climb available in any key — arpeggiate the barres rather than strumming them or the 6/8 lilt disappears.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'country-bluegrass-cycle': [
|
||||||
|
{
|
||||||
|
label: 'G shapes, jam-circle standard',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: OPEN_G_FOLK, note: 'I' },
|
||||||
|
{ shape: OPEN_C, note: 'IV — walk up G–A–B into it' },
|
||||||
|
{ shape: OPEN_G_FOLK, note: 'walk back down' },
|
||||||
|
{ shape: OPEN_D, note: 'V — chromatic walk G–A–B–C♯ if you\'re feeling it' },
|
||||||
|
],
|
||||||
|
tips: 'Bluegrass keys are called in fiddle terms: A = capo 2, B = capo 4, all G shapes. Bass on 1 & 3 locks with the upright; your 2 & 4 strums ARE the snare — there is no drummer.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'C shapes (for keys C, D via capo)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: OPEN_C, note: 'I' },
|
||||||
|
{ shape: OPEN_F, note: 'IV — small F, top four strings' },
|
||||||
|
{ shape: OPEN_C, note: '' },
|
||||||
|
{ shape: OPEN_G7, extensions: ['b7'], note: 'V played as V7 — the bluegrass default' },
|
||||||
|
],
|
||||||
|
tips: 'The C family gives different bass runs (C–D–E into F) — worth owning both families so the capo choice is about the singer, not your habits.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
improv: {
|
||||||
|
scales: [
|
||||||
|
{ over: 'I / major vamps', scale: 'major', why: 'Major pentatonic is the country default — the Don Rich/Buck Owens sweetness.' },
|
||||||
|
{ over: 'I with attitude', scale: 'mixolydian', why: 'The "country composite": major pentatonic + the ♭3 blue note, always resolved up to the major 3rd.' },
|
||||||
|
{ over: 'i (minor folk)', scale: 'minor', why: 'Natural minor with the harmonic-minor leading tone saved for when the V chord arrives.' },
|
||||||
|
{ over: 'V7 / II7 (ragtime circle)', scale: 'mixolydian', why: 'Each dominant gets its own Mixolydian; target the 3rd of each as the circle turns.' },
|
||||||
|
],
|
||||||
|
targetNotes:
|
||||||
|
'Country fills are double-stops: 3rds on the G+B pair, 6ths on G+e (hybrid-picked for the snap), slid or hammered into chord tones on the beat. End phrases with the G-run — it is the genre\'s punctuation mark.',
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
over: 'country-bluegrass-cycle',
|
||||||
|
description: 'THE G-run (Lester Flatt): the canonical bluegrass phrase-ending tag — hammer through the blue note and land on the open G chord on the downbeat.',
|
||||||
|
tab: 'e|----------------------------3--\nB|----------------------------0--\nG|---------------------0------0--\nD|---------------0--2---------0--\nA|--0--h1--2------------------2--\nE|----------------------------3--\n A A# B D E G (G chord on 1)',
|
||||||
|
source: 'hvbluegrass.org "The Truth About the Lester Flatt G Run"; artistworks.com essential bluegrass licks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'country-bluegrass-cycle',
|
||||||
|
description: 'The original two-note Flatt run for flying tempos: E up to G at the phrase end — "an exclamation point at the end of a paragraph."',
|
||||||
|
tab: 'G|--------0-- (open G)\nD|--2-------- (E)',
|
||||||
|
source: 'hvbluegrass.org / nativeground.com (Flatt & Scruggs history)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Structured licks (SCHEMA.md "Licks", task P-24). Written in a home key each
|
||||||
|
// (noted per lick) but key-agnostic in spirit — chordContext names the station.
|
||||||
|
// Every pitch hand-verified: s6=E s5=A s4=D s3=G s2=B s1=e (+fret, mod 12).
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
// In G, open position: 2, ♭3 hammered to the 3, then 5–6 up to the open-G
|
||||||
|
// root landed on the downbeat. THE bluegrass phrase-ending tag.
|
||||||
|
id: 'country-flatt-g-run',
|
||||||
|
name: 'The Lester Flatt G-run',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'phrase-ending tag on the I',
|
||||||
|
techniques: ['hammer-on'],
|
||||||
|
source: 'the canonical Flatt & Scruggs tag — hvbluegrass.org "The Truth About the Lester Flatt G Run"',
|
||||||
|
tab: [
|
||||||
|
{ string: 5, fret: 0 }, // A — the 2
|
||||||
|
{ string: 5, fret: 1, technique: 'hammer-on' }, // B♭ — the blue ♭3, hammered from the open string
|
||||||
|
{ string: 5, fret: 2 }, // B — the major 3: the curl resolves
|
||||||
|
{ string: 4, fret: 0 }, // D — 5
|
||||||
|
{ string: 4, fret: 2 }, // E — 6
|
||||||
|
{ string: 3, fret: 0 }, // G — root: land it ON the downbeat with the chord
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In A, over the I7: hybrid-picked 3rds walking up the Mixolydian —
|
||||||
|
// 3+5 (C♯+E) → 4+6 (D+F♯) → 5+♭7 (E+G) — with clucked ghost notes
|
||||||
|
// between, resolving to the root on top. Shift the hand up with each pair.
|
||||||
|
id: 'country-chicken-thirds',
|
||||||
|
name: 'Chicken-pickin\' 3rds walk-up',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'over the I7 — fills between vocal lines',
|
||||||
|
techniques: ['ghost-note', 'double-stop', 'slide', 'vibrato'],
|
||||||
|
source: 'in the style of Brent Mason\'s double-stop chicken pickin\' — Brent Mason, Nashville Chops & Western Swing Guitar (Hal Leonard); Sam Smiley\'s Brent Mason lick analyses',
|
||||||
|
tab: [
|
||||||
|
{ string: 3, fret: 5, technique: 'ghost-note' }, // muted cluck — the chicken in the pickin'
|
||||||
|
{ string: 3, fret: 6 }, // C♯ — the 3
|
||||||
|
{ string: 2, fret: 5, technique: 'double-stop' }, // E — the 5: first 3rd-pair
|
||||||
|
{ string: 3, fret: 6, technique: 'ghost-note' }, // cluck between pairs
|
||||||
|
{ string: 3, fret: 7 }, // D — the 4
|
||||||
|
{ string: 2, fret: 7, technique: 'double-stop' }, // F♯ — the 6: passing pair
|
||||||
|
{ string: 2, fret: 8, technique: 'slide' }, // G — ♭7, top voice slides up the B string
|
||||||
|
{ string: 3, fret: 9, technique: 'double-stop' }, // E — the 5: the dominant 5+♭7 pair lands
|
||||||
|
{ string: 2, fret: 10, technique: 'vibrato' }, // A — root on top: resolve
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In A: the classic pedal-steel move — bend the 2 (B) a whole step up to
|
||||||
|
// the 3 (C♯) on the G string and HOLD it while the 5th and then the root
|
||||||
|
// ring on top: the full triad stacks up, steel-style (an oblique bend).
|
||||||
|
id: 'country-pedal-steel-bend',
|
||||||
|
name: 'Pedal-steel bend into the 3rd',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'over the I — the money chord change',
|
||||||
|
techniques: ['bend', 'double-stop', 'vibrato'],
|
||||||
|
source: 'the oblique 2→3 steel bend — Premier Guitar "Twang 101: 6 Essential Pedal-Steel Bends"; Fundamental Changes "Country Guitar String Bending"',
|
||||||
|
tab: [
|
||||||
|
{ string: 3, fret: 2 }, // A — root pickup
|
||||||
|
{ string: 3, fret: 4, technique: 'bend' }, // B — the 2, bent a whole step up to C♯ and HELD
|
||||||
|
{ string: 2, fret: 5, technique: 'double-stop' }, // E — the 5th rings on top of the held bend
|
||||||
|
{ string: 1, fret: 5, technique: 'vibrato' }, // A — root struck next, bend still holding: the triad stack
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In G, open position: the banjo forward roll (thumb–index–middle)
|
||||||
|
// crosspicked across strings 3-2-1 — root, 3rd, high root cascading in
|
||||||
|
// even 8ths. No left-hand tricks: the ringing roll IS the technique.
|
||||||
|
id: 'country-forward-roll',
|
||||||
|
name: 'Forward-roll crosspicking',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'over the I — keeps time like a banjo under a vocal',
|
||||||
|
techniques: [],
|
||||||
|
source: 'the Scruggs forward roll (T–I–M) adapted to guitar crosspicking — Wikipedia "Banjo roll"; Deering "The Four Essential 5-String Banjo Rolls"',
|
||||||
|
tab: [
|
||||||
|
{ string: 3, fret: 0 }, // G — root (thumb)
|
||||||
|
{ string: 2, fret: 0 }, // B — the 3 (index)
|
||||||
|
{ string: 1, fret: 3 }, // G — octave root (middle)
|
||||||
|
{ string: 3, fret: 0 }, // roll it again —
|
||||||
|
{ string: 2, fret: 0 }, // even 8ths,
|
||||||
|
{ string: 1, fret: 3 }, // every note ringing into the next
|
||||||
|
{ string: 3, fret: 0 }, // G
|
||||||
|
{ string: 2, fret: 0 }, // B — hand back at the top of the roll
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
id: 'country',
|
||||||
|
label: 'Country / Folk',
|
||||||
|
feel: 'boom-chick',
|
||||||
|
tempoRange: [80, 160],
|
||||||
|
character: 'Open strings, alternating bass, and the capo as a transposition machine: the guitar is the band\'s kick drum and snare, and every chord change gets announced by a bass run.',
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export default [
|
||||||
|
{
|
||||||
|
id: 'country-145',
|
||||||
|
name: 'Country I–IV–V (8-bar form)',
|
||||||
|
rn: ['I', 'I', 'IV', 'I', 'I', 'I', 'V7', 'I'],
|
||||||
|
degrees: [0, 0, 5, 0, 0, 0, 7, 0],
|
||||||
|
qualities: ['maj', 'maj', 'maj', 'maj', 'maj', 'maj', 'dom7', 'maj'],
|
||||||
|
bars: [1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Will the Circle Be Unbroken', 'Ring of Fire — Johnny Cash'],
|
||||||
|
tip: 'The placement is the convention: IV arrives mid-phrase, V7 on the last line pulling home. Learn where the changes BREATHE, not just what they are.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'country-folk-axis',
|
||||||
|
name: 'Folk axis (Wagon Wheel loop)',
|
||||||
|
rn: ['I', 'V', 'vi', 'IV', 'I', 'V', 'IV', 'IV'],
|
||||||
|
degrees: [0, 7, 9, 5, 0, 7, 5, 5],
|
||||||
|
qualities: ['maj', 'maj', 'min', 'maj', 'maj', 'maj', 'maj', 'maj'],
|
||||||
|
bars: [1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Wagon Wheel — Old Crow Medicine Show (G shapes, capo 2)'],
|
||||||
|
tip: 'The pop axis loop in folk clothes — note the second half lands on IV–IV instead of vi–IV. Capo 2 with G shapes is the canonical version.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'country-ragtime',
|
||||||
|
name: 'Ragtime circle (I–VI7–II7–V7)',
|
||||||
|
rn: ['I', 'VI7', 'II7', 'V7'],
|
||||||
|
degrees: [0, 9, 2, 7],
|
||||||
|
qualities: ['maj', 'dom7', 'dom7', 'dom7'],
|
||||||
|
bars: [2, 2, 2, 2],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Salty Dog Blues — Flatt & Scruggs', "Alice's Restaurant — Arlo Guthrie", 'Tennessee Waltz (bridge)'],
|
||||||
|
tip: 'Every chord is the dominant of the next — a circle-of-fifths sled ride home. The truncated I–II7–V–I ("Hey, Good Lookin\'") is the same device minus the VI7.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'country-rising-sun',
|
||||||
|
name: 'Rising Sun (6/8 minor climb)',
|
||||||
|
rn: ['i', 'III', 'IV', 'VI', 'i', 'V'],
|
||||||
|
degrees: [0, 3, 5, 8, 0, 7],
|
||||||
|
qualities: ['min', 'maj', 'maj', 'maj', 'min', 'maj'],
|
||||||
|
bars: [1, 1, 1, 1, 1, 1],
|
||||||
|
mode: 'minor',
|
||||||
|
songs: ['House of the Rising Sun — The Animals'],
|
||||||
|
tip: 'Not the Andalusian descent people assume: it CLIMBS, and the major IV is borrowed Dorian colour. 6/8 time, arpeggiated, every chord one sweep of the arm.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'country-bluegrass-cycle',
|
||||||
|
name: 'Bluegrass cycle (I–IV–I–V)',
|
||||||
|
rn: ['I', 'IV', 'I', 'V'],
|
||||||
|
degrees: [0, 5, 0, 7],
|
||||||
|
qualities: ['maj', 'maj', 'maj', 'maj'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Nine Pound Hammer — Merle Travis'],
|
||||||
|
tip: 'The jam-circle workhorse: back to I between every excursion. Kick it off with the last line of the chorus, end every phrase with a G-run.',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
// Funk guitar pack. Shapes verified by note-spelling against: justinguitar.com
|
||||||
|
// (E9 "the funk chord"), fundamental-changes.com (funk chords, JB E9-D9 accents),
|
||||||
|
// yourguitaracademy.com (Sex Machine pattern), musicradar.com (Nile Rodgers grips),
|
||||||
|
// ethanhein.com (Chameleon Dorian analysis), Wikipedia (Jimmy Nolen chicken scratch).
|
||||||
|
|
||||||
|
// 5th-string-root colour chords — the Nolen vocabulary.
|
||||||
|
const NINE = { rootStr: 5, offsets: ['x', 0, -1, 0, 0, 0], fingers: [0, 2, 1, 3, 3, 3] } // R-3-♭7-9-5: THE funk chord
|
||||||
|
const THIRTEEN = { rootStr: 5, offsets: ['x', 0, -1, 0, 0, 2], fingers: [0, 2, 1, 3, 3, 4] } // 9 grip, pinky takes 5→13
|
||||||
|
const NINESUS = { rootStr: 5, offsets: ['x', 0, 0, 0, 0, 0], fingers: [0, 1, 1, 1, 1, 1] } // full barre: R-4-♭7-9-5
|
||||||
|
const HENDRIX = { rootStr: 5, offsets: ['x', 0, -1, 0, 1, 'x'], fingers: [0, 2, 1, 3, 4, 0] } // 7♯9 — ♭3 grit over a dominant
|
||||||
|
const M9 = { rootStr: 5, offsets: ['x', 0, -2, 0, 0, 'x'], fingers: [0, 2, 1, 3, 4, 0] } // R-♭3-♭7-9
|
||||||
|
const MAJ7_5 = { rootStr: 5, offsets: ['x', 0, 2, 1, 2, 'x'], fingers: [0, 1, 3, 2, 4, 0] } // R-5-7-3
|
||||||
|
|
||||||
|
// 6th-string-root grips.
|
||||||
|
const M7_6 = { rootStr: 6, offsets: [0, 'x', 0, 0, 0, 'x'], fingers: [1, 0, 2, 3, 4, 0] } // R-♭7-♭3-5 (the Le Freak Am7)
|
||||||
|
const M11_BARRE = { rootStr: 6, offsets: [0, 0, 0, 0, 0, 0], fingers: [1, 1, 1, 1, 1, 1] } // one-finger m11 (D'Angelo)
|
||||||
|
const THIRTEEN_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, 2, 'x'], fingers: [1, 0, 2, 3, 4, 0] } // R-♭7-3-13
|
||||||
|
|
||||||
|
// Top-4 fragments — above the bass, out of the keys' mid-range.
|
||||||
|
const M7_TOP4 = { rootStr: 1, offsets: ['x', 'x', 0, 0, 0, 0], fingers: [0, 0, 1, 1, 1, 1] } // ♭7-♭3-5-R barre
|
||||||
|
const MAJ7_TOP4 = { rootStr: 1, offsets: ['x', 'x', 1, 1, 0, 0], fingers: [0, 0, 2, 3, 1, 1] } // 7-3-5-R
|
||||||
|
|
||||||
|
export default {
|
||||||
|
styleIntro:
|
||||||
|
'The funk guitarist is a percussionist who happens to know chords: the strumming arm plays constant sixteenths like a hi-hat, and the fret hand decides which of them speak. Play the gaps the bass leaves, keep voicings small and high, and serve The One.',
|
||||||
|
|
||||||
|
comping: [
|
||||||
|
{
|
||||||
|
label: '16th-note scratch foundation',
|
||||||
|
rhythm: '1e&a 2e&a 3e&a 4e&a — DUDU, never stops',
|
||||||
|
description: 'Mute everything with the fret hand and strum constant sixteenths — pure percussion first. Voiced hits are added by pressing the chord only on chosen slots, releasing pressure immediately after (the choke).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Chicken scratch ("chika")',
|
||||||
|
rhythm: 'muted down-up 16th pairs',
|
||||||
|
description: 'Jimmy Nolen\'s signature: strings pressed just enough for a pitchless scratch, strummed near the bridge. The texture between the hits IS the part.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'JB one-bar cell',
|
||||||
|
rhythm: 'C x x x x x x x x x C x x x x x — hits on 1 and the &-of-3',
|
||||||
|
description: 'Chord stab on the One (always the One), a second on the and-of-3, ghosts everywhere else. The school pattern behind a hundred James Brown grooves.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sex Machine pattern',
|
||||||
|
rhythm: '9 . . . 9 . . . 13 . . . . . . 9',
|
||||||
|
description: 'I9 on beats 1 and 2, I13 on beat 3, an upstroke 9 at the bar\'s tail — the documented Eb9/Eb13 figure. The top note rocking 5↔13 is the hook.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Nile Rodgers selective 16ths',
|
||||||
|
rhythm: 'arm = metronome; the pick chooses string groups',
|
||||||
|
description: 'Down-up sixteenths never stop, whether or not strings are struck. Accents come from catching the bottom of the grip vs the top-3 fragment, plus fret-hand chucks. Built on what he doesn\'t play.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plays: {
|
||||||
|
'funk-one-chord': [
|
||||||
|
{
|
||||||
|
label: 'The Nolen cycle (9 → 13 → 9sus4)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: NINE, extensions: ['9'], note: 'I9 — the funk chord' },
|
||||||
|
{ shape: THIRTEEN, extensions: ['9', '13'], note: 'pinky stretches 5→13' },
|
||||||
|
{ shape: NINESUS, extensions: ['b7', '9'], note: 'one-finger barre — the lift' },
|
||||||
|
{ shape: NINE, extensions: ['9'], note: 'and home' },
|
||||||
|
],
|
||||||
|
tips: 'Three grips, one fret position, zero chord changes — the whole arrangement is the top two strings breathing. Choke every hit; the scratch between them never stops.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Grit set (7♯9 stabs)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: HENDRIX, extensions: ['#9'], note: 'I7♯9 — major and minor third at once' },
|
||||||
|
{ shape: THIRTEEN, extensions: ['9', '13'], note: '' },
|
||||||
|
{ shape: NINESUS, extensions: ['b7', '9'], note: '' },
|
||||||
|
{ shape: NINE, extensions: ['9'], note: '' },
|
||||||
|
],
|
||||||
|
tips: 'The ♯9 is the blues clash built into one grip — use it for the stabs you want to hurt, the clean 9 for the ones that groove. Sparser than the Nolen cycle: half the hits, twice the silence.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'funk-dorian-vamp': [
|
||||||
|
{
|
||||||
|
label: 'Chameleon pair (m9 + 9)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: M9, extensions: ['9'], note: 'i9' },
|
||||||
|
{ shape: NINE, extensions: ['9'], note: 'IV9 — same fret region, one string set' },
|
||||||
|
],
|
||||||
|
tips: 'Both grips share the A-string root region — the change is two fingers, not a position. Sixteenth scratch throughout; voice the chords only on the accents the bass leaves open.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'D\'Angelo barre (m11 wash)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: M11_BARRE, extensions: ['11'], note: 'i11 — one finger, all six strings' },
|
||||||
|
{ shape: THIRTEEN_6, extensions: ['13'], note: 'IV13' },
|
||||||
|
],
|
||||||
|
tips: 'The one-finger m11 is the deepest chord in funk for the least effort — lay it across and let the fret hand bounce for the rhythm. Keep it short; six strings of m11 sustained is soup.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'funk-25-loop': [
|
||||||
|
{
|
||||||
|
label: 'Le Freak grips',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: M7_6, note: 'ii7 — 6th-string root' },
|
||||||
|
{ shape: NINE, extensions: ['9'], note: 'V9 — 5th-string root, same position' },
|
||||||
|
],
|
||||||
|
tips: 'The Chic move: full grip held, pick selecting string groups in constant 16ths. Freak out on the mutes, not the volume.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Smooth set (m9 + 13)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: M9, extensions: ['9'], note: 'ii9' },
|
||||||
|
{ shape: THIRTEEN, extensions: ['9', '13'], note: 'V13' },
|
||||||
|
],
|
||||||
|
tips: 'The Stevie Wonder colour: 9ths on both sides of the loop. Push the V13 an eighth early every second bar and the loop starts to roll forward.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'funk-bvii-move': [
|
||||||
|
{
|
||||||
|
label: 'One grip, whole-step slide',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: NINE, extensions: ['9'], note: 'I9' },
|
||||||
|
{ shape: NINE, extensions: ['9'], note: '♭VII9 — two frets down, same grip' },
|
||||||
|
],
|
||||||
|
tips: 'The E9→D9 figure: the move is the slide itself — keep light finger pressure during the shift so the landing speaks. Snap back up to the I ON the One.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Grit on the I, clean below',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: HENDRIX, extensions: ['#9'], note: 'I7♯9' },
|
||||||
|
{ shape: NINE, extensions: ['9'], note: '♭VII9' },
|
||||||
|
],
|
||||||
|
tips: 'Contrast as arrangement: the ♯9 bites on home, the plain 9 relaxes a whole step down. Save this pairing for the bridge or the last vamp out.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'funk-smooth-loop': [
|
||||||
|
{
|
||||||
|
label: 'A-string roots (September set)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: MAJ7_5, note: 'IVmaj7' },
|
||||||
|
{ shape: M9, extensions: ['9'], note: 'iii9' },
|
||||||
|
{ shape: M9, extensions: ['9'], note: 'ii9 — whole grip down two frets' },
|
||||||
|
{ shape: M9, extensions: ['9'], note: 'back up' },
|
||||||
|
],
|
||||||
|
tips: 'The loop is one m9 grip walking between iii and ii under a stationary maj7 anchor. Clean tone, light palm mute, hits shorter than you think.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Top-4 shimmer (with keys/horns)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: MAJ7_TOP4, note: 'IVmaj7 — top four strings only' },
|
||||||
|
{ shape: M7_TOP4, note: 'iii7 — one-finger barre' },
|
||||||
|
{ shape: M7_TOP4, note: 'ii7' },
|
||||||
|
{ shape: M7_TOP4, note: '' },
|
||||||
|
],
|
||||||
|
tips: 'When keys and horns are present this register is yours and nothing below it. The barre fragments slide as one shape — think of it as playing the top of the arrangement, not chords.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
improv: {
|
||||||
|
scales: [
|
||||||
|
{ over: 'i7–IV7 vamps', scale: 'dorian', why: 'The whole vamp is one Dorian scale — Chameleon\'s entire harmony fits inside it. The natural 6 is the funk note.' },
|
||||||
|
{ over: 'I9 one-chord vamps', scale: 'mixolydian', why: 'Mixolydian plus the minor-pentatonic/blues blend — the 7♯9 chord literally spells that major/minor mix.' },
|
||||||
|
{ over: 'ii7–V9 loops', scale: 'dorian', why: 'Dorian on the ii covers both chords; it\'s the same dyad as the minor vamp heard from the ii.' },
|
||||||
|
{ over: 'maj7 loops', scale: 'major', why: 'Diatonic major/pentatonic — the EWF horn-line sweetness. Target 9ths and 6ths, not roots.' },
|
||||||
|
],
|
||||||
|
targetNotes:
|
||||||
|
'Funk solos are mostly chord fragments: the 3+♭7 tritone pair and the ♭7+9 pair of the 9 grip, slid in from a half-step below. Over static harmony, develop RHYTHM — state a short motif, displace it inside the 16th grid, add and remove ghosts. The harmony will not save you; the pocket will.',
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
over: 'funk-dorian-vamp',
|
||||||
|
description: 'Cissy Strut main line (The Meters, 1969): a descending Cm7 arpeggio answered by double-stop stabs — upper-structure fragments of the i11.',
|
||||||
|
tab: 'e|------------------|---------------6-6--5-5--\nB|------------------|---------------6-6--6-6--\nG|--5--3--0---------|---------------7-7--5-5--\nD|-----------1b-----|-------------------------\nA|--------------3---|--1--3--1--3-------------\n C Bb G Eb C Bb C Bb C (stab pairs)',
|
||||||
|
source: 'The Meters, "Cissy Strut" (1969); spytunes.com & pianote.com analyses (Cm7 arpeggio construction)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Structured licks (SCHEMA.md "Licks", task P-21). Written in the style's home
|
||||||
|
// positions (E for the dominant vamps, D dorian for the minor vamps) but
|
||||||
|
// key-agnostic in spirit — chordContext names the station.
|
||||||
|
// Every pitch hand-verified: s6=E s5=A s4=D s3=G s2=B s1=e (+fret, mod 12).
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
// In E: root, ghost, ♭3 hammered to the 3, 5, ♭7, octave root — the
|
||||||
|
// dominant arpeggio with the blues grit built in, played staccato.
|
||||||
|
id: 'funk-nine-riff',
|
||||||
|
name: 'Single-note dominant riff',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'over the I9 one-chord vamp',
|
||||||
|
techniques: ['ghost-note', 'hammer-on'],
|
||||||
|
source: 'in the style of the James Brown-band single-note riffs ("Sex Machine" lineage); Fundamental Changes funk guitar lessons',
|
||||||
|
tab: [
|
||||||
|
{ string: 6, fret: 0 }, // E — root, on the One
|
||||||
|
{ string: 6, fret: 0, technique: 'ghost-note' }, // scratch — the 16th grid never stops
|
||||||
|
{ string: 6, fret: 3 }, // G — ♭3 grit
|
||||||
|
{ string: 6, fret: 4, technique: 'hammer-on' }, // G♯ — the 3: the funk curl
|
||||||
|
{ string: 5, fret: 2 }, // B — 5
|
||||||
|
{ string: 5, fret: 5 }, // D — ♭7
|
||||||
|
{ string: 5, fret: 7 }, // E — octave root; shift up to grab it
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In E (♭VII = D): chromatic climb D–D♯–E back to the One, answered by the
|
||||||
|
// 3+♭7 tritone stab straight out of the E9 grip (G♯+D).
|
||||||
|
id: 'funk-chromatic-snapback',
|
||||||
|
name: 'Chromatic snap-back to the One',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: '♭VII9 → I9, landing on the One',
|
||||||
|
techniques: ['chromatic-approach', 'double-stop'],
|
||||||
|
source: 'the E9→D9 vamp move ("Papa\'s Got a Brand New Bag" lineage); Fundamental Changes JB-style accent lessons',
|
||||||
|
tab: [
|
||||||
|
{ string: 5, fret: 5 }, // D — the ♭VII's root
|
||||||
|
{ string: 5, fret: 6, technique: 'chromatic-approach' }, // D♯ — passing
|
||||||
|
{ string: 5, fret: 7 }, // E — the One
|
||||||
|
{ string: 4, fret: 6 }, // G♯ — the 3, from the 9-grip
|
||||||
|
{ string: 3, fret: 7, technique: 'double-stop' }, // D — the ♭7: tritone stab together
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In D dorian: the ♭3+♭7 dyad (F+C) slid in from one fret below, answered
|
||||||
|
// by the root+11 dyad (D+G) — the m11 wash as two-note stabs — and a ghost.
|
||||||
|
id: 'funk-dorian-stabs',
|
||||||
|
name: 'Dorian double-stop stabs',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'over the i7/i9 Dorian vamp',
|
||||||
|
techniques: ['double-stop', 'slide', 'ghost-note'],
|
||||||
|
source: 'in the style of the Meters\' dyad stabs ("Cissy Strut" lineage) and the Jimmy Nolen scratch vocabulary',
|
||||||
|
tab: [
|
||||||
|
{ string: 2, fret: 5 }, // E — one fret below F
|
||||||
|
{ string: 3, fret: 4, technique: 'double-stop' }, // B — one fret below C (approach dyad)
|
||||||
|
{ string: 3, fret: 5, technique: 'slide' }, // C — the ♭7
|
||||||
|
{ string: 2, fret: 6, technique: 'double-stop' }, // F — the ♭3: the m7 pair lands
|
||||||
|
{ string: 3, fret: 7 }, // D — root
|
||||||
|
{ string: 2, fret: 8, technique: 'double-stop' }, // G — the 11 on top: the m11 colour
|
||||||
|
{ string: 3, fret: 7, technique: 'ghost-note' }, // scratch — keep the grid breathing
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In D dorian: 1 ♭3 4 5 →6 5 ♭7 1 — a single-note vamp line that leans on
|
||||||
|
// the natural 6 (B over D), the note that makes Dorian sound like funk.
|
||||||
|
id: 'funk-dorian-six-line',
|
||||||
|
name: 'Dorian line leaning on the 6',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'over the i7–IV7 Dorian vamp',
|
||||||
|
techniques: ['slide', 'vibrato'],
|
||||||
|
source: 'the Dorian vamp language of "Chameleon" (Herbie Hancock) — see Ethan Hein\'s Chameleon analysis; a line in that style, not a transcription',
|
||||||
|
tab: [
|
||||||
|
{ string: 5, fret: 5 }, // D — 1
|
||||||
|
{ string: 5, fret: 8 }, // F — ♭3
|
||||||
|
{ string: 4, fret: 5 }, // G — 4
|
||||||
|
{ string: 4, fret: 7 }, // A — 5
|
||||||
|
{ string: 4, fret: 9, technique: 'slide' }, // B — the natural 6, slid into
|
||||||
|
{ string: 4, fret: 7 }, // A — 5
|
||||||
|
{ string: 3, fret: 5 }, // C — ♭7
|
||||||
|
{ string: 3, fret: 7, technique: 'vibrato' }, // D — octave home
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
id: 'funk',
|
||||||
|
label: 'Funk',
|
||||||
|
feel: '16th',
|
||||||
|
tempoRange: [90, 120],
|
||||||
|
character: 'One or two chords, sixteen subdivisions: harmony freezes so rhythm can talk. The guitar is a drum with pitch — most strokes are muted, and colour lives in the chord quality (9, 13, ♯9), not the changes.',
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export default [
|
||||||
|
{
|
||||||
|
id: 'funk-one-chord',
|
||||||
|
name: 'One-chord I9 vamp (James Brown)',
|
||||||
|
rn: ['I9', 'I13', 'I9sus4', 'I9'],
|
||||||
|
degrees: [0, 0, 0, 0],
|
||||||
|
qualities: ['dom7', 'dom7', 'sus4', 'dom7'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'mixolydian',
|
||||||
|
songs: ['Get Up (Sex Machine) — James Brown', 'Cold Sweat — James Brown', "Papa's Got a Brand New Bag"],
|
||||||
|
tip: 'The harmony never moves — the chord QUALITY does: 9 → 13 → 9sus4 → 9 is the whole arrangement. Funk colour lives in the voicing, not the progression.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'funk-dorian-vamp',
|
||||||
|
name: 'Dorian two-chord vamp (i7–IV7)',
|
||||||
|
rn: ['i9', 'IV9'],
|
||||||
|
degrees: [0, 5],
|
||||||
|
qualities: ['min7', 'dom7'],
|
||||||
|
bars: [1, 1],
|
||||||
|
mode: 'dorian',
|
||||||
|
songs: ['Chameleon — Herbie Hancock', 'Use Me — Bill Withers', 'Cissy Strut — The Meters (i7 side)'],
|
||||||
|
tip: 'THE funk pair: both chords live inside one Dorian scale, so soloists never have to switch. The major IV is what makes it Dorian, not sad.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'funk-25-loop',
|
||||||
|
name: 'Disco loop (ii7–V9, never resolves)',
|
||||||
|
rn: ['ii7', 'V9'],
|
||||||
|
degrees: [2, 7],
|
||||||
|
qualities: ['min7', 'dom7'],
|
||||||
|
bars: [1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Le Freak — Chic', 'Good Times — Chic', 'I Wish — Stevie Wonder'],
|
||||||
|
tip: 'A ii–V that never finds its I — the resolution is the dance floor. Same two-chord dyad as the Dorian vamp, heard from the other side.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'funk-bvii-move',
|
||||||
|
name: 'I9–♭VII9 figure',
|
||||||
|
rn: ['I9', '♭VII9'],
|
||||||
|
degrees: [0, 10],
|
||||||
|
qualities: ['dom7', 'dom7'],
|
||||||
|
bars: [1, 1],
|
||||||
|
mode: 'mixolydian',
|
||||||
|
songs: ['the James Brown E9→D9 figure', 'countless JB-school vamps'],
|
||||||
|
tip: 'One grip sliding down a whole step and back. The ♭VII is borrowed Mixolydian gravity — it falls back to the I on its own.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'funk-smooth-loop',
|
||||||
|
name: 'Smooth maj7 loop (EWF)',
|
||||||
|
rn: ['IVmaj7', 'iii7', 'ii7', 'iii7'],
|
||||||
|
degrees: [5, 4, 2, 4],
|
||||||
|
qualities: ['maj7', 'min7', 'min7', 'min7'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['September — Earth, Wind & Fire'],
|
||||||
|
tip: 'Starts on the IVmaj7 and orbits the iii — home is implied, never stated. (Every third pass, September turns the iii into V/vi for the lift.)',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
// Gospel guitar pack. Every shape's pitch classes were verified by note-spelling
|
||||||
|
// against standard tuning (EADGBe) and the chord quality before authoring.
|
||||||
|
// Voicing/comping references: hearandplay.com (gospel guitar comping, passing
|
||||||
|
// chords), pianogroove.com (sus/add9 colour, hymn reharmonization),
|
||||||
|
// jenslarsen.nl & jazzguitar.be (shell voicings, voice leading).
|
||||||
|
|
||||||
|
// --- Shell voicings (root + 3rd + 7th, fifth omitted) — the gospel-comp backbone ---
|
||||||
|
const SHELL_6 = { // root on the low-E string
|
||||||
|
maj7: { rootStr: 6, offsets: [0, 'x', 1, 1, 'x', 'x'], fingers: [1, 0, 3, 4, 0, 0] }, // R–7–3
|
||||||
|
dom7: { rootStr: 6, offsets: [0, 'x', 0, 1, 'x', 'x'], fingers: [1, 0, 2, 3, 0, 0] }, // R–♭7–3
|
||||||
|
min7: { rootStr: 6, offsets: [0, 'x', 0, 0, 'x', 'x'], fingers: [1, 0, 2, 3, 0, 0] }, // R–♭7–♭3
|
||||||
|
}
|
||||||
|
const SHELL_5 = { // root on the A string
|
||||||
|
maj7: { rootStr: 5, offsets: ['x', 0, -1, 1, 'x', 'x'], fingers: [0, 2, 1, 4, 0, 0] }, // R–3–7
|
||||||
|
dom7: { rootStr: 5, offsets: ['x', 0, -1, 0, 'x', 'x'], fingers: [0, 2, 1, 3, 0, 0] }, // R–3–♭7
|
||||||
|
min7: { rootStr: 5, offsets: ['x', 0, -2, 0, 'x', 'x'], fingers: [0, 3, 1, 4, 0, 0] }, // R–♭3–♭7
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 9th / colour voicings (the gospel "shine") ---
|
||||||
|
const MIN9_5 = { rootStr: 5, offsets: ['x', 0, -2, 0, 0, 0], fingers: [0, 2, 1, 3, 3, 3] } // R–♭3–♭7–9–5
|
||||||
|
const MAJ9_6 = { rootStr: 6, offsets: [0, -1, 1, -1, 'x', 'x'], fingers: [2, 1, 4, 1, 0, 0] } // R–3–7–9
|
||||||
|
const DOM9_6 = { rootStr: 6, offsets: [0, -1, 0, -1, 'x', 'x'], fingers: [3, 1, 2, 1, 0, 0] } // R–3–♭7–9
|
||||||
|
const DOM9_5 = { rootStr: 5, offsets: ['x', 0, -1, 0, 0, 'x'], fingers: [0, 2, 1, 3, 4, 0] } // R–3–♭7–9
|
||||||
|
|
||||||
|
// --- add9 / sus colour for the modern praise vamp ---
|
||||||
|
const ADD9_5 = { rootStr: 5, offsets: ['x', 0, -1, 2, 0, 'x'], fingers: [0, 2, 1, 4, 3, 0] } // R–3–(root)–9
|
||||||
|
const ADD9_6 = { rootStr: 6, offsets: [0, -1, 'x', -1, 0, 'x'], fingers: [2, 1, 0, 1, 3, 0] } // R–3–9–5
|
||||||
|
const SUS4_5 = { rootStr: 5, offsets: ['x', 0, 'x', 'x', 3, 0], fingers: [0, 1, 0, 0, 4, 1] } // R–4–5
|
||||||
|
const SUS4_6 = { rootStr: 6, offsets: [0, 0, 'x', 'x', 0, 'x'], fingers: [1, 2, 0, 0, 3, 0] } // R–4–5
|
||||||
|
|
||||||
|
// --- plain triads (the plagal "Amen" wants no extensions) ---
|
||||||
|
const MAJ_6 = { rootStr: 6, offsets: [0, 2, 2, 1, 'x', 'x'], fingers: [1, 3, 4, 2, 0, 0] } // R–5–R–3 (E-shape)
|
||||||
|
const MAJ_5 = { rootStr: 5, offsets: ['x', 0, 2, 2, 2, 'x'], fingers: [0, 1, 2, 3, 4, 0] } // R–5–R–3 (A-shape)
|
||||||
|
|
||||||
|
// --- the passing diminished 7th (#iv°7 / ♯i°7 — symmetric, one grip fits both) ---
|
||||||
|
const DIM7_5 = { rootStr: 5, offsets: ['x', 0, 1, -1, 1, 'x'], fingers: [0, 2, 3, 1, 4, 0] } // R–♭5–𝄫7–♭3 (symmetric)
|
||||||
|
|
||||||
|
// --- the borrowed iv (minor 6th on the middle strings, for the tonicized Amen) ---
|
||||||
|
const MIN6_4 = { rootStr: 4, offsets: ['x', 'x', 0, -2, 0, -2], fingers: [0, 0, 3, 1, 4, 2] } // R–♭3–6–R
|
||||||
|
|
||||||
|
export default {
|
||||||
|
styleIntro:
|
||||||
|
'Gospel guitar lives between the organ and the choir: short, voice-led grips that comp the changes without crowding the keys. The signature sounds are guide-tone shells for the moving 2–5–1 chains, 9th and add9 colour on the resting chords, sus tension in the modern praise vamp, and a single passing diminished that makes a plain hymn sound like church. Keep the fifth and often the root to the bass — your job is the 3rds, 7ths, and the colour note.',
|
||||||
|
|
||||||
|
comping: [
|
||||||
|
{
|
||||||
|
label: 'Triplet shuffle (12/8 church feel)',
|
||||||
|
rhythm: '♪♪♪ ♪♪♪ (compound, lilting)',
|
||||||
|
description: 'The default slow-gospel groove: a swung 12/8 with the chord landing on beat 1 and a soft stab on the last triplet partial of each beat. Let the long notes ring under the choir; chop the pickups.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Anticipated push into the change',
|
||||||
|
rhythm: 'tied from the "and" before the bar',
|
||||||
|
description: 'Strike the next chord an eighth (or final triplet) early and tie it over the barline — telegraphs the move to the band, the same anticipation gospel organists use to lead the turnaround.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sustained pads (praise-vamp register)',
|
||||||
|
rhythm: '𝅗𝅥 𝅗𝅥 (held, no chop)',
|
||||||
|
description: 'For the sus/add9 vamp: let each grip ring its full bar high on the neck, no percussive damping — a guitar "pad" under the worship leader. Move only the changing voices between chords.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plays: {
|
||||||
|
'gospel-cycle-251': [
|
||||||
|
{
|
||||||
|
label: 'Shell chain, guide-tone glue',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL_5.min7, note: 'iii7 — R–♭3–♭7' },
|
||||||
|
{ shape: SHELL_6.dom7, note: 'VI7 — the ♭3 of iii falls a half-step to the 3rd of VI7' },
|
||||||
|
{ shape: SHELL_5.min7, note: 'ii7 — back to the minor grip' },
|
||||||
|
{ shape: SHELL_6.dom7, note: 'V7 — ♭7 leads down to the maj-3rd of I' },
|
||||||
|
{ shape: SHELL_6.maj7, note: 'Imaj7 — home' },
|
||||||
|
],
|
||||||
|
tips: 'Roots alternate 5th-string → 6th-string at nearly the same fret; the two guide tones move 0–1 fret per change. Follow the D and G strings — that thread is the whole cascade.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '9th-coloured cycle (upper register)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: MIN9_5, extensions: ['9'], note: 'iii9 — R–♭3–♭7–9–5' },
|
||||||
|
{ shape: DOM9_6, extensions: ['9'], note: 'VI9 — R–3–♭7–9' },
|
||||||
|
{ shape: MIN9_5, extensions: ['9'], note: 'ii9 — same grip slid down' },
|
||||||
|
{ shape: DOM9_6, extensions: ['9'], note: 'V9 — the dominant shine' },
|
||||||
|
{ shape: MAJ9_6, extensions: ['9'], note: 'Imaj9 — resolve with the 9 on top' },
|
||||||
|
],
|
||||||
|
tips: 'Same harmony as the shells but each chord gains its 9th for the gospel "shimmer". The two dominant grips are identical shapes a 5th apart — learn one and transpose.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'gospel-amen-625': [
|
||||||
|
{
|
||||||
|
label: 'Shells through the turnaround + plain Amen',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL_6.min7, note: 'vi7' },
|
||||||
|
{ shape: SHELL_5.min7, note: 'ii7' },
|
||||||
|
{ shape: SHELL_6.dom7, note: 'V7' },
|
||||||
|
{ shape: SHELL_5.maj7, note: 'Imaj7' },
|
||||||
|
{ shape: MAJ_6, note: 'IV — plain triad: the plagal "Amen" wants no extensions' },
|
||||||
|
{ shape: SHELL_6.maj7, note: 'Imaj7 — final resolution' },
|
||||||
|
],
|
||||||
|
tips: 'Comp the 6–2–5–1 with the small shells, then drop to bare IV–I triads for the Amen so the cadence lands clean and hymn-like. The contrast between busy turnaround and plain plagal tag is the whole effect.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '9th turnaround, triad Amen high',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: MIN9_5, extensions: ['9'], note: 'vi9' },
|
||||||
|
{ shape: MIN9_5, extensions: ['9'], note: 'ii9 — same grip down the neck' },
|
||||||
|
{ shape: DOM9_5, extensions: ['9'], note: 'V9' },
|
||||||
|
{ shape: MAJ9_6, extensions: ['9'], note: 'Imaj9' },
|
||||||
|
{ shape: MAJ_5, note: 'IV — A-shape triad for the Amen' },
|
||||||
|
{ shape: MAJ9_6, extensions: ['9'], note: 'Imaj9 — back home with colour' },
|
||||||
|
],
|
||||||
|
tips: 'A richer reading: 9ths through the turnaround, then a clean triad IV for the plagal cadence before the maj9 resolves it. Keep the Amen quieter than the turnaround — dynamics sell the cadence.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'gospel-iv-passing-dim': [
|
||||||
|
{
|
||||||
|
label: 'Open-string add9 with the passing diminished',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: ADD9_6, note: 'Iadd9 — R–3–5–9, low register' },
|
||||||
|
{ shape: MAJ_5, note: 'IV — plain A-shape triad' },
|
||||||
|
{ shape: DIM7_5, note: '#iv°7 — chromatic passing chord, bass walks 4 → #4' },
|
||||||
|
{ shape: ADD9_6, note: 'Iadd9 — resolve up to I as the bass reaches 5/1' },
|
||||||
|
],
|
||||||
|
tips: 'The whole move is one bass walk: 4 → #4 → home. Hold the upper voices steady and let the diminished slide through underneath — that single borrowed chord is the gospel signature.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Compact add9 voicing up the neck',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: ADD9_5, note: 'Iadd9 — R–3–(root)–9 on the A-string root' },
|
||||||
|
{ shape: MAJ_6, note: 'IV — E-shape triad' },
|
||||||
|
{ shape: DIM7_5, note: '#iv°7 — same diminished grip, one shape' },
|
||||||
|
{ shape: ADD9_5, note: 'Iadd9 — back home' },
|
||||||
|
],
|
||||||
|
tips: 'A higher, tighter version of the same idea for when the bass and keys own the low end. The °7 is symmetric — the identical grip works from any of its four notes, so you can place it nearest the chords around it.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'gospel-sus-vamp': [
|
||||||
|
{
|
||||||
|
label: 'Praise-vamp pads (sustained, high)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: MIN9_5, extensions: ['9'], note: 'vi9 — full ringing grip' },
|
||||||
|
{ shape: ADD9_5, note: 'IVadd9 — R–3–9 colour, no chop' },
|
||||||
|
{ shape: SUS4_6, note: 'Vsus4 — R–4–5; the 4th hangs, never resolving to the 3rd' },
|
||||||
|
{ shape: ADD9_6, note: 'Iadd9 — R–3–5–9 to land the loop' },
|
||||||
|
],
|
||||||
|
tips: 'Let every chord ring its full bar like a synth pad — no percussive damping. The unresolved sus over V is what keeps the vamp lifting; loop it and the tension never closes.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Two-voice movement vamp (different register)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL_6.min7, note: 'vi7 — leaner shell to contrast the pads' },
|
||||||
|
{ shape: ADD9_6, note: 'IVadd9 — R–3–5–9 low' },
|
||||||
|
{ shape: SUS4_5, note: 'Vsus4 — A-string root, R–4–5' },
|
||||||
|
{ shape: ADD9_5, note: 'Iadd9 — compact, A-string root' },
|
||||||
|
],
|
||||||
|
tips: 'Same vamp, sparser: a shell vi instead of the wide min9, and the sus voiced low. Use this when a second guitar or keys is already holding the pad — you supply motion, not width.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'gospel-backdoor': [
|
||||||
|
{
|
||||||
|
label: 'Shells through the back door',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL_5.min7, note: 'iii7' },
|
||||||
|
{ shape: SHELL_6.min7, note: 'vi7 — soft diatonic drop' },
|
||||||
|
{ shape: SHELL_5.dom7, note: '♭VII7 — the back-door dominant, a whole step below I' },
|
||||||
|
{ shape: SHELL_6.maj7, note: 'Imaj7 — resolves UP by step, not down a fifth' },
|
||||||
|
],
|
||||||
|
tips: 'Listen for how ♭VII7 → I rises by a whole step into home rather than falling a fifth like V7 — a warmer, less expected cadence. The ♭7 of ♭VII7 is the ♭7 of the key: keep it under your fingers between the chords.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '9th-coloured back door',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: MIN9_5, extensions: ['9'], note: 'iii9' },
|
||||||
|
{ shape: MIN9_5, extensions: ['9'], note: 'vi9 — same grip slid up a fourth' },
|
||||||
|
{ shape: DOM9_6, extensions: ['9'], note: '♭VII9 — back-door dominant with its 9' },
|
||||||
|
{ shape: MAJ9_6, extensions: ['9'], note: 'Imaj9 — soft landing' },
|
||||||
|
],
|
||||||
|
tips: 'The two min9 chords are the identical shape a fourth apart — one of the easiest gospel moves to internalize. The maj9 resolution sounds especially plush after a back-door dominant.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'gospel-walkup-dim': [
|
||||||
|
{
|
||||||
|
label: 'Walk-up on the A string (shells)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL_5.maj7, note: 'Imaj7 — root on the A string' },
|
||||||
|
{ shape: DIM7_5, note: '♯i°7 — one fret up: the ladder rung' },
|
||||||
|
{ shape: SHELL_5.min7, note: 'ii7 — one more fret: the bass arrived by half steps' },
|
||||||
|
{ shape: SHELL_6.dom7, note: 'V7 — turn around and climb again' },
|
||||||
|
],
|
||||||
|
tips: 'The first three roots sit on one string, one fret apart — the walk-up is literally visible under your hand. Give the diminished its full beat but keep it quieter than the chords around it: it is a passing chord, a rung, not a destination. Over it, arpeggiate its four notes (all minor 3rds apart) — the fastest route from I\'s tones to ii\'s.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '9th-coloured walk-up (mixed registers)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: MAJ9_6, extensions: ['9'], note: 'Imaj9 — low root, the 9 shining on top' },
|
||||||
|
{ shape: DIM7_5, note: '♯i°7 — the diminished stays plain; its tension IS the colour' },
|
||||||
|
{ shape: MIN9_5, extensions: ['9'], note: 'ii9' },
|
||||||
|
{ shape: DOM9_5, extensions: ['9'], note: 'V9' },
|
||||||
|
],
|
||||||
|
tips: 'Same climb with the gospel shimmer on the resting chords. Remember what the ♯i°7 really is — a rootless VI7♭9 aimed at ii — but not every note climbs: only the root rises a half step into ii\'s root, while the hidden dominant\'s ♭7 and ♭9 sigh downward into the ii9. Don\'t decorate the diminished; decorate its resolution.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'gospel-tonicized-amen': [
|
||||||
|
{
|
||||||
|
label: 'Shell tag — one finger tells the story',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL_6.maj7, note: 'Imaj7 — R–7–3' },
|
||||||
|
{ shape: SHELL_6.dom7, note: 'I7 — same grip, one finger drops a fret: the 7 falls to ♭7' },
|
||||||
|
{ shape: SHELL_5.maj7, note: 'IVmaj7 — same fret, root string up (the I7 pointed here)' },
|
||||||
|
{ shape: MIN6_4, note: 'iv6 — the borrowed minor on the middle strings' },
|
||||||
|
{ shape: SHELL_6.maj7, note: 'Imaj7 — home' },
|
||||||
|
],
|
||||||
|
tips: 'Watch the two half-step falls: the I\'s major 7 drops to ♭7 (turning I into V7/IV), then the IV\'s 3rd drops to ♭3 (the borrowed iv). Everything else holds. When soloing, target exactly those two falling voices — the ♭7 of I7 resolves into the 3rd of IV, and the iv\'s ♭3 sighs down to the 9/1 of home.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '9th-coloured tag (upper register)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: MAJ9_6, extensions: ['9'], note: 'Imaj9' },
|
||||||
|
{ shape: DOM9_6, extensions: ['9'], note: 'I9 — the same one-finger drop, with the 9 held on top' },
|
||||||
|
{ shape: MAJ9_6, extensions: ['9'], note: 'IVmaj9 — identical grip five frets up' },
|
||||||
|
{ shape: MIN6_4, note: 'iv6 — drop to the plain borrowed minor; no 9 here' },
|
||||||
|
{ shape: MAJ9_6, extensions: ['9'], note: 'Imaj9 — resolve with colour' },
|
||||||
|
],
|
||||||
|
tips: 'The maj9 → dom9 move is the same story as the shells — only the D-string finger moves, 7 falling to ♭7 — but the held 9 makes the tag glow. Leave the iv6 uncoloured: after four 9th chords, the bare borrowed minor is the emotional dip that sets up the resolution.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
improv: {
|
||||||
|
scales: [
|
||||||
|
{ over: 'ii7 / iii7 / vi7', scale: 'dorian', why: 'Minor 7 chords in a major key take Dorian — the natural 6 keeps them bright, not mournful, which suits gospel.' },
|
||||||
|
{ over: 'V7', scale: 'mixolydian', why: 'The built-in ♭7 fits the dominant; over the praise-vamp Vsus4, stay on the 4th (suspension) and avoid the leading tone until you want to resolve.' },
|
||||||
|
{ over: 'VI7 / ♭VII7', scale: 'mixolydian', why: 'Borrowed dominants are still dominants — Mixolydian off their own root. Over ♭VII7 that scale is the key\'s major scale starting on ♭7, so it stays diatonic-sounding into the resolution.' },
|
||||||
|
{ over: 'Imaj7 / Iadd9', scale: 'major', why: 'The home major (or its pentatonic) sings over the tonic; the major 6th and 9th are the gospel sweet notes — lean on them.' },
|
||||||
|
{ over: '#iv°7', scale: 'dim', why: 'A symmetric diminished (half-whole/whole-half) over the passing chord; it lasts a beat or two, so an arpeggio of its four notes usually says more than a run.' },
|
||||||
|
],
|
||||||
|
targetNotes:
|
||||||
|
'On the resting chords land the 6th or 9th, not just the root — those are the gospel colour tones. Through the 2–5–1 chains, target the 3rd of each new chord on the downbeat; the ♭7 of one dominant falls a half-step into the 3rd of the next chord, the same guide-tone rail as in jazz.',
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
over: 'gospel-amen-625',
|
||||||
|
description: 'Classic plagal turn over the Amen: walk the bass/lowest voice down from the 1 of IV to the 5 then 1 of I (e.g. degrees 4-chord root → 3 → tonic), letting the major 3rd of I ring on the resolution. The whole point is the smooth descent into the cadence.',
|
||||||
|
source: 'hearandplay.com — "Who Else Wants To Learn The Famous Amen Cadence?"; Wikipedia: Plagal cadence',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'gospel-iv-passing-dim',
|
||||||
|
description: 'Over the IV → #iv°7 → I, run the diminished arpeggio (R–♭3–♭5–𝄫7, all minor-3rds apart) connecting the IV chord tones to the I chord tones — the symmetric shape lets you start it from whichever note sits nearest your IV grip and land on the 3rd of I.',
|
||||||
|
source: 'hearandplay.com — "The [Extended] Resolution Of The #4-Diminished Seventh Chord"; pianogroove.com gospel passing chords',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
id: 'gospel',
|
||||||
|
label: 'Gospel',
|
||||||
|
feel: 'shuffle',
|
||||||
|
tempoRange: [60, 130],
|
||||||
|
character: 'Church harmony in motion — cascading 2–5–1 chains, the plagal "Amen", chromatic passing diminisheds, and sus/9th colour everywhere, all pulling toward a big resolution.',
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
// Gospel piano pack (task P-23). Recipes verified by degree-spelling against
|
||||||
|
// CHORD_TYPES (src/lib/theory.js) and the validator's stacking convention
|
||||||
|
// (order inside each hand = voicing order low → high, documented at
|
||||||
|
// jazz/piano.js header). Voicings and treatments sourced from:
|
||||||
|
// hearandplay.com — "Gospel Pianists: Don't Leave Home Without These Essential
|
||||||
|
// Chords" (root–5 / octave left hands, full RH stacks), "Little known ways to
|
||||||
|
// use diminished walk-ups and walk-downs" (♯i°7/♯iv°7 as passing chords),
|
||||||
|
// "Gospel Piano Scales — 6 Different Scales That Can Work Now" (the
|
||||||
|
// pentatonic-♭3 "gospel scale"), and GospelKeys 202 (replacing plain triads
|
||||||
|
// with full-sounding 9th/6-9 voicings in worship playing);
|
||||||
|
// pianogroove.com — "Gospel Passing Chords & Substitutions", "Blues & Gospel
|
||||||
|
// Walk-Ups", "Gospel Hymn Styles" and "Gospel Time Signatures" (12/8 and 6/8
|
||||||
|
// hymn feels), "The Gospel Walk Down";
|
||||||
|
// piano.org — "Rootless Voicings: Type A and Type B (Bill Evans Style)" (the
|
||||||
|
// rootless grips contemporary gospel borrows once a bassist is present);
|
||||||
|
// pianowithjonny.com — "Play Gospel Piano: The 6-Step Beginner Guide"
|
||||||
|
// (root–5 foundations, add9/6th colour); learncolorpiano.com — "Gospel
|
||||||
|
// Progression 1 | Diminished 7th Chords" (rolling the passing °7).
|
||||||
|
// Voice-leading statements in notes/tips are pitch-class arithmetic, checked
|
||||||
|
// against the realized voicings (key of C) before writing.
|
||||||
|
|
||||||
|
// Reusable degree recipes. Order inside each hand = voicing order, low → high.
|
||||||
|
const LH_R5 = ['1', '5'] // open fifth — the hymn "pillar" left hand
|
||||||
|
const LH_OCT = ['1', '1'] // root octave — gospel's bass-player left hand
|
||||||
|
const RH_7TH = ['3', '5', '7'] // root-position seventh-chord upper structure
|
||||||
|
const RH_9TH = ['3', '5', '7', '9'] // the full-sounding 9th stack (GospelKeys-style)
|
||||||
|
const RH_ADD9 = ['3', '5', '9'] // triad with the 9 on top (no 7th)
|
||||||
|
const RH_69 = ['3', '5', '6', '9'] // the 6/9 landing hand — gospel's final chord
|
||||||
|
|
||||||
|
export default {
|
||||||
|
styleIntro:
|
||||||
|
'In church the piano often IS the rhythm section: the left hand supplies the bass (roots, fifths, octaves), the right hand supplies the choir (full triads and 7ths with 9ths and 6ths stacked in), and the space between chords is filled with walk-ups and passing diminisheds. Everything is voiced full and vocal — every chord supports a singer, and every voice is always headed somewhere: to the 4, to the Amen, back home.',
|
||||||
|
|
||||||
|
comping: [
|
||||||
|
{
|
||||||
|
label: '12/8 hymn pulse',
|
||||||
|
rhythm: 'four beats to the bar, each felt in three (12/8)',
|
||||||
|
description:
|
||||||
|
'The slow-gospel foundation: block both hands on the four main beats and let the triplet subdivision breathe underneath. Roll the bigger chords bottom-to-top into beats 1 and 3 — on a hymn the roll is the ornament, not the notes.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Walk-up into the downbeat',
|
||||||
|
rhythm: 'beats 3–4 walk, land on 1',
|
||||||
|
description:
|
||||||
|
'Gospel\'s transitional engine: on the last beats of the bar the left hand climbs stepwise or chromatically (often through a passing diminished) so the next chord\'s downbeat lands like an arrival. The chord change is announced before it happens.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Shout-drive stabs',
|
||||||
|
rhythm: 'relentless on-beat quarters, both hands',
|
||||||
|
description:
|
||||||
|
'The praise-break gear: octave roots in the left hand, full stacks in the right, hammered on every beat of a fast swung 4/4. Where jazz comping avoids downbeats, shout playing owns them — the piano is driving the room, not commenting on it.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plays: {
|
||||||
|
'gospel-cycle-251': [
|
||||||
|
{
|
||||||
|
label: 'Church pillars (root–5 under stacked 9ths)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'plain minor 7 — save the colour for the chords that move' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: ['3', '7', 'b9'] }, note: 'VI7♭9 — from the iii7 one RH note name holds (its ♭3 is this ♭7) and the other two each fall a half-step (5→♭9, ♭7→3)' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9TH }, note: 'the VI7\'s ♭9 just fell a half-step onto this 5th' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9TH }, note: 'two note names hold from the ii (♭3→♭7, 5→9); the ii\'s ♭7 fell a half-step onto this 3rd' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9TH }, note: 'maj9 home — the V7\'s ♭7 fell a half-step onto this 3rd' },
|
||||||
|
],
|
||||||
|
register: 'LH open fifths around C3, RH stacks just above middle C',
|
||||||
|
tips: 'Root and fifth below, four-note stack above — the full-choir sound that lets a piano carry a congregation alone. The cycle teaches one law: at every change the old ♭7 falls onto the new 3rd — a half-step when the landing chord is major or dominant (iii7→VI7, ii7→V7, V7→I), a whole step when it lands on the minor ii (VI7→ii7). Hear that thread and the whole chain plays itself.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Rootless cascade (band setting)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: ['3', '5', '7'] }, note: '♭3–5–♭7, no root — the bass player has it' },
|
||||||
|
{ recipe: { LH: ['7', 'b9', '3'] }, note: 'from the iii7: one note name holds, two fall a half-step' },
|
||||||
|
{ recipe: { LH: ['3', '5', '7', '9'] }, note: 'out of the VI7 everything sinks: two half-steps and a whole step' },
|
||||||
|
{ recipe: { LH: ['7', '9', '3', '13'] }, note: 'three note names hold from the ii (♭3→♭7, 5→9, 9→13) — only the ♭7 moves, a half-step onto this 3rd' },
|
||||||
|
{ recipe: { LH: ['3', '5', '7', '9'] }, note: 'maj9 landing — the V\'s 3rd holds over as this maj7' },
|
||||||
|
],
|
||||||
|
register: 'left hand alone, top note between C4 and C5 — right hand answers the choir',
|
||||||
|
tips: 'Contemporary gospel borrows the jazz rootless grips the moment a bassist is on the platform: doubling their root just muddies the mix. Watch the ii7→V7 seam and count what your hand does — three fingers stay on the same note names (♭3→♭7, 5→9, 9→13) and one falls a half-step. That near-stillness over a moving bass is the sound of a tight church band.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'gospel-amen-625': [
|
||||||
|
{
|
||||||
|
label: 'Hymn pillars into the Amen',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'the turnaround starts its fall home' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'the vi\'s ♭7 fell a whole step onto this ♭3' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'the ii\'s ♭7 fell a half-step onto this 3rd' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'home — the V\'s ♭7 fell a half-step onto this 3rd' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: ['3', '5', '1'] }, note: 'inverted triad, root on top — set up the Amen fall' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'Amen: all three RH voices sink — root-on-top a half-step onto the 3rd, 5th a half-step onto the maj7, 3rd a whole step onto the 5th' },
|
||||||
|
],
|
||||||
|
register: 'LH fifths around C3, RH close position around middle C; 12/8 pulse',
|
||||||
|
tips: 'The 6–2–5–1 is the same falling-fifths law as any turnaround: each ♭7 falls by step onto the next chord\'s 3rd. The Amen is the payoff — voice the IV with its root on top and every right-hand voice sinks into the I (two half-steps, one whole step). That downward sigh is why plagal cadences close hymns.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Octave bass, 6/9 colour (contemporary)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_OCT, RH: RH_ADD9 }, note: 'm9 spread — the 9 on top sings' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: RH_ADD9 }, note: 'same shape, next station of the fall' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: ['3', '7', '9'] }, note: 'V9 — its 9 will hold into the I as the 6th' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: RH_69 }, note: '6/9 instead of maj7 — the V9\'s ♭7 fell a half-step onto this 3rd, its 9 held as this 6' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: ['3', '6', '9'] }, note: 'IV as a 6/9 — every note of this hand is already a note of the I6/9' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: RH_69 }, note: 'the final chord of half the gospel repertoire: 6/9, root octave below' },
|
||||||
|
],
|
||||||
|
register: 'LH octaves around C2–C3, RH between C4 and C5 — big and open',
|
||||||
|
tips: 'Swapping the maj7 for the 6/9 is the single most gospel substitution there is — the 6th and 9th colour the tonic without the maj7\'s leading-tone rub against a melody on the root. The Amen becomes pure bass motion: every note of the IV6/9 right hand is already a tone of the I6/9 (its 3, 6 and 9 are the I\'s 6, 9 and 5), so those three note names hold, the I\'s 3rd joins beneath them, and the octave bass makes the change alone.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'gospel-iv-passing-dim': [
|
||||||
|
{
|
||||||
|
label: 'The 4–♯4–5 walk (dim7 into I over its 5th)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ADD9 }, note: 'add9 home, open and ringing' },
|
||||||
|
{ recipe: { LH: ['1'], RH: ['3', '5', '1'] }, note: 'single bass note — the walk starts; root on top of the RH' },
|
||||||
|
{ recipe: { LH: ['1'], RH: RH_7TH }, note: 'two RH notes freeze (the IV\'s 3rd and 5th are this °7\'s ♭3 and ♭5); the top slips a whole step; the bass does the talking' },
|
||||||
|
{ recipe: { LH: ['5', '1'], RH: RH_ADD9 }, note: 'I over its 5th — the bass lands 4→♯4→5 while the °7\'s 𝄫7 rises a half-step onto this 3rd' },
|
||||||
|
],
|
||||||
|
register: 'bass line front and centre (C2–C3); RH compact around middle C',
|
||||||
|
tips: 'The whole point is the bass: 4, ♯4, 5 — two half-steps that turn a plain IV–I into church. Above it almost nothing moves: the ♯iv°7 keeps two of the IV\'s notes, and it already contains the key\'s home note (its ♭5), so the resolution is prepared before you play it. Landing on I-over-its-5th instead of root position is what makes the walk feel continuous — save root position for the phrase\'s final chord.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Praise clusters with a rolled diminished',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_OCT, RH: ['9', '3', '5'] }, note: 'add9 cluster — 9 tucked under the 3rd, modern worship colour' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: ['9', '3', '5'] }, note: 'same grip, root a fourth up — the cluster shape carries the whole vamp' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: RH_7TH }, note: 'roll it bottom-to-top like a grace note — two of the IV\'s three cluster notes carry straight in' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: ['5', '1', '9'] }, note: 'open 5–1–9 — release after the crunch' },
|
||||||
|
],
|
||||||
|
register: 'LH octaves; RH clusters between C4 and D5; straight-16th contemporary feel',
|
||||||
|
tips: 'The 9-under-the-3rd cluster is the modern praise sound — tight, bright, no 7th. Treat the ♯iv°7 as a gesture, not a destination: roll it into the beat and let it release into the open 5–1–9 hand. One crunchy chord between two clean ones is exactly the dosage; two would be a jazz solo.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'gospel-sus-vamp': [
|
||||||
|
{
|
||||||
|
label: 'Worship pads (one shape, four chords)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ADD9 }, note: 'm9 pad' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ADD9 }, note: 'add9 — same grip, the roots do the work' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ADD9 }, note: 'the "3rd" slot is the suspended 4th — which is the key\'s home note' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ADD9 }, note: 'home, still hovering on the 9' },
|
||||||
|
],
|
||||||
|
register: 'sustained, pedal down; LH fifths low, RH between C4 and C5',
|
||||||
|
tips: 'One right-hand recipe — 3(4)–5–9 — planted on four different roots: this is how worship keyboardists play a whole set. The vamp hovers because the tonic note never leaves: it is the vi\'s ♭3, the IV\'s 5th and the Vsus\'s suspended 4th before it finally lands in the bass on the I. The Vsus\'s 9 is the vi\'s root — the loop\'s seam is sewn shut.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Anticipated stabs (octaves + clusters)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_OCT, RH: ['3', '5', '1'] }, note: 'first-inversion shape, root on top' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: ['9', '3', '5'] }, note: '9-cluster — tight against the pad version\'s spread' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: ['3', '1', '9'] }, note: 'sus stack: 4th, root, 9 — no 3rd to resolve' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: ['5', '1', '9'] }, note: 'two of the three stab notes (the key\'s 1 and 5) carry over from the Vsus' },
|
||||||
|
],
|
||||||
|
register: 'both hands mid-keyboard, short and percussive; hit the and-of-4 and tie',
|
||||||
|
tips: 'Same four chords, opposite job: instead of pads, strike each chord an eighth early (the and-of-4) and let the tie pull the band forward. Keep the right hand to three notes — stabs live or die on rhythm, and the Vsus→I seam proves how little needs to move: two of your three notes stay on the same note names while the octave bass resolves.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'gospel-backdoor': [
|
||||||
|
{
|
||||||
|
label: 'Pillars: the back door swings on its 9',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'soft diatonic start' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'two note names carry over: the iii\'s root becomes this chord\'s 5th, its ♭3 becomes this ♭7' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9TH }, note: '♭VII9 — the 9 of this chord is the key\'s home note; keep it on top of your ear' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9TH }, note: 'maj9 — the back door\'s ♭7 sank a half-step onto this 5th, its 5th a half-step onto this 3rd' },
|
||||||
|
],
|
||||||
|
register: 'LH fifths around C3, RH around middle C; unhurried',
|
||||||
|
tips: 'The back door earns its name in the voice leading: where V7 resolves with leading-tone pull, the ♭VII7 slides home on two half-step sighs — its ♭7 onto the I\'s 5th and its 5th onto the I\'s 3rd — while its 9 (the key\'s home note) rings through the whole change. Play the ♭VII9 slightly softer than the chords around it; the surprise chord should whisper.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Rootless slide (band setting)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: ['3', '5', '7'] }, note: 'three-note rootless — light' },
|
||||||
|
{ recipe: { LH: ['3', '5', '7'] }, note: 'the iii\'s ♭7 fell a whole step onto this ♭3' },
|
||||||
|
{ recipe: { LH: ['3', '13', '7', '9'] }, note: 'the vi\'s ♭3 and ♭7 are already this chord\'s 9 and 13 — two note names don\'t move' },
|
||||||
|
{ recipe: { LH: ['3', '5', '7', '9'] }, note: 'maj9 — the ♭VII\'s 13 was already this chord\'s 5th' },
|
||||||
|
],
|
||||||
|
register: 'left hand alone, top note between C4 and C5',
|
||||||
|
tips: 'The 13 is the trick: adding it to the ♭VII7 plants the I\'s 5th in your hand a bar early, and its 9 is the key\'s home note — so the "borrowed" chord is half at home before it resolves. Then the ♭7 sinks a half-step and the 3rd climbs a whole step onto the I\'s 3rd. Rootless because the bassist owns that ♭7̂ bass note — let them have the drama.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'gospel-walkup-dim': [
|
||||||
|
{
|
||||||
|
label: 'Walk-up: the bass climbs, the hands barely move',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: ['1'], RH: ['3', '5', '1'] }, note: 'root on top — it will fall a whole step while the rest of the hand freezes' },
|
||||||
|
{ recipe: { LH: ['1'], RH: RH_7TH }, note: 'the I\'s 3rd and 5th ARE this °7\'s ♭3 and ♭5 — two RH notes don\'t move; the bass climbs a half-step' },
|
||||||
|
{ recipe: { LH: ['1'], RH: RH_7TH }, note: 'every RH note climbs a half- or whole-step; the bass takes its second half-step' },
|
||||||
|
{ recipe: { LH: ['1'], RH: RH_7TH }, note: 'the ii\'s ♭3 holds as this ♭7 (same note name); its ♭7 fell a half-step onto this 3rd' },
|
||||||
|
],
|
||||||
|
register: 'single bass notes C2–C3 — the walking line is the melody; RH compact',
|
||||||
|
tips: 'A walk-up is a bass line wearing chords: 1, ♯1, 2 in the left hand while the right hand moves as little as arithmetic allows — two notes literally frozen through the first change. It works because ♯i°7 is a rootless VI7♭9 (its four notes are exactly the top four of the VI7♭9), so the ear hears a secondary dominant pointing at the ii. Practise hands separately: bass alone should already sound like gospel.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Full-church walk-up (root–5 + 9ths)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9TH }, note: 'maj9 — big two-hand home' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'thin to three: two RH notes freeze, the maj7 slips a half-step onto the 𝄫7, the 9 steps aside' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_9TH }, note: 'the whole right hand climbs by step out of the diminished — half-step, whole step, whole step — and the 9 returns on top' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: ['3', '13', '7', '9'] }, note: 'V13 — three note names hold from the ii (♭3→♭7, 5→9, 9→13); only its ♭7 falls, a half-step onto this 3rd' },
|
||||||
|
],
|
||||||
|
register: 'LH fifths around C3, RH between C4 and C5; land the changes on downbeats',
|
||||||
|
tips: 'The same walk-up dressed for Sunday: open fifths under four-note stacks. Notice the diminished bar is the THIN one — dropping to three notes while the bass climbs makes the ♯i°7 feel like motion instead of mud, then the 9 coming back on the ii is the choir breathing in. The V13 grip (3–13–♭7–9) is the classic gospel dominant: learn it as one hand-shape and the ii7→V7 change costs you one finger.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'gospel-tonicized-amen': [
|
||||||
|
{
|
||||||
|
label: 'The two sighs, voiced plain',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'home, unhurried' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'identical grip — one note changes: the 7 falls a half-step to the ♭7, and home becomes V7 of the IV' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'the ♭7 fell a half-step onto this 3rd; the I\'s 3rd holds as this maj7 (same note name)' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: ['3', '5', '6'] }, note: 'iv6: the 3rd sank a half-step to ♭3, the maj7 a whole step to the 6th, the 5th held' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_7TH }, note: 'the iv\'s ♭3 sighs a half-step onto this 5th; its 6th rises a whole step onto this 3rd' },
|
||||||
|
],
|
||||||
|
register: 'LH fifths around C3, RH around middle C; 12/8, rolled arrivals',
|
||||||
|
tips: 'Five chords, two borrowed notes, and each borrowed note resolves down a half-step — the ♭7 (into the IV\'s 3rd) and the borrowed ♭3 (into the I\'s 5th). Keep every other voice as still as the arithmetic allows and the tag plays like a pair of sighs, which is exactly how a congregation sings it. This is the hymn-ending to have under your fingers in all twelve keys.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Drive to the 4, land on the 6/9',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_OCT, RH: RH_ADD9 }, note: 'add9 home over octaves' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: ['3', '7', '9'] }, note: 'the drive: swap the 5 for the ♭7 — one finger hops up a minor third, the rest freeze' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: RH_ADD9 }, note: 'arrival: the ♭7 sighed a half-step onto this 3rd, the 9 fell a whole step onto this 5th' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: ['3', '6', '9'] }, note: 'iv6/9: flatten the 3rd, swap the 5 for the 6 — the 9 on top never moves' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: RH_69 }, note: 'the iv\'s 6 and 9 are already this chord\'s 9 and 5 — those note names hold; the borrowed ♭3 resolves down a half-step (♭6̂ to 5̂ of the key)' },
|
||||||
|
],
|
||||||
|
register: 'LH octaves low, RH between C4 and C5; push the I7 on the and-of-4',
|
||||||
|
tips: 'The I7 is a gesture, not a chord to sit on: strike it late in the bar (and-of-4, tied) so it shoves the music into the IV — that anticipation is the "drive to the 4" every gospel pianist leans on. Coming home, count what actually moves: two of the iv6/9\'s three notes are already tones of the I6/9, only the borrowed ♭3 resolves (down a half-step), and the I\'s 3rd slips in at the bottom of the hand. Endings this quiet are earned by voicings this shared.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
improv: {
|
||||||
|
scales: [
|
||||||
|
{
|
||||||
|
over: 'I / Imaj7 / Iadd9',
|
||||||
|
scale: 'major pentatonic + ♭3 (the gospel scale)',
|
||||||
|
why: '1–2–♭3–3–5–6: major pentatonic with a ♭3 crush note (hearandplay teaches it as the pentatonic-♭3 scale). The ♭3→3 slip into a chord tone is the single most gospel ornament on the piano.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'ii7',
|
||||||
|
scale: 'dorian',
|
||||||
|
why: 'The parent major scale started from 2 — no new notes to learn, and the natural 6 keeps the ii warm instead of mournful.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'vi7 / iii7',
|
||||||
|
scale: 'the key\'s own notes (aeolian / phrygian)',
|
||||||
|
why: 'These minor chords are diatonic, so the major scale you are already in covers them — from the vi it sounds aeolian, from the iii phrygian. Aim at each chord\'s ♭3 and ♭7 rather than reaching for a new scale.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'dom7 (V7, VI7, ♭VII7)',
|
||||||
|
scale: 'mixolydian',
|
||||||
|
why: 'Mixolydian from each dominant\'s own root hands you its ♭7. Over the backdoor ♭VII7 it also supplies the key\'s borrowed flat notes — that dusky colour is the point of the chord.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: '♯i°7 / ♯iv°7',
|
||||||
|
scale: 'the four chord tones (°7 arpeggio)',
|
||||||
|
why: 'Passing chords last a breath — arpeggiate the °7 itself (it is symmetric, stacked minor 3rds) and resolve by step: most of its tones sit a half-step or whole step from a tone of the next chord.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'iv6',
|
||||||
|
scale: 'dorian from the iv',
|
||||||
|
why: 'Dorian\'s natural 6 IS the chord\'s 6th, so the scale bakes the borrowed-iv colour in; land on the ♭3 and let it sigh down a half-step when the I arrives.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
targetNotes:
|
||||||
|
'Land 3rds on the strong beats, then double your line in 3rds or 6ths — the harmonized run is gospel\'s signature ornament, and on piano it is one hand-shape moved up the scale. Save the ♭3→3 crush for arrivals on the I, and when a walk-up is coming, aim your right-hand line at the same downbeat the bass is walking toward.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Structured piano licks (SCHEMA.md "Piano licks", task P-61). Degree-based
|
||||||
|
// and key-agnostic: every deg resolves through the stated quality, approach
|
||||||
|
// pitches are derived from the next deg note. Realized offsets in the
|
||||||
|
// comments use a C-rooted chord for readability; every interval claim in
|
||||||
|
// notes/tips was recomputed from those offsets before writing (P-41 bar).
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
// Over Cadd9: C4 D4 (E♭4)E4 G4 A4 C5 — offsets 0 2 [3]4 7 9 12: the
|
||||||
|
// hearandplay "gospel scale" (major pentatonic + ♭3) climbed straight
|
||||||
|
// up one octave, the ♭3 crushing a half-step into the 3rd.
|
||||||
|
id: 'gospel-scale-run',
|
||||||
|
name: 'Gospel-scale run (the ♭3→3 crush)',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'over the Iadd9 — any major-tonic bar',
|
||||||
|
quality: 'add9',
|
||||||
|
techniques: ['grace-note'],
|
||||||
|
source: 'the pentatonic-♭3 "gospel scale" — hearandplay.com, "Gospel Piano Scales — 6 Different Scales That Can Work Now"',
|
||||||
|
notes: [
|
||||||
|
{ deg: '1', beat: 1 },
|
||||||
|
{ deg: '9', beat: 1.5 },
|
||||||
|
{ deg: 'b3', beat: 2, technique: 'grace-note' },
|
||||||
|
{ deg: '3', beat: 2 },
|
||||||
|
{ deg: '5', beat: 2.5 },
|
||||||
|
{ deg: '6', beat: 3 },
|
||||||
|
{ deg: '1', octave: 1, beat: 3.5 },
|
||||||
|
],
|
||||||
|
tips: 'Major pentatonic with one borrowed note: 1–2–♭3–3–5–6–1, the ♭3 crushed into the 3rd almost as one gesture — gospel\'s answer to the string bend, same move the blues makes but headed somewhere brighter. Run it up one octave and stop; the restraint is what keeps it church and not cocktail.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Over C7 (the V7 of an F shout vamp): G4 A4 C5 D5 E5 — offsets
|
||||||
|
// 7 9 12 14 16: 5–6–1–9–3 major pentatonic, straight-8th pickup on
|
||||||
|
// beats 3–4.5 landing the 3rd on beat 5 (the next downbeat).
|
||||||
|
id: 'gospel-shout-runup',
|
||||||
|
name: 'Shout run-up (pentatonic pickup)',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'over the V7 — the run-up into a shout/praise-break downbeat',
|
||||||
|
quality: 'dom7',
|
||||||
|
techniques: [],
|
||||||
|
source: 'praise-break run-up vocabulary — the drive gear hearandplay teaches in GospelKeys 300 (praise songs & shouting music)',
|
||||||
|
notes: [
|
||||||
|
{ deg: '5', beat: 3 },
|
||||||
|
{ deg: '6', beat: 3.5 },
|
||||||
|
{ deg: '1', octave: 1, beat: 4 },
|
||||||
|
{ deg: '9', octave: 1, beat: 4.5 },
|
||||||
|
{ deg: '3', octave: 1, beat: 5 },
|
||||||
|
],
|
||||||
|
tips: 'A five-note ladder — 5, 6, root, 9, 3, all major pentatonic off the chord\'s root — thrown at the next bar\'s downbeat like a drummer\'s fill. The landing note is the 3rd, arriving exactly ON beat 1 with the band\'s next hit: in shout music the run exists to make the downbeat feel inevitable. Play it in octaves when the room gets loud.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Over Cmaj7, dyads struck together: C4+A4 → D4+B4 → E4+C5 → G4+E5 —
|
||||||
|
// offsets (0,9)(2,11)(4,12)(7,16). Every pair is a sixth (9,9,8,9
|
||||||
|
// semitones); bottom voice walks 1–2–3 then leaps a minor 3rd to 5,
|
||||||
|
// top sings 6–7–1 then leaps a major 3rd to the high 3. All eight
|
||||||
|
// notes diatonic to the root's major scale.
|
||||||
|
id: 'gospel-sixths-walkup',
|
||||||
|
name: 'Sixths walk-up (the harmonized choir line)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'over the Imaj7, walking into the next downbeat',
|
||||||
|
quality: 'maj7',
|
||||||
|
techniques: ['double-stop'],
|
||||||
|
source: 'harmonizing the scale in 3rds and 6ths — hearandplay.com, "Harmonization Of The Major Scale Using Third And Sixth Intervals"',
|
||||||
|
notes: [
|
||||||
|
{ deg: '1', beat: 2 },
|
||||||
|
{ deg: '6', beat: 2, technique: 'double-stop' },
|
||||||
|
{ deg: '9', beat: 3 },
|
||||||
|
{ deg: '7', beat: 3, technique: 'double-stop' },
|
||||||
|
{ deg: '3', beat: 4 },
|
||||||
|
{ deg: '1', octave: 1, beat: 4, technique: 'double-stop' },
|
||||||
|
{ deg: '5', beat: 5 },
|
||||||
|
{ deg: '3', octave: 1, beat: 5, technique: 'double-stop' },
|
||||||
|
],
|
||||||
|
tips: 'One singer with a shadow: the bottom voice walks 1–2–3 up the scale while the top sings 6–7–8 a sixth above, then both voices leap a third onto 5-under-3 for the arrival. Quarter notes, landing on the next bar\'s downbeat — it is the choir\'s soprano-alto pair under your right hand, and the 3rd on top of the last dyad is the note the pack keeps telling you to land on.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Over a °7 (C root): C4 E♭4 G♭4 A4 C5 E♭5 G♭5 A5 — offsets
|
||||||
|
// 0 3 6 9 12 15 18 21: pure stacked minor 3rds, two full octaves of
|
||||||
|
// the symmetric chord, rolled in 16ths across beats 1–2.75.
|
||||||
|
id: 'gospel-dim-roll',
|
||||||
|
name: 'Diminished roll (two octaves of the passing chord)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'over the passing diminished bar — the ♯iv°7 or ♯i°7',
|
||||||
|
quality: 'dim7',
|
||||||
|
techniques: [],
|
||||||
|
source: 'rolling the passing °7 — learncolorpiano.com "Gospel Progression 1 | Diminished 7th Chords"; hearandplay.com diminished walk-ups/walk-downs lesson',
|
||||||
|
notes: [
|
||||||
|
{ deg: '1', beat: 1 },
|
||||||
|
{ deg: 'b3', beat: 1.25 },
|
||||||
|
{ deg: 'b5', beat: 1.5 },
|
||||||
|
{ deg: '6', beat: 1.75 },
|
||||||
|
{ deg: '1', octave: 1, beat: 2 },
|
||||||
|
{ deg: 'b3', octave: 1, beat: 2.25 },
|
||||||
|
{ deg: 'b5', octave: 1, beat: 2.5 },
|
||||||
|
{ deg: '6', octave: 1, beat: 2.75 },
|
||||||
|
],
|
||||||
|
tips: 'The °7 is symmetric — minor thirds all the way up, the same four note names in every octave — so one four-note fingering (1-2-3-4, cross, repeat) sweeps two octaves without thinking. Roll it like a harp flourish inside the passing bar and let the last note hang: every tone of the chord sits a half-step or whole step from a tone of the chord you are walking into, so wherever you stop, the resolution is already prepared.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Gospel progressions. Moves and theory verified against:
|
||||||
|
// hearandplay.com (6-2-5-1 turnaround, #4-diminished resolution, Amen cadence),
|
||||||
|
// pianogroove.com (gospel passing chords / hymn reharmonization),
|
||||||
|
// gospelmaps.com "Top 10 Gospel Chord Progressions",
|
||||||
|
// Wikipedia: Plagal cadence, Backdoor progression.
|
||||||
|
// Song references are kept honest: where a single definitive recording isn't
|
||||||
|
// certain, the move is described by its standard use in the tradition.
|
||||||
|
//
|
||||||
|
// Level tags + the two intermediate progressions added by task P-20.
|
||||||
|
// Walk-up/tag theory verified against: hearandplay.com ("Little known ways to
|
||||||
|
// use diminished walk-ups and walk-downs", "An In-Depth Breakdown Of The
|
||||||
|
// Classic 1-4 Walk-Up"), pianogroove.com (Gospel Walk-Ups; Gospel Passing
|
||||||
|
// Chords & Substitutions), docs/progression-repertoire.md §1 (I–♯i°–ii,
|
||||||
|
// I–I7–IV, IV–iv–I rows).
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
id: 'gospel-cycle-251',
|
||||||
|
name: 'Cascading 2–5–1s (cycle of dominants)',
|
||||||
|
rn: ['iii7', 'VI7', 'ii7', 'V7', 'Imaj7'],
|
||||||
|
degrees: [4, 9, 2, 7, 0],
|
||||||
|
qualities: ['min7', 'dom7', 'min7', 'dom7', 'maj7'],
|
||||||
|
bars: [1, 1, 1, 1, 2],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['traditional gospel turnaround', 'His Eye Is on the Sparrow (reharmonized)'],
|
||||||
|
tip: 'Every root falls a fifth and the off-diatonic VI7 pushes hard to ii — chain two ii–V cells back to back and the ear is dragged home. Voice-lead the 3rds and 7ths and your hand barely moves.',
|
||||||
|
level: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gospel-amen-625',
|
||||||
|
name: '6–2–5–1 turnaround into the Amen',
|
||||||
|
rn: ['vi7', 'ii7', 'V7', 'Imaj7', 'IV', 'Imaj7'],
|
||||||
|
degrees: [9, 2, 7, 0, 5, 0],
|
||||||
|
qualities: ['min7', 'min7', 'dom7', 'maj7', 'maj', 'maj7'],
|
||||||
|
bars: [1, 1, 1, 1, 1, 2],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['traditional hymn endings', "I'll Fly Away (turnaround/tag)"],
|
||||||
|
tip: 'The 6–2–5–1 is the gospel turnaround — the extended cousin of the 2–5–1 that returns you from chorus to verse. Tag it with the plagal IV–I "Amen" for the classic church ending.',
|
||||||
|
level: 'foundation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gospel-iv-passing-dim',
|
||||||
|
name: 'IV–#iv°7–I (passing diminished)',
|
||||||
|
rn: ['Iadd9', 'IV', '#iv°7', 'Iadd9'],
|
||||||
|
degrees: [0, 5, 6, 0],
|
||||||
|
qualities: ['add9', 'maj', 'dim7', 'add9'],
|
||||||
|
bars: [2, 1, 1, 2],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['gospel hymn reharmonization', 'Amazing Grace (passing-chord arrangement)'],
|
||||||
|
tip: 'The #iv°7 is a chromatic passing chord linking IV back to I (bass walks 4 → #4 → 5/1). It is almost a signature of the gospel sound — one borrowed diminished and a plain hymn turns into church.',
|
||||||
|
level: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gospel-sus-vamp',
|
||||||
|
name: 'Modern praise vamp (6–4–5–1, sus/9th)',
|
||||||
|
rn: ['vi7', 'IVadd9', 'Vsus4', 'Iadd9'],
|
||||||
|
degrees: [9, 5, 7, 0],
|
||||||
|
qualities: ['min7', 'add9', 'sus4', 'add9'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Every Praise — Hezekiah Walker (vamp feel)', 'contemporary gospel/CCM praise loop'],
|
||||||
|
tip: 'Contemporary gospel leans on open sus and add9 colour instead of plain triads — the suspended 4th over V never fully resolves, keeping the vamp hovering and "lifting". Loop it; the tension is the point.',
|
||||||
|
level: 'foundation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gospel-backdoor',
|
||||||
|
name: 'iii–vi with the back door (♭VII7→I)',
|
||||||
|
rn: ['iii7', 'vi7', '♭VII7', 'Imaj7'],
|
||||||
|
degrees: [4, 9, 10, 0],
|
||||||
|
qualities: ['min7', 'min7', 'dom7', 'maj7'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['gospel/soul turnaround', 'standard back-door cadence (e.g. Tenderly, bars 5–7)'],
|
||||||
|
tip: 'iii→vi is a soft diatonic drop; the ♭VII7 is the "back door" — a borrowed dominant a whole step below I that resolves up by step instead of down a fifth. A warmer, less expected way home than V7.',
|
||||||
|
level: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gospel-walkup-dim',
|
||||||
|
name: 'Chromatic walk-up (I–♯i°7–ii7–V7)',
|
||||||
|
rn: ['Imaj7', '♯i°7', 'ii7', 'V7'],
|
||||||
|
degrees: [0, 1, 2, 7],
|
||||||
|
qualities: ['maj7', 'dim7', 'min7', 'dom7'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['traditional gospel walk-up (bass climbs 1–♯1–2)', 'the standard hymn-to-church reharmonization move'],
|
||||||
|
tip: 'The ♯i°7 turns the step from I to ii into two half-steps — the bass climbs while the upper voices barely move. It works because ♯i°7 is a rootless VI7♭9, the secondary dominant of ii in disguise; then the V7 launches the loop again.',
|
||||||
|
level: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gospel-tonicized-amen',
|
||||||
|
name: 'Tonicized Amen (I–I7–IV–iv–I)',
|
||||||
|
rn: ['Imaj7', 'I7', 'IVmaj7', 'iv6', 'Imaj7'],
|
||||||
|
degrees: [0, 0, 5, 5, 0],
|
||||||
|
qualities: ['maj7', 'dom7', 'maj7', 'min6', 'maj7'],
|
||||||
|
bars: [1, 1, 1, 1, 2],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Amazing Grace ("how sweet the sound" — the I7 pointing at IV)', 'traditional hymn tag (Amen with the borrowed iv)'],
|
||||||
|
tip: 'Two borrowed notes tell the whole story: the ♭7 that turns I into the dominant of IV (a secondary dominant, V7/IV), and the ♭3 that turns IV minor on the way home. Both fall by half step — the gospel tag is a pair of sighs.',
|
||||||
|
level: 'intermediate',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// KB registry — the UI reads only this. Each style folder registers here;
|
||||||
|
// instruments appear as their cells are completed (see docs/kb-backlog.md).
|
||||||
|
import jazzMeta from './jazz/meta.js'
|
||||||
|
import jazzProgressions from './jazz/progressions.js'
|
||||||
|
import jazzGuitar from './jazz/guitar.js'
|
||||||
|
import jazzPiano from './jazz/piano.js'
|
||||||
|
import bluesMeta from './blues/meta.js'
|
||||||
|
import bluesProgressions from './blues/progressions.js'
|
||||||
|
import bluesGuitar from './blues/guitar.js'
|
||||||
|
import bluesPiano from './blues/piano.js'
|
||||||
|
import bluesBass from './blues/bass.js'
|
||||||
|
import rockMeta from './rock/meta.js'
|
||||||
|
import rockProgressions from './rock/progressions.js'
|
||||||
|
import rockGuitar from './rock/guitar.js'
|
||||||
|
import bossaMeta from './bossa/meta.js'
|
||||||
|
import bossaProgressions from './bossa/progressions.js'
|
||||||
|
import bossaGuitar from './bossa/guitar.js'
|
||||||
|
import funkMeta from './funk/meta.js'
|
||||||
|
import funkProgressions from './funk/progressions.js'
|
||||||
|
import funkGuitar from './funk/guitar.js'
|
||||||
|
import reggaeMeta from './reggae/meta.js'
|
||||||
|
import reggaeProgressions from './reggae/progressions.js'
|
||||||
|
import reggaeGuitar from './reggae/guitar.js'
|
||||||
|
import countryMeta from './country/meta.js'
|
||||||
|
import countryProgressions from './country/progressions.js'
|
||||||
|
import countryGuitar from './country/guitar.js'
|
||||||
|
import rnbMeta from './rnb/meta.js'
|
||||||
|
import rnbProgressions from './rnb/progressions.js'
|
||||||
|
import rnbGuitar from './rnb/guitar.js'
|
||||||
|
import rnbPiano from './rnb/piano.js'
|
||||||
|
import gospelMeta from './gospel/meta.js'
|
||||||
|
import gospelProgressions from './gospel/progressions.js'
|
||||||
|
import gospelGuitar from './gospel/guitar.js'
|
||||||
|
import gospelPiano from './gospel/piano.js'
|
||||||
|
import popMeta from './pop/meta.js'
|
||||||
|
import popProgressions from './pop/progressions.js'
|
||||||
|
import popGuitar from './pop/guitar.js'
|
||||||
|
import popPiano from './pop/piano.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
jazz: {
|
||||||
|
meta: jazzMeta,
|
||||||
|
progressions: jazzProgressions,
|
||||||
|
instruments: { guitar: jazzGuitar, piano: jazzPiano },
|
||||||
|
},
|
||||||
|
blues: {
|
||||||
|
meta: bluesMeta,
|
||||||
|
progressions: bluesProgressions,
|
||||||
|
instruments: { guitar: bluesGuitar, piano: bluesPiano, bass: bluesBass },
|
||||||
|
},
|
||||||
|
rock: {
|
||||||
|
meta: rockMeta,
|
||||||
|
progressions: rockProgressions,
|
||||||
|
instruments: { guitar: rockGuitar },
|
||||||
|
},
|
||||||
|
bossa: {
|
||||||
|
meta: bossaMeta,
|
||||||
|
progressions: bossaProgressions,
|
||||||
|
instruments: { guitar: bossaGuitar },
|
||||||
|
},
|
||||||
|
funk: {
|
||||||
|
meta: funkMeta,
|
||||||
|
progressions: funkProgressions,
|
||||||
|
instruments: { guitar: funkGuitar },
|
||||||
|
},
|
||||||
|
reggae: {
|
||||||
|
meta: reggaeMeta,
|
||||||
|
progressions: reggaeProgressions,
|
||||||
|
instruments: { guitar: reggaeGuitar },
|
||||||
|
},
|
||||||
|
country: {
|
||||||
|
meta: countryMeta,
|
||||||
|
progressions: countryProgressions,
|
||||||
|
instruments: { guitar: countryGuitar },
|
||||||
|
},
|
||||||
|
rnb: {
|
||||||
|
meta: rnbMeta,
|
||||||
|
progressions: rnbProgressions,
|
||||||
|
instruments: { guitar: rnbGuitar, piano: rnbPiano },
|
||||||
|
},
|
||||||
|
gospel: {
|
||||||
|
meta: gospelMeta,
|
||||||
|
progressions: gospelProgressions,
|
||||||
|
instruments: { guitar: gospelGuitar, piano: gospelPiano },
|
||||||
|
},
|
||||||
|
pop: {
|
||||||
|
meta: popMeta,
|
||||||
|
progressions: popProgressions,
|
||||||
|
instruments: { guitar: popGuitar, piano: popPiano },
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
// Jazz guitar pack — gold-standard KB cell. Shapes verified by note-spelling
|
||||||
|
// against: jazzguitar.be (shell chords, drop-2, comping rhythms), freddiegreen.org,
|
||||||
|
// jenslarsen.nl (comping rhythms, voice leading), premierguitar.com (drop-2).
|
||||||
|
|
||||||
|
// Shell voicings (Freddie Green style) — root + 3rd + 7th, fifths omitted.
|
||||||
|
const SHELL_6 = { // root on low E string
|
||||||
|
maj7: { rootStr: 6, offsets: [0, 'x', 1, 1, 'x', 'x'], fingers: [1, 0, 3, 4, 0, 0] }, // R–7–3
|
||||||
|
dom7: { rootStr: 6, offsets: [0, 'x', 0, 1, 'x', 'x'], fingers: [1, 0, 2, 3, 0, 0] }, // R–♭7–3
|
||||||
|
min7: { rootStr: 6, offsets: [0, 'x', 0, 0, 'x', 'x'], fingers: [1, 0, 2, 3, 0, 0] }, // R–♭7–♭3
|
||||||
|
half_dim: { rootStr: 6, offsets: [0, 'x', 0, 0, -1, 'x'], fingers: [2, 0, 3, 4, 1, 0] }, // R–♭7–♭3–♭5
|
||||||
|
}
|
||||||
|
const SHELL_5 = { // root on A string
|
||||||
|
maj7: { rootStr: 5, offsets: ['x', 0, -1, 1, 'x', 'x'], fingers: [0, 2, 1, 4, 0, 0] }, // R–3–7
|
||||||
|
dom7: { rootStr: 5, offsets: ['x', 0, -1, 0, 'x', 'x'], fingers: [0, 2, 1, 3, 0, 0] }, // R–3–♭7
|
||||||
|
min7: { rootStr: 5, offsets: ['x', 0, -2, 0, 'x', 'x'], fingers: [0, 3, 1, 4, 0, 0] }, // R–♭3–♭7
|
||||||
|
half_dim: { rootStr: 5, offsets: ['x', 0, 1, 0, 1, 'x'], fingers: [0, 1, 3, 2, 4, 0] }, // R–♭5–♭7–♭3
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop-2 voicings on the top four strings (D–G–B–e) — stays out of the bass register.
|
||||||
|
const DROP2 = {
|
||||||
|
maj7Root: { rootStr: 4, offsets: ['x', 'x', 0, 2, 2, 2], fingers: [0, 0, 1, 3, 3, 3] }, // R–5–7–3
|
||||||
|
dom7Root: { rootStr: 4, offsets: ['x', 'x', 0, 2, 1, 2], fingers: [0, 0, 1, 3, 2, 4] }, // R–5–♭7–3
|
||||||
|
min7Root: { rootStr: 4, offsets: ['x', 'x', 0, 2, 1, 1], fingers: [0, 0, 1, 4, 2, 3] }, // R–5–♭7–♭3
|
||||||
|
halfDimRoot: { rootStr: 4, offsets: ['x', 'x', 0, 1, 1, 1], fingers: [0, 0, 1, 2, 3, 4] }, // R–♭5–♭7–♭3
|
||||||
|
min7Inv3: { rootStr: 1, offsets: ['x', 'x', 0, 0, 0, 0], fingers: [0, 0, 1, 1, 1, 1] }, // ♭7–♭3–5–R (one-finger barre)
|
||||||
|
dom7Inv2: { rootStr: 2, offsets: ['x', 'x', 1, 2, 0, 2], fingers: [0, 0, 2, 3, 1, 4] }, // 3–♭7–R–5
|
||||||
|
maj7Inv2: { rootStr: 1, offsets: ['x', 'x', 1, 1, 0, 0], fingers: [0, 0, 2, 3, 1, 1] }, // 7–3–5–R
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
styleIntro:
|
||||||
|
'In a jazz jam the guitar is part of the rhythm section: small voicings built on 3rds and 7ths, placed around the soloist, never on top of the piano. The fifths and often the roots are someone else\'s job — your two guide tones carry the whole harmony.',
|
||||||
|
|
||||||
|
comping: [
|
||||||
|
{
|
||||||
|
label: 'Four-to-the-bar (Freddie Green)',
|
||||||
|
rhythm: '♩ ♩ ♩ ♩',
|
||||||
|
description: 'Short, percussive quarter-note strums on all four beats, slight accent on 2 and 4 — the Count Basie pulse. Damp the unused strings; the chunk matters more than the chord.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Charleston',
|
||||||
|
rhythm: '𝅗𝅥. + "and of 2"',
|
||||||
|
description: 'Hit on beat 1 (held) plus a stab on the and-of-2 — the foundational syncopated comping cell. Displace it ("and of 1" + beat 3) for forward motion.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'The push (anticipated and-of-4)',
|
||||||
|
rhythm: 'tied from "and of 4"',
|
||||||
|
description: 'Strike the next bar\'s chord an eighth note early and tie it over the barline — the standard jazz anticipation. Telegraphs the change to the whole band.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plays: {
|
||||||
|
'jazz-251-major': [
|
||||||
|
{
|
||||||
|
label: 'Shell voicings, guide-tone glue',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL_5.min7, note: 'R–♭3–♭7' },
|
||||||
|
{ shape: SHELL_6.dom7, note: '♭7 of ii holds; ♭3 falls a half-step to become the 3rd' },
|
||||||
|
{ shape: SHELL_5.maj7, note: '♭7 of V falls a half-step to the 3rd; the other voice holds' },
|
||||||
|
],
|
||||||
|
tips: 'Only the roots jump — both upper voices move 0 or 1 fret across the whole progression. Watch the D and G strings: that two-note thread is the ii–V–I.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Drop-2 in one position (top four strings)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: DROP2.min7Inv3, note: 'one-finger barre: ♭7–♭3–5–R' },
|
||||||
|
{ shape: DROP2.dom7Inv2, note: 'every voice moves 0–2 frets' },
|
||||||
|
{ shape: DROP2.maj7Inv2, note: 'lands with the root on top' },
|
||||||
|
],
|
||||||
|
tips: 'The whole progression sits in one 3-fret window with no position jump — ideal when a piano is holding the low end. Great behind a singer: high, thin, out of the way.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'jazz-251-minor': [
|
||||||
|
{
|
||||||
|
label: 'Shell voicings with the ♭5 voiced',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL_6.half_dim, note: 'the ♭5 on the B string is the colour — don\'t skip it' },
|
||||||
|
{ shape: SHELL_5.dom7, extensions: ['b9'], note: 'add the ♭9 a fret above the root for the full minor-key sound' },
|
||||||
|
{ shape: SHELL_6.min7, note: 'home — resolve and get light' },
|
||||||
|
],
|
||||||
|
tips: 'The ♭5 of the iiø7 *is* the ♭9 of the V7 — same pitch, reinterpreted. Find it once, hold it through both chords.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Drop-2, top-four strings',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: DROP2.halfDimRoot, note: 'root + one-finger barre' },
|
||||||
|
{ shape: DROP2.dom7Inv2, note: '' },
|
||||||
|
{ shape: DROP2.min7Inv3, note: 'one-finger barre to rest on' },
|
||||||
|
],
|
||||||
|
tips: 'Both barre grips bookending this make the iiø7 the only real stretch — practise the V7 grip as the pivot between them.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'jazz-rhythm-a': [
|
||||||
|
{
|
||||||
|
label: 'Shells, four-to-the-bar',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL_6.maj7, note: '' },
|
||||||
|
{ shape: SHELL_5.min7, note: '' },
|
||||||
|
{ shape: SHELL_5.min7, note: 'same grip, two frets down from vi' },
|
||||||
|
{ shape: SHELL_6.dom7, note: '' },
|
||||||
|
],
|
||||||
|
tips: 'One chord per bar, four chunks per bar, Freddie Green style. At rhythm-changes tempo the small shapes are the only ones that keep up.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Drop-2 turnaround, upper register',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: DROP2.maj7Root, note: '' },
|
||||||
|
{ shape: DROP2.min7Inv3, note: '' },
|
||||||
|
{ shape: DROP2.min7Root, note: '' },
|
||||||
|
{ shape: DROP2.dom7Inv2, note: '' },
|
||||||
|
],
|
||||||
|
tips: 'A loop, not a line — bar 4 feeds bar 1. Practise it as one circular hand motion until the join disappears.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'jazz-625': [
|
||||||
|
{
|
||||||
|
label: 'Shells, alternating root strings',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL_6.min7, note: '' },
|
||||||
|
{ shape: SHELL_5.min7, note: '' },
|
||||||
|
{ shape: SHELL_6.dom7, note: '' },
|
||||||
|
{ shape: SHELL_5.maj7, note: '' },
|
||||||
|
],
|
||||||
|
tips: 'Roots falling in fifths alternate 6th string → 5th string at the same fret — the progression stays in one position by construction. This is why shells were built for circle-of-fifths tunes.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Drop-2 circle, top-four strings',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: DROP2.min7Inv3, note: '' },
|
||||||
|
{ shape: DROP2.min7Root, note: '' },
|
||||||
|
{ shape: DROP2.dom7Inv2, note: '' },
|
||||||
|
{ shape: DROP2.maj7Inv2, note: '' },
|
||||||
|
],
|
||||||
|
tips: 'Sing the top note of each grip as you move — drop-2 makes the melody line on the e string audible, and that line is what the soloist hears from you.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'jazz-tritone-sub': [
|
||||||
|
{
|
||||||
|
label: 'Shells down one string (chromatic slide)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL_5.min7, note: 'ii7 — root on the A string' },
|
||||||
|
{ shape: SHELL_5.dom7, note: '♭II7 — two fingers drop a fret; the guide-tone finger stays put' },
|
||||||
|
{ shape: SHELL_5.maj7, note: 'Imaj7 — one more fret down: home' },
|
||||||
|
],
|
||||||
|
tips: 'The tritone sub turns the ii–V–I bass into a one-string chromatic slide: 2 → ♭2 → 1, one fret per chord. It works because ♭II7 and V7 share their guide tones — the 3rd and ♭7 swap names (in C: Db7 has F and B, exactly G7\'s B and F). Target the ♭II7\'s 3rd when soloing; it is the old V7\'s ♭7.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Drop-2 slide, top four strings',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: DROP2.min7Root, note: 'ii7 — root position, root on the D string' },
|
||||||
|
{ shape: DROP2.dom7Root, note: '♭II7 — same architecture, one fret down' },
|
||||||
|
{ shape: DROP2.maj7Root, note: 'Imaj7 — one more fret down: three voices fall a half step, one holds' },
|
||||||
|
],
|
||||||
|
tips: 'The upper-register version: three root-position drop-2 grips, roots falling a fret at a time. Each change is three voices dropping a half step around one common tone — the ii7\'s ♭3 is held to become the ♭II7\'s 3rd, then the ♭II7\'s ♭7 is held to become the Imaj7\'s 7. High and thin, it stays out of the piano\'s way, and the top string sings the guide-tone line: held into the ♭II7, falling a half step into home.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'jazz-rhythm-bridge': [
|
||||||
|
{
|
||||||
|
label: 'Shells around the circle',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL_6.dom7, note: 'III7 — V7 of vi, the first domino' },
|
||||||
|
{ shape: SHELL_5.dom7, note: 'VI7 — root string up, nearly the same fret' },
|
||||||
|
{ shape: SHELL_6.dom7, note: 'II7 — the pattern repeats a whole step down' },
|
||||||
|
{ shape: SHELL_5.dom7, note: 'V7 — hands you back the A section' },
|
||||||
|
],
|
||||||
|
tips: 'One grip pair, four chords: roots falling in fifths alternate 6th → 5th string at almost the same fret, so the whole bridge sits in one position. Each chord is the V of the next — comp two bars each and voice-lead the ♭7 falling a half step onto the next chord\'s 3rd.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Drop-2, alternating inversions (top four strings)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: DROP2.dom7Inv2, note: 'III7 — 3rd in the bass voice' },
|
||||||
|
{ shape: DROP2.dom7Root, note: 'VI7 — nearest root-position grip' },
|
||||||
|
{ shape: DROP2.dom7Inv2, note: 'II7 — back to the inversion' },
|
||||||
|
{ shape: DROP2.dom7Root, note: 'V7 — root position into the turnaround' },
|
||||||
|
],
|
||||||
|
tips: 'Alternating the 3rd-in-the-bass grip with root-position drop-2 keeps every change a short hop — root-position-only would force five-fret jumps around this circle. Two bars per chord is room to decorate: restrike on the Charleston, or walk the top voice.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'jazz-blues': [
|
||||||
|
{
|
||||||
|
label: 'Shells through the form',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: SHELL_6.dom7, note: 'I7 — root on the 6th string' },
|
||||||
|
{ shape: SHELL_5.dom7, note: 'IV7 — same fret, root string up' },
|
||||||
|
{ shape: SHELL_6.dom7, note: '' },
|
||||||
|
{ shape: SHELL_6.dom7, note: '' },
|
||||||
|
{ shape: SHELL_5.dom7, note: '' },
|
||||||
|
{ shape: SHELL_5.dom7, note: '' },
|
||||||
|
{ shape: SHELL_6.dom7, note: '' },
|
||||||
|
{ shape: SHELL_5.dom7, note: 'VI7 — the jazz move; hear bar 8 coming' },
|
||||||
|
{ shape: SHELL_5.min7, note: 'ii7 of the turnaround' },
|
||||||
|
{ shape: SHELL_6.dom7, note: 'V7' },
|
||||||
|
{ shape: SHELL_6.dom7, note: 'home' },
|
||||||
|
{ shape: SHELL_6.dom7, note: 'V7 pickup into the next chorus' },
|
||||||
|
],
|
||||||
|
tips: 'I7 and IV7 sit at the same fret on adjacent root strings — the first four bars are a two-finger-move exercise. Keep everything within two frets of the I.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Drop-2 blues, top-four strings',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: DROP2.dom7Root, note: '' },
|
||||||
|
{ shape: DROP2.dom7Inv2, note: 'IV7 without leaving the position' },
|
||||||
|
{ shape: DROP2.dom7Root, note: '' },
|
||||||
|
{ shape: DROP2.dom7Root, note: '' },
|
||||||
|
{ shape: DROP2.dom7Inv2, note: '' },
|
||||||
|
{ shape: DROP2.dom7Inv2, note: '' },
|
||||||
|
{ shape: DROP2.dom7Root, note: '' },
|
||||||
|
{ shape: DROP2.dom7Inv2, note: 'VI7' },
|
||||||
|
{ shape: DROP2.min7Inv3, note: '' },
|
||||||
|
{ shape: DROP2.dom7Inv2, note: '' },
|
||||||
|
{ shape: DROP2.dom7Root, note: '' },
|
||||||
|
{ shape: DROP2.dom7Inv2, note: '' },
|
||||||
|
],
|
||||||
|
tips: 'Comping above the 7th fret leaves the whole low end to bass and piano — the classic organ-trio guitar register. Charleston rhythm, not four-to-the-bar, up here.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
improv: {
|
||||||
|
scales: [
|
||||||
|
{ over: 'ii7', scale: 'dorian', why: 'Minor 7 chords in a major key take Dorian — the natural 6 keeps it from sounding sad.' },
|
||||||
|
{ over: 'V7', scale: 'mixolydian', why: 'The ♭7 is built in; in minor keys use Phrygian dominant (harmonic minor from the V) for the ♭9 sound.' },
|
||||||
|
{ over: 'Imaj7', scale: 'major', why: 'Plain major works; avoid sitting on the 4th over the maj7.' },
|
||||||
|
{ over: 'I7 (blues)', scale: 'mixolydian', why: 'Mix with the blues scale — Mixolydian for the changes, blues scale for the attitude.' },
|
||||||
|
{ over: 'iiø7', scale: 'locrian', why: 'Target the ♭3 or ♭5; the ♭5 becomes the ♭9 of the next V7.' },
|
||||||
|
],
|
||||||
|
targetNotes:
|
||||||
|
'Land the 3rd of each chord on the downbeat of the change. In any ii–V–I the 7th of one chord falls a half-step to the 3rd of the next — that two-note rail is the whole map.',
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
over: 'jazz-251-major',
|
||||||
|
description: '"The Lick" — the most famous ii–V cliché in jazz (Parker, Coltrane, everyone). Degrees 1–2–♭3–4–2–♭7–1 over the ii chord.',
|
||||||
|
tab: 'e|--------------------------\nB|--------------------------\nG|--------------------------\nD|----2--3--5--2------------\nA|-5--------------3--5------\nE|--------------------------\n D E F G E C D (over Dm7 in C)',
|
||||||
|
source: 'Wikipedia: "The Lick"; Alex Heitlinger compilation (2011)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'jazz-251-major',
|
||||||
|
description: 'Stock bebop ii–V–I: ii arpeggio up, then the 3–5–♭7–♭9 diminished arpeggio over the V7 (B–D–F–A♭ over G7), resolving half-step into the I.',
|
||||||
|
tab: 'e|--------------------------|------------4--3---------|--------\nB|----------------5--3------|----3--6----------6--3----|--1-----\nG|-------------5---------5--|-4---------------------4--|--------\nD|----3--7---------------7--|--------------------------|--------\nA|-5------------------------|--------------------------|--------\nE|--------------------------|--------------------------|--------\n Dm7 arpeggio + 9th G7: 3-5-♭7-♭9 dim arp Cmaj7',
|
||||||
|
source: 'David Baker, How to Play Bebop Vol. 1; jazzguitar.be "50 Bebop Licks"',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Structured licks (SCHEMA.md "Licks", task P-21). Written over a ii–V–I in C
|
||||||
|
// (Dm7–G7–Cmaj7) but key-agnostic in spirit — chordContext names the station.
|
||||||
|
// Every pitch hand-verified: s6=E s5=A s4=D s3=G s2=B s1=e (+fret, mod 12).
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
// In D dorian: D E F G E C D — degrees 1 2 ♭3 4 2 ♭7 1 of the ii7.
|
||||||
|
id: 'jazz-the-lick',
|
||||||
|
name: 'The Lick',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'over the ii7',
|
||||||
|
techniques: ['vibrato'],
|
||||||
|
source: 'the most famous ii-chord cliché in jazz — Wikipedia "The Lick"; Alex Heitlinger\'s 2011 supercut',
|
||||||
|
tab: [
|
||||||
|
{ string: 5, fret: 5 }, // D — 1
|
||||||
|
{ string: 4, fret: 2 }, // E — 2
|
||||||
|
{ string: 4, fret: 3 }, // F — ♭3
|
||||||
|
{ string: 4, fret: 5 }, // G — 4
|
||||||
|
{ string: 4, fret: 2 }, // E — 2
|
||||||
|
{ string: 5, fret: 3 }, // C — ♭7
|
||||||
|
{ string: 5, fret: 5, technique: 'vibrato' }, // D — home
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In C: Dm7 arpeggio (D F A C), G7 guide tones (B F), then the bebop
|
||||||
|
// enclosure F–D♯–E — above diatonically, below chromatically — onto the
|
||||||
|
// Imaj7's 3rd on the downbeat.
|
||||||
|
id: 'jazz-enclosure-251',
|
||||||
|
name: 'ii–V–I enclosure into the 3rd',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'ii7 → V7 → Imaj7',
|
||||||
|
techniques: ['chromatic-approach', 'vibrato'],
|
||||||
|
source: 'in the style of bebop enclosure exercises — David Baker, How to Play Bebop Vol. 1; jazzguitar.be enclosure lessons',
|
||||||
|
tab: [
|
||||||
|
{ string: 5, fret: 5 }, // D — ii7 root
|
||||||
|
{ string: 4, fret: 3 }, // F — ♭3
|
||||||
|
{ string: 3, fret: 2 }, // A — 5
|
||||||
|
{ string: 3, fret: 5 }, // C — ♭7
|
||||||
|
{ string: 3, fret: 4 }, // B — the V7's 3rd, right at the change
|
||||||
|
{ string: 2, fret: 6 }, // F — the V7's ♭7 (upper neighbour of the target)
|
||||||
|
{ string: 2, fret: 4, technique: 'chromatic-approach' }, // D♯ — chromatic from below
|
||||||
|
{ string: 2, fret: 5, technique: 'vibrato' }, // E — the Imaj7's 3rd: enclosed and landed
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Descending G bebop dominant from the root: G F♯ F E D C B A G —
|
||||||
|
// Mixolydian plus the chromatic passing 7 so chord tones fall on strong
|
||||||
|
// beats in straight eighths.
|
||||||
|
id: 'jazz-bebop-dom-descent',
|
||||||
|
name: 'Bebop dominant descent',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'dom7 — e.g. the V7',
|
||||||
|
techniques: ['chromatic-approach', 'vibrato'],
|
||||||
|
source: 'the bebop dominant scale — David Baker, How to Play Bebop Vol. 1',
|
||||||
|
tab: [
|
||||||
|
{ string: 1, fret: 3 }, // G — root
|
||||||
|
{ string: 1, fret: 2, technique: 'chromatic-approach' }, // F♯ — the added passing tone
|
||||||
|
{ string: 1, fret: 1 }, // F — ♭7
|
||||||
|
{ string: 1, fret: 0 }, // E — 13
|
||||||
|
{ string: 2, fret: 3 }, // D — 5
|
||||||
|
{ string: 2, fret: 1 }, // C — passing 11
|
||||||
|
{ string: 3, fret: 4 }, // B — 3
|
||||||
|
{ string: 3, fret: 2 }, // A — 9
|
||||||
|
{ string: 4, fret: 5, technique: 'vibrato' }, // G — root, an octave down
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In C, as dyads: Dm7's ♭3+♭7 (F+C) → the ♭7 falls a half step to the
|
||||||
|
// V7's 3rd (B, F holds) → the held F falls a half step to the Imaj7's
|
||||||
|
// 3rd (E, B holds as the 7). The 7→3 rails, played literally.
|
||||||
|
id: 'jazz-guide-tone-rail',
|
||||||
|
name: 'Guide-tone rail (7 falls to 3)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'ii7 → V7 → Imaj7 guide tones',
|
||||||
|
techniques: ['double-stop', 'slide'],
|
||||||
|
source: 'guide-tone comping lines — jazzguitar.be guide-tone lessons; Jens Larsen voice-leading studies',
|
||||||
|
tab: [
|
||||||
|
{ string: 2, fret: 6 }, // F — the ii7's ♭3
|
||||||
|
{ string: 3, fret: 5, technique: 'double-stop' }, // C — the ii7's ♭7 (dyad)
|
||||||
|
{ string: 3, fret: 4, technique: 'slide' }, // B — C falls a half step: the V7's 3rd
|
||||||
|
{ string: 2, fret: 6, technique: 'double-stop' }, // F — held, renamed the V7's ♭7
|
||||||
|
{ string: 2, fret: 5, technique: 'slide' }, // E — F falls a half step: the Imaj7's 3rd
|
||||||
|
{ string: 3, fret: 4, technique: 'double-stop' }, // B — held, now the Imaj7's 7
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
id: 'jazz',
|
||||||
|
label: 'Jazz',
|
||||||
|
feel: 'swing',
|
||||||
|
tempoRange: [110, 230],
|
||||||
|
character: 'Harmony in constant motion — 7th chords everywhere, 3rds and 7ths doing the voice-leading work, rhythm section breathing around the soloist.',
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
// Jazz piano pack — first authored piano cell (task P-22). Recipes verified by
|
||||||
|
// degree-spelling against CHORD_TYPES (src/lib/theory.js) and SCHEMA.md's
|
||||||
|
// resolveDegree contract. Voicings sourced from: Mark Levine, The Jazz Piano
|
||||||
|
// Book (three-note "Bud Powell" shells; rootless left-hand voicings; altered
|
||||||
|
// dominants in minor); learnjazzstandards.com "Left-Hand Piano Voicings for
|
||||||
|
// ii-V7-Is" (the middle-C position set F-A-C-E / F-A-B-E / E-G-B-D);
|
||||||
|
// piano.org "Rootless Voicings: Type A and Type B (Bill Evans Style)"
|
||||||
|
// (A = 3rd on the bottom 3-5-7-9, B = 7th on the bottom 7-9-3-5, dominants
|
||||||
|
// swap the 5 for the 13); pianogroove.com (minor ii-V-i, ø11 colour);
|
||||||
|
// jazz-library.com/articles/comping (Charleston; Red Garland offbeats).
|
||||||
|
// Pedagogy frame: docs/learn-curriculum.md — Piano pillar (shells → rootless,
|
||||||
|
// register discipline, "rule of 1").
|
||||||
|
|
||||||
|
// Reusable degree recipes. Order inside each hand = voicing order, low → high.
|
||||||
|
const SHELL_R7 = { LH: ['1', '7'] } // Bud Powell shell: root + 7th
|
||||||
|
const SHELL_R3 = { LH: ['1', '3'] } // Bud Powell shell: root + 3rd
|
||||||
|
const A_FORM = { LH: ['3', '5', '7', '9'] } // rootless Type A (3rd on bottom)
|
||||||
|
const B_FORM = { LH: ['7', '9', '3', '5'] } // rootless Type B (7th on bottom)
|
||||||
|
const A_FORM_DOM = { LH: ['3', '13', '7', '9'] } // dominant Type A: 5 → 13
|
||||||
|
const B_FORM_DOM = { LH: ['7', '9', '3', '13'] } // dominant Type B: 5 → 13
|
||||||
|
|
||||||
|
export default {
|
||||||
|
styleIntro:
|
||||||
|
'In a jazz rhythm section the piano is a commentator, not a metronome: short voicings placed around the soloist, roots left to the bassist, colour tones (9ths, 13ths) doing the talking. Left hand learns two grips per chord — a shell and a rootless voicing — and the music comes from where you place them in time.',
|
||||||
|
|
||||||
|
comping: [
|
||||||
|
{
|
||||||
|
label: 'Charleston',
|
||||||
|
rhythm: '♩. + "and of 2"',
|
||||||
|
description: 'Chord on beat 1 (held) plus a stab on the and-of-2 — the foundational syncopated comping cell. Displace it around the bar once it is automatic; comping failure in jams is usually rhythmic, not harmonic.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Red Garland offbeats',
|
||||||
|
rhythm: '"and of 2" + "and of 4"',
|
||||||
|
description: 'Both hits off the beat — the Miles Davis quintet sound. The bassist and drummer own the downbeats; you answer them. Keep the voicings short and identical so only the rhythm speaks.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'The push (anticipated and-of-4)',
|
||||||
|
rhythm: 'tied from "and of 4"',
|
||||||
|
description: 'Strike the next bar\'s chord an eighth note early and hold it over the barline. Telegraphs the change to the whole band — use it going into bar 1 of the form.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plays: {
|
||||||
|
'jazz-251-major': [
|
||||||
|
{
|
||||||
|
label: 'Bud Powell shells (LH 1–7 / 1–3)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: SHELL_R7, note: 'root + ♭7 — the lightest possible ii chord' },
|
||||||
|
{ recipe: SHELL_R3, note: 'the ii\'s ♭7 just fell a half-step to become this 3rd' },
|
||||||
|
{ recipe: SHELL_R7, note: 'the V\'s 3rd holds over as the maj7 — one finger stays put' },
|
||||||
|
],
|
||||||
|
register: 'LH around C3; keep the right hand free for melody or answers',
|
||||||
|
tips: 'Alternating 1–7 and 1–3 shells is the whole voice-leading engine: the top note either holds or falls a half-step, never jumps. Two notes is not "beginner" — it is what Bud Powell played behind Charlie Parker. In a duo the root matters; once a bassist arrives, graduate to the rootless play below.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Rootless A–B–A (Bill Evans left hand)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: A_FORM, note: 'Type A: ♭3–5–♭7–9, 3rd on the bottom' },
|
||||||
|
{ recipe: B_FORM_DOM, note: 'Type B: ♭7–9–3–13 — only one finger moves from the ii' },
|
||||||
|
{ recipe: A_FORM, note: 'back to Type A: 3–5–7–9, a maj9 sound' },
|
||||||
|
],
|
||||||
|
register: 'top note between C4 and C5; the bassist owns everything below G3',
|
||||||
|
tips: 'A on the ii, B on the V, A on the I: alternate the two types and the hand barely moves — that is the entire point of the system. No roots anywhere: that is the bassist\'s lane, and doubling it muddies the band. Practise the pair in all 12 keys around the circle of fifths.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'jazz-251-minor': [
|
||||||
|
{
|
||||||
|
label: 'Shells with the colour on top',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: ['1', '7'], RH: ['3', '5'] }, note: 'RH ♭3 + ♭5 — the ø colour; don\'t skip the ♭5' },
|
||||||
|
{ recipe: { LH: ['1', '7'], RH: ['3', 'b9'] }, note: 'the ♭9 is the same key the iiø7\'s ♭5 just was — hold it' },
|
||||||
|
{ recipe: { LH: ['1', '7'], RH: ['3', '5'] }, note: 'home — resolve and get light' },
|
||||||
|
],
|
||||||
|
register: 'LH shells around C3, RH colour tones just above middle C',
|
||||||
|
tips: 'Hand-role splitting in miniature: LH anchors root + 7th, RH carries the two notes that make the minor ii–V dark. The physical lesson is the shared key: the iiø7\'s ♭5 and the V7\'s ♭9 are the same pitch reinterpreted — find it once and keep the finger there through both chords.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Rootless with the altered V',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: ['3', '5', '7', '11'] }, note: '♭3–♭5–♭7–11 — the 11 is the classic colour on ø chords' },
|
||||||
|
{ recipe: { LH: ['7', 'b9', '3', 'b13'] }, note: '♭7–♭9–3–♭13: the altered dominant that minor keys demand' },
|
||||||
|
{ recipe: A_FORM, note: '♭3–5–♭7–9 — a m9 sound; resolved but still coloured' },
|
||||||
|
],
|
||||||
|
register: 'top note between C4 and C5',
|
||||||
|
tips: 'Same A/B logic as the major ii–V–I, darker fuel: the V7 swaps its 9 and 13 for ♭9 and ♭13 (both live in the key\'s harmonic minor). If the four-note ø voicing feels crunchy, drop the 11 and play the three-note core ♭3–♭5–♭7 — the shells play above is the easier road into this one.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'jazz-rhythm-a': [
|
||||||
|
{
|
||||||
|
label: 'Shells on the Charleston',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: SHELL_R7, note: 'maj7 shell — root + 7' },
|
||||||
|
{ recipe: SHELL_R3, note: 'the I\'s 7th steps up a half-step to the vi\'s ♭3' },
|
||||||
|
{ recipe: SHELL_R7, note: 'the vi\'s ♭3 holds over as the ii\'s ♭7 — same key' },
|
||||||
|
{ recipe: SHELL_R3, note: 'falls a half-step onto the V\'s 3rd, which leads back to bar 1' },
|
||||||
|
],
|
||||||
|
register: 'LH around C3; Charleston rhythm, one cell per bar',
|
||||||
|
tips: 'One chord per bar at rhythm-changes tempo — the two-note grips are the only ones that keep up. Learn the top-voice thread as a loop: hold, half-step, hold, half-step, forever. Rhythm first: put the metronome on 2 and 4 and drill the Charleston until the hands stop negotiating.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Rootless turnaround in one hand position',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: A_FORM, note: 'Type A on the I' },
|
||||||
|
{ recipe: B_FORM, note: 'Type B on the vi — two fingers move, two hold' },
|
||||||
|
{ recipe: A_FORM, note: 'Type A on the ii' },
|
||||||
|
{ recipe: B_FORM_DOM, note: 'Type B on the V — one finger moves; bar 4 feeds bar 1' },
|
||||||
|
],
|
||||||
|
register: 'top note between C4 and C5',
|
||||||
|
tips: 'A–B–A–B around the loop keeps most changes to a finger or two — two keys hold into the vi and again into the ii, and only the seam back into bar 1 re-sets three fingers, each by a whole step or less. This is the "rule of 1" made physical: in a five-piece band you supply one fifth of the music, and identical small voicings placed on Red Garland offbeats are exactly that fifth.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'jazz-625': [
|
||||||
|
{
|
||||||
|
label: 'Shells falling in fifths',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: SHELL_R7, note: 'vi — root + ♭7' },
|
||||||
|
{ recipe: SHELL_R3, note: 'the vi\'s ♭7 falls a whole step onto the ii\'s ♭3' },
|
||||||
|
{ recipe: SHELL_R7, note: 'ii\'s ♭3 holds over as the V\'s ♭7' },
|
||||||
|
{ recipe: SHELL_R3, note: 'half-step fall onto the I\'s 3rd — journey over' },
|
||||||
|
],
|
||||||
|
register: 'LH around C3',
|
||||||
|
tips: 'Pure circle-of-fifths motion: alternating 1–7 / 1–3 shells was built for exactly this — every root falls a fifth while the top voice walks down by step. Sing the top voice while you play; if you can hear this two-note line, you can predict half the jazz repertoire.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Rootless B–A–B–A circle',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: B_FORM, note: 'Type B on the vi — ♭7 on the bottom' },
|
||||||
|
{ recipe: A_FORM, note: 'Type A on the ii — two voices fall a step, two hold' },
|
||||||
|
{ recipe: B_FORM_DOM, note: 'Type B on the V — a single half-step move' },
|
||||||
|
{ recipe: A_FORM, note: 'Type A on the I — maj9 landing' },
|
||||||
|
],
|
||||||
|
register: 'top note between C4 and C5',
|
||||||
|
tips: 'Starting on the B form instead of A puts the whole circle a fourth away — same alternation, different lane. Learn both start positions so you can pick whichever keeps the top note between C4 and C5 in the key of the night; that window sits above the bass and below the soloist.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'jazz-blues': [
|
||||||
|
{
|
||||||
|
label: 'Shells through the form',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: SHELL_R7, note: 'I7 — root + ♭7' },
|
||||||
|
{ recipe: SHELL_R3, note: 'IV7 — the I\'s ♭7 falls a half-step to this 3rd' },
|
||||||
|
{ recipe: SHELL_R7, note: 'back home' },
|
||||||
|
{ recipe: SHELL_R7, note: 'hold — or restrike on the and-of-2' },
|
||||||
|
{ recipe: SHELL_R3, note: 'IV7 again — same half-step fall' },
|
||||||
|
{ recipe: SHELL_R3, note: 'sit on it' },
|
||||||
|
{ recipe: SHELL_R7, note: 'home' },
|
||||||
|
{ recipe: SHELL_R3, note: 'VI7 — the jazz move; hear bar 8 coming' },
|
||||||
|
{ recipe: SHELL_R7, note: 'ii7 of the turnaround' },
|
||||||
|
{ recipe: SHELL_R3, note: 'V7 — the ii\'s ♭7 falls a half-step onto this 3rd' },
|
||||||
|
{ recipe: SHELL_R7, note: 'home' },
|
||||||
|
{ recipe: SHELL_R3, note: 'V7 pickup into the next chorus' },
|
||||||
|
],
|
||||||
|
register: 'LH around C3; RH free for blues fills between phrases',
|
||||||
|
tips: 'Most of the form runs on one alternation: 1–7 on the I, 1–3 on the chord it falls into — those tops arrive by half-step. Bar 8\'s VI7 is the one real reach in the chorus; aim for it early. Fill with the right hand only in the gaps the soloist leaves (rule of 1).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Rootless blues (Wynton Kelly lane)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: A_FORM_DOM, note: 'Type A dominant: 3–13–♭7–9' },
|
||||||
|
{ recipe: B_FORM_DOM, note: 'Type B on the IV7 — two fingers slide, two hold' },
|
||||||
|
{ recipe: A_FORM_DOM, note: '' },
|
||||||
|
{ recipe: A_FORM_DOM, note: 'thin it to just 3 + ♭7 if the band is loud' },
|
||||||
|
{ recipe: B_FORM_DOM, note: '' },
|
||||||
|
{ recipe: B_FORM_DOM, note: '' },
|
||||||
|
{ recipe: A_FORM_DOM, note: '' },
|
||||||
|
{ recipe: { LH: ['3', '7', 'b9'] }, note: 'VI7♭9 — the V-of-ii sound; the ♭9 pulls into the next bar' },
|
||||||
|
{ recipe: A_FORM, note: 'ii7 — Type A minor' },
|
||||||
|
{ recipe: B_FORM_DOM, note: 'V7 — Type B, one finger from the ii' },
|
||||||
|
{ recipe: A_FORM_DOM, note: 'home' },
|
||||||
|
{ recipe: B_FORM_DOM, note: 'V7 — hands you the next chorus' },
|
||||||
|
],
|
||||||
|
register: 'top note between C4 and C5; comp Red Garland offbeats',
|
||||||
|
tips: 'Dominant rootless voicings carry two colour tones each (9 and 13) — that is why a jazz blues sounds like a suit, not overalls. Between I7 and IV7 the A/B alternation trades like this: two keys hold while swapping names, one voice slips a half-step (the 3↔♭7 trade), and one finger hops a minor third. Bar 8\'s ♭9 is the one dark note in the form: let it ring into the ii.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'jazz-tritone-sub': [
|
||||||
|
{
|
||||||
|
label: 'Guide tones over the chromatic bass',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: ['1'], RH: ['3', '7'] }, note: 'ii7 — bass on 2, guide tones ♭3 + ♭7 on top' },
|
||||||
|
{ recipe: { LH: ['1'], RH: ['3', '7'] }, note: '♭II7 — bass slides to ♭2; the ii\'s ♭3 holds as this 3rd' },
|
||||||
|
{ recipe: { LH: ['1'], RH: ['3', '7'] }, note: 'Imaj7 — bass lands on 1; the ♭II7\'s ♭7 holds as this 7' },
|
||||||
|
],
|
||||||
|
register: 'LH single bass notes around C3, RH guide tones just above middle C',
|
||||||
|
tips: 'The sub turns the bass line into a chromatic slide — 2, ♭2, 1 — while the right hand proves *why* it works: at every change one guide tone holds and the other falls a half-step, because ♭II7 and V7 share the same tritone (the 3rd and ♭7 simply trade names). Play it slow and listen for the two threads.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Rootless: the ♭II7 is a free altered V7',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: A_FORM, note: 'ii7 — Type A' },
|
||||||
|
{ recipe: A_FORM_DOM, note: '♭II7 Type A — read from the old V7\'s root, this exact hand spells ♭7–♯9–3–♭13: a V7alt for free' },
|
||||||
|
{ recipe: A_FORM, note: 'Imaj7 — Type A; the ♭II7\'s ♭7 holds over as your maj7' },
|
||||||
|
],
|
||||||
|
register: 'top note between C4 and C5',
|
||||||
|
tips: 'This is the deepest lesson in the substitution: a plain 9/13 rootless voicing on the ♭II7 contains, note for note, the altered voicing of the V7 it replaced — ♭9s and ♭13s appear without you learning a single new grip. Into the ♭II7 every voice holds or moves a half-step — that slide is the sub\'s whole sales pitch. Coming home, three voices resolve the same way while one finger (the sub\'s 13) drops a third onto the I\'s 5th.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'jazz-rhythm-bridge': [
|
||||||
|
{
|
||||||
|
label: 'Shells around the circle of dominants',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: SHELL_R7, note: 'III7 — the first domino; two bars to sit on it' },
|
||||||
|
{ recipe: SHELL_R3, note: 'VI7 — the III7\'s ♭7 fell a half-step onto this 3rd' },
|
||||||
|
{ recipe: SHELL_R7, note: 'II7 — the pattern repeats a whole step down' },
|
||||||
|
{ recipe: SHELL_R3, note: 'V7 — one more half-step fall, then the A section' },
|
||||||
|
],
|
||||||
|
register: 'LH around C3; two bars per chord — leave space',
|
||||||
|
tips: 'Each chord is the V of the next, so the same two-grip alternation from the ii–V–I walks the whole bridge: ♭7 falls a half-step onto the next 3rd, four times in a row. That chromatic thread in your top voice is also the soloist\'s map — comp it clearly and you are conducting.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Rootless dominants, alternating types',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: A_FORM_DOM, note: 'III7 — Type A: 3–13–♭7–9' },
|
||||||
|
{ recipe: B_FORM_DOM, note: 'VI7 — Type B: two keys hold, the old 3rd falls a half-step, the old ♭7 drops a third onto the 9' },
|
||||||
|
{ recipe: A_FORM_DOM, note: 'II7 — back to Type A' },
|
||||||
|
{ recipe: B_FORM_DOM, note: 'V7 — Type B hands you the A section' },
|
||||||
|
],
|
||||||
|
register: 'top note between C4 and C5',
|
||||||
|
tips: 'Around a circle of dominants the A/B alternation keeps each step compact: at least one key holds, the old 3rd falls a half-step onto the new ♭7, and nothing moves more than a minor third. Two bars per chord is room to decorate — restrike the same voicing on the Charleston, or walk the top finger up to the 13 and back rather than reaching for a new chord.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
improv: {
|
||||||
|
scales: [
|
||||||
|
{ over: 'ii7', scale: 'dorian', why: 'Minor 7 chords in a major key take Dorian — the natural 6 keeps it from sounding sad. On piano it is the parent major scale started from 2: no new fingering.' },
|
||||||
|
{ over: 'V7', scale: 'mixolydian', why: 'The ♭7 is built in. In minor keys use Phrygian dominant (harmonic minor from the V) — it hands you the ♭9 and ♭13 your left hand is already voicing.' },
|
||||||
|
{ over: 'Imaj7', scale: 'major', why: 'Plain major works; avoid sitting on the 4th over the maj7 — it rubs against the 3rd a half-step below.' },
|
||||||
|
{ over: 'I7 (blues)', scale: 'mixolydian', why: 'Mixolydian for the changes, blues scale for the attitude — mix them phrase by phrase, not note by note.' },
|
||||||
|
{ over: 'iiø7', scale: 'locrian', why: 'Target the ♭3 or ♭5; the ♭5 becomes the ♭9 of the next V7 — the same pivot key your comping hand holds.' },
|
||||||
|
],
|
||||||
|
targetNotes:
|
||||||
|
'Land the 3rd of each chord on the downbeat of the change — your left hand is already holding it, so the ear-check is built in. In any ii–V–I the 7th of one chord falls a half-step to the 3rd of the next; play that two-note rail as a whole-note chorus before you play eighth notes.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Structured piano licks (SCHEMA.md "Piano licks", task P-60). Degree-based
|
||||||
|
// and key-agnostic: every deg resolves through the stated quality, approach
|
||||||
|
// pitches are derived from the next deg note. Realized offsets in the
|
||||||
|
// comments use a C-rooted chord for readability; every interval claim in
|
||||||
|
// notes/tips was recomputed from those offsets before writing (P-41 bar).
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
// Over Cm7: C5 B♭4 G4 (E4) (D4) E♭4 — offsets 12 10 7 [4] [2] 3. The
|
||||||
|
// approaches derive from the ♭3 target: above = 4 (the major 3rd, the
|
||||||
|
// outside note), below = 2 (the 9). Triplet pickup into beat 3.
|
||||||
|
id: 'jazz-enclosure-b3',
|
||||||
|
name: 'Enclosure into the ♭3',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'over the ii7',
|
||||||
|
quality: 'min7',
|
||||||
|
techniques: [],
|
||||||
|
source: 'the bebop enclosure — Barry Harris workshop vocabulary; David Baker, How to Play Bebop Vol. 1',
|
||||||
|
notes: [
|
||||||
|
{ deg: '1', octave: 1, beat: 1 },
|
||||||
|
{ deg: '7', beat: 1.5 },
|
||||||
|
{ deg: '5', beat: 2 },
|
||||||
|
{ approach: 'chrom-above', beat: 2.33 },
|
||||||
|
{ approach: 'chrom-below', beat: 2.67 },
|
||||||
|
{ deg: '3', beat: 3 },
|
||||||
|
],
|
||||||
|
tips: 'The line falls down the chord — octave root, ♭7, 5th — then the enclosure frames the ♭3 from a half-step either side: the upper neighbour is the major 3rd (deliberately outside on a minor chord), the lower is the 9. Land the target on a strong beat; the two "wrong" notes are what make the right one land.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Over C7: E4 G4 B♭4 D♭5 C5 — offsets 4 7 10 13 12: stacked minor
|
||||||
|
// thirds 4→7→10→13 (the dim7 arpeggio off the 3rd), then the ♭9
|
||||||
|
// falling a half-step onto the octave root.
|
||||||
|
id: 'jazz-dim-flip-v7',
|
||||||
|
name: '3–♭9 arpeggio over the V7',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'over the V7, resolving to the I',
|
||||||
|
quality: 'dom7',
|
||||||
|
techniques: [],
|
||||||
|
source: 'the 3–5–♭7–♭9 diminished arpeggio — David Baker, How to Play Bebop Vol. 1',
|
||||||
|
notes: [
|
||||||
|
{ deg: '3', beat: 1 },
|
||||||
|
{ deg: '5', beat: 1.5 },
|
||||||
|
{ deg: '7', beat: 2 },
|
||||||
|
{ deg: 'b9', octave: 1, beat: 2.5 },
|
||||||
|
{ deg: '1', octave: 1, beat: 3 },
|
||||||
|
],
|
||||||
|
tips: 'From the 3rd it is minor thirds all the way up — 3, 5, ♭7, ♭9 spell the diminished seventh chord that lives inside every dominant — and the ♭9 sighs a half-step back onto the octave root. When the chord changes, that landing note is renamed the I\'s 5th, so the lick resolves itself.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Over Cmaj7: C4 (E♭4) E4 G4 B4 D5 C5 — offsets 0 [3] 4 7 11 14 12.
|
||||||
|
// The chromatic slip derives from the 3rd (4−1=3); thirds 4→7→11→14;
|
||||||
|
// the 9 settles a whole step onto the octave root.
|
||||||
|
id: 'jazz-powell-ascent',
|
||||||
|
name: 'Powell ascent to the 9th',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'over the Imaj7',
|
||||||
|
quality: 'maj7',
|
||||||
|
techniques: [],
|
||||||
|
source: 'in the style of Bud Powell\'s right-hand arpeggio runs — Mark Levine, The Jazz Piano Book',
|
||||||
|
notes: [
|
||||||
|
{ deg: '1', beat: 1 },
|
||||||
|
{ approach: 'chrom-below', beat: 1.5 },
|
||||||
|
{ deg: '3', beat: 2 },
|
||||||
|
{ deg: '5', beat: 2.5 },
|
||||||
|
{ deg: '7', beat: 3 },
|
||||||
|
{ deg: '9', octave: 1, beat: 3.5 },
|
||||||
|
{ deg: '1', octave: 1, beat: 4 },
|
||||||
|
],
|
||||||
|
tips: 'One chromatic slip between the root and the 3rd, then nothing but thirds: 3–5–7–9 climbs the chord to the colour tone before the 9 relaxes a whole step onto the octave root. Arpeggiate to the 9th, not the root — the top of the line is where the chord gets its shine.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Over C7, dyads low+high struck together: B♭4+D5 → A4+C5 → G4+B♭4 →
|
||||||
|
// E4+G4 — offsets (10,14)(9,12)(7,10)(4,7). Every pair is a third
|
||||||
|
// (4,3,3,3 semitones); top voice 14→12→10→7, bottom 10→9→7→4; all
|
||||||
|
// eight notes diatonic to the dominant (Mixolydian) scale.
|
||||||
|
id: 'jazz-thirds-fall-dom',
|
||||||
|
name: 'Dominant thirds fall',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'over the V7, or any dominant bar of a jazz blues',
|
||||||
|
quality: 'dom7',
|
||||||
|
techniques: ['double-stop'],
|
||||||
|
source: 'the dominant scale in diatonic thirds — Barry Harris workshop vocabulary; placed like Red Garland\'s offbeat fills',
|
||||||
|
notes: [
|
||||||
|
{ deg: '7', beat: 1 },
|
||||||
|
{ deg: '9', octave: 1, beat: 1, technique: 'double-stop' },
|
||||||
|
{ deg: '13', beat: 1.5 },
|
||||||
|
{ deg: '1', octave: 1, beat: 1.5, technique: 'double-stop' },
|
||||||
|
{ deg: '5', beat: 2 },
|
||||||
|
{ deg: '7', beat: 2, technique: 'double-stop' },
|
||||||
|
{ deg: '3', beat: 2.5 },
|
||||||
|
{ deg: '5', beat: 2.5, technique: 'double-stop' },
|
||||||
|
],
|
||||||
|
tips: 'Four dyads, each one a third: the top voice walks 9–root–♭7–5 (two whole steps, then a skip) while the bottom answers ♭7–13–5–3 (a half-step, a whole step, then a skip), landing on the chord\'s 3rd-plus-5th. No finger travels more than a minor third between hits. Played short on the offbeats it is comping; let it ring on the beat and it is a fill — same four grips.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Level tags + the two intermediate progressions added by task P-20.
|
||||||
|
// Intermediate-entry theory verified against: musictheorymanual.com &
|
||||||
|
// learnjazzstandards.com (Girl from Ipanema tritone sub, ii–♭II7–I),
|
||||||
|
// pianogroove.com (Ipanema harmony/comping analysis), Wikipedia: Rhythm changes
|
||||||
|
// (bridge = III7–VI7–II7–V7 circle of dominants), docs/progression-repertoire.md §1.
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
id: 'jazz-251-major',
|
||||||
|
name: 'ii–V–I',
|
||||||
|
rn: ['ii7', 'V7', 'Imaj7'],
|
||||||
|
degrees: [2, 7, 0],
|
||||||
|
qualities: ['min7', 'dom7', 'maj7'],
|
||||||
|
bars: [1, 1, 2],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['All The Things You Are', 'Tune Up — Miles Davis', 'Honeysuckle Rose'],
|
||||||
|
tip: 'The 7th of each chord resolves down a half-step to the 3rd of the next — that two-note thread is the whole progression.',
|
||||||
|
level: 'foundation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jazz-251-minor',
|
||||||
|
name: 'minor ii–V–i',
|
||||||
|
rn: ['iiø7', 'V7', 'i7'],
|
||||||
|
degrees: [2, 7, 0],
|
||||||
|
qualities: ['half_dim', 'dom7', 'min7'],
|
||||||
|
bars: [1, 1, 2],
|
||||||
|
mode: 'minor',
|
||||||
|
songs: ['Autumn Leaves (bridge)', 'Blue Bossa', 'Beautiful Love'],
|
||||||
|
tip: 'Same engine as the major ii–V–I, darker fuel: the ♭5 of the iiø7 is the ♭9 colour waiting to happen on the V7.',
|
||||||
|
level: 'foundation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jazz-rhythm-a',
|
||||||
|
name: 'Rhythm changes turnaround',
|
||||||
|
rn: ['Imaj7', 'vi7', 'ii7', 'V7'],
|
||||||
|
degrees: [0, 9, 2, 7],
|
||||||
|
qualities: ['maj7', 'min7', 'min7', 'dom7'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['I Got Rhythm — Gershwin', 'Oleo — Sonny Rollins', 'Blue Moon'],
|
||||||
|
tip: 'A loop, not a line — bar 4 hands you straight back to bar 1. Learn it as one circular shape your hands repeat.',
|
||||||
|
level: 'foundation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jazz-625',
|
||||||
|
name: 'vi–ii–V–I circle',
|
||||||
|
rn: ['vi7', 'ii7', 'V7', 'Imaj7'],
|
||||||
|
degrees: [9, 2, 7, 0],
|
||||||
|
qualities: ['min7', 'min7', 'dom7', 'maj7'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Fly Me to the Moon', 'Autumn Leaves (A section, relative view)'],
|
||||||
|
tip: 'Pure circle-of-fifths motion: every root falls a fifth. If you can hear this one, you can predict half the jazz repertoire.',
|
||||||
|
level: 'foundation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jazz-blues',
|
||||||
|
name: 'Jazz blues (12-bar)',
|
||||||
|
rn: ['I7', 'IV7', 'I7', 'I7', 'IV7', 'IV7', 'I7', 'VI7', 'ii7', 'V7', 'I7', 'V7'],
|
||||||
|
degrees: [0, 5, 0, 0, 5, 5, 0, 9, 2, 7, 0, 7],
|
||||||
|
qualities: ['dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'min7', 'dom7', 'dom7', 'dom7'],
|
||||||
|
bars: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ["Billie's Bounce — Charlie Parker", "Now's the Time — Charlie Parker", 'Tenor Madness — Sonny Rollins'],
|
||||||
|
tip: 'A 12-bar blues wearing a suit: bars 8-10 swap the plain V-IV for a VI7 → ii–V turnaround. Hear bar 8 coming and you sound like a jazz player.',
|
||||||
|
level: 'foundation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jazz-tritone-sub',
|
||||||
|
name: 'Tritone-sub cadence (ii–♭II7–I)',
|
||||||
|
rn: ['ii7', '♭II7', 'Imaj7'],
|
||||||
|
degrees: [2, 1, 0],
|
||||||
|
qualities: ['min7', 'dom7', 'maj7'],
|
||||||
|
bars: [1, 1, 2],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['The Girl from Ipanema — Jobim (ii–♭II7–I in the A section)', 'standard substitute for any V7 in any tune'],
|
||||||
|
tip: 'The ♭II7 replaces V7 because they share the same tritone — the 3rd and ♭7 simply trade names. What changes is the bass: 2–♭2–1, a chromatic slide instead of a fifth-fall. Your old ii–V line still works over it.',
|
||||||
|
level: 'intermediate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jazz-rhythm-bridge',
|
||||||
|
name: 'Rhythm changes bridge (circle of dominants)',
|
||||||
|
rn: ['III7', 'VI7', 'II7', 'V7'],
|
||||||
|
degrees: [4, 9, 2, 7],
|
||||||
|
qualities: ['dom7', 'dom7', 'dom7', 'dom7'],
|
||||||
|
bars: [2, 2, 2, 2],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['I Got Rhythm — Gershwin (bridge)', 'Oleo — Sonny Rollins (bridge)', 'Anthropology — Parker/Gillespie (bridge)'],
|
||||||
|
tip: 'Four dominants, each the V of the next — the bridge leaves home and falls back in fifths until it hands you the A section. Follow the ♭7 of each chord dropping a half step onto the 3rd of the next; that chromatic thread is your solo map.',
|
||||||
|
level: 'intermediate',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
// Pop guitar pack. Every shape's pitch classes were verified by note-spelling
|
||||||
|
// against standard tuning (EADGBe) and the chord quality before authoring.
|
||||||
|
// Voicing / capo / strumming references:
|
||||||
|
// justinguitar.com (open "cowboy" chords, capo strategy, common strums),
|
||||||
|
// guitar.com & fretjam.com (sus2/sus4 add-colour, add9 cowboy chords),
|
||||||
|
// andyguitar.co.uk (eighth-note pop strum, palm-muted pop-rock),
|
||||||
|
// en.wikipedia.org/wiki/The_Axis_of_Awesome (capo-as-transpose for the axis).
|
||||||
|
//
|
||||||
|
// Pop guitar is a capo-and-open-shape craft: most hits are written in a flat or
|
||||||
|
// sharp key but PLAYED in a guitar-friendly shape set (G, C, D, Em, Am) with the
|
||||||
|
// capo doing the transposition. Each progression therefore gets one open
|
||||||
|
// "campfire" play (ringing strings, capo-friendly) and one movable barre play
|
||||||
|
// for the keys no capo position reaches. The barre play is genuinely different
|
||||||
|
// in register and ring, not a transposition of the open one.
|
||||||
|
|
||||||
|
// --- Open "cowboy" shapes — render only when the chord root matches ---
|
||||||
|
const OPEN_G = { onlyRoot: 7, frets: [3, 2, 0, 0, 3, 3], fingers: [2, 1, 0, 0, 3, 4] } // big ringing G
|
||||||
|
const OPEN_D = { onlyRoot: 2, frets: ['x', 'x', 0, 2, 3, 2], fingers: [0, 0, 0, 1, 3, 2] }
|
||||||
|
const OPEN_EM = { onlyRoot: 4, frets: [0, 2, 2, 0, 0, 0], fingers: [0, 2, 3, 0, 0, 0] }
|
||||||
|
const OPEN_AM = { onlyRoot: 9, frets: ['x', 0, 2, 2, 1, 0], fingers: [0, 0, 2, 3, 1, 0] }
|
||||||
|
const OPEN_C = { onlyRoot: 0, frets: ['x', 3, 2, 0, 1, 0], fingers: [0, 3, 2, 0, 1, 0] }
|
||||||
|
const OPEN_F = { onlyRoot: 5, frets: ['x', 'x', 3, 2, 1, 1], fingers: [0, 0, 3, 2, 1, 1] } // small F, top four
|
||||||
|
|
||||||
|
// --- add9 colour (the pop "sparkle") — used where the quality is add9 ---
|
||||||
|
const OPEN_CADD9 = { onlyRoot: 0, frets: ['x', 3, 2, 0, 3, 3], fingers: [0, 2, 1, 0, 3, 4] } // R–3–5–9
|
||||||
|
|
||||||
|
// --- Movable barres — for the capo-hostile keys ---
|
||||||
|
const BARRE_MAJ_6 = { rootStr: 6, offsets: [0, 2, 2, 1, 0, 0], fingers: [1, 3, 4, 2, 1, 1] } // E-shape
|
||||||
|
const BARRE_MAJ_5 = { rootStr: 5, offsets: ['x', 0, 2, 2, 2, 0], fingers: [0, 1, 2, 3, 4, 1] } // A-shape
|
||||||
|
const BARRE_MIN_6 = { rootStr: 6, offsets: [0, 2, 2, 0, 0, 0], fingers: [1, 3, 4, 1, 1, 1] } // Em-shape
|
||||||
|
const BARRE_MIN_5 = { rootStr: 5, offsets: ['x', 0, 2, 2, 1, 0], fingers: [0, 1, 3, 4, 2, 1] } // Am-shape
|
||||||
|
|
||||||
|
export default {
|
||||||
|
styleIntro:
|
||||||
|
'Pop guitar is built from a handful of open "cowboy" chords and a capo. Most chart hits live in flat or sharp keys, but the guitarist plays familiar open shapes and slides the capo to the singer\'s key — so the same G–D–Em–C grips power a thousand songs. The colour comes from sus2/sus4 hammer-ons and add9 ring, not from harmonic complexity; the loop stays simple and the strum, dynamics, and one well-placed suspension carry the part.',
|
||||||
|
|
||||||
|
comping: [
|
||||||
|
{
|
||||||
|
label: 'Eighth-note pop strum',
|
||||||
|
rhythm: 'D D U U D U (the "old faithful")',
|
||||||
|
description: 'The default acoustic-pop pattern: steady down-up eighths with a gap where the third downstroke would be, accent on the backbeat. Keep the wrist loose and constant even through the muted gap.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sus hammer-on figure',
|
||||||
|
rhythm: 'strum chord → hammer to sus4 / pull to sus2 within the bar',
|
||||||
|
description: 'The signature pop-guitar decoration: hold the open chord and wiggle one finger to a sus4 and back (or pull off to sus2). It animates a single static chord without changing the harmony — Tom Petty / Mr. Big territory.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Palm-muted eighths (pop-rock)',
|
||||||
|
rhythm: 'chugged straight eighths, edge of the palm on the bridge',
|
||||||
|
description: 'For the driving pop-rock loops: muted downstrokes on the low strings, opening up on the chorus. The dynamic jump from muted verse to open chorus is the arrangement doing the work the chords don\'t.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plays: {
|
||||||
|
'pop-axis': [
|
||||||
|
{
|
||||||
|
label: 'G–D–Em–C campfire (capo to taste)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: OPEN_G, note: 'I — big ringing G' },
|
||||||
|
{ shape: OPEN_D, note: 'V — open D' },
|
||||||
|
{ shape: OPEN_EM, note: 'vi — Em, the only minor in the loop' },
|
||||||
|
{ shape: OPEN_CADD9, extensions: ['9'], note: 'IV — Cadd9 keeps the top two strings ringing through the change' },
|
||||||
|
],
|
||||||
|
tips: 'This is the canonical pop guitar set: in G it is G–D–Em–C, and any key is just a capo move (A = capo 2, B♭ = capo 3…). Voicing the IV as Cadd9 lets the open B and e strings drone across all four chords — the modern-pop "wash".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Barre shapes (capo-hostile keys)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: BARRE_MAJ_6, note: 'I — E-shape barre' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: 'V — A-shape, same fret region' },
|
||||||
|
{ shape: BARRE_MIN_6, note: 'vi — Em-shape barre' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: 'IV — A-shape barre' },
|
||||||
|
],
|
||||||
|
tips: 'When no capo position gives you open strings (or a second guitar already owns the jangly part). I and vi share the 6th-string root; V and IV share the 5th — the hand rocks between two anchor frets. Lighten the barre between strums so it breathes.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'pop-50s-doowop': [
|
||||||
|
{
|
||||||
|
label: 'C-family doo-wop (Stand By Me grips)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: OPEN_C, note: 'I — open C' },
|
||||||
|
{ shape: OPEN_AM, note: 'vi — Am, the wistful drop' },
|
||||||
|
{ shape: OPEN_F, note: 'IV — small F, top four strings' },
|
||||||
|
{ shape: OPEN_G, note: 'V — open G' },
|
||||||
|
],
|
||||||
|
tips: 'In C this is the literal Stand By Me / "ice-cream changes" set. The whole feeling is the C → Am drop in the first two bars — let that ring before the F–G turns it home. Slow triplet-feel strum, not eighths.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Barre doo-wop, upper register',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: BARRE_MAJ_5, note: 'I — A-shape barre' },
|
||||||
|
{ shape: BARRE_MIN_6, note: 'vi — Em-shape, two frets down on the 6th string' },
|
||||||
|
{ shape: BARRE_MAJ_6, note: 'IV — E-shape barre' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: 'V — A-shape, up the neck' },
|
||||||
|
],
|
||||||
|
tips: 'A closed, vocal-group reading: roots walk between the 5th and 6th strings, so the hand never travels far. Mute the highest string and the four-voice grip starts to sound like the backing singers it came from.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'pop-canon': [
|
||||||
|
{
|
||||||
|
label: 'Open Canon line (descending bass)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: OPEN_G, note: 'I — bass G' },
|
||||||
|
{ shape: OPEN_D, note: 'V — bass D (the line: 1 → 7 sits in the inner voice)' },
|
||||||
|
{ shape: OPEN_EM, note: 'vi — bass E' },
|
||||||
|
{ shape: OPEN_AM, note: 'iii — Am as the diatonic iii; bass B/A keeps the stair descending' },
|
||||||
|
{ shape: OPEN_C, note: 'IV — bass C, ready to loop back to G' },
|
||||||
|
],
|
||||||
|
tips: 'The point of this line is the descending bass stair — pick the lowest string of each open shape as a bass note before strumming and you hear Pachelbel inside the pop. Five open chords, no barre, no capo needed in G.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Barre Canon, even register',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: BARRE_MAJ_6, note: 'I — E-shape' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: 'V — A-shape' },
|
||||||
|
{ shape: BARRE_MIN_6, note: 'vi — Em-shape' },
|
||||||
|
{ shape: BARRE_MIN_5, note: 'iii — Am-shape barre' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: 'IV — A-shape' },
|
||||||
|
],
|
||||||
|
tips: 'Closed shapes keep every chord the same density — useful under a busy vocal where the open version\'s ringing strings would clutter. Keep the top-string melody note audible; that moving top line is the second half of what makes the Canon line sing.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'pop-mixo-bVII': [
|
||||||
|
{
|
||||||
|
label: 'Open Mixolydian vamp with sus colour',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: OPEN_D, note: 'I — open D; wiggle to Dsus2/Dsus4 and back for the pop-rock "ring"' },
|
||||||
|
{ shape: OPEN_C, note: '♭VII — open C, the borrowed flat-seven' },
|
||||||
|
{ shape: OPEN_G, note: 'IV — open G; hammer the sus4 and pull back as the fill' },
|
||||||
|
],
|
||||||
|
tips: 'In D this is D–C–G, the brightest open-string Mixolydian shape set. Decorate the held I and IV with sus2/sus4 hammer-ons (the verse decoration in countless pop-rock loops) — the chord stays major, your finger wiggles. Let the open strings ring through the C.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Barre power-vamp (pop-rock drive)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: BARRE_MAJ_6, note: 'I — E-shape, palm-muted in the verse' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: '♭VII — A-shape two frets below the I root' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: 'IV — A-shape, slides up from the ♭VII' },
|
||||||
|
],
|
||||||
|
tips: 'The driving reading: palm-muted E-shape on the I, then the two A-shape majors share a grip and just slide — ♭VII to IV is a two-fret move. Open the palm mute on the chorus for the lift; the dynamic, not a new chord, is the chorus.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'pop-minor-loop': [
|
||||||
|
{
|
||||||
|
label: 'Em campfire loop (Save Tonight grips)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: OPEN_EM, note: 'i — Em, the lone minor' },
|
||||||
|
{ shape: OPEN_C, note: '♭VI — open C' },
|
||||||
|
{ shape: OPEN_G, note: '♭III — open G' },
|
||||||
|
{ shape: OPEN_D, note: '♭VII — open D, leans back to Em' },
|
||||||
|
],
|
||||||
|
tips: 'In E minor this is the literal Em–C–G–D of Save Tonight / Numb. After the single minor i, three open majors cascade home — keep the strum constant and let the all-major run feel anthemic against the minor tonic. Capo to move it to any minor key.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Barre minor loop, even and driving',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: BARRE_MIN_6, note: 'i — Em-shape barre' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: '♭VI — A-shape' },
|
||||||
|
{ shape: BARRE_MAJ_6, note: '♭III — E-shape' },
|
||||||
|
{ shape: BARRE_MAJ_5, note: '♭VII — A-shape' },
|
||||||
|
],
|
||||||
|
tips: 'The closed reading for a fuller, rockier feel. The three majors alternate E-shape / A-shape so the hand stays in one neighbourhood; palm-mute the i for tension and release it across the three majors for the climb.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
improv: {
|
||||||
|
scales: [
|
||||||
|
{ over: 'I (major loops)', scale: 'major', why: 'Major pentatonic is the safe melodic home over the axis and doo-wop loops; add the full major scale for passing tones between chord tones.' },
|
||||||
|
{ over: 'vi / iii / Am (the minor chords)', scale: 'minor', why: 'Over the vi (the relative minor) the natural-minor / minor-pentatonic shape sits right under the major-key notes — same fingering, darker target tones.' },
|
||||||
|
{ over: 'I / IV (Mixolydian vamp)', scale: 'mixolydian', why: 'The ♭VII in the loop is the ♭7 of the key — Mixolydian builds it in, so a single scale covers the whole I–♭VII–IV vamp.' },
|
||||||
|
{ over: 'i (minor pop loop)', scale: 'minor', why: 'Natural minor over the i–♭VI–♭III–♭VII loop; every chord in the loop is diatonic to natural minor, so one scale covers all four.' },
|
||||||
|
],
|
||||||
|
targetNotes:
|
||||||
|
'In looping pop the melody usually lands on a common tone held across the changes — find the one or two notes that belong to all four chords and lean on them (the high open strings in the open-shape plays are doing exactly this). On the doo-wop loop, voice-lead the top note down I→vi (root to its own 3rd) for the signature sweetness.',
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
over: 'pop-axis',
|
||||||
|
description: 'The "drone" decoration: keep the open B (and high e) ringing across all four open chords — G, D, Em, Cadd9 all contain or tolerate those open strings, which is why the Cadd9 voicing is chosen over plain C. The unchanging top notes are the modern-pop wash.',
|
||||||
|
source: 'justinguitar.com — "Cadd9 & the G–D–Em–C trick"; en.wikipedia.org/wiki/The_Axis_of_Awesome',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'pop-mixo-bVII',
|
||||||
|
description: 'The sus hammer-on fill: over the static I, strum the chord then hammer the sus4 and pull to sus2 in a steady eighth pulse (e.g. D → Dsus4 → D → Dsus2). It implies motion over one held chord — the standard trick for filling bars in a slow pop-rock loop.',
|
||||||
|
source: 'fretjam.com / guitar.com — "sus2 & sus4 chord embellishments"; andyguitar.co.uk pop-rock strumming',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
id: 'pop',
|
||||||
|
label: 'Pop',
|
||||||
|
feel: 'straight',
|
||||||
|
tempoRange: [70, 132],
|
||||||
|
character: 'A handful of diatonic chords looped without apology — the four-chord "axis", the doo-wop turn, the Canon line — where the arrangement and the hook, not the harmony, carry the song.',
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
// Pop piano pack (task P-62). Recipes verified by degree-spelling against
|
||||||
|
// CHORD_TYPES (src/lib/theory.js) and the validator's stacking convention
|
||||||
|
// (order inside each hand = voicing order low → high, nearest strictly above —
|
||||||
|
// documented at jazz/piano.js header). Every voice-leading claim in notes/tips
|
||||||
|
// is pitch-class arithmetic, computed and checked against the realized
|
||||||
|
// voicings (key of C for the major loops, E minor for the minor loop) before
|
||||||
|
// writing. Max combined stack in this pack: 19 semitones (renders for all 12
|
||||||
|
// roots inside the 36-key MiniPiano window).
|
||||||
|
//
|
||||||
|
// Pop piano is deliberately NOT jazz: plain triads and inversions, sus4 pulls
|
||||||
|
// and one add9 shimmer where the songs themselves put it — no 7ths or 13ths
|
||||||
|
// bolted on. Patterns and treatments sourced from:
|
||||||
|
// pianote.com — "Someone Like You" tutorial (the broken bottom–middle–top–
|
||||||
|
// middle roll over A–E–F♯m–D, i.e. the axis) and "How To Make Your Left Hand
|
||||||
|
// Sound Awesome" / "3 Left Hand Piano Patterns" (the root–5th–octave "1–5–8"
|
||||||
|
// pop anchor); pianowithjonny.com — "Rock and Roll Piano Chords and
|
||||||
|
// Accompaniment" (the '50s triplet pump) and "Pop Piano Accompaniment:
|
||||||
|
// Popstinatos" (repeated-figure pop comping); 500songs.com episode 25 —
|
||||||
|
// "Earth Angel" (piano triplets as the doo-wop signature, on the record
|
||||||
|
// itself); en.wikipedia.org/wiki/Pachelbel's_Canon and /wiki/'50s_progression
|
||||||
|
// (the descending first-inversion bass stair; the ice-cream changes); and the
|
||||||
|
// records the progressions.js songs list names: Let It Be (quarter-note block
|
||||||
|
// chords), Graduation — Vitamin C (Canon changes as straight-8th piano pulse),
|
||||||
|
// Numb — Linkin Park (8th-note octave pulse), Royals — Lorde (sparse pads,
|
||||||
|
// the ♭VII shimmer). Pedagogy frame: docs/learn-curriculum.md — Piano pillar
|
||||||
|
// (register discipline; voice-leading as hand economy).
|
||||||
|
|
||||||
|
// Reusable degree recipes. Order inside each hand = voicing order, low → high.
|
||||||
|
const LH_R = ['1'] // single bass root
|
||||||
|
const LH_R5 = ['1', '5'] // root + fifth anchor
|
||||||
|
const LH_OCT = ['1', '1'] // pumping octaves (the rock-piano kick drum)
|
||||||
|
const LH_STACK = ['1', '5', '1'] // root–5th–octave: the pianote "1–5–8" pop anchor
|
||||||
|
const RH_ROOT = ['1', '3', '5'] // root-position triad
|
||||||
|
const RH_INV1 = ['3', '5', '1'] // first inversion (root on top)
|
||||||
|
const RH_INV2 = ['5', '1', '3'] // second inversion (3rd on top)
|
||||||
|
const RH_35 = ['3', '5'] // bare 3rd + 5th dyad
|
||||||
|
|
||||||
|
export default {
|
||||||
|
styleIntro:
|
||||||
|
'Pop piano is the whole arrangement in one instrument: the left hand plays the bass part (roots, fifths, octaves), the right hand stacks plain triads, and the rhythm — pillars, pulses, rolls, triplets — is what changes between songs, not the harmony. Voice-lead the inversions so the top note barely moves, and spend your one colour (a sus4 pull, an add9 shimmer) where the record spends it.',
|
||||||
|
|
||||||
|
comping: [
|
||||||
|
{
|
||||||
|
label: 'Quarter-note pillars',
|
||||||
|
rhythm: 'four even quarter-note chords per bar',
|
||||||
|
description:
|
||||||
|
'The Let It Be pulse: block the chord on every beat, straight and unhurried, and let the sustain pedal glue the changes. The steadiness is the point — the vocal supplies all the motion.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Driving eighths',
|
||||||
|
rhythm: 'straight 8ths, accents on 2 and 4',
|
||||||
|
description:
|
||||||
|
'Eight even strokes a bar, the piano as rhythm guitar. Verse quiet, chorus loud — in pop the dynamic jump IS the arrangement, so resist adding notes and add weight instead.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "'50s triplets",
|
||||||
|
rhythm: '12/8 — three strokes per beat',
|
||||||
|
description:
|
||||||
|
'Twelve even triplet chords a bar, the sound of every slow dance since Earth Angel. Keep them soft and metronomic; accent beats 2 and 4 and the whole band swings around you.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'The ballad roll',
|
||||||
|
rhythm: 'broken-chord 16ths: bottom–middle–top–middle',
|
||||||
|
description:
|
||||||
|
'Never block the chord — roll it, low to high to low, in an even stream (the Someone Like You engine). One dynamic swell per phrase, pedal through each change.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plays: {
|
||||||
|
'pop-axis': [
|
||||||
|
{
|
||||||
|
label: 'The ballad roll (Someone Like You bed)',
|
||||||
|
level: 'foundation',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ROOT }, note: 'anchor 1–5 low, roll the triad above: bottom–middle–top–middle in even 16ths' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ROOT }, note: 'the I\'s 5th is this chord\'s root — the whole pattern just slides down a fourth' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ROOT }, note: 'nothing carries over from the V — the total change is why the relative-minor drop lands' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ROOT }, note: 'two names return: the vi\'s root and ♭3 are this chord\'s 3rd and 5th' },
|
||||||
|
],
|
||||||
|
register: 'LH fifths around C3, RH roll around middle C',
|
||||||
|
tips: 'The piano-ballad axis is a texture, not a chord trick: keep the 16ths rolling (the literal Someone Like You verse figure) and let the pedal connect the bars. Know the loop\'s one seam with zero shared notes — V into vi — and lean into it; every other change hands you at least one common tone for free.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Let It Be pillars (voice-led quarters)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_INV1 }, note: 'first inversion — the key\'s home note on top' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_INV2 }, note: 'the G holds; the other two fall by step (top C→B a half-step, E→D a whole)' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_INV2 }, note: 'all three voices rise by step: D→E and G→A whole steps, B→C the half-step' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_ROOT }, note: 'two keys hold (A and C); one finger moves — E up a half-step to F' },
|
||||||
|
],
|
||||||
|
register: 'RH pillars around middle C, LH single roots an octave and more below',
|
||||||
|
tips: 'Four even quarters per bar — the Let It Be pulse — with inversions chosen so the top note only ever leaves the tonic for its neighbour a half-step below (C…B…C…C in the reference key). No right-hand finger moves more than a whole step anywhere in the loop, and the seam home is almost free: the top C holds while F and A fall by step onto E and G.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'pop-50s-doowop': [
|
||||||
|
{
|
||||||
|
label: 'Earth Angel triplets',
|
||||||
|
level: 'foundation',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ROOT }, note: 'root-position triad over the 1–5 anchor — pump it in triplets' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ROOT }, note: 'two names survive the wistful drop: the I\'s root and 3rd are this chord\'s ♭3 and 5th' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ROOT }, note: 'two survive again: the vi\'s root and ♭3 become this chord\'s 3rd and 5th' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ROOT }, note: 'nothing survives — the whole triad steps up a whole step in parallel: the turn home' },
|
||||||
|
],
|
||||||
|
register: 'LH around C3, RH triads just above middle C',
|
||||||
|
tips: 'Twelve even triplet strokes a bar — the doo-wop engine on the Earth Angel record itself. Root positions on purpose: the pump, not the voice leading, is the part. Play them soft and metronomic, accent 2 and 4, and give the singer the whole top of the keyboard.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Wedding-band walk (one-finger changes)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_INV2 }, note: 'second inversion — G–C–E, the 3rd on top' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_ROOT }, note: 'one finger: the bottom G rises a whole step to A; C and E hold, re-named from root+3rd to ♭3+5th' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_INV1 }, note: 'one finger again: E rises a half-step to F; A and C hold' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_INV1 }, note: 'the whole hand lifts a whole step — A→B, C→D, F→G' },
|
||||||
|
],
|
||||||
|
register: 'RH close position around middle C, LH roots below; sustained halves and wholes',
|
||||||
|
tips: 'The same four chords as the triplet play, reorganised so the first two changes cost exactly one finger each. The seam home is the lesson: the G stays under your hand (top of the V, bottom of the I) while B and D resolve up by step onto C and E. This is the slow-set reading; the triplets play is the dance-set one.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'pop-canon': [
|
||||||
|
{
|
||||||
|
label: 'The Pachelbel stair (first-inversion walk)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_INV1 }, note: 'root in the bass — the stair starts on 1' },
|
||||||
|
{ recipe: { LH: ['3'], RH: RH_INV2 }, note: 'LH plays the 3rd, not the root: the bass steps down a half-step onto the key\'s 7th; above, G holds while E→D and C→B fall by step' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_INV2 }, note: 'bass falls a whole step to 6; every right-hand voice rises by step (D→E, G→A, B→C)' },
|
||||||
|
{ recipe: { LH: ['3'], RH: RH_INV2 }, note: 'LH takes the ♭3: the bass stair falls another whole step onto the key\'s 5th; E holds while A→G and C→B fall' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_INV2 }, note: 'bass reaches 4, one more whole step down; all three voices rise (B→C and E→F by half-step, G→A by whole)' },
|
||||||
|
],
|
||||||
|
register: 'LH bass line around C3, RH close voicings just above middle C',
|
||||||
|
tips: 'The whole trick is two first inversions: put the V and iii over their own 3rds and the left hand walks 1–7–6–5–4 — a half-step then three whole steps, the "classical" stair under Memories and Don\'t Look Back in Anger. The right hand alternates fall, rise, fall, rise so no voice ever leaps; at the loop seam the C holds while F and A fall by step.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Graduation pulse (root-position eighths)',
|
||||||
|
level: 'foundation',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ROOT }, note: 'straight-eighth root positions — the school-assembly reading' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ROOT }, note: 'the bass drops a fourth; the right hand just re-plants the same grip' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ROOT }, note: 'bass up a whole step' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ROOT }, note: 'down a fourth again' },
|
||||||
|
{ recipe: { LH: LH_R5, RH: RH_ROOT }, note: 'up a half-step — then the loop falls a fourth home' },
|
||||||
|
],
|
||||||
|
register: 'both hands mid-low; eight even strokes a bar',
|
||||||
|
tips: 'Graduation (Friends Forever) plays the Canon changes exactly like this: every chord root position, eight even eighths, zero negotiation in the right hand. Let the left hand tell the story instead — down a fourth, up a step, down a fourth, up a half-step, and a fourth drop home. The stair play is what to graduate to once this grooves.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'pop-mixo-bVII': [
|
||||||
|
{
|
||||||
|
label: 'The tonic drone (pad voicings)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_INV1 }, note: 'the key\'s home note on top for the two tonic bars' },
|
||||||
|
{ recipe: { LH: LH_R, RH: ['3', '5', '9'] }, note: 'the top note refuses to move — over the ♭VII it becomes the 9, an add9 shimmer; the two voices under it each fall a whole step (E→D, G→F)' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_ROOT }, note: 'two keys stay down (the ♭VII\'s 5th is this root, its 9 this 5th); only the 3rd moves, leaping a fifth up onto the IV\'s 3rd' },
|
||||||
|
],
|
||||||
|
register: 'RH just above middle C; long sustains, pedal through each chord',
|
||||||
|
tips: 'One key — the key\'s tonic — is physically held down for the entire loop: root of the I, 9 of the ♭VII, 5th of the IV. That drone is the piano translation of the guitar pack\'s ringing open strings, and the 9 it creates on the ♭VII is honest pop colour (Royals hangs its verse on exactly this shimmer). At the seam home the tonic holds again while F and A fall by step onto E and G.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Stadium pulse with the sus pull',
|
||||||
|
level: 'foundation',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_STACK, RH: RH_35 }, note: 'root–5th–octave low, bare 3–5 above; across the two bars lift the 3rd a half-step to the 4th and drop it back — the sus4 pull' },
|
||||||
|
{ recipe: { LH: LH_STACK, RH: RH_35 }, note: 'the whole five-note stack slides down a whole step in parallel' },
|
||||||
|
{ recipe: { LH: LH_STACK, RH: RH_35 }, note: 'up a fifth (or feel it as down a fourth) — same grip, third station' },
|
||||||
|
],
|
||||||
|
register: 'LH stack from around C2, RH dyad just above middle C — big and low',
|
||||||
|
tips: 'The piano as rhythm guitar: the 1–5–8 left hand is the pop anchor pianote teaches, the two-finger right hand keeps the mid-range clear for the vocal, and dynamics — not new chords — make the chorus. The sus4 pull on the long I bar is the keys version of the guitar pack\'s hammer-on wiggle: 3rd up a half-step, back down, harmony never changes.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'pop-minor-loop': [
|
||||||
|
{
|
||||||
|
label: 'Dark pads (voice-led)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_ROOT }, note: 'root-position minor — state the lone i plainly' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_INV1 }, note: 'one finger: the i\'s 5th rises a half-step onto this root (on top); the other two hold, re-named as the ♭VI\'s 3rd and 5th' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_INV2 }, note: 'the ♭VI\'s 5th holds as this root; E→D a whole step, C→B a half-step (reference key E minor)' },
|
||||||
|
{ recipe: { LH: LH_R, RH: RH_ROOT }, note: 'the ♭III\'s 5th holds as this root; G→F♯ a half-step, B→A a whole step' },
|
||||||
|
],
|
||||||
|
register: 'RH around middle C, LH roots below; sustained wholes',
|
||||||
|
tips: 'Three of the four changes cost a finger or two; the seam home costs everything — the ♭VII and the i share no notes at all, so all three voices rise by step together (D→E and A→B whole steps, F♯→G the half-step). That full-hand climb is why the loop feels like it leans back into the minor i. Practise the seam alone until the climb is one gesture.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Octave drive (Numb pulse)',
|
||||||
|
level: 'foundation',
|
||||||
|
chords: [
|
||||||
|
{ recipe: { LH: LH_OCT, RH: RH_35 }, note: 'left-hand octaves hammer the root; the right hand is just ♭3+5 — thin on purpose' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: RH_35 }, note: 'same grip on the major: the dyad narrows by one key (♭3–5 is four semitones, 3–5 is three)' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: RH_35 }, note: 'keep the eighths even; accent 2 and 4' },
|
||||||
|
{ recipe: { LH: LH_OCT, RH: RH_35 }, note: 'last bar — lean the crescendo into the climb back to the i' },
|
||||||
|
],
|
||||||
|
register: 'LH octaves around C2–C3, RH dyad mid-keyboard; straight eighths throughout',
|
||||||
|
tips: 'The rock-piano reading (Numb\'s pulse): octaves are the kick drum, the two-note right hand stays out of the vocal\'s way, and the chorus is a dynamic, not a chord. The one theory note worth keeping: the dyad is four semitones wide on the minor i and three on the majors — one key narrower every time the loop brightens.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
improv: {
|
||||||
|
scales: [
|
||||||
|
{
|
||||||
|
over: 'the major loops (axis, doo-wop, Canon)',
|
||||||
|
scale: 'major pentatonic',
|
||||||
|
why: 'Pop toplines are mostly pentatonic: five notes with no avoid tone over any chord in the loop. Add the full major scale for passing notes between phrases.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'vi (and the minor loop\'s i)',
|
||||||
|
scale: 'minor',
|
||||||
|
why: 'Natural minor of the vi is the same keys as the parent major — identical fingering, darker targets. Minor pentatonic when in doubt; every chord of the minor loop is diatonic to it.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'I–♭VII–IV',
|
||||||
|
scale: 'mixolydian',
|
||||||
|
why: 'The ♭VII is built from the key\'s ♭7, so Mixolydian covers the whole vamp with one scale — the same call the guitar pack makes.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'V (doo-wop, Canon)',
|
||||||
|
scale: 'major',
|
||||||
|
why: 'Stay in the parent major; land on the V\'s 3rd — the key\'s leading tone — when you want the turn home to bite.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
targetNotes:
|
||||||
|
'Pop fills live between vocal phrases, not over them. Find the note the whole loop shares — the tonic-drone play makes it physical — and hang fills off it, landing phrase endings on the current chord\'s 3rd. The 9 is pop\'s one free colour: a whole step above any root, it reads as shimmer, never as jazz.',
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
over: 'pop-axis',
|
||||||
|
description:
|
||||||
|
'The roll figure is itself the fill: when the vocal rests, take the right-hand bottom–middle–top–middle broken pattern up an octave for two beats and come back down — motion without a single new note. This is how the Someone Like You verse breathes.',
|
||||||
|
source: 'pianote.com — "Someone Like You" piano tutorial (the broken-chord verse figure)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
over: 'pop-mixo-bVII',
|
||||||
|
description:
|
||||||
|
'The sus4 pull as a fill: over the held I, alternate the 3rd and 4th in even eighths (3–4–3, harmony unchanged) — the keyboard translation of the guitar sus hammer-on that fills long tonic bars in pop-rock loops.',
|
||||||
|
source: 'the guitar pack\'s sus figure (fretjam.com / guitar.com sus embellishments), translated per pianowithjonny.com pop accompaniment patterns',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Pop progressions. Loops and song references verified against:
|
||||||
|
// en.wikipedia.org/wiki/The_Axis_of_Awesome (I–V–vi–IV "four chords"),
|
||||||
|
// en.wikipedia.org/wiki/'50s_progression (doo-wop I–vi–IV–V),
|
||||||
|
// classicfm.com (Maroon 5 "Memories" = Pachelbel I–V–vi–iii–IV line),
|
||||||
|
// en.wikipedia.org/wiki/List_of_variations_on_Pachelbel's_Canon,
|
||||||
|
// tunableapp.com chord-progressions (i–♭VI–♭III–♭VII minor loop).
|
||||||
|
// Deliberately differentiated from the rock pack: rock-axis already owns the
|
||||||
|
// I-started axis and rock-mixo-vamp the I–♭VII–IV vamp; here the axis is framed
|
||||||
|
// for capo/open pop guitar, the Mixolydian move gets honest pop songs, and the
|
||||||
|
// minor loop is the relative-minor (vi-started) reading, not rock's i–♭VII–♭VI–V.
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
id: 'pop-axis',
|
||||||
|
name: 'Four chords (I–V–vi–IV)',
|
||||||
|
rn: ['I', 'V', 'vi', 'IV'],
|
||||||
|
degrees: [0, 7, 9, 5],
|
||||||
|
qualities: ['maj', 'maj', 'min', 'maj'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['No Woman No Cry — Bob Marley', 'Someone Like You — Adele (verse loop)', 'Let It Be — The Beatles'],
|
||||||
|
tip: 'The single most-used loop in modern pop (the Axis of Awesome "four chords"). On guitar it is really a capo decision: pick the capo position that lets you play it as open G–D–Em–C shapes and the whole song rings.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pop-50s-doowop',
|
||||||
|
name: "'50s / doo-wop (I–vi–IV–V)",
|
||||||
|
rn: ['I', 'vi', 'IV', 'V'],
|
||||||
|
degrees: [0, 9, 5, 7],
|
||||||
|
qualities: ['maj', 'min', 'maj', 'maj'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Stand By Me — Ben E. King', 'Earth Angel — The Penguins', 'Blue Moon (the Marcels, 1961)'],
|
||||||
|
tip: 'The "ice-cream changes": dropping from the bright I straight to vi is the sweet, wistful doo-wop gesture. It loops forever — Stand By Me never leaves these four chords.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pop-canon',
|
||||||
|
name: 'Canon pop line (I–V–vi–iii–IV)',
|
||||||
|
rn: ['I', 'V', 'vi', 'iii', 'IV'],
|
||||||
|
degrees: [0, 7, 9, 4, 5],
|
||||||
|
qualities: ['maj', 'maj', 'min', 'min', 'maj'],
|
||||||
|
bars: [1, 1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Memories — Maroon 5 (Pachelbel, set in B)', "Don't Look Back in Anger — Oasis", 'Graduation (Friends Forever) — Vitamin C'],
|
||||||
|
tip: 'Pachelbel\'s Canon wearing pop clothes — the axis with an extra iii inserted between vi and IV. The bass walks down a clean diatonic stair (1–7–6–5–4), which is what makes the line feel "classical".',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pop-mixo-bVII',
|
||||||
|
name: 'Mixolydian pop (I–♭VII–IV)',
|
||||||
|
rn: ['I', '♭VII', 'IV'],
|
||||||
|
degrees: [0, 10, 5],
|
||||||
|
qualities: ['maj', 'maj', 'maj'],
|
||||||
|
bars: [2, 1, 1],
|
||||||
|
mode: 'mixolydian',
|
||||||
|
songs: ['Clocks — Coldplay (♭VII colour)', 'Cigarettes & Alcohol — Oasis', 'Royals — Lorde (verse)'],
|
||||||
|
tip: 'Swap the polite V for the borrowed ♭VII and the loop turns anthemic without a single minor chord. Every chord is major, so a single open or capo position covers all three with one hand shape moved twice.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pop-minor-loop',
|
||||||
|
name: 'Minor pop loop (i–♭VI–♭III–♭VII)',
|
||||||
|
rn: ['i', '♭VI', '♭III', '♭VII'],
|
||||||
|
degrees: [0, 8, 3, 10],
|
||||||
|
qualities: ['min', 'maj', 'maj', 'maj'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'minor',
|
||||||
|
songs: ['Save Tonight — Eagle-Eye Cherry', 'Numb — Linkin Park', "Self Esteem — The Offspring"],
|
||||||
|
tip: 'The axis read from its relative minor: start on vi and the same family of chords turns dark and anthemic. After the lone minor i, three major chords cascade home — an all-major run inside a minor key.',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// Reggae guitar pack. Shapes verified by note-spelling against: guitarwiz.app
|
||||||
|
// (skank voicings, bubble grid), guitarworld.com (Marley rhythm lesson),
|
||||||
|
// guitarkitchen.com (Stir It Up / Get Up Stand Up lessons), Wikipedia ("Ska
|
||||||
|
// stroke", "One drop rhythm"), ethanhein.com (Stir It Up hook analysis).
|
||||||
|
|
||||||
|
// Top-3-string triads — the classic skank register (above the bubble, far above the bass).
|
||||||
|
const TOP3_MAJ = { rootStr: 1, offsets: ['x', 'x', 'x', 1, 0, 0], fingers: [0, 0, 0, 2, 1, 1] } // 3-5-R
|
||||||
|
const TOP3_MIN = { rootStr: 1, offsets: ['x', 'x', 'x', 0, 0, 0], fingers: [0, 0, 0, 1, 1, 1] } // ♭3-5-R barre
|
||||||
|
|
||||||
|
// Middle-string triads (strings 4-3-2) — the Wailers chop register.
|
||||||
|
const MID_MAJ = { rootStr: 3, offsets: ['x', 'x', 0, 0, 0, 'x'], fingers: [0, 0, 1, 1, 1, 0] } // 5-R-3
|
||||||
|
const MID_MIN = { rootStr: 3, offsets: ['x', 'x', 0, 0, -1, 'x'], fingers: [0, 0, 2, 3, 1, 0] } // 5-R-♭3
|
||||||
|
|
||||||
|
// Top-4 partial barres — fuller chop, still no low strings.
|
||||||
|
const TOP4_MAJ = { rootStr: 4, offsets: ['x', 'x', 0, -1, -2, -2], fingers: [0, 0, 4, 3, 1, 1] } // R-3-5-R
|
||||||
|
const TOP4_MIN = { rootStr: 4, offsets: ['x', 'x', 0, -2, -2, -2], fingers: [0, 0, 4, 1, 1, 1] } // R-♭3-5-R
|
||||||
|
|
||||||
|
// Quality-neutral 5th+root dyad (strings B+e) — works over major or minor.
|
||||||
|
const DYAD_5R = { rootStr: 1, offsets: ['x', 'x', 'x', 'x', 0, 0], fingers: [0, 0, 0, 0, 1, 1] }
|
||||||
|
|
||||||
|
export default {
|
||||||
|
styleIntro:
|
||||||
|
'The reggae guitarist plays one thing and plays it perfectly: the skank — a small, high, choked chord chop strictly on the offbeats. Beat 1 is sacred silence (the one drop), beat 3 belongs to the drums, the low end belongs to the bass. Strike, choke, wait.',
|
||||||
|
|
||||||
|
comping: [
|
||||||
|
{
|
||||||
|
label: 'One-drop skank',
|
||||||
|
rhythm: '. . X . | . . X . — chops on 2 and 4 only',
|
||||||
|
description: 'The core part. Strike the chord, release fret pressure instantly (fingers stay touching, off the frets), silence until the next chop. Downstroke-dominant in reggae. Counted double-time it becomes the and-of-every-beat.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Double skank (Stir It Up)',
|
||||||
|
rhythm: '. . D U | . . D U — down-up pair on 2 and 4',
|
||||||
|
description: 'Each chop splits into a two-hit "chak-a": downstroke on the beat, upstroke on its and. The rockers-era intensifier — same placement, doubled motion.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Ska upstroke',
|
||||||
|
rhythm: '. U . U . U . U — every offbeat 8th, bright and fast',
|
||||||
|
description: 'The same offbeat principle at 120–180 BPM: upstrokes on every "and". Rocksteady is this relaxed; roots reggae is this halved. The placement never changes across the whole family — only the drums do.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'The bubble (only without keys)',
|
||||||
|
rhythm: '. . X X . . X X . . X X . . X X — the "& a" of every beat',
|
||||||
|
description: 'Normally the organ\'s job (Jackie Mittoo): a palm-muted continuous offbeat pulse in the midrange. Cover it on guitar only when there is no keyboardist — never alongside one.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plays: {
|
||||||
|
'reggae-stir': [
|
||||||
|
{
|
||||||
|
label: 'Top-3 triads (the classic skank)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: TOP3_MAJ, note: 'I' },
|
||||||
|
{ shape: TOP3_MAJ, note: 'IV — same shape up the neck' },
|
||||||
|
{ shape: TOP3_MAJ, note: 'V — two frets above the IV' },
|
||||||
|
],
|
||||||
|
tips: 'One shape, three positions, chop on 2 and 4. The chop must die immediately — if a chord rings into the next beat, that\'s a rock strum, not a skank.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Middle-string set (Wailers register)',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: MID_MAJ, note: 'I — strings 4-3-2' },
|
||||||
|
{ shape: MID_MAJ, note: 'IV' },
|
||||||
|
{ shape: MID_MAJ, note: 'V' },
|
||||||
|
],
|
||||||
|
tips: 'A warmer chop one string set down — the Marley band register. Use it when a second guitar or keys already occupy the top strings.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'reggae-two-chord': [
|
||||||
|
{
|
||||||
|
label: 'Top-3 triads',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: TOP3_MAJ, note: 'I' },
|
||||||
|
{ shape: TOP3_MAJ, note: 'IV — five frets up, or two down on the next string set' },
|
||||||
|
],
|
||||||
|
tips: 'Two chords for the whole tune means the skank IS your entire job: identical length, identical volume, every chop. Boredom is the test — pass it.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Top-4 partial barres',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: TOP4_MAJ, note: 'I' },
|
||||||
|
{ shape: TOP4_MAJ, note: 'IV' },
|
||||||
|
],
|
||||||
|
tips: 'The fuller chop for when the band is sparse — still nothing below the D string. Half-press the barre (Marley style) and the chop turns almost fully percussive.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'reggae-minor-vamp': [
|
||||||
|
{
|
||||||
|
label: 'Top-3 triads',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: TOP3_MIN, note: 'i — one-finger barre' },
|
||||||
|
{ shape: TOP3_MAJ, note: '♭VII — a passing breath, two frets down' },
|
||||||
|
],
|
||||||
|
tips: 'Treat the ♭VII as ornament, not destination — Get Up Stand Up is functionally one chord, and the groove is the message. The intro hook (♭7→root on the G string) doubles the bass.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Middle-string set',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: MID_MIN, note: 'i' },
|
||||||
|
{ shape: MID_MAJ, note: '♭VII' },
|
||||||
|
],
|
||||||
|
tips: 'Lower, darker chop for the heavier roots feel. Keep the choke brutal at slow tempos — space is the instrument at 75 BPM.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'reggae-nwnc': [
|
||||||
|
{
|
||||||
|
label: 'Top-3 triads with the bass walk',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: TOP3_MAJ, note: 'I' },
|
||||||
|
{ shape: TOP3_MAJ, note: 'V — the bass plays its 3rd underneath; your triad doesn\'t change' },
|
||||||
|
{ shape: TOP3_MIN, note: 'vi' },
|
||||||
|
{ shape: TOP3_MAJ, note: 'IV' },
|
||||||
|
],
|
||||||
|
tips: 'The V⁶\'s descending bass (root→7th of the scale→6th) is the bassist\'s line — your job is to NOT double it. Stay high, stay small, let the walk happen below you.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Neutral 5+R dyads',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: DYAD_5R, omit3: true, note: 'I' },
|
||||||
|
{ shape: DYAD_5R, omit3: true, note: 'V' },
|
||||||
|
{ shape: DYAD_5R, omit3: true, note: 'vi — same dyad; the bass supplies the minor' },
|
||||||
|
{ shape: DYAD_5R, omit3: true, note: 'IV' },
|
||||||
|
],
|
||||||
|
tips: 'Two strings, no 3rd — quality-neutral, so one dyad shape skanks the whole progression while bass and vocals colour it. The most transparent part you can play behind a singer.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
'reggae-rocksteady': [
|
||||||
|
{
|
||||||
|
label: 'Top-3 triads, doo-wop sweetness',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: TOP3_MAJ, note: 'I' },
|
||||||
|
{ shape: TOP3_MIN, note: 'ii' },
|
||||||
|
{ shape: TOP3_MIN, note: 'iii — two frets up' },
|
||||||
|
{ shape: TOP3_MIN, note: 'ii' },
|
||||||
|
],
|
||||||
|
tips: 'A diatonic staircase: the minor barre walks up two frets and back while the I anchors. Rocksteady tempo (~86 BPM) sits between ska\'s sprint and roots\' crawl — relax the chop accordingly.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Top-4 partial barres',
|
||||||
|
level: 'intermediate',
|
||||||
|
chords: [
|
||||||
|
{ shape: TOP4_MAJ, note: 'I' },
|
||||||
|
{ shape: TOP4_MIN, note: 'ii' },
|
||||||
|
{ shape: TOP4_MIN, note: 'iii' },
|
||||||
|
{ shape: TOP4_MIN, note: 'ii' },
|
||||||
|
],
|
||||||
|
tips: 'The soul-ballad version of the skank — slightly fuller, still bass-free. Good under a falsetto lead vocal where top-3 triads would crowd the singer\'s register.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
improv: {
|
||||||
|
scales: [
|
||||||
|
{ over: 'major vamps', scale: 'major', why: 'Major pentatonic fills in the gaps AFTER vocal lines — call and response, never over the singer.' },
|
||||||
|
{ over: 'i–♭VII vamps', scale: 'minor', why: 'Minor pentatonic answer phrases; the ♭7 doubles as the ♭VII chord\'s root — the free note of roots reggae.' },
|
||||||
|
{ over: 'any vamp (riddim role)', scale: 'minor', why: 'The classic second-guitar job is doubling the bass melody in unison, palm-muted for a dull attack — locked exactly, not approximately.' },
|
||||||
|
],
|
||||||
|
targetNotes:
|
||||||
|
'Reggae lead is economy: short pentatonic answers in vocal gaps, high picked arpeggios of the current chord with 16th-note pickups, or unison bass-doubling. If you\'re not sure whether to play — don\'t. Beat 1 stays empty even for the soloist\'s instincts.',
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
over: 'reggae-stir',
|
||||||
|
description: 'The Stir It Up hook (reconstruction from Ethan Hein\'s published note-by-note analysis, not a record transcription): an ornamented rise A→C♯→D over the I, then arpeggios of the IV and V with 16th-note pickups carrying the swing.',
|
||||||
|
tab: ' A (I) D (IV) E (V)\ne|--------------------|----2----5---|----4----7----\nB|---------2----3-----|--3----------|--5-----------\nG|----2---------------|-------------|--------------\n A C# D D F# A E G# B',
|
||||||
|
source: 'ethanhein.com "Musical simples: Stir It Up"; Bob Marley & The Wailers (1973)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Structured licks (SCHEMA.md "Licks", task P-24). Written in a home key each
|
||||||
|
// (noted per lick) but key-agnostic in spirit — chordContext names the station.
|
||||||
|
// Every pitch hand-verified: s6=E s5=A s4=D s3=G s2=B s1=e (+fret, mod 12).
|
||||||
|
licks: [
|
||||||
|
{
|
||||||
|
// In A minor (i–♭VII = Am–G): the skank chords answered as a fill —
|
||||||
|
// the i's ♭3+5 pair (C+E), the ♭VII's 3+5 pair (B+D) two frets down,
|
||||||
|
// back home, root on top. Strictly in the vocal gaps.
|
||||||
|
id: 'reggae-skank-answer',
|
||||||
|
name: 'Skank-answer double-stop fill',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'answer fill over the i–♭VII vamp, in the vocal gaps',
|
||||||
|
techniques: ['double-stop', 'vibrato'],
|
||||||
|
source: 'call-and-response fills in the Wailers style — Guitar World\'s Bob Marley rhythm-guitar lesson',
|
||||||
|
tab: [
|
||||||
|
{ string: 3, fret: 5 }, // C — the i's ♭3
|
||||||
|
{ string: 2, fret: 5, technique: 'double-stop' }, // E — the i's 5: the minor pair
|
||||||
|
{ string: 3, fret: 4 }, // B — the ♭VII's 3, two frets down
|
||||||
|
{ string: 2, fret: 3, technique: 'double-stop' }, // D — the ♭VII's 5: the pair moves with the chord
|
||||||
|
{ string: 3, fret: 5 }, // C — back home
|
||||||
|
{ string: 2, fret: 5, technique: 'double-stop' }, // E
|
||||||
|
{ string: 1, fret: 5, technique: 'vibrato' }, // A — the root caps the answer
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In A minor, low register, palm-muted throughout: a minor-pentatonic
|
||||||
|
// line doubling the bassline in unison — the Lynn Taitt second-guitar
|
||||||
|
// job. Dull attack, locked exactly with the bass, never approximately.
|
||||||
|
id: 'reggae-bass-double',
|
||||||
|
name: 'Muted bass-doubling riddim line',
|
||||||
|
level: 'foundation',
|
||||||
|
chordContext: 'over the i — unison with the bassline',
|
||||||
|
techniques: ['ghost-note'],
|
||||||
|
source: 'the rocksteady bass-doubling role in Lynn Taitt\'s muted picking style — Wikipedia "Rocksteady"; Brian Keyo\'s Taitt tribute (tallawah.com)',
|
||||||
|
tab: [
|
||||||
|
{ string: 6, fret: 5 }, // A — root, palm-muted like everything here
|
||||||
|
{ string: 6, fret: 5, technique: 'ghost-note' }, // muted pluck — the fluttering Taitt attack
|
||||||
|
{ string: 6, fret: 8 }, // C — ♭3
|
||||||
|
{ string: 5, fret: 5 }, // D — 4
|
||||||
|
{ string: 5, fret: 7 }, // E — 5
|
||||||
|
{ string: 5, fret: 5 }, // D — 4
|
||||||
|
{ string: 6, fret: 8 }, // C — ♭3
|
||||||
|
{ string: 6, fret: 5 }, // A — home; keep the attack dull to the end
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// In A: chromatic walk-up 6–♭7–7 into the root, timed so the root lands
|
||||||
|
// with the drum's drop (beat 3 of the one drop — beat 1 stays sacred and
|
||||||
|
// silent), answered by the 3+5 chop up top on the offbeat.
|
||||||
|
id: 'reggae-walkup-drop',
|
||||||
|
name: 'Chromatic walk into the one drop',
|
||||||
|
level: 'intermediate',
|
||||||
|
chordContext: 'pickup into the I — the root lands on the drop (beat 3)',
|
||||||
|
techniques: ['chromatic-approach', 'double-stop'],
|
||||||
|
source: 'in the style of rocksteady/early-reggae walk-ups (the Lynn Taitt-era bass-and-guitar lines — Wikipedia "Rocksteady", "One drop rhythm")',
|
||||||
|
tab: [
|
||||||
|
{ string: 6, fret: 2 }, // F♯ — the 6
|
||||||
|
{ string: 6, fret: 3, technique: 'chromatic-approach' }, // G — ♭7, passing
|
||||||
|
{ string: 6, fret: 4, technique: 'chromatic-approach' }, // G♯ — 7, passing
|
||||||
|
{ string: 6, fret: 5 }, // A — root, landing WITH the drop
|
||||||
|
{ string: 3, fret: 6 }, // C♯ — the 3: jump up for the answer chop
|
||||||
|
{ string: 2, fret: 5, technique: 'double-stop' }, // E — the 5: the skank answers on the offbeat
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
id: 'reggae',
|
||||||
|
label: 'Reggae',
|
||||||
|
feel: 'one drop',
|
||||||
|
tempoRange: [70, 95],
|
||||||
|
character: 'Strict role separation and sacred negative space: bass owns the melody, drums drop beat one entirely, and the guitar is a choked offbeat chop that never, ever lands on 1 or 3.',
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export default [
|
||||||
|
{
|
||||||
|
id: 'reggae-stir',
|
||||||
|
name: 'Circular I–IV–V vamp',
|
||||||
|
rn: ['I', 'IV', 'V'],
|
||||||
|
degrees: [0, 5, 7],
|
||||||
|
qualities: ['maj', 'maj', 'maj'],
|
||||||
|
bars: [2, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Stir It Up — Bob Marley & The Wailers'],
|
||||||
|
tip: 'Not a cadence — a wheel: a full bar of I, then IV and V share a bar, forever. The V never "resolves"; it just hands back to the I.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reggae-two-chord',
|
||||||
|
name: 'Two-chord I–IV vamp',
|
||||||
|
rn: ['I', 'IV'],
|
||||||
|
degrees: [0, 5],
|
||||||
|
qualities: ['maj', 'maj'],
|
||||||
|
bars: [2, 2],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Lively Up Yourself — Bob Marley'],
|
||||||
|
tip: 'Two chords for an entire song. The interest is the interlock — every instrument in its own time slot — not the harmony.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reggae-minor-vamp',
|
||||||
|
name: 'Minor vamp (i–♭VII)',
|
||||||
|
rn: ['i', '♭VII'],
|
||||||
|
degrees: [0, 10],
|
||||||
|
qualities: ['min', 'maj'],
|
||||||
|
bars: [3, 1],
|
||||||
|
mode: 'minor',
|
||||||
|
songs: ['Get Up, Stand Up — Bob Marley', 'Them Belly Full — Bob Marley (chorus)'],
|
||||||
|
tip: 'Get Up Stand Up is arguably ONE chord — the ♭VII is a passing breath, not a destination. Roots reggae treats harmony as a drone with occasional weather.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reggae-nwnc',
|
||||||
|
name: 'Roots ballad (I–V⁶–vi–IV)',
|
||||||
|
rn: ['I', 'V⁶', 'vi', 'IV'],
|
||||||
|
degrees: [0, 7, 9, 5],
|
||||||
|
qualities: ['maj', 'maj', 'min', 'maj'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['No Woman, No Cry — Bob Marley'],
|
||||||
|
tip: 'Major key despite the tears: the V carries its 3rd in the bass (G/B in C), walking the bassline down by step into the vi. The descent is the emotion.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reggae-rocksteady',
|
||||||
|
name: 'Rocksteady climb (I–ii–iii–ii)',
|
||||||
|
rn: ['I', 'ii', 'iii', 'ii'],
|
||||||
|
degrees: [0, 2, 4, 2],
|
||||||
|
qualities: ['maj', 'min', 'min', 'min'],
|
||||||
|
bars: [1, 1, 1, 1],
|
||||||
|
mode: 'major',
|
||||||
|
songs: ['Queen Majesty — The Techniques (1967)'],
|
||||||
|
tip: 'Rocksteady grew from doo-wop covers, and it shows: a gentle diatonic staircase up and back at ~86 BPM. Soul harmony with the bass carrying the tune.',
|
||||||
|
},
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user