Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b31742e923 | |||
| 53d78ce30f | |||
| ddbca3f197 | |||
| 66a06b2884 | |||
| 8a7f932170 | |||
| e737e0c0b2 | |||
| e692181299 | |||
| 1674c1df86 | |||
| 0cd7a8d735 | |||
| 71d5e445c7 | |||
| 6c1c7966da | |||
| 61abf8a513 | |||
| 66ccc90fdd | |||
| 5bd48da5ac | |||
| 389be97d1a | |||
| d6fa7c9598 | |||
| 51358b2b56 | |||
| fbaf5f4f91 | |||
| dc1778a134 | |||
| 3c3e30de75 |
@@ -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,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.
|
||||
@@ -102,6 +102,16 @@ When the user manually switches mode (the documented K-S limitation — by desig
|
||||
|
||||
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").
|
||||
|
||||
@@ -1,134 +1,122 @@
|
||||
# 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
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **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 |
|
||||
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).
|
||||
|
||||
### 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**
|
||||
- `react` / `react-dom` — UI
|
||||
- `pitchy` — pitch detection
|
||||
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).
|
||||
|
||||
**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
|
||||
cd frontend
|
||||
git clone https://github.com/whattheflat/whattheflat.git
|
||||
cd whattheflat
|
||||
npm install
|
||||
|
||||
# Desktop app (Vite dev server + Electron window, hot reload)
|
||||
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.
|
||||
|
||||
## Building an Installer
|
||||
|
||||
Add app icons to `frontend/assets/` first:
|
||||
- `icon.ico` — Windows
|
||||
- `icon.icns` — macOS
|
||||
- `icon.png` — Linux (256×256 minimum)
|
||||
|
||||
Then build:
|
||||
Prefer the browser? Run the renderer on its own (no Electron shell):
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Windows installer (NSIS)
|
||||
npm run electron:build:win
|
||||
|
||||
# macOS DMG
|
||||
npm run electron:build:mac
|
||||
|
||||
# Linux AppImage
|
||||
npm run electron:build:linux
|
||||
npm run dev # then open http://localhost:5173
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
### Building installers
|
||||
|
||||
```bash
|
||||
# update package.json version first if desired
|
||||
git tag -a v0.6.1 -m "Release v0.6.1"
|
||||
git push origin v0.6.1
|
||||
npm run electron:build:win # Windows NSIS installer → release/
|
||||
npm run electron:build:mac # macOS DMG → release/
|
||||
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.
|
||||
- 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.
|
||||
## Contributing — add a style without deep coding
|
||||
|
||||
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
|
||||
# Windows NSIS
|
||||
npm run electron:build:win
|
||||
- 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.
|
||||
- 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.
|
||||
- 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)
|
||||
npm run electron:build:mac -- --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.
|
||||
|
||||
# Linux AppImage
|
||||
npm run electron:build:linux
|
||||
```
|
||||
---
|
||||
|
||||
CI notes / troubleshooting
|
||||
- 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`.
|
||||
## How it's built — the ensemble
|
||||
|
||||
## 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 |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
The product north star and full roadmap are in [`GOAL.md`](GOAL.md).
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# 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-jam-guide` (branch: `sprint-jam-guide`)
|
||||
|
||||
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) · **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.
|
||||
|
||||
---
|
||||
|
||||
## Backlog (future sprints)
|
||||
|
||||
- **Jam Guide phase 2:** `MiniPiano.jsx` + piano recipe resolver (unblocks piano packs); bass pattern renderer.
|
||||
- **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-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)
|
||||
@@ -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,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,240 @@
|
||||
// Smoke-test harness — asserts the KB registry and the loop matcher are wired
|
||||
// correctly before a merge. Imports the REAL src/data/kb/index.js and
|
||||
// src/lib/match.js (no mocks), runs structural + behavioural checks, and exits
|
||||
// non-zero on any failure so CI can gate on it.
|
||||
//
|
||||
// Run: node scripts/smoke.mjs (exit 1 on any failure, 0 on all-pass)
|
||||
//
|
||||
// Style mirrors scripts/validate-kb.mjs: plain Node ESM, node: imports, a flat
|
||||
// list of checks with ✓/✗ per check, a summary line, and process.exit().
|
||||
import { register } from 'node:module'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
// match.js imports './theory' extensionless (resolved by Vite at build time, but
|
||||
// raw Node ESM requires the extension). Register a tiny resolve hook that retries
|
||||
// a failed extensionless relative specifier with '.js' appended, so we can import
|
||||
// the REAL match.js unmodified (it's locked to task L-01 — we must not touch it).
|
||||
register(
|
||||
'data:text/javascript,' +
|
||||
encodeURIComponent(`
|
||||
export async function resolve(specifier, context, next) {
|
||||
try {
|
||||
return await next(specifier, context)
|
||||
} catch (e) {
|
||||
if (/^\\.{1,2}\\//.test(specifier) && !/\\.[mc]?js$/.test(specifier)) {
|
||||
return await next(specifier + '.js', context)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
`),
|
||||
import.meta.url,
|
||||
)
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const load = async (rel) => (await import(pathToFileURL(join(ROOT, rel)).href))
|
||||
|
||||
// ─── Tiny assertion harness ───────────────────────────────────────────────────
|
||||
|
||||
let passed = 0
|
||||
const failures = []
|
||||
|
||||
function check(label, fn) {
|
||||
try {
|
||||
fn()
|
||||
passed++
|
||||
console.log(` ✓ ${label}`)
|
||||
} catch (e) {
|
||||
failures.push(`${label}: ${e.message}`)
|
||||
console.log(` ✗ ${label} — ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function warn(label, msg) {
|
||||
console.log(` ⚠ ${label} — ${msg} (skipped)`)
|
||||
}
|
||||
|
||||
function assert(cond, msg) {
|
||||
if (!cond) throw new Error(msg)
|
||||
}
|
||||
|
||||
// ─── Load the real modules ────────────────────────────────────────────────────
|
||||
|
||||
const kb = (await load('src/data/kb/index.js')).default
|
||||
const match = await load('src/lib/match.js')
|
||||
const { buildLoopIndex, matchLoopToProgression, findLoopPosition } = match
|
||||
|
||||
// ─── 1. Registry integrity ────────────────────────────────────────────────────
|
||||
|
||||
console.log('\nRegistry integrity:')
|
||||
|
||||
check('KB default export is a non-empty object with ≥1 style', () => {
|
||||
assert(kb && typeof kb === 'object', 'kb default export is not an object')
|
||||
assert(Object.keys(kb).length >= 1, 'kb has no styles')
|
||||
})
|
||||
|
||||
const styleNames = kb && typeof kb === 'object' ? Object.keys(kb) : []
|
||||
const allIds = new Map() // id → style (for uniqueness across the whole KB)
|
||||
|
||||
for (const styleName of styleNames) {
|
||||
const style = kb[styleName]
|
||||
|
||||
check(`style '${styleName}' has meta, progressions[], instruments.guitar`, () => {
|
||||
assert(style && typeof style === 'object', 'style entry is not an object')
|
||||
assert(style.meta && typeof style.meta === 'object', 'missing meta')
|
||||
assert(Array.isArray(style.progressions) && style.progressions.length > 0, 'progressions must be a non-empty array')
|
||||
assert(style.instruments && typeof style.instruments === 'object', 'missing instruments')
|
||||
assert(style.instruments.guitar && typeof style.instruments.guitar === 'object', 'missing instruments.guitar')
|
||||
})
|
||||
|
||||
const progs = Array.isArray(style?.progressions) ? style.progressions : []
|
||||
for (const p of progs) {
|
||||
check(`'${styleName}' progression '${p?.id ?? '?'}' has consistent degrees/qualities/rn/bars + unique id`, () => {
|
||||
assert(typeof p.id === 'string' && p.id.length > 0, 'progression id missing')
|
||||
assert(!allIds.has(p.id), `duplicate id '${p.id}' (also in style '${allIds.get(p.id)}')`)
|
||||
allIds.set(p.id, styleName)
|
||||
const n = Array.isArray(p.degrees) ? p.degrees.length : 0
|
||||
assert(n > 0, 'degrees missing/empty')
|
||||
for (const [field, arr] of [['qualities', p.qualities], ['rn', p.rn], ['bars', p.bars]]) {
|
||||
assert(Array.isArray(arr), `${field} is not an array`)
|
||||
assert(arr.length === n, `${field} length ${arr.length} ≠ degrees length ${n}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 2. Matcher correctness on sample loops ───────────────────────────────────
|
||||
|
||||
console.log('\nMatcher correctness:')
|
||||
|
||||
const index = buildLoopIndex(kb)
|
||||
|
||||
check('buildLoopIndex returns a { byCanonical: Map } shape', () => {
|
||||
assert(index && index.byCanonical instanceof Map, 'byCanonical is not a Map')
|
||||
assert(index.byCanonical.size > 0, 'index is empty')
|
||||
})
|
||||
|
||||
// Helper: rotate a degree array by r so it starts at index r.
|
||||
const rotate = (arr, r) => arr.map((_, i) => arr[(r + i) % arr.length])
|
||||
// Helper: re-base a degree array so its first element is 0 (the loop "shape").
|
||||
const rebase = (arr) => {
|
||||
const base = arr[0]
|
||||
return arr.map((d) => (((d - base) % 12) + 12) % 12)
|
||||
}
|
||||
|
||||
// --- ii–V–I → some jazz 2-5-1, matched:true; derive the expected id from KB ---
|
||||
// (don't hardcode 'jazz-251-major' — look up what the loop actually maps to and
|
||||
// assert its structural truth: the matched progression's degrees rotate to the
|
||||
// loop's degree shape.)
|
||||
{
|
||||
const loop = ['Dm7', 'G7', 'Cmaj7'] // ii–V–I in C → degree shape [0,5,10]
|
||||
const r = matchLoopToProgression(loop, index)
|
||||
|
||||
check('ii–V–I [Dm7,G7,Cmaj7] matches a progression (matched:true)', () => {
|
||||
assert(r.matched === true, `expected matched:true, got ${JSON.stringify(r)}`)
|
||||
assert(typeof r.id === 'string' && r.id.length > 0, 'matched but no id')
|
||||
assert(kb[r.style], `matched style '${r.style}' not in KB`)
|
||||
})
|
||||
|
||||
check("ii–V–I maps to a 2-5-1-shaped progression (derived from KB)", () => {
|
||||
// Structural: the matched progression's degrees, rotated by the reported
|
||||
// rotation to align with the loop, must equal the loop's own degree shape.
|
||||
const loopShape = rebase([2, 7, 0]) // Dm7 G7 Cmaj7 pcs → re-based shape [0,5,10]
|
||||
const kbDeg = r.progression.degrees
|
||||
const rotatedKbShape = rebase(rotate(kbDeg, r.rotation))
|
||||
assert(rotatedKbShape.join(',') === loopShape.join(','),
|
||||
`KB degrees ${kbDeg} rotated by ${r.rotation} → ${rotatedKbShape} ≠ loop shape ${loopShape}`)
|
||||
// Soft: this loop is a major 2-5-1, expect the major-quality (maj7) candidate.
|
||||
assert(/251/.test(r.id) || r.progression.qualities.includes('maj7'),
|
||||
`expected a 2-5-1-shaped id, got '${r.id}'`)
|
||||
})
|
||||
|
||||
// Remember the id for the rotation test below (derived, not hardcoded).
|
||||
globalThis.__ii_v_i_id = r.id
|
||||
}
|
||||
|
||||
// --- A rotation of ii–V–I matches the SAME id with non-zero rotation ----------
|
||||
{
|
||||
const loop = ['G7', 'Cmaj7', 'Dm7'] // V–I–ii: same loop, rotated by 1
|
||||
const r = matchLoopToProgression(loop, index)
|
||||
|
||||
check('rotation [G7,Cmaj7,Dm7] matches the SAME id as ii–V–I', () => {
|
||||
assert(r.matched === true, `expected matched:true, got ${JSON.stringify(r)}`)
|
||||
assert(r.id === globalThis.__ii_v_i_id,
|
||||
`rotation matched '${r.id}', expected same id '${globalThis.__ii_v_i_id}'`)
|
||||
})
|
||||
|
||||
check('rotation reports a non-zero rotation index', () => {
|
||||
assert(r.rotation !== 0, `expected non-zero rotation, got ${r.rotation}`)
|
||||
assert(r.rotation > 0 && r.rotation < loop.length, `rotation ${r.rotation} out of range`)
|
||||
// The rotation index should point at the loop slot aligning with KB degrees[0].
|
||||
// KB ii–V–I starts on ii (Dm7); Dm7 is at loop index 2 here.
|
||||
assert(loop[r.rotation] === 'Dm7', `loop[${r.rotation}] is '${loop[r.rotation]}', expected 'Dm7'`)
|
||||
})
|
||||
}
|
||||
|
||||
// --- I–V–vi–IV axis loop → axis progression (pop may be absent) ----------------
|
||||
{
|
||||
const loop = ['C', 'G', 'Am', 'F'] // I–V–vi–IV, degree shape [0,7,9,5]
|
||||
const r = matchLoopToProgression(loop, index)
|
||||
const loopShape = rebase([0, 7, 9, 5])
|
||||
|
||||
if (r.matched) {
|
||||
check('axis [C,G,Am,F] matches an axis-shaped progression', () => {
|
||||
assert(kb[r.style], `matched style '${r.style}' not in KB`)
|
||||
// Whatever style owns the axis, its degrees must rotate to the loop shape.
|
||||
const rotatedKbShape = rebase(rotate(r.progression.degrees, r.rotation))
|
||||
assert(rotatedKbShape.join(',') === loopShape.join(','),
|
||||
`matched '${r.id}' degrees ${r.progression.degrees} rotated by ${r.rotation} don't match axis shape ${loopShape}`)
|
||||
// The axis is the four-chord I–V–vi–IV; sanity-check it's a 4-chord loop.
|
||||
assert(r.progression.degrees.length === 4, `expected a 4-chord axis, got length ${r.progression.degrees.length}`)
|
||||
})
|
||||
} else {
|
||||
warn('axis [C,G,Am,F] match', 'no axis-shaped 4-chord progression in current KB')
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chromatic / garbage loop → matched:false, no throw -----------------------
|
||||
{
|
||||
check('chromatic loop [C,C#,D] returns matched:false cleanly (no throw)', () => {
|
||||
const r = matchLoopToProgression(['C', 'C#', 'D'], index)
|
||||
assert(r && r.matched === false, `expected matched:false, got ${JSON.stringify(r)}`)
|
||||
assert(r.id === null, `expected id:null on no-match, got ${r.id}`)
|
||||
})
|
||||
}
|
||||
|
||||
// --- findLoopPosition: in-range index for a known loop, -1 for no-match --------
|
||||
{
|
||||
const loop = ['Dm7', 'G7', 'Cmaj7']
|
||||
|
||||
check('findLoopPosition returns an in-range index for a known loop', () => {
|
||||
const history = ['Dm7', 'G7'] // player is on G7
|
||||
const pos = findLoopPosition(history, loop)
|
||||
assert(Number.isInteger(pos), `expected an integer, got ${pos}`)
|
||||
assert(pos >= 0 && pos < loop.length, `index ${pos} out of range [0,${loop.length})`)
|
||||
assert(loop[pos] === 'G7', `loop[${pos}] is '${loop[pos]}', expected 'G7'`)
|
||||
})
|
||||
|
||||
check('findLoopPosition returns -1 when the last chord is not in the loop', () => {
|
||||
const pos = findLoopPosition(['Ebmaj7'], loop)
|
||||
assert(pos === -1, `expected -1, got ${pos}`)
|
||||
})
|
||||
|
||||
check('findLoopPosition returns -1 on empty inputs', () => {
|
||||
assert(findLoopPosition([], loop) === -1, 'empty history should be -1')
|
||||
assert(findLoopPosition(['Dm7'], []) === -1, 'empty loop should be -1')
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 3. Summary + exit code ───────────────────────────────────────────────────
|
||||
|
||||
const total = passed + failures.length
|
||||
console.log('')
|
||||
if (failures.length) {
|
||||
console.error(`✗ Smoke test FAILED — ${failures.length}/${total} check(s) failed:\n`)
|
||||
for (const f of failures) console.error(' ' + f)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`✓ Smoke test passed — ${passed}/${total} checks green (${styleNames.length} styles, ${allIds.size} progressions)`)
|
||||
process.exit(0)
|
||||
+19
-1
@@ -13,6 +13,7 @@ import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingP
|
||||
import ChordDetailModal from './components/ChordDetailModal'
|
||||
import CurrentJamPanel from './components/CurrentJamPanel'
|
||||
import LoopStation from './components/LoopStation'
|
||||
import JamGuide from './components/JamGuide'
|
||||
import { useLoopEngine } from './services/loopEngine'
|
||||
import settingIcon from './assets/setting-icon.png'
|
||||
|
||||
@@ -60,6 +61,13 @@ export default function App() {
|
||||
const [showDrumView, setShowDrumView] = useState(false)
|
||||
const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false))
|
||||
|
||||
// ── 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 ──────────────────────────────────────────────────────
|
||||
const [micError, setMicError] = useState(null)
|
||||
|
||||
@@ -568,7 +576,7 @@ export default function App() {
|
||||
{/* ── Instrument + progressions row ── */}
|
||||
<div className="flex gap-3 mb-3 items-stretch">
|
||||
<div className="w-full lg:w-[70%] min-w-0">
|
||||
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} />}
|
||||
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} jamFocusChord={jamFocusChord} />}
|
||||
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
||||
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
||||
</div>
|
||||
@@ -658,6 +666,16 @@ export default function App() {
|
||||
</button>
|
||||
{showTuner && <div className="border-t border-border"><Tuner /></div>}
|
||||
</div>
|
||||
|
||||
{/* ── Jam Guide — bottom dock (Roadmap) ── */}
|
||||
<JamGuide
|
||||
detectedProgression={detectedProgression}
|
||||
keyInfo={effectiveKey}
|
||||
chordHistory={chordHistory}
|
||||
bpm={bpm}
|
||||
currentChord={currentChord}
|
||||
onFocusChord={setJamFocusChord}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
const STRINGS = [
|
||||
@@ -37,7 +37,7 @@ function noteColor(isChordTone, isPenta, isScale, mono = false) {
|
||||
return null
|
||||
}
|
||||
|
||||
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false }) {
|
||||
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false, jamFocusChord = null }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
|
||||
if (!root) return null
|
||||
@@ -50,11 +50,40 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
||||
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
|
||||
: 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
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest 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>
|
||||
|
||||
<div>
|
||||
@@ -121,23 +150,51 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
||||
Array.from({ length: NUM_FRETS }, (_, fi) => {
|
||||
const pc = (str.root + fi) % 12
|
||||
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 cy = stringY(si)
|
||||
const baseFill = color ? color.fill : '#2a2a2a'
|
||||
const baseText = color ? color.text : '#a855f7'
|
||||
|
||||
return (
|
||||
<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
|
||||
x={cx} y={cy + 4}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
fontWeight="600"
|
||||
fill={color.text}
|
||||
fill={baseText}
|
||||
>
|
||||
{NOTES[pc]}
|
||||
</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>
|
||||
)
|
||||
})
|
||||
@@ -145,10 +202,18 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
||||
<div className="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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { useState, useMemo, useRef, useEffect } from 'react'
|
||||
import kb from '../data/kb/index.js'
|
||||
import { buildLoopIndex, matchLoopToProgression, findLoopPosition, chordRootPC } from '../lib/match'
|
||||
import { NOTES, CHORD_TYPES } from '../lib/theory'
|
||||
import RoadmapTrack from './RoadmapTrack'
|
||||
import ChordDiagram from './ChordDiagram'
|
||||
|
||||
// ─── JamGuide — the Roadmap bottom dock ───────────────────────────────────────
|
||||
//
|
||||
// The large bottom panel of JamBuddy. This is the SHELL (task L-02): the
|
||||
// collapsed header bar, instrument + style tabs (derived from the KB registry),
|
||||
// live loop → KB progression resolution, and a clearly-marked placeholder slot
|
||||
// where the Roadmap visualization (RoadmapTrack + ChordDiagram, task D-02) will
|
||||
// be wired in afterwards.
|
||||
//
|
||||
// This component does NOT import RoadmapTrack or ChordDiagram — sibling tasks
|
||||
// build those in parallel; D-02 fills the [data-roadmap-slot] left here.
|
||||
//
|
||||
// Props (the contract D-02 relies on):
|
||||
// detectedProgression : string[] | null — the live detected loop (chord names)
|
||||
// keyInfo : { root, mode, confidence } | null — effective key
|
||||
// chordHistory : string[] — committed chord history (for position)
|
||||
// bpm : number | null — live tempo from the onset pipeline
|
||||
// currentChord : string | undefined — most recent committed chord
|
||||
|
||||
// Display order for instrument tabs; availability is derived from the KB, not hardcoded.
|
||||
const INSTRUMENTS = [
|
||||
{ id: 'guitar', label: 'Guitar', icon: '🎸' },
|
||||
{ id: 'piano', label: 'Piano', icon: '🎹' },
|
||||
{ id: 'bass', label: 'Bass', icon: '🎵' },
|
||||
]
|
||||
|
||||
export default function JamGuide({ detectedProgression, keyInfo, chordHistory = [], bpm, currentChord, onFocusChord }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Which instruments have at least one KB pack across the registry.
|
||||
const availableInstruments = useMemo(() => {
|
||||
const set = new Set()
|
||||
for (const style of Object.values(kb)) {
|
||||
for (const inst of Object.keys(style?.instruments ?? {})) set.add(inst)
|
||||
}
|
||||
return set
|
||||
}, [])
|
||||
|
||||
// Style tabs straight from the KB registry, labelled via each style's meta.
|
||||
const styles = useMemo(
|
||||
() => Object.entries(kb).map(([id, style]) => ({ id, label: style?.meta?.label ?? id })),
|
||||
[]
|
||||
)
|
||||
|
||||
// Build the rotation-invariant loop index once.
|
||||
const kbIndex = useMemo(() => buildLoopIndex(kb), [])
|
||||
|
||||
// Resolve the live loop → KB progression + rotation, and the current station.
|
||||
// Keyed on the loop input so this only recomputes when the loop changes.
|
||||
const loopKey = detectedProgression ? detectedProgression.join(',') : ''
|
||||
const match = useMemo(
|
||||
() => matchLoopToProgression(detectedProgression, kbIndex),
|
||||
[loopKey, kbIndex] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
)
|
||||
const position = useMemo(
|
||||
() => findLoopPosition(chordHistory, detectedProgression),
|
||||
[chordHistory, loopKey] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
)
|
||||
|
||||
// Instrument tab: default to guitar (the only packs that exist today).
|
||||
const [instrument, setInstrument] = useState('guitar')
|
||||
|
||||
// Style tab: follow the matched style, but let the user override.
|
||||
const [styleOverride, setStyleOverride] = useState(null)
|
||||
const activeStyle = styleOverride ?? (match.matched ? match.style : styles[0]?.id)
|
||||
|
||||
// Header summary: matched progression name, or a listening hint.
|
||||
const matchedName = match.matched ? match.progression?.name : null
|
||||
const headerLabel = matchedName
|
||||
? matchedName
|
||||
: (detectedProgression?.length ? 'mapping the changes…' : 'listening…')
|
||||
|
||||
// ── Key root: keyInfo.root is a note NAME (e.g. "C"). RoadmapTrack and
|
||||
// ChordDiagram both want a pitch class 0–11. Convert once; default to C (0)
|
||||
// until a key is known so the roadmap still resolves to *some* spelling. ──
|
||||
const keyRoot = useMemo(() => {
|
||||
const pc = chordRootPC(keyInfo?.root)
|
||||
return pc >= 0 ? pc : 0
|
||||
}, [keyInfo?.root])
|
||||
const keyMode = keyInfo?.mode === 'minor' ? 'minor' : 'major'
|
||||
|
||||
// ── Playhead reconciliation ────────────────────────────────────────────────
|
||||
// `position` from findLoopPosition is an index into the *detected* loop, which
|
||||
// can start on any rotation of the KB progression. RoadmapTrack renders the
|
||||
// progression in *canonical KB order* (degrees[0] first). They differ by
|
||||
// `match.rotation` — the loop index that aligns with KB degrees[0]. To map a
|
||||
// detected-loop index back to its canonical station:
|
||||
// canonicalPos = ((position − rotation) mod n + n) mod n
|
||||
// Worked example — KB blues-turnaround [I VI ii V] looped as [ii V I VI]:
|
||||
// rotation = 2 (loop index 2 = the "I" = KB degrees[0]).
|
||||
// Playhead on the V (detected index 1) → ((1 − 2) % 4 + 4) % 4 = 3 = the V's
|
||||
// canonical station. NOW lands on the right station. ✓
|
||||
const canonicalPos = useMemo(() => {
|
||||
if (!match.matched) return -1
|
||||
const n = match.progression?.degrees?.length ?? 0
|
||||
if (!n || typeof position !== 'number' || position < 0) return -1
|
||||
return (((position - match.rotation) % n) + n) % n
|
||||
}, [match.matched, match.progression, match.rotation, position])
|
||||
|
||||
// ── Per-station voicing shapes from the KB ──────────────────────────────────
|
||||
// For the matched style + progression id, pull the recommended guitar play
|
||||
// (the first play). Its `chords` array is in canonical KB order — chords[i]
|
||||
// aligns 1:1 with progression.degrees[i] (the same station order RoadmapTrack
|
||||
// renders). Each entry: { shape, note }. A station with no shape → graceful gap.
|
||||
const stationVoicings = useMemo(() => {
|
||||
if (!match.matched || instrument !== 'guitar') return []
|
||||
const prog = match.progression
|
||||
const styleId = match.style
|
||||
const plays = kb[styleId]?.instruments?.guitar?.plays?.[prog?.id]
|
||||
const play = Array.isArray(plays) ? plays[0] : null
|
||||
const chords = play?.chords ?? []
|
||||
const degrees = prog?.degrees ?? []
|
||||
const qualities = prog?.qualities ?? []
|
||||
return degrees.map((deg, i) => {
|
||||
const rootPc = (((keyRoot + deg) % 12) + 12) % 12
|
||||
const noteName = NOTES[rootPc]
|
||||
const suffix = CHORD_TYPES[qualities[i]]?.suffix ?? ''
|
||||
return {
|
||||
shape: chords[i]?.shape ?? null,
|
||||
rootPc,
|
||||
quality: qualities[i] ?? 'maj',
|
||||
label: `${noteName}${suffix}`,
|
||||
rn: prog?.rn?.[i] ?? '',
|
||||
}
|
||||
})
|
||||
}, [match.matched, match.progression, match.style, instrument, keyRoot])
|
||||
|
||||
// ── Tap-to-enlarge: which station's voicing is expanded (full diagram). ──
|
||||
const [selectedStation, setSelectedStation] = useState(null)
|
||||
// Reset the selection whenever the loop or style changes underneath us.
|
||||
useEffect(() => { setSelectedStation(null) }, [match.id, match.style, instrument])
|
||||
|
||||
// ── Cross-link to the main Fretboard (D-03) ─────────────────────────────────
|
||||
// When a station is selected, report its {rootPc, quality} upward so the
|
||||
// Fretboard can light that chord's guide tones; clear (null) on deselect. The
|
||||
// reset effect above sets selectedStation → null on loop/style/instrument
|
||||
// change, which flows through here and clears the highlight too. Guarded so
|
||||
// the component still works standalone (onFocusChord optional).
|
||||
useEffect(() => {
|
||||
if (!onFocusChord) return
|
||||
const st = selectedStation != null ? stationVoicings[selectedStation] : null
|
||||
onFocusChord(st ? { rootPc: st.rootPc, quality: st.quality } : null)
|
||||
}, [selectedStation, stationVoicings, onFocusChord])
|
||||
|
||||
// Clear the Fretboard highlight when JamGuide unmounts.
|
||||
useEffect(() => () => { onFocusChord?.(null) }, [onFocusChord])
|
||||
|
||||
return (
|
||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||
|
||||
{/* ── Collapsed header bar (always visible) ── */}
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-accent/5 transition-colors"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-base shrink-0">🎸</span>
|
||||
<span className="text-sm font-semibold text-accent shrink-0">Jam Guide</span>
|
||||
<span className="text-gray-600 shrink-0">—</span>
|
||||
<span className="text-sm text-gray-300 truncate">{headerLabel}</span>
|
||||
{match.matched && keyInfo?.root && (
|
||||
<span className="text-xs text-gray-500 shrink-0">
|
||||
in {keyInfo.root} {keyInfo.mode}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-gray-500 shrink-0 ml-3">{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{/* ── Expanded body (~70vh) ── */}
|
||||
{open && (
|
||||
<div className="border-t border-border flex flex-col" style={{ height: '70vh' }}>
|
||||
|
||||
{/* ── Tab rows ── */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2 border-b border-border">
|
||||
|
||||
{/* Instrument tabs */}
|
||||
<div className="flex items-center gap-1">
|
||||
{INSTRUMENTS.map(inst => {
|
||||
const enabled = availableInstruments.has(inst.id)
|
||||
const active = enabled && inst.id === instrument
|
||||
return (
|
||||
<button
|
||||
key={inst.id}
|
||||
onClick={() => enabled && setInstrument(inst.id)}
|
||||
disabled={!enabled}
|
||||
title={enabled ? inst.label : `${inst.label} packs coming soon`}
|
||||
className={`px-3 py-1 rounded-lg text-sm font-medium transition-colors flex items-center gap-1.5 ${
|
||||
active
|
||||
? 'bg-accent/20 border border-accent text-accent'
|
||||
: enabled
|
||||
? 'border border-border text-gray-300 hover:border-gray-500 hover:text-gray-100'
|
||||
: 'border border-border/50 text-gray-600 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<span>{inst.icon}</span>
|
||||
<span>{inst.label}</span>
|
||||
{!enabled && <span className="text-[10px] text-gray-700 ml-0.5">soon</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-5 bg-border shrink-0" />
|
||||
|
||||
{/* Style tabs (from the KB registry) */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{styles.map(style => {
|
||||
const active = style.id === activeStyle
|
||||
const isMatched = match.matched && style.id === match.style
|
||||
return (
|
||||
<button
|
||||
key={style.id}
|
||||
onClick={() => setStyleOverride(style.id)}
|
||||
className={`px-2.5 py-1 rounded-lg text-sm transition-colors ${
|
||||
active
|
||||
? 'bg-accent/20 border border-accent text-accent font-semibold'
|
||||
: 'border border-transparent text-gray-400 hover:text-gray-200 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{style.label}
|
||||
{isMatched && <span className="ml-1 text-accent/70" title="matches your loop">●</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Roadmap slot (D-02 assembly) ── */}
|
||||
<div data-roadmap-slot className="flex-1 min-h-0 p-4 overflow-auto">
|
||||
{match.matched ? (
|
||||
<RoadmapAssembly
|
||||
progression={match.progression}
|
||||
keyRoot={keyRoot}
|
||||
keyMode={keyMode}
|
||||
position={canonicalPos}
|
||||
bpm={bpm}
|
||||
stationVoicings={stationVoicings}
|
||||
selectedStation={selectedStation}
|
||||
onSelectStation={setSelectedStation}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="h-full min-h-[200px] flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border text-center"
|
||||
>
|
||||
<p className="text-sm text-gray-400">Play a few bars — I'll map the changes</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
{detectedProgression?.length
|
||||
? `Heard ${detectedProgression.join(' → ')}, but it doesn't match a ${activeStyle} pattern yet.`
|
||||
: 'Roadmap renders here once a repeating loop is detected.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── RoadmapAssembly — the live panel body ────────────────────────────────────
|
||||
//
|
||||
// Composes RoadmapTrack (the improv highway) with a secondary voicing strip of
|
||||
// ChordDiagram thumbnails (one per station, canonical KB order). Tapping a
|
||||
// thumbnail enlarges it to a full diagram inline. The active station auto-scrolls
|
||||
// into view. Narrow viewports (< ~640px) reflow: the strip wraps and the whole
|
||||
// panel scrolls vertically rather than forcing a wide horizontal layout.
|
||||
function RoadmapAssembly({
|
||||
progression, keyRoot, keyMode, position, bpm,
|
||||
stationVoicings, selectedStation, onSelectStation,
|
||||
}) {
|
||||
const stripRef = useRef(null)
|
||||
const activeRef = useRef(null)
|
||||
|
||||
// Auto-scroll the active station's thumbnail into view as the playhead moves.
|
||||
// Prop-driven (off `position`) — no rAF loop tied to the audio thread.
|
||||
useEffect(() => {
|
||||
if (position < 0 || !activeRef.current) return
|
||||
activeRef.current.scrollIntoView({
|
||||
behavior: 'smooth', inline: 'center', block: 'nearest',
|
||||
})
|
||||
}, [position])
|
||||
|
||||
const selected = selectedStation != null ? stationVoicings[selectedStation] : null
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4">
|
||||
{/* The improv highway — active-station styling + playhead live inside it. */}
|
||||
<RoadmapTrack
|
||||
progression={progression}
|
||||
keyRoot={keyRoot}
|
||||
keyMode={keyMode}
|
||||
position={position}
|
||||
bpm={bpm}
|
||||
/>
|
||||
|
||||
{/* Secondary voicing strip: one thumbnail per station, canonical order. */}
|
||||
{stationVoicings.length > 0 && (
|
||||
<section
|
||||
className="rounded-2xl border border-border bg-panel p-3"
|
||||
aria-label="Voicing thumbnails"
|
||||
>
|
||||
<h4 className="mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-500">
|
||||
Voicings · tap to enlarge
|
||||
</h4>
|
||||
<div
|
||||
ref={stripRef}
|
||||
className="flex flex-wrap gap-2 overflow-x-auto sm:flex-nowrap"
|
||||
role="list"
|
||||
>
|
||||
{stationVoicings.map((st, i) => {
|
||||
const isNow = i === position
|
||||
const isSelected = i === selectedStation
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
ref={isNow ? activeRef : undefined}
|
||||
type="button"
|
||||
role="listitem"
|
||||
aria-pressed={isSelected}
|
||||
aria-current={isNow ? 'true' : undefined}
|
||||
onClick={() => onSelectStation(isSelected ? null : i)}
|
||||
title={`${st.label} — ${st.rn || `station ${i + 1}`}`}
|
||||
className={
|
||||
`flex shrink-0 flex-col items-center gap-1 rounded-lg border p-2 outline-none transition ` +
|
||||
`focus-visible:ring-2 focus-visible:ring-accent ` +
|
||||
(isNow
|
||||
? 'border-accent bg-accent/10 ring-1 ring-accent'
|
||||
: isSelected
|
||||
? 'border-accent/60 bg-accent/5'
|
||||
: 'border-border bg-surface hover:border-gray-500')
|
||||
}
|
||||
style={{ opacity: isNow ? 1 : 0.85 }}
|
||||
>
|
||||
<ChordDiagram
|
||||
shape={st.shape}
|
||||
keyRoot={keyRoot}
|
||||
rootPc={st.rootPc}
|
||||
size="thumb"
|
||||
label={st.label}
|
||||
/>
|
||||
{st.rn && (
|
||||
<span className="text-[9px] font-medium uppercase tracking-wide text-gray-500">
|
||||
{st.rn}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Enlarged view of the tapped station (deferred fretboard cross-link
|
||||
lives here instead — see D-02 return note). */}
|
||||
{selected && (
|
||||
<div className="mt-3 flex flex-col items-center gap-2 border-t border-border pt-3">
|
||||
<ChordDiagram
|
||||
shape={selected.shape}
|
||||
keyRoot={keyRoot}
|
||||
rootPc={selected.rootPc}
|
||||
size="full"
|
||||
label={`${selected.label}${selected.rn ? ` · ${selected.rn}` : ''}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectStation(null)}
|
||||
className="text-[11px] text-gray-500 underline-offset-2 hover:text-gray-300 hover:underline focus-visible:ring-2 focus-visible:ring-accent rounded outline-none"
|
||||
>
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,9 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { toRomanNumeral } from '../lib/theory'
|
||||
import { findLoopPosition } from '../lib/match'
|
||||
|
||||
const HISTORY_SHOWN = 8
|
||||
|
||||
function findLoopPosition(chordHistory, progression) {
|
||||
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, onChordClick }) {
|
||||
const { root, mode, confidence } = keyInfo ?? {}
|
||||
|
||||
|
||||
@@ -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,216 @@
|
||||
// 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) ---
|
||||
const DIM7_5 = { rootStr: 5, offsets: ['x', 0, 1, -1, 1, 'x'], fingers: [0, 2, 3, 1, 4, 0] } // R–♭5–𝄫7–♭3 (symmetric)
|
||||
|
||||
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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
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,64 @@
|
||||
// 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.
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
]
|
||||
@@ -24,6 +24,12 @@ 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 gospelMeta from './gospel/meta.js'
|
||||
import gospelProgressions from './gospel/progressions.js'
|
||||
import gospelGuitar from './gospel/guitar.js'
|
||||
import popMeta from './pop/meta.js'
|
||||
import popProgressions from './pop/progressions.js'
|
||||
import popGuitar from './pop/guitar.js'
|
||||
|
||||
export default {
|
||||
jazz: {
|
||||
@@ -66,4 +72,14 @@ export default {
|
||||
progressions: rnbProgressions,
|
||||
instruments: { guitar: rnbGuitar },
|
||||
},
|
||||
gospel: {
|
||||
meta: gospelMeta,
|
||||
progressions: gospelProgressions,
|
||||
instruments: { guitar: gospelGuitar },
|
||||
},
|
||||
pop: {
|
||||
meta: popMeta,
|
||||
progressions: popProgressions,
|
||||
instruments: { guitar: popGuitar },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,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,232 @@
|
||||
// ─── Loop matching & positioning ──────────────────────────────────────────────
|
||||
//
|
||||
// Shared, rotation-invariant loop matcher used by both the live ProgressionBanner
|
||||
// and the upcoming Jam Guide (Roadmap) panel.
|
||||
//
|
||||
// Two concerns live here:
|
||||
// 1. POSITION — where in a known loop the player currently is (drives playheads).
|
||||
// 2. IDENTITY — which KB progression a detected loop *is*, independent of which
|
||||
// chord it happens to start on ([I IV V] == [IV V I] == [V I IV]).
|
||||
//
|
||||
// This file is matching/position logic only. All music-theory primitives
|
||||
// (note names, roman numerals) come read-only from theory.js.
|
||||
|
||||
import { NOTES, NOTES_FLAT, toRomanNumeral } from './theory'
|
||||
|
||||
// ─── Chord-name parsing (local — theory.js does not export a pitch-class helper) ─
|
||||
|
||||
// Maps a note name (sharp or flat spelling) to its pitch class 0–11, or -1.
|
||||
function pitchClassOf(note) {
|
||||
if (!note) return -1
|
||||
const idx = NOTES.indexOf(note)
|
||||
if (idx !== -1) return idx
|
||||
return NOTES_FLAT.indexOf(note)
|
||||
}
|
||||
|
||||
// Splits a chord name like "Cm7" / "F#maj7" / "Bb" into { root, quality }.
|
||||
function parseChord(chordName) {
|
||||
if (!chordName || typeof chordName !== 'string') return null
|
||||
const m = chordName.match(/^([A-G][b#]?)(.*)$/)
|
||||
if (!m) return null
|
||||
return { root: m[1], quality: m[2] }
|
||||
}
|
||||
|
||||
// Pitch class (0–11) of a chord name's root, or -1 if unparseable.
|
||||
export function chordRootPC(chordName) {
|
||||
const parsed = parseChord(chordName)
|
||||
if (!parsed) return -1
|
||||
return pitchClassOf(parsed.root)
|
||||
}
|
||||
|
||||
// Maps a chord-name suffix to a KB quality token (the vocabulary used in
|
||||
// kb/**/progressions.js `qualities` arrays). Used as a soft tie-breaker only.
|
||||
function suffixToQuality(suffix) {
|
||||
switch (suffix) {
|
||||
case '': return 'maj'
|
||||
case 'm': return 'min'
|
||||
case '7': return 'dom7'
|
||||
case 'maj7': return 'maj7'
|
||||
case 'm7': return 'min7'
|
||||
case 'dim': return 'dim'
|
||||
case 'dim7': return 'dim7'
|
||||
case 'm7b5': return 'half_dim'
|
||||
case 'aug': return 'aug'
|
||||
case 'sus4': return 'sus4'
|
||||
case 'sus2': return 'sus2'
|
||||
case '6': return 'maj6'
|
||||
case 'm6': return 'min6'
|
||||
case 'add9': return 'add9'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Position within a known loop ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* findLoopPosition(chordHistory, loop) → index in `loop` the player is on, or -1.
|
||||
*
|
||||
* `loop` is an array of chord-name strings (e.g. the detected progression, or a
|
||||
* KB progression rendered into chord names for the current key). Robust to the
|
||||
* most recent chord: walks back from each occurrence of the last chord and keeps
|
||||
* the position whose preceding chords best continue the recent history; falls
|
||||
* back to the first plain occurrence of the last chord.
|
||||
*/
|
||||
export function findLoopPosition(chordHistory, loop) {
|
||||
if (!loop?.length || !chordHistory?.length) return -1
|
||||
const last = chordHistory[chordHistory.length - 1]
|
||||
for (let p = loop.length - 1; p >= 0; p--) {
|
||||
if (loop[p] !== last) continue
|
||||
let match = true
|
||||
for (let i = 1; i < Math.min(p + 1, chordHistory.length); i++) {
|
||||
if (loop[p - i] !== chordHistory[chordHistory.length - 1 - i]) { match = false; break }
|
||||
}
|
||||
if (match) return p
|
||||
}
|
||||
return loop.indexOf(last)
|
||||
}
|
||||
|
||||
// ─── Degree-relative, rotation-invariant identity ─────────────────────────────
|
||||
|
||||
// Turns a degree sequence (semitone offsets) into the canonical rotation:
|
||||
// for each rotation, re-base so the first element is 0, then pick the
|
||||
// lexicographically smallest resulting sequence. Same loop → same string,
|
||||
// regardless of which chord it starts on.
|
||||
function canonicalDegrees(degrees) {
|
||||
const n = degrees.length
|
||||
if (n === 0) return ''
|
||||
let best = null
|
||||
for (let r = 0; r < n; r++) {
|
||||
const base = degrees[r]
|
||||
const rot = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = degrees[(r + i) % n]
|
||||
rot.push(((d - base) % 12 + 12) % 12)
|
||||
}
|
||||
const key = rot.join(',')
|
||||
if (best === null || key < best) best = key
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// Converts a loop of chord-name strings into semitone offsets from the loop's
|
||||
// own first chord. Returns null if any chord root is unparseable.
|
||||
function loopToDegrees(loop) {
|
||||
if (!loop?.length) return null
|
||||
const pcs = loop.map(chordRootPC)
|
||||
if (pcs.some(pc => pc < 0)) return null
|
||||
const tonic = pcs[0]
|
||||
return pcs.map(pc => ((pc - tonic) % 12 + 12) % 12)
|
||||
}
|
||||
|
||||
/**
|
||||
* Precompute a lookup table from a KB registry (kb/index.js default export).
|
||||
* Returns { byCanonical: Map<canonicalDegrees, entry[]> } where each entry is
|
||||
* { style, id, progression }. Build once, reuse across matches.
|
||||
*/
|
||||
export function buildLoopIndex(kb) {
|
||||
const byCanonical = new Map()
|
||||
if (!kb) return { byCanonical }
|
||||
for (const style of Object.keys(kb)) {
|
||||
const progs = kb[style]?.progressions
|
||||
if (!Array.isArray(progs)) continue
|
||||
for (const progression of progs) {
|
||||
if (!Array.isArray(progression.degrees) || !progression.degrees.length) continue
|
||||
const canon = canonicalDegrees(progression.degrees)
|
||||
const entry = { style, id: progression.id, progression }
|
||||
if (!byCanonical.has(canon)) byCanonical.set(canon, [])
|
||||
byCanonical.get(canon).push(entry)
|
||||
}
|
||||
}
|
||||
return { byCanonical }
|
||||
}
|
||||
|
||||
// A "no match" result, shared so callers can compare shape consistently.
|
||||
const NO_MATCH = { matched: false, id: null, style: null, rotation: 0, progression: null }
|
||||
|
||||
/**
|
||||
* matchLoopToProgression(loop, kbOrIndex) → match result.
|
||||
*
|
||||
* `loop` is an array of chord-name strings (the detected repeating progression).
|
||||
* `kbOrIndex` is either the kb/index.js default export OR a prebuilt index from
|
||||
* buildLoopIndex() (preferred for repeated calls).
|
||||
*
|
||||
* Rotation-invariant: a detected [F7 G7 Cmaj7] (= IV V I) matches a KB
|
||||
* progression stored as ii–V–I etc. when their degree shapes coincide.
|
||||
*
|
||||
* Returns:
|
||||
* { matched: true, id, style, rotation, progression }
|
||||
* rotation = index into `loop` that aligns with the KB progression's first
|
||||
* degree (degrees[0]); callers rotate the loop by `rotation` to put it in
|
||||
* canonical KB order for the playhead.
|
||||
* { matched: false, id: null, style: null, rotation: 0, progression: null }
|
||||
*/
|
||||
export function matchLoopToProgression(loop, kbOrIndex) {
|
||||
const degrees = loopToDegrees(loop)
|
||||
if (!degrees) return NO_MATCH
|
||||
|
||||
const index = kbOrIndex?.byCanonical instanceof Map ? kbOrIndex : buildLoopIndex(kbOrIndex)
|
||||
const canon = canonicalDegrees(degrees)
|
||||
const candidates = index.byCanonical.get(canon)
|
||||
if (!candidates || !candidates.length) return NO_MATCH
|
||||
|
||||
// Disambiguate same-shape progressions (e.g. major vs minor ii–V) by chord
|
||||
// quality overlap with the loop, then by fewer chords (the tighter loop).
|
||||
const loopQualities = loop.map(c => {
|
||||
const p = parseChord(c)
|
||||
return p ? suffixToQuality(p.quality) : null
|
||||
})
|
||||
|
||||
let best = null
|
||||
let bestScore = -Infinity
|
||||
for (const cand of candidates) {
|
||||
const kbQ = cand.progression.qualities
|
||||
let qScore = 0
|
||||
if (Array.isArray(kbQ) && kbQ.length) {
|
||||
const kbSet = new Set(kbQ)
|
||||
for (const q of loopQualities) if (q && kbSet.has(q)) qScore++
|
||||
}
|
||||
// Prefer quality overlap; break ties toward shorter canonical progressions.
|
||||
const score = qScore * 100 - cand.progression.degrees.length
|
||||
if (score > bestScore) { bestScore = score; best = cand }
|
||||
}
|
||||
if (!best) return NO_MATCH
|
||||
|
||||
return {
|
||||
matched: true,
|
||||
id: best.id,
|
||||
style: best.style,
|
||||
rotation: rotationToCanonicalOrder(degrees, best.progression.degrees),
|
||||
progression: best.progression,
|
||||
}
|
||||
}
|
||||
|
||||
// Finds the rotation `r` of the loop's degree sequence that matches the KB
|
||||
// progression's degree shape (re-based to start at the KB's first degree).
|
||||
// Returns the index into the loop that lines up with KB degrees[0].
|
||||
function rotationToCanonicalOrder(loopDegrees, kbDegrees) {
|
||||
const n = loopDegrees.length
|
||||
if (n === 0 || n !== kbDegrees.length) return 0
|
||||
const kbBase = kbDegrees[0]
|
||||
const kbShape = kbDegrees.map(d => ((d - kbBase) % 12 + 12) % 12).join(',')
|
||||
for (let r = 0; r < n; r++) {
|
||||
const base = loopDegrees[r]
|
||||
const rot = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = loopDegrees[(r + i) % n]
|
||||
rot.push(((d - base) % 12 + 12) % 12)
|
||||
}
|
||||
if (rot.join(',') === kbShape) return r
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ─── Roman-numeral helpers (re-exported for callers that only need matching) ───
|
||||
|
||||
/**
|
||||
* loopRomanNumerals(loop, keyRoot, keyMode) → roman numeral per chord.
|
||||
* Thin wrapper over theory.toRomanNumeral so banner/roadmap share one path.
|
||||
*/
|
||||
export function loopRomanNumerals(loop, keyRoot, keyMode) {
|
||||
if (!loop?.length) return []
|
||||
return loop.map(chord => (keyRoot ? toRomanNumeral(chord, keyRoot, keyMode) : chord))
|
||||
}
|
||||
@@ -235,6 +235,168 @@ export function getChordsInKey(root, mode) {
|
||||
return scale.map((degree, i) => noteName(rootIdx + degree) + qualities[i])
|
||||
}
|
||||
|
||||
// ─── Jam Guide: derived improv theory (L-01b) ─────────────────────────────────
|
||||
//
|
||||
// Three additive, pure helpers that feed the Roadmap Jam Guide. They are
|
||||
// key-agnostic: chords are described as a pitch class (0–11) + a CHORD_TYPES
|
||||
// quality key (e.g. 'min7', 'dom7', 'maj7', 'half_dim'), exactly the shape the
|
||||
// KB progression data already uses (`degrees` + `qualities`). They reuse
|
||||
// CHORD_TYPES / getChordTones rather than re-deriving intervals.
|
||||
//
|
||||
// Chord arg shape: { root, quality } where root is a pitch class 0–11 and
|
||||
// quality is a CHORD_TYPES key. This matches the KB station model
|
||||
// ({ degrees[i], qualities[i] }) so a caller maps a station to a chord with no
|
||||
// string parsing.
|
||||
|
||||
// Internal: resolve a chord's pitch-class tone set from a CHORD_TYPES key.
|
||||
// Returns the intervals mapped to absolute pitch classes, preserving the
|
||||
// CHORD_TYPES interval order (index 0 = root, 1 = 3rd, last = 7th when present).
|
||||
function chordTonePcs(rootPc, quality) {
|
||||
const type = CHORD_TYPES[quality] ?? CHORD_TYPES.maj
|
||||
const r = ((rootPc % 12) + 12) % 12
|
||||
return type.intervals.map(i => (r + i) % 12)
|
||||
}
|
||||
|
||||
/**
|
||||
* guideTones(rootPc, quality) → { third, seventh, root }
|
||||
*
|
||||
* The guide tones a soloist targets: a chord's 3rd and 7th. Index 1 in every
|
||||
* CHORD_TYPES interval set is the 3rd. A chord has a TRUE 7th only if its
|
||||
* interval set contains 10 (m7) or 11 (M7) — NOT merely if it has 4 tones.
|
||||
* When there is no real 7th (triads, and 4-tone non-7th chords like add9
|
||||
* [0,2,4,7] or maj6/min6 [0,4,7,9]) we fall back to the 5th as the secondary
|
||||
* anchor and flag `hasSeventh: false` so a caller labels it honestly ("5th",
|
||||
* not "7th"). dim/dim7/aug have no perfect 5th, so they anchor on their ♭5/#5.
|
||||
*
|
||||
* Returns pitch classes (0–11) so the Roadmap TARGET lane can place dots in any
|
||||
* key. `root` is included as the third anchor the design's badges reference.
|
||||
*
|
||||
* Sanity (C): guideTones(0,'maj7') → third 4 (E), seventh 11 (B), hasSeventh:true.
|
||||
* guideTones(7,'dom7') → third 11 (B), seventh 5 (F), hasSeventh:true.
|
||||
* guideTones(2,'min7') → third 5 (F), seventh 0 (C), hasSeventh:true.
|
||||
* guideTones(0,'add9') → third 4 (E), seventh 7 (G=5th), hasSeventh:false.
|
||||
* guideTones(0,'maj6') / (0,'min6') → seventh 7 (G=5th), hasSeventh:false.
|
||||
*/
|
||||
export function guideTones(rootPc, quality) {
|
||||
const type = CHORD_TYPES[quality] ?? CHORD_TYPES.maj
|
||||
const r = ((rootPc % 12) + 12) % 12
|
||||
const ints = type.intervals
|
||||
const third = (r + ints[1]) % 12 // index 1 is always the 3rd
|
||||
// A chord has a TRUE 7th only if its interval set contains 10 (m7) or 11 (M7).
|
||||
// `length >= 4` is wrong: add9 [0,2,4,7] and maj6/min6 [0,4,7,9] are 4-tone
|
||||
// chords with NO seventh, so their secondary anchor must fall back to the 5th —
|
||||
// never badge a 5th/6th as a "7". (add9 → hasSeventh:false, anchor=5th.)
|
||||
const seventhInt = ints.find(i => i === 10 || i === 11) // m7 / M7
|
||||
const hasSeventh = seventhInt !== undefined
|
||||
// Secondary anchor: the true 7th when present; otherwise the perfect 5th (7).
|
||||
// When no perfect 5th exists either (dim/dim7 carry a ♭5=6, aug carries a #5=8),
|
||||
// anchor on whichever altered 5th the chord actually contains.
|
||||
const fifthInt = ints.includes(7) ? 7 : ints.includes(6) ? 6 : ints.includes(8) ? 8 : 7
|
||||
const seventh = (r + (hasSeventh ? seventhInt : fifthInt)) % 12
|
||||
return { third, seventh, root: r, hasSeventh }
|
||||
}
|
||||
|
||||
/**
|
||||
* voiceLeadingPairs(chordA, chordB) → [{ from, to, semitones }]
|
||||
*
|
||||
* The voice-leading rails between two adjacent stations. For each guide tone of
|
||||
* chordA (its 3rd and 7th) it finds the nearest tone of chordB (chordB's full
|
||||
* tone set) and returns the smallest signed semitone move (negative = falls,
|
||||
* positive = rises). Only rails moving ≤2 semitones are kept — that is the
|
||||
* "smooth voice leading" band; bigger leaps are not rails. A 0-semitone rail
|
||||
* (a held common tone) is kept so the design can draw "B holds → next loop".
|
||||
*
|
||||
* Each chord is { root, quality } (pitch class + CHORD_TYPES key).
|
||||
*
|
||||
* Sanity — ii–V–I in C (the gold-standard rails):
|
||||
* Dm7 → G7 : 7th of Dm7 (C=0) → 3rd of G7 (B=11) ⇒ { from:0, to:11, semitones:-1 }
|
||||
* G7 → Cmaj7: 7th of G7 (F=5) → 3rd of Cmaj7 (E=4) ⇒ { from:5, to:4, semitones:-1 }
|
||||
* i.e. the classic 7→3 falls a half-step, proving C→B and F→E.
|
||||
*/
|
||||
export function voiceLeadingPairs(chordA, chordB) {
|
||||
const a = guideTones(chordA.root, chordA.quality)
|
||||
const targets = chordTonePcs(chordB.root, chordB.quality)
|
||||
|
||||
// smallest signed interval from pc x to pc y, in range (-6, 6]
|
||||
const signedStep = (x, y) => {
|
||||
let d = (((y - x) % 12) + 12) % 12
|
||||
if (d > 6) d -= 12
|
||||
return d
|
||||
}
|
||||
|
||||
const rails = []
|
||||
for (const from of [a.seventh, a.third]) { // 7th first (the headline 7→3 rail)
|
||||
let best = null
|
||||
for (const to of targets) {
|
||||
const semitones = signedStep(from, to)
|
||||
if (Math.abs(semitones) > 2) continue // only smooth moves are rails
|
||||
if (best === null || Math.abs(semitones) < Math.abs(best.semitones)) {
|
||||
best = { from, to, semitones }
|
||||
}
|
||||
}
|
||||
if (best) rails.push(best)
|
||||
}
|
||||
return rails
|
||||
}
|
||||
|
||||
// Default solo scale per chord quality (used when a KB pack didn't author an
|
||||
// improv.scales entry for a degree). Maps a CHORD_TYPES key → a SCALES mode.
|
||||
// 'locrian' is named here even though it isn't in SCALES (the KB references it
|
||||
// for half-diminished); intervals are provided so a caller never has to look it
|
||||
// up in SCALES for the half_dim case.
|
||||
const SOLO_SCALE_BY_QUALITY = {
|
||||
maj: 'major',
|
||||
maj7: 'major', // Ionian; packs may upgrade to Lydian via improv.scales
|
||||
maj6: 'major',
|
||||
add9: 'major',
|
||||
dom7: 'mixolydian',
|
||||
min: 'dorian',
|
||||
min7: 'dorian',
|
||||
min6: 'dorian',
|
||||
half_dim: 'locrian',
|
||||
dim: 'diminished',
|
||||
dim7: 'diminished',
|
||||
aug: 'whole_tone',
|
||||
sus4: 'mixolydian',
|
||||
sus2: 'major',
|
||||
}
|
||||
|
||||
// Locrian isn't in SCALES (no diatonic degree uses it); supply its intervals so
|
||||
// soloScale can return a complete { name, intervals } for half-diminished.
|
||||
const LOCRIAN_INTERVALS = [0, 1, 3, 5, 6, 8, 10]
|
||||
|
||||
/**
|
||||
* soloScale(quality, mode) → { name, intervals }
|
||||
*
|
||||
* The computed default scale to solo over a chord of the given quality — the
|
||||
* fallback for packs that didn't author an improv.scales entry. Returns the
|
||||
* same shape callers already get from SCALES (a relative interval set) plus its
|
||||
* mode `name`, so the Roadmap SCALE lane can label it ("G mixolydian") and the
|
||||
* fretboard can offset the intervals against the chord root.
|
||||
*
|
||||
* `mode` (the song's key mode, e.g. 'major'/'minor') is an optional context
|
||||
* hint: a dominant chord in a minor key implies the ♭9 colour, so we nudge
|
||||
* dom7 → phrygian dominant there; otherwise it is ignored. This keeps the
|
||||
* default sensible without needing per-chord KB data.
|
||||
*
|
||||
* Sanity: soloScale('dom7') → { name:'mixolydian', intervals:[0,2,4,5,7,9,10] }
|
||||
* soloScale('min7') → { name:'dorian', intervals:[0,2,3,5,7,9,10] }
|
||||
* soloScale('maj7') → { name:'major', intervals:[0,2,4,5,7,9,11] }
|
||||
* soloScale('half_dim') → { name:'locrian', intervals:[0,1,3,5,6,8,10] }
|
||||
* soloScale('dom7','minor') → phrygian-dominant intervals (♭9 over the V)
|
||||
*/
|
||||
export function soloScale(quality, mode) {
|
||||
// Dominant in a minor key → Phrygian dominant (the ♭9/♭13 "V of i" sound).
|
||||
if (quality === 'dom7' && mode === 'minor') {
|
||||
return { name: 'phrygian_dominant', intervals: [0, 1, 4, 5, 7, 8, 10] }
|
||||
}
|
||||
const name = SOLO_SCALE_BY_QUALITY[quality] ?? 'major'
|
||||
const intervals = name === 'locrian'
|
||||
? LOCRIAN_INTERVALS
|
||||
: (SCALES[name] ?? SCALES.major)
|
||||
return { name, intervals }
|
||||
}
|
||||
|
||||
// ─── Progression suggestions ─────────────────────────────────────────────────
|
||||
|
||||
export function getSuggestedProgressions(root, mode) {
|
||||
|
||||
Reference in New Issue
Block a user