Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3258060661 |
@@ -1,27 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
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).
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
name: kb-expand
|
||||
description: Expand the JamBuddy jam knowledgebase by exactly one style × instrument cell — research, author, validate, commit. Run repeatedly (or via /loop) to fill the backlog in docs/kb-backlog.md.
|
||||
---
|
||||
|
||||
# KB Expand — one cell per session
|
||||
|
||||
You are expanding JamBuddy's jam knowledgebase: intermediate-level standard progressions and ways to play them, per style × instrument. **Do exactly one cell, end to end.** Small, validated, committed.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Orient (always, every session)
|
||||
Read, in this order:
|
||||
- `docs/kb-plan.md` — architecture, schema conventions, quality gates, success criteria
|
||||
- `docs/kb-backlog.md` — the queue
|
||||
- `src/data/kb/SCHEMA.md` and the gold standard `src/data/kb/jazz/guitar.js` — **if they exist**
|
||||
- `docs/progression-repertoire.md` §1 — cross-check progressions for the style
|
||||
- `docs/learn-curriculum.md` — the intermediate level definition for the instrument
|
||||
|
||||
### 2. Claim a cell
|
||||
Take the **first `todo` cell** in the backlog (respect the order: bootstrap → guitar → piano → bass). Mark it `in-progress` in `docs/kb-backlog.md`.
|
||||
|
||||
**If the foundation doesn't exist yet (no `src/data/kb/`), this session is Session 0:** build `src/data/kb/` with `index.js` registry, `SCHEMA.md` (formats from kb-plan.md §1, one fully-worked example, the musician checklist from §2), `scripts/validate-kb.mjs` (all mechanical checks from kb-plan.md §2 — especially the pitch-class verification of guitar shapes against chord qualities from `src/lib/theory.js` CHORD_TYPES), and the **jazz/guitar** cell as the gold standard. That is one full session; stop after it.
|
||||
|
||||
### 3. Research
|
||||
Dispatch 1-2 web-research subagents for the claimed style × instrument:
|
||||
- the style's standard progressions (verify against `docs/progression-repertoire.md`; add style-specific ones with named sources)
|
||||
- 2-3 genuinely different intermediate ways to play each progression on this instrument (voicings with exact frets/fingerings for guitar, degree recipes for piano, line patterns for bass)
|
||||
- comping rhythm(s) characteristic of the style, improv guidance (scales over each chord, target notes, 1-2 licks)
|
||||
- require named sources/URLs in the agent's report
|
||||
|
||||
### 4. Author
|
||||
Write `src/data/kb/<style>/progressions.js` (if new style) and `src/data/kb/<style>/<instrument>.js` per SCHEMA.md. Key-agnostic only: degrees and movable shapes (`rootStr` + `offsets`), open shapes with `onlyRoot`. Qualities must be keys of `CHORD_TYPES` in `src/lib/theory.js`. Register the style in `src/data/kb/index.js`.
|
||||
|
||||
### 5. Validate — hard gate
|
||||
- `node scripts/validate-kb.mjs` must pass. Fix content, don't weaken the validator.
|
||||
- Run the musician checklist in SCHEMA.md; cut or fix anything that fails it.
|
||||
- `npm run build` must pass.
|
||||
|
||||
### 6. Record and commit
|
||||
- Backlog: mark the cell `done (YYYY-MM-DD, N progressions × M plays)`.
|
||||
- Commit on the current branch: `kb: add <style> <instrument> pack` (or `kb: bootstrap foundation + jazz guitar gold standard`). Do not push unless asked.
|
||||
|
||||
### 7. Report
|
||||
Tell the user: what was added (progressions, plays, sources), validator result, and **the next cell in the queue**. If a UI milestone in the backlog just became unblocked (e.g. Jam Guide MVP after cell 0), say so explicitly.
|
||||
|
||||
## Rules
|
||||
- One cell per invocation. Never start a second cell, even if the first went quickly.
|
||||
- Never commit content that fails the validator; never relax a validator rule to make content pass — flag the conflict to the user instead.
|
||||
- Intermediate level: no 5+ fret stretches, no advanced-only voicings without an intermediate alternative in the same play set.
|
||||
- Plays per progression must be idiomatically different (register/density/technique), not transpositions of each other.
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
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. -->
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
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? -->
|
||||
@@ -1,8 +0,0 @@
|
||||
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.
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
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. -->
|
||||
@@ -9,69 +9,33 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false # Ensures one OS failing doesn't kill the others
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
command: npm run electron:build:win
|
||||
artifact_pattern: "release/*.exe"
|
||||
- os: macos-latest
|
||||
# Added --universal here if you want to support both Intel and Apple Silicon
|
||||
command: npm run electron:build:mac -- --universal
|
||||
artifact_pattern: "release/*.dmg"
|
||||
- os: ubuntu-latest
|
||||
command: npm run electron:build:linux
|
||||
artifact_pattern: "release/*.AppImage"
|
||||
|
||||
env:
|
||||
# This fixes the "GitHub Personal Access Token is not set" error
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Prevents errors related to missing code-signing certificates
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build Application
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artifacts-${{ matrix.os }}
|
||||
path: ${{ matrix.artifact_pattern }}
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
# This downloads all "artifacts-*" into a folder named 'all-outputs'
|
||||
path: all-outputs
|
||||
merge-multiple: true
|
||||
|
||||
- name: List files for debugging
|
||||
run: ls -R all-outputs
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
# Point directly to the folder where all OS builds are merged
|
||||
files: all-outputs/*
|
||||
generate_release_notes: true
|
||||
- run: npm install
|
||||
- run: npm run electron:build:win
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
WIN_CSC_LINK: ''
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: releases/*.exe
|
||||
|
||||
build-mac:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- run: npm install
|
||||
- run: npm run electron:build:mac
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: releases/*.dmg
|
||||
|
||||
-163
@@ -1,163 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,120 +0,0 @@
|
||||
# GOAL — From Detection to Direction
|
||||
|
||||
WhatTheFlat already solves the hard live problem: **knowing what key and chords people are playing in a jam, in real time.** This document defines the next level, in two parts:
|
||||
|
||||
1. **Chord progressions** — make it easier to work with *different* progressions: a bigger genre repertoire, clearer "1-5-4"-style readout of the detected loop, a builder where you place chords yourself, and alternative voicings for every chord in a progression.
|
||||
2. **Learn** — expand the education section for the player who already knows the basics and is confident enough to jam, but wants to go next level.
|
||||
|
||||
Supporting research and full repertoires live in:
|
||||
|
||||
- [`docs/progression-repertoire.md`](docs/progression-repertoire.md) — genre-by-genre progression tables, substitution rules, voicing data sources, UX patterns from existing tools
|
||||
- [`docs/learn-curriculum.md`](docs/learn-curriculum.md) — intermediate training methods for guitar, piano, and bass, with drills and how app features map onto them
|
||||
- [`docs/kb-plan.md`](docs/kb-plan.md) — the **jam knowledgebase**: styles × instruments × progressions × voicings, the `/kb-expand` session loop that grows it ([`docs/kb-backlog.md`](docs/kb-backlog.md)), and the **Jam Guide** panel that renders it live at the bottom of the app
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Chord progressions
|
||||
|
||||
### Where we are
|
||||
|
||||
- `PROGRESSIONS` in `src/lib/theory.js` holds **14 hardcoded progressions across 7 genres** (Pop, Blues, Folk, Jazz, Rock, '50s, Flamenco). These drive `ProgressionSuggestions.jsx`.
|
||||
- `detectRepeatingProgression()` finds the repeating loop in chord history; `ProgressionBanner.jsx` already shows it with Roman numerals (I–V–IV) via `toRomanNumeral()`.
|
||||
- `EducationPanel.jsx` + `src/lib/education.js` carry 15 famous progressions with substitutions and style variations.
|
||||
- `src/lib/voicings.js` has ~50 guitar shapes (open + barre) across 14 chord types; no inversions, no triad string-sets, thin piano coverage.
|
||||
- There is **no way to enter or arrange a progression manually** — everything is detection-driven.
|
||||
|
||||
### Goals
|
||||
|
||||
**G1 — Expanded genre repertoire (data, not code).**
|
||||
Grow `PROGRESSIONS` from 7 to ~12 genres using the researched tables in `docs/progression-repertoire.md`: Funk (Dorian i7–IV7 vamps), Reggae (two-chord skanks), Country (V/V secondary dominant moves), R&B/Neo-soul (iii–vi–ii–V, 6-2-5-1), Gospel (chained 2-5-1s), plus blues variants (quick-change, minor blues) and the J-pop "Royal Road" (IV–V–iii–vi). Progressions stay in the existing `{ name, rn, degrees }` format so suggestions, Roman numerals and key mapping keep working unchanged.
|
||||
|
||||
**G2 — Numeral clarity ("is this 1-5-4?").**
|
||||
The loop banner already shows Roman numerals; add a **Nashville-number display option** (1-5-4 instead of I-V-IV) since that is how musicians call changes at a jam. One formatting layer over `toRomanNumeral`, toggled in Settings.
|
||||
|
||||
**G3 — Progression Builder (drag and drop).**
|
||||
A panel where the user assembles a progression by hand:
|
||||
- A **key-relative chord palette** (Hookpad's best idea): the diatonic chords of the current detected/locked key, one tap to add, with borrowed-chord palette (iv, ♭VII, ♭VI, V/V…) one level deeper.
|
||||
- Slots that can be **reordered by drag and drop**, with live Roman/Nashville numerals under each chord.
|
||||
- Tap any slot → **alternative voicings** for that chord (G4).
|
||||
- Seeded from the detected loop ("send loop to builder") so a jam can be captured, edited, and varied.
|
||||
- Variation buttons per chord powered by the substitution taxonomy (diatonic swap, borrow, secondary dominant, 7th/sus/add9 color) — the rules are in `docs/progression-repertoire.md` §2.
|
||||
|
||||
**G4 — Alternative voicings per progression chord.**
|
||||
- **Guitar:** extend `voicings.js` with CAGED positions and triads on string-sets (top-3 / middle-3), or adopt the MIT-licensed [`tombatossals/chords-db`](https://github.com/tombatossals/chords-db) dataset (multiple positions per chord, JSON, with a companion React SVG renderer).
|
||||
- **Piano:** generate voicings from interval recipes rather than data files — root position, inversions, shells (1-3-7), rootless A (3-5-7-9) / B (7-9-3-5) — choosing the inversion that minimizes movement from the previous chord (voice-leading distance).
|
||||
- Surface these in the Builder (G3) and in `CurrentJamPanel` voicing strips.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Learn: basics → jam-ready next level
|
||||
|
||||
### Audience
|
||||
|
||||
Not step one. The target player already knows open/barre chords (guitar), triads and simple lead sheets (piano), roots and simple scales (bass) — and is confident enough to show up at a jam. The Learn section's job is to take them **from "can survive a jam" to "makes the jam better."**
|
||||
|
||||
### What the research says (full detail in `docs/learn-curriculum.md`)
|
||||
|
||||
Across guitar, piano, and bass pedagogy (Berklee methods, Justin Guitar grades 4–6, Tomo Fujita, Mark Levine, Open Studio, PianoGroove, Scott's Bass Lessons, TalkingBass, Ed Friedland), the intermediate-to-advanced jump converges on four pillars:
|
||||
|
||||
| Pillar | Guitar | Piano | Bass |
|
||||
|---|---|---|---|
|
||||
| **Fretboard/keyboard liberation** | CAGED, triads on string sets, connecting pentatonic boxes | Inversions in all keys, voice leading | Neck zones, chord-tone arpeggios everywhere |
|
||||
| **Playing the changes** | Chord-tone targeting, guide tones (3rds & 7ths) | Shell + rootless voicings, sus/add9 colors | Walking lines, chromatic approach notes |
|
||||
| **Ensemble skills** | Small voicings, register discipline, comping | Comping rhythms (Charleston…), "rule of 1", staying out of the bass lane | Pocket/drummer lock, ghost notes, subdivision switching |
|
||||
| **Functional ears** | Hearing I-IV-V / vi-IV-I-V by bass line | Nashville numbers, 12-key transposition | Singing root movement, predicting the V |
|
||||
|
||||
### Goals
|
||||
|
||||
**L1 — Practice drills tab.**
|
||||
Add a drills library to the Learn section: per instrument, per pillar, the concrete drills from the curriculum doc (e.g. "first note after every chord change = the 3rd", "Charleston comping ladder", "W|H|H chromatic walkup"). Keyed to the *current detected key and loop* so every drill is in today's jam context, not abstract C major.
|
||||
|
||||
**L2 — Detection-powered feedback (the unfair advantage).**
|
||||
No practice app can hear the player; this one can. Phased:
|
||||
- **Target-note highlighting:** on each detected chord change, highlight the new chord's 3rd/7th on the fretboard/piano for a beat (drill scaffold — uses existing tier rendering).
|
||||
- **Next-chord preview tier:** when a loop is detected, show the *upcoming* chord's root and its chromatic approach notes (the bassist's walking-line scaffold).
|
||||
- **Chord-tone hit rate:** classify detected notes against the current chord (chord tone / scale tone / outside) and show a session score.
|
||||
- **Pocket report:** extend the onset/BPM pipeline to show timing drift against the established grid.
|
||||
|
||||
**L3 — Ear training from your own jam.**
|
||||
A quiz mode that hides the chord banner and asks the user to name the progression in numbers before revealing — using the *user's own chord history* as the corpus. Converts the existing detection + `toRomanNumeral` into the functional ear training every method prescribes.
|
||||
|
||||
**L4 — Mode-difference teaching.**
|
||||
When the user manually switches mode (the documented K-S limitation — by design), briefly highlight the *difference notes* (e.g. the raised 6th going minor → Dorian) on the instrument views. Turns a limitation into a lesson.
|
||||
|
||||
---
|
||||
|
||||
## What we need to go next level — priorities
|
||||
|
||||
| # | Item | Effort | Why first |
|
||||
|---|---|---|---|
|
||||
| 1 | **G1** Genre repertoire expansion | S (data only) | Immediate value, zero architectural risk |
|
||||
| 2 | **G2** Nashville number toggle | S | Directly answers "is it 1-5-4", jam-native language |
|
||||
| 3 | **G3** Progression Builder MVP (palette + reorder + numerals) | M | The single most-requested workflow gap |
|
||||
| 4 | **L1** Drills tab seeded from curriculum doc | M (content + UI) | Makes Learn level-appropriate |
|
||||
| 5 | **G4** Voicing alternatives (guitar string-sets + piano recipes) | M | Feeds both Builder and Learn |
|
||||
| 6 | **L2** Target-note highlighting + next-chord preview | M | First detection-powered trainer, reuses tier rendering |
|
||||
| 7 | **L3** Ear-training quiz on own history | M | High pedagogical value, small surface |
|
||||
| 8 | **L2** Hit-rate scoring + pocket report | L | Needs tuning of pitch/onset classification |
|
||||
| 9 | **G3** Builder phase 2: borrowed palette, variation buttons, loop import | L | Builds on MVP + substitution rules |
|
||||
| 10 | **KB** Knowledgebase + Jam Guide panel (see `docs/kb-plan.md`) | L, but looped in S-sized sessions via `/kb-expand` | The style × instrument playbook that powers improv learning |
|
||||
|
||||
### How we execute
|
||||
|
||||
Knowledgebase work runs as **looped sessions**: `/kb-expand` does exactly one style × instrument cell (research → author → validate → commit), driven by the queue in `docs/kb-backlog.md`. Tranches run on a dedicated branch — e.g. an hourly `/loop /kb-expand` for a working day — and **end with a pull request** so a whole tranche is reviewed in one place. First tranche (started 2026-06-12, branch `kb-expansion`): Session 0 bootstrap + the first guitar style cells, hourly for 8 hours, PR to `main` at the end.
|
||||
|
||||
## How we build it — the ensemble
|
||||
|
||||
The work is carried by a six-agent team that collaborates through files (a shared ledger + the repo), conducted by one scheduled loop. Full design in [`docs/agents/`](docs/agents/):
|
||||
|
||||
- [`ROSTER.md`](docs/agents/ROSTER.md) — the six agents, their domains, file ownership, and cadence weights: 🎼 Maestro (orchestrator), 🎓 Professor (music & pedagogy), 🔧 Luthier (engineering), 🎨 Muse (design & UX), 🔍 Critic (the review gate), 📣 Herald (open-source & community).
|
||||
- [`PROTOCOL.md`](docs/agents/PROTOCOL.md) — how they collaborate: the task-locking rule that prevents file conflicts, the `backlog→ready→claimed→in-review→done` lifecycle, the appointment algorithm, scheduling (schedule the conductor, not the band), and PRs.
|
||||
- [`LEDGER.md`](docs/agents/LEDGER.md) — the live task board, seeded with the `sprint-jam-guide` starter sprint.
|
||||
|
||||
**Run it:** `/jam-loop` advances one orchestrated iteration (Maestro appoints → workers build → Critic gates → reconcile + commit). Schedule it with `/loop 1h /jam-loop` (session) or `/schedule` (durable). `/kb-expand` remains the single-domain content fast-path (Professor solo).
|
||||
|
||||
### Definition of "next level" (success criteria)
|
||||
|
||||
- A jammer can glance at the app and call the loop in numbers ("it's a 1-5-4").
|
||||
- Suggestions cover the genres people actually jam (funk/reggae/R&B/gospel included), not just pop/blues.
|
||||
- A user can lay out their own progression, drag chords around, and see 3+ ways to voice every chord on their instrument.
|
||||
- The Learn section gives an intermediate guitarist, pianist, or bassist a *specific* next drill in the key they're jamming in right now — and at least one drill where the app verifies them by listening.
|
||||
@@ -1,122 +1,98 @@
|
||||
# JamBuddy
|
||||
# WhatTheFlat
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
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.
|
||||
## Features
|
||||
|
||||
---
|
||||
- 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
|
||||
|
||||
## The idea: detect → guide → level up
|
||||
|
||||
1. **Detect.** Point your mic at a guitar, piano, or a whole band. JamBuddy identifies the **key** and the **chords** as you play, finds the repeating **loop**, and reads it back in Roman numerals (ii–V–I, I–V–vi–IV).
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
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
|
||||
```
|
||||
|
||||
Prefer the browser? Run the renderer on its own (no Electron shell):
|
||||
|
||||
```bash
|
||||
npm run dev # then open http://localhost:5173
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
### Building installers
|
||||
|
||||
```bash
|
||||
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/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing — add a style without deep coding
|
||||
|
||||
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.
|
||||
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## How it's built — the ensemble
|
||||
|
||||
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/):
|
||||
|
||||
- [`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.
|
||||
|
||||
The product north star and full roadmap are in [`GOAL.md`](GOAL.md).
|
||||
|
||||
---
|
||||
|
||||
## Tech & how detection works
|
||||
## Tech Stack
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **App shell** | Electron (window host only — all logic runs in the renderer) |
|
||||
| **UI** | React, Tailwind CSS, Vite |
|
||||
| **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, harmonic-summation chroma chord matching |
|
||||
| **Music theory** | Custom JS — Krumhansl-Schmuckler key detection, chroma-based chord matching |
|
||||
|
||||
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).
|
||||
### Dependencies (`frontend/package.json`)
|
||||
|
||||
> 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.
|
||||
**Runtime**
|
||||
- `react` / `react-dom` — UI
|
||||
- `pitchy` — pitch detection
|
||||
|
||||
---
|
||||
**Dev / build**
|
||||
- `electron` — desktop runtime
|
||||
- `electron-builder` — installer packaging
|
||||
- `vite` + `@vitejs/plugin-react` — bundler
|
||||
- `tailwindcss` + `autoprefixer` + `postcss` — styling
|
||||
- `concurrently` — run Vite + Electron together in dev
|
||||
|
||||
## License
|
||||
## Development
|
||||
|
||||
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.
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Output is placed in `frontend/release/`.
|
||||
|
||||
## Design Tokens
|
||||
|
||||
All colors are defined in `frontend/tailwind.config.js` and can be referenced by name in any component.
|
||||
|
||||
| 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 |
|
||||
|
||||
Tailwind usage examples: `bg-surface`, `bg-panel`, `border-border`, `text-accent`, `bg-accent/20` (20% opacity).
|
||||
|
||||
## How It Works
|
||||
|
||||
All processing happens locally in the Electron window — no server, no network calls.
|
||||
|
||||
Audio is captured via the browser's Web Audio API and processed in two parallel paths:
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,123 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,63 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,82 +0,0 @@
|
||||
# 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♯) │
|
||||
└───────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -1,116 +0,0 @@
|
||||
# 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 ↕ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -1,130 +0,0 @@
|
||||
# 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 ▸
|
||||
```
|
||||
@@ -1,65 +0,0 @@
|
||||
# KB Expansion Backlog
|
||||
|
||||
The queue for the `/kb-expand` loop. One cell per session, top-to-bottom. Protocol and schema: [`docs/kb-plan.md`](kb-plan.md). Statuses: `todo` → `in-progress` → `done (YYYY-MM-DD, coverage)`.
|
||||
|
||||
## Phase 0 — Foundation (must be first)
|
||||
|
||||
| # | Cell | Status |
|
||||
|---|---|---|
|
||||
| 0 | Bootstrap: `src/data/kb/` + `SCHEMA.md` + `scripts/validate-kb.mjs` + `kb/index.js` + **jazz/guitar gold standard** | done (2026-06-12, iteration 1) |
|
||||
|
||||
## Guitar
|
||||
|
||||
| # | Style | Status |
|
||||
|---|---|---|
|
||||
| 1 | Jazz (part of bootstrap) | done (2026-06-12, 5 progressions × 2 plays, validator ✓) |
|
||||
| 2 | Blues | done (2026-06-12, 5 progressions × 2 plays, validator ✓) |
|
||||
| 3 | Rock | done (2026-06-12, 5 progressions × 2 plays, validator ✓) |
|
||||
| 4 | Bossa Nova | done (2026-06-12, 5 progressions × 2 plays, validator ✓) |
|
||||
| 5 | Funk | done (2026-06-12, 5 progressions × 2 plays, validator ✓) |
|
||||
| 6 | Reggae | done (2026-06-12, 5 progressions × 2 plays, validator ✓) |
|
||||
| 7 | Country / Folk | done (2026-06-12, 5 progressions × 2 plays, validator ✓) |
|
||||
| 8 | R&B / Neo-soul | done (2026-06-12, 5 progressions × 2 plays, validator ✓) |
|
||||
| 9 | Gospel | todo |
|
||||
| 10 | Pop | todo |
|
||||
|
||||
## Piano
|
||||
|
||||
| # | Style | Status |
|
||||
|---|---|---|
|
||||
| 11 | Jazz | todo |
|
||||
| 12 | Blues | todo |
|
||||
| 13 | Bossa Nova | todo |
|
||||
| 14 | Gospel | todo |
|
||||
| 15 | R&B / Neo-soul | todo |
|
||||
| 16 | Pop | todo |
|
||||
| 17 | Rock | todo |
|
||||
| 18 | Funk | todo |
|
||||
| 19 | Country / Folk | todo |
|
||||
| 20 | Reggae | todo |
|
||||
|
||||
## Bass
|
||||
|
||||
| # | Style | Status |
|
||||
|---|---|---|
|
||||
| 21 | Blues | todo |
|
||||
| 22 | Jazz | todo |
|
||||
| 23 | Funk | todo |
|
||||
| 24 | Reggae | todo |
|
||||
| 25 | Rock | todo |
|
||||
| 26 | Bossa Nova | todo |
|
||||
| 27 | R&B / Neo-soul | todo |
|
||||
| 28 | Country / Folk | todo |
|
||||
| 29 | Gospel | todo |
|
||||
| 30 | Pop | todo |
|
||||
|
||||
## UI milestones (interleave when their data exists)
|
||||
|
||||
| Milestone | Depends on | Status |
|
||||
|---|---|---|
|
||||
| Jam Guide MVP (panel, matching, `ChordDiagram.jsx`, live sync) | cell 0 | todo |
|
||||
| `MiniPiano.jsx` + recipe resolver | cell 11 | todo |
|
||||
| Bass pattern renderer | cell 21 | todo |
|
||||
| Improv layer (licks/tabs display) | a few guitar cells | todo |
|
||||
|
||||
> Notes for sessions: piano style order front-loads the styles where piano voicings differ most (jazz/gospel/neo-soul); bass order front-loads line-driven styles (blues/jazz/funk). Adjust freely — order is a default, not a rule.
|
||||
-223
@@ -1,223 +0,0 @@
|
||||
# Knowledgebase Plan — Styles × Instruments × Progressions × Voicings
|
||||
|
||||
The plan for building JamBuddy's **jam knowledgebase**: an intermediate guide to the standard progressions of each style (Jazz, Blues, Rock, Bossa Nova, …) and the different ways to *play* them per instrument (guitar first, then piano, then bass) — expandable one session at a time via a repeatable loop, and rendered live in a large **Jam Guide** panel at the bottom of the app.
|
||||
|
||||
Three principles drive everything:
|
||||
|
||||
1. **Key-agnostic data.** Everything is stored as scale degrees and movable shapes, never absolute chords. The app detects the key; one KB entry renders in all 12 keys. This is the same convention `PROGRESSIONS.degrees` and the movable shapes in `voicings.js` already use.
|
||||
2. **Machine-verifiable quality.** A validator proves every voicing actually contains the chord's tones before content lands. That's what makes agent-generated content trustworthy over many loop iterations.
|
||||
3. **One bounded cell per session.** Each expansion session completes exactly one style × instrument cell (researched, authored, validated, committed). Small enough to review, big enough to matter.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
```
|
||||
src/data/kb/
|
||||
index.js — registry aggregating all styles (UI reads only this)
|
||||
SCHEMA.md — the authoring contract (formats below, with one full example)
|
||||
jazz/
|
||||
meta.js — { id, label, feel, tempoRange, character }
|
||||
progressions.js — the style's standard progressions (instrument-independent)
|
||||
guitar.js — guitar pack: plays + comping + improv
|
||||
piano.js — piano pack
|
||||
bass.js — bass pack
|
||||
blues/ … — same shape per style
|
||||
scripts/
|
||||
validate-kb.mjs — quality gate, run with `node scripts/validate-kb.mjs`
|
||||
docs/
|
||||
kb-backlog.md — the cell matrix with statuses (the loop's queue)
|
||||
```
|
||||
|
||||
`index.js` imports whatever style folders exist — the Jam Guide's style tabs grow automatically as the loop fills cells. A style is usable for one instrument before the others exist (guitar-first rollout).
|
||||
|
||||
### Progression entry (per style)
|
||||
|
||||
```js
|
||||
// kb/jazz/progressions.js
|
||||
export default [
|
||||
{
|
||||
id: 'jazz-251-major',
|
||||
name: 'ii–V–I',
|
||||
rn: ['ii7', 'V7', 'Imaj7'],
|
||||
degrees: [2, 7, 0], // semitone offsets from key root
|
||||
qualities: ['min7', 'dom7', 'maj7'], // keys of CHORD_TYPES in theory.js
|
||||
bars: [1, 1, 2],
|
||||
mode: 'major',
|
||||
songs: ['Autumn Leaves', 'All The Things You Are'],
|
||||
tip: 'The 7th of each chord resolves down a half-step to the 3rd of the next.',
|
||||
},
|
||||
// … 4-8 progressions per style (see docs/progression-repertoire.md §1 for the lists)
|
||||
]
|
||||
```
|
||||
|
||||
### Instrument pack — guitar
|
||||
|
||||
```js
|
||||
// kb/jazz/guitar.js
|
||||
export default {
|
||||
styleIntro: '2-3 sentences on the guitarist's role in this style.',
|
||||
comping: [{ label: 'Four-to-the-bar (Freddie Green)', rhythm: '♩ ♩ ♩ ♩', description: '…' }],
|
||||
plays: {
|
||||
'jazz-251-major': [ // ≥2 "ways to play" per progression
|
||||
{
|
||||
label: 'Shell voicings',
|
||||
level: 'intermediate',
|
||||
chords: [ // one entry per progression step
|
||||
{ shape: { rootStr: 5, offsets: ['x', 0, 'x', 0, 1, 'x'], fingers: [0,1,0,2,3,0] },
|
||||
note: 'root–♭7–♭3' },
|
||||
// …
|
||||
],
|
||||
tips: 'Stay light; the 3rds and 7ths do all the work.',
|
||||
},
|
||||
{ label: 'Drop-2 on top four strings', /* … */ },
|
||||
],
|
||||
},
|
||||
improv: {
|
||||
scales: [{ over: 'ii7', scale: 'dorian', why: '…' }],
|
||||
targetNotes: 'Land the 3rd of each chord on beat 1.',
|
||||
licks: [{ tab: 'e|---…', description: '…', over: 'jazz-251-major' }],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Shape format** follows the existing `voicings.js` convention so the renderer is shared: movable shapes use `rootStr` + `offsets` relative to the root fret (renders in any key); open shapes use absolute `frets` + `onlyRoot` (pitch class) and only render when the key matches. Strings are arrays of 6, low-E first, `'x'` = muted.
|
||||
|
||||
### Instrument pack — piano
|
||||
|
||||
Voicings are **interval recipes** resolved per chord quality (no fingering data needed):
|
||||
|
||||
```js
|
||||
plays: {
|
||||
'jazz-251-major': [
|
||||
{
|
||||
label: 'Rootless A/B alternation',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ recipe: { LH: ['3', '5', '7', '9'] }, note: 'Type A' }, // ii7
|
||||
{ recipe: { LH: ['7', '9', '3', '13'] }, note: 'Type B' }, // V7
|
||||
{ recipe: { LH: ['3', '5', '7', '9'] }, note: 'Type A' }, // Imaj7
|
||||
],
|
||||
register: 'top note between C4 and C5',
|
||||
tips: 'Alternate types so inner voices barely move.',
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Degrees are chord-degree strings (`'1' '3' 'b7' '9' '13'`); the resolver maps them through the chord quality's intervals (which `theory.js` chord templates already encode).
|
||||
|
||||
### Instrument pack — bass
|
||||
|
||||
Line patterns per progression step, in degrees plus approach annotations:
|
||||
|
||||
```js
|
||||
plays: {
|
||||
'blues-12bar': [
|
||||
{
|
||||
label: 'Walking, chromatic approach',
|
||||
level: 'intermediate',
|
||||
bars: [{ beats: ['R', '3', '5', 'chrom→next'] } /* … per bar */],
|
||||
tips: 'Beat 1 is always the new root; beat 4 walks into it.',
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Quality gates
|
||||
|
||||
### Mechanical — `scripts/validate-kb.mjs` (must pass before any commit)
|
||||
|
||||
- ids unique; every `plays` key references an existing progression id; `chords`/`bars` length matches the progression length
|
||||
- `degrees` ∈ 0–11; `qualities` are keys of `CHORD_TYPES`; `mode` is a known mode
|
||||
- guitar shapes: 6 entries per array, frets 0–15, **fret span ≤ 4** (intermediate hands), and — the strong check — the shape's computed pitch classes (standard tuning EADGBE) must contain the chord's root and defining tones (3rd/7th or quality equivalent) and contain **no out-of-chord tones**
|
||||
- piano recipes: every degree resolvable for that chord quality
|
||||
- coverage per cell: ≥ 4 progressions, ≥ 2 plays per progression, improv section present (guitar/piano), styleIntro present
|
||||
|
||||
### Musician checklist (human/agent self-review, in SCHEMA.md)
|
||||
|
||||
- Are the plays *idiomatically different* (register, density, difficulty), not just transpositions of each other?
|
||||
- Is each play genuinely intermediate — no 5-fret stretches, no 2-octave rootless clusters?
|
||||
- Does the style actually sound like the style (bossa ≠ jazz with different labels: distinct rhythm descriptions)?
|
||||
- Do tips teach a *transferable* idea (voice leading, register, space), not just "play this"?
|
||||
|
||||
---
|
||||
|
||||
## 3. The expansion loop
|
||||
|
||||
### The queue
|
||||
|
||||
`docs/kb-backlog.md` holds the matrix of cells with statuses (`todo` / `in-progress` / `done` + date + coverage). Order: **all guitar cells first** (most voicing complexity — it sets the quality bar), then piano, then bass. Style priority within each instrument: jazz → blues → rock → bossa → funk → reggae → country/folk → R&B/neo-soul → gospel → pop.
|
||||
|
||||
### The session protocol (encoded as the `/kb-expand` project skill)
|
||||
|
||||
Each session:
|
||||
|
||||
1. **Orient** — read this plan, `SCHEMA.md`, the backlog, and the gold-standard cell (`kb/jazz/guitar.js`, the first one built).
|
||||
2. **Claim** — take the first `todo` cell, mark it `in-progress`.
|
||||
3. **Research** — dispatch web-research agent(s) for that style × instrument: the style's standard progressions (cross-check against `docs/progression-repertoire.md`), the 2-3 idiomatic intermediate ways to play each, comping rhythms, improv approach. Named sources required.
|
||||
4. **Author** — write `progressions.js` (if the style is new) and the instrument pack, conforming to SCHEMA.md.
|
||||
5. **Validate** — run `node scripts/validate-kb.mjs`; fix until green; run the musician checklist.
|
||||
6. **Integrate** — register the style in `kb/index.js`; `npm run build` must pass.
|
||||
7. **Record** — mark the cell `done` with date + coverage stats in the backlog; commit (`kb: add <style> <instrument> pack`).
|
||||
8. **Report** — summarize what was added and name the next cell.
|
||||
|
||||
**Session 0 (bootstrap):** if `src/data/kb/`, `SCHEMA.md`, or the validator don't exist yet, the first session builds them *plus* the jazz/guitar gold-standard cell. Every later session imitates that exemplar.
|
||||
|
||||
### How to run it
|
||||
|
||||
- One session: type **`/kb-expand`** — does exactly one cell.
|
||||
- Several in a row: `/loop /kb-expand` and let it self-pace, or run `/kb-expand` whenever there's time.
|
||||
- Review cadence: cells are individual commits on a branch — review/merge per instrument tranche if preferred.
|
||||
|
||||
30 cells ≈ 30 short sessions; guitar's 10 cells deliver user-visible value immediately because the Jam Guide reads whatever exists.
|
||||
|
||||
---
|
||||
|
||||
## 4. The Jam Guide panel (UI)
|
||||
|
||||
A large panel at the **bottom of the main scroll** — while jamming you scroll down and the current progression's playbook is laid out to fit the screen.
|
||||
|
||||
```
|
||||
┌─ JAM GUIDE ─────────────────────────────── [Guitar|Piano|Bass] [Jazz][Blues][Rock][Bossa]… ─┐
|
||||
│ Matched: ii–V–I in G major your loop: Am7 → D7 → Gmaj7 │
|
||||
│ │
|
||||
│ Am7 (ii7) D7 (V7) Gmaj7 (Imaj7) │
|
||||
│ ▼ playing now │
|
||||
│ Shells [diagram] [diagram] [diagram] root–3–7, four-to-the-bar │
|
||||
│ Drop-2 [diagram] [diagram] [diagram] top-4 strings, stays high │
|
||||
│ Triads 1-3 [diagram] [diagram] [diagram] fills between vocal lines │
|
||||
│ ───────────────────────────────────────────────────────────────────────────── │
|
||||
│ IMPROV D dorian → G mixo → G major · target the 3rds: C → F# → B · lick ▸ tab… │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Component:** `JamGuide.jsx`, mounted last in `App.jsx`; collapsed header always visible, expands to ~70vh.
|
||||
- **Inputs:** `keyInfo`, `detectedProgression`, `currentChord` — plus instrument + style selection (persisted in settings; style tabs are generated from `kb/index.js`, so the panel grows as the loop runs).
|
||||
- **Matching:** convert the detected loop to degrees relative to the key root, match against the selected style's progressions **rotation-invariantly** (same canonicalization idea as `detectRepeatingProgression`). No match → fallback: per-chord voicing alternatives from `voicings.js`, so the panel is never empty.
|
||||
- **Live sync:** the active chord column highlights using the loop-position logic in `ProgressionBanner.jsx` (`findLoopPosition` — extract it to a shared util). The player reads the *next* voicing in time, in rhythm with the band.
|
||||
- **Diagrams:** new small renderers — `ChordDiagram.jsx` (6-string × 5-fret SVG grid, consumes the shape format), `MiniPiano.jsx` (~2-octave SVG, highlights resolved recipe notes), bass patterns as degree badges (R · 3 · 5 · ♭7) over a mini string diagram. Reuse design tokens (`bg-panel`, `border-border`, accent purple for chord tones).
|
||||
- **Smart fit:** CSS grid — columns = progression chords (4–6), rows = plays; rows beyond what fits collapse behind "more ways ▾"; diagrams scale to column width; on narrow windows the grid flips to one play per row, chords scrolling horizontally.
|
||||
- **Key-aware rendering:** movable shapes get their fret position computed from the detected key; open shapes appear only when the chord's root matches; piano recipes resolve through the chord quality. All 12 keys for free, per principle 1.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phases
|
||||
|
||||
| Phase | What | Outcome |
|
||||
|---|---|---|
|
||||
| 0 | Foundation: `kb/` dirs, `SCHEMA.md`, validator, backlog, `/kb-expand` skill, jazz/guitar gold standard | The loop exists and has an exemplar |
|
||||
| 1 | Jam Guide MVP: panel + matching + guitar `ChordDiagram` + live sync | jazz/guitar visible in the app while jamming |
|
||||
| 2 | Loop guitar cells: blues, rock, bossa, funk, reggae, country, R&B, gospel, pop | Full guitar guide across styles |
|
||||
| 3 | Piano: `MiniPiano` renderer + recipe resolver, loop piano cells | Second instrument live |
|
||||
| 4 | Bass: pattern renderer, loop bass cells | Third instrument live |
|
||||
| 5 | Polish: improv layer with tabs/licks, Progression Builder integration (GOAL G3), ToneGym-style tap-to-hear | Guide ↔ Builder round-trip |
|
||||
|
||||
### Success criteria
|
||||
|
||||
- During a jam, scrolling to the Jam Guide shows ≥ 3 ways to play the detected progression on the selected instrument, in the detected key, with the active chord highlighted in time.
|
||||
- `/kb-expand` completes a cell in one session with the validator green, no hand-holding.
|
||||
- A new style added by the loop appears in the UI with **zero code changes** (data + registry only).
|
||||
- An intermediate player can switch Jazz → Bossa over the same ii–V–I and see *genuinely different* voicings and rhythm guidance.
|
||||
@@ -1,129 +0,0 @@
|
||||
# Learn Curriculum — Intermediate, Jam-Ready Players
|
||||
|
||||
Research-backed training repertoire for the Learn section. Companion to [`GOAL.md`](../GOAL.md) Part 2. Audience: players who know the basics and can survive a jam — the goal is making them better *in* the jam. Not beginner material.
|
||||
|
||||
Across guitar, piano, and bass pedagogy the intermediate→advanced jump converges on four pillars: **instrument liberation** (play anything anywhere), **playing the changes** (chord-tone awareness), **ensemble skills** (space, register, pocket), and **functional ears** (hearing 1-5-6-4). The per-instrument curricula below feed both the Learn UI and the knowledgebase (see `docs/kb-plan.md`).
|
||||
|
||||
---
|
||||
|
||||
## Guitar
|
||||
|
||||
### Skill taxonomy (rough order)
|
||||
|
||||
**Tier A — Fretboard liberation**
|
||||
1. CAGED system fluency — locate any chord in 5 places instantly (Fretboard Logic, Pickup Music)
|
||||
2. Triads on string sets — major/minor triads + inversions on strings 1-3, 2-4, 3-5 (Justin Guitar Grade 5, Leavitt Vol. 2)
|
||||
3. Connecting pentatonic boxes — the box 2↔3 seam is the documented weak point
|
||||
4. Scale-over-chord mapping — seeing the chord *inside* the scale shape
|
||||
|
||||
**Tier B — Playing the changes**
|
||||
5. Chord-tone / target-note soloing — land on a chord tone on beat 1 of each change
|
||||
6. Guide tones (3rds & 7ths) — the 7th of one chord resolves to the 3rd of the next
|
||||
7. Phrasing across positions
|
||||
|
||||
**Tier C — Ensemble skills**
|
||||
8. Comping with small triad voicings — stay out of the vocalist's/keys' register
|
||||
9. Internal time — metronome on beats 2 & 4, then no click (Tomo Fujita's core emphasis)
|
||||
10. Ear-led playing — "let your ears lead you instead of your eyes" (Fujita)
|
||||
|
||||
**Tier D — Functional ears**
|
||||
11. Progression recognition by ear — track the bass line first
|
||||
12. Modal awareness — how Dorian/Mixolydian overlap shapes you already know
|
||||
13. Transcribing & daily riff-writing — the recurring plateau fix
|
||||
|
||||
### Top drills
|
||||
1. **Triad voice-leading over 12-bar blues** — only close triads on one string set; on every change move each finger to the *nearest* note of the next triad. Forces inversions + minimal-motion voice leading at once.
|
||||
2. **One-CAGED-position soloing** — improvise using only one grip's chord tones + surrounding scale notes; shift position each chorus. Welds chord, arpeggio, and scale into one visual unit.
|
||||
3. **Target-note drill** — first note after each chord change must be the 3rd (then 7ths, then 7th→3rd resolutions); pentatonic filler in between. Pure pentatonic playing suddenly "follows the changes".
|
||||
4. **Pentatonic seam drill** — ascend box 1, exit through a named seam note into box 2, etc. Shifts become melodic destinations.
|
||||
5. **Metronome on 2 & 4** — click as the snare backbeat; progress to click once per bar, then none.
|
||||
6. **Comping ladder** — comp behind a recorded soloist using only 3-string triads above fret 5, varying rhythm/dynamics, never register-clashing.
|
||||
7. **Daily progression dictation** — 10-15 min naming I-IV-V vs vi-IV-I-V vs ii-V-I from songs, bass line first.
|
||||
|
||||
### Sources
|
||||
Fretboard Logic (Bill Edwards) · Justin Guitar Grades 4–6 · Tomo Fujita *Accelerate Your Guitar Playing* (Berklee) · Leavitt *A Modern Method for Guitar* Vol. 2 · Absolutely Understand Guitar · Pickup Music CAGED pathway · fundamental-changes.com (guide tones) · TrueFire (box connection, plateaus) · Premier Guitar "Rhythm Rules" · zotzinguitarlessons.com (triads in 12 keys) · ToneGym / tonedear.com · stringshock.com & jazzguitartoday.com (jam etiquette)
|
||||
|
||||
---
|
||||
|
||||
## Piano / Keys
|
||||
|
||||
### Skill taxonomy (rough order)
|
||||
|
||||
**Tier 1 — Harmonic vocabulary**
|
||||
1. Triad inversions in all 12 keys — grab any chord near the current hand position
|
||||
2. Voice leading — minimum-distance inversion choice; the biggest "amateur → pro" jump
|
||||
3. Shell voicings (root–3–7) — light, clear, gateway to comping (Open Studio, PianoGroove)
|
||||
4. Sus2/sus4/add9 colors and slash chords — pop/worship vocabulary
|
||||
5. Rootless voicings — Type A (3-5-7-9), Type B (7-9-3-5); top note between C4–C5
|
||||
|
||||
**Tier 2 — Rhythm & ensemble role**
|
||||
6. Comping rhythms — Charleston, reverse Charleston, Red Garland pattern, anticipations
|
||||
7. Register discipline — LH stays above ~G3 when a bassist is present
|
||||
8. Density discipline ("rule of 1") — in a 5-piece band, play 1/5 of the music
|
||||
9. Hand-role splitting — LH harmony/groove anchor, RH color and answers
|
||||
|
||||
**Tier 3 — Functional/ear skills**
|
||||
10. Thinking in numbers (Nashville Number System / Roman numerals)
|
||||
11. Progression recognition by ear — bass line + emotional flow of each degree
|
||||
12. Transposition fluency — known songs in all 12 keys via the number method
|
||||
13. Sight-comping — realize an unfamiliar lead sheet at tempo (Berklee keyboard method)
|
||||
|
||||
**Tier 4 — Bandstand**
|
||||
14. Form-keeping under pressure — never lose bar 1
|
||||
15. Improvising over changes — chord tones → pentatonics → scale tones
|
||||
16. Repertoire in 2-3 keys from memory
|
||||
|
||||
### Top drills
|
||||
1. **Voice-led progression loop in 12 keys** — I–V–vi–IV with minimum hand movement (C → G/B → Am → F/A), through the circle of fifths. Self-grading: you can see and hear when you jump.
|
||||
2. **Shell ii–V–I cycle** — root+3+7 through all keys, alternating types so 3rds/7ths swap and resolve by half-step. The core voice-leading mechanic made physical.
|
||||
3. **Charleston metronome ladder** — one syncopation pattern to automaticity at 80→160 BPM; comping failure in jams is usually rhythmic, not harmonic.
|
||||
4. **Backing-track subtraction** — chorus 1 whole notes only; chorus 2 LH only above G3, no roots; chorus 3 RH colors only; chorus 4 two hits per bar. Simulates bandmates occupying frequency space.
|
||||
5. **Bass-line ear training** — hum the bass note of each chord in a pop song, convert to numbers, play it.
|
||||
6. **One song, twelve keys** — number-chart a known song, new key daily.
|
||||
7. **Cold lead-sheet sight-comping** — slow metronome, once through the form, never stopping. Rehearses the actual jam failure mode.
|
||||
|
||||
### Sources
|
||||
Berklee Online Keyboard Method · Mark Levine *The Jazz Piano Book* · Open Studio Piano Pathway · PianoGroove (rootless voicings, comping) · The Jazz Piano Site (jam prep) · Piano With Jonny (voicings, transposing) · Jens Larsen (comping rhythms) · Pianote (band guide, NNS) · Worship Online / Musicademy / Sweetwater (band role) · ToneDear / ToneGym / Musical U / trainear.com (ear training)
|
||||
|
||||
---
|
||||
|
||||
## Bass
|
||||
|
||||
### Skill taxonomy (rough order)
|
||||
1. Fretboard zone mastery — every note to fret 12, lines through each zone (Friedland)
|
||||
2. Root-fifth-octave vocabulary — the "safe but musical" jam fallback
|
||||
3. Chord-tone fluency — R-3-5-7 of maj/min/dom/m7♭5 anywhere (TalkingBass: chord tones *before* scales)
|
||||
4. Scale-tone vs chord-tone discrimination — outline on strong beats, connect on weak
|
||||
5. Approach-note technique — chromatic from above/below, W|H|H walkup, dominant approach
|
||||
6. Walking bass construction — root on 1, chord tones on 1 & 3, approach into the next root on 4
|
||||
7. Subdivision command — straight 8ths / swing / shuffle / 16th funk, switching mid-groove
|
||||
8. Pocket / drummer lock — kick matching, ghost notes, dynamic mirroring
|
||||
9. Functional ear training — root movement, I/IV/V/vi by function
|
||||
10. Real-time harmonic prediction — the V "pushes home", reacting within one pass of the form
|
||||
11. Dynamics, touch, space — most cited intermediate→pro separator
|
||||
12. Fills and form awareness — fills at bars 4/8 phrase boundaries
|
||||
|
||||
### Top drills
|
||||
1. **Root-only song stripping** — play only the root of each change by ear; add 5ths and octaves on later passes. Strips songs to harmonic skeleton.
|
||||
2. **Arpeggiate the progression** — R-3-5(-7) over I-V-vi-IV in several keys, then inversions, then other neck zones.
|
||||
3. **W|H|H chromatic walkup** — between chords a 4th apart: root, whole, half, half (C-D-E♭-E→F). Formulaic forward motion that telegraphs the next chord.
|
||||
4. **Walking 12-bar / 1-6-2-5 loop** — a decision every beat about chord vs passing tone (Friedland, SBL 5-step formula).
|
||||
5. **2-bar loop challenge** — one groove for 5+ minutes changing only tone/dynamics/note length. Pocket training; exposes drift.
|
||||
6. **Subdivision switching** — 2 bars 8ths / 2 bars 16ths at 60-80 BPM; click on 2&4 only; mute the click 4 bars and check.
|
||||
7. **Sing-then-play root movement** — sing the roots before touching the bass, then 3rds/5ths/7ths.
|
||||
|
||||
### Sources
|
||||
Scott's Bass Lessons (Players Path, Groove Trainer) · TalkingBass Chord Tone Essentials · Ed Friedland *Building Walking Bass Lines* · Hal Leonard Bass Method · Berklee Practice Method: Bass · Bass Musician Magazine (drummer lock) · Premier Guitar (jam survival) · StudyBass · Learn Jazz Standards · Jazz Night School (chromatic 4) · onlinebasscourses.com · Functional Ear Trainer
|
||||
|
||||
---
|
||||
|
||||
## How the app supports this (detection-powered training)
|
||||
|
||||
These map to GOAL.md L1–L4; the app's unfair advantage is that it *hears* the player.
|
||||
|
||||
1. **Target-note highlighting** — on each detected chord change, flash the new chord's 3rd/7th on the fretboard/piano (guitar drill 3, piano drill 2). Later: score whether the first detected note after the change was a chord tone.
|
||||
2. **Voice-leading coach (piano view)** — highlight the *nearest inversion* to the previous chord, common tones marked "hold"; score total semitone travel per progression.
|
||||
3. **Next-chord preview tier (bass)** — when a loop is detected, highlight the upcoming chord's root plus its chromatic approach notes a half-step above/below — the walking-line scaffold, one beat ahead.
|
||||
4. **Progression ear-trainer on your own jam** — hide the chord banner, ask for the numbers (vi-IV-I-V), reveal. Uses chord history + `toRomanNumeral`; contextual beats abstract drills.
|
||||
5. **Pocket report** — extend the onset/BPM histogram to show beat-phase drift (rushing/dragging), plus a 2-&-4-only click synced to the detected tempo.
|
||||
6. **Mode-difference teaching** — when the user manually switches mode (the K-S limitation), briefly highlight the difference notes (minor → Dorian = raised 6th).
|
||||
@@ -1,149 +0,0 @@
|
||||
# Chord Progression Repertoire
|
||||
|
||||
Research-backed reference for expanding the progression features. Companion to [`GOAL.md`](../GOAL.md) Part 1. Notation: uppercase = major, lowercase = minor, ° = diminished, 7 = dominant unless marked maj7/m7.
|
||||
|
||||
How this maps to code today:
|
||||
|
||||
- `PROGRESSIONS` in `src/lib/theory.js` — `{ name, rn, degrees }` per progression; `degrees` are semitone offsets from the key root. This is the format new entries should use.
|
||||
- `getSuggestedProgressions(root, mode)` maps degrees → chord names in key; `toRomanNumeral()` converts any chord back to a numeral.
|
||||
- `FAMOUS_PROGRESSIONS` in `src/lib/education.js` — richer entries (songs, tips, style variations) for the Learn side.
|
||||
|
||||
## 1. Genre-by-genre progression tables
|
||||
|
||||
### Pop
|
||||
| Progression | Name / notes |
|
||||
|---|---|
|
||||
| I–V–vi–IV | "Axis of Awesome" — #1 in Hooktheory's corpus of 75k+ analyzed songs |
|
||||
| vi–IV–I–V | Same loop rotated to start on vi ("pessimistic axis") |
|
||||
| I–vi–IV–V | "Doo-wop" / "'50s progression" |
|
||||
| I–IV–vi–V | Common variant (Africa chorus) |
|
||||
| IV–V–iii–vi | "Royal Road" — J-pop/anime staple, spreading into Western pop |
|
||||
|
||||
### Rock
|
||||
| Progression | Name / notes |
|
||||
|---|---|
|
||||
| I–IV–V | Foundation of rock/blues/country |
|
||||
| I–♭VII–IV(–I) | Mixolydian rock cliché (Sweet Home Alabama as V–IV–I rotation) |
|
||||
| i–♭VII–♭VI(–V) | Andalusian-derived minor loop; with V = full Andalusian cadence |
|
||||
| I–♭III–IV | Blues-rock riff progression (borrowed ♭III) |
|
||||
|
||||
### Blues (12-bar family)
|
||||
| Progression | Name / notes |
|
||||
|---|---|
|
||||
| I7×4 / IV7×2, I7×2 / V7, IV7, I7, V7 | Standard 12-bar |
|
||||
| Bar 2 → IV7 | "Quick change" / "quick four" |
|
||||
| ii7–V7 in bars 9–10, turnaround I–VI7–ii–V7 | Jazz blues |
|
||||
| i7–iv7–i7 … ♭VI7–V7–i7 | Minor blues (The Thrill Is Gone) |
|
||||
| I–V–IV–IV–I–V–I–V | 8-bar blues (Key to the Highway) |
|
||||
|
||||
### Jazz
|
||||
| Progression | Name / notes |
|
||||
|---|---|
|
||||
| ii7–V7–Imaj7 | The fundamental cadence |
|
||||
| I–vi–ii–V (also iii–vi–ii–V) | Rhythm changes A / turnaround |
|
||||
| III7–VI7–II7–V7 | Rhythm changes bridge (circle of dominants) |
|
||||
| iim7♭5–V7♭9–i | Minor ii–V–i |
|
||||
| ii7–♭II7–Imaj7 | Tritone-sub cadence |
|
||||
| ivm7–♭VII7–Imaj7 | "Backdoor" progression |
|
||||
|
||||
### Folk / Country
|
||||
| Progression | Name / notes |
|
||||
|---|---|
|
||||
| I–IV–V(–I) | Core of both genres |
|
||||
| I–V–I–IV | Two/three-chord verse pattern |
|
||||
| i–♭VII–♭VI | Minor folk descent (Am–G–F) |
|
||||
| I–V–vi–iii–IV–I–IV–V | Pachelbel progression |
|
||||
| I–II7–V–I | Classic country secondary-dominant (V/V) move |
|
||||
|
||||
### Funk
|
||||
| Progression | Name / notes |
|
||||
|---|---|
|
||||
| I7 vamp | James Brown static dominant, voiced as 9th |
|
||||
| i7–IV7 | Dorian two-chord vamp — the most common funk pair |
|
||||
| i7 / m11 vamp | Minor one-chord groove |
|
||||
| ii7–V7 loop | Funk/disco vamp |
|
||||
|
||||
Design insight: funk needs few *progressions* but rich *chord qualities* (9, 7♯9, m11, 13sus) — colour lives in the voicing, not the changes.
|
||||
|
||||
### Reggae
|
||||
| Progression | Name / notes |
|
||||
|---|---|
|
||||
| I–V or I–IV | Two-chord skank vamps |
|
||||
| I–V–vi–IV | No Woman No Cry |
|
||||
| i–♭VII(–♭VI) | Minor roots-reggae vamp |
|
||||
| I–IV–V | Ska/rocksteady standard |
|
||||
|
||||
### R&B / Neo-soul
|
||||
| Progression | Name / notes |
|
||||
|---|---|
|
||||
| ii7–V7–Imaj7 (with 9/11/13 extensions) | Core cadence |
|
||||
| iii7–vi7–ii7–V7 | Circle movement from the mediant — neo-soul staple |
|
||||
| vi–ii–V–I | "6-2-5-1" cyclical soul loop |
|
||||
| Imaj7–IVmaj7 / Imaj7–iii7 | Two-chord vamps |
|
||||
| i7–iv7 | Dorian D'Angelo-style minor vamp |
|
||||
|
||||
### Gospel
|
||||
| Progression | Name / notes |
|
||||
|---|---|
|
||||
| ii7–V7–I | The gospel "2-5-1", often chained: 6-2-5-1, 3-6-2-5-1 |
|
||||
| I–I7–IV | Tonicizing IV (V7/IV "amen" setup) |
|
||||
| IV–iv–I | Plagal with borrowed iv |
|
||||
| I–♯i°–ii | Chromatic passing-diminished walk-up |
|
||||
|
||||
## 2. Substitution / variation taxonomy
|
||||
|
||||
Progression "families" relate through a small set of transforms — these are the generation rules for variation buttons and the Builder:
|
||||
|
||||
1. **Rotation** — any loop can start on any chord (I–V–vi–IV ≡ vi–IV–I–V). Treat loops as cyclic equivalence classes; display the rotation matching the user's tonic emphasis. (`detectRepeatingProgression` already canonicalizes rotations.)
|
||||
2. **Diatonic (function) substitution** — chords sharing two notes swap: I↔vi↔iii (tonic), IV↔ii (subdominant), V↔vii° (dominant).
|
||||
3. **Modal interchange / borrowing** — take a chord from the parallel mode: iv, ♭VI, ♭VII, ♭III, iim7♭5 in major; major IV (Dorian) in minor.
|
||||
4. **Secondary dominants** — precede any diatonic target with its V7: V/V = II7, V/vi = III7, V/IV = I7, V/ii = VI7.
|
||||
5. **Tritone substitution** — replace any dominant with the dominant a tritone away (V7 → ♭II7). Jazz flavour flag.
|
||||
6. **Backdoor dominant** — ♭VII7 resolving to I, usually as ivm7–♭VII7–I.
|
||||
7. **Quality embellishment** — same root, richer colour: triad → 7th → 9/11/13, sus2/4, add9. The main axis distinguishing genres (pop = triads/sus, jazz/neo-soul/gospel = extensions, funk = dominant 9/♯9). Already partially covered by `CHORD_SUBSTITUTIONS` in `education.js`.
|
||||
8. **Passing/approach chords** — chromatic passing diminished (I–♯i°–ii), bass-line inversions (slash chords).
|
||||
|
||||
## 3. UX patterns worth copying
|
||||
|
||||
- **Hookpad (Hooktheory)** — *key-relative chord palette*: only the diatonic chords of the current key, colour-coded consistently per scale degree (key-agnostic colours). Borrowed chords live in expandable secondary palettes. *Magic Chord* suggests the statistically likeliest next chord. Drag-and-drop onto a timeline. → Direct model for the Progression Builder (GOAL G3).
|
||||
- **Hooktheory TheoryTab** — progressions ranked by real-song frequency; each links to songs using it. "You're playing the Creep progression" is a strong engagement hook (partially exists via `FAMOUS_PROGRESSIONS` song lists).
|
||||
- **Scaler 2/3** — three-zone vertical flow: detection area (top) → suggested chords/scales (middle) → user-built progression (bottom). Maps directly onto this app: live detection → suggestions → builder.
|
||||
- **iReal Pro** — one-tap transposition; per-genre rendering of the same progression.
|
||||
- **ToneGym** — instant audio preview when tapping any chord/progression.
|
||||
|
||||
## 4. Voicing data
|
||||
|
||||
### Guitar
|
||||
Best option found: [`tombatossals/chords-db`](https://github.com/tombatossals/chords-db) (MIT, npm `@tombatossals/chords-db`, prebuilt `lib/guitar.json`):
|
||||
- All 12 keys × large suffix list, **multiple positions per chord** (open + barre + higher CAGED positions).
|
||||
- Per position: `frets` (per string, `x` = mute, low-E first), `fingers`, optional `barres`, `baseFret`. Example: `{ frets: '55775x', fingers: '114310', barres: 5 }`.
|
||||
- Companion renderer: [`tombatossals/react-chords`](https://github.com/tombatossals/react-chords) (React SVG diagrams consuming this format).
|
||||
|
||||
Triads on string-sets (top-3 / middle-3) are *not* in chords-db but are cheap to generate: for each inversion of the triad, map the 3 chord tones onto a chosen string set within a 4-fret window. This complements the existing `GUITAR_SHAPES` in `src/lib/voicings.js`.
|
||||
|
||||
### Piano
|
||||
No canonical open dataset exists. The sane model is **interval recipes resolved per chord quality** (the chord templates in `theory.js` already encode quality → semitone mapping):
|
||||
|
||||
```js
|
||||
// voicing = named recipe of chord degrees, resolved per chord quality
|
||||
{
|
||||
shell: { LH: ['1', '7'], RH: ['3'] },
|
||||
rootPosition: { LH: ['1'], RH: ['1', '3', '5', '7'] },
|
||||
rootlessA: { LH: ['3', '5', '7', '9'] }, // Type A: 3rd on bottom
|
||||
rootlessB: { LH: ['7', '9', '3', '5'] }, // Type B: 7th on bottom
|
||||
guideTones: { LH: ['3', '7'] },
|
||||
}
|
||||
```
|
||||
|
||||
Conventions to encode: rootless voicings keep the top note between C4–C5; alternate Type A/B through a progression so inner voices barely move — i.e. pick the voicing minimizing semitone travel from the previous chord (simple voice-leading distance minimization).
|
||||
|
||||
## 5. Sources
|
||||
|
||||
- Hooktheory corpus & blog: hooktheory.com/blog/i-analyzed-the-chords-of-1300-popular-songs-for-patterns-this-is-what-i-found/ ; hooktheory.com/blog/jazz-chord-progressions/
|
||||
- 12-bar variants: en.wikipedia.org/wiki/Twelve-bar_blues ; happybluesman.com/common-variations-12-bar-blues/
|
||||
- Named progressions: en.wikipedia.org/wiki/%2750s_progression ; piano.org/chord-progressions/ ; supersimplepiano.com/learn/chord-progressions/royal-road
|
||||
- Substitutions: learnjazzstandards.com (chord substitution) ; hub.yamaha.com (beyond diatonic) ; hubguitar.com (tritone subs)
|
||||
- Gospel: gospelmaps.com/top-gospel-chord-progressions/ ; gospel.hearandplay.com (2-5-1)
|
||||
- Genre vamps: orphiq.com (reggae) ; guitar-chord.org/articles/funk.html ; orangecandymusic.com & pickupmusic.com (R&B/neo-soul)
|
||||
- Tools: producelikeapro.com (Scaler review) ; hooktheory.com/hookpad
|
||||
- Voicing data: github.com/tombatossals/chords-db ; github.com/tombatossals/react-chords ; voicinglab.com & pianowithjonny.com & thejazzpianosite.com (rootless voicings)
|
||||
+1
-1
@@ -23,7 +23,7 @@ function createWindow() {
|
||||
height: 900,
|
||||
minWidth: 620,
|
||||
minHeight: 600,
|
||||
title: 'WhatTheFlat ♭? - JamBuddy',
|
||||
title: 'WhatTheFlat',
|
||||
icon: path.join(__dirname, '../assets/whattheflat-logo.png'),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval'; connect-src 'self' http://localhost:5173 ws://localhost:5173; style-src 'self' 'unsafe-inline'; img-src 'self' data:;">
|
||||
<title>WhatTheFlat ♭? - JamBuddy</title>
|
||||
<title>WhatTheFlat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+23
-68
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "jambuddy",
|
||||
"version": "0.6.2",
|
||||
"name": "whattheflat",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "jambuddy",
|
||||
"version": "0.6.2",
|
||||
"name": "whattheflat",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"audiomotion-analyzer": "^4.5.4",
|
||||
"essentia.js": "^0.1.3",
|
||||
"pitchy": "^4.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
@@ -2189,70 +2190,6 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
||||
@@ -4247,6 +4184,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/essentia.js": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/essentia.js/-/essentia.js-0.1.3.tgz",
|
||||
"integrity": "sha512-vVEPgeVMEBLRXbM5o5H5Rgu53EPHu25vyFKYg+flWLzI/nEoegJQez9FKRv8GR/KxIBwm+fXDEFL+MkQeoHaLw==",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"node-wav": "0.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/exponential-backoff": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||
@@ -5929,6 +5875,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-wav": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/node-wav/-/node-wav-0.0.2.tgz",
|
||||
"integrity": "sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz",
|
||||
|
||||
+6
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jambuddy",
|
||||
"name": "whattheflat",
|
||||
"description": "A jam session companion app that provides a chromatic tuner, chord progressions, and more.",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -13,10 +13,11 @@
|
||||
"electron:build": "vite build && electron-builder",
|
||||
"electron:build:win": "vite build && electron-builder --win --publish never",
|
||||
"electron:build:mac": "vite build && electron-builder --mac --publish never",
|
||||
"electron:build:linux": "vite build && electron-builder --linux --publish never"
|
||||
"electron:build:linux": "vite build && electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"audiomotion-analyzer": "^4.5.4",
|
||||
"essentia.js": "^0.1.3",
|
||||
"pitchy": "^4.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
@@ -34,8 +35,8 @@
|
||||
"wait-on": "^9.0.4"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.jambuddy.app",
|
||||
"productName": "JamBuddy",
|
||||
"appId": "com.whattheflat.app",
|
||||
"productName": "WhatTheFlat",
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
// 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)
|
||||
@@ -1,192 +0,0 @@
|
||||
// KB quality gate — validates src/data/kb/ against the contract in src/data/kb/SCHEMA.md.
|
||||
// Run: node scripts/validate-kb.mjs (exit 1 on any error)
|
||||
import { readdirSync, existsSync } from 'node:fs'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { CHORD_TYPES } from '../src/lib/theory.js'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const KB = join(ROOT, 'src', 'data', 'kb')
|
||||
|
||||
const MODES = ['major', 'minor', 'dorian', 'phrygian', 'lydian', 'mixolydian']
|
||||
const OPEN_PC = [4, 9, 2, 7, 11, 4] // EADGBe low-E first
|
||||
const PERFECT_FIFTH = 7
|
||||
const BASS_TOKENS = ['R', 'b3', '3', '5', '6', 'b7', '7', '9', 'O', 'chrom>', 'chrom<', '5>', 'x', '-']
|
||||
const MIN_PROGRESSIONS = 4
|
||||
const MIN_PLAYS = 2
|
||||
const MAX_SPAN = 4
|
||||
|
||||
const errors = []
|
||||
const err = (where, msg) => errors.push(`${where}: ${msg}`)
|
||||
|
||||
// Resolve a degree string ('3', 'b9', '13'…) to a pitch class relative to the
|
||||
// chord root, through the quality's intervals where the degree is quality-dependent.
|
||||
function resolveDegree(deg, quality) {
|
||||
const iv = CHORD_TYPES[quality].intervals
|
||||
const fixed = { 1: 0, b9: 1, 9: 2, '#9': 3, 11: 5, '#11': 6, b5: 6, b13: 8, 13: 9, 6: 9, b3: 3, b7: 10 }
|
||||
if (deg === '3') return iv.find(i => i === 3 || i === 4) ?? iv.find(i => i === 2 || i === 5) ?? null
|
||||
if (deg === '5') return iv.find(i => i === 6 || i === 7 || i === 8) ?? null
|
||||
if (deg === '7') return iv.find(i => i === 9 || i === 10 || i === 11) ?? null
|
||||
return fixed[deg] ?? null
|
||||
}
|
||||
|
||||
function checkGuitarShape(where, chordStep, quality) {
|
||||
const { shape, extensions = [] } = chordStep
|
||||
if (!shape) return err(where, 'missing shape')
|
||||
const strings = shape.offsets ?? shape.frets
|
||||
if (!Array.isArray(strings) || strings.length !== 6)
|
||||
return err(where, 'offsets/frets must be an array of 6 (low E first)')
|
||||
const isMovable = !!shape.offsets
|
||||
|
||||
if (isMovable) {
|
||||
if (!(shape.rootStr >= 1 && shape.rootStr <= 6)) return err(where, `bad rootStr ${shape.rootStr}`)
|
||||
if (strings[6 - shape.rootStr] !== 0) return err(where, 'offset on the root string must be 0')
|
||||
} else {
|
||||
if (!(shape.onlyRoot >= 0 && shape.onlyRoot <= 11)) return err(where, 'open shape needs onlyRoot (pc 0-11)')
|
||||
}
|
||||
|
||||
const fretted = strings.filter(f => f !== 'x')
|
||||
if (fretted.some(f => !Number.isInteger(f) || f < -2 || f > 15))
|
||||
return err(where, `bad fret values: ${JSON.stringify(strings)}`)
|
||||
const nonOpen = fretted.filter(f => f !== 0)
|
||||
if (nonOpen.length && Math.max(...nonOpen) - Math.min(...nonOpen) > MAX_SPAN)
|
||||
return err(where, `fret span > ${MAX_SPAN} — not intermediate-friendly`)
|
||||
|
||||
// Pitch-class verification: every sounded note must belong to the chord
|
||||
// (quality intervals + declared extensions); defining tones must be present.
|
||||
const iv = CHORD_TYPES[quality].intervals
|
||||
const allowed = new Set(iv)
|
||||
for (const ext of extensions) {
|
||||
const pc = resolveDegree(ext, quality)
|
||||
if (pc === null) return err(where, `unresolvable extension '${ext}' for ${quality}`)
|
||||
allowed.add(pc)
|
||||
}
|
||||
const rootRel = isMovable ? (12 - OPEN_PC[6 - shape.rootStr]) % 12 : null
|
||||
const sounded = new Set()
|
||||
strings.forEach((f, i) => {
|
||||
if (f === 'x') return
|
||||
const pc = isMovable
|
||||
? (OPEN_PC[i] + rootRel + f + 24) % 12
|
||||
: (OPEN_PC[i] + f - shape.onlyRoot + 24) % 12
|
||||
sounded.add(pc)
|
||||
})
|
||||
for (const pc of sounded)
|
||||
if (!allowed.has(pc)) return err(where, `sounded pc ${pc} is not in ${quality} (+ext) — shape misspells the chord`)
|
||||
const required = iv.filter(i =>
|
||||
i !== PERFECT_FIFTH
|
||||
&& !(chordStep.rootless && i === 0)
|
||||
&& !(chordStep.omit3 && (i === 3 || i === 4)))
|
||||
for (const pc of required)
|
||||
if (!sounded.has(pc)) return err(where, `defining tone pc ${pc} of ${quality} missing from shape`)
|
||||
}
|
||||
|
||||
function checkPianoRecipe(where, chordStep, quality) {
|
||||
const { recipe } = chordStep
|
||||
if (!recipe) return err(where, 'missing recipe')
|
||||
for (const hand of ['LH', 'RH']) {
|
||||
const degs = recipe[hand]
|
||||
if (degs === undefined) continue
|
||||
if (!Array.isArray(degs) || !degs.length) return err(where, `${hand} must be a non-empty array`)
|
||||
if (degs.length > 5) return err(where, `${hand} has ${degs.length} notes — one hand, max 5`)
|
||||
for (const d of degs)
|
||||
if (resolveDegree(d, quality) === null) err(where, `unresolvable degree '${d}' for ${quality}`)
|
||||
}
|
||||
if (recipe.LH === undefined && recipe.RH === undefined) err(where, 'recipe needs LH and/or RH')
|
||||
}
|
||||
|
||||
function checkBassPlay(where, play, prog) {
|
||||
const totalBars = prog.bars.reduce((a, b) => a + b, 0)
|
||||
if (!Array.isArray(play.bars) || play.bars.length !== totalBars)
|
||||
return err(where, `bars length ${play.bars?.length} ≠ progression total ${totalBars}`)
|
||||
play.bars.forEach((bar, i) => {
|
||||
if (!Array.isArray(bar.beats) || !bar.beats.length) return err(`${where} bar ${i}`, 'missing beats')
|
||||
for (const b of bar.beats)
|
||||
if (!BASS_TOKENS.includes(b)) err(`${where} bar ${i}`, `unknown beat token '${b}'`)
|
||||
})
|
||||
}
|
||||
|
||||
async function loadModule(path) {
|
||||
return (await import(pathToFileURL(path).href)).default
|
||||
}
|
||||
|
||||
const styleDirs = readdirSync(KB, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name)
|
||||
if (!styleDirs.length) { console.error('No style folders in src/data/kb/'); process.exit(1) }
|
||||
|
||||
const registry = existsSync(join(KB, 'index.js')) ? await loadModule(join(KB, 'index.js')) : null
|
||||
if (!registry) err('kb/index.js', 'registry missing')
|
||||
|
||||
const allIds = new Set()
|
||||
let totals = { styles: 0, progressions: 0, plays: 0 }
|
||||
|
||||
for (const style of styleDirs) {
|
||||
const dir = join(KB, style)
|
||||
const w = `kb/${style}`
|
||||
if (registry && !registry[style]) err('kb/index.js', `style '${style}' not registered`)
|
||||
|
||||
const meta = existsSync(join(dir, 'meta.js')) ? await loadModule(join(dir, 'meta.js')) : null
|
||||
if (!meta) { err(w, 'meta.js missing'); continue }
|
||||
if (meta.id !== style) err(`${w}/meta.js`, `id '${meta.id}' ≠ folder '${style}'`)
|
||||
for (const f of ['label', 'feel', 'character']) if (!meta[f]) err(`${w}/meta.js`, `missing ${f}`)
|
||||
|
||||
const progs = existsSync(join(dir, 'progressions.js')) ? await loadModule(join(dir, 'progressions.js')) : null
|
||||
if (!Array.isArray(progs) || !progs.length) { err(w, 'progressions.js missing/empty'); continue }
|
||||
if (progs.length < MIN_PROGRESSIONS) err(w, `${progs.length} progressions < ${MIN_PROGRESSIONS}`)
|
||||
|
||||
const progById = {}
|
||||
for (const p of progs) {
|
||||
const pw = `${w}/progressions.js [${p.id}]`
|
||||
if (!p.id?.startsWith(`${style}-`)) err(pw, `id must start with '${style}-'`)
|
||||
if (allIds.has(p.id)) err(pw, 'duplicate id'); allIds.add(p.id)
|
||||
progById[p.id] = p
|
||||
const n = p.degrees?.length
|
||||
if (!n) { err(pw, 'degrees missing'); continue }
|
||||
for (const [field, arr] of [['rn', p.rn], ['qualities', p.qualities], ['bars', p.bars]])
|
||||
if (!Array.isArray(arr) || arr.length !== n) err(pw, `${field} length ≠ degrees length`)
|
||||
if (p.degrees.some(d => !Number.isInteger(d) || d < 0 || d > 11)) err(pw, 'degrees must be ints 0-11')
|
||||
for (const q of p.qualities ?? []) if (!CHORD_TYPES[q]) err(pw, `unknown quality '${q}'`)
|
||||
if (!MODES.includes(p.mode)) err(pw, `unknown mode '${p.mode}'`)
|
||||
if (!Array.isArray(p.songs) || !p.songs.length) err(pw, 'songs missing')
|
||||
if (!p.tip) err(pw, 'tip missing')
|
||||
}
|
||||
totals.styles++; totals.progressions += progs.length
|
||||
|
||||
for (const inst of ['guitar', 'piano', 'bass']) {
|
||||
const file = join(dir, `${inst}.js`)
|
||||
if (!existsSync(file)) continue
|
||||
const pack = await loadModule(file)
|
||||
const iw = `${w}/${inst}.js`
|
||||
if (!pack.styleIntro) err(iw, 'styleIntro missing')
|
||||
if (!Array.isArray(pack.comping) || !pack.comping.length) err(iw, 'comping missing')
|
||||
if (inst !== 'bass' && (!pack.improv?.scales?.length || !pack.improv?.targetNotes))
|
||||
err(iw, 'improv.scales / improv.targetNotes required')
|
||||
|
||||
for (const p of progs)
|
||||
if ((pack.plays?.[p.id]?.length ?? 0) < MIN_PLAYS)
|
||||
err(iw, `progression '${p.id}' has < ${MIN_PLAYS} plays`)
|
||||
|
||||
for (const [pid, plays] of Object.entries(pack.plays ?? {})) {
|
||||
const prog = progById[pid]
|
||||
if (!prog) { err(iw, `plays key '${pid}' is not a progression of this style`); continue }
|
||||
plays.forEach((play, pi) => {
|
||||
const lw = `${iw} ${pid} play[${pi}] "${play.label ?? '?'}"`
|
||||
if (!play.label || !play.level || !play.tips) err(lw, 'label/level/tips required')
|
||||
totals.plays++
|
||||
if (inst === 'bass') return checkBassPlay(lw, play, prog)
|
||||
if (!Array.isArray(play.chords) || play.chords.length !== prog.degrees.length)
|
||||
return err(lw, `chords length ≠ progression length ${prog.degrees.length}`)
|
||||
play.chords.forEach((step, ci) => {
|
||||
const cw = `${lw} chord[${ci}] (${prog.rn[ci]})`
|
||||
if (inst === 'guitar') checkGuitarShape(cw, step, prog.qualities[ci])
|
||||
else checkPianoRecipe(cw, step, prog.qualities[ci])
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error(`✗ KB validation failed — ${errors.length} error(s):\n`)
|
||||
for (const e of errors) console.error(' ' + e)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`✓ KB valid — ${totals.styles} style(s), ${totals.progressions} progressions, ${totals.plays} plays`)
|
||||
+58
-175
@@ -8,14 +8,9 @@ import Tuner from './components/Tuner'
|
||||
import Piano from './components/Piano'
|
||||
import Settings from './components/Settings'
|
||||
import DebugView from './components/DebugView'
|
||||
import DrumView from './components/DrumView'
|
||||
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
|
||||
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'
|
||||
import viewIcon from './assets/view.png'
|
||||
|
||||
const DEFAULTS = {
|
||||
// Key detection
|
||||
@@ -30,20 +25,15 @@ const DEFAULTS = {
|
||||
// Audio input
|
||||
minClarity: 0.80,
|
||||
minVolume: 0.01,
|
||||
// Selected device (null = system default)
|
||||
audioDeviceId: null,
|
||||
}
|
||||
|
||||
function loadStored(key, fallback) {
|
||||
try { const v = localStorage.getItem(key); return v !== null ? JSON.parse(v) : fallback }
|
||||
catch { return fallback }
|
||||
// Experimental
|
||||
useEssentia: false,
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// ── Config ───────────────────────────────────────────────────────────────────
|
||||
const [config, setConfig] = useState(() => ({ ...DEFAULTS, ...loadStored('wtf_config', {}) }))
|
||||
const configRef = useRef(config)
|
||||
useEffect(() => { configRef.current = config; localStorage.setItem('wtf_config', JSON.stringify(config)) }, [config])
|
||||
const [config, setConfig] = useState(DEFAULTS)
|
||||
const configRef = useRef(DEFAULTS)
|
||||
useEffect(() => { configRef.current = config }, [config])
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
@@ -55,18 +45,10 @@ export default function App() {
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
|
||||
// ── Instrument view + tuner ───────────────────────────────────────────────────
|
||||
const [instrument, setInstrument] = useState('piano') // 'piano' | 'guitar' | 'bass'
|
||||
const [instrument, setInstrument] = useState('guitar') // 'guitar' | 'bass' | 'piano'
|
||||
const [showTuner, setShowTuner] = useState(false)
|
||||
const [showDebug, setShowDebug] = useState(false)
|
||||
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
|
||||
const [showDebug, setShowDebug] = useState(false)
|
||||
const [monoColor, setMonoColor] = useState(false)
|
||||
|
||||
// ── Mic permission error ──────────────────────────────────────────────────────
|
||||
const [micError, setMicError] = useState(null)
|
||||
@@ -75,40 +57,17 @@ export default function App() {
|
||||
const [debugChroma, setDebugChroma] = useState(null)
|
||||
const [debugCandidates, setDebugCandidates] = useState([])
|
||||
const [debugNoteAnalysis, setDebugNoteAnalysis] = useState(null)
|
||||
const [debugWaveform, setDebugWaveform] = useState(null)
|
||||
|
||||
// ── Stable refs for values used inside callbacks ──────────────────────────────
|
||||
const showDebugRef = useRef(showDebug)
|
||||
const showDrumViewRef = useRef(showDrumView)
|
||||
const lockedKeyRef = useRef(null)
|
||||
const listenStartRef = useRef(null)
|
||||
const showDebugRef = useRef(showDebug)
|
||||
const lockedKeyRef = useRef(null)
|
||||
useEffect(() => { showDebugRef.current = showDebug }, [showDebug])
|
||||
useEffect(() => { showDrumViewRef.current = showDrumView }, [showDrumView])
|
||||
useEffect(() => { localStorage.setItem('wtf_monoColor', JSON.stringify(monoColor)) }, [monoColor])
|
||||
useEffect(() => { if (isListening) listenStartRef.current = Date.now() }, [isListening])
|
||||
|
||||
// ── BPM estimation from onset timestamps ─────────────────────────────────────
|
||||
const [bpm, setBpm] = useState(null)
|
||||
const onsetTimestampsRef = useRef([])
|
||||
const bpmSmoothRef = useRef(null)
|
||||
|
||||
// ── Loop station ─────────────────────────────────────────────────────────────
|
||||
const {
|
||||
slots,
|
||||
masterLen,
|
||||
setStream: loopSetStream,
|
||||
handleSlotClick,
|
||||
commitTrim,
|
||||
cancelRecord,
|
||||
retrimSlot,
|
||||
deleteSlot,
|
||||
setVolume: loopSetVolume,
|
||||
addSlot: loopAddSlot,
|
||||
audioCtxRef: loopAudioCtxRef,
|
||||
masterStartRef: loopMasterStartRef,
|
||||
masterLenRef: loopMasterLenRef,
|
||||
} = useLoopEngine(bpm)
|
||||
|
||||
// ── Key: auto-detected + optional lock ───────────────────────────────────────
|
||||
const [keyInfo, setKeyInfo] = useState(null) // auto-detected
|
||||
const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null
|
||||
@@ -121,7 +80,6 @@ export default function App() {
|
||||
// ── Chord state ───────────────────────────────────────────────────────────────
|
||||
const [chordHistory, setChordHistory] = useState([])
|
||||
const [detectedProgression, setDetectedProgression] = useState(null)
|
||||
const [selectedChord, setSelectedChord] = useState(null)
|
||||
|
||||
// ── Top key candidates (shown as quick-lock chips) ────────────────────────────
|
||||
const [topKeyCandidates, setTopKeyCandidates] = useState([])
|
||||
@@ -136,7 +94,6 @@ export default function App() {
|
||||
const chromaIdxRef = useRef(0)
|
||||
const chordVotesRef = useRef([])
|
||||
const progressionVoteRef = useRef(null)
|
||||
const progressionMissRef = useRef(0)
|
||||
const pendingKeyRef = useRef(null)
|
||||
|
||||
// Keep refs in sync
|
||||
@@ -154,16 +111,7 @@ export default function App() {
|
||||
// ── Detect progression — require 2 consecutive identical results to commit ────
|
||||
useEffect(() => {
|
||||
const detected = detectRepeatingProgression(chordHistory)
|
||||
if (!detected) {
|
||||
progressionMissRef.current++
|
||||
// Clear stale loop after 4 chord changes with no pattern found
|
||||
if (progressionMissRef.current >= 4) {
|
||||
setDetectedProgression(null)
|
||||
progressionVoteRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
progressionMissRef.current = 0
|
||||
if (!detected) return
|
||||
const key = detected.join(',')
|
||||
if (progressionVoteRef.current === key) {
|
||||
setDetectedProgression(detected)
|
||||
@@ -179,13 +127,11 @@ export default function App() {
|
||||
keyVotesRef.current = []
|
||||
chordVotesRef.current = []
|
||||
progressionVoteRef.current = null
|
||||
progressionMissRef.current = 0
|
||||
pendingKeyRef.current = null
|
||||
chromaIdxRef.current = 0
|
||||
chromaRingRef.current = Array.from({ length: cfg.chromaSmooth }, () => new Float32Array(12))
|
||||
onsetTimestampsRef.current = []
|
||||
bpmSmoothRef.current = null
|
||||
listenStartRef.current = Date.now()
|
||||
setKeyInfo(null)
|
||||
setLockedKey(null)
|
||||
effectiveKeyRef.current = null
|
||||
@@ -197,7 +143,6 @@ export default function App() {
|
||||
setDebugChroma(null)
|
||||
setDebugCandidates([])
|
||||
setDebugNoteAnalysis(null)
|
||||
setDebugWaveform(null)
|
||||
}
|
||||
|
||||
// ── Key lock handlers ─────────────────────────────────────────────────────────
|
||||
@@ -220,13 +165,6 @@ export default function App() {
|
||||
effectiveKeyRef.current = keyInfo
|
||||
}
|
||||
|
||||
// ── Waveform handler: feeds oscilloscope / drum view ─────────────────────────
|
||||
const handleWaveform = useCallback((data) => {
|
||||
if (showDebugRef.current || showDrumViewRef.current) {
|
||||
setDebugWaveform({ ...data, onsets: [...onsetTimestampsRef.current] })
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── Note handler: drives key detection (pitch-based) ──────────────────────────
|
||||
const handleNote = useCallback(({ pitchClass }) => {
|
||||
const cfg = configRef.current
|
||||
@@ -238,11 +176,7 @@ export default function App() {
|
||||
|
||||
const result = detectKey(history)
|
||||
setTopKeyCandidates(detectTopKeys(history))
|
||||
if (showDebugRef.current) {
|
||||
const analysis = getNoteHistoryAnalysis(history)
|
||||
analysis.sessionSecs = listenStartRef.current ? Math.floor((Date.now() - listenStartRef.current) / 1000) : 0
|
||||
setDebugNoteAnalysis(analysis)
|
||||
}
|
||||
if (showDebugRef.current) setDebugNoteAnalysis(getNoteHistoryAnalysis(history))
|
||||
if (result.confidence < 0.5) return
|
||||
|
||||
const votes = keyVotesRef.current
|
||||
@@ -294,7 +228,7 @@ export default function App() {
|
||||
|
||||
if (showDebugRef.current) {
|
||||
setDebugChroma([...avg])
|
||||
setDebugCandidates(getChordCandidates(avg, key, bassPC, 5))
|
||||
setDebugCandidates(getChordCandidates(avg, key, bassPC))
|
||||
}
|
||||
|
||||
// Stability gate — if chroma is still changing across frames, we're mid-transition.
|
||||
@@ -305,7 +239,10 @@ export default function App() {
|
||||
for (const frame of ring) { const d = frame[i] - avg[i]; v += d * d }
|
||||
if (v / cfg.chromaSmooth > maxVar) maxVar = v / cfg.chromaSmooth
|
||||
}
|
||||
if (maxVar > 0.05) return
|
||||
if (maxVar > 0.05) {
|
||||
chordVotesRef.current = []
|
||||
return
|
||||
}
|
||||
|
||||
const chord = matchChordFromChroma(avg, key, bassPC, false, cfg.chordMinScore)
|
||||
if (!chord) {
|
||||
@@ -321,7 +258,7 @@ export default function App() {
|
||||
const winner = votes[0]
|
||||
setChordHistory(prev => {
|
||||
if (prev[prev.length - 1] === winner) return prev
|
||||
return [...prev.slice(-48), winner]
|
||||
return [...prev.slice(-30), winner]
|
||||
})
|
||||
|
||||
// Inject chord tones into note history to anchor key detection
|
||||
@@ -388,9 +325,7 @@ export default function App() {
|
||||
config={config}
|
||||
onChange={updateConfig}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onReset={() => { setConfig(DEFAULTS); setMonoColor(false) }}
|
||||
monoColor={monoColor}
|
||||
onMonoColorChange={setMonoColor}
|
||||
onReset={() => setConfig(DEFAULTS)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -402,11 +337,28 @@ export default function App() {
|
||||
<header className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-accent">
|
||||
WhatTheFlat <span className="text-gray-600">♭?</span> <span className="text-amber-400">- JamBuddy</span>
|
||||
WhatTheFlat <span className="text-gray-600">♭?</span>
|
||||
</h1>
|
||||
<p className="text-xs text-gray-600">Real-time key detection for live jams</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
onClick={() => setMonoColor(v => !v)}
|
||||
className={`p-2 rounded-full border transition-all ${monoColor ? 'border-accent bg-accent/10' : 'border-border hover:border-gray-400'}`}
|
||||
title="Mono color mode"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" style={{ opacity: 0.75 }}>
|
||||
<circle cx="6" cy="10" r="4" fill={monoColor ? '#a855f7' : '#a855f7'} />
|
||||
<circle cx="13" cy="10" r="4" fill={monoColor ? '#c084fc' : '#f59e0b'} />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDebug(v => !v)}
|
||||
className={`p-2 rounded-full border transition-all ${showDebug ? 'border-accent bg-accent/10' : 'border-border hover:border-gray-400'}`}
|
||||
title="Behind the scenes"
|
||||
>
|
||||
<img src={viewIcon} alt="Debug view" className="w-5 h-5" style={{ filter: 'invert(1) opacity(0.75)' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="p-2 rounded-full border border-border hover:border-gray-400 transition-all"
|
||||
@@ -444,9 +396,9 @@ export default function App() {
|
||||
onChange={e => setInstrument(e.target.value)}
|
||||
className="appearance-none bg-surface border border-border hover:border-gray-500 focus:border-accent focus:outline-none rounded-lg pl-3 pr-7 py-1 text-sm text-gray-200 cursor-pointer transition-colors"
|
||||
>
|
||||
<option value="piano">Piano</option>
|
||||
<option value="guitar">Guitar</option>
|
||||
<option value="bass">Bass</option>
|
||||
<option value="piano">Piano</option>
|
||||
</select>
|
||||
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs">▾</span>
|
||||
</div>
|
||||
@@ -542,16 +494,14 @@ export default function App() {
|
||||
onNote={handleNote}
|
||||
onChroma={handleChroma}
|
||||
onOnset={handleOnset}
|
||||
onWaveform={handleWaveform}
|
||||
isListening={isListening}
|
||||
minClarity={config.minClarity}
|
||||
minVolume={config.minVolume}
|
||||
audioDeviceId={config.audioDeviceId}
|
||||
useEssentia={config.useEssentia}
|
||||
onPermissionError={() => {
|
||||
setMicError(true)
|
||||
setIsListening(false)
|
||||
}}
|
||||
onStreamReady={loopSetStream}
|
||||
/>
|
||||
|
||||
{micError && (
|
||||
@@ -561,22 +511,18 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chord detail modal ── */}
|
||||
<ChordDetailModal chord={selectedChord} onClose={() => setSelectedChord(null)} onChordClick={setSelectedChord} keyInfo={effectiveKey} chordHistory={chordHistory} />
|
||||
|
||||
{/* ── Progression banner ── */}
|
||||
<ProgressionBanner
|
||||
chordHistory={chordHistory}
|
||||
keyInfo={effectiveKey}
|
||||
detectedProgression={detectedProgression}
|
||||
currentChord={currentChord}
|
||||
onChordClick={setSelectedChord}
|
||||
/>
|
||||
|
||||
{/* ── 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} jamFocusChord={jamFocusChord} />}
|
||||
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} />}
|
||||
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
||||
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
||||
</div>
|
||||
@@ -588,94 +534,31 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* ── Current jam — collapsible ── */}
|
||||
<CurrentJamPanel
|
||||
keyInfo={effectiveKey}
|
||||
chordHistory={chordHistory}
|
||||
detectedProgression={detectedProgression}
|
||||
onChordClick={setSelectedChord}
|
||||
/>
|
||||
|
||||
{/* ── Loop station ── */}
|
||||
<LoopStation
|
||||
slots={slots}
|
||||
bpm={bpm}
|
||||
masterLen={masterLen}
|
||||
audioCtxRef={loopAudioCtxRef}
|
||||
masterStartRef={loopMasterStartRef}
|
||||
masterLenRef={loopMasterLenRef}
|
||||
onSlotClick={handleSlotClick}
|
||||
onCommitTrim={commitTrim}
|
||||
onCancelRecord={cancelRecord}
|
||||
onRetrim={retrimSlot}
|
||||
onDelete={deleteSlot}
|
||||
onVolumeChange={loopSetVolume}
|
||||
onAddSlot={loopAddSlot}
|
||||
/>
|
||||
|
||||
{/* ── Behind the scenes — collapsible ── */}
|
||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowDebug(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
|
||||
>
|
||||
<span>BEHIND THE SCENES</span>
|
||||
<span>{showDebug ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{showDebug && (
|
||||
<div className="border-t border-border p-4">
|
||||
<DebugView
|
||||
chroma={debugChroma}
|
||||
chordCandidates={debugCandidates}
|
||||
noteAnalysis={debugNoteAnalysis}
|
||||
waveform={debugWaveform}
|
||||
keyInfo={effectiveKey}
|
||||
currentChord={currentChord}
|
||||
instrument={instrument}
|
||||
monoColor={monoColor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Rhythm / drum analyser — collapsible ── */}
|
||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowDrumView(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
|
||||
>
|
||||
<span>RHYTHM ANALYSER</span>
|
||||
<span>{showDrumView ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{showDrumView && (
|
||||
<div className="border-t border-border p-4">
|
||||
<DrumView waveform={debugWaveform} bpm={bpm} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* ── Behind the scenes debug view ── */}
|
||||
{showDebug && (
|
||||
<div className="mb-3">
|
||||
<DebugView
|
||||
chroma={debugChroma}
|
||||
chordCandidates={debugCandidates}
|
||||
noteAnalysis={debugNoteAnalysis}
|
||||
keyInfo={effectiveKey}
|
||||
currentChord={currentChord}
|
||||
instrument={instrument}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tuner — collapsible ── */}
|
||||
<div className="bg-panel border border-border rounded-xl overflow-hidden">
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowTuner(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
|
||||
className="w-full flex items-center justify-between px-4 py-2 bg-panel border border-border rounded-xl text-sm text-gray-400 hover:text-gray-200 hover:border-gray-500 transition-all"
|
||||
>
|
||||
<span>TUNER</span>
|
||||
<span>Tuner</span>
|
||||
<span>{showTuner ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{showTuner && <div className="border-t border-border"><Tuner /></div>}
|
||||
{showTuner && <div className="mt-2"><Tuner /></div>}
|
||||
</div>
|
||||
|
||||
{/* ── Jam Guide — bottom dock (Roadmap) ── */}
|
||||
<JamGuide
|
||||
detectedProgression={detectedProgression}
|
||||
keyInfo={effectiveKey}
|
||||
chordHistory={chordHistory}
|
||||
bpm={bpm}
|
||||
currentChord={currentChord}
|
||||
onFocusChord={setJamFocusChord}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { PitchDetector } from 'pitchy'
|
||||
import { NOTES } from '../lib/theory'
|
||||
import { initEssentia, getEssentia, computeHPCP } from '../lib/essentiaHPCP'
|
||||
|
||||
// ─── Why two analysers? ───────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -81,7 +82,7 @@ function detectBassPC(freqData, sampleRate, fftSize) {
|
||||
return ((bestMidi % 12) + 12) % 12
|
||||
}
|
||||
|
||||
export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError, audioDeviceId = null, onStreamReady = null }) {
|
||||
export default function AudioCapture({ onNote, onChroma, onOnset, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError, useEssentia = false }) {
|
||||
const audioCtxRef = useRef(null)
|
||||
const timeBufRef = useRef(null)
|
||||
const freqBufRef = useRef(null)
|
||||
@@ -94,26 +95,26 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
||||
const onNoteRef = useRef(onNote)
|
||||
const onChromaRef = useRef(onChroma)
|
||||
const onOnsetRef = useRef(onOnset)
|
||||
const onWaveformRef = useRef(onWaveform)
|
||||
const onPermissionErrorRef = useRef(onPermissionError)
|
||||
const onStreamReadyRef = useRef(onStreamReady)
|
||||
const minClarityRef = useRef(minClarity)
|
||||
const minVolumeRef = useRef(minVolume)
|
||||
const useEssentiaRef = useRef(useEssentia)
|
||||
const smoothRmsRef = useRef(0)
|
||||
const lastOnsetRef = useRef(0)
|
||||
const specPeakRef = useRef(null) // peak-hold spectrum for display lingering
|
||||
useEffect(() => { onNoteRef.current = onNote }, [onNote])
|
||||
useEffect(() => { onChromaRef.current = onChroma }, [onChroma])
|
||||
useEffect(() => { onOnsetRef.current = onOnset }, [onOnset])
|
||||
useEffect(() => { onWaveformRef.current = onWaveform }, [onWaveform])
|
||||
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
|
||||
useEffect(() => { onStreamReadyRef.current = onStreamReady }, [onStreamReady])
|
||||
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
|
||||
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
|
||||
useEffect(() => { useEssentiaRef.current = useEssentia }, [useEssentia])
|
||||
|
||||
// Pre-load Essentia WASM as soon as the component mounts — it's a singleton,
|
||||
// so repeated calls just return the cached instance.
|
||||
useEffect(() => { initEssentia().catch(console.error) }, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
activeRef.current = false
|
||||
specPeakRef.current = null
|
||||
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
|
||||
if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null }
|
||||
if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null }
|
||||
@@ -123,40 +124,13 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
||||
stop()
|
||||
let stream
|
||||
try {
|
||||
// Helpful debug: list available media devices before requesting permission
|
||||
try {
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
const audioIns = devices.filter(d => d.kind === 'audioinput')
|
||||
console.log('Audio inputs available:', audioIns)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('enumerateDevices failed', e)
|
||||
}
|
||||
|
||||
const constraints = audioDeviceId
|
||||
? { audio: { deviceId: { exact: audioDeviceId } } }
|
||||
: { audio: true }
|
||||
|
||||
console.log('Requesting getUserMedia with constraints:', constraints)
|
||||
stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
} catch (err) {
|
||||
// If permission denied or other error, surface extra diagnostics when possible
|
||||
console.warn('getUserMedia failed', err)
|
||||
try {
|
||||
if (navigator.permissions && navigator.permissions.query) {
|
||||
const p = await navigator.permissions.query({ name: 'microphone' })
|
||||
console.log('microphone permission state:', p.state)
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore; not all environments support Permissions API for microphone
|
||||
}
|
||||
onPermissionErrorRef.current?.(err)
|
||||
return
|
||||
}
|
||||
streamRef.current = stream
|
||||
activeRef.current = true
|
||||
onStreamReadyRef.current?.(stream)
|
||||
smoothRmsRef.current = 0
|
||||
lastOnsetRef.current = 0
|
||||
|
||||
@@ -164,14 +138,6 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
||||
audioCtxRef.current = ctx
|
||||
const source = ctx.createMediaStreamSource(stream)
|
||||
|
||||
// Debug: log the acquired audio tracks and labels/deviceIds
|
||||
try {
|
||||
const tracks = stream.getAudioTracks()
|
||||
console.log('Acquired audio tracks:', tracks.map(t => ({ label: t.label, id: t.id, enabled: t.enabled, muted: t.muted })))
|
||||
} catch (e) {
|
||||
console.warn('Could not inspect stream tracks', e)
|
||||
}
|
||||
|
||||
// Small analyser — pitch detection needs fast time-domain data
|
||||
const pa = ctx.createAnalyser()
|
||||
pa.fftSize = PITCH_FFT
|
||||
@@ -204,46 +170,6 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
||||
onOnsetRef.current?.()
|
||||
}
|
||||
|
||||
// Always fire waveform callback — downsample 4096 → 512 points + log-binned spectrum
|
||||
if (onWaveformRef.current) {
|
||||
const stride = 8 // 4096 / 8 = 512 points
|
||||
const wave = new Float32Array(PITCH_FFT / stride)
|
||||
for (let i = 0; i < wave.length; i++) wave[i] = timeBuf[i * stride]
|
||||
|
||||
// Log-binned frequency spectrum: 256 bins from 40 Hz → 4000 Hz
|
||||
const LOG_BINS = 256
|
||||
const F_MIN = 40, F_MAX = 4000
|
||||
const binHz = ctx.sampleRate / ca.fftSize
|
||||
const freqBuf = freqBufRef.current
|
||||
ca.getFloatFrequencyData(freqBuf)
|
||||
const spectrum = new Float32Array(LOG_BINS)
|
||||
for (let b = 0; b < LOG_BINS; b++) {
|
||||
const f = F_MIN * Math.pow(F_MAX / F_MIN, b / (LOG_BINS - 1))
|
||||
const bin = Math.round(f / binHz)
|
||||
if (bin < freqBuf.length) {
|
||||
const db = freqBuf[bin]
|
||||
spectrum[b] = db < NOISE_FLOOR ? 0 : Math.max(0, (db - NOISE_FLOOR) / (-NOISE_FLOOR))
|
||||
}
|
||||
}
|
||||
|
||||
// Peak-hold with exponential decay — spectrum rises instantly, falls slowly
|
||||
if (!specPeakRef.current) specPeakRef.current = new Float32Array(LOG_BINS)
|
||||
const peak = specPeakRef.current
|
||||
for (let b = 0; b < LOG_BINS; b++) {
|
||||
peak[b] = spectrum[b] > peak[b] ? spectrum[b] : peak[b] * 0.92
|
||||
}
|
||||
|
||||
let detectedFreq = null, detectedNote = null
|
||||
if (rms >= minVolumeRef.current) {
|
||||
const [f, c] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
|
||||
if (c >= minClarityRef.current && f > 60 && f < 4200) {
|
||||
detectedFreq = f
|
||||
detectedNote = NOTES[((Math.round(12 * Math.log2(f / 440) + 69) % 12) + 12) % 12]
|
||||
}
|
||||
}
|
||||
onWaveformRef.current({ wave, rms, detectedFreq, detectedNote, spectrum: peak })
|
||||
}
|
||||
|
||||
if (rms >= minVolumeRef.current) {
|
||||
const [freq, clarity] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
|
||||
if (clarity >= minClarityRef.current && freq > 60 && freq < 4200) {
|
||||
@@ -255,8 +181,20 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
||||
if (onChromaRef.current) {
|
||||
const freqBuf = freqBufRef.current
|
||||
ca.getFloatFrequencyData(freqBuf)
|
||||
let chroma
|
||||
const essentia = useEssentiaRef.current ? getEssentia() : null
|
||||
if (essentia) {
|
||||
try {
|
||||
chroma = computeHPCP(essentia, freqBuf, ctx.sampleRate)
|
||||
} catch (err) {
|
||||
console.error('[Essentia] computeHPCP failed, falling back to custom chroma:', err)
|
||||
chroma = computeChroma(freqBuf, ctx.sampleRate, ca.fftSize)
|
||||
}
|
||||
} else {
|
||||
chroma = computeChroma(freqBuf, ctx.sampleRate, ca.fftSize)
|
||||
}
|
||||
onChromaRef.current(
|
||||
computeChroma(freqBuf, ctx.sampleRate, ca.fftSize),
|
||||
chroma,
|
||||
detectBassPC(freqBuf, ctx.sampleRate, ca.fftSize)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,20 +8,20 @@ const STRINGS = [
|
||||
{ label: 'E', root: 4, thickness: 3 },
|
||||
]
|
||||
|
||||
const NUM_FRETS = 13
|
||||
const FRET_MARKERS = [3, 5, 7, 9]
|
||||
const NUM_FRETS = 13
|
||||
const FRET_MARKERS = [3, 5, 7, 9]
|
||||
const DOUBLE_MARKER = 12
|
||||
|
||||
// Layout
|
||||
const NUT_X = 40
|
||||
const OPEN_X = 18
|
||||
const FRET_W = 52
|
||||
const NUT_X = 40
|
||||
const OPEN_X = 18
|
||||
const FRET_W = 52
|
||||
const STRING_H = 36 // wider spacing than guitar — 4 strings feel more spread
|
||||
const PAD_T = 28
|
||||
const PAD_B = 18
|
||||
const BOARD_W = NUT_X + (NUM_FRETS - 1) * FRET_W + 10
|
||||
const BOARD_H = PAD_T + 3 * STRING_H + PAD_B
|
||||
const DOT_R = 10
|
||||
const PAD_T = 28
|
||||
const PAD_B = 18
|
||||
const BOARD_W = NUT_X + (NUM_FRETS - 1) * FRET_W + 10
|
||||
const BOARD_H = PAD_T + 3 * STRING_H + PAD_B
|
||||
const DOT_R = 10
|
||||
|
||||
const fretX = f => NUT_X + (f - 0.5) * FRET_W
|
||||
const stringY = si => PAD_T + si * STRING_H
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
// SVG chord diagram — 6 strings × 5 visible frets
|
||||
// Props:
|
||||
// frets[] — [s6…s1]: fret number or 'x' (muted)
|
||||
// fingers[] — [s6…s1]: finger 1-4, 0 = open/barre indicator
|
||||
// barre — { fret, fromStr, toStr } or null
|
||||
// baseFret — which fret number is at the top of the diagram (1 = standard)
|
||||
// label — caption below the box
|
||||
|
||||
const STRINGS = 6
|
||||
const ROWS = 5 // visible frets
|
||||
const SX = 32 // left margin (open/mute indicators)
|
||||
const SY = 28 // top margin (nut / baseFret label)
|
||||
const GX = 26 // gap between strings
|
||||
const GY = 22 // gap between frets
|
||||
const DOT_R = 9 // dot radius
|
||||
const W = SX + GX * (STRINGS - 1) + 24 // total width
|
||||
const H = SY + GY * ROWS + 20 // total height
|
||||
|
||||
function strX(s) { return SX + (STRINGS - 1 - s) * GX } // s=0 is s6 (low E, leftmost)
|
||||
function fretY(f) { return SY + f * GY } // f=0 is above first fret, f=1…5 are fret centers
|
||||
|
||||
export default function ChordBox({ frets, fingers, barre, baseFret = 1, label }) {
|
||||
const isOpen = baseFret === 1
|
||||
|
||||
// Map fret numbers to diagram row (0-indexed from top)
|
||||
function toRow(absF) {
|
||||
return absF - baseFret + 1 // fret at baseFret → row 1 (center of first fret)
|
||||
}
|
||||
|
||||
// Barre bar: draw a rounded rect across strings
|
||||
function renderBarre() {
|
||||
if (!barre) return null
|
||||
const row = toRow(barre.fret)
|
||||
if (row < 1 || row > ROWS) return null
|
||||
const x1 = strX(STRINGS - barre.toStr) // toStr is highest string number = leftmost
|
||||
const x2 = strX(STRINGS - barre.fromStr) // fromStr is lowest string number = rightmost
|
||||
const cy = fretY(row) - GY / 2
|
||||
return (
|
||||
<rect
|
||||
key="barre"
|
||||
x={x1 - DOT_R}
|
||||
y={cy - DOT_R}
|
||||
width={x2 - x1 + DOT_R * 2}
|
||||
height={DOT_R * 2}
|
||||
rx={DOT_R}
|
||||
fill="#a855f7"
|
||||
opacity={0.9}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="overflow-visible">
|
||||
|
||||
{/* ── Nut or baseFret indicator ── */}
|
||||
{isOpen ? (
|
||||
<rect x={SX - 2} y={SY - 4} width={GX * (STRINGS - 1) + 4} height={4} rx={2} fill="#e5e7eb" />
|
||||
) : (
|
||||
<text x={SX - 6} y={SY + GY * 0.5} textAnchor="end" dominantBaseline="middle"
|
||||
fill="#9ca3af" fontSize={10} fontFamily="monospace">
|
||||
{baseFret}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* ── Fret lines ── */}
|
||||
{Array.from({ length: ROWS + 1 }, (_, i) => (
|
||||
<line key={`fl${i}`}
|
||||
x1={SX} y1={fretY(i) - GY / 2}
|
||||
x2={SX + GX * (STRINGS - 1)} y2={fretY(i) - GY / 2}
|
||||
stroke="#374151" strokeWidth={i === 0 && isOpen ? 3 : 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* ── String lines ── */}
|
||||
{Array.from({ length: STRINGS }, (_, s) => (
|
||||
<line key={`sl${s}`}
|
||||
x1={strX(s)} y1={SY - GY / 2}
|
||||
x2={strX(s)} y2={fretY(ROWS) - GY / 2}
|
||||
stroke="#4b5563" strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* ── Barre ── */}
|
||||
{renderBarre()}
|
||||
|
||||
{/* ── Dots + open/mute indicators ── */}
|
||||
{frets.map((f, s) => {
|
||||
const cx = strX(STRINGS - 1 - s)
|
||||
if (f === 'x') {
|
||||
return (
|
||||
<text key={`m${s}`} x={cx} y={SY - GY / 2 - 7}
|
||||
textAnchor="middle" fill="#6b7280" fontSize={12} fontWeight="bold">
|
||||
×
|
||||
</text>
|
||||
)
|
||||
}
|
||||
if (f === 0) {
|
||||
return (
|
||||
<circle key={`o${s}`} cx={cx} cy={SY - GY / 2 - 7}
|
||||
r={5} fill="none" stroke="#6b7280" strokeWidth={1.5} />
|
||||
)
|
||||
}
|
||||
const row = toRow(f)
|
||||
if (row < 1 || row > ROWS) return null
|
||||
const cy = fretY(row) - GY / 2
|
||||
const finger = fingers?.[s] ?? 0
|
||||
return (
|
||||
<g key={`d${s}`}>
|
||||
<circle cx={cx} cy={cy} r={DOT_R} fill="#a855f7" />
|
||||
{finger > 0 && (
|
||||
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="white" fontSize={9} fontWeight="bold">
|
||||
{finger}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── String name labels ── */}
|
||||
{['e','B','G','D','A','E'].map((n, i) => (
|
||||
<text key={`sn${i}`}
|
||||
x={strX(i)} y={H - 4}
|
||||
textAnchor="middle" fill="#4b5563" fontSize={8}>
|
||||
{n}
|
||||
</text>
|
||||
))}
|
||||
|
||||
</svg>
|
||||
|
||||
{label && (
|
||||
<p className="text-[11px] text-gray-400 text-center leading-tight max-w-[120px]">{label}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,789 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import ChordBox from './ChordBox'
|
||||
import MiniPiano from './MiniPiano'
|
||||
import { getGuitarVoicings, getPianoTechniques, parseChord } from '../lib/voicings'
|
||||
import { CHORD_TYPES, NOTES, getChordsInKey, toRomanNumeral, getSuggestedProgressions } from '../lib/theory'
|
||||
import { FAMOUS_PROGRESSIONS, progressionInKey, getChordSubstitutions, CHORD_PLAYBOOK } from '../lib/education'
|
||||
|
||||
const CHORD_SUFFIX_OPTIONS = [
|
||||
{ key: 'maj', label: 'Major' },
|
||||
{ key: 'min', label: 'Minor' },
|
||||
{ key: 'dom7', label: '7' },
|
||||
{ key: 'maj7', label: 'maj7' },
|
||||
{ key: 'min7', label: 'm7' },
|
||||
{ key: 'dim', label: 'dim' },
|
||||
{ key: 'dim7', label: 'dim7' },
|
||||
{ key: 'half_dim', label: 'm7♭5' },
|
||||
{ key: 'aug', label: 'aug' },
|
||||
{ key: 'sus4', label: 'sus4' },
|
||||
{ key: 'sus2', label: 'sus2' },
|
||||
{ key: 'maj6', label: '6' },
|
||||
{ key: 'min6', label: 'm6' },
|
||||
{ key: 'add9', label: 'add9' },
|
||||
]
|
||||
|
||||
function chordDisplayName(root, typeKey) {
|
||||
const type = CHORD_TYPES[typeKey]
|
||||
if (!type) return root
|
||||
return root + type.suffix
|
||||
}
|
||||
|
||||
function GuitarTab({ chordName }) {
|
||||
const voicings = getGuitarVoicings(chordName)
|
||||
if (!voicings.length) {
|
||||
return <p className="text-gray-500 text-sm text-center py-8">No guitar voicings found for {chordName}.</p>
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Click any voicing to learn it. Purple = chord tones. Finger numbers inside dots (1=index, 4=pinky).
|
||||
Barre chords show the fret number on the left.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-6 justify-start">
|
||||
{voicings.map((v, i) => (
|
||||
<div key={i} className="flex flex-col items-center gap-1 p-3 rounded-xl bg-surface border border-border hover:border-accent/40 transition-colors">
|
||||
<ChordBox
|
||||
frets={v.frets}
|
||||
fingers={v.fingers}
|
||||
barre={v.barre}
|
||||
baseFret={v.baseFret}
|
||||
/>
|
||||
<p className="text-[11px] text-gray-400 text-center mt-1 max-w-[120px] leading-tight">{v.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 p-3 rounded-lg bg-surface border border-border">
|
||||
<p className="text-xs text-gray-500">
|
||||
<span className="text-accent font-semibold">Pro tip:</span> Learn the E-shape and A-shape barres first
|
||||
— they cover all 12 roots. Then add open voicings for the keys you play in most.
|
||||
High-fret voicings (above fret 7) work great as jazz comping shapes in a band mix.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PianoTab({ chordName }) {
|
||||
const parsed = parseChord(chordName)
|
||||
const techniques = getPianoTechniques(chordName)
|
||||
const rootPc = parsed?.rootPc ?? 0
|
||||
|
||||
if (!techniques.length) {
|
||||
return <p className="text-gray-500 text-sm text-center py-8">No piano techniques for {chordName}.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-xs text-gray-500">
|
||||
<span className="text-blue-400 font-semibold">Blue = Left hand</span> ·
|
||||
<span className="text-accent font-semibold">Purple = Right hand</span> ·
|
||||
R marks the root.
|
||||
</p>
|
||||
{techniques.map((t, i) => (
|
||||
<div key={i} className="p-4 rounded-xl bg-surface border border-border hover:border-accent/30 transition-colors">
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-start">
|
||||
<div className="shrink-0 overflow-x-auto">
|
||||
<MiniPiano rootPc={rootPc} lh={t.lh} rh={t.rh} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 min-w-0">
|
||||
<h3 className="font-bold text-white text-sm">{t.name}</h3>
|
||||
<p className="text-gray-400 text-xs">{t.desc}</p>
|
||||
<p className="text-xs text-amber-400/80 mt-1">
|
||||
<span className="text-amber-400 font-semibold">Tip:</span> {t.tip}
|
||||
</p>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-600">
|
||||
{t.lh.length > 0 && (
|
||||
<span className="text-blue-400">LH: {t.lh.map(iv => {
|
||||
const n = NOTES[(rootPc + iv) % 12]
|
||||
return iv === 0 ? `${n} (root)` : n
|
||||
}).join(', ')}</span>
|
||||
)}
|
||||
{t.rh.length > 0 && (
|
||||
<span className="text-accent">RH: {t.rh.map(iv => {
|
||||
const n = NOTES[(rootPc + iv) % 12]
|
||||
return n
|
||||
}).join(', ')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChordQuickPick({ label, chords, active, keyInfo, onSelect }) {
|
||||
if (!chords.length) return null
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[11px] text-gray-600 uppercase tracking-wider shrink-0 w-20">{label}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{chords.map(chord => {
|
||||
const rn = keyInfo?.root ? toRomanNumeral(chord, keyInfo.root, keyInfo.mode) : ''
|
||||
return (
|
||||
<button key={chord}
|
||||
onClick={() => onSelect(chord)}
|
||||
className={`flex flex-col items-center px-2.5 py-1 rounded-lg border text-xs font-bold transition-all ${
|
||||
active === chord
|
||||
? 'bg-accent border-accent text-white'
|
||||
: 'bg-surface border-border text-gray-300 hover:border-accent/50 hover:text-white'
|
||||
}`}>
|
||||
<span>{chord}</span>
|
||||
{rn && <span className="text-[9px] font-normal opacity-60 leading-none">{rn}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Progressions sub-tab ─────────────────────────────────────────────────────
|
||||
// Determine whether a chord type is major-ish or minor-ish for matching
|
||||
const MAJOR_TYPES = new Set(['maj','maj7','maj6','add9','sus4','sus2','aug','dom7'])
|
||||
const MINOR_TYPES = new Set(['min','min7','min6','half_dim','dim','dim7'])
|
||||
|
||||
function isMajorType(t) { return MAJOR_TYPES.has(t) }
|
||||
function isMinorType(t) { return MINOR_TYPES.has(t) }
|
||||
|
||||
function ProgressionsSubTab({ chordName, onChordClick }) {
|
||||
const parsed = parseChord(chordName)
|
||||
if (!parsed) return null
|
||||
const { rootPc, type } = parsed
|
||||
const root = NOTES[rootPc]
|
||||
|
||||
// Famous progressions where this chord can be the tonic (degree 0)
|
||||
const isMajor = isMajorType(type)
|
||||
const isMinor = isMinorType(type)
|
||||
const tonicProgs = FAMOUS_PROGRESSIONS.filter(p => {
|
||||
const q0 = p.qualities[0]
|
||||
if (isMajor && isMajorType(q0)) return true
|
||||
if (isMinor && isMinorType(q0)) return true
|
||||
return false
|
||||
})
|
||||
|
||||
// Genre-based suggestions from theory.js
|
||||
const genreProgs = getSuggestedProgressions(root, isMajor ? 'major' : 'minor')
|
||||
|
||||
// Roles this chord plays in other keys
|
||||
const ROLES = []
|
||||
for (let keyPc = 0; keyPc < 12; keyPc++) {
|
||||
for (const mode of ['major', 'minor']) {
|
||||
const diatonicChords = getChordsInKey(NOTES[keyPc], mode)
|
||||
const idx = diatonicChords.indexOf(chordName)
|
||||
if (idx !== -1) {
|
||||
const rn = toRomanNumeral(chordName, NOTES[keyPc], mode)
|
||||
ROLES.push({ keyRoot: NOTES[keyPc], mode, rn, diatonicChords })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* ── Famous progressions starting from this chord ── */}
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">
|
||||
Famous progressions — {chordName} as tonic
|
||||
</p>
|
||||
{tonicProgs.length === 0 && (
|
||||
<p className="text-gray-600 text-sm">No exact matches — try a major or minor chord.</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{tonicProgs.slice(0, 6).map(prog => {
|
||||
const chordsHere = progressionInKey(prog, root)
|
||||
return (
|
||||
<div key={prog.id} className="p-3 bg-surface border border-border rounded-xl hover:border-accent/30 transition-colors">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<span className="font-bold text-white text-sm">{prog.name}</span>
|
||||
<span className="text-[10px] font-mono text-gray-500">{prog.pattern}</span>
|
||||
{prog.genre.map(g => (
|
||||
<span key={g} className="px-1.5 py-0.5 bg-accent/10 border border-accent/20 rounded text-[10px] text-accent">{g}</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Chord sequence */}
|
||||
<div className="flex flex-wrap gap-1.5 items-center mb-2">
|
||||
{chordsHere.map((c, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onChordClick?.(c)}
|
||||
className={`px-2.5 py-1 rounded-lg font-bold text-sm border transition-all ${
|
||||
i === 0
|
||||
? 'bg-accent border-accent text-white'
|
||||
: 'bg-panel border-border text-gray-200 hover:border-accent/50 hover:text-accent'
|
||||
}`}
|
||||
title={`Voicings for ${c}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
{i < chordsHere.length - 1 && <span className="text-gray-700 text-xs">→</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-snug">{prog.description}</p>
|
||||
{prog.songs[0] && (
|
||||
<p className="text-[11px] text-gray-600 mt-1">e.g. {prog.songs.slice(0, 3).join(' · ')}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Genre-based next-chord suggestions ── */}
|
||||
{genreProgs.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">
|
||||
Genre suggestions — starting from {chordName}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{genreProgs.slice(0, 6).map((prog, pi) => (
|
||||
<div key={pi} className="flex items-center gap-2 p-2 bg-surface border border-border rounded-lg flex-wrap">
|
||||
<span className="text-[10px] font-bold text-gray-500 w-14 shrink-0">{prog.genre}</span>
|
||||
<div className="flex gap-1.5 flex-wrap items-center">
|
||||
{prog.chords.map((c, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onChordClick?.(c)}
|
||||
className="px-2 py-0.5 bg-panel border border-border hover:border-accent/50 rounded text-xs font-bold text-gray-200 hover:text-accent transition-all"
|
||||
title={`Voicings for ${c}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
{i < prog.chords.length - 1 && <span className="text-gray-700 text-[10px]">→</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-600 font-mono ml-1">{prog.rn?.join(' – ')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Roles this chord plays ── */}
|
||||
{ROLES.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">
|
||||
{chordName} appears in these keys
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ROLES.slice(0, 8).map(({ keyRoot, mode, rn, diatonicChords }) => (
|
||||
<div key={`${keyRoot}-${mode}`}
|
||||
className="px-3 py-2 bg-surface border border-border rounded-xl text-xs flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-bold text-white">{keyRoot}</span>
|
||||
<span className="text-gray-500 capitalize">{mode}</span>
|
||||
<span className="text-amber-400 font-bold">{rn}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{diatonicChords.map((c, i) => (
|
||||
<button key={i}
|
||||
onClick={() => onChordClick?.(c)}
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] font-bold transition-all ${
|
||||
c === chordName
|
||||
? 'bg-accent text-white'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
}`}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Theory tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
const CHORD_THEORY = {
|
||||
maj: {
|
||||
name: 'Major',
|
||||
formula: 'Root + Major 3rd (4 semitones) + Perfect 5th (7 semitones)',
|
||||
vibe: 'Bright, happy, resolved. The most "complete" sound in Western music.',
|
||||
beginner: 'Major chords are the foundation of almost every song you know. They feel stable and uplifting — like a musical full stop.',
|
||||
tension: 'Low — very stable',
|
||||
color: 'text-yellow-400',
|
||||
},
|
||||
min: {
|
||||
name: 'Minor',
|
||||
formula: 'Root + Minor 3rd (3 semitones) + Perfect 5th (7 semitones)',
|
||||
vibe: 'Dark, melancholic, introspective. The 3rd is lowered by just one semitone — that one note changes everything.',
|
||||
beginner: 'One note separates major from minor. Minor chords carry emotion and depth — sadness, mystery, tension.',
|
||||
tension: 'Low-medium — stable but moody',
|
||||
color: 'text-blue-400',
|
||||
},
|
||||
dom7: {
|
||||
name: 'Dominant 7th',
|
||||
formula: 'Major triad + Minor 7th (10 semitones)',
|
||||
vibe: 'Tense, bluesy, urgent. Wants desperately to resolve to a chord a 5th lower.',
|
||||
beginner: 'The 7th chord is the engine of blues and jazz. It creates tension that begs to resolve — like holding your breath. Play G7 then C to feel it.',
|
||||
tension: 'High — strongly pulls to resolution',
|
||||
color: 'text-red-400',
|
||||
},
|
||||
maj7: {
|
||||
name: 'Major 7th',
|
||||
formula: 'Major triad + Major 7th (11 semitones)',
|
||||
vibe: 'Dreamy, lush, sophisticated. Jazz-infused warmth without the tension of a dominant 7th.',
|
||||
beginner: 'The major 7th is the note just below the octave. Adding it to a major chord gives you that smooth jazz-bossa nova sound — think "Autumn Leaves".',
|
||||
tension: 'Very low — ethereal and floating',
|
||||
color: 'text-purple-400',
|
||||
},
|
||||
min7: {
|
||||
name: 'Minor 7th',
|
||||
formula: 'Minor triad + Minor 7th (10 semitones)',
|
||||
vibe: 'Smooth, soulful, relaxed. Darker than major 7th but less tense than a dominant 7th.',
|
||||
beginner: 'Minor 7ths are everywhere in soul, R&B, and jazz. They\'re minor chords with added warmth — moody but not harsh.',
|
||||
tension: 'Low-medium — smooth and flowing',
|
||||
color: 'text-indigo-400',
|
||||
},
|
||||
dim: {
|
||||
name: 'Diminished',
|
||||
formula: 'Root + Minor 3rd (3 semitones) + Diminished 5th (6 semitones)',
|
||||
vibe: 'Dark, tense, unstable. The flattened 5th creates a tritone interval — historically called "diabolus in musica" (the devil in music).',
|
||||
beginner: 'Diminished chords are passing chords — they create maximum tension so the next chord feels like a huge relief. Like a musical cliffhanger.',
|
||||
tension: 'Very high — wants to resolve immediately',
|
||||
color: 'text-orange-400',
|
||||
},
|
||||
dim7: {
|
||||
name: 'Diminished 7th',
|
||||
formula: 'Diminished triad + Diminished 7th (9 semitones) — fully symmetric, all minor 3rds',
|
||||
vibe: 'Extremely tense and dramatic. Used in horror film scores and dramatic classical passages.',
|
||||
beginner: 'All four notes are equally spaced (all minor 3rds apart), making it the most symmetrical and unstable chord. Classic "villain arrives" sound.',
|
||||
tension: 'Extreme — maximum instability',
|
||||
color: 'text-red-600',
|
||||
},
|
||||
half_dim: {
|
||||
name: 'Half-Diminished (m7♭5)',
|
||||
formula: 'Diminished triad + Minor 7th (10 semitones)',
|
||||
vibe: 'Dark and tense but with slightly more resolution than full dim7. The "ii" chord in minor ii–V–i jazz progressions.',
|
||||
beginner: 'Half-diminished sits between a minor 7th and a fully diminished chord. It\'s the moody jazz workhorse — think the intro to "Autumn Leaves".',
|
||||
tension: 'High — tense but musical',
|
||||
color: 'text-orange-500',
|
||||
},
|
||||
aug: {
|
||||
name: 'Augmented',
|
||||
formula: 'Root + Major 3rd (4 semitones) + Augmented 5th (8 semitones) — all major 3rds',
|
||||
vibe: 'Eerie, floating, dreamlike. The raised 5th creates instability that can resolve either up or down.',
|
||||
beginner: 'Augmented chords sound like something is about to happen. They\'re often used as a passing chord between major and minor — the 5th feels like it\'s "reaching" upward.',
|
||||
tension: 'High — ambiguous direction',
|
||||
color: 'text-emerald-400',
|
||||
},
|
||||
sus4: {
|
||||
name: 'Suspended 4th',
|
||||
formula: 'Root + Perfect 4th (5 semitones) + Perfect 5th (7 semitones)',
|
||||
vibe: 'Open, unresolved, expectant. The 3rd is replaced by a 4th — neither major nor minor, just floating.',
|
||||
beginner: '"Sus" means suspended — the 3rd is suspended in mid-air. It wants to drop down to a major or minor chord. Classic rock move: sus4 → major.',
|
||||
tension: 'Medium — pleasant tension, easy on the ear',
|
||||
color: 'text-cyan-400',
|
||||
},
|
||||
sus2: {
|
||||
name: 'Suspended 2nd',
|
||||
formula: 'Root + Major 2nd (2 semitones) + Perfect 5th (7 semitones)',
|
||||
vibe: 'Airy, spacious, ambiguous. Like sus4 but lighter — the 2nd sits high above the root.',
|
||||
beginner: 'Sus2 is a favourite of modern pop and ambient music. Without a 3rd, it has no major/minor quality — it just floats. Think Sting, U2, Coldplay.',
|
||||
tension: 'Low-medium — open and spacious',
|
||||
color: 'text-teal-400',
|
||||
},
|
||||
maj6: {
|
||||
name: 'Major 6th',
|
||||
formula: 'Major triad + Major 6th (9 semitones)',
|
||||
vibe: 'Sweet, vintage, nostalgic. The 6th adds a note from the scale without the tension of a 7th.',
|
||||
beginner: "The 6th is a colour tone that sweetens a major chord. Common in jazz, bossa nova, and 50s pop — \"Misty\" and \"Fly Me To The Moon\" territory.",
|
||||
tension: 'Very low — sweeter than major triad',
|
||||
color: 'text-amber-300',
|
||||
},
|
||||
min6: {
|
||||
name: 'Minor 6th',
|
||||
formula: 'Minor triad + Major 6th (9 semitones)',
|
||||
vibe: 'Bittersweet, exotic, dramatic. A major 6th over a minor chord creates a striking contrast.',
|
||||
beginner: 'Minor 6ths have a flamenco/tango feel. The bright 6th sitting on top of a dark minor chord creates a sophisticated tension — think Django Reinhardt.',
|
||||
tension: 'Medium — intriguing contrast',
|
||||
color: 'text-amber-400',
|
||||
},
|
||||
add9: {
|
||||
name: 'Add 9',
|
||||
formula: 'Major triad + Major 9th (14 semitones = octave + 2)',
|
||||
vibe: 'Open, modern, slightly epic. The 9th adds colour without the smoothness of a 7th.',
|
||||
beginner: 'Add9 is the chord of modern rock and pop. Unlike maj9 (which also has a 7th), add9 keeps things clean and direct. Coldplay, Radiohead, and U2 love it.',
|
||||
tension: 'Very low — bright and open',
|
||||
color: 'text-lime-400',
|
||||
},
|
||||
}
|
||||
|
||||
const INTERVAL_NAMES = {
|
||||
0: 'Root', 2: 'Major 2nd', 3: 'Minor 3rd', 4: 'Major 3rd',
|
||||
5: 'Perfect 4th', 6: 'Tritone (♭5)', 7: 'Perfect 5th',
|
||||
8: 'Aug 5th', 9: 'Major 6th', 10: 'Minor 7th', 11: 'Major 7th',
|
||||
14: 'Major 9th',
|
||||
}
|
||||
|
||||
function TheoryTab({ chordName }) {
|
||||
const parsed = parseChord(chordName)
|
||||
if (!parsed) return <p className="text-gray-500 text-sm text-center py-8">Could not parse chord.</p>
|
||||
|
||||
const { rootPc, type } = parsed
|
||||
const typeInfo = CHORD_TYPES[type]
|
||||
const theory = CHORD_THEORY[type]
|
||||
const subs = getChordSubstitutions(chordName)
|
||||
|
||||
// Actual note names
|
||||
const noteNames = (typeInfo?.intervals ?? []).map(iv => NOTES[(rootPc + iv) % 12])
|
||||
|
||||
// Roles this chord can play
|
||||
const ROLES = []
|
||||
for (let keyPc = 0; keyPc < 12; keyPc++) {
|
||||
for (const mode of ['major', 'minor']) {
|
||||
const diatonicChords = getChordsInKey(NOTES[keyPc], mode)
|
||||
const idx = diatonicChords.indexOf(chordName)
|
||||
if (idx !== -1) {
|
||||
const rn = toRomanNumeral(chordName, NOTES[keyPc], mode)
|
||||
ROLES.push({ keyRoot: NOTES[keyPc], mode, rn })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* ── What is this chord? ── */}
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-baseline gap-3 mb-3">
|
||||
<span className={`text-lg font-black ${theory?.color ?? 'text-accent'}`}>{chordName}</span>
|
||||
<span className="text-sm text-gray-400">{theory?.name ?? type}</span>
|
||||
</div>
|
||||
{theory && (
|
||||
<>
|
||||
<p className="text-sm text-gray-200 leading-relaxed mb-2">{theory.beginner}</p>
|
||||
<p className="text-xs text-gray-500 italic leading-relaxed">{theory.vibe}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Notes & Formula ── */}
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">Notes in this chord</p>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{(typeInfo?.intervals ?? []).map((iv, i) => (
|
||||
<div key={i} className={`flex flex-col items-center px-3 py-2 rounded-xl border ${
|
||||
i === 0 ? 'bg-accent/20 border-accent text-accent' : 'bg-panel border-border text-gray-300'
|
||||
}`}>
|
||||
<span className="text-base font-black">{noteNames[i]}</span>
|
||||
<span className="text-[10px] text-gray-500 leading-none mt-0.5">{INTERVAL_NAMES[iv] ?? `+${iv}`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{theory && (
|
||||
<div className="text-xs text-gray-600 font-mono bg-panel/50 rounded-lg px-3 py-2 border border-border">
|
||||
{theory.formula}
|
||||
</div>
|
||||
)}
|
||||
{theory && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-gray-600">Tension:</span>
|
||||
<span className="text-xs text-gray-400">{theory.tension}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Chord substitutions ── */}
|
||||
{subs.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">Colour swaps — try these instead</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{subs.map((sub, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 bg-surface border border-border rounded-xl hover:border-accent/30 transition-colors">
|
||||
<span className="text-sm font-black text-accent shrink-0 w-16">{sub.chord}</span>
|
||||
<p className="text-xs text-gray-400 leading-snug">{sub.tip}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Keys this chord belongs to ── */}
|
||||
{ROLES.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">{chordName} appears in these keys</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ROLES.slice(0, 10).map(({ keyRoot, mode, rn }) => (
|
||||
<div key={`${keyRoot}-${mode}`}
|
||||
className="px-3 py-2 bg-surface border border-border rounded-xl text-xs flex items-center gap-2">
|
||||
<span className="font-bold text-white">{keyRoot}</span>
|
||||
<span className="text-gray-500 capitalize">{mode}</span>
|
||||
<span className="text-amber-400 font-bold">{rn}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-700 mt-2">
|
||||
Roman numerals show the chord's role: I/i = home, IV = subdominant, V = dominant tension, vi/♭VI = relative minor/major, etc.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Learn tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function LearnTab({ chordName }) {
|
||||
const parsed = parseChord(chordName)
|
||||
const playbook = parsed ? CHORD_PLAYBOOK[parsed.type] : null
|
||||
|
||||
if (!playbook) {
|
||||
return <p className="text-gray-500 text-sm text-center py-8">No jam content for {chordName} yet.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* ── Jam role ── */}
|
||||
<div className="px-4 py-3 bg-accent/10 border border-accent/20 rounded-xl">
|
||||
<p className="text-[10px] uppercase tracking-wider text-accent/60 mb-1">Your role in the jam</p>
|
||||
<p className="text-sm text-white leading-relaxed">{playbook.jamRole}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Voicings for jamming ── */}
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Voicings — when to use which</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{playbook.voicings.map((v, i) => (
|
||||
<div key={i} className="flex gap-3 p-3 bg-surface border border-border rounded-xl hover:border-accent/20 transition-colors">
|
||||
<span className="text-accent font-black text-lg shrink-0 leading-none mt-0.5">{i + 1}</span>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-white mb-0.5">{v.name}</p>
|
||||
<p className="text-xs text-gray-400 leading-relaxed">{v.use}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-700 mt-2">See the Guitar tab for the actual fingerings of each shape.</p>
|
||||
</div>
|
||||
|
||||
{/* ── Licks & fills ── */}
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Licks & fills</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{playbook.licks.map((l, i) => (
|
||||
<div key={i} className="p-4 bg-surface border border-border rounded-xl hover:border-accent/30 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<p className="text-sm font-bold text-white">{l.title}</p>
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-accent/10 border border-accent/20 text-accent">{l.style}</span>
|
||||
</div>
|
||||
<pre className="text-[10px] font-mono text-accent/70 bg-black/40 border border-border rounded-lg px-3 py-2 overflow-x-auto leading-relaxed whitespace-pre mb-2">{l.tab}</pre>
|
||||
<p className="text-xs text-amber-400/80">
|
||||
<span className="font-semibold text-amber-400">Key insight: </span>{l.tip}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Jam tips ── */}
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Jam tips</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{playbook.jamTips.map((tip, i) => (
|
||||
<div key={i} className="flex gap-2.5 text-xs text-gray-300 leading-relaxed p-2.5 rounded-lg bg-surface border border-border">
|
||||
<span className="text-accent shrink-0 font-bold mt-0.5">→</span>
|
||||
<p>{tip}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Loop station practice ── */}
|
||||
{playbook.loopPractice?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Loop station practice</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{playbook.loopPractice.map((lp, i) => (
|
||||
<div key={i} className="p-3 bg-surface border border-border rounded-xl border-l-2 border-l-accent/40">
|
||||
<p className="text-xs font-bold text-white mb-1">🔁 {lp.title}</p>
|
||||
<p className="text-xs text-gray-400 leading-relaxed">{lp.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Explore tab ──────────────────────────────────────────────────────────────
|
||||
function ExploreTab({ initialChord, keyInfo, chordHistory }) {
|
||||
const parsed = parseChord(initialChord)
|
||||
const [root, setRoot] = useState(parsed ? NOTES[parsed.rootPc] : 'C')
|
||||
const [typeKey, setTypeKey] = useState(parsed?.type ?? 'maj')
|
||||
const [subTab, setSubTab] = useState('guitar')
|
||||
const [active, setActive] = useState(initialChord ?? '')
|
||||
|
||||
const chordName = chordDisplayName(root, typeKey)
|
||||
|
||||
function selectChord(chord) {
|
||||
setActive(chord)
|
||||
const p = parseChord(chord)
|
||||
if (p) { setRoot(NOTES[p.rootPc]); setTypeKey(p.type) }
|
||||
}
|
||||
|
||||
const recentChords = [...new Set([...(chordHistory ?? [])].reverse())].slice(0, 12)
|
||||
const keyChords = keyInfo?.root ? getChordsInKey(keyInfo.root, keyInfo.mode ?? 'major') : []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* ── Contextual quick-picks ── */}
|
||||
{(recentChords.length > 0 || keyChords.length > 0) && (
|
||||
<div className="flex flex-col gap-3 p-3 bg-surface border border-border rounded-xl">
|
||||
<ChordQuickPick label="History" chords={recentChords} active={active} keyInfo={keyInfo} onSelect={selectChord} />
|
||||
{keyChords.length > 0 && (
|
||||
<>
|
||||
{recentChords.length > 0 && <div className="h-px bg-border" />}
|
||||
<ChordQuickPick
|
||||
label={`${keyInfo.root} ${keyInfo.mode ?? ''}`}
|
||||
chords={keyChords} active={active} keyInfo={keyInfo} onSelect={selectChord}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Manual picker ── */}
|
||||
<div className="flex flex-wrap gap-2 items-center p-3 bg-surface border border-border rounded-xl">
|
||||
<span className="text-xs text-gray-500 shrink-0">Root:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{NOTES.map(n => (
|
||||
<button key={n}
|
||||
onClick={() => { setRoot(n); setActive('') }}
|
||||
className={`px-2 py-0.5 rounded text-xs font-bold transition-all ${
|
||||
root === n ? 'bg-accent text-white' : 'bg-border text-gray-400 hover:text-white'
|
||||
}`}>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-px h-4 bg-border shrink-0" />
|
||||
<span className="text-xs text-gray-500 shrink-0">Type:</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={typeKey}
|
||||
onChange={e => { setTypeKey(e.target.value); setActive('') }}
|
||||
className="appearance-none bg-panel border border-border rounded-lg pl-2 pr-6 py-1 text-xs text-gray-200 cursor-pointer focus:outline-none focus:border-accent"
|
||||
>
|
||||
{CHORD_SUFFIX_OPTIONS.map(o => (
|
||||
<option key={o.key} value={o.key}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="pointer-events-none absolute right-1.5 top-1/2 -translate-y-1/2 text-gray-500 text-xs">▾</span>
|
||||
</div>
|
||||
<div className="ml-auto text-xl font-black text-accent">{chordName}</div>
|
||||
</div>
|
||||
|
||||
{/* ── Sub-tabs ── */}
|
||||
<div className="flex gap-1 bg-surface border border-border rounded-xl p-1 overflow-x-auto">
|
||||
{[
|
||||
{ key: 'guitar', label: '🎸 Guitar' },
|
||||
{ key: 'piano', label: '🎹 Piano' },
|
||||
].map(t => (
|
||||
<button key={t.key}
|
||||
onClick={() => setSubTab(t.key)}
|
||||
className={`px-4 py-1.5 rounded-lg text-sm font-semibold transition-all whitespace-nowrap ${
|
||||
subTab === t.key ? 'bg-accent text-white' : 'text-gray-400 hover:text-white'
|
||||
}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{subTab === 'guitar' && <GuitarTab chordName={chordName} />}
|
||||
{subTab === 'piano' && <PianoTab chordName={chordName} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main modal ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ChordDetailModal({ chord, onClose, onChordClick, keyInfo, chordHistory }) {
|
||||
const [tab, setTab] = useState('guitar')
|
||||
|
||||
// Reset tab when chord changes
|
||||
useEffect(() => { setTab('guitar') }, [chord])
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
function onKey(e) { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
if (!chord) return null
|
||||
|
||||
const parsed = parseChord(chord)
|
||||
const typeName = parsed ? (CHORD_SUFFIX_OPTIONS.find(o => o.key === parsed.type)?.label ?? parsed.type) : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/70 backdrop-blur-sm p-4 overflow-y-auto"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="w-full max-w-3xl bg-panel border border-border rounded-2xl shadow-2xl mt-8 mb-8">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black text-accent leading-none">{chord}</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{typeName} chord · tap a voicing to study it</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-500 hover:text-white transition-colors text-xl leading-none"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 px-6 pt-4 overflow-x-auto">
|
||||
{[
|
||||
{ key: 'guitar', label: '🎸 Guitar' },
|
||||
{ key: 'piano', label: '🎹 Piano' },
|
||||
{ key: 'theory', label: '📚 Theory' },
|
||||
{ key: 'learn', label: '🎓 Learn' },
|
||||
{ key: 'progressions', label: '🎵 Progressions' },
|
||||
{ key: 'explore', label: '🔍 Explore' },
|
||||
].map(t => (
|
||||
<button key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 rounded-t-xl text-sm font-semibold transition-all border-b-2 whitespace-nowrap ${
|
||||
tab === t.key
|
||||
? 'text-accent border-accent bg-accent/10'
|
||||
: 'text-gray-500 border-transparent hover:text-gray-300'
|
||||
}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-5">
|
||||
{tab === 'guitar' && <GuitarTab chordName={chord} />}
|
||||
{tab === 'piano' && <PianoTab chordName={chord} />}
|
||||
{tab === 'theory' && <TheoryTab chordName={chord} />}
|
||||
{tab === 'learn' && <LearnTab chordName={chord} />}
|
||||
{tab === 'progressions' && <ProgressionsSubTab chordName={chord} onChordClick={c => { onChordClick?.(c) }} />}
|
||||
{tab === 'explore' && <ExploreTab initialChord={chord} keyInfo={keyInfo} chordHistory={chordHistory} />}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
// 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,367 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import ChordBox from './ChordBox'
|
||||
import RiffDiagram from './RiffDiagram'
|
||||
import { getGuitarVoicings, parseChord } from '../lib/voicings'
|
||||
import { CHORD_TYPES, NOTES, toRomanNumeral } from '../lib/theory'
|
||||
import { findSimilarProgressions, progressionInKey } from '../lib/education'
|
||||
|
||||
// ─── Scale ideas per mode ─────────────────────────────────────────────────────
|
||||
const SCALE_IDEAS = {
|
||||
major: [
|
||||
{ name: 'Major Pentatonic', intervals: '1–2–3–5–6', scaleIntervals: [0,2,4,7,9], desc: 'Safe and bright. Everything you play will land. Start on the root, end on the root.' },
|
||||
{ name: 'Mixolydian', intervals: '1–2–3–4–5–6–♭7', scaleIntervals: [0,2,4,5,7,9,10], desc: 'Major with a bluesy ♭7. The defining sound of classic rock — Sweet Home Alabama lives here.' },
|
||||
{ name: 'Lydian', intervals: '1–2–3–♯4–5–6–7', scaleIntervals: [0,2,4,6,7,9,11], desc: 'Dreamy and floating. The ♯4 is the magic note — use it on long sustained notes for instant wonder.' },
|
||||
],
|
||||
minor: [
|
||||
{ name: 'Minor Pentatonic', intervals: '1–♭3–4–5–♭7', scaleIntervals: [0,3,5,7,10], desc: 'The blues box. Bends on ♭3 and slides to 5 are gold. Start here every time.' },
|
||||
{ name: 'Natural Minor', intervals: '1–2–♭3–4–5–♭6–♭7', scaleIntervals: [0,2,3,5,7,8,10], desc: 'Full Aeolian scale. Melodic and dark. The ♭6 gives it a cinematic quality.' },
|
||||
{ name: 'Dorian', intervals: '1–2–♭3–4–5–6–♭7', scaleIntervals: [0,2,3,5,7,9,10], desc: "Minor with a raised 6th — smooth and soulful. Santana's go-to. That major 6th is everything." },
|
||||
],
|
||||
dorian: [
|
||||
{ name: 'Dorian Mode', intervals: '1–2–♭3–4–5–6–♭7', scaleIntervals: [0,2,3,5,7,9,10], desc: "The raised 6th over minor is the colour. Mix freely with minor pentatonic and touch that 6th note." },
|
||||
{ name: 'Minor Pentatonic', intervals: '1–♭3–4–5–♭7', scaleIntervals: [0,3,5,7,10], desc: 'Safe backbone in Dorian. You can ignore the 6th — or highlight it for that Dorian sparkle.' },
|
||||
{ name: 'Blues Scale', intervals: '1–♭3–4–♭5–5–♭7', scaleIntervals: [0,3,5,6,7,10], desc: 'Add the ♭5 passing tone through the 5 — that slide is the essence of blues expression.' },
|
||||
],
|
||||
mixolydian: [
|
||||
{ name: 'Mixolydian Mode', intervals: '1–2–3–4–5–6–♭7', scaleIntervals: [0,2,4,5,7,9,10], desc: 'The ♭7 is your signature note. Hit it and slide down — instant swagger.' },
|
||||
{ name: 'Major Pentatonic', intervals: '1–2–3–5–6', scaleIntervals: [0,2,4,7,9], desc: 'Works beautifully over the I chord. Clean and reliable when you need to land safely.' },
|
||||
{ name: 'Blues Scale', intervals: '1–♭3–3–4–5–♭7', scaleIntervals: [0,3,4,5,7,10], desc: 'The hybrid blues scale. Bend the ♭3 up to the 3 — that tension and release is everything.' },
|
||||
],
|
||||
phrygian: [
|
||||
{ name: 'Phrygian Mode', intervals: '1–♭2–♭3–4–5–♭6–♭7', scaleIntervals: [0,1,3,5,7,8,10], desc: 'That ♭2 is the spine-chilling note. Lean on it. Spanish fire and metal darkness in one scale.' },
|
||||
{ name: 'Phrygian Dominant',intervals: '1–♭2–3–4–5–♭6–♭7', scaleIntervals: [0,1,4,5,7,8,10], desc: 'Raise the ♭3 to a major 3rd. Flamenco and Middle-Eastern intensity. Dramatic every time.' },
|
||||
{ name: 'Minor Pentatonic', intervals: '1–♭3–4–5–♭7', scaleIntervals: [0,3,5,7,10], desc: 'Avoid the ♭2 and play safe pentatonic runs — then hit the ♭2 as a surprise.' },
|
||||
],
|
||||
lydian: [
|
||||
{ name: 'Lydian Mode', intervals: '1–2–3–♯4–5–6–7', scaleIntervals: [0,2,4,6,7,9,11], desc: 'Float on the ♯4. John Williams writes entire film scores in Lydian. Sustain everything.' },
|
||||
{ name: 'Major Pentatonic', intervals: '1–2–3–5–6', scaleIntervals: [0,2,4,7,9], desc: 'The reliable base. Use Lydian mode sparingly on top for colour.' },
|
||||
{ name: 'Lydian Dominant', intervals: '1–2–3–♯4–5–6–♭7', scaleIntervals: [0,2,4,6,7,9,10], desc: 'Lydian with a ♭7 — the jazz/fusion ♯4 chord sound. Herbie Hancock territory.' },
|
||||
],
|
||||
}
|
||||
|
||||
// Fallback
|
||||
const SCALE_FALLBACK = SCALE_IDEAS.major
|
||||
|
||||
// ─── Style variations for a progression ──────────────────────────────────────
|
||||
const STYLE_VARIATIONS = [
|
||||
{
|
||||
key: 'open',
|
||||
label: 'Open & Spacious',
|
||||
desc: 'Sus2 and add9 voicings — airy, gentle. Great for quiet intros and ambient sections.',
|
||||
typeMap: { maj: 'sus2', min: 'sus2', dom7: 'sus4', maj7: 'add9', min7: 'sus2', add9: 'sus2', sus4: 'sus4', sus2: 'sus2', dim: 'dim', aug: 'aug', half_dim: 'half_dim', maj6: 'sus2', min6: 'sus2' },
|
||||
color: 'text-blue-400',
|
||||
border: 'border-blue-900/40',
|
||||
},
|
||||
{
|
||||
key: 'jazz',
|
||||
label: 'Jazz Upgrade',
|
||||
desc: 'Triads → 7ths — instant sophistication. Works at any tempo, in any band context.',
|
||||
typeMap: { maj: 'maj7', min: 'min7', dom7: 'dom7', add9: 'maj7', sus2: 'sus2', sus4: 'sus4', dim: 'dim7', aug: 'aug', half_dim: 'half_dim', maj6: 'maj6', min6: 'min6' },
|
||||
color: 'text-amber-400',
|
||||
border: 'border-amber-900/40',
|
||||
},
|
||||
{
|
||||
key: 'blues',
|
||||
label: 'Blues Stomp',
|
||||
desc: 'Everything → dom7. Gritty, raw, powerful. All three chords want to slide and bend.',
|
||||
typeMap: { maj: 'dom7', min: 'dom7', maj7: 'dom7', min7: 'dom7', add9: 'dom7', sus2: 'dom7', sus4: 'dom7', dim: 'dim7', aug: 'aug', half_dim: 'dom7', maj6: 'dom7', min6: 'dom7' },
|
||||
color: 'text-red-400',
|
||||
border: 'border-red-900/40',
|
||||
},
|
||||
{
|
||||
key: 'modern',
|
||||
label: 'Neo-Soul / Modern',
|
||||
desc: "Add9 on majors, m7 on minors. D'Angelo, Thundercat, Childish Gambino territory.",
|
||||
typeMap: { maj: 'add9', min: 'min7', dom7: 'dom7', maj7: 'add9', min7: 'min7', add9: 'add9', sus2: 'sus2', sus4: 'sus4', dim: 'dim', aug: 'aug', half_dim: 'half_dim', maj6: 'add9', min6: 'min7' },
|
||||
color: 'text-purple-400',
|
||||
border: 'border-purple-900/40',
|
||||
},
|
||||
]
|
||||
|
||||
// Transform a chord via a type map
|
||||
function transformChord(chordStr, typeMap) {
|
||||
const p = parseChord(chordStr)
|
||||
if (!p) return chordStr
|
||||
const newType = typeMap[p.type] ?? p.type
|
||||
return NOTES[p.rootPc] + (CHORD_TYPES[newType]?.suffix ?? '')
|
||||
}
|
||||
|
||||
// Get best voicings for a chord — prefer open shapes, then low-fret barre
|
||||
function getBestVoicings(chordStr, max = 4) {
|
||||
const all = getGuitarVoicings(chordStr)
|
||||
// Sort: open shapes first (label contains "Open"), then barre
|
||||
const open = all.filter(v => v.label.includes('Open'))
|
||||
const barre = all.filter(v => !v.label.includes('Open'))
|
||||
return [...open, ...barre].slice(0, max)
|
||||
}
|
||||
|
||||
// ─── Per-chord voicing strip ──────────────────────────────────────────────────
|
||||
function ChordStrip({ chordStr, keyInfo, onChordClick }) {
|
||||
const voicings = getBestVoicings(chordStr, 4)
|
||||
const rn = keyInfo?.root ? toRomanNumeral(chordStr, keyInfo.root, keyInfo.mode) : ''
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => onChordClick?.(chordStr)}
|
||||
className="px-3 py-1 bg-accent/10 border border-accent/40 rounded-lg font-black text-lg text-accent hover:bg-accent/20 transition-colors">
|
||||
{chordStr}
|
||||
</button>
|
||||
{rn && <span className="text-amber-400 text-sm font-semibold">{rn}</span>}
|
||||
<span className="text-[11px] text-gray-600 ml-auto">click for all voicings</span>
|
||||
</div>
|
||||
{voicings.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{voicings.map((v, i) => (
|
||||
<div key={i} className="flex flex-col items-center">
|
||||
<ChordBox frets={v.frets} fingers={v.fingers} barre={v.barre} baseFret={v.baseFret} />
|
||||
<p className="text-[10px] text-gray-600 text-center mt-1 max-w-[100px]">{v.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-600 text-xs">No voicings available.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Style variation section ──────────────────────────────────────────────────
|
||||
function StyleSection({ progression, onChordClick }) {
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{STYLE_VARIATIONS.map(style => {
|
||||
const isOpen = expanded === style.key
|
||||
const transformed = progression.map(c => transformChord(c, style.typeMap))
|
||||
|
||||
return (
|
||||
<div key={style.key} className={`border rounded-xl overflow-hidden transition-colors ${style.border} hover:border-opacity-70`}>
|
||||
<button onClick={() => setExpanded(isOpen ? null : style.key)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-bold text-sm ${style.color}`}>{style.label}</span>
|
||||
<div className="flex gap-1">
|
||||
{transformed.map((c, i) => (
|
||||
<span key={i} className="text-xs font-bold text-gray-300">{c}{i < transformed.length - 1 ? ' →' : ''}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-gray-500">{style.desc}</span>
|
||||
</div>
|
||||
<span className="text-gray-600 shrink-0 ml-3">{isOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t border-border/50 px-4 py-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{transformed.map((c, i) => {
|
||||
const voicings = getBestVoicings(c, 2)
|
||||
return (
|
||||
<div key={i} className="flex flex-col items-center gap-2">
|
||||
<button onClick={() => onChordClick?.(c)}
|
||||
className="px-2 py-0.5 bg-panel border border-border hover:border-accent/50 rounded-lg font-bold text-sm text-gray-200 hover:text-accent transition-all">
|
||||
{c}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
{voicings.map((v, vi) => (
|
||||
<div key={vi} className="flex flex-col items-center">
|
||||
<ChordBox frets={v.frets} fingers={v.fingers} barre={v.barre} baseFret={v.baseFret} />
|
||||
<p className="text-[9px] text-gray-700 text-center mt-0.5 max-w-[90px]">{v.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Similar famous progressions ─────────────────────────────────────────────
|
||||
function SimilarSection({ progression, keyInfo, onChordClick }) {
|
||||
const similar = findSimilarProgressions(progression, keyInfo)
|
||||
if (!similar.length) return (
|
||||
<p className="text-gray-600 text-sm text-center py-3">Play more and lock a key — similar progressions will appear here.</p>
|
||||
)
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{similar.slice(0, 3).map(prog => {
|
||||
const chordsHere = keyInfo?.root ? progressionInKey(prog, keyInfo.root) : []
|
||||
return (
|
||||
<div key={prog.id} className="p-3 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-center flex-wrap gap-2 mb-2">
|
||||
<span className="font-bold text-white text-sm">{prog.name}</span>
|
||||
<span className="text-[10px] font-mono text-gray-600">{prog.pattern}</span>
|
||||
<span className="text-xs text-gray-600 ml-auto">{Math.round(prog.score * 100)}% match</span>
|
||||
</div>
|
||||
{chordsHere.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 items-center mb-2">
|
||||
{chordsHere.map((c, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<button onClick={() => onChordClick?.(c)}
|
||||
className={`px-2 py-0.5 rounded-lg font-bold text-xs border transition-all ${
|
||||
i === 0 ? 'bg-accent border-accent text-white' : 'bg-panel border-border text-gray-300 hover:border-accent/50 hover:text-accent'
|
||||
}`}>
|
||||
{c}
|
||||
</button>
|
||||
{i < chordsHere.length - 1 && <span className="text-gray-700 text-xs">→</span>}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-[10px] text-gray-600 ml-1">in {keyInfo?.root} {keyInfo?.mode}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-gray-600">{prog.songs.slice(0, 3).join(' · ')}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main panel ───────────────────────────────────────────────────────────────
|
||||
export default function CurrentJamPanel({ keyInfo, chordHistory, detectedProgression, onChordClick }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState('voicings') // voicings | scales | styles | similar
|
||||
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
|
||||
// Working progression: detected loop or last 4 unique chords
|
||||
const workingProgression = detectedProgression?.length
|
||||
? detectedProgression
|
||||
: [...new Set([...chordHistory].reverse())].reverse().slice(-4)
|
||||
|
||||
const scaleIdeas = SCALE_IDEAS[mode] ?? SCALE_FALLBACK
|
||||
const rootPc = root ? NOTES.indexOf(root) : null
|
||||
const hasSession = workingProgression.length > 0
|
||||
|
||||
return (
|
||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||
<button onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>CURRENT JAM</span>
|
||||
{root && (
|
||||
<span className="text-[10px] px-2 py-0.5 bg-accent/10 border border-accent/30 rounded text-accent">
|
||||
{root} {mode} {detectedProgression?.length ? `· ${workingProgression.join(' → ')}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span>{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-border p-4 flex flex-col gap-4">
|
||||
|
||||
{!hasSession ? (
|
||||
<p className="text-gray-600 text-sm text-center py-6">Start listening and play some chords — your jam will appear here.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* ── Progression summary ── */}
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2 bg-surface border border-border rounded-xl">
|
||||
{root ? (
|
||||
<span className="text-accent font-bold text-sm">{root} {mode}</span>
|
||||
) : (
|
||||
<span className="text-gray-600 text-sm">Key detecting…</span>
|
||||
)}
|
||||
{workingProgression.length > 0 && (
|
||||
<>
|
||||
<span className="text-gray-700">·</span>
|
||||
{workingProgression.map((c, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className="text-gray-300 font-bold text-sm">{c}</span>
|
||||
{root && <span className="text-amber-400/60 text-[10px]">{toRomanNumeral(c, root, mode)}</span>}
|
||||
{i < workingProgression.length - 1 && <span className="text-gray-700">→</span>}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── View tabs ── */}
|
||||
<div className="flex gap-1 bg-surface border border-border rounded-xl p-1 overflow-x-auto">
|
||||
{[
|
||||
{ key: 'voicings', label: '🎸 Open Voicings' },
|
||||
{ key: 'scales', label: '🎵 Scales to Solo' },
|
||||
{ key: 'styles', label: '🎨 Style Options' },
|
||||
{ key: 'similar', label: '🔗 Similar Progressions' },
|
||||
].map(t => (
|
||||
<button key={t.key} onClick={() => setView(t.key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all whitespace-nowrap ${
|
||||
view === t.key ? 'bg-accent text-white' : 'text-gray-400 hover:text-white'
|
||||
}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Voicings: per chord open shapes ── */}
|
||||
{view === 'voicings' && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-gray-500">
|
||||
Best open and barre voicings for each chord in your jam. Click a chord name to see all its voicings.
|
||||
</p>
|
||||
{workingProgression.map(chord => (
|
||||
<ChordStrip key={chord} chordStr={chord} keyInfo={keyInfo} onChordClick={onChordClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Scales ── */}
|
||||
{view === 'scales' && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-gray-500">
|
||||
Scales and modes that fit {root ? `${root} ${mode}` : 'your current key'}.
|
||||
Start with the pentatonic — add the extra notes once you feel comfortable.
|
||||
</p>
|
||||
{scaleIdeas.map(idea => (
|
||||
<div key={idea.name} className="p-3 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="font-bold text-white text-sm">{idea.name}</span>
|
||||
<span className="font-mono text-xs text-accent">{idea.intervals}</span>
|
||||
</div>
|
||||
{rootPc !== null && idea.scaleIntervals && (
|
||||
<div className="mb-2 overflow-x-auto">
|
||||
<RiffDiagram rootPc={rootPc} scaleIntervals={idea.scaleIntervals} />
|
||||
<p className="text-[10px] text-gray-600 mt-1">
|
||||
Purple = root · Grey = scale tone · Fret numbers above
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 leading-snug">{idea.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[11px] text-gray-700 text-center">
|
||||
Pro tip: always resolve to a chord tone at the end of a phrase — ♭7 leading to root, or 3rd landing on the 1.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Style options ── */}
|
||||
{view === 'styles' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
Your progression re-voiced four ways. Expand any style to see the chord boxes.
|
||||
</p>
|
||||
<StyleSection progression={workingProgression} onChordClick={onChordClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Similar progressions ── */}
|
||||
{view === 'similar' && (
|
||||
<SimilarSection progression={workingProgression} keyInfo={keyInfo} onChordClick={onChordClick} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+27
-370
@@ -1,4 +1,3 @@
|
||||
import { useRef } from 'react'
|
||||
import { getScale, getChordTones, NOTES } from '../lib/theory'
|
||||
|
||||
// ─── SVG Piano — 2 octaves (C3–B4) ───────────────────────────────────────────
|
||||
@@ -15,7 +14,7 @@ const BLACK_OCT = [
|
||||
]
|
||||
const WHITE_LABELS = ['C3','D3','E3','F3','G3','A3','B3','C4','D4','E4','F4','G4','A4','B4']
|
||||
|
||||
function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false, monoColor = false }) {
|
||||
function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false }) {
|
||||
const max = Math.max(...values, 0.01)
|
||||
const wKeys = []
|
||||
const bKeys = []
|
||||
@@ -36,7 +35,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false,
|
||||
const fillColor = inChord
|
||||
? `rgba(167,139,250,${0.12 + energy * 0.88})`
|
||||
: inKey
|
||||
? monoColor ? `rgba(192,132,252,${0.1 + energy * 0.7})` : `rgba(251,191,36,${0.1 + energy * 0.7})`
|
||||
? `rgba(251,191,36,${0.1 + energy * 0.7})`
|
||||
: `rgba(180,180,190,${0.05 + energy * 0.2})`
|
||||
const pct = showPct ? Math.round(values[pc] * 100) : 0
|
||||
|
||||
@@ -44,7 +43,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false,
|
||||
<g key={`w${wi}`}>
|
||||
<rect x={x+1} y={3} width={KEY_W-2} height={keyH}
|
||||
rx={3} fill="rgb(20,20,26)" stroke="rgba(255,255,255,0.08)" strokeWidth={1} />
|
||||
{energy > (inChord || inKey ? 0.12 : 0.35) && (
|
||||
{energy > 0.04 && (
|
||||
<rect
|
||||
x={x+1} y={3 + keyH * (1 - Math.min(energy, 1) * 0.85)}
|
||||
width={KEY_W-2} height={keyH * Math.min(energy, 1) * 0.85}
|
||||
@@ -56,7 +55,7 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false,
|
||||
</text>
|
||||
{showPct && pct > 0 && (
|
||||
<text x={x + KEY_W/2} y={keyH + 14} textAnchor="middle" fontSize={8}
|
||||
fill={inKey ? (monoColor ? 'rgb(192,132,252)' : 'rgb(251,191,36)') : 'rgba(100,100,110,0.8)'}>
|
||||
fill={inKey ? 'rgb(251,191,36)' : 'rgba(100,100,110,0.8)'}>
|
||||
{pct}%
|
||||
</text>
|
||||
)}
|
||||
@@ -70,34 +69,17 @@ function PianoSVG({ values, keyNotes, chordNotes, keyH = KEY_H, showPct = false,
|
||||
const inChord = chordNotes?.has(pc)
|
||||
const inKey = keyNotes?.has(pc)
|
||||
const x = wi * KEY_W + KEY_W - BLACK_W / 2
|
||||
const fillColor = inChord
|
||||
? 'rgba(139,92,246,0.9)'
|
||||
const bg = inChord
|
||||
? `rgba(139,92,246,${0.4 + energy * 0.6})`
|
||||
: inKey
|
||||
? monoColor ? 'rgba(192,132,252,0.85)' : 'rgba(180,130,0,0.85)'
|
||||
: 'rgba(70,70,80,0.75)'
|
||||
const pct = showPct ? Math.round(values[pc] * 100) : 0
|
||||
? `rgba(180,130,0,${0.35 + energy * 0.55})`
|
||||
: `rgba(12,12,16,0.95)`
|
||||
|
||||
return (
|
||||
<g key={`b${i}`}>
|
||||
{/* Base */}
|
||||
<rect x={x} y={3} width={BLACK_W} height={BLACK_H}
|
||||
rx={2} fill="rgb(14,14,18)" stroke="rgba(255,255,255,0.06)" strokeWidth={1} />
|
||||
{/* Partial fill from bottom — same mechanic as white keys */}
|
||||
{energy > (inChord || inKey ? 0.05 : 0.35) && (
|
||||
<rect
|
||||
x={x} y={3 + BLACK_H * (1 - Math.min(energy, 1) * 0.9)}
|
||||
width={BLACK_W} height={BLACK_H * Math.min(energy, 1) * 0.9}
|
||||
rx={1} fill={fillColor} />
|
||||
)}
|
||||
{/* % label near top of key (inside) */}
|
||||
{showPct && pct > 0 && (
|
||||
<text x={x + BLACK_W/2} y={3 + 10} textAnchor="middle" fontSize={7}
|
||||
fill={inKey || inChord ? 'rgba(220,220,230,0.9)' : 'rgba(110,110,120,0.7)'}>
|
||||
{pct}%
|
||||
</text>
|
||||
)}
|
||||
{/* Note name near bottom of key */}
|
||||
<text x={x + BLACK_W/2} y={3 + BLACK_H - 5} textAnchor="middle" fontSize={7}
|
||||
rx={2} fill={bg} stroke="rgba(255,255,255,0.06)" strokeWidth={1} />
|
||||
<text x={x + BLACK_W/2} y={BLACK_H - 5} textAnchor="middle" fontSize={7}
|
||||
fill={inKey || inChord ? 'rgba(210,210,220,0.85)' : 'rgba(110,110,120,0.6)'}>
|
||||
{NOTES[pc]}
|
||||
</text>
|
||||
@@ -131,7 +113,7 @@ const MF_H = MF_PAD_T + 5 * MF_STR_H + MF_PAD_B
|
||||
const mfFretX = f => MF_NUT_X + (f - 0.5) * MF_FRET_W
|
||||
const mfStringY = si => MF_PAD_T + si * MF_STR_H
|
||||
|
||||
function MiniFretboard({ values, keyNotes, chordNotes, monoColor = false }) {
|
||||
function MiniFretboard({ values, keyNotes, chordNotes }) {
|
||||
const max = Math.max(...values, 0.01)
|
||||
|
||||
return (
|
||||
@@ -188,8 +170,7 @@ function MiniFretboard({ values, keyNotes, chordNotes, monoColor = false }) {
|
||||
const energy = values[pc] / max
|
||||
const inChord = chordNotes?.has(pc)
|
||||
const inKey = keyNotes?.has(pc)
|
||||
if (!inChord && !inKey && energy < 0.35) return null
|
||||
if ((inChord || inKey) && energy < 0.08) return null
|
||||
if (!inChord && !inKey && energy < 0.12) return null
|
||||
|
||||
const cx = fi === 0 ? MF_OPEN_X : mfFretX(fi)
|
||||
const cy = mfStringY(si)
|
||||
@@ -199,8 +180,8 @@ function MiniFretboard({ values, keyNotes, chordNotes, monoColor = false }) {
|
||||
fill = `rgba(168,85,247,${0.3 + energy * 0.7})`
|
||||
textFill = '#fff'
|
||||
} else if (inKey) {
|
||||
fill = monoColor ? `rgba(192,132,252,${0.2 + energy * 0.75})` : `rgba(245,158,11,${0.2 + energy * 0.75})`
|
||||
textFill = monoColor ? '#fff' : 'rgba(0,0,0,0.85)'
|
||||
fill = `rgba(245,158,11,${0.2 + energy * 0.75})`
|
||||
textFill = 'rgba(0,0,0,0.85)'
|
||||
} else {
|
||||
fill = `rgba(100,100,120,${energy * 0.7})`
|
||||
textFill = 'rgba(180,180,190,0.7)'
|
||||
@@ -210,7 +191,7 @@ function MiniFretboard({ values, keyNotes, chordNotes, monoColor = false }) {
|
||||
<g key={`${si}-${fi}`}>
|
||||
{energy > 0.3 && (inChord || inKey) && (
|
||||
<circle cx={cx} cy={cy} r={MF_DOT_R + 4}
|
||||
fill={inChord ? 'rgba(168,85,247,0.25)' : monoColor ? 'rgba(192,132,252,0.2)' : 'rgba(245,158,11,0.2)'}
|
||||
fill={inChord ? 'rgba(168,85,247,0.25)' : 'rgba(245,158,11,0.2)'}
|
||||
style={{ filter: 'blur(4px)' }} />
|
||||
)}
|
||||
<circle cx={cx} cy={cy} r={MF_DOT_R} fill={fill} />
|
||||
@@ -225,338 +206,27 @@ function MiniFretboard({ values, keyNotes, chordNotes, monoColor = false }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Oscilloscope strip ───────────────────────────────────────────────────────
|
||||
const OSC_W = 600
|
||||
const OSC_H = 110
|
||||
|
||||
function Oscilloscope({ waveform }) {
|
||||
const { wave, rms, detectedFreq, detectedNote } = waveform || {}
|
||||
const silent = !rms || rms < 0.005
|
||||
|
||||
// ── Note scroll history — last 5 distinct notes ───────────────────────────
|
||||
const noteHistoryRef = useRef([]) // [{ note, freq, id }, ...] oldest first
|
||||
const lastNoteRef = useRef(null)
|
||||
const noteIdRef = useRef(0)
|
||||
const lastDisplayRef = useRef(null) // last detected note shown in header — never flickers
|
||||
if (detectedNote && detectedNote !== lastNoteRef.current) {
|
||||
lastNoteRef.current = detectedNote
|
||||
lastDisplayRef.current = { note: detectedNote, freq: detectedFreq }
|
||||
noteHistoryRef.current.push({ note: detectedNote, freq: detectedFreq, id: noteIdRef.current++ })
|
||||
if (noteHistoryRef.current.length > 5) noteHistoryRef.current.shift()
|
||||
} else if (detectedFreq && detectedNote) {
|
||||
lastDisplayRef.current = { note: detectedNote, freq: detectedFreq }
|
||||
}
|
||||
|
||||
// ── Ghost waveform — holds the last clear-pitch shape, fades slowly ───────
|
||||
const ghostRef = useRef({ path: '', fill: '', opacity: 0 })
|
||||
if (detectedFreq) {
|
||||
ghostRef.current = { path: '', fill: '', opacity: 1 } // will be filled below
|
||||
} else {
|
||||
ghostRef.current = { ...ghostRef.current, opacity: ghostRef.current.opacity * 0.97 }
|
||||
}
|
||||
|
||||
let path = '', sinePath = ''
|
||||
if (wave?.length) {
|
||||
const mid = OSC_H / 2
|
||||
const waveAmp = Math.max(...wave.map(Math.abs), 0.001)
|
||||
const gain = Math.min((OSC_H * 0.44) / waveAmp, OSC_H * 0.44)
|
||||
|
||||
const lo = Math.floor(wave.length / 4)
|
||||
const hi = Math.floor(wave.length / 2)
|
||||
let offset = lo
|
||||
for (let i = lo; i < hi - 1; i++) {
|
||||
if (wave[i] <= 0 && wave[i + 1] > 0) { offset = i; break }
|
||||
}
|
||||
const drawLen = Math.min(wave.length - offset, Math.floor(wave.length * 0.85))
|
||||
const step = OSC_W / drawLen
|
||||
|
||||
path = Array.from({ length: drawLen }, (_, i) => {
|
||||
const v = wave[offset + i]
|
||||
return `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(1)},${(mid - v * gain).toFixed(1)}`
|
||||
}).join(' ')
|
||||
|
||||
// Capture ghost path when we have a clear pitch
|
||||
if (detectedFreq) {
|
||||
ghostRef.current.path = path
|
||||
ghostRef.current.fill = path + ` L${OSC_W},${mid} L0,${mid} Z`
|
||||
}
|
||||
|
||||
if (detectedFreq) {
|
||||
const effectiveSR = 44100 / 8
|
||||
const sineAmp = Math.min(waveAmp * gain * 0.55, OSC_H * 0.38)
|
||||
sinePath = Array.from({ length: 300 }, (_, i) => {
|
||||
const t = i / 299
|
||||
const x = (t * OSC_W).toFixed(1)
|
||||
const phase = ((offset + t * drawLen) / effectiveSR) * detectedFreq * Math.PI * 2
|
||||
const y = (mid - Math.sin(phase) * sineAmp).toFixed(1)
|
||||
return `${i === 0 ? 'M' : 'L'}${x},${y}`
|
||||
}).join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
const lineColor = detectedFreq
|
||||
? 'rgba(168,85,247,0.9)'
|
||||
: silent ? 'rgba(50,50,60,0.8)' : 'rgba(100,200,140,0.75)'
|
||||
|
||||
const ghost = ghostRef.current
|
||||
const ghostOp = ghost.opacity
|
||||
const noteHistory = noteHistoryRef.current
|
||||
const lastDisplay = lastDisplayRef.current
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-xs text-gray-600 uppercase tracking-widest">Oscilloscope — raw mic input</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastDisplay && (
|
||||
<>
|
||||
<span className={`text-xs font-bold ${detectedFreq ? 'text-accent' : 'text-gray-500'}`}>{lastDisplay.note}</span>
|
||||
<span className="text-xs text-gray-500 tabular-nums">{lastDisplay.freq.toFixed(1)} Hz</span>
|
||||
<span className="text-xs text-gray-600 tabular-nums">{(1000 / lastDisplay.freq).toFixed(2)} ms / cycle</span>
|
||||
</>
|
||||
)}
|
||||
{silent && <span className="text-xs text-gray-700">silence</span>}
|
||||
<span className="text-xs text-gray-700 tabular-nums">rms {rms ? (rms * 100).toFixed(1) : '0.0'}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${OSC_W} ${OSC_H}`} width="100%" style={{ display: 'block' }}
|
||||
className="rounded-lg bg-surface border border-border">
|
||||
{/* Zero line */}
|
||||
<line x1={0} y1={OSC_H / 2} x2={OSC_W} y2={OSC_H / 2}
|
||||
stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} />
|
||||
|
||||
{/* Ghost waveform — previous clear-pitch shape fading out */}
|
||||
{ghost.path && ghostOp > 0.04 && !detectedFreq && (
|
||||
<>
|
||||
<path d={ghost.fill} fill={`rgba(168,85,247,${(ghostOp * 0.06).toFixed(3)})`} />
|
||||
<path d={ghost.path} fill="none"
|
||||
stroke={`rgba(150,120,200,${(ghostOp * 0.35).toFixed(3)})`}
|
||||
strokeWidth={0.8} strokeLinejoin="round" strokeLinecap="round" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fill body */}
|
||||
{path && (
|
||||
<path
|
||||
d={`${path} L${OSC_W},${OSC_H / 2} L0,${OSC_H / 2} Z`}
|
||||
fill={detectedFreq
|
||||
? 'rgba(168,85,247,0.08)'
|
||||
: silent ? 'none' : 'rgba(90,190,130,0.07)'}
|
||||
/>
|
||||
)}
|
||||
{/* Waveform line */}
|
||||
{path && <path d={path} fill="none" stroke={lineColor} strokeWidth={0.9}
|
||||
strokeLinejoin="round" strokeLinecap="round" />}
|
||||
{/* Sine overlay */}
|
||||
{sinePath && <path d={sinePath} fill="none"
|
||||
stroke="rgba(168,85,247,0.28)" strokeWidth={0.9}
|
||||
strokeLinejoin="round" strokeDasharray="5 4" />}
|
||||
|
||||
{/* Scrolling note history — newest on right, slides left on each new note */}
|
||||
{noteHistory.map((entry, i) => {
|
||||
const age = noteHistory.length - 1 - i // 0 = newest
|
||||
const x = OSC_W - 28 - age * 100
|
||||
const op = (1 - age * 0.18).toFixed(2)
|
||||
const isNew = age === 0
|
||||
return (
|
||||
<g key={entry.id}
|
||||
style={{ transform: `translateX(${x}px)`, transition: 'transform 0.45s cubic-bezier(0.4,0,0.2,1)' }}>
|
||||
<text x={0} y={OSC_H - 18} textAnchor="middle"
|
||||
fontSize={isNew ? 13 : 11} fontWeight={isNew ? '700' : '400'}
|
||||
fill={isNew ? `rgba(168,85,247,${op})` : `rgba(160,130,210,${op})`}>
|
||||
{entry.note}
|
||||
</text>
|
||||
<text x={0} y={OSC_H - 7} textAnchor="middle" fontSize={7}
|
||||
fill={`rgba(120,100,160,${(parseFloat(op) * 0.7).toFixed(2)})`}>
|
||||
{entry.freq ? entry.freq.toFixed(0) : ''}Hz
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Frequency spectrum ───────────────────────────────────────────────────────
|
||||
const SPEC_H = 130
|
||||
const SPEC_F_MIN = 40
|
||||
const SPEC_F_MAX = 4000
|
||||
const SPEC_LOG = Math.log(SPEC_F_MAX / SPEC_F_MIN)
|
||||
|
||||
// Map a frequency in Hz to an x pixel position (log scale)
|
||||
function specX(f, w) {
|
||||
if (f <= SPEC_F_MIN) return 0
|
||||
if (f >= SPEC_F_MAX) return w
|
||||
return w * Math.log(f / SPEC_F_MIN) / SPEC_LOG
|
||||
}
|
||||
|
||||
const SPEC_GRID = [
|
||||
{ label: 'E2', freq: 82.4 },
|
||||
{ label: 'C3', freq: 130.8 },
|
||||
{ label: 'E3', freq: 164.8 },
|
||||
{ label: 'A3', freq: 220 },
|
||||
{ label: 'C4', freq: 261.6 },
|
||||
{ label: 'E4', freq: 329.6 },
|
||||
{ label: 'A4', freq: 440 },
|
||||
{ label: 'C5', freq: 523.3 },
|
||||
{ label: 'C6', freq: 1046.5},
|
||||
{ label: 'C7', freq: 2093 },
|
||||
]
|
||||
|
||||
function SpectrumPanel({ spectrum, detectedFreq }) {
|
||||
const W = OSC_W
|
||||
const ghostRef = useRef(null)
|
||||
const ghostFreqRef = useRef(null) // { freq, opacity }
|
||||
|
||||
// Ghost frequency lines — lock on detection, decay slowly when gone
|
||||
if (detectedFreq) {
|
||||
ghostFreqRef.current = { freq: detectedFreq, opacity: 1 }
|
||||
} else if (ghostFreqRef.current) {
|
||||
ghostFreqRef.current = { freq: ghostFreqRef.current.freq, opacity: ghostFreqRef.current.opacity * 0.97 }
|
||||
}
|
||||
const ghostFreq = ghostFreqRef.current?.opacity > 0.04 ? ghostFreqRef.current.freq : null
|
||||
const ghostOpacity = ghostFreqRef.current?.opacity ?? 0
|
||||
|
||||
// Ghost: rises instantly with signal, decays very slowly — lingers as grey
|
||||
if (spectrum?.length) {
|
||||
if (!ghostRef.current) ghostRef.current = new Float32Array(spectrum.length)
|
||||
const ghost = ghostRef.current
|
||||
for (let i = 0; i < spectrum.length; i++) {
|
||||
ghost[i] = spectrum[i] > ghost[i] ? spectrum[i] : ghost[i] * 0.988
|
||||
}
|
||||
}
|
||||
|
||||
let fillPath = '', strokePath = '', ghostFill = '', ghostStroke = ''
|
||||
|
||||
if (spectrum?.length) {
|
||||
const n = spectrum.length
|
||||
const pts = Array.from({ length: n }, (_, i) => {
|
||||
const x = ((i / (n - 1)) * W).toFixed(1)
|
||||
const y = (SPEC_H * (1 - spectrum[i])).toFixed(1)
|
||||
return `${i === 0 ? 'M' : 'L'}${x},${y}`
|
||||
}).join(' ')
|
||||
strokePath = pts
|
||||
fillPath = pts + ` L${W},${SPEC_H} L0,${SPEC_H} Z`
|
||||
|
||||
const ghost = ghostRef.current
|
||||
if (ghost) {
|
||||
const gpts = Array.from({ length: n }, (_, i) => {
|
||||
const x = ((i / (n - 1)) * W).toFixed(1)
|
||||
const y = (SPEC_H * (1 - ghost[i])).toFixed(1)
|
||||
return `${i === 0 ? 'M' : 'L'}${x},${y}`
|
||||
}).join(' ')
|
||||
ghostStroke = gpts
|
||||
ghostFill = gpts + ` L${W},${SPEC_H} L0,${SPEC_H} Z`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 uppercase tracking-widest mb-1">
|
||||
Frequency spectrum — 40 Hz → 4 kHz (log scale)
|
||||
</p>
|
||||
<svg viewBox={`0 0 ${W} ${SPEC_H}`} width="100%" style={{ display: 'block' }}
|
||||
className="rounded-lg bg-surface border border-border">
|
||||
|
||||
{/* Note grid lines */}
|
||||
{SPEC_GRID.map(({ label, freq }) => {
|
||||
const x = specX(freq, W).toFixed(1)
|
||||
return (
|
||||
<g key={label}>
|
||||
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
|
||||
stroke="rgba(255,255,255,0.06)" strokeWidth={1} />
|
||||
<text x={x} y={SPEC_H - 3} textAnchor="middle" fontSize={7.5}
|
||||
fill="rgba(80,80,95,0.9)">{label}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Ghost — slow-decaying grey residue from previous peaks */}
|
||||
{ghostFill && (
|
||||
<>
|
||||
<path d={ghostFill} fill="rgba(120,120,130,0.08)" />
|
||||
<path d={ghostStroke} fill="none" stroke="rgba(130,130,145,0.30)" strokeWidth={0.7} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Live spectrum fill + stroke */}
|
||||
{fillPath && (
|
||||
<>
|
||||
<path d={fillPath} fill="rgba(80,180,130,0.13)" />
|
||||
<path d={strokePath} fill="none" stroke="rgba(90,200,145,0.55)" strokeWidth={0.8} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fundamental + harmonics */}
|
||||
{ghostFreq && [1, 2, 3, 4, 5].map(h => {
|
||||
const hf = ghostFreq * h
|
||||
if (hf > SPEC_F_MAX) return null
|
||||
const xNum = specX(hf, W)
|
||||
const x = xNum.toFixed(1)
|
||||
const midi = Math.round(12 * Math.log2(hf / 440) + 69)
|
||||
const note = NOTES[((midi % 12) + 12) % 12]
|
||||
const oct = Math.floor(midi / 12) - 1
|
||||
// Place label left of line near the right edge, right of line elsewhere
|
||||
const labelX = xNum > W - 40 ? xNum - 3 : xNum + 3
|
||||
const anchor = xNum > W - 40 ? 'end' : 'start'
|
||||
|
||||
if (h === 1) {
|
||||
const op = (0.9 * ghostOpacity).toFixed(3)
|
||||
const textOp = (ghostOpacity * 0.95).toFixed(3)
|
||||
return (
|
||||
<g key={h}>
|
||||
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
|
||||
stroke={`rgba(168,85,247,${op})`} strokeWidth={1.2} />
|
||||
<text x={labelX} y={10} textAnchor={anchor} fontSize={8} fontWeight="700"
|
||||
fill={`rgba(168,85,247,${textOp})`}>{note}{oct}</text>
|
||||
<text x={labelX} y={20} textAnchor={anchor} fontSize={7}
|
||||
fill={`rgba(168,85,247,${(ghostOpacity * 0.55).toFixed(3)})`}>f</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
const op = ((0.5 - (h - 2) * 0.1) * ghostOpacity).toFixed(3)
|
||||
return (
|
||||
<g key={h}>
|
||||
<line x1={x} y1={0} x2={x} y2={SPEC_H - 14}
|
||||
stroke={`rgba(168,85,247,${op})`} strokeWidth={0.7} strokeDasharray="3 4" />
|
||||
<text x={labelX} y={10} textAnchor={anchor} fontSize={7.5}
|
||||
fill={`rgba(168,85,247,${op})`}>{note}{oct}</text>
|
||||
<text x={labelX} y={19} textAnchor={anchor} fontSize={7}
|
||||
fill={`rgba(168,85,247,${(parseFloat(op) * 0.7).toFixed(3)})`}>{h}f</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
export default function DebugView({ chroma, chordCandidates, noteAnalysis, waveform, keyInfo, currentChord, instrument = 'guitar', monoColor = false }) {
|
||||
export default function DebugView({ chroma, chordCandidates, noteAnalysis, keyInfo, currentChord, instrument = 'guitar' }) {
|
||||
const keyPCs = new Set(keyInfo ? getScale(keyInfo.root, keyInfo.mode).map(n => NOTES.indexOf(n)) : [])
|
||||
const chordPCs = new Set(currentChord ? getChordTones(currentChord).map(n => NOTES.indexOf(n)) : [])
|
||||
|
||||
const chromaArr = chroma ? [...chroma] : new Array(12).fill(0)
|
||||
const histFreq = noteAnalysis ? noteAnalysis.freq : new Array(12).fill(0)
|
||||
const topKeys = noteAnalysis ? noteAnalysis.topKeys : []
|
||||
const totalNotes = noteAnalysis?.total ?? 0
|
||||
const sessionSecs = noteAnalysis?.sessionSecs ?? 0
|
||||
const sessionLabel = sessionSecs >= 60
|
||||
? `${Math.floor(sessionSecs / 60)}m ${sessionSecs % 60}s`
|
||||
: `${sessionSecs}s`
|
||||
const chromaArr = chroma ? [...chroma] : new Array(12).fill(0)
|
||||
const histFreq = noteAnalysis ? noteAnalysis.freq : new Array(12).fill(0)
|
||||
const topKeys = noteAnalysis ? noteAnalysis.topKeys : []
|
||||
const topScore = chordCandidates[0]?.score ?? 1
|
||||
const topKeyScore = topKeys[0]?.score ?? 1
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* ── Live chroma visualization (instrument-synced) ── */}
|
||||
<div className="bg-panel border border-border rounded-2xl p-4 flex flex-col gap-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest shrink-0">Behind the Scenes</p>
|
||||
|
||||
{/* ── Live chroma visualization (instrument-synced) ── */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Live chroma — what the engine hears right now</p>
|
||||
{instrument === 'guitar'
|
||||
? <MiniFretboard values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} monoColor={monoColor} />
|
||||
: <PianoSVG values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} keyH={90} monoColor={monoColor} />
|
||||
? <MiniFretboard values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} />
|
||||
: <PianoSVG values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} keyH={90} />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -599,15 +269,8 @@ export default function DebugView({ chroma, chordCandidates, noteAnalysis, wavef
|
||||
|
||||
{/* Col 2: Note history piano with % labels */}
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<p className="text-xs text-gray-600 uppercase tracking-widest">Note history</p>
|
||||
{totalNotes > 0 && (
|
||||
<span className="text-[10px] text-gray-600 tabular-nums">
|
||||
{totalNotes.toLocaleString()} notes · {sessionLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<PianoSVG values={histFreq} keyNotes={keyPCs} chordNotes={chordPCs} keyH={70} showPct={true} monoColor={monoColor} />
|
||||
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Note history — key evidence</p>
|
||||
<PianoSVG values={histFreq} keyNotes={keyPCs} chordNotes={chordPCs} keyH={70} showPct={true} />
|
||||
</div>
|
||||
|
||||
{/* Col 3: Key candidates */}
|
||||
@@ -635,12 +298,6 @@ export default function DebugView({ chroma, chordCandidates, noteAnalysis, wavef
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Oscilloscope + spectrum ── */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Oscilloscope waveform={waveform} />
|
||||
<SpectrumPanel spectrum={waveform?.spectrum} detectedFreq={waveform?.detectedFreq} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
|
||||
// Map a frequency (Hz) to a bin index in the 256-bin log spectrum (40–4000 Hz)
|
||||
function freqToBin(freq) {
|
||||
return Math.round(255 * Math.log(freq / 40) / Math.log(4000 / 40))
|
||||
}
|
||||
|
||||
function bandMax(spectrum, lo, hi) {
|
||||
if (!spectrum) return 0
|
||||
const a = freqToBin(lo)
|
||||
const b = Math.min(freqToBin(hi), spectrum.length - 1)
|
||||
let max = 0
|
||||
for (let i = a; i <= b; i++) if (spectrum[i] > max) max = spectrum[i]
|
||||
return max
|
||||
}
|
||||
|
||||
const BANDS = [
|
||||
{ label: 'Kick', lo: 40, hi: 120, color: '#ef4444' },
|
||||
{ label: 'Snare', lo: 120, hi: 300, color: '#f59e0b' },
|
||||
{ label: 'Mid', lo: 300, hi: 1000, color: '#22c55e' },
|
||||
{ label: 'Presence', lo: 1000, hi: 4000, color: '#60a5fa' },
|
||||
]
|
||||
|
||||
const TIMELINE_MS = 4000 // onset timeline window
|
||||
const RMS_HISTORY = 180 // ~3s at 60fps
|
||||
|
||||
export default function DrumView({ waveform, bpm }) {
|
||||
const rmsHistRef = useRef([])
|
||||
const beatCanvasRef = useRef(null)
|
||||
const rmsCanvasRef = useRef(null)
|
||||
|
||||
const spectrum = waveform?.spectrum ?? null
|
||||
const bandLevels = BANDS.map(b => bandMax(spectrum, b.lo, b.hi))
|
||||
|
||||
// Accumulate RMS history
|
||||
useEffect(() => {
|
||||
if (waveform == null) return
|
||||
const h = rmsHistRef.current
|
||||
h.push(Math.min(waveform.rms * 10, 1))
|
||||
if (h.length > RMS_HISTORY) h.shift()
|
||||
}, [waveform])
|
||||
|
||||
// Draw onset / beat timeline
|
||||
useEffect(() => {
|
||||
const canvas = beatCanvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
const W = canvas.width, H = canvas.height
|
||||
|
||||
ctx.fillStyle = '#0a0a0a'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
|
||||
const onsets = waveform?.onsets ?? []
|
||||
const now = performance.now()
|
||||
|
||||
// Beat grid aligned to the most recent onset
|
||||
if (bpm) {
|
||||
const beatMs = 60000 / bpm
|
||||
const numBeats = Math.ceil(TIMELINE_MS / beatMs) + 1
|
||||
const latest = onsets[onsets.length - 1]
|
||||
const phase = latest != null ? (now - latest) % beatMs : 0
|
||||
for (let b = 0; b <= numBeats; b++) {
|
||||
const ageMs = b * beatMs - phase
|
||||
if (ageMs < 0 || ageMs > TIMELINE_MS) continue
|
||||
const x = W * (1 - ageMs / TIMELINE_MS)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.07)'
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
// Onset dots + vertical tails
|
||||
const recent = onsets.filter(t => now - t <= TIMELINE_MS)
|
||||
for (const t of recent) {
|
||||
const age = now - t
|
||||
const x = W * (1 - age / TIMELINE_MS)
|
||||
const alpha = Math.pow(1 - age / TIMELINE_MS, 0.4)
|
||||
ctx.strokeStyle = `rgba(168,85,247,${(alpha * 0.35).toFixed(2)})`
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||
ctx.fillStyle = `rgba(168,85,247,${alpha.toFixed(2)})`
|
||||
ctx.beginPath(); ctx.arc(x, H / 2, 5, 0, Math.PI * 2); ctx.fill()
|
||||
}
|
||||
|
||||
// "Now" edge
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.18)'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(W - 1, 0); ctx.lineTo(W - 1, H); ctx.stroke()
|
||||
}, [waveform, bpm])
|
||||
|
||||
// Draw RMS envelope
|
||||
useEffect(() => {
|
||||
const canvas = rmsCanvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
const W = canvas.width, H = canvas.height
|
||||
const h = rmsHistRef.current
|
||||
|
||||
ctx.fillStyle = '#0a0a0a'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
if (h.length < 2) return
|
||||
|
||||
const barW = W / RMS_HISTORY
|
||||
for (let i = 0; i < h.length; i++) {
|
||||
const x = W * (i / RMS_HISTORY)
|
||||
const barH = h[i] * H
|
||||
const v = Math.round(h[i] * 160 + 60)
|
||||
ctx.fillStyle = `rgb(${v},30,${v})`
|
||||
ctx.fillRect(x, H - barH, Math.max(barW - 0.5, 1), barH)
|
||||
}
|
||||
}, [waveform])
|
||||
|
||||
const noData = !waveform
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Band meters */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 font-mono uppercase tracking-widest mb-2">Frequency Bands</p>
|
||||
<div className="flex gap-3" style={{ height: 96 }}>
|
||||
{BANDS.map((b, i) => (
|
||||
<div key={b.label} className="flex flex-col items-center gap-1 flex-1">
|
||||
<div className="flex-1 w-full bg-gray-900 rounded-sm relative overflow-hidden">
|
||||
{noData ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-[9px] text-gray-700 font-mono">—</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 rounded-sm"
|
||||
style={{
|
||||
height: `${bandLevels[i] * 100}%`,
|
||||
backgroundColor: b.color,
|
||||
transition: 'height 60ms linear',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-gray-500 uppercase">{b.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Onset timeline */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 font-mono uppercase tracking-widest mb-2">
|
||||
Onset Timeline{bpm ? ` · ${bpm} BPM` : ''}
|
||||
<span className="ml-2 text-gray-700 normal-case">← 4 seconds</span>
|
||||
</p>
|
||||
<canvas
|
||||
ref={beatCanvasRef}
|
||||
width={800}
|
||||
height={56}
|
||||
className="w-full rounded"
|
||||
style={{ height: 56 }}
|
||||
/>
|
||||
{noData && (
|
||||
<p className="text-xs text-gray-700 font-mono mt-1 text-center">Start listening to see hits</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Volume envelope */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 font-mono uppercase tracking-widest mb-2">
|
||||
Volume Envelope
|
||||
<span className="ml-2 text-gray-700 normal-case">← ~3 seconds</span>
|
||||
</p>
|
||||
<canvas
|
||||
ref={rmsCanvasRef}
|
||||
width={800}
|
||||
height={56}
|
||||
className="w-full rounded"
|
||||
style={{ height: 56 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { toRomanNumeral, CHORD_TYPES, NOTES } from '../lib/theory'
|
||||
import {
|
||||
findSimilarProgressions,
|
||||
getChordSubstitutions,
|
||||
progressionInKey,
|
||||
styleVariationInKey,
|
||||
parseChord,
|
||||
} from '../lib/education'
|
||||
|
||||
// ─── Session Snapshot ─────────────────────────────────────────────────────────
|
||||
function SessionSnapshot({ keyInfo, detectedProgression, chordHistory }) {
|
||||
const { root, mode, confidence } = keyInfo ?? {}
|
||||
const uniqueChords = [...new Set(chordHistory)]
|
||||
const totalPlayed = chordHistory.length
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-4 p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="flex flex-col gap-1 min-w-[120px]">
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600">Key</p>
|
||||
{root ? (
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-black text-accent">{root}</span>
|
||||
<span className="text-sm text-gray-400 capitalize">{mode}</span>
|
||||
{confidence && <span className="text-xs text-gray-600">{Math.round(confidence * 100)}%</span>}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-600 text-sm">Detecting…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px bg-border shrink-0" />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600">Detected Loop</p>
|
||||
{detectedProgression?.length ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{detectedProgression.map((chord, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-accent/10 border border-accent/30 rounded text-xs font-bold text-accent">
|
||||
{chord}
|
||||
{root && <span className="text-amber-400/70 ml-1 font-normal text-[10px]">
|
||||
{toRomanNumeral(chord, root, mode)}
|
||||
</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-600 text-sm">None yet — keep playing!</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px bg-border shrink-0" />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600">Session</p>
|
||||
<p className="text-sm text-gray-300">
|
||||
<span className="font-bold text-white">{totalPlayed}</span> chords ·
|
||||
<span className="font-bold text-white">{uniqueChords.length}</span> unique
|
||||
</p>
|
||||
{uniqueChords.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{uniqueChords.slice(0, 10).map(c => (
|
||||
<span key={c} className="text-[10px] text-gray-500 bg-border px-1.5 py-0.5 rounded">{c}</span>
|
||||
))}
|
||||
{uniqueChords.length > 10 && <span className="text-[10px] text-gray-600">+{uniqueChords.length - 10}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Similar Progressions ─────────────────────────────────────────────────────
|
||||
function SimilarProgressions({ similar, keyInfo, onChordClick }) {
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
if (!similar.length) return (
|
||||
<p className="text-gray-600 text-sm text-center py-4">
|
||||
Play more chords and lock a key to find similar famous progressions.
|
||||
</p>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{similar.map(prog => {
|
||||
const isOpen = expanded === prog.id
|
||||
const chordsInKey = keyInfo?.root ? progressionInKey(prog, keyInfo.root) : []
|
||||
|
||||
return (
|
||||
<div key={prog.id}
|
||||
className="border border-border rounded-xl overflow-hidden hover:border-accent/30 transition-colors">
|
||||
|
||||
{/* Header row */}
|
||||
<button
|
||||
onClick={() => setExpanded(isOpen ? null : prog.id)}
|
||||
className="w-full flex items-start justify-between gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-bold text-white text-sm">{prog.name}</span>
|
||||
{prog.genre.map(g => (
|
||||
<span key={g} className="px-1.5 py-0.5 bg-accent/10 border border-accent/20 rounded text-[10px] text-accent">{g}</span>
|
||||
))}
|
||||
<span className="text-[11px] text-gray-500 font-mono">{prog.pattern}</span>
|
||||
<span className="ml-auto text-xs text-gray-600">{Math.round(prog.score * 100)}% match</span>
|
||||
</div>
|
||||
{/* Chords in current key */}
|
||||
{chordsInKey.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{chordsInKey.map((c, i) => (
|
||||
<button key={i}
|
||||
onClick={e => { e.stopPropagation(); onChordClick?.(c) }}
|
||||
className="px-2 py-0.5 bg-surface border border-border hover:border-accent/50 rounded text-xs font-bold text-gray-200 hover:text-accent transition-colors"
|
||||
title={`See voicings for ${c}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
<span className="text-[10px] text-gray-600 self-center ml-1">in {keyInfo.root} {keyInfo.mode}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-600 shrink-0 text-sm mt-0.5">{isOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{isOpen && (
|
||||
<div className="border-t border-border px-4 py-4 flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-400">{prog.description}</p>
|
||||
{prog.tip && (
|
||||
<p className="text-xs text-amber-400/80">
|
||||
<span className="text-amber-400 font-semibold">Insight:</span> {prog.tip}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Song examples */}
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Famous examples</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{prog.songs.map(s => (
|
||||
<span key={s} className="px-2 py-1 bg-surface border border-border rounded-lg text-xs text-gray-400">{s}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Style variations */}
|
||||
{prog.styleVariations.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Style variations</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{prog.styleVariations.map(sv => {
|
||||
const svChords = keyInfo?.root ? styleVariationInKey(sv, prog, keyInfo.root) : []
|
||||
return (
|
||||
<div key={sv.label} className="flex items-start gap-3 p-2 bg-surface rounded-lg border border-border">
|
||||
<span className="text-xs font-bold text-accent shrink-0 w-16">{sv.label}</span>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-xs text-gray-500 font-mono">{sv.pattern}</span>
|
||||
{svChords.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{svChords.map((c, i) => (
|
||||
<button key={i}
|
||||
onClick={() => onChordClick?.(c)}
|
||||
className="px-1.5 py-0.5 bg-accent/10 border border-accent/20 hover:border-accent rounded text-[11px] font-bold text-accent/90 hover:text-accent transition-colors"
|
||||
title={`See voicings for ${c}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
<span className="text-[10px] text-gray-600 self-center ml-1">in {keyInfo.root}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Play It Differently ──────────────────────────────────────────────────────
|
||||
function PlayDifferently({ progression, onChordClick }) {
|
||||
if (!progression?.length) return (
|
||||
<p className="text-gray-600 text-sm text-center py-4">
|
||||
Keep playing — a repeating progression will appear here with substitution ideas.
|
||||
</p>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-gray-500">
|
||||
Tap any substitution to see how to play it. These are harmonic replacements — same role, different colour.
|
||||
</p>
|
||||
{progression.map(chord => {
|
||||
const subs = getChordSubstitutions(chord)
|
||||
return (
|
||||
<div key={chord} className="flex flex-wrap items-start gap-3 p-3 bg-surface border border-border rounded-xl">
|
||||
{/* Original chord */}
|
||||
<button
|
||||
onClick={() => onChordClick?.(chord)}
|
||||
className="px-3 py-1.5 bg-accent text-white font-black rounded-lg text-sm shrink-0 hover:bg-purple-600 transition-colors"
|
||||
title="See voicings"
|
||||
>
|
||||
{chord}
|
||||
</button>
|
||||
|
||||
<span className="text-gray-700 self-center">→</span>
|
||||
|
||||
{/* Substitutions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subs.map(sub => (
|
||||
<div key={sub.chord} className="relative group">
|
||||
<button
|
||||
onClick={() => onChordClick?.(sub.chord)}
|
||||
className="px-2.5 py-1.5 bg-panel border border-border hover:border-accent/50 hover:text-accent rounded-lg text-sm font-bold text-gray-300 transition-all"
|
||||
>
|
||||
{sub.chord}
|
||||
</button>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 w-48 px-2 py-1.5 bg-gray-900 border border-border rounded-lg text-[11px] text-gray-300 leading-snug opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-xl">
|
||||
{sub.tip}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className="text-[11px] text-gray-700 text-center">
|
||||
Hover substitutions to see what they change · click to see voicings
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Chord Variation Ideas ────────────────────────────────────────────────────
|
||||
const VARIATION_ROWS = [
|
||||
{
|
||||
label: '7th Upgrade',
|
||||
desc: 'Add 7ths throughout — jazz and soul texture',
|
||||
typeMap: { maj: 'maj7', min: 'min7', dom7: 'dom7', maj7: 'maj7', min7: 'min7', dim: 'dim7', add9: 'maj7', sus2: 'sus2', sus4: 'sus4', aug: 'aug', half_dim: 'half_dim', maj6: 'maj6', min6: 'min6' },
|
||||
},
|
||||
{
|
||||
label: 'Sus2 Wash',
|
||||
desc: 'Replace triads with sus2 — ambient and spacious',
|
||||
typeMap: { maj: 'sus2', min: 'sus2', dom7: 'sus4', maj7: 'sus2', min7: 'sus2', add9: 'sus2', dim: 'dim', aug: 'aug', sus4: 'sus4', sus2: 'sus2', half_dim: 'half_dim', maj6: 'sus2', min6: 'sus2' },
|
||||
},
|
||||
{
|
||||
label: 'Add9 Modern',
|
||||
desc: 'Add9 on majors, m7 on minors — indie and neo-soul',
|
||||
typeMap: { maj: 'add9', min: 'min7', dom7: 'dom7', maj7: 'add9', min7: 'min7', add9: 'add9', sus2: 'sus2', sus4: 'sus4', dim: 'dim', aug: 'aug', half_dim: 'half_dim', maj6: 'add9', min6: 'min7' },
|
||||
},
|
||||
{
|
||||
label: 'Blues Dominant',
|
||||
desc: 'All chords → dom7 — instant 12-bar blues energy',
|
||||
typeMap: { maj: 'dom7', min: 'dom7', dom7: 'dom7', maj7: 'dom7', min7: 'dom7', add9: 'dom7', sus2: 'dom7', sus4: 'dom7', dim: 'dim7', aug: 'aug', half_dim: 'dom7', maj6: 'dom7', min6: 'dom7' },
|
||||
},
|
||||
]
|
||||
|
||||
function ProgressionVariationIdeas({ progression, onChordClick }) {
|
||||
if (!progression?.length) return (
|
||||
<p className="text-gray-600 text-sm text-center py-4">
|
||||
Keep playing — your progression will appear here.
|
||||
</p>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-gray-500">
|
||||
Your progression re-harmonised four ways. Click any chord to open its voicing explorer.
|
||||
</p>
|
||||
{VARIATION_ROWS.map(row => {
|
||||
const transformed = progression.map(chord => {
|
||||
const p = parseChord(chord)
|
||||
if (!p) return chord
|
||||
const newType = row.typeMap[p.type] ?? p.type
|
||||
return NOTES[p.rootPc] + (CHORD_TYPES[newType]?.suffix ?? '')
|
||||
})
|
||||
return (
|
||||
<div key={row.label} className="p-3 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<span className="text-xs font-bold text-accent">{row.label}</span>
|
||||
<span className="text-[11px] text-gray-500">{row.desc}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{progression.map((orig, i) => (
|
||||
<span key={i} className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-gray-600">{orig}</span>
|
||||
<span className="text-gray-700 text-xs">→</span>
|
||||
<button
|
||||
onClick={() => onChordClick?.(transformed[i])}
|
||||
className="px-2.5 py-1 bg-panel border border-border hover:border-accent/50 hover:text-accent rounded-lg font-bold text-sm text-gray-200 transition-all"
|
||||
>
|
||||
{transformed[i]}
|
||||
</button>
|
||||
{i < progression.length - 1 && <span className="text-gray-700">·</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
export default function EducationPanel({ chordHistory, keyInfo, detectedProgression, onChordClick }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [section, setSection] = useState('similar')
|
||||
|
||||
// Use detected progression if available, else last 4 unique chords from history
|
||||
const workingProgression = detectedProgression?.length
|
||||
? detectedProgression
|
||||
: [...new Set([...chordHistory].reverse())].reverse().slice(-4)
|
||||
|
||||
const similar = findSimilarProgressions(workingProgression, keyInfo)
|
||||
|
||||
return (
|
||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>EDUCATION</span>
|
||||
{similar.length > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-accent/20 border border-accent/30 rounded text-accent">
|
||||
{similar.length} match{similar.length !== 1 ? 'es' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span>{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-border">
|
||||
|
||||
{/* Section nav */}
|
||||
<div className="flex gap-1 px-4 pt-4 pb-0 border-b border-border overflow-x-auto">
|
||||
{[
|
||||
{ key: 'snapshot', label: '📊 Session' },
|
||||
{ key: 'similar', label: `🎵 Similar Progressions${similar.length ? ` (${similar.length})` : ''}` },
|
||||
{ key: 'play', label: '🎨 Play Differently' },
|
||||
{ key: 'variations',label: '🔀 Progression Variations' },
|
||||
].map(s => (
|
||||
<button key={s.key}
|
||||
onClick={() => setSection(s.key)}
|
||||
className={`px-3 py-2 text-xs font-semibold whitespace-nowrap border-b-2 transition-all shrink-0 ${
|
||||
section === s.key
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||
}`}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{section === 'snapshot' && (
|
||||
<SessionSnapshot
|
||||
keyInfo={keyInfo}
|
||||
detectedProgression={detectedProgression}
|
||||
chordHistory={chordHistory}
|
||||
/>
|
||||
)}
|
||||
{section === 'similar' && (
|
||||
<SimilarProgressions
|
||||
similar={similar}
|
||||
keyInfo={keyInfo}
|
||||
onChordClick={onChordClick}
|
||||
/>
|
||||
)}
|
||||
{section === 'play' && (
|
||||
<PlayDifferently
|
||||
progression={workingProgression}
|
||||
onChordClick={onChordClick}
|
||||
/>
|
||||
)}
|
||||
{section === 'variations' && (
|
||||
<ProgressionVariationIdeas
|
||||
progression={workingProgression}
|
||||
onChordClick={onChordClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import ChordBox from './ChordBox'
|
||||
import MiniPiano from './MiniPiano'
|
||||
import { getGuitarVoicings, getPianoTechniques, parseChord } from '../lib/voicings'
|
||||
import { CHORD_TYPES, NOTES, getChordsInKey, toRomanNumeral } from '../lib/theory'
|
||||
import { FAMOUS_PROGRESSIONS, progressionInKey } from '../lib/education'
|
||||
|
||||
const CHORD_TYPE_OPTIONS = [
|
||||
{ key: 'maj', label: 'Major' },
|
||||
{ key: 'min', label: 'Minor' },
|
||||
{ key: 'dom7', label: '7' },
|
||||
{ key: 'maj7', label: 'maj7' },
|
||||
{ key: 'min7', label: 'm7' },
|
||||
{ key: 'dim', label: 'dim' },
|
||||
{ key: 'dim7', label: 'dim7' },
|
||||
{ key: 'half_dim', label: 'm7♭5' },
|
||||
{ key: 'aug', label: 'aug' },
|
||||
{ key: 'sus4', label: 'sus4' },
|
||||
{ key: 'sus2', label: 'sus2' },
|
||||
{ key: 'maj6', label: '6' },
|
||||
{ key: 'min6', label: 'm6' },
|
||||
{ key: 'add9', label: 'add9' },
|
||||
]
|
||||
|
||||
const MAJOR_TYPES = new Set(['maj','maj7','maj6','add9','sus4','sus2','aug','dom7'])
|
||||
|
||||
// ─── Quick-pick chip row ──────────────────────────────────────────────────────
|
||||
function ChipRow({ label, chords, active, keyInfo, onSelect }) {
|
||||
if (!chords?.length) return null
|
||||
return (
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
<span className="text-[10px] uppercase tracking-wider text-gray-600 w-16 pt-1 shrink-0">{label}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{chords.map(chord => {
|
||||
const rn = keyInfo?.root ? toRomanNumeral(chord, keyInfo.root, keyInfo.mode) : ''
|
||||
return (
|
||||
<button key={chord} onClick={() => onSelect(chord)}
|
||||
className={`flex flex-col items-center px-2.5 py-1 rounded-lg border text-xs font-bold transition-all ${
|
||||
active === chord
|
||||
? 'bg-accent border-accent text-white'
|
||||
: 'bg-surface border-border text-gray-300 hover:border-accent/50 hover:text-accent'
|
||||
}`}>
|
||||
<span>{chord}</span>
|
||||
{rn && <span className="text-[9px] font-normal opacity-60 leading-none mt-0.5">{rn}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Guitar voicings grid ─────────────────────────────────────────────────────
|
||||
function GuitarGrid({ chordName }) {
|
||||
const voicings = getGuitarVoicings(chordName)
|
||||
if (!voicings.length) return <p className="text-gray-600 text-sm py-4">No voicings for {chordName}.</p>
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{voicings.map((v, i) => (
|
||||
<div key={i} className="flex flex-col items-center p-3 rounded-xl bg-surface border border-border hover:border-accent/30 transition-colors">
|
||||
<ChordBox frets={v.frets} fingers={v.fingers} barre={v.barre} baseFret={v.baseFret} />
|
||||
<p className="text-[11px] text-gray-500 text-center mt-1 max-w-[110px] leading-tight">{v.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-700 mt-3">
|
||||
Purple = chord tone · finger numbers inside dots (1=index 4=pinky) · fret number on left if not starting at fret 1
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Piano techniques grid ────────────────────────────────────────────────────
|
||||
function PianoGrid({ chordName }) {
|
||||
const parsed = parseChord(chordName)
|
||||
const techniques = getPianoTechniques(chordName)
|
||||
const rootPc = parsed?.rootPc ?? 0
|
||||
if (!techniques.length) return <p className="text-gray-600 text-sm py-4">No techniques for {chordName}.</p>
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{techniques.map((t, i) => (
|
||||
<div key={i} className="flex flex-col lg:flex-row gap-3 p-3 bg-surface border border-border rounded-xl hover:border-accent/30 transition-colors">
|
||||
<div className="shrink-0 overflow-x-auto">
|
||||
<MiniPiano rootPc={rootPc} lh={t.lh} rh={t.rh} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0 justify-center">
|
||||
<p className="font-bold text-white text-sm">{t.name}</p>
|
||||
<p className="text-gray-400 text-xs">{t.desc}</p>
|
||||
<p className="text-xs text-amber-400/80 mt-0.5">
|
||||
<span className="text-amber-400 font-semibold">Tip:</span> {t.tip}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Famous progressions using this chord as tonic ───────────────────────────
|
||||
function ProgressionCards({ chordName, onChordClick }) {
|
||||
const parsed = parseChord(chordName)
|
||||
if (!parsed) return null
|
||||
const { rootPc, type } = parsed
|
||||
const root = NOTES[rootPc]
|
||||
const isMajor = MAJOR_TYPES.has(type)
|
||||
|
||||
const matching = FAMOUS_PROGRESSIONS.filter(p => {
|
||||
const q0 = p.qualities[0]
|
||||
return isMajor ? MAJOR_TYPES.has(q0) : !MAJOR_TYPES.has(q0)
|
||||
}).slice(0, 6)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-gray-500">
|
||||
Famous progressions with <span className="text-accent font-bold">{chordName}</span> as the tonic.
|
||||
Click any chord to see its voicings.
|
||||
</p>
|
||||
{matching.map(prog => {
|
||||
const chordsHere = progressionInKey(prog, root)
|
||||
return (
|
||||
<div key={prog.id} className="p-3 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-center flex-wrap gap-2 mb-2">
|
||||
<span className="font-bold text-white text-sm">{prog.name}</span>
|
||||
<span className="text-[10px] font-mono text-gray-600">{prog.pattern}</span>
|
||||
{prog.genre.slice(0, 2).map(g => (
|
||||
<span key={g} className="px-1.5 py-0.5 bg-accent/10 border border-accent/20 rounded text-[10px] text-accent">{g}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 items-center mb-2">
|
||||
{chordsHere.map((c, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<button onClick={() => onChordClick?.(c)}
|
||||
className={`px-2.5 py-1 rounded-lg font-bold text-sm border transition-all ${
|
||||
i === 0
|
||||
? 'bg-accent border-accent text-white'
|
||||
: 'bg-panel border-border text-gray-200 hover:border-accent/50 hover:text-accent'
|
||||
}`}>
|
||||
{c}
|
||||
</button>
|
||||
{i < chordsHere.length - 1 && <span className="text-gray-700 text-xs">→</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 leading-snug">{prog.description}</p>
|
||||
{prog.songs.length > 0 && (
|
||||
<p className="text-[11px] text-gray-700 mt-1">{prog.songs.slice(0, 3).join(' · ')}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main panel ───────────────────────────────────────────────────────────────
|
||||
export default function ExplorePanel({ keyInfo, chordHistory, onChordClick }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [root, setRoot] = useState('C')
|
||||
const [typeKey, setTypeKey] = useState('maj')
|
||||
const [view, setView] = useState('guitar') // guitar | piano | progressions
|
||||
const [active, setActive] = useState('')
|
||||
|
||||
const chordName = root + (CHORD_TYPES[typeKey]?.suffix ?? '')
|
||||
|
||||
function selectChord(chord) {
|
||||
setActive(chord)
|
||||
const p = parseChord(chord)
|
||||
if (p) { setRoot(NOTES[p.rootPc]); setTypeKey(p.type) }
|
||||
}
|
||||
|
||||
// Context-aware quick-picks
|
||||
const recentChords = [...new Set([...(chordHistory ?? [])].reverse())].slice(0, 12)
|
||||
const keyChords = keyInfo?.root ? getChordsInKey(keyInfo.root, keyInfo.mode ?? 'major') : []
|
||||
|
||||
return (
|
||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||
<button onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all">
|
||||
<span>EXPLORE ANY CHORD</span>
|
||||
<span>{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-border p-4 flex flex-col gap-4">
|
||||
|
||||
{/* ── Context quick-picks ── */}
|
||||
{(recentChords.length > 0 || keyChords.length > 0) && (
|
||||
<div className="flex flex-col gap-2.5 p-3 bg-surface border border-border rounded-xl">
|
||||
<ChipRow label="History" chords={recentChords} active={active} keyInfo={keyInfo} onSelect={selectChord} />
|
||||
{keyChords.length > 0 && recentChords.length > 0 && <div className="h-px bg-border" />}
|
||||
{keyChords.length > 0 && (
|
||||
<ChipRow
|
||||
label={keyInfo.root + ' ' + (keyInfo.mode ?? '')}
|
||||
chords={keyChords} active={active} keyInfo={keyInfo} onSelect={selectChord}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Manual chord picker ── */}
|
||||
<div className="flex flex-wrap gap-2 items-center p-3 bg-surface border border-border rounded-xl">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{NOTES.map(n => (
|
||||
<button key={n} onClick={() => { setRoot(n); setActive('') }}
|
||||
className={`px-2 py-0.5 rounded text-xs font-bold transition-all ${
|
||||
root === n ? 'bg-accent text-white' : 'bg-border text-gray-400 hover:text-white'
|
||||
}`}>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-px h-5 bg-border shrink-0" />
|
||||
<div className="relative">
|
||||
<select value={typeKey} onChange={e => { setTypeKey(e.target.value); setActive('') }}
|
||||
className="appearance-none bg-panel border border-border rounded-lg pl-2 pr-6 py-1 text-xs text-gray-200 cursor-pointer focus:outline-none focus:border-accent">
|
||||
{CHORD_TYPE_OPTIONS.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
|
||||
</select>
|
||||
<span className="pointer-events-none absolute right-1.5 top-1/2 -translate-y-1/2 text-gray-500 text-xs">▾</span>
|
||||
</div>
|
||||
<div className="text-2xl font-black text-accent ml-2">{chordName}</div>
|
||||
</div>
|
||||
|
||||
{/* ── View tabs ── */}
|
||||
<div className="flex gap-1 bg-surface border border-border rounded-xl p-1 w-fit">
|
||||
{[
|
||||
{ key: 'guitar', label: '🎸 Guitar Voicings' },
|
||||
{ key: 'piano', label: '🎹 Piano Techniques' },
|
||||
{ key: 'progressions', label: '🎵 Progressions' },
|
||||
].map(t => (
|
||||
<button key={t.key} onClick={() => setView(t.key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all whitespace-nowrap ${
|
||||
view === t.key ? 'bg-accent text-white' : 'text-gray-400 hover:text-white'
|
||||
}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Content ── */}
|
||||
{view === 'guitar' && <GuitarGrid chordName={chordName} />}
|
||||
{view === 'piano' && <PianoGrid chordName={chordName} />}
|
||||
{view === 'progressions' && <ProgressionCards chordName={chordName} onChordClick={c => { selectChord(c); onChordClick?.(c) }} />}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getPentatonicScale, getFullScale, getChordTones, guideTones, NOTES } from '../lib/theory'
|
||||
import { getPentatonicScale, getFullScale, getChordTones, 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, jamFocusChord = null }) {
|
||||
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
|
||||
if (!root) return null
|
||||
@@ -50,40 +50,11 @@ 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>
|
||||
@@ -150,51 +121,23 @@ 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)
|
||||
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
|
||||
if (!color) 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}`}>
|
||||
{/* 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} />
|
||||
<circle cx={cx} cy={cy} r={DOT_R} fill={color.fill} />
|
||||
<text
|
||||
x={cx} y={cy + 4}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
fontWeight="600"
|
||||
fill={baseText}
|
||||
fill={color.text}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
})
|
||||
@@ -202,18 +145,10 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-5 text-xs text-gray-500">
|
||||
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
||||
<span><span className="text-accent">●</span> Chord tone</span>
|
||||
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
||||
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
||||
{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>
|
||||
)
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
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,169 +0,0 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
|
||||
const STYLE = {
|
||||
empty: { border: 'border-border', bg: 'bg-surface', icon: '●', iconColor: 'text-gray-700' },
|
||||
recording: { border: 'border-red-500', bg: 'bg-red-950/20', icon: '⏺', iconColor: 'text-red-400' },
|
||||
trimming: { border: 'border-amber-400', bg: 'bg-amber-950/20', icon: '✂', iconColor: 'text-amber-400'},
|
||||
playing: { border: 'border-accent', bg: 'bg-accent/10', icon: '▶', iconColor: 'text-accent' },
|
||||
muted: { border: 'border-border', bg: 'bg-surface', icon: '⏸', iconColor: 'text-gray-500' },
|
||||
}
|
||||
|
||||
const LABEL = {
|
||||
empty: 'tap to rec',
|
||||
recording: 'tap to stop',
|
||||
trimming: 'trimming…',
|
||||
playing: 'tap to mute',
|
||||
muted: 'tap to play',
|
||||
}
|
||||
|
||||
export default function LoopSlot({ slot, slotIdx, audioCtxRef, masterStartRef, masterLenRef, onClick, onRetrim, onDelete, onVolumeChange }) {
|
||||
const progressRef = useRef(null)
|
||||
const rafRef = useRef(null)
|
||||
const [showVol, setShowVol] = useState(false)
|
||||
const [holdTimer, setHoldTimer] = useState(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const { status, recordingDuration, volume, originalBuffer } = slot
|
||||
const style = STYLE[status] ?? STYLE.empty
|
||||
const isActive = status === 'playing' || status === 'muted'
|
||||
|
||||
// ── Progress bar via rAF ─────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
if (progressRef.current) progressRef.current.style.width = '0%'
|
||||
return
|
||||
}
|
||||
function tick() {
|
||||
const ctx = audioCtxRef.current
|
||||
const mStart = masterStartRef.current
|
||||
const mLen = masterLenRef.current
|
||||
if (ctx && mStart !== null && mLen && progressRef.current) {
|
||||
const pos = ((ctx.currentTime - mStart) % mLen) / mLen * 100
|
||||
progressRef.current.style.width = `${pos}%`
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
|
||||
}, [isActive, audioCtxRef, masterStartRef, masterLenRef])
|
||||
|
||||
// ── Long-press to delete ──────────────────────────────────────────────────
|
||||
const onPointerDown = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
const t = setTimeout(() => setDeleting(true), 500)
|
||||
setHoldTimer(t)
|
||||
}, [])
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
if (holdTimer) { clearTimeout(holdTimer); setHoldTimer(null) }
|
||||
if (!deleting) onClick(slotIdx)
|
||||
}, [holdTimer, deleting, onClick, slotIdx])
|
||||
|
||||
const onPointerLeave = useCallback(() => {
|
||||
if (holdTimer) { clearTimeout(holdTimer); setHoldTimer(null) }
|
||||
}, [holdTimer])
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
setDeleting(false)
|
||||
onDelete(slotIdx)
|
||||
}, [onDelete, slotIdx])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 select-none relative">
|
||||
|
||||
{/* Delete confirmation overlay (long-press) */}
|
||||
{deleting && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 rounded-xl bg-black/90 border border-red-500">
|
||||
<button
|
||||
className="text-[10px] font-bold text-red-400 px-2 py-0.5 rounded bg-red-900/50 hover:bg-red-900"
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="text-[10px] text-gray-500 hover:text-gray-300"
|
||||
onClick={() => setDeleting(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick clear button — visible on non-empty slots */}
|
||||
{status !== 'empty' && !deleting && (
|
||||
<button
|
||||
className="absolute -top-1.5 -right-1.5 z-10 w-4 h-4 rounded-full bg-gray-800 border border-border text-gray-500 hover:bg-red-900/60 hover:text-red-400 hover:border-red-700 text-[9px] leading-none flex items-center justify-center transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(slotIdx) }}
|
||||
title="Clear slot"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Main button */}
|
||||
<button
|
||||
className={`relative w-[76px] h-[54px] rounded-xl border-2 overflow-hidden flex flex-col items-center justify-center gap-0.5 transition-colors cursor-pointer ${style.bg} ${style.border} ${status === 'recording' ? 'animate-pulse' : ''}`}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerLeave}
|
||||
>
|
||||
<span className={`text-lg leading-none ${style.iconColor}`}>
|
||||
{style.icon}
|
||||
</span>
|
||||
<span className={`text-[9px] font-bold uppercase tracking-widest leading-none ${style.iconColor} opacity-70`}>
|
||||
{status === 'recording'
|
||||
? `${(recordingDuration ?? 0).toFixed(1)}s`
|
||||
: `Loop ${slotIdx + 1}`}
|
||||
</span>
|
||||
|
||||
{/* Progress bar */}
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-border">
|
||||
<div
|
||||
ref={progressRef}
|
||||
className="h-full bg-accent"
|
||||
style={{ width: '0%', transition: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Controls row (playing/muted only) */}
|
||||
{isActive && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Volume toggle */}
|
||||
<button
|
||||
className={`text-[11px] transition-colors ${showVol ? 'text-accent' : 'text-gray-600 hover:text-gray-400'}`}
|
||||
onClick={() => setShowVol(v => !v)}
|
||||
title="Volume"
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
{showVol && (
|
||||
<input
|
||||
type="range" min={0} max={1} step={0.05}
|
||||
value={volume}
|
||||
onChange={e => onVolumeChange(slotIdx, parseFloat(e.target.value))}
|
||||
className="w-12 h-1 cursor-pointer accent-purple-500"
|
||||
/>
|
||||
)}
|
||||
{/* Re-trim button — only when original recording exists */}
|
||||
{originalBuffer && (
|
||||
<button
|
||||
className="text-[11px] text-gray-600 hover:text-amber-400 transition-colors"
|
||||
onClick={() => onRetrim(slotIdx)}
|
||||
title="Re-trim this loop"
|
||||
>
|
||||
✂
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status label */}
|
||||
<span className={`text-[9px] uppercase tracking-wider leading-none ${style.iconColor} opacity-50`}>
|
||||
{LABEL[status] ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import LoopTrimmer from './LoopTrimmer'
|
||||
|
||||
const H_TRACK = 56 // track canvas height px
|
||||
const H_MASTER = 26 // master timeline height px
|
||||
|
||||
// ── Canvas draw helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function drawGrid(canvas, totalSec, bpm) {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
const dpr = window.devicePixelRatio ?? 1
|
||||
canvas.width = rect.width * dpr
|
||||
canvas.height = rect.height * dpr
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(dpr, dpr)
|
||||
const W = rect.width, H = rect.height
|
||||
|
||||
ctx.fillStyle = '#0f0f0f'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
if (!bpm || !totalSec) return
|
||||
|
||||
const beatSec = 60 / bpm
|
||||
const barSec = beatSec * 4
|
||||
|
||||
// Beat lines
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
|
||||
ctx.lineWidth = 1
|
||||
for (let t = beatSec; t < totalSec; t += beatSec) {
|
||||
if ((t % barSec) < beatSec * 0.4) continue
|
||||
const x = (t / totalSec) * W
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||
}
|
||||
// Bar lines + numbers
|
||||
for (let t = 0; t <= totalSec; t += barSec) {
|
||||
ctx.strokeStyle = 'rgba(168,85,247,0.45)'
|
||||
ctx.lineWidth = 1.5
|
||||
const x = (t / totalSec) * W
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||
const n = Math.round(t / barSec)
|
||||
if (n > 0) {
|
||||
ctx.fillStyle = 'rgba(168,85,247,0.55)'
|
||||
ctx.font = '9px monospace'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(String(n), x + 3, H - 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawWaveform(canvas, waveform, muted) {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
const dpr = window.devicePixelRatio ?? 1
|
||||
canvas.width = rect.width * dpr
|
||||
canvas.height = rect.height * dpr
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(dpr, dpr)
|
||||
const W = rect.width, H = rect.height
|
||||
|
||||
ctx.fillStyle = '#0f0f0f'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
|
||||
const N = waveform.length
|
||||
const mid = H / 2
|
||||
for (let i = 0; i < N; i++) {
|
||||
const x = (i / N) * W
|
||||
const barW = Math.max(1, W / N - 0.3)
|
||||
ctx.fillStyle = muted ? '#3b1f55' : '#a855f7'
|
||||
const h = waveform[i] * mid * 0.85
|
||||
ctx.fillRect(x, mid - h, barW, h * 2)
|
||||
}
|
||||
}
|
||||
|
||||
function drawRecording(canvas, duration, bpm) {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
const dpr = window.devicePixelRatio ?? 1
|
||||
canvas.width = rect.width * dpr
|
||||
canvas.height = rect.height * dpr
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(dpr, dpr)
|
||||
const W = rect.width, H = rect.height
|
||||
|
||||
ctx.fillStyle = '#0f0f0f'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
|
||||
const beatSec = bpm ? 60 / bpm : null
|
||||
const barSec = beatSec ? beatSec * 4 : null
|
||||
const viewDur = barSec
|
||||
? Math.max(barSec * 4, Math.ceil(duration / barSec + 1) * barSec)
|
||||
: Math.max(8, duration * 1.4)
|
||||
|
||||
// Grid
|
||||
if (beatSec) {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
|
||||
ctx.lineWidth = 1
|
||||
for (let t = beatSec; t < viewDur; t += beatSec) {
|
||||
if (barSec && (t % barSec) < beatSec * 0.4) continue
|
||||
const x = (t / viewDur) * W
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||
}
|
||||
if (barSec) {
|
||||
for (let t = barSec; t <= viewDur; t += barSec) {
|
||||
ctx.strokeStyle = 'rgba(239,68,68,0.3)'
|
||||
ctx.lineWidth = 1.5
|
||||
const x = (t / viewDur) * W
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||
const n = Math.round(t / barSec)
|
||||
ctx.fillStyle = 'rgba(239,68,68,0.45)'
|
||||
ctx.font = '9px monospace'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(String(n), x + 2, H - 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Growing fill + cursor
|
||||
const fillX = (duration / viewDur) * W
|
||||
ctx.fillStyle = 'rgba(239,68,68,0.18)'
|
||||
ctx.fillRect(0, 0, fillX, H)
|
||||
ctx.strokeStyle = 'rgba(239,68,68,0.85)'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath(); ctx.moveTo(fillX, 0); ctx.lineTo(fillX, H); ctx.stroke()
|
||||
|
||||
// Counter label
|
||||
const bars = barSec ? Math.floor(duration / barSec) + 1 : null
|
||||
const label = bars !== null
|
||||
? `● REC BAR ${bars} ${duration.toFixed(1)}s — tap to stop`
|
||||
: `● REC ${duration.toFixed(1)}s — tap to stop`
|
||||
ctx.fillStyle = 'rgba(239,68,68,0.9)'
|
||||
ctx.font = 'bold 11px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(label, W / 2, H / 2)
|
||||
}
|
||||
|
||||
// ── Master Timeline ───────────────────────────────────────────────────────────
|
||||
|
||||
function MasterTimeline({ masterStartRef, masterLenRef, audioCtxRef, bpm, masterLen }) {
|
||||
const canvasRef = useRef(null)
|
||||
const playheadRef = useRef(null)
|
||||
const rafRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const id = requestAnimationFrame(() => drawGrid(canvas, masterLen, bpm))
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [masterLen, bpm])
|
||||
|
||||
useEffect(() => {
|
||||
if (!masterLen) { cancelAnimationFrame(rafRef.current); return }
|
||||
function tick() {
|
||||
const ac = audioCtxRef.current
|
||||
const t0 = masterStartRef.current
|
||||
const len = masterLenRef.current
|
||||
if (ac && t0 !== null && len && playheadRef.current) {
|
||||
const pos = ((ac.currentTime - t0) % len) / len
|
||||
playheadRef.current.style.left = `${pos * 100}%`
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(rafRef.current)
|
||||
}, [masterLen, audioCtxRef, masterStartRef, masterLenRef])
|
||||
|
||||
return (
|
||||
<div className="relative mx-4 mb-3 rounded overflow-hidden bg-surface border border-border"
|
||||
style={{ height: `${H_MASTER}px` }}>
|
||||
<canvas ref={canvasRef} className="w-full h-full block" />
|
||||
{!masterLen && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="text-[10px] text-gray-700">
|
||||
record first loop to set master length
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{masterLen && (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute top-0 bottom-0 w-px bg-white/50 pointer-events-none"
|
||||
style={{ left: '0%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Track Row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TrackRow({ slot, slotIdx, bpm, audioCtxRef, masterStartRef, masterLenRef,
|
||||
onSlotClick, onRetrim, onDelete, onVolumeChange }) {
|
||||
const [showVol, setShowVol] = useState(false)
|
||||
const canvasRef = useRef(null)
|
||||
const playheadRef = useRef(null)
|
||||
const rafRef = useRef(null)
|
||||
|
||||
const DOT_CLASS = {
|
||||
empty: 'bg-gray-700',
|
||||
recording: 'bg-red-500 animate-pulse',
|
||||
trimming: 'bg-amber-500',
|
||||
playing: 'bg-accent',
|
||||
muted: 'bg-gray-500',
|
||||
}
|
||||
const BORDER_CLASS = {
|
||||
empty: 'border-border',
|
||||
recording: 'border-red-800',
|
||||
trimming: 'border-amber-800/60',
|
||||
playing: 'border-accent/40',
|
||||
muted: 'border-border',
|
||||
}
|
||||
|
||||
const dotClass = DOT_CLASS[slot.status] ?? 'bg-gray-700'
|
||||
const borderClass = BORDER_CLASS[slot.status] ?? 'border-border'
|
||||
|
||||
// Draw waveform when data arrives or mute state changes
|
||||
useEffect(() => {
|
||||
if (!slot.waveform) return
|
||||
if (slot.status === 'recording' || slot.status === 'trimming') return
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const id = requestAnimationFrame(() =>
|
||||
drawWaveform(canvas, slot.waveform, slot.status === 'muted')
|
||||
)
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [slot.waveform, slot.status])
|
||||
|
||||
// Draw recording progress on each duration tick
|
||||
useEffect(() => {
|
||||
if (slot.status !== 'recording') return
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
drawRecording(canvas, slot.recordingDuration, bpm)
|
||||
}, [slot.recordingDuration, slot.status, bpm])
|
||||
|
||||
// Playhead animation
|
||||
useEffect(() => {
|
||||
const active = slot.status === 'playing' || slot.status === 'muted'
|
||||
if (!active) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
if (playheadRef.current) playheadRef.current.style.left = '-2px'
|
||||
return
|
||||
}
|
||||
function tick() {
|
||||
const ac = audioCtxRef.current
|
||||
const t0 = masterStartRef.current
|
||||
const len = masterLenRef.current
|
||||
if (ac && t0 !== null && len && playheadRef.current) {
|
||||
const pos = ((ac.currentTime - t0) % len) / len
|
||||
playheadRef.current.style.left = `${pos * 100}%`
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(rafRef.current)
|
||||
}, [slot.status, audioCtxRef, masterStartRef, masterLenRef])
|
||||
|
||||
const isClickable = slot.status !== 'trimming'
|
||||
const isActive = slot.status === 'playing' || slot.status === 'muted'
|
||||
|
||||
return (
|
||||
<div className={`flex items-stretch border rounded-lg mb-1.5 overflow-hidden transition-colors ${borderClass}`}>
|
||||
|
||||
{/* Left: tap button (state dot + track number) */}
|
||||
<button
|
||||
onClick={() => isClickable && onSlotClick(slotIdx)}
|
||||
disabled={!isClickable}
|
||||
title={
|
||||
slot.status === 'empty' ? 'Tap to record' :
|
||||
slot.status === 'recording' ? 'Tap to stop' :
|
||||
slot.status === 'playing' ? 'Tap to mute' :
|
||||
slot.status === 'muted' ? 'Tap to unmute' : ''
|
||||
}
|
||||
className="flex flex-col items-center justify-center gap-1 px-3 bg-surface border-r border-border shrink-0 hover:bg-white/5 transition-colors disabled:cursor-default"
|
||||
style={{ width: '44px' }}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||
<span className="text-[10px] text-gray-600 font-mono">{slotIdx + 1}</span>
|
||||
</button>
|
||||
|
||||
{/* Canvas: waveform / recording / empty */}
|
||||
<div
|
||||
className="relative flex-1 cursor-pointer"
|
||||
style={{ height: `${H_TRACK}px` }}
|
||||
onClick={() => isClickable && onSlotClick(slotIdx)}
|
||||
>
|
||||
<canvas ref={canvasRef} className="w-full h-full block" />
|
||||
|
||||
{slot.status === 'empty' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="text-xs text-gray-700">tap to record</span>
|
||||
</div>
|
||||
)}
|
||||
{slot.status === 'trimming' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-amber-950/20 pointer-events-none">
|
||||
<span className="text-xs text-amber-500/60">trimming ↓</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Moving playhead */}
|
||||
{isActive && (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute top-0 bottom-0 w-px bg-white/40 pointer-events-none"
|
||||
style={{ left: '-2px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: controls */}
|
||||
<div className="flex items-center gap-1 px-2 bg-surface border-l border-border shrink-0">
|
||||
{showVol && (
|
||||
<input
|
||||
type="range"
|
||||
min={0} max={1} step={0.01}
|
||||
value={slot.volume}
|
||||
onChange={e => onVolumeChange(slotIdx, parseFloat(e.target.value))}
|
||||
className="w-14 accent-purple-500"
|
||||
title="Volume"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowVol(v => !v)}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded text-base transition-colors ${
|
||||
showVol ? 'text-accent' : 'text-gray-600 hover:text-gray-300'
|
||||
}`}
|
||||
title="Volume"
|
||||
>
|
||||
{slot.status === 'muted' ? '🔇' : '🔊'}
|
||||
</button>
|
||||
{isActive && slot.originalBuffer && (
|
||||
<button
|
||||
onClick={() => onRetrim(slotIdx)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded text-gray-600 hover:text-amber-400 transition-colors text-sm"
|
||||
title="Re-trim loop"
|
||||
>
|
||||
✂
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(slotIdx)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded text-gray-700 hover:text-red-400 transition-colors text-sm"
|
||||
title="Clear track"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function LoopStation({
|
||||
slots,
|
||||
bpm,
|
||||
masterLen,
|
||||
audioCtxRef,
|
||||
masterStartRef,
|
||||
masterLenRef,
|
||||
onSlotClick,
|
||||
onCommitTrim,
|
||||
onCancelRecord,
|
||||
onRetrim,
|
||||
onDelete,
|
||||
onVolumeChange,
|
||||
onAddSlot,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [gridBpm, setGridBpm] = useState(bpm ?? '')
|
||||
|
||||
// Pre-fill BPM when detection arrives
|
||||
useEffect(() => {
|
||||
if (bpm && !gridBpm) setGridBpm(bpm)
|
||||
}, [bpm]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const gridBpmNum = parseFloat(gridBpm) || null
|
||||
const trimmingIdx = slots.findIndex(s => s.status === 'trimming')
|
||||
|
||||
const recordingCount = slots.filter(s => s.status === 'recording').length
|
||||
const playingCount = slots.filter(s => s.status === 'playing').length
|
||||
|
||||
const dotClass = recordingCount > 0
|
||||
? 'bg-red-500 animate-pulse'
|
||||
: playingCount > 0
|
||||
? 'bg-accent'
|
||||
: 'bg-gray-700'
|
||||
|
||||
const masterLabel = (() => {
|
||||
if (!masterLen) return null
|
||||
if (gridBpmNum) {
|
||||
const bars = Math.round(masterLen / ((60 / gridBpmNum) * 4))
|
||||
return `${bars} bar${bars !== 1 ? 's' : ''} · ${masterLen.toFixed(2)}s`
|
||||
}
|
||||
return masterLen.toFixed(2) + 's'
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-400">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex items-center gap-2 hover:text-gray-200 transition-colors text-left"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||
<span>LOOP STATION</span>
|
||||
{masterLabel && (
|
||||
<span className="text-[11px] text-gray-600 font-mono">{masterLabel}</span>
|
||||
)}
|
||||
{recordingCount > 0 && (
|
||||
<span className="text-[11px] text-red-400">● rec</span>
|
||||
)}
|
||||
{playingCount > 0 && recordingCount === 0 && (
|
||||
<span className="text-[11px] text-accent">
|
||||
{playingCount} loop{playingCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 bg-surface border border-border rounded-lg px-2 py-1">
|
||||
<span className="text-[10px] text-gray-600 uppercase tracking-wider">BPM</span>
|
||||
<input
|
||||
type="number"
|
||||
min={40} max={300} step={1}
|
||||
value={gridBpm}
|
||||
onChange={e => setGridBpm(e.target.value)}
|
||||
placeholder={bpm ? String(Math.round(bpm)) : '—'}
|
||||
className="w-10 bg-transparent text-xs text-gray-300 text-center focus:outline-none focus:text-white"
|
||||
style={{ MozAppearance: 'textfield' }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-all"
|
||||
title={open ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
{open
|
||||
? <polyline points="2,8 6,4 10,8" />
|
||||
: <polyline points="2,4 6,8 10,4" />
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ────────────────────────────────────────────────────────────── */}
|
||||
{open && (
|
||||
<div className="border-t border-border pt-3">
|
||||
|
||||
{/* Master timeline */}
|
||||
<MasterTimeline
|
||||
masterStartRef={masterStartRef}
|
||||
masterLenRef={masterLenRef}
|
||||
audioCtxRef={audioCtxRef}
|
||||
bpm={gridBpmNum}
|
||||
masterLen={masterLen}
|
||||
/>
|
||||
|
||||
{/* Track rows */}
|
||||
<div className="px-4">
|
||||
{slots.map((slot, i) => (
|
||||
<TrackRow
|
||||
key={i}
|
||||
slot={slot}
|
||||
slotIdx={i}
|
||||
bpm={gridBpmNum}
|
||||
audioCtxRef={audioCtxRef}
|
||||
masterStartRef={masterStartRef}
|
||||
masterLenRef={masterLenRef}
|
||||
onSlotClick={onSlotClick}
|
||||
onRetrim={onRetrim}
|
||||
onDelete={onDelete}
|
||||
onVolumeChange={onVolumeChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add track */}
|
||||
<div className="px-4 pb-3">
|
||||
<button
|
||||
onClick={onAddSlot}
|
||||
className="w-full py-1.5 rounded-lg border border-dashed border-border text-gray-700 hover:border-accent/40 hover:text-accent/60 transition-colors text-xs flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="text-base leading-none">+</span>
|
||||
<span>Add Track</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LoopTrimmer — shown below tracks when trimming */}
|
||||
{trimmingIdx !== -1 && (
|
||||
<LoopTrimmer
|
||||
slot={slots[trimmingIdx]}
|
||||
slotIdx={trimmingIdx}
|
||||
bpm={gridBpmNum}
|
||||
audioCtxRef={audioCtxRef}
|
||||
onCommit={onCommitTrim}
|
||||
onCancel={onCancelRecord}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
|
||||
function fmtMs(sec) {
|
||||
return `${(sec * 1000).toFixed(0)}ms`
|
||||
}
|
||||
|
||||
function fmtSec(sec) {
|
||||
return sec < 10 ? `${sec.toFixed(2)}s` : `${sec.toFixed(1)}s`
|
||||
}
|
||||
|
||||
export default function LoopTrimmer({ slot, slotIdx, bpm, audioCtxRef, onCommit, onCancel }) {
|
||||
const canvasRef = useRef(null)
|
||||
const containerRef = useRef(null)
|
||||
const previewRef = useRef(null) // AudioBufferSourceNode for preview
|
||||
|
||||
const [trimStart, setTrimStart] = useState(slot.trimStart)
|
||||
const [trimEnd, setTrimEnd] = useState(slot.trimEnd)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
|
||||
// Refs so drag closures always have current values
|
||||
const trimStartRef = useRef(trimStart)
|
||||
const trimEndRef = useRef(trimEnd)
|
||||
useEffect(() => { trimStartRef.current = trimStart }, [trimStart])
|
||||
useEffect(() => { trimEndRef.current = trimEnd }, [trimEnd])
|
||||
|
||||
const duration = slot.audioBuffer?.duration ?? 0
|
||||
const startSec = trimStart * duration
|
||||
const endSec = trimEnd * duration
|
||||
const selectedSec = endSec - startSec
|
||||
|
||||
// Beat grid info — visual reference only, no snapping
|
||||
const beatSec = bpm ? 60 / bpm : null
|
||||
const barSec = beatSec ? beatSec * 4 : null
|
||||
|
||||
// Stop preview when handles change
|
||||
useEffect(() => {
|
||||
if (previewing) stopPreview()
|
||||
}, [trimStart, trimEnd]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => stopPreview()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function stopPreview() {
|
||||
try { previewRef.current?.stop() } catch {}
|
||||
previewRef.current = null
|
||||
setPreviewing(false)
|
||||
}
|
||||
|
||||
function togglePreview() {
|
||||
if (previewing) { stopPreview(); return }
|
||||
const ctx = audioCtxRef?.current
|
||||
const buf = slot.audioBuffer
|
||||
if (!ctx || !buf) return
|
||||
|
||||
// Resume context if suspended
|
||||
if (ctx.state === 'suspended') ctx.resume().catch(() => {})
|
||||
|
||||
const sr = buf.sampleRate
|
||||
const startSample = Math.floor(trimStartRef.current * buf.length)
|
||||
const endSample = Math.ceil(trimEndRef.current * buf.length)
|
||||
const len = Math.max(1, endSample - startSample)
|
||||
const data = buf.getChannelData(0).slice(startSample, endSample)
|
||||
|
||||
const previewBuf = ctx.createBuffer(1, len, sr)
|
||||
previewBuf.copyToChannel(data, 0)
|
||||
|
||||
const node = ctx.createBufferSource()
|
||||
node.buffer = previewBuf
|
||||
node.loop = true
|
||||
node.loopStart = 0
|
||||
node.loopEnd = len / sr
|
||||
node.connect(ctx.destination)
|
||||
node.start()
|
||||
node.onended = () => { previewRef.current = null; setPreviewing(false) }
|
||||
|
||||
previewRef.current = node
|
||||
setPreviewing(true)
|
||||
}
|
||||
|
||||
// Snap end handle to N bars from current start
|
||||
function snapBars(n) {
|
||||
if (!barSec || !duration) return
|
||||
const newEnd = Math.min(1, trimStartRef.current + (n * barSec) / duration)
|
||||
setTrimEnd(newEnd)
|
||||
trimEndRef.current = newEnd
|
||||
draw()
|
||||
}
|
||||
|
||||
// ── Canvas draw ─────────────────────────────────────────────────────────────
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !slot.waveform) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (rect.width === 0) return
|
||||
const dpr = window.devicePixelRatio ?? 1
|
||||
canvas.width = rect.width * dpr
|
||||
canvas.height = rect.height * dpr
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(dpr, dpr)
|
||||
const W = rect.width
|
||||
const H = rect.height
|
||||
const wf = slot.waveform
|
||||
const N = wf.length
|
||||
const ts = trimStartRef.current
|
||||
const te = trimEndRef.current
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#0f0f0f'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
|
||||
// Dim regions outside selection
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.6)'
|
||||
ctx.fillRect(0, 0, ts * W, H)
|
||||
ctx.fillRect(te * W, 0, W - te * W, H)
|
||||
|
||||
// Beat grid — visual only, beat lines then bar lines (bars on top)
|
||||
if (beatSec && duration) {
|
||||
// Beat lines
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.10)'
|
||||
ctx.lineWidth = 1
|
||||
for (let t = 0; t <= duration; t += beatSec) {
|
||||
const isBar = barSec ? (t % barSec) < beatSec * 0.4 : false
|
||||
if (!isBar) {
|
||||
const x = (t / duration) * W
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||
}
|
||||
}
|
||||
// Bar lines (brighter, thicker)
|
||||
if (barSec) {
|
||||
ctx.strokeStyle = 'rgba(168,85,247,0.55)'
|
||||
ctx.lineWidth = 1.5
|
||||
for (let t = 0; t <= duration; t += barSec) {
|
||||
const x = (t / duration) * W
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||
// Bar number label
|
||||
const barNum = Math.round(t / barSec)
|
||||
if (barNum > 0) {
|
||||
ctx.fillStyle = 'rgba(168,85,247,0.5)'
|
||||
ctx.font = `${9 * dpr / dpr}px monospace`
|
||||
ctx.fillText(`${barNum}`, x + 3, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Waveform bars
|
||||
const mid = H / 2
|
||||
for (let i = 0; i < N; i++) {
|
||||
const x = (i / N) * W
|
||||
const barW = Math.max(1, W / N - 0.5)
|
||||
const inSel = (i / N) >= ts && (i / N) <= te
|
||||
ctx.fillStyle = inSel ? '#a855f7' : '#3b0764'
|
||||
const h = wf[i] * mid * 0.88
|
||||
ctx.fillRect(x, mid - h, barW, h * 2)
|
||||
}
|
||||
|
||||
// Handle lines
|
||||
ctx.strokeStyle = '#a855f7'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(ts * W, 0); ctx.lineTo(ts * W, H); ctx.stroke()
|
||||
ctx.beginPath(); ctx.moveTo(te * W, 0); ctx.lineTo(te * W, H); ctx.stroke()
|
||||
}, [slot.waveform, beatSec, barSec, duration])
|
||||
|
||||
useEffect(() => { draw() }, [draw, trimStart, trimEnd])
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => draw())
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [draw])
|
||||
|
||||
// ── Pointer → fraction ──────────────────────────────────────────────────────
|
||||
function fracFromClientX(clientX) {
|
||||
const el = containerRef.current
|
||||
if (!el) return 0
|
||||
const rect = el.getBoundingClientRect()
|
||||
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
}
|
||||
|
||||
// ── Drag handles — free movement, no snapping ───────────────────────────────
|
||||
function handleMouseDown(handle) {
|
||||
return (e) => {
|
||||
e.preventDefault()
|
||||
function onMove(ev) {
|
||||
const raw = fracFromClientX(ev.clientX)
|
||||
if (handle === 'start') {
|
||||
const c = Math.max(0, Math.min(raw, trimEndRef.current - 0.01))
|
||||
setTrimStart(c); trimStartRef.current = c
|
||||
} else {
|
||||
const c = Math.max(trimStartRef.current + 0.01, Math.min(1, raw))
|
||||
setTrimEnd(c); trimEndRef.current = c
|
||||
}
|
||||
draw()
|
||||
}
|
||||
function onUp() {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}
|
||||
}
|
||||
|
||||
// Click canvas to move nearest handle
|
||||
function handleCanvasClick(e) {
|
||||
if (e.target !== canvasRef.current) return
|
||||
const raw = fracFromClientX(e.clientX)
|
||||
if (Math.abs(raw - trimStart) <= Math.abs(raw - trimEnd)) {
|
||||
const c = Math.max(0, Math.min(raw, trimEndRef.current - 0.01))
|
||||
setTrimStart(c); trimStartRef.current = c
|
||||
} else {
|
||||
const c = Math.max(trimStartRef.current + 0.01, Math.min(1, raw))
|
||||
setTrimEnd(c); trimEndRef.current = c
|
||||
}
|
||||
draw()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between px-4 pt-3 pb-1">
|
||||
<span className="text-[11px] uppercase tracking-wider text-gray-500">
|
||||
Trim — Loop {slotIdx + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 font-mono">
|
||||
<span className="text-gray-600">{fmtMs(startSec)} → {fmtMs(endSec)}</span>
|
||||
<span className="text-accent font-semibold">{fmtSec(selectedSec)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waveform + handles */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative mx-4 h-20 rounded-lg overflow-visible cursor-crosshair select-none"
|
||||
onClick={handleCanvasClick}
|
||||
>
|
||||
<canvas ref={canvasRef} className="w-full h-full block rounded-lg" />
|
||||
|
||||
{/* Left handle */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-5 -translate-x-1/2 cursor-ew-resize flex items-center justify-center group z-10"
|
||||
style={{ left: `${trimStart * 100}%` }}
|
||||
onMouseDown={handleMouseDown('start')}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-0.5 h-full bg-accent/70 group-hover:bg-accent group-hover:w-1 transition-all" />
|
||||
<div className="absolute w-3.5 h-3.5 rounded-full bg-accent border-2 border-white/20 shadow-lg top-1/2 -translate-y-1/2" />
|
||||
<div className="absolute bottom-full mb-1 text-[9px] font-mono text-accent bg-panel border border-border rounded px-1 py-0.5 whitespace-nowrap pointer-events-none">
|
||||
{fmtMs(startSec)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right handle */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-5 -translate-x-1/2 cursor-ew-resize flex items-center justify-center group z-10"
|
||||
style={{ left: `${trimEnd * 100}%` }}
|
||||
onMouseDown={handleMouseDown('end')}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-0.5 h-full bg-accent/70 group-hover:bg-accent group-hover:w-1 transition-all" />
|
||||
<div className="absolute w-3.5 h-3.5 rounded-full bg-accent border-2 border-white/20 shadow-lg top-1/2 -translate-y-1/2" />
|
||||
<div className="absolute bottom-full mb-1 text-[9px] font-mono text-accent bg-panel border border-border rounded px-1 py-0.5 whitespace-nowrap pointer-events-none">
|
||||
{fmtMs(endSec)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: preview + bar snap + hint */}
|
||||
<div className="flex items-center gap-2 px-4 pt-2 pb-1 flex-wrap">
|
||||
{/* Preview play/stop */}
|
||||
<button
|
||||
onClick={togglePreview}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg border text-xs font-medium transition-all ${
|
||||
previewing
|
||||
? 'bg-accent/20 border-accent text-accent'
|
||||
: 'bg-surface border-border text-gray-400 hover:text-white hover:border-gray-500'
|
||||
}`}
|
||||
title="Preview loop selection"
|
||||
>
|
||||
{previewing
|
||||
? <><span>⏹</span><span>Stop</span></>
|
||||
: <><span>▶</span><span>Preview</span></>
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* Bar snap buttons — only if BPM is set */}
|
||||
{barSec && duration && (
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<span className="text-[10px] text-gray-600 uppercase tracking-wider mr-0.5">snap end →</span>
|
||||
{[1, 2, 4].map(n => {
|
||||
const endFrac = trimStart + (n * barSec) / duration
|
||||
const fits = endFrac <= 1.02
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => snapBars(n)}
|
||||
disabled={!fits}
|
||||
className="px-2 py-0.5 rounded border border-border text-[10px] text-gray-400 hover:text-accent hover:border-accent/50 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={`Set end to ${n} bar${n > 1 ? 's' : ''} from start`}
|
||||
>
|
||||
{n} bar{n > 1 ? 's' : ''}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="ml-auto text-[10px] text-gray-700">
|
||||
{bpm ? `${bpm} BPM grid` : 'no BPM — trim freely'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 px-4 pb-3">
|
||||
<button
|
||||
onClick={() => { stopPreview(); onCommit(slotIdx, trimStart, trimEnd) }}
|
||||
className="px-5 py-1.5 bg-accent text-white text-sm font-bold rounded-lg hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
Set Loop ▶
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { stopPreview(); onCommit(slotIdx, 0, 1) }}
|
||||
className="px-4 py-1.5 bg-surface border border-border text-gray-400 text-sm rounded-lg hover:text-white hover:border-gray-500 transition-colors"
|
||||
title="Use the full recording without trimming"
|
||||
>
|
||||
Use Full
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { stopPreview(); onCancel(slotIdx) }}
|
||||
className="px-4 py-1.5 bg-surface border border-border text-gray-500 text-sm rounded-lg hover:text-red-400 hover:border-red-800 transition-colors ml-auto"
|
||||
>
|
||||
Re-record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
// 2-octave mini piano keyboard showing technique notes
|
||||
// Props:
|
||||
// rootPc — root pitch class 0-11
|
||||
// lh — array of semitone intervals above root (left hand, shown in blue)
|
||||
// rh — array of semitone intervals above root (right hand, shown in purple)
|
||||
|
||||
const OCTAVES = 2
|
||||
const WW = 22 // white key width
|
||||
const WH = 60 // white key height
|
||||
const BW = 14 // black key width
|
||||
const BH = 38 // black key height
|
||||
|
||||
// White key pitch classes within an octave, in order
|
||||
const WHITE_PCS = [0, 2, 4, 5, 7, 9, 11] // C D E F G A B
|
||||
const WHITE_NAMES = ['C','D','E','F','G','A','B']
|
||||
// Black key offsets (x position relative to white key 0) and pitch classes
|
||||
const BLACK_OFFSETS = [
|
||||
{ pc: 1, afterWhite: 0 }, // C#
|
||||
{ pc: 3, afterWhite: 1 }, // D#
|
||||
{ pc: 6, afterWhite: 3 }, // F#
|
||||
{ pc: 8, afterWhite: 4 }, // G#
|
||||
{ pc: 10, afterWhite: 5 }, // A#
|
||||
]
|
||||
|
||||
const TOTAL_WHITES = WHITE_PCS.length * OCTAVES // 14
|
||||
const SVG_W = WW * TOTAL_WHITES + 2
|
||||
const SVG_H = WH + 24
|
||||
|
||||
function noteColor(interval) {
|
||||
// interval < 12 → first octave (root region), ≥12 → second octave
|
||||
return interval < 12 ? '#a855f7' : '#c084fc'
|
||||
}
|
||||
|
||||
function handLabel(hand) {
|
||||
return hand === 'L' ? 'LH' : 'RH'
|
||||
}
|
||||
|
||||
export default function MiniPiano({ rootPc, lh = [], rh = [] }) {
|
||||
// Build a set of highlighted notes: pc → { hand, interval }
|
||||
// We span 2 octaves (semitones 0…23 above root), mapped to absolute pitch classes
|
||||
const highlights = new Map() // absIdx → { color, label }
|
||||
|
||||
function addNotes(intervals, hand) {
|
||||
for (const iv of intervals) {
|
||||
const octave = Math.floor(iv / 12)
|
||||
const pc = (rootPc + iv) % 12
|
||||
const absIdx = octave * 12 + pc // unique index per octave slot
|
||||
highlights.set(`${octave}-${pc}`, { color: hand === 'L' ? '#3b82f6' : '#a855f7', label: handLabel(hand) })
|
||||
}
|
||||
}
|
||||
addNotes(lh, 'L')
|
||||
addNotes(rh, 'R')
|
||||
|
||||
function isHighlighted(octave, pc) {
|
||||
return highlights.get(`${octave}-${pc}`)
|
||||
}
|
||||
|
||||
// White keys
|
||||
const whites = []
|
||||
for (let oct = 0; oct < OCTAVES; oct++) {
|
||||
for (let wi = 0; wi < WHITE_PCS.length; wi++) {
|
||||
const pc = WHITE_PCS[wi]
|
||||
const absWi = oct * WHITE_PCS.length + wi
|
||||
const x = absWi * WW + 1
|
||||
const hl = isHighlighted(oct, pc)
|
||||
whites.push({ x, pc, oct, wi, absWi, hl, name: WHITE_NAMES[wi] + (oct + 4) })
|
||||
}
|
||||
}
|
||||
|
||||
// Black keys
|
||||
const blacks = []
|
||||
for (let oct = 0; oct < OCTAVES; oct++) {
|
||||
for (const { pc, afterWhite } of BLACK_OFFSETS) {
|
||||
const absWi = oct * WHITE_PCS.length + afterWhite
|
||||
const x = absWi * WW + WW - BW / 2
|
||||
const hl = isHighlighted(oct, pc)
|
||||
blacks.push({ x, pc, oct, hl })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<svg width={SVG_W} height={SVG_H} viewBox={`0 0 ${SVG_W} ${SVG_H}`} className="overflow-visible">
|
||||
{/* White keys */}
|
||||
{whites.map(({ x, hl, name, absWi }) => (
|
||||
<g key={`w${absWi}`}>
|
||||
<rect
|
||||
x={x} y={1} width={WW - 1} height={WH}
|
||||
rx={2}
|
||||
fill={hl ? hl.color : '#f5f5f5'}
|
||||
stroke="#374151"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
{hl && (
|
||||
<text x={x + (WW - 1) / 2} y={WH - 8}
|
||||
textAnchor="middle" fill="white" fontSize={7} fontWeight="bold">
|
||||
{hl.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Black keys */}
|
||||
{blacks.map(({ x, pc, oct, hl }, i) => (
|
||||
<g key={`b${oct}-${pc}`}>
|
||||
<rect
|
||||
x={x} y={1} width={BW} height={BH}
|
||||
rx={2}
|
||||
fill={hl ? hl.color : '#1f2937'}
|
||||
stroke="#111827"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
{hl && (
|
||||
<text x={x + BW / 2} y={BH - 5}
|
||||
textAnchor="middle" fill="white" fontSize={6} fontWeight="bold">
|
||||
{hl.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Root label at bottom */}
|
||||
{whites.map(({ x, pc, oct, name, absWi }) => {
|
||||
const isRoot = pc === rootPc && oct === 0
|
||||
if (!isRoot) return null
|
||||
return (
|
||||
<text key={`lbl${absWi}`} x={x + (WW - 1) / 2} y={WH + 14}
|
||||
textAnchor="middle" fill="#a855f7" fontSize={8} fontWeight="bold">
|
||||
R
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
|
||||
const MODEL = 'claude-sonnet-4-6'
|
||||
const API_URL = 'https://api.anthropic.com/v1/messages'
|
||||
const LS_KEY = 'wtf_teacher_key'
|
||||
|
||||
// ── System prompt — rebuilt with live session context on every request ────────
|
||||
function buildSystemPrompt({ keyInfo, currentChord, bpm, chordHistory }) {
|
||||
const keyStr = keyInfo ? `${keyInfo.root} ${keyInfo.mode}` : 'not detected yet'
|
||||
const chordStr = currentChord?.name ?? 'none detected'
|
||||
const bpmStr = bpm ? `${Math.round(bpm)} BPM` : 'not detected'
|
||||
const histStr = chordHistory?.length
|
||||
? chordHistory.map(c => c.name).join(' → ')
|
||||
: 'none yet'
|
||||
|
||||
return `You are an expert music teacher and session musician embedded in JamBuddy, a real-time chord and key detection app for guitarists and keyboard players at live jam sessions.
|
||||
|
||||
LIVE SESSION CONTEXT (updated in real time):
|
||||
• Detected key: ${keyStr}
|
||||
• Current chord: ${chordStr}
|
||||
• BPM: ${bpmStr}
|
||||
• Recent chord history: ${histStr}
|
||||
|
||||
YOUR ROLE:
|
||||
- Explain chords, scales, and music theory in plain, friendly language
|
||||
- Suggest what to practice based on the current key and chord progression
|
||||
- Teach playing techniques: fretting, strumming patterns, chord voicings, fingerpicking
|
||||
- Help musicians understand WHY things sound the way they do
|
||||
- Suggest progressions that work with whatever the user is currently playing
|
||||
- Adjust depth to the user — explain basics if they seem new, go deep if they ask for it
|
||||
- Point out interesting connections: "that Dm7 works here because it's the ii chord in C major"
|
||||
|
||||
STYLE:
|
||||
- Keep responses focused and practical — this is a live jam, not a classroom
|
||||
- Use plain text, not markdown. Short paragraphs. Bullet points with "-" are fine.
|
||||
- If someone asks about the current chord or key, use the live context above
|
||||
- Max ~150 words unless someone asks for a deep dive`
|
||||
}
|
||||
|
||||
// ── Quick-action chips ────────────────────────────────────────────────────────
|
||||
const CHIPS = [
|
||||
{ label: 'What should I practice?', msg: 'Based on what I\'m playing right now, what\'s the most useful thing I could practice?' },
|
||||
{ label: 'Explain current chord', msg: 'Explain the current chord I\'m playing — what it is, why it sounds the way it does, and where it tends to appear.' },
|
||||
{ label: 'Scales that work here', msg: 'What scales work over the current key and chord? Which notes sound best to improvise with?' },
|
||||
{ label: 'Suggest a progression', msg: 'Suggest a chord progression that fits the current key. Give me something interesting to try.' },
|
||||
{ label: 'Technique tip', msg: 'Give me one technique tip — something I can work on in the next few minutes to sound better.' },
|
||||
{ label: 'Why does this sound good?', msg: 'Looking at my recent chord history, why do these chords sound good together? What\'s the music theory behind it?' },
|
||||
]
|
||||
|
||||
// ── Simple text renderer (bold + line breaks) ─────────────────────────────────
|
||||
function MessageText({ text }) {
|
||||
const lines = text.split('\n')
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{lines.map((line, i) => {
|
||||
if (!line.trim()) return <div key={i} className="h-1" />
|
||||
// Bold: **text**
|
||||
const parts = line.split(/(\*\*[^*]+\*\*)/)
|
||||
return (
|
||||
<p key={i} className="leading-relaxed">
|
||||
{parts.map((part, j) =>
|
||||
part.startsWith('**') && part.endsWith('**')
|
||||
? <strong key={j} className="text-white font-semibold">{part.slice(2, -2)}</strong>
|
||||
: part
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
export default function MusicTeacher({ keyInfo, currentChord, bpm, chordHistory }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [apiKey, setApiKey] = useState(() => localStorage.getItem(LS_KEY) ?? '')
|
||||
const [showKeyInput, setShowKeyInput] = useState(false)
|
||||
const [messages, setMessages] = useState([]) // [{role, content}]
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streaming, setStreaming] = useState('') // partial response being streamed
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const scrollRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
const abortRef = useRef(null)
|
||||
|
||||
// Always scroll to bottom on new content
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messages, streaming])
|
||||
|
||||
// Focus input when panel opens
|
||||
useEffect(() => {
|
||||
if (open && apiKey && inputRef.current) {
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open, apiKey])
|
||||
|
||||
function saveKey(k) {
|
||||
setApiKey(k)
|
||||
localStorage.setItem(LS_KEY, k)
|
||||
}
|
||||
|
||||
function clearKey() {
|
||||
setApiKey('')
|
||||
localStorage.removeItem(LS_KEY)
|
||||
setShowKeyInput(true)
|
||||
}
|
||||
|
||||
const sendMessage = useCallback(async (userText) => {
|
||||
if (!userText.trim() || loading || !apiKey) return
|
||||
|
||||
setError(null)
|
||||
const userMsg = { role: 'user', content: userText.trim() }
|
||||
const nextMessages = [...messages, userMsg]
|
||||
setMessages(nextMessages)
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
setStreaming('')
|
||||
|
||||
const context = { keyInfo, currentChord, bpm, chordHistory }
|
||||
|
||||
try {
|
||||
const ctrl = new AbortController()
|
||||
abortRef.current = ctrl
|
||||
|
||||
const res = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
signal: ctrl.signal,
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
max_tokens: 1024,
|
||||
stream: true,
|
||||
system: buildSystemPrompt(context),
|
||||
messages: nextMessages,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body?.error?.message ?? `API error ${res.status}`)
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let full = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const data = line.slice(6).trim()
|
||||
if (data === '[DONE]' || !data) continue
|
||||
try {
|
||||
const ev = JSON.parse(data)
|
||||
if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
|
||||
full += ev.delta.text
|
||||
setStreaming(full)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: full }])
|
||||
setStreaming('')
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
setError(err.message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
abortRef.current = null
|
||||
}
|
||||
}, [messages, loading, apiKey, keyInfo, currentChord, bpm, chordHistory])
|
||||
|
||||
function stopGeneration() {
|
||||
abortRef.current?.abort()
|
||||
if (streaming) {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: streaming }])
|
||||
setStreaming('')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendMessage(input)
|
||||
}
|
||||
}
|
||||
|
||||
const hasKey = apiKey.trim().length > 0
|
||||
|
||||
// Dot: purple when API key set, gray otherwise
|
||||
const dotClass = hasKey ? 'bg-accent' : 'bg-gray-700'
|
||||
|
||||
return (
|
||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-400">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex items-center gap-2 hover:text-gray-200 transition-colors text-left"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||
<span>MUSIC TEACHER</span>
|
||||
<span className="text-[11px] text-gray-600">AI · Claude</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Key indicator */}
|
||||
<button
|
||||
onClick={() => setShowKeyInput(v => !v)}
|
||||
className="text-[10px] text-gray-600 hover:text-gray-400 transition-colors px-1.5 py-0.5 rounded border border-transparent hover:border-border"
|
||||
title={hasKey ? 'API key set — click to change' : 'Set API key'}
|
||||
>
|
||||
{hasKey ? '🔑 key set' : '🔑 add key'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-all"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
{open
|
||||
? <polyline points="2,8 6,4 10,8" />
|
||||
: <polyline points="2,4 6,8 10,4" />
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ───────────────────────────────────────────────────────────── */}
|
||||
{open && (
|
||||
<div className="border-t border-border">
|
||||
|
||||
{/* API key input (shown when no key or user wants to change) */}
|
||||
{(!hasKey || showKeyInput) && (
|
||||
<div className="px-4 py-3 bg-surface/50 border-b border-border">
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Enter your <a className="text-accent underline" href="https://console.anthropic.com/keys" target="_blank" rel="noreferrer">Anthropic API key</a> to enable the music teacher. Stored locally on your device only.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
className="flex-1 px-2.5 py-1.5 bg-surface border border-border rounded-lg text-xs text-gray-300 focus:outline-none focus:border-accent font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { saveKey(apiKey); setShowKeyInput(false) }}
|
||||
disabled={!apiKey.trim()}
|
||||
className="px-3 py-1.5 bg-accent text-white text-xs font-bold rounded-lg hover:bg-accent/80 transition-colors disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
{hasKey && (
|
||||
<button
|
||||
onClick={() => setShowKeyInput(false)}
|
||||
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasKey && (
|
||||
<>
|
||||
{/* Live session context strip */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border text-[10px] font-mono">
|
||||
<span className="text-gray-600 uppercase tracking-wider">Now:</span>
|
||||
{keyInfo ? (
|
||||
<span className="text-accent">{keyInfo.root} {keyInfo.mode}</span>
|
||||
) : (
|
||||
<span className="text-gray-700">no key</span>
|
||||
)}
|
||||
<span className="text-gray-800">·</span>
|
||||
{currentChord ? (
|
||||
<span className="text-white">{currentChord.name}</span>
|
||||
) : (
|
||||
<span className="text-gray-700">no chord</span>
|
||||
)}
|
||||
<span className="text-gray-800">·</span>
|
||||
<span className="text-gray-500">{bpm ? `${Math.round(bpm)} bpm` : '— bpm'}</span>
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={() => { setMessages([]); setError(null) }}
|
||||
className="ml-auto text-gray-700 hover:text-gray-400 transition-colors"
|
||||
title="Clear conversation"
|
||||
>
|
||||
clear chat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat messages */}
|
||||
{messages.length > 0 || streaming ? (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-72 overflow-y-auto px-4 py-3 space-y-3 text-xs"
|
||||
>
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={m.role === 'user' ? 'flex justify-end' : ''}>
|
||||
{m.role === 'user' ? (
|
||||
<div className="max-w-[80%] bg-accent/20 border border-accent/30 rounded-xl rounded-tr-sm px-3 py-2 text-gray-200">
|
||||
{m.content}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-300 leading-relaxed">
|
||||
<MessageText text={m.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{streaming && (
|
||||
<div className="text-gray-300 text-xs leading-relaxed">
|
||||
<MessageText text={streaming} />
|
||||
<span className="inline-block w-1.5 h-3.5 bg-accent/70 animate-pulse ml-0.5 align-middle" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-red-400 text-xs bg-red-950/30 border border-red-900/50 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Quick-action chips (shown when no chat history yet) */
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-[10px] text-gray-700 mb-2 uppercase tracking-wider">Ask something</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CHIPS.map(chip => (
|
||||
<button
|
||||
key={chip.label}
|
||||
onClick={() => sendMessage(chip.msg)}
|
||||
className="px-2.5 py-1 bg-surface border border-border rounded-full text-[10px] text-gray-400 hover:text-white hover:border-accent/50 hover:bg-accent/10 transition-all"
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input row */}
|
||||
<div className="px-4 py-3 border-t border-border flex gap-2 items-end">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything about music theory, technique, or what to play…"
|
||||
rows={1}
|
||||
className="flex-1 px-3 py-2 bg-surface border border-border rounded-xl text-xs text-gray-300 placeholder-gray-700 focus:outline-none focus:border-accent resize-none leading-relaxed"
|
||||
style={{ maxHeight: '80px', overflowY: 'auto' }}
|
||||
/>
|
||||
{loading ? (
|
||||
<button
|
||||
onClick={stopGeneration}
|
||||
className="px-3 py-2 bg-surface border border-border text-gray-500 hover:text-red-400 hover:border-red-800 text-xs rounded-xl transition-colors shrink-0"
|
||||
title="Stop"
|
||||
>
|
||||
⏹
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={!input.trim()}
|
||||
className="px-3 py-2 bg-accent text-white text-xs font-bold rounded-xl hover:bg-accent/80 transition-colors disabled:opacity-40 shrink-0"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick chips after first message */}
|
||||
{messages.length > 0 && (
|
||||
<div className="px-4 pb-3 flex flex-wrap gap-1.5">
|
||||
{CHIPS.slice(0, 4).map(chip => (
|
||||
<button
|
||||
key={chip.label}
|
||||
onClick={() => sendMessage(chip.msg)}
|
||||
disabled={loading}
|
||||
className="px-2 py-0.5 bg-surface border border-border rounded-full text-[9px] text-gray-600 hover:text-gray-300 hover:border-accent/30 transition-all disabled:opacity-30"
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,23 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { toRomanNumeral } from '../lib/theory'
|
||||
import { findLoopPosition } from '../lib/match'
|
||||
|
||||
const HISTORY_SHOWN = 8
|
||||
|
||||
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, currentChord, onChordClick }) {
|
||||
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 }) {
|
||||
const { root, mode, confidence } = keyInfo ?? {}
|
||||
|
||||
const visible = chordHistory.slice(-HISTORY_SHOWN)
|
||||
@@ -62,11 +75,10 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
||||
key={i}
|
||||
ref={isCurrent ? currentRef : null}
|
||||
style={{ opacity }}
|
||||
onClick={() => onChordClick?.(chord)}
|
||||
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 cursor-pointer ${
|
||||
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 ${
|
||||
isCurrent
|
||||
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20 hover:bg-accent/20'
|
||||
: 'border border-transparent hover:border-border hover:bg-panel'
|
||||
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20'
|
||||
: 'border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<span className={`font-black leading-none tracking-tight ${
|
||||
@@ -96,11 +108,10 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => onChordClick?.(chord)}
|
||||
className={`flex flex-col items-center px-2 py-0.5 rounded-lg border transition-all duration-200 cursor-pointer ${
|
||||
className={`flex flex-col items-center px-2 py-0.5 rounded-lg border transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-accent/20 border-accent shadow-[0_0_10px_rgba(168,85,247,0.3)] hover:bg-accent/30'
|
||||
: 'bg-border border-border hover:border-gray-500'
|
||||
? 'bg-accent/20 border-accent shadow-[0_0_10px_rgba(168,85,247,0.3)]'
|
||||
: 'bg-border border-border'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-sm font-bold leading-none ${isActive ? 'text-accent' : 'text-gray-300'}`}>
|
||||
@@ -121,16 +132,11 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
||||
{/* ── Right: big chord ── */}
|
||||
<div className="hidden lg:flex w-[30%] flex-col items-center justify-center gap-1">
|
||||
{current ? (
|
||||
<button
|
||||
onClick={() => onChordClick?.(current)}
|
||||
className="flex flex-col items-center gap-1 px-4 py-2 rounded-xl hover:bg-accent/10 transition-colors group"
|
||||
title="Click to see voicings"
|
||||
>
|
||||
<>
|
||||
<p className="text-xs text-gray-600 uppercase tracking-widest">Now Playing</p>
|
||||
<div className="text-6xl font-black text-amber-400 leading-none group-hover:text-accent transition-colors">{current}</div>
|
||||
<div className="text-6xl font-black text-amber-400 leading-none">{current}</div>
|
||||
<div className="text-sm text-gray-500">{currentRN}</div>
|
||||
<p className="text-[10px] text-gray-700 group-hover:text-gray-500 transition-colors">tap for voicings</p>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-600 text-xs text-center">Play a chord</p>
|
||||
)}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
// ─── Mini fretboard scale diagram ─────────────────────────────────────────────
|
||||
// Shows a 6-string × 5-fret window of scale tones.
|
||||
// Root notes → purple fill. Scale tones → dark grey fill.
|
||||
// Strings: top = s6 (low E), bottom = s1 (high e).
|
||||
// Window starts at the root fret on string 6.
|
||||
|
||||
const OPEN_PITCHES = [4, 9, 2, 7, 11, 4] // E A D G B e (s6 … s1)
|
||||
const FRETS = 5
|
||||
|
||||
export default function RiffDiagram({ rootPc, scaleIntervals = [0, 3, 5, 7, 10] }) {
|
||||
if (rootPc === undefined || rootPc === null) return null
|
||||
|
||||
// Fret window starts where the root lands on s6 (low E)
|
||||
const startFret = (rootPc - OPEN_PITCHES[0] + 12) % 12
|
||||
|
||||
// Which pitch classes are in the scale?
|
||||
const scaleSet = new Set(scaleIntervals.map(i => (rootPc + i) % 12))
|
||||
|
||||
// Collect dots: { s (0=s6…5=s1), f (0-4 within window), isRoot }
|
||||
const dots = []
|
||||
for (let s = 0; s < 6; s++) {
|
||||
for (let f = 0; f < FRETS; f++) {
|
||||
const pc = (OPEN_PITCHES[s] + startFret + f) % 12
|
||||
if (scaleSet.has(pc)) {
|
||||
dots.push({ s, f, isRoot: pc === rootPc })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SVG layout
|
||||
const W = 152, H = 70
|
||||
const mL = 6, mT = 14, mR = 6, mB = 4
|
||||
const innerW = W - mL - mR // 140
|
||||
const innerH = H - mT - mB // 52
|
||||
|
||||
const cellW = innerW / FRETS // 28
|
||||
const strGap = innerH / 5 // gap between 6 strings (5 gaps)
|
||||
|
||||
const sx = (f) => mL + f * cellW // left edge of fret cell
|
||||
const cx = (f) => mL + (f + 0.5) * cellW // centre of fret cell
|
||||
const sy = (s) => mT + s * strGap // y of string s
|
||||
|
||||
return (
|
||||
<svg width={W} height={H} className="shrink-0 overflow-visible">
|
||||
{/* Fret separators (vertical lines) */}
|
||||
{Array.from({ length: FRETS + 1 }, (_, f) => (
|
||||
<line key={f}
|
||||
x1={sx(f)} y1={mT - 2}
|
||||
x2={sx(f)} y2={H - mB}
|
||||
stroke={f === 0 ? '#555' : '#2a2a2a'}
|
||||
strokeWidth={f === 0 ? 2 : 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* String lines (horizontal) */}
|
||||
{Array.from({ length: 6 }, (_, s) => (
|
||||
<line key={s}
|
||||
x1={mL} y1={sy(s)}
|
||||
x2={W - mR} y2={sy(s)}
|
||||
stroke="#3a3a3a"
|
||||
strokeWidth={s === 0 ? 1.5 : 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Fret numbers above */}
|
||||
{Array.from({ length: FRETS }, (_, f) => (
|
||||
<text key={f}
|
||||
x={cx(f)} y={9}
|
||||
textAnchor="middle" fontSize={8}
|
||||
fill={f === 0 && startFret > 0 ? '#a855f7' : '#555'}
|
||||
fontWeight={f === 0 && startFret > 0 ? 'bold' : 'normal'}>
|
||||
{startFret + f === 0 ? 'O' : startFret + f}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Scale dots */}
|
||||
{dots.map((d, i) => (
|
||||
<circle key={i}
|
||||
cx={cx(d.f)} cy={sy(d.s)}
|
||||
r={4.5}
|
||||
fill={d.isRoot ? '#a855f7' : '#3d3d3d'}
|
||||
stroke={d.isRoot ? '#c084fc' : '#606060'}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Root labels */}
|
||||
{dots.filter(d => d.isRoot).map((d, i) => (
|
||||
<text key={i}
|
||||
x={cx(d.f)} y={sy(d.s) + 3.5}
|
||||
textAnchor="middle" fontSize={6}
|
||||
fill="white" fontWeight="bold">
|
||||
R
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+37
-80
@@ -1,5 +1,3 @@
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
|
||||
const SETTINGS = [
|
||||
{
|
||||
section: 'Chord Detection',
|
||||
@@ -72,52 +70,20 @@ const SETTINGS = [
|
||||
},
|
||||
]
|
||||
|
||||
export default function Settings({ config, onChange, onClose, onReset, monoColor, onMonoColorChange }) {
|
||||
// Snapshot on mount so Cancel can restore
|
||||
const savedConfig = useRef(config)
|
||||
const savedMono = useRef(monoColor)
|
||||
|
||||
function handleCancel() {
|
||||
Object.entries(savedConfig.current).forEach(([k, v]) => onChange(k, v))
|
||||
onMonoColorChange(savedMono.current)
|
||||
onClose()
|
||||
}
|
||||
|
||||
function DeviceSelector({ config, onChange }) {
|
||||
const [devices, setDevices] = useState([])
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
|
||||
const list = await navigator.mediaDevices.enumerateDevices()
|
||||
setDevices(list.filter(d => d.kind === 'audioinput'))
|
||||
} catch (e) {
|
||||
console.warn('enumerateDevices failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3 items-center">
|
||||
<select
|
||||
value={config.audioDeviceId ?? ''}
|
||||
onChange={e => onChange('audioDeviceId', e.target.value === '' ? null : e.target.value)}
|
||||
className="appearance-none bg-surface border border-border hover:border-gray-500 focus:border-accent focus:outline-none rounded-lg pl-3 pr-7 py-1 text-sm text-gray-200 cursor-pointer transition-colors w-full"
|
||||
>
|
||||
<option value="">System default</option>
|
||||
{devices.map((d, i) => (
|
||||
<option key={d.deviceId || i} value={d.deviceId}>{d.label || `Microphone ${i + 1}`}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={refresh} className="px-3 py-1 rounded-lg border border-border text-sm text-gray-400">Refresh</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">If device labels are empty, grant microphone permission first and hit Refresh.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const TOGGLES = [
|
||||
{
|
||||
section: 'Experimental',
|
||||
items: [
|
||||
{
|
||||
key: 'useEssentia',
|
||||
label: 'Essentia HPCP',
|
||||
desc: 'Replace custom chroma with Essentia\'s Harmonic Pitch Class Profile (spectral peaks + 8 harmonics). More accurate on polyphonic input. May be slower.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function Settings({ config, onChange, onClose, onReset }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-surface z-50 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
@@ -134,42 +100,41 @@ export default function Settings({ config, onChange, onClose, onReset, monoColor
|
||||
>
|
||||
Reset defaults
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-border text-gray-500 hover:text-gray-300 hover:border-gray-400 transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 rounded-lg text-sm bg-accent hover:bg-purple-600 text-white font-semibold transition-all"
|
||||
>
|
||||
Save
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* ── Display ── */}
|
||||
<div>
|
||||
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
|
||||
Display
|
||||
</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-200">Mono Color Mode</p>
|
||||
<p className="text-xs text-gray-600 mt-0.5">Use a single purple palette instead of purple + amber for note tiers.</p>
|
||||
{TOGGLES.map(section => (
|
||||
<div key={section.section}>
|
||||
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
|
||||
{section.section}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{section.items.map(item => (
|
||||
<label key={item.key} className="flex items-start gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!config[item.key]}
|
||||
onChange={e => onChange(item.key, e.target.checked)}
|
||||
className="mt-0.5 accent-purple-500 w-4 h-4 flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-200 group-hover:text-white transition-colors">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-0.5">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onMonoColorChange(v => !v)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${monoColor ? 'bg-accent' : 'bg-gray-700'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${monoColor ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
))}
|
||||
{SETTINGS.map(section => (
|
||||
<div key={section.section}>
|
||||
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
|
||||
@@ -210,14 +175,6 @@ export default function Settings({ config, onChange, onClose, onReset, monoColor
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Audio device selector */}
|
||||
<div>
|
||||
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
|
||||
Microphone
|
||||
</h3>
|
||||
<DeviceSelector config={config} onChange={onChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -108,14 +108,17 @@ export default function Tuner() {
|
||||
|
||||
|
||||
return (
|
||||
<div className="p-6 text-center">
|
||||
<div className="flex items-start justify-end mb-4">
|
||||
<button
|
||||
onClick={() => (isListening ? stopListening() : startListening())}
|
||||
className={`px-4 py-2 rounded-full text-sm font-semibold ${isListening ? 'bg-red-600' : 'bg-accent'}`}
|
||||
>
|
||||
{isListening ? 'Stop' : 'Start'}
|
||||
</button>
|
||||
<div className="p-6 bg-panel border border-border rounded-xl text-center">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Tuner</h3>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => (isListening ? stopListening() : startListening())}
|
||||
className={`px-4 py-2 rounded-full text-sm font-semibold ${isListening ? 'bg-red-600' : 'bg-accent'}`}
|
||||
>
|
||||
{isListening ? 'Stop' : 'Start'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col items-center">
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
# KB Authoring Contract
|
||||
|
||||
Every knowledgebase cell must conform to this schema and pass `node scripts/validate-kb.mjs`. The gold-standard exemplar is `jazz/` — imitate it. Background and rationale: `docs/kb-plan.md`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/data/kb/<style>/
|
||||
meta.js — style identity
|
||||
progressions.js — the style's standard progressions (instrument-independent)
|
||||
guitar.js — instrument packs (piano.js, bass.js as cells are completed)
|
||||
```
|
||||
|
||||
Register each style in `src/data/kb/index.js`. The UI reads only the registry.
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **Key-agnostic.** Degrees and movable shapes only — never absolute chord names in data. Open guitar shapes are the one exception (they declare `onlyRoot`, a pitch class, and render only in matching keys).
|
||||
2. **Qualities** must be keys of `CHORD_TYPES` in `src/lib/theory.js` (`maj`, `min`, `dom7`, `maj7`, `min7`, `dim`, `dim7`, `half_dim`, `aug`, `sus4`, `sus2`, `maj6`, `min6`, `add9`).
|
||||
3. **Intermediate level.** Guitar: fret span ≤ 4 within a shape. Piano: one hand per recipe stays within a 10th. If a play is harder, provide an easier alternative in the same play set.
|
||||
4. Plays for the same progression must be **idiomatically different** (register, density, technique) — not transpositions of each other.
|
||||
|
||||
## meta.js
|
||||
|
||||
```js
|
||||
export default {
|
||||
id: 'jazz', // folder name
|
||||
label: 'Jazz',
|
||||
feel: 'swing', // swing | straight | shuffle | 16th | bossa…
|
||||
tempoRange: [110, 230],
|
||||
character: 'One sentence on what makes the style sound like itself.',
|
||||
}
|
||||
```
|
||||
|
||||
## progressions.js
|
||||
|
||||
```js
|
||||
export default [
|
||||
{
|
||||
id: 'jazz-251-major', // '<style>-<slug>', globally unique
|
||||
name: 'ii–V–I',
|
||||
rn: ['ii7', 'V7', 'Imaj7'], // display numerals
|
||||
degrees: [2, 7, 0], // semitone offsets from key root, 0–11
|
||||
qualities: ['min7', 'dom7', 'maj7'],
|
||||
bars: [1, 1, 2], // same length as degrees
|
||||
mode: 'major', // major | minor | dorian | mixolydian | …
|
||||
songs: ['Autumn Leaves'],
|
||||
tip: 'One transferable idea.',
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
4–8 progressions per style. Cross-check `docs/progression-repertoire.md` §1.
|
||||
|
||||
## Instrument packs
|
||||
|
||||
Common envelope:
|
||||
|
||||
```js
|
||||
export default {
|
||||
styleIntro: '2-3 sentences on this instrument's role in the style.',
|
||||
comping: [{ label, rhythm, description }], // ≥1 named rhythm
|
||||
plays: { '<progression-id>': [ <play>, <play> ] }, // ≥2 plays per progression
|
||||
improv: { // guitar/piano; optional for bass
|
||||
scales: [{ over: 'ii7', scale: 'dorian', why }],
|
||||
targetNotes: '…',
|
||||
licks: [{ tab/notation, description, over: '<progression-id>', source }],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Guitar play
|
||||
|
||||
```js
|
||||
{
|
||||
label: 'Shell voicings',
|
||||
level: 'intermediate',
|
||||
chords: [ // one per progression step
|
||||
{
|
||||
shape: {
|
||||
// movable: fret offsets relative to the root fret; 'x' = muted
|
||||
rootStr: 6, // string carrying the root, 6 = low E
|
||||
offsets: [0, 'x', 0, 0, 'x', 'x'], // ALWAYS 6 entries, low E first
|
||||
fingers: [1, 0, 2, 3, 0, 0],
|
||||
// open shapes instead use: frets: [...absolute], onlyRoot: <pc 0-11>
|
||||
},
|
||||
extensions: ['9'], // declared color tones beyond the quality (validator allows only these)
|
||||
// declared omissions (honest data, surfaced by the UI):
|
||||
// rootless: true — shape omits the root (e.g. guide-tone grips)
|
||||
// omit3: true — shape omits the 3rd (e.g. power chords; works over major or minor)
|
||||
note: 'root–♭7–♭3',
|
||||
},
|
||||
// …
|
||||
],
|
||||
tips: 'Voice-leading or ensemble advice.',
|
||||
}
|
||||
```
|
||||
|
||||
### Piano play
|
||||
|
||||
Voicings are degree recipes resolved through the chord quality. Degrees: `'1' '3' '5' '7'` resolve per quality (e.g. `'3'` → ♭3 for min7); altered/extended degrees are explicit: `'b9' '9' '#9' '11' '#11' 'b13' '13' '6'`.
|
||||
|
||||
```js
|
||||
{
|
||||
label: 'Rootless A/B alternation',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ recipe: { LH: ['3', '5', '7', '9'] }, note: 'Type A' },
|
||||
{ recipe: { LH: ['7', '9', '3', '13'] }, note: 'Type B' },
|
||||
],
|
||||
register: 'top note between C4 and C5',
|
||||
tips: '…',
|
||||
}
|
||||
```
|
||||
|
||||
### Bass play
|
||||
|
||||
```js
|
||||
{
|
||||
label: 'Walking, chromatic approach',
|
||||
level: 'intermediate',
|
||||
bars: [{ beats: ['R', '3', '5', 'chrom>'] }], // per bar of the progression
|
||||
// beat tokens: R 3 5 7 (chord degrees) · 'chrom>' / 'chrom<' (chromatic into next root
|
||||
// from below/above) · '5>' (dominant approach) · 'x' (ghost) · '-' (hold)
|
||||
tips: '…',
|
||||
}
|
||||
```
|
||||
|
||||
## Musician checklist (self-review before committing)
|
||||
|
||||
- [ ] Plays per progression genuinely differ in register/density/technique
|
||||
- [ ] Every shape/recipe is playable by intermediate hands (rule 3)
|
||||
- [ ] The style is recognizable from the rhythm descriptions alone (bossa ≠ jazz with new labels)
|
||||
- [ ] Every tip teaches a transferable idea (voice leading, register, space), not just "play this"
|
||||
- [ ] Songs/licks have sources; nothing invented
|
||||
- [ ] `node scripts/validate-kb.mjs` green; `npm run build` green
|
||||
@@ -1,233 +0,0 @@
|
||||
// Blues guitar pack. Shapes verified by note-spelling against: guitarworld.com
|
||||
// (13th chords, Jimmy Reed rhythm), fundamental-changes.com (SRV/Freddie King 9ths,
|
||||
// turnarounds), truefire.com (Texas comping, chord-tone targeting), jazzguitar.be
|
||||
// (tritone shells), guitarplayer.com (B.B. box, turnarounds).
|
||||
|
||||
// Big barre grips — full-band downbeat hits.
|
||||
const E_BARRE7 = { rootStr: 6, offsets: [0, 2, 0, 1, 0, 0], fingers: [1, 3, 1, 2, 1, 1] } // R-5-♭7-3-5-R
|
||||
const A_BARRE7 = { rootStr: 5, offsets: ['x', 0, 2, 0, 2, 0], fingers: [0, 1, 3, 1, 4, 1] } // R-5-♭7-3-5
|
||||
const MIN7_BARRE_6 = { rootStr: 6, offsets: [0, 2, 0, 0, 0, 0], fingers: [1, 3, 1, 1, 1, 1] } // Em-shape m7
|
||||
const MIN7_BARRE_5 = { rootStr: 5, offsets: ['x', 0, 2, 0, 1, 0], fingers: [0, 1, 3, 1, 2, 1] } // Am-shape m7
|
||||
|
||||
// The blues colour chords — root on the A string.
|
||||
const NINTH = { rootStr: 5, offsets: ['x', 0, -1, 0, 0, 0], fingers: [0, 2, 1, 3, 3, 3] } // R-3-♭7-9-5
|
||||
const THIRTEEN = { rootStr: 5, offsets: ['x', 0, -1, 0, 0, 2], fingers: [0, 2, 1, 3, 3, 4] } // R-3-♭7-9-13
|
||||
const THIRTEEN_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, 2, 'x'], fingers: [1, 0, 2, 3, 4, 0] } // R-♭7-3-13 (T-Bone register)
|
||||
const HENDRIX = { rootStr: 5, offsets: ['x', 0, -1, 0, 1, 'x'], fingers: [0, 2, 1, 3, 4, 0] } // 7#9 — ♭3 vs 3 in one grip
|
||||
|
||||
// Two-note-tritone shells — the Chicago comping grips.
|
||||
const SHELL7_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, 'x', 'x'], fingers: [1, 0, 2, 3, 0, 0] } // R-♭7-3
|
||||
const SHELL7_5 = { rootStr: 5, offsets: ['x', 0, 'x', 0, 2, 'x'], fingers: [0, 1, 0, 2, 4, 0] } // R-♭7-3
|
||||
const SHELL_M7_5 = { rootStr: 5, offsets: ['x', 0, -2, 0, 'x', 'x'], fingers: [0, 3, 1, 4, 0, 0] } // R-♭3-♭7
|
||||
|
||||
export default {
|
||||
styleIntro:
|
||||
'Blues rhythm guitar is a drum kit with pitch: the shuffle is the job, the chord is the decoration. Pick a lane — low boogie locked with the bass, or high 9th-chord stabs answering the vocal — and never both at once.',
|
||||
|
||||
comping: [
|
||||
{
|
||||
label: 'Jimmy Reed boogie shuffle',
|
||||
rhythm: 'swung 8ths: R+5 / R+6 alternating',
|
||||
description: 'Two-note dyads on the bottom strings, alternating the 5th and 6th above the root in swung eighths; move the same cell to the IV and V strings. Low register, dense — doubles the bass. The "second guitar" role Reed pioneered.',
|
||||
},
|
||||
{
|
||||
label: '9th-chord stabs (Texas / SRV)',
|
||||
rhythm: 'staccato hits, slide in from a half-step below',
|
||||
description: 'Short muted stabs of the 9th grip, approached from one fret under (B♭9→B9). Mid-high register, sparse — leaves the low end to the bass. The Freddie King "Hide Away" sound.',
|
||||
},
|
||||
{
|
||||
label: 'Slow blues 12/8',
|
||||
rhythm: 'rolled chords on a triplet grid',
|
||||
description: 'At ~60 BPM everything subdivides into triplets: arpeggiated 9ths, the 6↔9 rock on the top strings, fills answering the vocal. Density drops; space is the instrument.',
|
||||
},
|
||||
{
|
||||
label: 'Stormy Monday walk-up',
|
||||
rhythm: 'one chord per walking step, bars 7–8',
|
||||
description: 'Diatonic chord climb I7→ii7→iii7 then chromatic back down — a bassline played as chords. Canonical on the Allman Brothers\' At Fillmore East.',
|
||||
},
|
||||
],
|
||||
|
||||
plays: {
|
||||
'blues-12bar': [
|
||||
{
|
||||
label: 'Barre-chord shuffle',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: E_BARRE7, note: 'I7 — root on the 6th string' },
|
||||
{ shape: E_BARRE7, note: '' }, { shape: E_BARRE7, note: '' }, { shape: E_BARRE7, note: '' },
|
||||
{ shape: A_BARRE7, note: 'IV7 — same fret, root string up' },
|
||||
{ shape: A_BARRE7, note: '' },
|
||||
{ shape: E_BARRE7, note: '' }, { shape: E_BARRE7, note: '' },
|
||||
{ shape: A_BARRE7, note: 'V7 — two frets above the IV grip' },
|
||||
{ shape: A_BARRE7, note: 'IV7' },
|
||||
{ shape: E_BARRE7, note: '' },
|
||||
{ shape: A_BARRE7, note: 'V7 — push into the next chorus' },
|
||||
],
|
||||
tips: 'I, IV and V all live within two frets: 6th-string root, then 5th-string root at the same fret (IV) and two up (V). Strum short — the shuffle lives in the damping hand.',
|
||||
},
|
||||
{
|
||||
label: '9th-chord stabs (Texas)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: NINTH, extensions: ['9'], note: 'slide in from one fret below' },
|
||||
{ shape: NINTH, extensions: ['9'], note: '' }, { shape: NINTH, extensions: ['9'], note: '' }, { shape: NINTH, extensions: ['9'], note: '' },
|
||||
{ shape: NINTH, extensions: ['9'], note: 'IV9' }, { shape: NINTH, extensions: ['9'], note: '' },
|
||||
{ shape: NINTH, extensions: ['9'], note: '' }, { shape: NINTH, extensions: ['9'], note: '' },
|
||||
{ shape: THIRTEEN, extensions: ['9', '13'], note: 'V13 — pinky reaches the 13' },
|
||||
{ shape: NINTH, extensions: ['9'], note: 'IV9' },
|
||||
{ shape: NINTH, extensions: ['9'], note: '' },
|
||||
{ shape: THIRTEEN, extensions: ['9', '13'], note: 'V13' },
|
||||
],
|
||||
tips: 'Stab, mute, wait. The 13↔9 drop on the top string is a free melodic hook — comping that sounds like a horn section.',
|
||||
},
|
||||
],
|
||||
|
||||
'blues-quickchange': [
|
||||
{
|
||||
label: 'Tritone shells (Chicago)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: SHELL7_6, note: 'I7' },
|
||||
{ shape: SHELL7_5, note: 'quick IV — only the inner pair moves' },
|
||||
{ shape: SHELL7_6, note: '' }, { shape: SHELL7_6, note: '' },
|
||||
{ shape: SHELL7_5, note: '' }, { shape: SHELL7_5, note: '' },
|
||||
{ shape: SHELL7_6, note: '' }, { shape: SHELL7_6, note: '' },
|
||||
{ shape: SHELL7_5, note: 'V7' },
|
||||
{ shape: SHELL7_5, note: 'IV7' },
|
||||
{ shape: SHELL7_6, note: '' },
|
||||
{ shape: SHELL7_5, note: 'V7' },
|
||||
],
|
||||
tips: 'Three strings, two of them the chord-defining tritone. Drop the I7\'s inner pair one fret and you\'re already playing the IV7\'s guide tones — the quick change costs one finger.',
|
||||
},
|
||||
{
|
||||
label: 'Big barres, quick four',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: E_BARRE7, note: '' },
|
||||
{ shape: A_BARRE7, note: 'the quick change — bar 2' },
|
||||
{ shape: E_BARRE7, note: '' }, { shape: E_BARRE7, note: '' },
|
||||
{ shape: A_BARRE7, note: '' }, { shape: A_BARRE7, note: '' },
|
||||
{ shape: E_BARRE7, note: '' }, { shape: E_BARRE7, note: '' },
|
||||
{ shape: A_BARRE7, note: 'V7' },
|
||||
{ shape: A_BARRE7, note: 'IV7' },
|
||||
{ shape: E_BARRE7, note: '' },
|
||||
{ shape: A_BARRE7, note: 'V7' },
|
||||
],
|
||||
tips: 'Accent bar 2 slightly — telegraphing the quick change keeps the whole jam from splitting between the two 12-bar variants.',
|
||||
},
|
||||
],
|
||||
|
||||
'blues-8bar': [
|
||||
{
|
||||
label: 'Barres through the 8-bar form',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: E_BARRE7, note: 'I7' },
|
||||
{ shape: A_BARRE7, note: 'V7 already — count!' },
|
||||
{ shape: A_BARRE7, note: 'IV7' }, { shape: A_BARRE7, note: '' },
|
||||
{ shape: E_BARRE7, note: '' },
|
||||
{ shape: A_BARRE7, note: 'V7' },
|
||||
{ shape: E_BARRE7, note: '' },
|
||||
{ shape: A_BARRE7, note: 'V7 — turnaround' },
|
||||
],
|
||||
tips: 'Half the length, twice the changes per chorus. Lock the form before decorating it.',
|
||||
},
|
||||
{
|
||||
label: '9ths and 13ths, uptown',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: NINTH, extensions: ['9'], note: '' },
|
||||
{ shape: THIRTEEN, extensions: ['9', '13'], note: 'V13' },
|
||||
{ shape: NINTH, extensions: ['9'], note: 'IV9' }, { shape: NINTH, extensions: ['9'], note: '' },
|
||||
{ shape: NINTH, extensions: ['9'], note: '' },
|
||||
{ shape: THIRTEEN, extensions: ['9', '13'], note: '' },
|
||||
{ shape: NINTH, extensions: ['9'], note: '' },
|
||||
{ shape: THIRTEEN, extensions: ['9', '13'], note: '' },
|
||||
],
|
||||
tips: 'The Key-to-the-Highway feel is gentle — roll the chords instead of stabbing them, triplet feel even at medium tempo.',
|
||||
},
|
||||
],
|
||||
|
||||
'blues-minor': [
|
||||
{
|
||||
label: 'm7 barres with the ♯9 climax',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: MIN7_BARRE_6, note: 'i7' },
|
||||
{ shape: MIN7_BARRE_6, note: '' }, { shape: MIN7_BARRE_6, note: '' }, { shape: MIN7_BARRE_6, note: '' },
|
||||
{ shape: MIN7_BARRE_5, note: 'iv7' }, { shape: MIN7_BARRE_5, note: '' },
|
||||
{ shape: MIN7_BARRE_6, note: '' }, { shape: MIN7_BARRE_6, note: '' },
|
||||
{ shape: A_BARRE7, note: '♭VI7 — the drama bar' },
|
||||
{ shape: HENDRIX, extensions: ['#9'], note: 'V7♯9 — the slow-blues scream' },
|
||||
{ shape: MIN7_BARRE_6, note: '' }, { shape: MIN7_BARRE_6, note: '' },
|
||||
],
|
||||
tips: 'Save your dynamics for bars 9–10: the ♭VI7→V7♯9 half-step drop is the whole emotional payload of the form. Everything before it is patience.',
|
||||
},
|
||||
{
|
||||
label: 'Upper-register minor comping',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: MIN7_BARRE_5, note: 'i7 — A-string root, above the bass' },
|
||||
{ shape: MIN7_BARRE_5, note: '' }, { shape: MIN7_BARRE_5, note: '' }, { shape: MIN7_BARRE_5, note: '' },
|
||||
{ shape: SHELL_M7_5, note: 'iv7 — thin out, the singer is working' },
|
||||
{ shape: SHELL_M7_5, note: '' },
|
||||
{ shape: MIN7_BARRE_5, note: '' }, { shape: MIN7_BARRE_5, note: '' },
|
||||
{ shape: THIRTEEN_6, extensions: ['13'], note: '♭VI13' },
|
||||
{ shape: NINTH, extensions: ['9'], note: 'V9' },
|
||||
{ shape: MIN7_BARRE_5, note: '' }, { shape: SHELL_M7_5, note: 'fade to the turnaround' },
|
||||
],
|
||||
tips: 'Minor blues is usually slow — 12/8 triplet grid, rolled chords, and at least one full bar per chorus where you play nothing at all.',
|
||||
},
|
||||
],
|
||||
|
||||
'blues-turnaround': [
|
||||
{
|
||||
label: 'Shell cycle',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: SHELL7_6, note: 'I7' },
|
||||
{ shape: SHELL7_5, note: 'VI7' },
|
||||
{ shape: SHELL_M7_5, note: 'ii7' },
|
||||
{ shape: SHELL7_6, note: 'V7' },
|
||||
],
|
||||
tips: 'Often two beats per chord, not a bar — practise it at both speeds. The roots fall in fifths from the VI on, so the grips alternate strings on their own.',
|
||||
},
|
||||
{
|
||||
label: 'Uptown 9ths (T-Bone)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: NINTH, extensions: ['9'], note: 'I9' },
|
||||
{ shape: NINTH, extensions: ['9'], note: 'VI9' },
|
||||
{ shape: MIN7_BARRE_5, note: 'ii7' },
|
||||
{ shape: THIRTEEN, extensions: ['9', '13'], note: 'V13 — hold, then slide down a fret into the next chorus' },
|
||||
],
|
||||
tips: 'This is the Stormy Monday sound: every dominant becomes a 9th or 13th, approached chromatically. Roll them lazily on the triplet grid.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
improv: {
|
||||
scales: [
|
||||
{ over: 'I7', scale: 'mixolydian', why: 'Major pentatonic and Mixolydian shine over the I — the sweet B.B. King side of the coin.' },
|
||||
{ over: 'IV7', scale: 'mixolydian', why: 'Switch to minor pentatonic (or think the IV\'s own Mixolydian) when the IV arrives — the key\'s major 3rd clashes with its ♭7.' },
|
||||
{ over: 'V7', scale: 'mixolydian', why: 'Each chord gets its own Mixolydian; adjacent ones differ by one note, so really it\'s "follow the chord tones."' },
|
||||
{ over: 'i7 (minor blues)', scale: 'minor', why: 'Minor pentatonic + the natural 6 over the iv; harmonic minor colour over the V7.' },
|
||||
],
|
||||
targetNotes:
|
||||
'The 3rd of the current chord at every change is the whole game; hit the ♭7 of the I in bar 4 to announce the IV. The blues curl — a quarter-step bend of the ♭3 toward the major 3 — is the signature ornament.',
|
||||
licks: [
|
||||
{
|
||||
over: 'blues-12bar',
|
||||
description: 'Classic descending turnaround in E (Robert Johnson "Kind Hearted Woman" lineage): ♭7–6–♭6–5 under a high-E pedal, swung triplets, resolving to B7.',
|
||||
tab: 'e|--0---0---0---0--------2--\nB|--3---2---1---0--------0--\nG|-----------------------2--\nD|-----------------------1--\nA|-----------------------2--\nE|--------------------------\n D C# C B → B7',
|
||||
source: 'GuitarPlayer "Blues Turnarounds Pt 1"; Fundamental Changes "Blues Turnarounds for Guitar"',
|
||||
},
|
||||
{
|
||||
over: 'blues-12bar',
|
||||
description: 'B.B. King box lick in C: major-pentatonic box around frets 8–10 with the signature 2→3 whole-step bend (D bent to E, the 3rd of C7).',
|
||||
tab: 'e|--8--10b12--10--8---------------\nB|------------------10--8---------\nG|------------------------9-------\n C D→E D C A G E',
|
||||
source: 'GuitarPlayer "12 Killer Blues Licks"; Guitar World (B.B. box, R-2-4-5-6)',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default {
|
||||
id: 'blues',
|
||||
label: 'Blues',
|
||||
feel: 'shuffle',
|
||||
tempoRange: [60, 180],
|
||||
character: 'Dominant 7ths on every chord, swung eighths or 12/8 triplets, and the ♭3-against-3 tension that makes it talk — form is sacred, everything else is conversation.',
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
id: 'blues-12bar',
|
||||
name: 'Standard 12-bar',
|
||||
rn: ['I7', 'I7', 'I7', 'I7', 'IV7', 'IV7', 'I7', 'I7', 'V7', 'IV7', 'I7', 'V7'],
|
||||
degrees: [0, 0, 0, 0, 5, 5, 0, 0, 7, 5, 0, 7],
|
||||
qualities: ['dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7'],
|
||||
bars: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['Sweet Home Chicago', 'Pride and Joy — Stevie Ray Vaughan', 'Johnny B. Goode — Chuck Berry'],
|
||||
tip: 'Bar 12 is the V7 launching the next chorus — never let it resolve flat. The form is a wheel, and bar 12 is where you push it.',
|
||||
},
|
||||
{
|
||||
id: 'blues-quickchange',
|
||||
name: 'Quick-change 12-bar',
|
||||
rn: ['I7', 'IV7', 'I7', 'I7', 'IV7', 'IV7', 'I7', 'I7', 'V7', 'IV7', 'I7', 'V7'],
|
||||
degrees: [0, 5, 0, 0, 5, 5, 0, 0, 7, 5, 0, 7],
|
||||
qualities: ['dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7'],
|
||||
bars: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['Dust My Broom — Elmore James', 'Hide Away — Freddie King', 'Crossroads — Cream'],
|
||||
tip: 'One bar of IV in bar 2, then home. Listen for it in the first seconds of a jam — guessing wrong here is the most common train wreck in blues.',
|
||||
},
|
||||
{
|
||||
id: 'blues-8bar',
|
||||
name: '8-bar blues',
|
||||
rn: ['I7', 'V7', 'IV7', 'IV7', 'I7', 'V7', 'I7', 'V7'],
|
||||
degrees: [0, 7, 5, 5, 0, 7, 0, 7],
|
||||
qualities: ['dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7'],
|
||||
bars: [1, 1, 1, 1, 1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['Key to the Highway — Big Bill Broonzy', 'It Hurts Me Too — Elmore James'],
|
||||
tip: 'The V arrives in bar 2 — much sooner than a 12-bar. Count the form out loud the first chorus; 8-bar tunes wrong-foot 12-bar reflexes.',
|
||||
},
|
||||
{
|
||||
id: 'blues-minor',
|
||||
name: 'Minor blues',
|
||||
rn: ['i7', 'i7', 'i7', 'i7', 'iv7', 'iv7', 'i7', 'i7', '♭VI7', 'V7', 'i7', 'i7'],
|
||||
degrees: [0, 0, 0, 0, 5, 5, 0, 0, 8, 7, 0, 0],
|
||||
qualities: ['min7', 'min7', 'min7', 'min7', 'min7', 'min7', 'min7', 'min7', 'dom7', 'dom7', 'min7', 'min7'],
|
||||
bars: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||
mode: 'minor',
|
||||
songs: ['The Thrill Is Gone — B.B. King', 'As the Years Go Passing By — Albert King'],
|
||||
tip: 'Bars 9–10 are the whole drama: ♭VI sliding down a half-step to V7. (B.B.\'s recording makes the ♭VI a maj7 — try both colours.)',
|
||||
},
|
||||
{
|
||||
id: 'blues-turnaround',
|
||||
name: 'Turnaround cycle (I–VI–ii–V)',
|
||||
rn: ['I7', 'VI7', 'ii7', 'V7'],
|
||||
degrees: [0, 9, 2, 7],
|
||||
qualities: ['dom7', 'dom7', 'min7', 'dom7'],
|
||||
bars: [1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['Call It Stormy Monday — T-Bone Walker (intro)', 'jazz-blues bars 11–12 everywhere'],
|
||||
tip: 'The jazz handshake inside the blues — often squeezed into two bars (two beats per chord). Loop it as a vamp and you\'ve learned bars 11–12 of every uptown blues.',
|
||||
},
|
||||
]
|
||||
@@ -1,227 +0,0 @@
|
||||
// Bossa nova guitar pack. Grips verified by note-spelling against: jazzguitar.be
|
||||
// (Ipanema chords), jenslarsen.nl (bossa patterns, 5 levels), thejazzpianosite.com
|
||||
// (rhythm layers), Nelson Faria "The Brazilian Guitar Book" (canonical grip source),
|
||||
// mdecksmusic.com (Ipanema analysis), jazz-circle.com (Blue Bossa, Black Orpheus).
|
||||
//
|
||||
// Construction: thumb takes the root on string 6 or 5; fingers take 3-4 notes on
|
||||
// D-G-B(-e). Every grip movable. Two plays per progression = the two root-string
|
||||
// sets, because that's how bossa voice-leads: adjacent chords trade root strings
|
||||
// so inner voices move by one fret ("two fingers move, the chord transforms").
|
||||
|
||||
// Root on the low E string (thumb).
|
||||
const M7_6 = { rootStr: 6, offsets: [0, 'x', 0, 0, 0, 'x'], fingers: [1, 0, 2, 3, 4, 0] } // R-♭7-♭3-5
|
||||
const MAJ7_6 = { rootStr: 6, offsets: [0, 'x', 1, 1, 0, 'x'], fingers: [1, 0, 3, 4, 2, 0] } // R-7-3-5
|
||||
const DOM7_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, 0, 'x'], fingers: [1, 0, 2, 3, 4, 0] } // R-♭7-3-5
|
||||
const DOM13_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, 2, 'x'], fingers: [1, 0, 2, 3, 4, 0] } // R-♭7-3-13
|
||||
const DOM7B9_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, 0, 1], fingers: [1, 0, 2, 3, 1, 4] } // R-♭7-3-5-♭9
|
||||
const M7B5_6 = { rootStr: 6, offsets: [0, 'x', 0, 0, -1, 'x'], fingers: [2, 0, 3, 4, 1, 0] } // R-♭7-♭3-♭5
|
||||
const M6_6 = { rootStr: 6, offsets: [0, 'x', -1, 0, 0, 'x'], fingers: [2, 0, 1, 3, 4, 0] } // R-6-♭3-5
|
||||
const DIM7_6 = { rootStr: 6, offsets: [0, 'x', -1, 0, -1, 'x'], fingers: [2, 0, 1, 3, 1, 0] } // R-♭♭7-♭3-♭5
|
||||
const DOM7S11_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, -1, 'x'], fingers: [2, 0, 3, 4, 1, 0] } // R-♭7-3-♯11 (the tritone-sub grip)
|
||||
|
||||
// Root on the A string (thumb).
|
||||
const M7_5 = { rootStr: 5, offsets: ['x', 0, 2, 0, 1, 'x'], fingers: [0, 1, 3, 2, 4, 0] } // R-5-♭7-♭3
|
||||
const M7_5C = { rootStr: 5, offsets: ['x', 0, -2, 0, 1, 'x'], fingers: [0, 2, 1, 3, 4, 0] } // R-♭3-♭7-♭3 compact grab
|
||||
const MAJ9_5 = { rootStr: 5, offsets: ['x', 0, -1, 1, 0, 'x'], fingers: [0, 2, 1, 4, 3, 0] } // R-3-7-9
|
||||
const DOM9_5 = { rootStr: 5, offsets: ['x', 0, -1, 0, 0, 'x'], fingers: [0, 2, 1, 3, 4, 0] } // R-3-♭7-9
|
||||
const DOM7B9_5 = { rootStr: 5, offsets: ['x', 0, -1, 0, -1, 'x'], fingers: [0, 3, 1, 4, 2, 0] } // R-3-♭7-♭9
|
||||
const M7B5_5 = { rootStr: 5, offsets: ['x', 0, 1, 0, 1, 'x'], fingers: [0, 1, 3, 2, 4, 0] } // R-♭5-♭7-♭3
|
||||
const M6_5 = { rootStr: 5, offsets: ['x', 0, -2, -1, -2, 'x'], fingers: [0, 4, 1, 3, 2, 0] } // R-♭3-6-R
|
||||
const DIM7_5 = { rootStr: 5, offsets: ['x', 0, 1, -1, 1, 'x'], fingers: [0, 2, 3, 1, 4, 0] } // R-♭5-♭♭7-♭3
|
||||
|
||||
export default {
|
||||
styleIntro:
|
||||
'The bossa guitarist is the whole rhythm section: thumb plays the surdo drum (root on 1, fifth on 3, never syncopated), fingers play the chord block on the anticipations. Quiet is louder — the genre was invented at apartment volume, and intensity comes from rhythmic placement and harmonic colour, never from strumming harder.',
|
||||
|
||||
comping: [
|
||||
{
|
||||
label: 'Thumb bass (the surdo)',
|
||||
rhythm: 'B . . . B . . . — root on 1, fifth on 3',
|
||||
description: 'Metronomic, soft, every bar, under everything. The one layer that is never syncopated. With a bassist: drop it entirely and play only the upper notes.',
|
||||
},
|
||||
{
|
||||
label: 'One-bar starter pattern',
|
||||
rhythm: 'X . . X . . X . — hits on 1, and-of-2, 4',
|
||||
description: 'The training-wheels comp: chord block on 1, the and-of-2, and 4 over the steady thumb. Master this before the two-bar pattern.',
|
||||
},
|
||||
{
|
||||
label: 'Two-bar João Gilberto pattern',
|
||||
rhythm: 'X . . X . . . X~ | . . . X . . X . — the 4& ties over the barline',
|
||||
description: 'Bar 2 has no downbeat chord — the tied and-of-4 carries across. The anticipation is the hardest and most essential bossa skill. Gilberto drifted between patterns freely; treat it as a motif, not a loop.',
|
||||
},
|
||||
{
|
||||
label: 'Partido alto (the samba cousin — for contrast)',
|
||||
rhythm: '. X . X X . . X — lands HARD on beat 3',
|
||||
description: 'Percussive, chopped, with muted ghost-strums — the opposite aesthetic. Bossa never accents beat 3: that beat belongs to the bass register (the surdo). Hammering it squares the lilt into a polka.',
|
||||
},
|
||||
],
|
||||
|
||||
plays: {
|
||||
'bossa-ipanema': [
|
||||
{
|
||||
label: 'Low-E roots (the João position)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: MAJ7_6, note: 'Imaj7' },
|
||||
{ shape: DOM13_6, extensions: ['13'], note: 'II7(13) — the Lydian ♭7 colour' },
|
||||
{ shape: M7_6, note: 'ii7' },
|
||||
{ shape: DOM7S11_6, extensions: ['#11'], note: '♭II7(♯11) — tritone sub of V' },
|
||||
{ shape: MAJ7_6, note: 'home' },
|
||||
{ shape: DOM7S11_6, extensions: ['#11'], note: 'and the ♭II7 again — Jobim never quite lets go' },
|
||||
],
|
||||
tips: 'The whole A-section lives in a four-fret window: each change moves the thumb a fret or two and one or two fingers inside the grip. If a finger jumps more than two frets, you took a wrong turn.',
|
||||
},
|
||||
{
|
||||
label: 'A-string roots, colour-tone set',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: MAJ9_5, extensions: ['9'], note: 'Imaj9' },
|
||||
{ shape: DOM9_5, extensions: ['9'], note: 'II9' },
|
||||
{ shape: M7_5, note: 'ii7' },
|
||||
{ shape: DOM9_5, extensions: ['9'], note: '♭II9' },
|
||||
{ shape: MAJ9_5, extensions: ['9'], note: '' },
|
||||
{ shape: DOM9_5, extensions: ['9'], note: '' },
|
||||
],
|
||||
tips: 'Same progression, one string set higher and sweeter — 9ths everywhere. Use this set when another guitarist or pianist already owns the low-E register. (If the band plays a plain major tonic, the 6/9 grab — drop the 7th for the 6 — is the classic bossa colour.)',
|
||||
},
|
||||
],
|
||||
|
||||
'bossa-minor-251': [
|
||||
{
|
||||
label: 'Low-E roots with the ♭9',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M7_6, note: 'i7' },
|
||||
{ shape: M7B5_6, note: 'iiø7 — the ♭5 on the B string is the saudade note' },
|
||||
{ shape: DOM7B9_6, extensions: ['b9'], note: 'V7♭9 — the ♭5 you just played, reinterpreted' },
|
||||
{ shape: M6_6, note: 'i6 — resolve to the sixth, not the seventh' },
|
||||
],
|
||||
tips: 'One pitch threads the middle of the progression: the iiø7\'s ♭5 IS the V7\'s ♭9. Find it, hold it, let the thumb do the moving.',
|
||||
},
|
||||
{
|
||||
label: 'A-string roots, compact grabs',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M7_5C, note: 'compact i7 — no 5th, pure bossa economy' },
|
||||
{ shape: M7B5_5, note: 'iiø7' },
|
||||
{ shape: DOM7B9_5, extensions: ['b9'], note: 'V7♭9' },
|
||||
{ shape: M6_5, note: 'i6' },
|
||||
],
|
||||
tips: 'Black Orpheus oscillates between this cell and the relative major\'s ii–V–I — learn both as one hand pattern and the whole tune is two moves.',
|
||||
},
|
||||
],
|
||||
|
||||
'bossa-blue': [
|
||||
{
|
||||
label: 'Thumb-bass through the form',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M7_6, note: 'i7' }, { shape: M7_6, note: '' },
|
||||
{ shape: M7_5, note: 'iv7 — A-string root, same fret region' }, { shape: M7_5, note: '' },
|
||||
{ shape: M7B5_5, note: 'iiø7' },
|
||||
{ shape: DOM7B9_6, extensions: ['b9'], note: 'V7♭9' },
|
||||
{ shape: M7_6, note: '' }, { shape: M7_6, note: '' },
|
||||
{ shape: M7_6, note: '♭iii7 — the excursion begins' },
|
||||
{ shape: DOM9_5, extensions: ['9'], note: '♭VI9' },
|
||||
{ shape: MAJ9_5, extensions: ['9'], note: '♭IImaj9 — a major-key vacation' },
|
||||
{ shape: MAJ9_5, extensions: ['9'], note: '' },
|
||||
{ shape: M7B5_5, note: 'iiø7 — back to reality' },
|
||||
{ shape: DOM7B9_6, extensions: ['b9'], note: 'V7♭9' },
|
||||
{ shape: M7_6, note: '' }, { shape: M7_6, note: '' },
|
||||
],
|
||||
tips: 'i and iv sit on adjacent root strings in one position, like a blues. The bars 9–12 excursion is a normal major ii–V–I — play it sweeter, then darken again for the iiø7.',
|
||||
},
|
||||
{
|
||||
label: 'Colour set (9ths and the 6/9 cadence)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M7_5C, note: '' }, { shape: M7_5C, note: '' },
|
||||
{ shape: M7_6, note: 'iv7 low' }, { shape: M7_6, note: '' },
|
||||
{ shape: M7B5_6, note: '' },
|
||||
{ shape: DOM7B9_5, extensions: ['b9'], note: '' },
|
||||
{ shape: M7_5C, note: '' }, { shape: M7_5C, note: '' },
|
||||
{ shape: M7_5C, note: '' },
|
||||
{ shape: DOM13_6, extensions: ['13'], note: '♭VI13' },
|
||||
{ shape: MAJ9_5, extensions: ['9'], note: '♭IImaj9' },
|
||||
{ shape: MAJ9_5, extensions: ['9'], note: '' },
|
||||
{ shape: M7B5_6, note: '' },
|
||||
{ shape: DOM7B9_5, extensions: ['b9'], note: '' },
|
||||
{ shape: M7_5C, note: '' }, { shape: M7_5C, note: '' },
|
||||
],
|
||||
tips: 'Blue Bossa\'s tonic is a true m7 — save the m6 colour for tunes that ask for it (see the minor ii–V–i cell). Keep all of it at whisper volume.',
|
||||
},
|
||||
],
|
||||
|
||||
'bossa-one-note': [
|
||||
{
|
||||
label: 'Two grips falling by half-steps (A-string roots)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M7_5, note: 'iii7' },
|
||||
{ shape: DOM9_5, extensions: ['9'], note: '♭III9 — same fret region, one finger reshapes' },
|
||||
{ shape: M7_5, note: 'ii7 — whole grip slides down' },
|
||||
{ shape: DOM9_5, extensions: ['9'], note: '♭II9' },
|
||||
],
|
||||
tips: 'The entire progression is two grips alternating while the thumb walks down chromatically. Hold one melody note on top if you can reach it — that\'s the whole point of the tune.',
|
||||
},
|
||||
{
|
||||
label: 'Low-E roots with 13s',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M7_6, note: '' },
|
||||
{ shape: DOM13_6, extensions: ['13'], note: '♭III13' },
|
||||
{ shape: M7_6, note: '' },
|
||||
{ shape: DOM13_6, extensions: ['13'], note: '♭II13' },
|
||||
],
|
||||
tips: 'The 13 on top of each dominant descends in parallel with the bass — two chromatic lines moving in lockstep, which is why this progression sounds inevitable.',
|
||||
},
|
||||
],
|
||||
|
||||
'bossa-corcovado': [
|
||||
{
|
||||
label: 'Chromatic staircase, low-E roots',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M6_6, note: 'iii6' },
|
||||
{ shape: DIM7_6, note: 'passing °7 — one fret down' },
|
||||
{ shape: M7_6, note: 'ii7 — one more' },
|
||||
{ shape: DOM7S11_6, extensions: ['#11'], note: '♭II7(♯11) — tritone sub' },
|
||||
{ shape: MAJ7_6, note: 'Imaj7 — arrival' },
|
||||
],
|
||||
tips: 'The low E string walks down one fret per bar — let that bassline sing through the grips. This is the same passing-diminished device as How Insensitive; learn it once, hear it everywhere in Jobim.',
|
||||
},
|
||||
{
|
||||
label: 'A-string set with the 6/9 landing',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M6_5, note: 'iii6' },
|
||||
{ shape: DIM7_5, note: '°7' },
|
||||
{ shape: M7_5C, note: 'ii7' },
|
||||
{ shape: DOM7B9_5, extensions: ['b9'], note: '♭II7(♭9)' },
|
||||
{ shape: MAJ9_5, extensions: ['9'], note: 'Imaj9 — arrival' },
|
||||
],
|
||||
tips: 'When the singer holds the tonic, swap the maj9 for a 6/9 grab (7th down to the 6) — no leading tone to fight them. Over a detected maj7, stay with the maj9.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
improv: {
|
||||
scales: [
|
||||
{ over: 'ii7 / i7 / iv7', scale: 'dorian', why: 'All the minor 7ths take Dorian — bossa is jazz harmony in a swimsuit.' },
|
||||
{ over: 'V7 → major I', scale: 'mixolydian', why: 'Plain Mixolydian when resolving to major; add the 13 — it\'s the genre\'s favourite colour.' },
|
||||
{ over: 'II7 / ♭II7 (tritone subs)', scale: 'lydian', why: 'Lydian dominant (melodic minor from the 5th) — the ♯11 is already in the chord grip.' },
|
||||
{ over: 'V7♭9 → minor i', scale: 'phrygian', why: 'Phrygian dominant (harmonic minor from the V) for the ♭9; the altered scale if you want more trouble.' },
|
||||
{ over: 'iiø7', scale: 'locrian', why: 'Locrian, or raise the 2 (melodic-minor mode 6) for a smoother colour.' },
|
||||
],
|
||||
targetNotes:
|
||||
'Bossa solos are melody-first: hold or repeat a small cell and let the CHORDS recontextualise it — One Note Samba is the method stated as a song title. Target the colour tones (9, 13, ♯11) and the 3rd/7th guide-tone line; avoid sitting on roots. Phrase behind the beat and leave bar-length gaps.',
|
||||
licks: [
|
||||
{
|
||||
over: 'bossa-ipanema',
|
||||
description: 'The Ipanema opening cell: the melody sits on the 9th and major 7th of the Imaj7 — never the root. The identical two notes work over the II7 bars, where they become root and 13.',
|
||||
tab: 'e|--3--------------3-----------\nB|------5--5--3--------5--5----\n G E E D G E E\n (9) (7)(7)(6) (9) (7)(7) over Fmaj7',
|
||||
source: '"Garota de Ipanema" — Jobim/de Moraes (Real Book lead sheet; mDecks harmonic analysis)',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default {
|
||||
id: 'bossa',
|
||||
label: 'Bossa Nova',
|
||||
feel: 'bossa',
|
||||
tempoRange: [110, 160],
|
||||
character: 'A whole samba band condensed into one quiet guitar: metronomic thumb bass, syncopated chord block that never accents beat 3, and jazz harmony at conversation volume.',
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
id: 'bossa-ipanema',
|
||||
name: 'Ipanema A-section (Imaj7–II7–ii7–♭II7)',
|
||||
rn: ['Imaj7', 'II7', 'ii7', '♭II7', 'Imaj7', '♭II7'],
|
||||
degrees: [0, 2, 2, 1, 0, 1],
|
||||
qualities: ['maj7', 'dom7', 'min7', 'dom7', 'maj7', 'dom7'],
|
||||
bars: [2, 2, 1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['The Girl from Ipanema — Jobim', 'Desafinado (same II7 colour)'],
|
||||
tip: 'The II7 is a true dominant-II (Lydian ♭7 colour), and the ♭II7 is the tritone sub of V — the bass slides home by half-step instead of jumping a fifth.',
|
||||
},
|
||||
{
|
||||
id: 'bossa-minor-251',
|
||||
name: 'Minor ii–V–i (Black Orpheus)',
|
||||
rn: ['i7', 'iiø7', 'V7♭9', 'i6'],
|
||||
degrees: [0, 2, 7, 0],
|
||||
qualities: ['min7', 'half_dim', 'dom7', 'min6'],
|
||||
bars: [1, 1, 1, 1],
|
||||
mode: 'minor',
|
||||
songs: ['Manhã de Carnaval (Black Orpheus) — Luiz Bonfá', 'How Insensitive — Jobim (same engine)'],
|
||||
tip: 'The bossa minor tonic is the m6, not the m7 — that major sixth is the saudade. The ♭5 of the iiø7 returns as the ♭9 of the V7.',
|
||||
},
|
||||
{
|
||||
id: 'bossa-blue',
|
||||
name: 'Blue Bossa (16-bar form)',
|
||||
rn: ['i7', 'i7', 'iv7', 'iv7', 'iiø7', 'V7', 'i7', 'i7', '♭iii7', '♭VI7', '♭IImaj7', '♭IImaj7', 'iiø7', 'V7', 'i7', 'i7'],
|
||||
degrees: [0, 0, 5, 5, 2, 7, 0, 0, 3, 8, 1, 1, 2, 7, 0, 0],
|
||||
qualities: ['min7', 'min7', 'min7', 'min7', 'half_dim', 'dom7', 'min7', 'min7', 'min7', 'dom7', 'maj7', 'maj7', 'half_dim', 'dom7', 'min7', 'min7'],
|
||||
bars: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||
mode: 'minor',
|
||||
songs: ['Blue Bossa — Kenny Dorham'],
|
||||
tip: 'i–iv–iiø–V in minor, then a complete ii–V–I excursion a half-step up (♭II major) in bars 9–12. The jam-session bossa: everyone knows it, nobody minds another chorus.',
|
||||
},
|
||||
{
|
||||
id: 'bossa-one-note',
|
||||
name: 'Chromatic ii–V chain (One Note Samba)',
|
||||
rn: ['iii7', '♭III7', 'ii7', '♭II7'],
|
||||
degrees: [4, 3, 2, 1],
|
||||
qualities: ['min7', 'dom7', 'min7', 'dom7'],
|
||||
bars: [1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['One Note Samba — Jobim'],
|
||||
tip: 'iii–VI–ii–V with both dominants tritone-subbed: the bass walks straight down by half-steps while one melody note holds on top. Two grips, alternating, falling.',
|
||||
},
|
||||
{
|
||||
id: 'bossa-corcovado',
|
||||
name: 'Corcovado descent',
|
||||
rn: ['iii6', '♭iii°7', 'ii7', '♭II7', 'Imaj7'],
|
||||
degrees: [4, 3, 2, 1, 0],
|
||||
qualities: ['min6', 'dim7', 'min7', 'dom7', 'maj7'],
|
||||
bars: [1, 1, 1, 1, 2],
|
||||
mode: 'major',
|
||||
songs: ['Corcovado (Quiet Nights) — Jobim', 'How Insensitive — Jobim (same passing-dim device)'],
|
||||
tip: 'A chromatic staircase into the I: m6 → passing diminished → ii → tritone sub. (Charts vary on bar 2 — some write a II7(13) instead of the ♭iii°7; both are in circulation.)',
|
||||
},
|
||||
]
|
||||
@@ -1,230 +0,0 @@
|
||||
// Country/folk guitar pack. Shapes verified by note-spelling against:
|
||||
// acousticguitar.com (boom-chuck, bass runs, Travis picking), fretjam.com
|
||||
// (alternate bass map), hvbluegrass.org (the Lester Flatt G-run), premierguitar.com
|
||||
// (double-stops), wernickmethod.org & drbanjo.com (jam etiquette), andyguitar.co.uk
|
||||
// (train beat), untidymusic.com (anchor-finger folk chords).
|
||||
|
||||
// Open shapes — the genre's home. Render only when the chord root matches.
|
||||
const OPEN_G_FOLK = { onlyRoot: 7, frets: [3, 2, 0, 0, 3, 3], fingers: [2, 1, 0, 0, 3, 4] } // big ringing folk G
|
||||
const OPEN_C = { onlyRoot: 0, frets: ['x', 3, 2, 0, 1, 0], fingers: [0, 3, 2, 0, 1, 0] }
|
||||
const OPEN_CADD9 = { onlyRoot: 0, frets: ['x', 3, 2, 0, 3, 3], fingers: [0, 2, 1, 0, 3, 4] }
|
||||
const OPEN_D = { onlyRoot: 2, frets: ['x', 'x', 0, 2, 3, 2], fingers: [0, 0, 0, 1, 3, 2] }
|
||||
const OPEN_D7 = { onlyRoot: 2, frets: ['x', 'x', 0, 2, 1, 2], fingers: [0, 0, 0, 2, 1, 3] }
|
||||
const OPEN_A7 = { onlyRoot: 9, frets: ['x', 0, 2, 0, 2, 0], fingers: [0, 0, 1, 0, 2, 0] }
|
||||
const OPEN_G7 = { onlyRoot: 7, frets: [3, 2, 0, 0, 0, 1], fingers: [3, 2, 0, 0, 0, 1] }
|
||||
const OPEN_E = { onlyRoot: 4, frets: [0, 2, 2, 1, 0, 0], fingers: [0, 2, 3, 1, 0, 0] }
|
||||
const OPEN_EM7 = { onlyRoot: 4, frets: [0, 2, 2, 0, 3, 3], fingers: [0, 1, 2, 0, 3, 4] }
|
||||
const OPEN_AM = { onlyRoot: 9, frets: ['x', 0, 2, 2, 1, 0], fingers: [0, 0, 2, 3, 1, 0] }
|
||||
const OPEN_F = { onlyRoot: 5, frets: ['x', 'x', 3, 2, 1, 1], fingers: [0, 0, 3, 2, 1, 1] }
|
||||
|
||||
// Movable barres — for when the capo can't save you.
|
||||
const BARRE_MAJ_6 = { rootStr: 6, offsets: [0, 2, 2, 1, 0, 0], fingers: [1, 3, 4, 2, 1, 1] }
|
||||
const BARRE_MAJ_5 = { rootStr: 5, offsets: ['x', 0, 2, 2, 2, 0], fingers: [0, 1, 2, 3, 4, 1] }
|
||||
const BARRE_MIN_6 = { rootStr: 6, offsets: [0, 2, 2, 0, 0, 0], fingers: [1, 3, 4, 1, 1, 1] }
|
||||
const BARRE_DOM7_6 = { rootStr: 6, offsets: [0, 2, 0, 1, 0, 0], fingers: [1, 3, 1, 2, 1, 1] }
|
||||
const BARRE_DOM7_5 = { rootStr: 5, offsets: ['x', 0, 2, 0, 2, 0], fingers: [0, 1, 3, 1, 4, 1] }
|
||||
|
||||
export default {
|
||||
styleIntro:
|
||||
'In a folk circle you accompany the singer; in a bluegrass jam you ARE the drums — bass notes on 1 and 3 (kick), crisp strums on 2 and 4 (snare), bass runs announcing every change, and the capo moving your open shapes to whatever key the singer holds up fingers for.',
|
||||
|
||||
comping: [
|
||||
{
|
||||
label: 'Boom-chick (alternating bass)',
|
||||
rhythm: 'B . X . B . X . — root bass, strum, 5th bass, strum',
|
||||
description: 'The engine: picked bass note (root on 1, fifth on 3 — each open chord has its alternation map), down-strum chick between. Add an up-strum after each chick for "boom chick-a".',
|
||||
},
|
||||
{
|
||||
label: 'Carter bass runs',
|
||||
rhythm: 'runs replace beats 3–4 before a change',
|
||||
description: 'G→C: walk G–A–B into the C. C→G: walk back down. G→D: G–A–B–C♯. The run tells the whole circle the change is coming — in bluegrass it\'s practically mandatory.',
|
||||
},
|
||||
{
|
||||
label: 'Travis picking',
|
||||
rhythm: 'thumb: steady quarters on alternating bass; fingers: syncopated treble',
|
||||
description: 'Thumb never stops (the §boom-chick map), index/middle pick G and B strings between, pinch on beat 1. Freight Train is the curriculum.',
|
||||
},
|
||||
{
|
||||
label: 'Train beat (Cash)',
|
||||
rhythm: 'D D U D U D U with accents on 2 & 4, half-muted',
|
||||
description: 'Strings damped just enough to fake a snare; keep the boom note clean, mute only the chicks. Folsom Prison at any tempo.',
|
||||
},
|
||||
{
|
||||
label: 'Waltz boom-chick-chick',
|
||||
rhythm: '3/4: B X X — bass, strum, strum',
|
||||
description: 'Bass note on 1, two strums after, alternating root/5th by bar. Tennessee Waltz, Amazing Grace — every jam has them.',
|
||||
},
|
||||
],
|
||||
|
||||
plays: {
|
||||
'country-145': [
|
||||
{
|
||||
label: 'Open G-family, boom-chick',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: OPEN_G_FOLK, note: 'I — bass alternates low-E G / open-D D' },
|
||||
{ shape: OPEN_G_FOLK, note: '' },
|
||||
{ shape: OPEN_C, extensions: [], note: 'IV — bass: A-string C / low-E G' },
|
||||
{ shape: OPEN_G_FOLK, note: 'walk back down C–B–A–G' },
|
||||
{ shape: OPEN_G_FOLK, note: '' },
|
||||
{ shape: OPEN_G_FOLK, note: '' },
|
||||
{ shape: OPEN_D7, note: 'V7 — bass: open D / open A' },
|
||||
{ shape: OPEN_G_FOLK, note: 'home, G-run on the phrase end' },
|
||||
],
|
||||
tips: 'This is the key-of-G home position; for the singer\'s key, move the capo, not the shapes (A = capo 2, B♭ = capo 3, B = capo 4 — the G-run survives the capo, a barre kills it).',
|
||||
},
|
||||
{
|
||||
label: 'Barre shapes (capo-proof)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: BARRE_MAJ_6, note: 'I' },
|
||||
{ shape: BARRE_MAJ_6, note: '' },
|
||||
{ shape: BARRE_MAJ_5, note: 'IV — same fret, next string' },
|
||||
{ shape: BARRE_MAJ_6, note: '' },
|
||||
{ shape: BARRE_MAJ_6, note: '' },
|
||||
{ shape: BARRE_MAJ_6, note: '' },
|
||||
{ shape: BARRE_DOM7_5, note: 'V7 — two frets up from the IV' },
|
||||
{ shape: BARRE_MAJ_6, note: '' },
|
||||
],
|
||||
tips: 'For keys where no capo position gives you open strings. You lose the ringing folk voice — keep the alternating-bass right hand so you don\'t lose the genre.',
|
||||
},
|
||||
],
|
||||
|
||||
'country-folk-axis': [
|
||||
{
|
||||
label: 'Anchor-finger folk set',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: OPEN_G_FOLK, note: 'I — ring+pinky stay planted on the top two strings' },
|
||||
{ shape: OPEN_D, note: 'V' },
|
||||
{ shape: OPEN_EM7, extensions: ['b7'], note: 'vi as Em7 — two fingers move, anchors hold' },
|
||||
{ shape: OPEN_CADD9, extensions: ['9'], note: 'IV as Cadd9 — same anchors again' },
|
||||
{ shape: OPEN_G_FOLK, note: '' },
|
||||
{ shape: OPEN_D, note: '' },
|
||||
{ shape: OPEN_CADD9, extensions: ['9'], note: '' },
|
||||
{ shape: OPEN_CADD9, extensions: ['9'], note: 'two bars of IV — let it ring' },
|
||||
],
|
||||
tips: 'The modern-folk G-family sound: the top two strings drone through every chord while two fingers do the changes. Wagon Wheel is capo 2 with exactly these grips.',
|
||||
},
|
||||
{
|
||||
label: 'Barre version',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: BARRE_MAJ_6, note: 'I' },
|
||||
{ shape: BARRE_MAJ_5, note: 'V' },
|
||||
{ shape: BARRE_MIN_6, note: 'vi' },
|
||||
{ shape: BARRE_MAJ_5, note: 'IV' },
|
||||
{ shape: BARRE_MAJ_6, note: '' },
|
||||
{ shape: BARRE_MAJ_5, note: '' },
|
||||
{ shape: BARRE_MAJ_5, note: '' },
|
||||
{ shape: BARRE_MAJ_5, note: '' },
|
||||
],
|
||||
tips: 'When the song lands in a capo-hostile key. Lighten the left hand between strums — folk barres should breathe, not sustain like rock.',
|
||||
},
|
||||
],
|
||||
|
||||
'country-ragtime': [
|
||||
{
|
||||
label: 'Open C-family (Alice\'s Restaurant grips)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: OPEN_C, note: 'I' },
|
||||
{ shape: OPEN_A7, note: 'VI7 — the ragtime surprise' },
|
||||
{ shape: OPEN_D7, note: 'II7' },
|
||||
{ shape: OPEN_G7, note: 'V7 — and the sled arrives home' },
|
||||
],
|
||||
tips: 'Each dominant pulls into the next — lean on the bass notes (C→A→D→G is itself a circle of fifths) and the progression plays itself. Capo 2 = the Alice\'s Restaurant recording.',
|
||||
},
|
||||
{
|
||||
label: 'Barre circle',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: BARRE_MAJ_6, note: 'I' },
|
||||
{ shape: BARRE_DOM7_5, note: 'VI7' },
|
||||
{ shape: BARRE_DOM7_6, note: 'II7' },
|
||||
{ shape: BARRE_DOM7_5, note: 'V7' },
|
||||
],
|
||||
tips: 'Roots alternate 6th and 5th strings around the circle, so the hand barely travels. Swing the strums — this family is ragtime\'s grandchild.',
|
||||
},
|
||||
],
|
||||
|
||||
'country-rising-sun': [
|
||||
{
|
||||
label: 'The Animals grips (6/8 arpeggios)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: OPEN_AM, note: 'i — arpeggiate low to high, one sweep per bar' },
|
||||
{ shape: OPEN_C, note: 'III' },
|
||||
{ shape: OPEN_D, note: 'IV — the borrowed Dorian colour' },
|
||||
{ shape: OPEN_F, note: 'VI' },
|
||||
{ shape: OPEN_AM, note: '' },
|
||||
{ shape: OPEN_E, note: 'V — the harmonic-minor pull home' },
|
||||
],
|
||||
tips: 'Six chords, one arpeggio pattern: bass note then climb the strings in 6/8. The D major is the chord that makes it haunting — don\'t flatten it to Dm.',
|
||||
},
|
||||
{
|
||||
label: 'Barre version',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: BARRE_MIN_6, note: 'i' },
|
||||
{ shape: BARRE_MAJ_6, note: 'III' },
|
||||
{ shape: BARRE_MAJ_5, note: 'IV' },
|
||||
{ shape: BARRE_MAJ_6, note: 'VI' },
|
||||
{ shape: BARRE_MIN_6, note: '' },
|
||||
{ shape: BARRE_MAJ_6, note: 'V' },
|
||||
],
|
||||
tips: 'Keeps the climb available in any key — arpeggiate the barres rather than strumming them or the 6/8 lilt disappears.',
|
||||
},
|
||||
],
|
||||
|
||||
'country-bluegrass-cycle': [
|
||||
{
|
||||
label: 'G shapes, jam-circle standard',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: OPEN_G_FOLK, note: 'I' },
|
||||
{ shape: OPEN_C, note: 'IV — walk up G–A–B into it' },
|
||||
{ shape: OPEN_G_FOLK, note: 'walk back down' },
|
||||
{ shape: OPEN_D, note: 'V — chromatic walk G–A–B–C♯ if you\'re feeling it' },
|
||||
],
|
||||
tips: 'Bluegrass keys are called in fiddle terms: A = capo 2, B = capo 4, all G shapes. Bass on 1 & 3 locks with the upright; your 2 & 4 strums ARE the snare — there is no drummer.',
|
||||
},
|
||||
{
|
||||
label: 'C shapes (for keys C, D via capo)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: OPEN_C, note: 'I' },
|
||||
{ shape: OPEN_F, note: 'IV — small F, top four strings' },
|
||||
{ shape: OPEN_C, note: '' },
|
||||
{ shape: OPEN_G7, extensions: ['b7'], note: 'V played as V7 — the bluegrass default' },
|
||||
],
|
||||
tips: 'The C family gives different bass runs (C–D–E into F) — worth owning both families so the capo choice is about the singer, not your habits.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
improv: {
|
||||
scales: [
|
||||
{ over: 'I / major vamps', scale: 'major', why: 'Major pentatonic is the country default — the Don Rich/Buck Owens sweetness.' },
|
||||
{ over: 'I with attitude', scale: 'mixolydian', why: 'The "country composite": major pentatonic + the ♭3 blue note, always resolved up to the major 3rd.' },
|
||||
{ over: 'i (minor folk)', scale: 'minor', why: 'Natural minor with the harmonic-minor leading tone saved for when the V chord arrives.' },
|
||||
{ over: 'V7 / II7 (ragtime circle)', scale: 'mixolydian', why: 'Each dominant gets its own Mixolydian; target the 3rd of each as the circle turns.' },
|
||||
],
|
||||
targetNotes:
|
||||
'Country fills are double-stops: 3rds on the G+B pair, 6ths on G+e (hybrid-picked for the snap), slid or hammered into chord tones on the beat. End phrases with the G-run — it is the genre\'s punctuation mark.',
|
||||
licks: [
|
||||
{
|
||||
over: 'country-bluegrass-cycle',
|
||||
description: 'THE G-run (Lester Flatt): the canonical bluegrass phrase-ending tag — hammer through the blue note and land on the open G chord on the downbeat.',
|
||||
tab: 'e|----------------------------3--\nB|----------------------------0--\nG|---------------------0------0--\nD|---------------0--2---------0--\nA|--0--h1--2------------------2--\nE|----------------------------3--\n A A# B D E G (G chord on 1)',
|
||||
source: 'hvbluegrass.org "The Truth About the Lester Flatt G Run"; artistworks.com essential bluegrass licks',
|
||||
},
|
||||
{
|
||||
over: 'country-bluegrass-cycle',
|
||||
description: 'The original two-note Flatt run for flying tempos: E up to G at the phrase end — "an exclamation point at the end of a paragraph."',
|
||||
tab: 'G|--------0-- (open G)\nD|--2-------- (E)',
|
||||
source: 'hvbluegrass.org / nativeground.com (Flatt & Scruggs history)',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default {
|
||||
id: 'country',
|
||||
label: 'Country / Folk',
|
||||
feel: 'boom-chick',
|
||||
tempoRange: [80, 160],
|
||||
character: 'Open strings, alternating bass, and the capo as a transposition machine: the guitar is the band\'s kick drum and snare, and every chord change gets announced by a bass run.',
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
id: 'country-145',
|
||||
name: 'Country I–IV–V (8-bar form)',
|
||||
rn: ['I', 'I', 'IV', 'I', 'I', 'I', 'V7', 'I'],
|
||||
degrees: [0, 0, 5, 0, 0, 0, 7, 0],
|
||||
qualities: ['maj', 'maj', 'maj', 'maj', 'maj', 'maj', 'dom7', 'maj'],
|
||||
bars: [1, 1, 1, 1, 1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['Will the Circle Be Unbroken', 'Ring of Fire — Johnny Cash'],
|
||||
tip: 'The placement is the convention: IV arrives mid-phrase, V7 on the last line pulling home. Learn where the changes BREATHE, not just what they are.',
|
||||
},
|
||||
{
|
||||
id: 'country-folk-axis',
|
||||
name: 'Folk axis (Wagon Wheel loop)',
|
||||
rn: ['I', 'V', 'vi', 'IV', 'I', 'V', 'IV', 'IV'],
|
||||
degrees: [0, 7, 9, 5, 0, 7, 5, 5],
|
||||
qualities: ['maj', 'maj', 'min', 'maj', 'maj', 'maj', 'maj', 'maj'],
|
||||
bars: [1, 1, 1, 1, 1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['Wagon Wheel — Old Crow Medicine Show (G shapes, capo 2)'],
|
||||
tip: 'The pop axis loop in folk clothes — note the second half lands on IV–IV instead of vi–IV. Capo 2 with G shapes is the canonical version.',
|
||||
},
|
||||
{
|
||||
id: 'country-ragtime',
|
||||
name: 'Ragtime circle (I–VI7–II7–V7)',
|
||||
rn: ['I', 'VI7', 'II7', 'V7'],
|
||||
degrees: [0, 9, 2, 7],
|
||||
qualities: ['maj', 'dom7', 'dom7', 'dom7'],
|
||||
bars: [2, 2, 2, 2],
|
||||
mode: 'major',
|
||||
songs: ['Salty Dog Blues — Flatt & Scruggs', "Alice's Restaurant — Arlo Guthrie", 'Tennessee Waltz (bridge)'],
|
||||
tip: 'Every chord is the dominant of the next — a circle-of-fifths sled ride home. The truncated I–II7–V–I ("Hey, Good Lookin\'") is the same device minus the VI7.',
|
||||
},
|
||||
{
|
||||
id: 'country-rising-sun',
|
||||
name: 'Rising Sun (6/8 minor climb)',
|
||||
rn: ['i', 'III', 'IV', 'VI', 'i', 'V'],
|
||||
degrees: [0, 3, 5, 8, 0, 7],
|
||||
qualities: ['min', 'maj', 'maj', 'maj', 'min', 'maj'],
|
||||
bars: [1, 1, 1, 1, 1, 1],
|
||||
mode: 'minor',
|
||||
songs: ['House of the Rising Sun — The Animals'],
|
||||
tip: 'Not the Andalusian descent people assume: it CLIMBS, and the major IV is borrowed Dorian colour. 6/8 time, arpeggiated, every chord one sweep of the arm.',
|
||||
},
|
||||
{
|
||||
id: 'country-bluegrass-cycle',
|
||||
name: 'Bluegrass cycle (I–IV–I–V)',
|
||||
rn: ['I', 'IV', 'I', 'V'],
|
||||
degrees: [0, 5, 0, 7],
|
||||
qualities: ['maj', 'maj', 'maj', 'maj'],
|
||||
bars: [1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['Nine Pound Hammer — Merle Travis'],
|
||||
tip: 'The jam-circle workhorse: back to I between every excursion. Kick it off with the last line of the chorus, end every phrase with a G-run.',
|
||||
},
|
||||
]
|
||||
@@ -1,188 +0,0 @@
|
||||
// Funk guitar pack. Shapes verified by note-spelling against: justinguitar.com
|
||||
// (E9 "the funk chord"), fundamental-changes.com (funk chords, JB E9-D9 accents),
|
||||
// yourguitaracademy.com (Sex Machine pattern), musicradar.com (Nile Rodgers grips),
|
||||
// ethanhein.com (Chameleon Dorian analysis), Wikipedia (Jimmy Nolen chicken scratch).
|
||||
|
||||
// 5th-string-root colour chords — the Nolen vocabulary.
|
||||
const NINE = { rootStr: 5, offsets: ['x', 0, -1, 0, 0, 0], fingers: [0, 2, 1, 3, 3, 3] } // R-3-♭7-9-5: THE funk chord
|
||||
const THIRTEEN = { rootStr: 5, offsets: ['x', 0, -1, 0, 0, 2], fingers: [0, 2, 1, 3, 3, 4] } // 9 grip, pinky takes 5→13
|
||||
const NINESUS = { rootStr: 5, offsets: ['x', 0, 0, 0, 0, 0], fingers: [0, 1, 1, 1, 1, 1] } // full barre: R-4-♭7-9-5
|
||||
const HENDRIX = { rootStr: 5, offsets: ['x', 0, -1, 0, 1, 'x'], fingers: [0, 2, 1, 3, 4, 0] } // 7♯9 — ♭3 grit over a dominant
|
||||
const M9 = { rootStr: 5, offsets: ['x', 0, -2, 0, 0, 'x'], fingers: [0, 2, 1, 3, 4, 0] } // R-♭3-♭7-9
|
||||
const MAJ7_5 = { rootStr: 5, offsets: ['x', 0, 2, 1, 2, 'x'], fingers: [0, 1, 3, 2, 4, 0] } // R-5-7-3
|
||||
|
||||
// 6th-string-root grips.
|
||||
const M7_6 = { rootStr: 6, offsets: [0, 'x', 0, 0, 0, 'x'], fingers: [1, 0, 2, 3, 4, 0] } // R-♭7-♭3-5 (the Le Freak Am7)
|
||||
const M11_BARRE = { rootStr: 6, offsets: [0, 0, 0, 0, 0, 0], fingers: [1, 1, 1, 1, 1, 1] } // one-finger m11 (D'Angelo)
|
||||
const THIRTEEN_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, 2, 'x'], fingers: [1, 0, 2, 3, 4, 0] } // R-♭7-3-13
|
||||
|
||||
// Top-4 fragments — above the bass, out of the keys' mid-range.
|
||||
const M7_TOP4 = { rootStr: 1, offsets: ['x', 'x', 0, 0, 0, 0], fingers: [0, 0, 1, 1, 1, 1] } // ♭7-♭3-5-R barre
|
||||
const MAJ7_TOP4 = { rootStr: 1, offsets: ['x', 'x', 1, 1, 0, 0], fingers: [0, 0, 2, 3, 1, 1] } // 7-3-5-R
|
||||
|
||||
export default {
|
||||
styleIntro:
|
||||
'The funk guitarist is a percussionist who happens to know chords: the strumming arm plays constant sixteenths like a hi-hat, and the fret hand decides which of them speak. Play the gaps the bass leaves, keep voicings small and high, and serve The One.',
|
||||
|
||||
comping: [
|
||||
{
|
||||
label: '16th-note scratch foundation',
|
||||
rhythm: '1e&a 2e&a 3e&a 4e&a — DUDU, never stops',
|
||||
description: 'Mute everything with the fret hand and strum constant sixteenths — pure percussion first. Voiced hits are added by pressing the chord only on chosen slots, releasing pressure immediately after (the choke).',
|
||||
},
|
||||
{
|
||||
label: 'Chicken scratch ("chika")',
|
||||
rhythm: 'muted down-up 16th pairs',
|
||||
description: 'Jimmy Nolen\'s signature: strings pressed just enough for a pitchless scratch, strummed near the bridge. The texture between the hits IS the part.',
|
||||
},
|
||||
{
|
||||
label: 'JB one-bar cell',
|
||||
rhythm: 'C x x x x x x x x x C x x x x x — hits on 1 and the &-of-3',
|
||||
description: 'Chord stab on the One (always the One), a second on the and-of-3, ghosts everywhere else. The school pattern behind a hundred James Brown grooves.',
|
||||
},
|
||||
{
|
||||
label: 'Sex Machine pattern',
|
||||
rhythm: '9 . . . 9 . . . 13 . . . . . . 9',
|
||||
description: 'I9 on beats 1 and 2, I13 on beat 3, an upstroke 9 at the bar\'s tail — the documented Eb9/Eb13 figure. The top note rocking 5↔13 is the hook.',
|
||||
},
|
||||
{
|
||||
label: 'Nile Rodgers selective 16ths',
|
||||
rhythm: 'arm = metronome; the pick chooses string groups',
|
||||
description: 'Down-up sixteenths never stop, whether or not strings are struck. Accents come from catching the bottom of the grip vs the top-3 fragment, plus fret-hand chucks. Built on what he doesn\'t play.',
|
||||
},
|
||||
],
|
||||
|
||||
plays: {
|
||||
'funk-one-chord': [
|
||||
{
|
||||
label: 'The Nolen cycle (9 → 13 → 9sus4)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: NINE, extensions: ['9'], note: 'I9 — the funk chord' },
|
||||
{ shape: THIRTEEN, extensions: ['9', '13'], note: 'pinky stretches 5→13' },
|
||||
{ shape: NINESUS, extensions: ['b7', '9'], note: 'one-finger barre — the lift' },
|
||||
{ shape: NINE, extensions: ['9'], note: 'and home' },
|
||||
],
|
||||
tips: 'Three grips, one fret position, zero chord changes — the whole arrangement is the top two strings breathing. Choke every hit; the scratch between them never stops.',
|
||||
},
|
||||
{
|
||||
label: 'Grit set (7♯9 stabs)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: HENDRIX, extensions: ['#9'], note: 'I7♯9 — major and minor third at once' },
|
||||
{ shape: THIRTEEN, extensions: ['9', '13'], note: '' },
|
||||
{ shape: NINESUS, extensions: ['b7', '9'], note: '' },
|
||||
{ shape: NINE, extensions: ['9'], note: '' },
|
||||
],
|
||||
tips: 'The ♯9 is the blues clash built into one grip — use it for the stabs you want to hurt, the clean 9 for the ones that groove. Sparser than the Nolen cycle: half the hits, twice the silence.',
|
||||
},
|
||||
],
|
||||
|
||||
'funk-dorian-vamp': [
|
||||
{
|
||||
label: 'Chameleon pair (m9 + 9)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M9, extensions: ['9'], note: 'i9' },
|
||||
{ shape: NINE, extensions: ['9'], note: 'IV9 — same fret region, one string set' },
|
||||
],
|
||||
tips: 'Both grips share the A-string root region — the change is two fingers, not a position. Sixteenth scratch throughout; voice the chords only on the accents the bass leaves open.',
|
||||
},
|
||||
{
|
||||
label: 'D\'Angelo barre (m11 wash)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M11_BARRE, extensions: ['11'], note: 'i11 — one finger, all six strings' },
|
||||
{ shape: THIRTEEN_6, extensions: ['13'], note: 'IV13' },
|
||||
],
|
||||
tips: 'The one-finger m11 is the deepest chord in funk for the least effort — lay it across and let the fret hand bounce for the rhythm. Keep it short; six strings of m11 sustained is soup.',
|
||||
},
|
||||
],
|
||||
|
||||
'funk-25-loop': [
|
||||
{
|
||||
label: 'Le Freak grips',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M7_6, note: 'ii7 — 6th-string root' },
|
||||
{ shape: NINE, extensions: ['9'], note: 'V9 — 5th-string root, same position' },
|
||||
],
|
||||
tips: 'The Chic move: full grip held, pick selecting string groups in constant 16ths. Freak out on the mutes, not the volume.',
|
||||
},
|
||||
{
|
||||
label: 'Smooth set (m9 + 13)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M9, extensions: ['9'], note: 'ii9' },
|
||||
{ shape: THIRTEEN, extensions: ['9', '13'], note: 'V13' },
|
||||
],
|
||||
tips: 'The Stevie Wonder colour: 9ths on both sides of the loop. Push the V13 an eighth early every second bar and the loop starts to roll forward.',
|
||||
},
|
||||
],
|
||||
|
||||
'funk-bvii-move': [
|
||||
{
|
||||
label: 'One grip, whole-step slide',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: NINE, extensions: ['9'], note: 'I9' },
|
||||
{ shape: NINE, extensions: ['9'], note: '♭VII9 — two frets down, same grip' },
|
||||
],
|
||||
tips: 'The E9→D9 figure: the move is the slide itself — keep light finger pressure during the shift so the landing speaks. Snap back up to the I ON the One.',
|
||||
},
|
||||
{
|
||||
label: 'Grit on the I, clean below',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: HENDRIX, extensions: ['#9'], note: 'I7♯9' },
|
||||
{ shape: NINE, extensions: ['9'], note: '♭VII9' },
|
||||
],
|
||||
tips: 'Contrast as arrangement: the ♯9 bites on home, the plain 9 relaxes a whole step down. Save this pairing for the bridge or the last vamp out.',
|
||||
},
|
||||
],
|
||||
|
||||
'funk-smooth-loop': [
|
||||
{
|
||||
label: 'A-string roots (September set)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: MAJ7_5, note: 'IVmaj7' },
|
||||
{ shape: M9, extensions: ['9'], note: 'iii9' },
|
||||
{ shape: M9, extensions: ['9'], note: 'ii9 — whole grip down two frets' },
|
||||
{ shape: M9, extensions: ['9'], note: 'back up' },
|
||||
],
|
||||
tips: 'The loop is one m9 grip walking between iii and ii under a stationary maj7 anchor. Clean tone, light palm mute, hits shorter than you think.',
|
||||
},
|
||||
{
|
||||
label: 'Top-4 shimmer (with keys/horns)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: MAJ7_TOP4, note: 'IVmaj7 — top four strings only' },
|
||||
{ shape: M7_TOP4, note: 'iii7 — one-finger barre' },
|
||||
{ shape: M7_TOP4, note: 'ii7' },
|
||||
{ shape: M7_TOP4, note: '' },
|
||||
],
|
||||
tips: 'When keys and horns are present this register is yours and nothing below it. The barre fragments slide as one shape — think of it as playing the top of the arrangement, not chords.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
improv: {
|
||||
scales: [
|
||||
{ over: 'i7–IV7 vamps', scale: 'dorian', why: 'The whole vamp is one Dorian scale — Chameleon\'s entire harmony fits inside it. The natural 6 is the funk note.' },
|
||||
{ over: 'I9 one-chord vamps', scale: 'mixolydian', why: 'Mixolydian plus the minor-pentatonic/blues blend — the 7♯9 chord literally spells that major/minor mix.' },
|
||||
{ over: 'ii7–V9 loops', scale: 'dorian', why: 'Dorian on the ii covers both chords; it\'s the same dyad as the minor vamp heard from the ii.' },
|
||||
{ over: 'maj7 loops', scale: 'major', why: 'Diatonic major/pentatonic — the EWF horn-line sweetness. Target 9ths and 6ths, not roots.' },
|
||||
],
|
||||
targetNotes:
|
||||
'Funk solos are mostly chord fragments: the 3+♭7 tritone pair and the ♭7+9 pair of the 9 grip, slid in from a half-step below. Over static harmony, develop RHYTHM — state a short motif, displace it inside the 16th grid, add and remove ghosts. The harmony will not save you; the pocket will.',
|
||||
licks: [
|
||||
{
|
||||
over: 'funk-dorian-vamp',
|
||||
description: 'Cissy Strut main line (The Meters, 1969): a descending Cm7 arpeggio answered by double-stop stabs — upper-structure fragments of the i11.',
|
||||
tab: 'e|------------------|---------------6-6--5-5--\nB|------------------|---------------6-6--6-6--\nG|--5--3--0---------|---------------7-7--5-5--\nD|-----------1b-----|-------------------------\nA|--------------3---|--1--3--1--3-------------\n C Bb G Eb C Bb C Bb C (stab pairs)',
|
||||
source: 'The Meters, "Cissy Strut" (1969); spytunes.com & pianote.com analyses (Cm7 arpeggio construction)',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default {
|
||||
id: 'funk',
|
||||
label: 'Funk',
|
||||
feel: '16th',
|
||||
tempoRange: [90, 120],
|
||||
character: 'One or two chords, sixteen subdivisions: harmony freezes so rhythm can talk. The guitar is a drum with pitch — most strokes are muted, and colour lives in the chord quality (9, 13, ♯9), not the changes.',
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
id: 'funk-one-chord',
|
||||
name: 'One-chord I9 vamp (James Brown)',
|
||||
rn: ['I9', 'I13', 'I9sus4', 'I9'],
|
||||
degrees: [0, 0, 0, 0],
|
||||
qualities: ['dom7', 'dom7', 'sus4', 'dom7'],
|
||||
bars: [1, 1, 1, 1],
|
||||
mode: 'mixolydian',
|
||||
songs: ['Get Up (Sex Machine) — James Brown', 'Cold Sweat — James Brown', "Papa's Got a Brand New Bag"],
|
||||
tip: 'The harmony never moves — the chord QUALITY does: 9 → 13 → 9sus4 → 9 is the whole arrangement. Funk colour lives in the voicing, not the progression.',
|
||||
},
|
||||
{
|
||||
id: 'funk-dorian-vamp',
|
||||
name: 'Dorian two-chord vamp (i7–IV7)',
|
||||
rn: ['i9', 'IV9'],
|
||||
degrees: [0, 5],
|
||||
qualities: ['min7', 'dom7'],
|
||||
bars: [1, 1],
|
||||
mode: 'dorian',
|
||||
songs: ['Chameleon — Herbie Hancock', 'Use Me — Bill Withers', 'Cissy Strut — The Meters (i7 side)'],
|
||||
tip: 'THE funk pair: both chords live inside one Dorian scale, so soloists never have to switch. The major IV is what makes it Dorian, not sad.',
|
||||
},
|
||||
{
|
||||
id: 'funk-25-loop',
|
||||
name: 'Disco loop (ii7–V9, never resolves)',
|
||||
rn: ['ii7', 'V9'],
|
||||
degrees: [2, 7],
|
||||
qualities: ['min7', 'dom7'],
|
||||
bars: [1, 1],
|
||||
mode: 'major',
|
||||
songs: ['Le Freak — Chic', 'Good Times — Chic', 'I Wish — Stevie Wonder'],
|
||||
tip: 'A ii–V that never finds its I — the resolution is the dance floor. Same two-chord dyad as the Dorian vamp, heard from the other side.',
|
||||
},
|
||||
{
|
||||
id: 'funk-bvii-move',
|
||||
name: 'I9–♭VII9 figure',
|
||||
rn: ['I9', '♭VII9'],
|
||||
degrees: [0, 10],
|
||||
qualities: ['dom7', 'dom7'],
|
||||
bars: [1, 1],
|
||||
mode: 'mixolydian',
|
||||
songs: ['the James Brown E9→D9 figure', 'countless JB-school vamps'],
|
||||
tip: 'One grip sliding down a whole step and back. The ♭VII is borrowed Mixolydian gravity — it falls back to the I on its own.',
|
||||
},
|
||||
{
|
||||
id: 'funk-smooth-loop',
|
||||
name: 'Smooth maj7 loop (EWF)',
|
||||
rn: ['IVmaj7', 'iii7', 'ii7', 'iii7'],
|
||||
degrees: [5, 4, 2, 4],
|
||||
qualities: ['maj7', 'min7', 'min7', 'min7'],
|
||||
bars: [1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['September — Earth, Wind & Fire'],
|
||||
tip: 'Starts on the IVmaj7 and orbits the iii — home is implied, never stated. (Every third pass, September turns the iii into V/vi for the lift.)',
|
||||
},
|
||||
]
|
||||
@@ -1,216 +0,0 @@
|
||||
// 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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.',
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// 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.',
|
||||
},
|
||||
]
|
||||
@@ -1,85 +0,0 @@
|
||||
// KB registry — the UI reads only this. Each style folder registers here;
|
||||
// instruments appear as their cells are completed (see docs/kb-backlog.md).
|
||||
import jazzMeta from './jazz/meta.js'
|
||||
import jazzProgressions from './jazz/progressions.js'
|
||||
import jazzGuitar from './jazz/guitar.js'
|
||||
import bluesMeta from './blues/meta.js'
|
||||
import bluesProgressions from './blues/progressions.js'
|
||||
import bluesGuitar from './blues/guitar.js'
|
||||
import rockMeta from './rock/meta.js'
|
||||
import rockProgressions from './rock/progressions.js'
|
||||
import rockGuitar from './rock/guitar.js'
|
||||
import bossaMeta from './bossa/meta.js'
|
||||
import bossaProgressions from './bossa/progressions.js'
|
||||
import bossaGuitar from './bossa/guitar.js'
|
||||
import funkMeta from './funk/meta.js'
|
||||
import funkProgressions from './funk/progressions.js'
|
||||
import funkGuitar from './funk/guitar.js'
|
||||
import reggaeMeta from './reggae/meta.js'
|
||||
import reggaeProgressions from './reggae/progressions.js'
|
||||
import reggaeGuitar from './reggae/guitar.js'
|
||||
import countryMeta from './country/meta.js'
|
||||
import countryProgressions from './country/progressions.js'
|
||||
import countryGuitar from './country/guitar.js'
|
||||
import rnbMeta from './rnb/meta.js'
|
||||
import rnbProgressions from './rnb/progressions.js'
|
||||
import rnbGuitar from './rnb/guitar.js'
|
||||
import 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: {
|
||||
meta: jazzMeta,
|
||||
progressions: jazzProgressions,
|
||||
instruments: { guitar: jazzGuitar },
|
||||
},
|
||||
blues: {
|
||||
meta: bluesMeta,
|
||||
progressions: bluesProgressions,
|
||||
instruments: { guitar: bluesGuitar },
|
||||
},
|
||||
rock: {
|
||||
meta: rockMeta,
|
||||
progressions: rockProgressions,
|
||||
instruments: { guitar: rockGuitar },
|
||||
},
|
||||
bossa: {
|
||||
meta: bossaMeta,
|
||||
progressions: bossaProgressions,
|
||||
instruments: { guitar: bossaGuitar },
|
||||
},
|
||||
funk: {
|
||||
meta: funkMeta,
|
||||
progressions: funkProgressions,
|
||||
instruments: { guitar: funkGuitar },
|
||||
},
|
||||
reggae: {
|
||||
meta: reggaeMeta,
|
||||
progressions: reggaeProgressions,
|
||||
instruments: { guitar: reggaeGuitar },
|
||||
},
|
||||
country: {
|
||||
meta: countryMeta,
|
||||
progressions: countryProgressions,
|
||||
instruments: { guitar: countryGuitar },
|
||||
},
|
||||
rnb: {
|
||||
meta: rnbMeta,
|
||||
progressions: rnbProgressions,
|
||||
instruments: { guitar: rnbGuitar },
|
||||
},
|
||||
gospel: {
|
||||
meta: gospelMeta,
|
||||
progressions: gospelProgressions,
|
||||
instruments: { guitar: gospelGuitar },
|
||||
},
|
||||
pop: {
|
||||
meta: popMeta,
|
||||
progressions: popProgressions,
|
||||
instruments: { guitar: popGuitar },
|
||||
},
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
// Jazz guitar pack — gold-standard KB cell. Shapes verified by note-spelling
|
||||
// against: jazzguitar.be (shell chords, drop-2, comping rhythms), freddiegreen.org,
|
||||
// jenslarsen.nl (comping rhythms, voice leading), premierguitar.com (drop-2).
|
||||
|
||||
// Shell voicings (Freddie Green style) — root + 3rd + 7th, fifths omitted.
|
||||
const SHELL_6 = { // root on low E string
|
||||
maj7: { rootStr: 6, offsets: [0, 'x', 1, 1, 'x', 'x'], fingers: [1, 0, 3, 4, 0, 0] }, // R–7–3
|
||||
dom7: { rootStr: 6, offsets: [0, 'x', 0, 1, 'x', 'x'], fingers: [1, 0, 2, 3, 0, 0] }, // R–♭7–3
|
||||
min7: { rootStr: 6, offsets: [0, 'x', 0, 0, 'x', 'x'], fingers: [1, 0, 2, 3, 0, 0] }, // R–♭7–♭3
|
||||
half_dim: { rootStr: 6, offsets: [0, 'x', 0, 0, -1, 'x'], fingers: [2, 0, 3, 4, 1, 0] }, // R–♭7–♭3–♭5
|
||||
}
|
||||
const SHELL_5 = { // root on A string
|
||||
maj7: { rootStr: 5, offsets: ['x', 0, -1, 1, 'x', 'x'], fingers: [0, 2, 1, 4, 0, 0] }, // R–3–7
|
||||
dom7: { rootStr: 5, offsets: ['x', 0, -1, 0, 'x', 'x'], fingers: [0, 2, 1, 3, 0, 0] }, // R–3–♭7
|
||||
min7: { rootStr: 5, offsets: ['x', 0, -2, 0, 'x', 'x'], fingers: [0, 3, 1, 4, 0, 0] }, // R–♭3–♭7
|
||||
half_dim: { rootStr: 5, offsets: ['x', 0, 1, 0, 1, 'x'], fingers: [0, 1, 3, 2, 4, 0] }, // R–♭5–♭7–♭3
|
||||
}
|
||||
|
||||
// Drop-2 voicings on the top four strings (D–G–B–e) — stays out of the bass register.
|
||||
const DROP2 = {
|
||||
maj7Root: { rootStr: 4, offsets: ['x', 'x', 0, 2, 2, 2], fingers: [0, 0, 1, 3, 3, 3] }, // R–5–7–3
|
||||
dom7Root: { rootStr: 4, offsets: ['x', 'x', 0, 2, 1, 2], fingers: [0, 0, 1, 3, 2, 4] }, // R–5–♭7–3
|
||||
min7Root: { rootStr: 4, offsets: ['x', 'x', 0, 2, 1, 1], fingers: [0, 0, 1, 4, 2, 3] }, // R–5–♭7–♭3
|
||||
halfDimRoot: { rootStr: 4, offsets: ['x', 'x', 0, 1, 1, 1], fingers: [0, 0, 1, 2, 3, 4] }, // R–♭5–♭7–♭3
|
||||
min7Inv3: { rootStr: 1, offsets: ['x', 'x', 0, 0, 0, 0], fingers: [0, 0, 1, 1, 1, 1] }, // ♭7–♭3–5–R (one-finger barre)
|
||||
dom7Inv2: { rootStr: 2, offsets: ['x', 'x', 1, 2, 0, 2], fingers: [0, 0, 2, 3, 1, 4] }, // 3–♭7–R–5
|
||||
maj7Inv2: { rootStr: 1, offsets: ['x', 'x', 1, 1, 0, 0], fingers: [0, 0, 2, 3, 1, 1] }, // 7–3–5–R
|
||||
}
|
||||
|
||||
export default {
|
||||
styleIntro:
|
||||
'In a jazz jam the guitar is part of the rhythm section: small voicings built on 3rds and 7ths, placed around the soloist, never on top of the piano. The fifths and often the roots are someone else\'s job — your two guide tones carry the whole harmony.',
|
||||
|
||||
comping: [
|
||||
{
|
||||
label: 'Four-to-the-bar (Freddie Green)',
|
||||
rhythm: '♩ ♩ ♩ ♩',
|
||||
description: 'Short, percussive quarter-note strums on all four beats, slight accent on 2 and 4 — the Count Basie pulse. Damp the unused strings; the chunk matters more than the chord.',
|
||||
},
|
||||
{
|
||||
label: 'Charleston',
|
||||
rhythm: '𝅗𝅥. + "and of 2"',
|
||||
description: 'Hit on beat 1 (held) plus a stab on the and-of-2 — the foundational syncopated comping cell. Displace it ("and of 1" + beat 3) for forward motion.',
|
||||
},
|
||||
{
|
||||
label: 'The push (anticipated and-of-4)',
|
||||
rhythm: 'tied from "and of 4"',
|
||||
description: 'Strike the next bar\'s chord an eighth note early and tie it over the barline — the standard jazz anticipation. Telegraphs the change to the whole band.',
|
||||
},
|
||||
],
|
||||
|
||||
plays: {
|
||||
'jazz-251-major': [
|
||||
{
|
||||
label: 'Shell voicings, guide-tone glue',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: SHELL_5.min7, note: 'R–♭3–♭7' },
|
||||
{ shape: SHELL_6.dom7, note: '♭7 of ii holds; ♭3 falls a half-step to become the 3rd' },
|
||||
{ shape: SHELL_5.maj7, note: '♭7 of V falls a half-step to the 3rd; the other voice holds' },
|
||||
],
|
||||
tips: 'Only the roots jump — both upper voices move 0 or 1 fret across the whole progression. Watch the D and G strings: that two-note thread is the ii–V–I.',
|
||||
},
|
||||
{
|
||||
label: 'Drop-2 in one position (top four strings)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: DROP2.min7Inv3, note: 'one-finger barre: ♭7–♭3–5–R' },
|
||||
{ shape: DROP2.dom7Inv2, note: 'every voice moves 0–2 frets' },
|
||||
{ shape: DROP2.maj7Inv2, note: 'lands with the root on top' },
|
||||
],
|
||||
tips: 'The whole progression sits in one 3-fret window with no position jump — ideal when a piano is holding the low end. Great behind a singer: high, thin, out of the way.',
|
||||
},
|
||||
],
|
||||
|
||||
'jazz-251-minor': [
|
||||
{
|
||||
label: 'Shell voicings with the ♭5 voiced',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: SHELL_6.half_dim, note: 'the ♭5 on the B string is the colour — don\'t skip it' },
|
||||
{ shape: SHELL_5.dom7, extensions: ['b9'], note: 'add the ♭9 a fret above the root for the full minor-key sound' },
|
||||
{ shape: SHELL_6.min7, note: 'home — resolve and get light' },
|
||||
],
|
||||
tips: 'The ♭5 of the iiø7 *is* the ♭9 of the V7 — same pitch, reinterpreted. Find it once, hold it through both chords.',
|
||||
},
|
||||
{
|
||||
label: 'Drop-2, top-four strings',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: DROP2.halfDimRoot, note: 'root + one-finger barre' },
|
||||
{ shape: DROP2.dom7Inv2, note: '' },
|
||||
{ shape: DROP2.min7Inv3, note: 'one-finger barre to rest on' },
|
||||
],
|
||||
tips: 'Both barre grips bookending this make the iiø7 the only real stretch — practise the V7 grip as the pivot between them.',
|
||||
},
|
||||
],
|
||||
|
||||
'jazz-rhythm-a': [
|
||||
{
|
||||
label: 'Shells, four-to-the-bar',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: SHELL_6.maj7, note: '' },
|
||||
{ shape: SHELL_5.min7, note: '' },
|
||||
{ shape: SHELL_5.min7, note: 'same grip, two frets down from vi' },
|
||||
{ shape: SHELL_6.dom7, note: '' },
|
||||
],
|
||||
tips: 'One chord per bar, four chunks per bar, Freddie Green style. At rhythm-changes tempo the small shapes are the only ones that keep up.',
|
||||
},
|
||||
{
|
||||
label: 'Drop-2 turnaround, upper register',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: DROP2.maj7Root, note: '' },
|
||||
{ shape: DROP2.min7Inv3, note: '' },
|
||||
{ shape: DROP2.min7Root, note: '' },
|
||||
{ shape: DROP2.dom7Inv2, note: '' },
|
||||
],
|
||||
tips: 'A loop, not a line — bar 4 feeds bar 1. Practise it as one circular hand motion until the join disappears.',
|
||||
},
|
||||
],
|
||||
|
||||
'jazz-625': [
|
||||
{
|
||||
label: 'Shells, alternating root strings',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: SHELL_6.min7, note: '' },
|
||||
{ shape: SHELL_5.min7, note: '' },
|
||||
{ shape: SHELL_6.dom7, note: '' },
|
||||
{ shape: SHELL_5.maj7, note: '' },
|
||||
],
|
||||
tips: 'Roots falling in fifths alternate 6th string → 5th string at the same fret — the progression stays in one position by construction. This is why shells were built for circle-of-fifths tunes.',
|
||||
},
|
||||
{
|
||||
label: 'Drop-2 circle, top-four strings',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: DROP2.min7Inv3, note: '' },
|
||||
{ shape: DROP2.min7Root, note: '' },
|
||||
{ shape: DROP2.dom7Inv2, note: '' },
|
||||
{ shape: DROP2.maj7Inv2, note: '' },
|
||||
],
|
||||
tips: 'Sing the top note of each grip as you move — drop-2 makes the melody line on the e string audible, and that line is what the soloist hears from you.',
|
||||
},
|
||||
],
|
||||
|
||||
'jazz-blues': [
|
||||
{
|
||||
label: 'Shells through the form',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: SHELL_6.dom7, note: 'I7 — root on the 6th string' },
|
||||
{ shape: SHELL_5.dom7, note: 'IV7 — same fret, root string up' },
|
||||
{ shape: SHELL_6.dom7, note: '' },
|
||||
{ shape: SHELL_6.dom7, note: '' },
|
||||
{ shape: SHELL_5.dom7, note: '' },
|
||||
{ shape: SHELL_5.dom7, note: '' },
|
||||
{ shape: SHELL_6.dom7, note: '' },
|
||||
{ shape: SHELL_5.dom7, note: 'VI7 — the jazz move; hear bar 8 coming' },
|
||||
{ shape: SHELL_5.min7, note: 'ii7 of the turnaround' },
|
||||
{ shape: SHELL_6.dom7, note: 'V7' },
|
||||
{ shape: SHELL_6.dom7, note: 'home' },
|
||||
{ shape: SHELL_6.dom7, note: 'V7 pickup into the next chorus' },
|
||||
],
|
||||
tips: 'I7 and IV7 sit at the same fret on adjacent root strings — the first four bars are a two-finger-move exercise. Keep everything within two frets of the I.',
|
||||
},
|
||||
{
|
||||
label: 'Drop-2 blues, top-four strings',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: DROP2.dom7Root, note: '' },
|
||||
{ shape: DROP2.dom7Inv2, note: 'IV7 without leaving the position' },
|
||||
{ shape: DROP2.dom7Root, note: '' },
|
||||
{ shape: DROP2.dom7Root, note: '' },
|
||||
{ shape: DROP2.dom7Inv2, note: '' },
|
||||
{ shape: DROP2.dom7Inv2, note: '' },
|
||||
{ shape: DROP2.dom7Root, note: '' },
|
||||
{ shape: DROP2.dom7Inv2, note: 'VI7' },
|
||||
{ shape: DROP2.min7Inv3, note: '' },
|
||||
{ shape: DROP2.dom7Inv2, note: '' },
|
||||
{ shape: DROP2.dom7Root, note: '' },
|
||||
{ shape: DROP2.dom7Inv2, note: '' },
|
||||
],
|
||||
tips: 'Comping above the 7th fret leaves the whole low end to bass and piano — the classic organ-trio guitar register. Charleston rhythm, not four-to-the-bar, up here.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
improv: {
|
||||
scales: [
|
||||
{ over: 'ii7', scale: 'dorian', why: 'Minor 7 chords in a major key take Dorian — the natural 6 keeps it from sounding sad.' },
|
||||
{ over: 'V7', scale: 'mixolydian', why: 'The ♭7 is built in; in minor keys use Phrygian dominant (harmonic minor from the V) for the ♭9 sound.' },
|
||||
{ over: 'Imaj7', scale: 'major', why: 'Plain major works; avoid sitting on the 4th over the maj7.' },
|
||||
{ over: 'I7 (blues)', scale: 'mixolydian', why: 'Mix with the blues scale — Mixolydian for the changes, blues scale for the attitude.' },
|
||||
{ over: 'iiø7', scale: 'locrian', why: 'Target the ♭3 or ♭5; the ♭5 becomes the ♭9 of the next V7.' },
|
||||
],
|
||||
targetNotes:
|
||||
'Land the 3rd of each chord on the downbeat of the change. In any ii–V–I the 7th of one chord falls a half-step to the 3rd of the next — that two-note rail is the whole map.',
|
||||
licks: [
|
||||
{
|
||||
over: 'jazz-251-major',
|
||||
description: '"The Lick" — the most famous ii–V cliché in jazz (Parker, Coltrane, everyone). Degrees 1–2–♭3–4–2–♭7–1 over the ii chord.',
|
||||
tab: 'e|--------------------------\nB|--------------------------\nG|--------------------------\nD|----2--3--5--2------------\nA|-5--------------3--5------\nE|--------------------------\n D E F G E C D (over Dm7 in C)',
|
||||
source: 'Wikipedia: "The Lick"; Alex Heitlinger compilation (2011)',
|
||||
},
|
||||
{
|
||||
over: 'jazz-251-major',
|
||||
description: 'Stock bebop ii–V–I: ii arpeggio up, then the 3–5–♭7–♭9 diminished arpeggio over the V7 (B–D–F–A♭ over G7), resolving half-step into the I.',
|
||||
tab: 'e|--------------------------|------------4--3---------|--------\nB|----------------5--3------|----3--6----------6--3----|--1-----\nG|-------------5---------5--|-4---------------------4--|--------\nD|----3--7---------------7--|--------------------------|--------\nA|-5------------------------|--------------------------|--------\nE|--------------------------|--------------------------|--------\n Dm7 arpeggio + 9th G7: 3-5-♭7-♭9 dim arp Cmaj7',
|
||||
source: 'David Baker, How to Play Bebop Vol. 1; jazzguitar.be "50 Bebop Licks"',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default {
|
||||
id: 'jazz',
|
||||
label: 'Jazz',
|
||||
feel: 'swing',
|
||||
tempoRange: [110, 230],
|
||||
character: 'Harmony in constant motion — 7th chords everywhere, 3rds and 7ths doing the voice-leading work, rhythm section breathing around the soloist.',
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
id: 'jazz-251-major',
|
||||
name: 'ii–V–I',
|
||||
rn: ['ii7', 'V7', 'Imaj7'],
|
||||
degrees: [2, 7, 0],
|
||||
qualities: ['min7', 'dom7', 'maj7'],
|
||||
bars: [1, 1, 2],
|
||||
mode: 'major',
|
||||
songs: ['All The Things You Are', 'Tune Up — Miles Davis', 'Honeysuckle Rose'],
|
||||
tip: 'The 7th of each chord resolves down a half-step to the 3rd of the next — that two-note thread is the whole progression.',
|
||||
},
|
||||
{
|
||||
id: 'jazz-251-minor',
|
||||
name: 'minor ii–V–i',
|
||||
rn: ['iiø7', 'V7', 'i7'],
|
||||
degrees: [2, 7, 0],
|
||||
qualities: ['half_dim', 'dom7', 'min7'],
|
||||
bars: [1, 1, 2],
|
||||
mode: 'minor',
|
||||
songs: ['Autumn Leaves (bridge)', 'Blue Bossa', 'Beautiful Love'],
|
||||
tip: 'Same engine as the major ii–V–I, darker fuel: the ♭5 of the iiø7 is the ♭9 colour waiting to happen on the V7.',
|
||||
},
|
||||
{
|
||||
id: 'jazz-rhythm-a',
|
||||
name: 'Rhythm changes turnaround',
|
||||
rn: ['Imaj7', 'vi7', 'ii7', 'V7'],
|
||||
degrees: [0, 9, 2, 7],
|
||||
qualities: ['maj7', 'min7', 'min7', 'dom7'],
|
||||
bars: [1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['I Got Rhythm — Gershwin', 'Oleo — Sonny Rollins', 'Blue Moon'],
|
||||
tip: 'A loop, not a line — bar 4 hands you straight back to bar 1. Learn it as one circular shape your hands repeat.',
|
||||
},
|
||||
{
|
||||
id: 'jazz-625',
|
||||
name: 'vi–ii–V–I circle',
|
||||
rn: ['vi7', 'ii7', 'V7', 'Imaj7'],
|
||||
degrees: [9, 2, 7, 0],
|
||||
qualities: ['min7', 'min7', 'dom7', 'maj7'],
|
||||
bars: [1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['Fly Me to the Moon', 'Autumn Leaves (A section, relative view)'],
|
||||
tip: 'Pure circle-of-fifths motion: every root falls a fifth. If you can hear this one, you can predict half the jazz repertoire.',
|
||||
},
|
||||
{
|
||||
id: 'jazz-blues',
|
||||
name: 'Jazz blues (12-bar)',
|
||||
rn: ['I7', 'IV7', 'I7', 'I7', 'IV7', 'IV7', 'I7', 'VI7', 'ii7', 'V7', 'I7', 'V7'],
|
||||
degrees: [0, 5, 0, 0, 5, 5, 0, 9, 2, 7, 0, 7],
|
||||
qualities: ['dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'dom7', 'min7', 'dom7', 'dom7', 'dom7'],
|
||||
bars: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ["Billie's Bounce — Charlie Parker", "Now's the Time — Charlie Parker", 'Tenor Madness — Sonny Rollins'],
|
||||
tip: 'A 12-bar blues wearing a suit: bars 8-10 swap the plain V-IV for a VI7 → ii–V turnaround. Hear bar 8 coming and you sound like a jazz player.',
|
||||
},
|
||||
]
|
||||
@@ -1,204 +0,0 @@
|
||||
// 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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.',
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
// 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.',
|
||||
},
|
||||
]
|
||||
@@ -1,182 +0,0 @@
|
||||
// Reggae guitar pack. Shapes verified by note-spelling against: guitarwiz.app
|
||||
// (skank voicings, bubble grid), guitarworld.com (Marley rhythm lesson),
|
||||
// guitarkitchen.com (Stir It Up / Get Up Stand Up lessons), Wikipedia ("Ska
|
||||
// stroke", "One drop rhythm"), ethanhein.com (Stir It Up hook analysis).
|
||||
|
||||
// Top-3-string triads — the classic skank register (above the bubble, far above the bass).
|
||||
const TOP3_MAJ = { rootStr: 1, offsets: ['x', 'x', 'x', 1, 0, 0], fingers: [0, 0, 0, 2, 1, 1] } // 3-5-R
|
||||
const TOP3_MIN = { rootStr: 1, offsets: ['x', 'x', 'x', 0, 0, 0], fingers: [0, 0, 0, 1, 1, 1] } // ♭3-5-R barre
|
||||
|
||||
// Middle-string triads (strings 4-3-2) — the Wailers chop register.
|
||||
const MID_MAJ = { rootStr: 3, offsets: ['x', 'x', 0, 0, 0, 'x'], fingers: [0, 0, 1, 1, 1, 0] } // 5-R-3
|
||||
const MID_MIN = { rootStr: 3, offsets: ['x', 'x', 0, 0, -1, 'x'], fingers: [0, 0, 2, 3, 1, 0] } // 5-R-♭3
|
||||
|
||||
// Top-4 partial barres — fuller chop, still no low strings.
|
||||
const TOP4_MAJ = { rootStr: 4, offsets: ['x', 'x', 0, -1, -2, -2], fingers: [0, 0, 4, 3, 1, 1] } // R-3-5-R
|
||||
const TOP4_MIN = { rootStr: 4, offsets: ['x', 'x', 0, -2, -2, -2], fingers: [0, 0, 4, 1, 1, 1] } // R-♭3-5-R
|
||||
|
||||
// Quality-neutral 5th+root dyad (strings B+e) — works over major or minor.
|
||||
const DYAD_5R = { rootStr: 1, offsets: ['x', 'x', 'x', 'x', 0, 0], fingers: [0, 0, 0, 0, 1, 1] }
|
||||
|
||||
export default {
|
||||
styleIntro:
|
||||
'The reggae guitarist plays one thing and plays it perfectly: the skank — a small, high, choked chord chop strictly on the offbeats. Beat 1 is sacred silence (the one drop), beat 3 belongs to the drums, the low end belongs to the bass. Strike, choke, wait.',
|
||||
|
||||
comping: [
|
||||
{
|
||||
label: 'One-drop skank',
|
||||
rhythm: '. . X . | . . X . — chops on 2 and 4 only',
|
||||
description: 'The core part. Strike the chord, release fret pressure instantly (fingers stay touching, off the frets), silence until the next chop. Downstroke-dominant in reggae. Counted double-time it becomes the and-of-every-beat.',
|
||||
},
|
||||
{
|
||||
label: 'Double skank (Stir It Up)',
|
||||
rhythm: '. . D U | . . D U — down-up pair on 2 and 4',
|
||||
description: 'Each chop splits into a two-hit "chak-a": downstroke on the beat, upstroke on its and. The rockers-era intensifier — same placement, doubled motion.',
|
||||
},
|
||||
{
|
||||
label: 'Ska upstroke',
|
||||
rhythm: '. U . U . U . U — every offbeat 8th, bright and fast',
|
||||
description: 'The same offbeat principle at 120–180 BPM: upstrokes on every "and". Rocksteady is this relaxed; roots reggae is this halved. The placement never changes across the whole family — only the drums do.',
|
||||
},
|
||||
{
|
||||
label: 'The bubble (only without keys)',
|
||||
rhythm: '. . X X . . X X . . X X . . X X — the "& a" of every beat',
|
||||
description: 'Normally the organ\'s job (Jackie Mittoo): a palm-muted continuous offbeat pulse in the midrange. Cover it on guitar only when there is no keyboardist — never alongside one.',
|
||||
},
|
||||
],
|
||||
|
||||
plays: {
|
||||
'reggae-stir': [
|
||||
{
|
||||
label: 'Top-3 triads (the classic skank)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: TOP3_MAJ, note: 'I' },
|
||||
{ shape: TOP3_MAJ, note: 'IV — same shape up the neck' },
|
||||
{ shape: TOP3_MAJ, note: 'V — two frets above the IV' },
|
||||
],
|
||||
tips: 'One shape, three positions, chop on 2 and 4. The chop must die immediately — if a chord rings into the next beat, that\'s a rock strum, not a skank.',
|
||||
},
|
||||
{
|
||||
label: 'Middle-string set (Wailers register)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: MID_MAJ, note: 'I — strings 4-3-2' },
|
||||
{ shape: MID_MAJ, note: 'IV' },
|
||||
{ shape: MID_MAJ, note: 'V' },
|
||||
],
|
||||
tips: 'A warmer chop one string set down — the Marley band register. Use it when a second guitar or keys already occupy the top strings.',
|
||||
},
|
||||
],
|
||||
|
||||
'reggae-two-chord': [
|
||||
{
|
||||
label: 'Top-3 triads',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: TOP3_MAJ, note: 'I' },
|
||||
{ shape: TOP3_MAJ, note: 'IV — five frets up, or two down on the next string set' },
|
||||
],
|
||||
tips: 'Two chords for the whole tune means the skank IS your entire job: identical length, identical volume, every chop. Boredom is the test — pass it.',
|
||||
},
|
||||
{
|
||||
label: 'Top-4 partial barres',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: TOP4_MAJ, note: 'I' },
|
||||
{ shape: TOP4_MAJ, note: 'IV' },
|
||||
],
|
||||
tips: 'The fuller chop for when the band is sparse — still nothing below the D string. Half-press the barre (Marley style) and the chop turns almost fully percussive.',
|
||||
},
|
||||
],
|
||||
|
||||
'reggae-minor-vamp': [
|
||||
{
|
||||
label: 'Top-3 triads',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: TOP3_MIN, note: 'i — one-finger barre' },
|
||||
{ shape: TOP3_MAJ, note: '♭VII — a passing breath, two frets down' },
|
||||
],
|
||||
tips: 'Treat the ♭VII as ornament, not destination — Get Up Stand Up is functionally one chord, and the groove is the message. The intro hook (♭7→root on the G string) doubles the bass.',
|
||||
},
|
||||
{
|
||||
label: 'Middle-string set',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: MID_MIN, note: 'i' },
|
||||
{ shape: MID_MAJ, note: '♭VII' },
|
||||
],
|
||||
tips: 'Lower, darker chop for the heavier roots feel. Keep the choke brutal at slow tempos — space is the instrument at 75 BPM.',
|
||||
},
|
||||
],
|
||||
|
||||
'reggae-nwnc': [
|
||||
{
|
||||
label: 'Top-3 triads with the bass walk',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: TOP3_MAJ, note: 'I' },
|
||||
{ shape: TOP3_MAJ, note: 'V — the bass plays its 3rd underneath; your triad doesn\'t change' },
|
||||
{ shape: TOP3_MIN, note: 'vi' },
|
||||
{ shape: TOP3_MAJ, note: 'IV' },
|
||||
],
|
||||
tips: 'The V⁶\'s descending bass (root→7th of the scale→6th) is the bassist\'s line — your job is to NOT double it. Stay high, stay small, let the walk happen below you.',
|
||||
},
|
||||
{
|
||||
label: 'Neutral 5+R dyads',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: DYAD_5R, omit3: true, note: 'I' },
|
||||
{ shape: DYAD_5R, omit3: true, note: 'V' },
|
||||
{ shape: DYAD_5R, omit3: true, note: 'vi — same dyad; the bass supplies the minor' },
|
||||
{ shape: DYAD_5R, omit3: true, note: 'IV' },
|
||||
],
|
||||
tips: 'Two strings, no 3rd — quality-neutral, so one dyad shape skanks the whole progression while bass and vocals colour it. The most transparent part you can play behind a singer.',
|
||||
},
|
||||
],
|
||||
|
||||
'reggae-rocksteady': [
|
||||
{
|
||||
label: 'Top-3 triads, doo-wop sweetness',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: TOP3_MAJ, note: 'I' },
|
||||
{ shape: TOP3_MIN, note: 'ii' },
|
||||
{ shape: TOP3_MIN, note: 'iii — two frets up' },
|
||||
{ shape: TOP3_MIN, note: 'ii' },
|
||||
],
|
||||
tips: 'A diatonic staircase: the minor barre walks up two frets and back while the I anchors. Rocksteady tempo (~86 BPM) sits between ska\'s sprint and roots\' crawl — relax the chop accordingly.',
|
||||
},
|
||||
{
|
||||
label: 'Top-4 partial barres',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: TOP4_MAJ, note: 'I' },
|
||||
{ shape: TOP4_MIN, note: 'ii' },
|
||||
{ shape: TOP4_MIN, note: 'iii' },
|
||||
{ shape: TOP4_MIN, note: 'ii' },
|
||||
],
|
||||
tips: 'The soul-ballad version of the skank — slightly fuller, still bass-free. Good under a falsetto lead vocal where top-3 triads would crowd the singer\'s register.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
improv: {
|
||||
scales: [
|
||||
{ over: 'major vamps', scale: 'major', why: 'Major pentatonic fills in the gaps AFTER vocal lines — call and response, never over the singer.' },
|
||||
{ over: 'i–♭VII vamps', scale: 'minor', why: 'Minor pentatonic answer phrases; the ♭7 doubles as the ♭VII chord\'s root — the free note of roots reggae.' },
|
||||
{ over: 'any vamp (riddim role)', scale: 'minor', why: 'The classic second-guitar job is doubling the bass melody in unison, palm-muted for a dull attack — locked exactly, not approximately.' },
|
||||
],
|
||||
targetNotes:
|
||||
'Reggae lead is economy: short pentatonic answers in vocal gaps, high picked arpeggios of the current chord with 16th-note pickups, or unison bass-doubling. If you\'re not sure whether to play — don\'t. Beat 1 stays empty even for the soloist\'s instincts.',
|
||||
licks: [
|
||||
{
|
||||
over: 'reggae-stir',
|
||||
description: 'The Stir It Up hook (reconstruction from Ethan Hein\'s published note-by-note analysis, not a record transcription): an ornamented rise A→C♯→D over the I, then arpeggios of the IV and V with 16th-note pickups carrying the swing.',
|
||||
tab: ' A (I) D (IV) E (V)\ne|--------------------|----2----5---|----4----7----\nB|---------2----3-----|--3----------|--5-----------\nG|----2---------------|-------------|--------------\n A C# D D F# A E G# B',
|
||||
source: 'ethanhein.com "Musical simples: Stir It Up"; Bob Marley & The Wailers (1973)',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default {
|
||||
id: 'reggae',
|
||||
label: 'Reggae',
|
||||
feel: 'one drop',
|
||||
tempoRange: [70, 95],
|
||||
character: 'Strict role separation and sacred negative space: bass owns the melody, drums drop beat one entirely, and the guitar is a choked offbeat chop that never, ever lands on 1 or 3.',
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
id: 'reggae-stir',
|
||||
name: 'Circular I–IV–V vamp',
|
||||
rn: ['I', 'IV', 'V'],
|
||||
degrees: [0, 5, 7],
|
||||
qualities: ['maj', 'maj', 'maj'],
|
||||
bars: [2, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['Stir It Up — Bob Marley & The Wailers'],
|
||||
tip: 'Not a cadence — a wheel: a full bar of I, then IV and V share a bar, forever. The V never "resolves"; it just hands back to the I.',
|
||||
},
|
||||
{
|
||||
id: 'reggae-two-chord',
|
||||
name: 'Two-chord I–IV vamp',
|
||||
rn: ['I', 'IV'],
|
||||
degrees: [0, 5],
|
||||
qualities: ['maj', 'maj'],
|
||||
bars: [2, 2],
|
||||
mode: 'major',
|
||||
songs: ['Lively Up Yourself — Bob Marley'],
|
||||
tip: 'Two chords for an entire song. The interest is the interlock — every instrument in its own time slot — not the harmony.',
|
||||
},
|
||||
{
|
||||
id: 'reggae-minor-vamp',
|
||||
name: 'Minor vamp (i–♭VII)',
|
||||
rn: ['i', '♭VII'],
|
||||
degrees: [0, 10],
|
||||
qualities: ['min', 'maj'],
|
||||
bars: [3, 1],
|
||||
mode: 'minor',
|
||||
songs: ['Get Up, Stand Up — Bob Marley', 'Them Belly Full — Bob Marley (chorus)'],
|
||||
tip: 'Get Up Stand Up is arguably ONE chord — the ♭VII is a passing breath, not a destination. Roots reggae treats harmony as a drone with occasional weather.',
|
||||
},
|
||||
{
|
||||
id: 'reggae-nwnc',
|
||||
name: 'Roots ballad (I–V⁶–vi–IV)',
|
||||
rn: ['I', 'V⁶', 'vi', 'IV'],
|
||||
degrees: [0, 7, 9, 5],
|
||||
qualities: ['maj', 'maj', 'min', 'maj'],
|
||||
bars: [1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['No Woman, No Cry — Bob Marley'],
|
||||
tip: 'Major key despite the tears: the V carries its 3rd in the bass (G/B in C), walking the bassline down by step into the vi. The descent is the emotion.',
|
||||
},
|
||||
{
|
||||
id: 'reggae-rocksteady',
|
||||
name: 'Rocksteady climb (I–ii–iii–ii)',
|
||||
rn: ['I', 'ii', 'iii', 'ii'],
|
||||
degrees: [0, 2, 4, 2],
|
||||
qualities: ['maj', 'min', 'min', 'min'],
|
||||
bars: [1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['Queen Majesty — The Techniques (1967)'],
|
||||
tip: 'Rocksteady grew from doo-wop covers, and it shows: a gentle diatonic staircase up and back at ~86 BPM. Soul harmony with the bass carrying the tune.',
|
||||
},
|
||||
]
|
||||
@@ -1,192 +0,0 @@
|
||||
// R&B / neo-soul guitar pack. Shapes verified by note-spelling against:
|
||||
// pickupmusic.com (m9/maj9 grips, slide-into-chords), fundamental-changes.com
|
||||
// (The Neo-Soul Guitar Book vocabulary, hammer-ons inside shapes),
|
||||
// premierguitar.com (Curtis Mayfield figures — standard-tuning adaptations;
|
||||
// originals are in open F# tuning), justinguitar.com (Mayfield/Hendrix fills),
|
||||
// musicradar.com (D'Angelo Brown Sugar breakdown), landr.com & hearandplay.com
|
||||
// (gospel borrowed-iv), brltheory.com (the Dilla feel).
|
||||
|
||||
// The core neo-soul grips.
|
||||
const M9_5 = { rootStr: 5, offsets: ['x', 0, -2, 0, 0, 'x'], fingers: [0, 2, 1, 3, 4, 0] } // R-♭3-♭7-9
|
||||
const M9_6 = { rootStr: 6, offsets: [0, 'x', 0, 0, 0, 2], fingers: [1, 0, 2, 3, 3, 4] } // R-♭7-♭3-5-9
|
||||
const M11_C = { rootStr: 6, offsets: [0, 'x', 0, 0, -2, 'x'], fingers: [2, 0, 3, 4, 1, 0] } // R-♭7-♭3-11 compact
|
||||
const M11_BARRE = { rootStr: 6, offsets: [0, 0, 0, 0, 0, 0], fingers: [1, 1, 1, 1, 1, 1] } // one-finger m11
|
||||
const MAJ9_5 = { rootStr: 5, offsets: ['x', 0, -1, 1, 0, 'x'], fingers: [0, 2, 1, 4, 3, 0] } // R-3-7-9
|
||||
const MAJ7_5 = { rootStr: 5, offsets: ['x', 0, 2, 1, 2, 'x'], fingers: [0, 1, 3, 2, 4, 0] } // R-5-7-3
|
||||
const MAJ7_BARRE_6 = { rootStr: 6, offsets: [0, 2, 1, 1, 0, 0], fingers: [1, 4, 2, 3, 1, 1] } // E-shape maj7 (embellishment host)
|
||||
const DOM9_5 = { rootStr: 5, offsets: ['x', 0, -1, 0, 0, 0], fingers: [0, 2, 1, 3, 3, 3] } // R-3-♭7-9-5
|
||||
const DOM13_6 = { rootStr: 6, offsets: [0, 'x', 0, 1, 2, 'x'], fingers: [1, 0, 2, 3, 4, 0] } // R-♭7-3-13
|
||||
const NINESUS = { rootStr: 5, offsets: ['x', 0, 0, 0, 0, 0], fingers: [0, 1, 1, 1, 1, 1] } // R-4-♭7-9-5 (the 9sus barre)
|
||||
const HENDRIX = { rootStr: 5, offsets: ['x', 0, -1, 0, 1, 'x'], fingers: [0, 2, 1, 3, 4, 0] } // 7♯9
|
||||
const SIX9_5 = { rootStr: 5, offsets: ['x', 0, -1, -1, 0, 0], fingers: [0, 3, 1, 2, 4, 4] } // R-3-6-9-5 (Motown I-colour)
|
||||
const M6_4 = { rootStr: 4, offsets: ['x', 'x', 0, -2, 0, -2], fingers: [0, 0, 3, 1, 4, 2] } // R-♭3-6-R (the borrowed iv)
|
||||
const TOP3_MAJ = { rootStr: 1, offsets: ['x', 'x', 'x', 1, 0, 0], fingers: [0, 0, 0, 2, 1, 1] } // plain triad, top strings
|
||||
|
||||
export default {
|
||||
styleIntro:
|
||||
'Against keys-heavy neo-soul arrangements the guitar is a colourist: small rootless grips on the top strings, double-stop fills between vocal phrases, and chord stabs placed just behind the drums. Agree on extensions with the keys player, stay out of their octave, and treat silence as part of the part.',
|
||||
|
||||
comping: [
|
||||
{
|
||||
label: 'Pluck and mute',
|
||||
rhythm: 'fingerstyle stabs + muted ghost 16ths',
|
||||
description: 'Pluck the grip with thumb and fingers, release fret pressure instantly for the staccato, fill between hits with muted ghost strums. Per-note dynamic control is why the genre is fingerstyle.',
|
||||
},
|
||||
{
|
||||
label: 'The neo-soul slide',
|
||||
rhythm: 'grip formed a half-step away, slid in on the beat',
|
||||
description: 'Assemble the full chord shape a half-step below (or above) the target, pluck, slide the whole grip in. The signature move — most common on the m9 and maj9 grips.',
|
||||
},
|
||||
{
|
||||
label: 'Mayfield hammer vocabulary',
|
||||
rhythm: 'one finger moves inside a held shape',
|
||||
description: 'Hold the barre, hammer single embellishments: 9→3 on the B string (A-shape), 5→6 on the B string (the My Girl move), ♭3→11 on the G string (minor shapes). Little Wing is this vocabulary at ballad tempo.',
|
||||
},
|
||||
{
|
||||
label: 'Cropper 6ths',
|
||||
rhythm: 'diatonic 6ths slid in from a fret below',
|
||||
description: 'Double-stop 6ths on the G+e string pair walking the scale — the Soul Man hook. One ladder per fill, then get out of the vocal\'s way.',
|
||||
},
|
||||
{
|
||||
label: 'The Dilla feel',
|
||||
rhythm: 'everything a hair behind the grid',
|
||||
description: 'Lay stabs slightly behind the drums and keep ghost 16ths even — displaced by less than a subdivision, drifting back across the phrase. Never rush a fill; "perfectly imperfect."',
|
||||
},
|
||||
],
|
||||
|
||||
plays: {
|
||||
'rnb-mediant-circle': [
|
||||
{
|
||||
label: 'm9 circle, slid into',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M9_5, extensions: ['9'], note: 'iii9 — slide in from a half-step below' },
|
||||
{ shape: M9_5, extensions: ['9'], note: 'vi9' },
|
||||
{ shape: M9_5, extensions: ['9'], note: 'ii9' },
|
||||
{ shape: DOM13_6, extensions: ['13'], note: 'V13' },
|
||||
],
|
||||
tips: 'One grip walks the whole circle — the move IS the slide into each new root. Keep every stab behind the beat; the drums are ahead of you on purpose.',
|
||||
},
|
||||
{
|
||||
label: 'm11 colour set',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M11_C, extensions: ['11'], note: 'iii11' },
|
||||
{ shape: M9_6, extensions: ['9'], note: 'vi9 — 9 on top' },
|
||||
{ shape: M11_C, extensions: ['11'], note: 'ii11' },
|
||||
{ shape: HENDRIX, extensions: ['#9'], note: 'V7♯9 — the soul exclamation' },
|
||||
],
|
||||
tips: 'The 11 grips are darker and hollower than the 9s — use this set on the verse, the m9 set on the hook. The ♯9 V is the once-per-chorus spice, not the default.',
|
||||
},
|
||||
],
|
||||
|
||||
'rnb-6251': [
|
||||
{
|
||||
label: 'Stevie cadence (9s and the 9sus)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M9_5, extensions: ['9'], note: 'vi9' },
|
||||
{ shape: DOM9_5, extensions: ['9'], note: 'II9 — the secondary dominant' },
|
||||
{ shape: NINESUS, extensions: ['b7', '9'], note: 'V9sus — one-finger barre, suspended sweetness' },
|
||||
{ shape: SIX9_5, extensions: ['6', '9'], note: 'I6/9 — resolve without a leading tone' },
|
||||
],
|
||||
tips: 'The 9sus never sharpens into a plain dominant — it melts. Resolve to the 6/9 rather than a maj7 and the cadence lands like a sigh instead of a full stop.',
|
||||
},
|
||||
{
|
||||
label: 'Verse set with the ♯9 lift',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M11_C, extensions: ['11'], note: 'vi11' },
|
||||
{ shape: HENDRIX, extensions: ['#9'], note: 'II7♯9 — grit before the suspension' },
|
||||
{ shape: NINESUS, extensions: ['b7', '9'], note: 'V9sus' },
|
||||
{ shape: TOP3_MAJ, note: 'plain triad on top — let the bass own the root' },
|
||||
],
|
||||
tips: 'Ending on a bare high triad after three extended chords is the dynamic trick: the simplest chord in the progression hits hardest because of what preceded it.',
|
||||
},
|
||||
],
|
||||
|
||||
'rnb-maj7-vamp': [
|
||||
{
|
||||
label: 'maj9 pair',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: MAJ9_5, extensions: ['9'], note: 'Imaj9' },
|
||||
{ shape: MAJ9_5, extensions: ['9'], note: 'IVmaj9 — same grip, five frets up (or string set over)' },
|
||||
],
|
||||
tips: 'Two grips, four bars, infinite patience. Vary the pluck pattern, not the harmony — and if a keys player holds these voicings, drop to double-stop 6ths fills instead.',
|
||||
},
|
||||
{
|
||||
label: 'Embellished barres (Mayfield/Hendrix)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: MAJ7_BARRE_6, note: 'Imaj7 — hammer 5→6 on the B string, 9→3 inside' },
|
||||
{ shape: MAJ7_5, note: 'IVmaj7 — answer with the A-shape hammers' },
|
||||
],
|
||||
tips: 'The chord is a house and the hammers are someone moving around inside it. One embellishment per bar maximum — Castles Made of Sand is mostly air.',
|
||||
},
|
||||
],
|
||||
|
||||
'rnb-dorian': [
|
||||
{
|
||||
label: "D'Angelo pair",
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M9_5, extensions: ['9'], note: 'i9' },
|
||||
{ shape: DOM9_5, extensions: ['9'], note: 'IV9 — the raised 6th lives here' },
|
||||
],
|
||||
tips: 'Same dyad as the funk vamp at two-thirds the tempo: drag every hit behind the kick, ghost the 16ths unevenly, and let the pocket wobble — that wobble is the genre.',
|
||||
},
|
||||
{
|
||||
label: 'Barre wash (m11 + 13)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: M11_BARRE, extensions: ['11'], note: 'i11 — one finger, lay it across' },
|
||||
{ shape: DOM13_6, extensions: ['13'], note: 'IV13' },
|
||||
],
|
||||
tips: 'The lazy-looking version that sounds the deepest. Half-press the barre between hits for the pitchless scratch — texture first, harmony second.',
|
||||
},
|
||||
],
|
||||
|
||||
'rnb-gospel-amen': [
|
||||
{
|
||||
label: 'The Amen cadence',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: MAJ7_5, note: 'IVmaj7' },
|
||||
{ shape: M6_4, note: 'iv6 — the borrowed minor, one semitone falls' },
|
||||
{ shape: MAJ9_5, extensions: ['9'], note: 'Imaj9 — home, with the 9 glowing on top' },
|
||||
],
|
||||
tips: 'Voice-lead the 3rd of the IV down a semitone and hold everything else — the whole cadence is one finger\'s journey. Slow it down further than feels right.',
|
||||
},
|
||||
{
|
||||
label: 'Barre version with the high iv',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: MAJ7_BARRE_6, note: 'IVmaj7' },
|
||||
{ shape: M6_4, note: 'iv6 on the top four strings' },
|
||||
{ shape: MAJ7_5, note: 'Imaj7' },
|
||||
],
|
||||
tips: 'For the gospel walk, insert a passing diminished between any two diatonic chords a step apart (I→♯i°→ii) — same borrowed-from-the-choir logic as the iv.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
improv: {
|
||||
scales: [
|
||||
{ over: 'i9 / m9 vamps', scale: 'dorian', why: 'The neo-soul default — Brown Sugar is pure E Dorian. ("Neo-soul scale" in lesson jargon ≈ minor pentatonic + the 9; an alias, not a different scale.)' },
|
||||
{ over: 'maj7 / maj9 vamps', scale: 'major', why: 'Major pentatonic with the 9th and 6th emphasized — land on colours, not roots.' },
|
||||
{ over: 'V9sus / II9', scale: 'mixolydian', why: 'Mixolydian of each dominant; over the 9sus avoid the 3rd entirely — the suspension is the point.' },
|
||||
{ over: 'i–iv minor vamps', scale: 'minor', why: 'Aeolian when the iv is minor (Didn\'t Cha Know) — the ♭6 differentiates it from the Dorian vamps.' },
|
||||
],
|
||||
targetNotes:
|
||||
'Target the 9ths and 13ths, never the roots — over a m9 land on its 9, over a 13 land on its 13. The melodic vocabulary is double-stops (3rds, 4ths, 6ths) slid in from a fret below, and pentatonics stacked in 4ths for the modern sound.',
|
||||
licks: [
|
||||
{
|
||||
over: 'rnb-maj7-vamp',
|
||||
description: 'Curtis Mayfield figure (standard-tuning adaptation — Mayfield tuned to open F♯): hammer cells inside the held shape, landing on the 3rd and the 9th. The direct ancestor of Little Wing.',
|
||||
tab: ' Figure A (land on the 3rd) Figure B (land on the 9th→7th)\ne|---5h7p5--------| e|--5-------5------5------\nB|----------7-----| B|-----5h7-----p5---------\nG|----------------| G|---------------------6--\n A B A F# (A) E F# E C# (in D major)',
|
||||
source: 'Premier Guitar "Digging Deeper: Curtis Mayfield"; JustinGuitar "Mayfield & Hendrix Style Fills" (RF-101)',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default {
|
||||
id: 'rnb',
|
||||
label: 'R&B / Neo-soul',
|
||||
feel: 'laid-back 16th',
|
||||
tempoRange: [65, 100],
|
||||
character: 'Extended chords as the baseline (9ths are the floor, not the ceiling), grips slid into from a half-step away, and everything placed slightly behind the drums — the Dilla feel: the precise right amount of wrong.',
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
id: 'rnb-mediant-circle',
|
||||
name: 'Mediant circle (iii7–vi7–ii7–V)',
|
||||
rn: ['iii7', 'vi7', 'ii7', 'V13'],
|
||||
degrees: [4, 9, 2, 7],
|
||||
qualities: ['min7', 'min7', 'min7', 'dom7'],
|
||||
bars: [1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['September — Earth, Wind & Fire (rotation family)', 'the soul turnaround started from the iii'],
|
||||
tip: 'The 1-6-2-5 family entered from the mediant — home is implied for three bars before the V finally points at it. Neo-soul lives in that deferral.',
|
||||
},
|
||||
{
|
||||
id: 'rnb-6251',
|
||||
name: 'Soul 6-2-5-1 (with dominant II)',
|
||||
rn: ['vi7', 'II9', 'V9sus', 'I'],
|
||||
degrees: [9, 2, 7, 0],
|
||||
qualities: ['min7', 'dom7', 'sus4', 'maj'],
|
||||
bars: [1, 1, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ["Isn't She Lovely — Stevie Wonder"],
|
||||
tip: 'The II is a secondary dominant (not the polite ii), and the V arrives as a 9sus — suspended, never quite dominant, melting into the I. Stevie\'s whole cadence language in four chords.',
|
||||
},
|
||||
{
|
||||
id: 'rnb-maj7-vamp',
|
||||
name: 'Two-chord maj7 vamp (Imaj7–IVmaj7)',
|
||||
rn: ['Imaj7', 'IVmaj7'],
|
||||
degrees: [0, 5],
|
||||
qualities: ['maj7', 'maj7'],
|
||||
bars: [2, 2],
|
||||
mode: 'major',
|
||||
songs: ['Waiting in Vain — Bob Marley', "Cruisin' — Smokey Robinson (I–ii7 variant)"],
|
||||
tip: 'Two lush chords trading forever — the song is the texture. The same vamp underpins half of modern bedroom R&B; colour it differently every two bars.',
|
||||
},
|
||||
{
|
||||
id: 'rnb-dorian',
|
||||
name: 'Dorian vamp (i7–IV9, D\'Angelo school)',
|
||||
rn: ['i9', 'IV9'],
|
||||
degrees: [0, 5],
|
||||
qualities: ['min7', 'dom7'],
|
||||
bars: [1, 1],
|
||||
mode: 'dorian',
|
||||
songs: ["Spanish Joint — D'Angelo", "Didn't Cha Know — Erykah Badu (i–iv minor variant)", "Brown Sugar — D'Angelo (E Dorian loop)"],
|
||||
tip: 'The IV9 carries the raised 6th that makes it Dorian — the same pair as the funk vamp, slowed to 70 BPM and dragged behind the beat.',
|
||||
},
|
||||
{
|
||||
id: 'rnb-gospel-amen',
|
||||
name: 'Gospel Amen (IV–iv–I)',
|
||||
rn: ['IVmaj7', 'iv6', 'Imaj7'],
|
||||
degrees: [5, 5, 0],
|
||||
qualities: ['maj7', 'min6', 'maj7'],
|
||||
bars: [1, 1, 2],
|
||||
mode: 'major',
|
||||
songs: ['the gospel "Amen" cadence', 'countless soul ballad turnarounds (borrowed iv)'],
|
||||
tip: 'The IV turns minor on its way home — borrowed from the parallel minor, the single most-borrowed chord in soul. One semitone (the 3rd of the IV falling) does all the work.',
|
||||
},
|
||||
]
|
||||
@@ -1,212 +0,0 @@
|
||||
// Rock guitar pack. Shapes verified by note-spelling against: guitarplayer.com
|
||||
// (Malcolm Young, Hendrix rhythm rules), musicradar.com (Keith Richards grips,
|
||||
// Andy Summers add9), appliedguitartheory.com (triads on string sets),
|
||||
// fundamental-changes.com (open-G translations), justinguitar.com (unison bends).
|
||||
|
||||
// Power chords — the rock engine room. omit3: works over major or minor.
|
||||
const P5R_6 = { rootStr: 6, offsets: [0, 2, 2, 'x', 'x', 'x'], fingers: [1, 3, 4, 0, 0, 0] } // R-5-R
|
||||
const P5R_5 = { rootStr: 5, offsets: ['x', 0, 2, 2, 'x', 'x'], fingers: [0, 1, 3, 4, 0, 0] }
|
||||
|
||||
// Full barres.
|
||||
const E_BARRE_MAJ = { rootStr: 6, offsets: [0, 2, 2, 1, 0, 0], fingers: [1, 3, 4, 2, 1, 1] }
|
||||
const A_BARRE_MAJ = { rootStr: 5, offsets: ['x', 0, 2, 2, 2, 0], fingers: [0, 1, 2, 3, 4, 1] }
|
||||
const E_BARRE_MIN = { rootStr: 6, offsets: [0, 2, 2, 0, 0, 0], fingers: [1, 3, 4, 1, 1, 1] }
|
||||
|
||||
// Open-position big chords (Malcolm Young) — render only when the chord root matches.
|
||||
const OPEN_A = { onlyRoot: 9, frets: ['x', 0, 2, 2, 2, 0], fingers: [0, 0, 1, 2, 3, 0] }
|
||||
const OPEN_G = { onlyRoot: 7, frets: [3, 2, 0, 0, 0, 3], fingers: [2, 1, 0, 0, 0, 3] }
|
||||
const OPEN_D = { onlyRoot: 2, frets: ['x', 'x', 0, 2, 3, 2], fingers: [0, 0, 0, 1, 3, 2] }
|
||||
|
||||
// Triads on the top string sets — the second-guitar register.
|
||||
const TRIAD_GBE_MAJ = { rootStr: 3, offsets: ['x', 'x', 'x', 0, 0, -2], fingers: [0, 0, 0, 3, 4, 1] } // R-3-5
|
||||
const TRIAD_GBE_MAJ_INV1 = { rootStr: 1, offsets: ['x', 'x', 'x', 1, 0, 0], fingers: [0, 0, 0, 2, 1, 1] } // 3-5-R
|
||||
const TRIAD_GBE_MIN_INV1 = { rootStr: 1, offsets: ['x', 'x', 'x', 0, 0, 0], fingers: [0, 0, 0, 1, 1, 1] } // ♭3-5-R barre
|
||||
const TRIAD_DGB_MAJ = { rootStr: 4, offsets: ['x', 'x', 0, -1, -2, 'x'], fingers: [0, 0, 3, 2, 1, 0] } // R-3-5
|
||||
const TRIAD_DGB_MIN = { rootStr: 4, offsets: ['x', 'x', 0, -2, -2, 'x'], fingers: [0, 0, 3, 1, 1, 0] } // R-♭3-5
|
||||
|
||||
// Hendrix/Frusciante thumb-over E-shape — A string muted, fingers free to embellish.
|
||||
const THUMB_E = { rootStr: 6, offsets: [0, 'x', 2, 1, 0, 0], fingers: [0, 0, 3, 2, 1, 1] }
|
||||
|
||||
// Police-style add9 stretches (clean arpeggio picking).
|
||||
const ADD9_ARP = { rootStr: 6, offsets: [0, 'x', 2, 1, 0, 2], fingers: [1, 0, 3, 2, 1, 4] } // R-R-3-5-9
|
||||
const MADD9_ARP = { rootStr: 6, offsets: [0, 'x', 2, 0, 0, 2], fingers: [1, 0, 3, 1, 1, 4] } // R-R-♭3-5-9
|
||||
|
||||
export default {
|
||||
styleIntro:
|
||||
'Rock rhythm guitar is arrangement: the same chords played as muted chugs, open-string detonations, or high triad shimmer depending on the section. With two guitars, split the register — one low and thick, one high and thin — and never share an octave.',
|
||||
|
||||
comping: [
|
||||
{
|
||||
label: 'Straight-8ths downstroke drive',
|
||||
rhythm: '♪♪♪♪♪♪♪♪ all downstrokes',
|
||||
description: 'Relentless eighth-note downpicks on power chords, slight palm mute, no gaps — the punk/Ramones engine. Only works if the other guitar stays sparse or high.',
|
||||
},
|
||||
{
|
||||
label: 'Malcolm Young space',
|
||||
rhythm: 'accent hits + deliberate rests',
|
||||
description: 'Big open-position chords hit hard with silence between — the rests ARE the hook. Low gain, all downstrokes, let the band breathe.',
|
||||
},
|
||||
{
|
||||
label: 'Palm-muted chug with pushes',
|
||||
rhythm: 'muted 8ths/16ths, lift on the and-of-4',
|
||||
description: 'Muted root chug; lift the palm for full-ring accents on anticipations. High note-density, low spectral density — leaves room above.',
|
||||
},
|
||||
{
|
||||
label: 'Keith Richards sus figure',
|
||||
rhythm: 'hammer sus4/6 on the offbeats',
|
||||
description: 'Hold the A-shape barre, hammer the sus4 (B string +1) and the 6th (top string +2) on the "ands", pull back to the triad. The Start Me Up / Brown Sugar move.',
|
||||
},
|
||||
{
|
||||
label: 'Clean add9 arpeggio picking',
|
||||
rhythm: 'steady picked 8ths, light mute',
|
||||
description: 'Hold the add9 stretch and pick through it string by string — the Every Breath You Take verse texture everyone else lays out under.',
|
||||
},
|
||||
],
|
||||
|
||||
plays: {
|
||||
'rock-mixo-vamp': [
|
||||
{
|
||||
label: 'Big open chords (Malcolm)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: OPEN_A, note: 'I — open A, all six strings of it' },
|
||||
{ shape: OPEN_G, note: '♭VII — open G' },
|
||||
{ shape: OPEN_D, note: 'IV — open D' },
|
||||
],
|
||||
tips: 'The open-chord version lives in the key of A (A–G–D). Hit hard, then shut up — the space between hits is the AC/DC trick. In other keys, use the barre or triad play.',
|
||||
},
|
||||
{
|
||||
label: 'Second-guitar triads, top strings',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: TRIAD_GBE_MAJ, note: 'I up high' },
|
||||
{ shape: TRIAD_GBE_MAJ_INV1, note: '♭VII — nearest inversion, no jump' },
|
||||
{ shape: TRIAD_GBE_MAJ, note: 'IV' },
|
||||
],
|
||||
tips: 'This is the guitar-2 part: triads above fret 5 while someone else owns the low end. Move to the nearest inversion, not the same shape up the neck.',
|
||||
},
|
||||
],
|
||||
|
||||
'rock-145': [
|
||||
{
|
||||
label: 'Barres, straight-8ths drive',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: E_BARRE_MAJ, note: 'I — 6th-string root' },
|
||||
{ shape: A_BARRE_MAJ, note: 'IV — same fret, root string up' },
|
||||
{ shape: A_BARRE_MAJ, note: 'V — two frets up' },
|
||||
],
|
||||
tips: 'Same I/IV/V geometry as a blues: one position, two grips. Verse = palm-muted, chorus = open. The dynamic contrast is the part.',
|
||||
},
|
||||
{
|
||||
label: 'Keith sus figure on the IV and V',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: A_BARRE_MAJ, note: 'hammer the sus4 (B string +1) on the and-beats' },
|
||||
{ shape: A_BARRE_MAJ, note: 'same figure — sus4 and 6th (top string +2) trading' },
|
||||
{ shape: A_BARRE_MAJ, note: 'resolve the hammer INTO the change' },
|
||||
],
|
||||
tips: 'The grip stays still; two fingers do the talking. Swing the hammers slightly even over straight drums — that lag is the Stones feel.',
|
||||
},
|
||||
],
|
||||
|
||||
'rock-minor-descent': [
|
||||
{
|
||||
label: 'Barres walking down the 6th string',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: E_BARRE_MIN, note: 'i' },
|
||||
{ shape: E_BARRE_MAJ, note: '♭VII — two frets down' },
|
||||
{ shape: E_BARRE_MAJ, note: '♭VI — two more' },
|
||||
{ shape: E_BARRE_MAJ, note: 'V — one more half-step, the borrowed pull home' },
|
||||
],
|
||||
tips: 'The whole progression is the low-E string walking down: root, -2, -4, -5 frets. Let the audience hear that bassline inside your chords.',
|
||||
},
|
||||
{
|
||||
label: 'High triads (Watchtower texture)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: TRIAD_DGB_MIN, note: 'i — middle-string triad' },
|
||||
{ shape: TRIAD_DGB_MAJ, note: '♭VII' },
|
||||
{ shape: TRIAD_DGB_MAJ, note: '♭VI' },
|
||||
{ shape: TRIAD_DGB_MAJ, note: 'V' },
|
||||
],
|
||||
tips: 'Strum these as 16th-note skanks or let them ring — either way you\'re the texture, not the foundation. Classic when keys or a second guitar hold the low end.',
|
||||
},
|
||||
],
|
||||
|
||||
'rock-axis': [
|
||||
{
|
||||
label: 'Power-chord chug',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: P5R_6, omit3: true, note: 'I' },
|
||||
{ shape: P5R_6, omit3: true, note: 'V' },
|
||||
{ shape: P5R_6, omit3: true, note: 'vi — same shape; the bass note carries the minor' },
|
||||
{ shape: P5R_5, omit3: true, note: 'IV — 5th-string root keeps you in position' },
|
||||
],
|
||||
tips: 'No 3rds anywhere — major and minor come from the root motion, which is why one shape plays the whole loop. Save the full barres for the last chorus.',
|
||||
},
|
||||
{
|
||||
label: 'Add9 arpeggio verse (Police)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: ADD9_ARP, extensions: ['9'], note: 'pick through, low to high, let it ring' },
|
||||
{ shape: ADD9_ARP, extensions: ['9'], note: '' },
|
||||
{ shape: MADD9_ARP, extensions: ['9'], note: 'minor add9 — one finger lifts' },
|
||||
{ shape: ADD9_ARP, extensions: ['9'], note: '' },
|
||||
],
|
||||
tips: 'It\'s a stretch — practise the grip high on the neck first, then move down. Light palm mute, clean tone, metronomic 8ths: the part is a clock, not a riff.',
|
||||
},
|
||||
],
|
||||
|
||||
'rock-riff-cell': [
|
||||
{
|
||||
label: 'Power chords (Smoke on the Water)',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: P5R_6, omit3: true, note: 'i' },
|
||||
{ shape: P5R_6, omit3: true, note: '♭III — three frets up, same shape' },
|
||||
{ shape: P5R_6, omit3: true, note: 'IV — two more' },
|
||||
],
|
||||
tips: 'Root–♭3–4 on one string is the riff; the power chords are it harmonised. The famous version is two-note 4ths on the middle strings — same cell.',
|
||||
},
|
||||
{
|
||||
label: 'Thumb-over with embellishments',
|
||||
level: 'intermediate',
|
||||
chords: [
|
||||
{ shape: E_BARRE_MIN, note: 'i' },
|
||||
{ shape: THUMB_E, note: '♭III — thumb takes the bass, hammer the sus4 (G string +1)' },
|
||||
{ shape: THUMB_E, note: 'IV — add the 9 with the pinky (top string +2)' },
|
||||
],
|
||||
tips: 'The Hendrix/Frusciante device: thumb frets the root, the freed fingers decorate inside the chord. The Dorian IV is where the colour lives — lean on it.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
improv: {
|
||||
scales: [
|
||||
{ over: 'i / I', scale: 'minor', why: 'Minor pentatonic box 1 plus the ♭5 (blues scale) is the rock default over almost everything.' },
|
||||
{ over: 'I (country-rock)', scale: 'major', why: 'Major pentatonic for the Skynyrd/Allman sweetness — same box shape three frets down from the minor one.' },
|
||||
{ over: 'I–♭VII–IV', scale: 'mixolydian', why: 'Major pentatonic + the ♭7 and 4. The ♭7 of the key IS the root of the ♭VII chord — free target note.' },
|
||||
{ over: 'i–♭VII–♭VI', scale: 'minor', why: 'Natural minor (Aeolian): minor pentatonic + the 2 and ♭6 for the stepwise colour.' },
|
||||
{ over: 'i–♭III–IV', scale: 'dorian', why: 'The major IV asks for Dorian — minor pentatonic + the natural 6.' },
|
||||
],
|
||||
targetNotes:
|
||||
'Root and 5th are safe everywhere; the 3rd of the chord of the moment is the pro move. Double-stops are rock\'s vocabulary: Berry 4ths on the top two strings, unison bends, and the quarter-step blues curl on the ♭3.',
|
||||
licks: [
|
||||
{
|
||||
over: 'rock-145',
|
||||
description: 'Chuck Berry double-stop intro figure (Johnny B. Goode device, shown in A): one-finger barre on the top two strings, slid in from below, hammered in triplets.',
|
||||
tab: 'e|--5--5--5--5--5--5--5--5--5--5--5--5--\nB|3/5--5--5--5--5--5--5--5--5--5--5--5--\n (slide in; A on e + E on B = root+5th of A)',
|
||||
source: 'Chuck Berry, "Johnny B. Goode" (1958); JustinGuitar song lesson SB-425',
|
||||
},
|
||||
{
|
||||
over: 'rock-axis',
|
||||
description: 'Box-1 unison bend cliché: bend the B-string ♭7 a whole step up to the root against the same note held on the top string (Hendrix "Purple Haze" outro, Page, May).',
|
||||
tab: 'e|---5------5------5------5----\nB|--8b10---8b10---8b10---8b10--\n (G bent to A against the A on e-string 5)',
|
||||
source: 'JustinGuitar unison bend technique BL-607; Happy Bluesman unison bends',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default {
|
||||
id: 'rock',
|
||||
label: 'Rock',
|
||||
feel: 'straight',
|
||||
tempoRange: [80, 180],
|
||||
character: 'Straight eighths, big chords, and arrangement-as-dynamics: the same progression whispered in the verse and detonated in the chorus.',
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
id: 'rock-mixo-vamp',
|
||||
name: 'Mixolydian vamp (I–♭VII–IV)',
|
||||
rn: ['I', '♭VII', 'IV'],
|
||||
degrees: [0, 10, 5],
|
||||
qualities: ['maj', 'maj', 'maj'],
|
||||
bars: [2, 1, 1],
|
||||
mode: 'mixolydian',
|
||||
songs: ['Sweet Home Alabama — Lynyrd Skynyrd', 'Sympathy for the Devil — Rolling Stones'],
|
||||
tip: 'The ♭VII is the rock sound — borrowed flat-seven instead of the polite V. (Sweet Home Alabama famously also parses as V–IV–I; the band played it I-centred.)',
|
||||
},
|
||||
{
|
||||
id: 'rock-145',
|
||||
name: 'I–IV–V',
|
||||
rn: ['I', 'IV', 'V'],
|
||||
degrees: [0, 5, 7],
|
||||
qualities: ['maj', 'maj', 'maj'],
|
||||
bars: [2, 1, 1],
|
||||
mode: 'major',
|
||||
songs: ['Wild Thing — The Troggs', 'Twist and Shout — The Beatles', 'La Bamba'],
|
||||
tip: 'Three chords, no slack: the arrangement does the work. Decide per section how big each hit is — that decision is the song.',
|
||||
},
|
||||
{
|
||||
id: 'rock-minor-descent',
|
||||
name: 'Minor descent (i–♭VII–♭VI–V)',
|
||||
rn: ['i', '♭VII', '♭VI', 'V'],
|
||||
degrees: [0, 10, 8, 7],
|
||||
qualities: ['min', 'maj', 'maj', 'maj'],
|
||||
bars: [1, 1, 1, 1],
|
||||
mode: 'minor',
|
||||
songs: ['Hit the Road Jack — Ray Charles', 'Sultans of Swing — Dire Straits (chorus tail)', 'All Along the Watchtower (without the V)'],
|
||||
tip: 'A staircase to the V: the major V is borrowed from harmonic minor and is what yanks the loop home. Drop it and you get the floatier Watchtower version.',
|
||||
},
|
||||
{
|
||||
id: 'rock-axis',
|
||||
name: 'Axis (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: ['With or Without You — U2', 'Let It Be — The Beatles'],
|
||||
tip: 'The same four chords carry a whisper or a stadium — rock\'s job is choosing which. Start the loop on the vi for the dark version.',
|
||||
},
|
||||
{
|
||||
id: 'rock-riff-cell',
|
||||
name: 'Riff cell (i–♭III–IV)',
|
||||
rn: ['i', '♭III', 'IV'],
|
||||
degrees: [0, 3, 5],
|
||||
qualities: ['min', 'maj', 'maj'],
|
||||
bars: [2, 1, 1],
|
||||
mode: 'dorian',
|
||||
songs: ['Smoke on the Water — Deep Purple', 'Iron Man — Black Sabbath (spine of the riff)'],
|
||||
tip: 'Root–♭3–4 is THE rock riff cell, lifted straight from minor pentatonic. The major IV makes it Dorian — the app\'s Dorian mode lights up exactly these notes.',
|
||||
},
|
||||
]
|
||||
@@ -1,873 +0,0 @@
|
||||
import { NOTES, CHORD_TYPES } from './theory'
|
||||
|
||||
// ─── Parse helper (self-contained, mirrors voicings.js) ───────────────────────
|
||||
export function parseChord(name) {
|
||||
if (!name) return null
|
||||
const FLAT = ['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B']
|
||||
let rest = name
|
||||
let root = rest.length > 1 && (rest[1] === '#' || rest[1] === 'b')
|
||||
? (rest = rest.slice(2), name.slice(0, 2))
|
||||
: (rest = rest.slice(1), name.slice(0, 1))
|
||||
let rootPc = NOTES.indexOf(root)
|
||||
if (rootPc === -1) rootPc = FLAT.indexOf(root)
|
||||
if (rootPc === -1) return null
|
||||
const suffixMap = {
|
||||
'': 'maj', 'm': 'min', 'min': 'min', 'maj': 'maj',
|
||||
'7': 'dom7', 'maj7': 'maj7', 'm7': 'min7', 'min7': 'min7',
|
||||
'dim': 'dim', 'dim7': 'dim7', 'm7b5': 'half_dim', 'ø': 'half_dim',
|
||||
'aug': 'aug', '+': 'aug',
|
||||
'sus4': 'sus4', 'sus2': 'sus2',
|
||||
'6': 'maj6', 'm6': 'min6', 'add9': 'add9',
|
||||
}
|
||||
return { rootPc, type: suffixMap[rest] ?? 'maj' }
|
||||
}
|
||||
|
||||
function chordName(rootPc, type) {
|
||||
return NOTES[rootPc] + (CHORD_TYPES[type]?.suffix ?? '')
|
||||
}
|
||||
|
||||
// ─── Famous progressions ──────────────────────────────────────────────────────
|
||||
// degrees[] — semitone offsets from tonic
|
||||
// qualities[] — chord type keys (CHORD_TYPES) for each degree
|
||||
// mode — the mode this progression is naturally written in
|
||||
// styleVariations — how genre players re-harmonise these chords
|
||||
|
||||
export const FAMOUS_PROGRESSIONS = [
|
||||
{
|
||||
id: 'axis',
|
||||
name: 'Axis Progression',
|
||||
pattern: 'I – V – vi – IV',
|
||||
degrees: [0, 7, 9, 5],
|
||||
qualities: ['maj', 'maj', 'min', 'maj'],
|
||||
mode: 'major',
|
||||
genre: ['Pop', 'Rock'],
|
||||
songs: ['Let It Be — Beatles', 'With or Without You — U2', 'Someone Like You — Adele', "Don't Stop Believin' — Journey", 'Wonderwall — Oasis', 'Demons — Imagine Dragons'],
|
||||
description: 'The defining progression of modern pop. Works in every genre and tempo.',
|
||||
tip: 'Try starting on the vi instead — suddenly it feels darker and more yearning.',
|
||||
styleVariations: [
|
||||
{ label: 'Jazz', pattern: 'Imaj7 – V7 – vim7 – IVmaj7', qualities: ['maj7','dom7','min7','maj7'] },
|
||||
{ label: 'Soul', pattern: 'Imaj9 – V9 – vim9 – IVmaj9', qualities: ['maj7','dom7','min7','maj7'] },
|
||||
{ label: 'Blues', pattern: 'I7 – V7 – vi7 – IV7', qualities: ['dom7','dom7','dom7','dom7'] },
|
||||
{ label: 'Ambient', pattern: 'Isus2 – Vsus2 – vim7 – IVadd9', qualities: ['sus2','sus2','min7','add9'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fifties',
|
||||
name: '50s / Doo-Wop',
|
||||
pattern: 'I – vi – IV – V',
|
||||
degrees: [0, 9, 5, 7],
|
||||
qualities: ['maj', 'min', 'maj', 'maj'],
|
||||
mode: 'major',
|
||||
genre: ['Pop', 'Doo-Wop', 'Rock'],
|
||||
songs: ['Stand By Me — Ben E. King', 'Earth Angel', 'Blue Moon', 'Unchained Melody', 'Every Breath You Take — Police'],
|
||||
description: 'The doo-wop backbone. Sweet, nostalgic, universally singable and timeless.',
|
||||
tip: "The vi chord is the emotional pivot — it's the same root as in the Axis, just a different order.",
|
||||
styleVariations: [
|
||||
{ label: 'Jazz', pattern: 'Imaj7 – vim7 – IVmaj7 – V7', qualities: ['maj7','min7','maj7','dom7'] },
|
||||
{ label: 'Soul', pattern: 'I6 – vim7 – IV – V7sus4', qualities: ['maj6','min7','maj','sus4'] },
|
||||
{ label: 'Funk', pattern: 'Imaj9 – vim9 – IV9 – V9', qualities: ['maj7','min7','dom7','dom7'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'canon',
|
||||
name: 'Canon / Pachelbel',
|
||||
pattern: 'I – V – vi – iii – IV – I – IV – V',
|
||||
degrees: [0, 7, 9, 4, 5, 0, 5, 7],
|
||||
qualities: ['maj','maj','min','min','maj','maj','maj','maj'],
|
||||
mode: 'major',
|
||||
genre: ['Classical', 'Pop', 'Rock'],
|
||||
songs: ['Canon in D — Pachelbel', 'Basket Case — Green Day', 'Go West — Pet Shop Boys', 'Graduation — Vitamin C'],
|
||||
description: 'Baroque timelessness. The descending bass line creates inevitable forward motion.',
|
||||
tip: 'The iii chord is the secret ingredient — it bridges vi and IV with aristocratic weight.',
|
||||
styleVariations: [
|
||||
{ label: 'Rock', pattern: 'Power chords all the way down', qualities: [] },
|
||||
{ label: 'Neo-soul',pattern: 'Imaj9 – V9 – vim9 – iiim7 – IVmaj9', qualities: ['maj7','dom7','min7','min7','maj7'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'two_five_one',
|
||||
name: 'ii – V – I (Jazz)',
|
||||
pattern: 'iim7 – V7 – Imaj7',
|
||||
degrees: [2, 7, 0],
|
||||
qualities: ['min7', 'dom7', 'maj7'],
|
||||
mode: 'major',
|
||||
genre: ['Jazz', 'Bossa Nova'],
|
||||
songs: ['Autumn Leaves', 'All The Things You Are', 'Fly Me To The Moon', 'The Girl From Ipanema', 'Misty'],
|
||||
description: 'The bedrock of jazz harmony. The tritone in V7 resolves to I with beautiful tension.',
|
||||
tip: 'The ii chord pre-resolves the V — together they create an inevitable pull to the I.',
|
||||
styleVariations: [
|
||||
{ label: 'Bossa', pattern: 'iim7 – V7(9) – Imaj9', qualities: ['min7','dom7','maj7'] },
|
||||
{ label: 'Bebop', pattern: 'iim7 – V7(♭9) – Imaj7(#11)', qualities: ['min7','dom7','maj7'] },
|
||||
{ label: 'Modal', pattern: 'im7 – IV7 (Dorian vamp)', qualities: ['min7','dom7'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'blues_12',
|
||||
name: '12-Bar Blues',
|
||||
pattern: 'I – I – I – I – IV – IV – I – I – V – IV – I – V',
|
||||
degrees: [0, 0, 0, 0, 5, 5, 0, 0, 7, 5, 0, 7],
|
||||
qualities: ['dom7','dom7','dom7','dom7','dom7','dom7','dom7','dom7','dom7','dom7','dom7','dom7'],
|
||||
mode: 'major',
|
||||
genre: ['Blues', 'Rock', 'Jazz'],
|
||||
songs: ['Johnny B. Goode — Chuck Berry', 'Pride & Joy — SRV', 'Crossroads — Robert Johnson', 'Hound Dog', 'Folsom Prison Blues — Cash'],
|
||||
description: 'The foundation of rock and blues. Master this and you can jam with anyone on Earth.',
|
||||
tip: 'All three chords are dominant 7ths — that dissonance is what makes blues feel so restless.',
|
||||
styleVariations: [
|
||||
{ label: 'Shuffle', pattern: 'I7 – IV7 – V7 with shuffle rhythm', qualities: ['dom7','dom7','dom7'] },
|
||||
{ label: 'Jazz', pattern: 'Imaj7 – IV7 – iim7 – V7 quick changes', qualities: ['maj7','dom7','min7','dom7'] },
|
||||
{ label: 'Minor', pattern: 'im7 – ivm7 – vm7 (minor blues)', qualities: ['min7','min7','min7'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'andalusian',
|
||||
name: 'Andalusian Cadence',
|
||||
pattern: 'i – ♭VII – ♭VI – V',
|
||||
degrees: [0, 10, 8, 7],
|
||||
qualities: ['min', 'maj', 'maj', 'maj'],
|
||||
mode: 'minor',
|
||||
genre: ['Flamenco', 'Rock', 'Classical'],
|
||||
songs: ['Stairway to Heaven intro — Led Zeppelin', 'Hit the Road Jack', 'Sultans of Swing — Dire Straits', 'White Christmas'],
|
||||
description: 'Descending bass line creates unstoppable forward motion. Timeless, dramatic, inevitable.',
|
||||
tip: 'The final V chord (major) is the harmonic surprise in a minor context — forces resolution.',
|
||||
styleVariations: [
|
||||
{ label: 'Flamenco', pattern: 'im – ♭VII – ♭VI – V7', qualities: ['min','maj','maj','dom7'] },
|
||||
{ label: 'Rock', pattern: 'im5 – ♭VII5 – ♭VI5 – V5 (power)', qualities: ['min','maj','maj','maj'] },
|
||||
{ label: 'Jazz', pattern: 'im(maj7) – ♭VIImaj7 – ♭VImaj7 – V7(#9)', qualities: ['min7','maj7','maj7','dom7'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'minor_anthem',
|
||||
name: 'Minor Anthem',
|
||||
pattern: 'i – ♭VI – ♭III – ♭VII',
|
||||
degrees: [0, 8, 3, 10],
|
||||
qualities: ['min', 'maj', 'maj', 'maj'],
|
||||
mode: 'minor',
|
||||
genre: ['Rock', 'Pop', 'Metal'],
|
||||
songs: ['Numb — Linkin Park', 'In The End — Linkin Park', 'Boulevard of Broken Dreams — Green Day', 'Creep — Radiohead', 'Smells Like Teen Spirit — Nirvana'],
|
||||
description: 'The anthem of angst. Powerful, relentless, emotionally direct.',
|
||||
tip: 'Every chord is major except the tonic — the contrast makes the i feel even more desperate.',
|
||||
styleVariations: [
|
||||
{ label: 'Stripped', pattern: 'im7 – ♭VImaj7 – ♭IIImaj7 – ♭VIImaj7', qualities: ['min7','maj7','maj7','maj7'] },
|
||||
{ label: 'Epic', pattern: 'im – ♭VI – ♭III – ♭VII with sus2 variants', qualities: ['min','maj','maj','maj'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'minor_oscillate',
|
||||
name: 'Minor Oscillation',
|
||||
pattern: 'i – ♭VII – ♭VI – ♭VII',
|
||||
degrees: [0, 10, 8, 10],
|
||||
qualities: ['min', 'maj', 'maj', 'maj'],
|
||||
mode: 'minor',
|
||||
genre: ['Rock', 'Folk', 'Pop'],
|
||||
songs: ['All Along the Watchtower — Dylan/Hendrix', "Knockin' on Heaven's Door — Dylan", 'Pumped Up Kicks — Foster the People', 'Africa — Toto'],
|
||||
description: 'The ♭VII oscillates back and forth — creates hypnotic looping energy.',
|
||||
tip: 'Works as a 2-bar vamp (i – ♭VII) or a full 4-bar loop. The ♭VI adds breathing room.',
|
||||
styleVariations: [
|
||||
{ label: 'Folk', pattern: 'im – ♭VII – ♭VI – ♭VII fingerpicked', qualities: ['min','maj','maj','maj'] },
|
||||
{ label: 'Rock', pattern: 'im – ♭VII – ♭VI power chords', qualities: ['min','maj','maj','maj'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mixolydian',
|
||||
name: 'Mixolydian Rock',
|
||||
pattern: 'I – ♭VII – IV',
|
||||
degrees: [0, 10, 5],
|
||||
qualities: ['maj', 'maj', 'maj'],
|
||||
mode: 'major',
|
||||
genre: ['Rock', 'Folk', 'Celtic'],
|
||||
songs: ['Sweet Home Alabama — Lynyrd Skynyrd', 'La Grange — ZZ Top', 'Werewolves of London — Warren Zevon', 'Norwegian Wood — Beatles'],
|
||||
description: 'The ♭VII chord defines Mixolydian mode. Swagger and swagger only.',
|
||||
tip: 'In standard major, the VII is diminished. Flattening it to ♭VII gives you a major chord — that shift is everything.',
|
||||
styleVariations: [
|
||||
{ label: 'Celtic', pattern: 'I – ♭VII – IV – I alternating', qualities: ['maj','maj','maj','maj'] },
|
||||
{ label: 'Funk', pattern: 'I9 – ♭VII9 – IV9 dominant 9ths', qualities: ['dom7','dom7','dom7'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dorian_vamp',
|
||||
name: 'Dorian Vamp',
|
||||
pattern: 'i – IV',
|
||||
degrees: [0, 5],
|
||||
qualities: ['min', 'maj'],
|
||||
mode: 'minor',
|
||||
genre: ['Rock', 'Jazz', 'Funk'],
|
||||
songs: ['Oye Como Va — Santana', 'So What — Miles Davis', 'Scarborough Fair', 'Eleanor Rigby verse — Beatles', 'Mad World — Tears for Fears'],
|
||||
description: 'The major IV over a minor i chord signals Dorian mode. Smooth, open, sophisticated.',
|
||||
tip: 'Natural minor would have a minor iv. The major IV is Dorian\'s signature — it feels both sad and groovy.',
|
||||
styleVariations: [
|
||||
{ label: 'Jazz', pattern: 'im7 – IV7 (comp beneath a soloist)', qualities: ['min7','dom7'] },
|
||||
{ label: 'Funk', pattern: 'im9 – IV13 (layered synth and guitar)', qualities: ['min7','dom7'] },
|
||||
{ label: 'Rock', pattern: 'im – IV – im – IV (guitar riff)', qualities: ['min','maj'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'jazz_turnaround',
|
||||
name: 'Jazz Turnaround',
|
||||
pattern: 'I – vi – ii – V',
|
||||
degrees: [0, 9, 2, 7],
|
||||
qualities: ['maj7', 'min7', 'min7', 'dom7'],
|
||||
mode: 'major',
|
||||
genre: ['Jazz', 'Swing'],
|
||||
songs: ['I Got Rhythm — Gershwin', 'Rhythm Changes', 'How High the Moon', 'countless jazz standards'],
|
||||
description: 'The jazz turnaround — loops back to the top of the form with elegant inevitability.',
|
||||
tip: 'Each chord resolves down a fifth to the next. That chain of fifths is what makes jazz sound "right".',
|
||||
styleVariations: [
|
||||
{ label: 'Bebop', pattern: 'Imaj7 – vim7 – iim7 – V7(♭9)', qualities: ['maj7','min7','min7','dom7'] },
|
||||
{ label: 'Tritone', pattern: 'Imaj7 – ♭III7 – iim7 – ♭II7 (tritone subs)', qualities: ['maj7','dom7','min7','dom7'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'phrygian',
|
||||
name: 'Phrygian Tension',
|
||||
pattern: 'i – ♭II',
|
||||
degrees: [0, 1],
|
||||
qualities: ['min', 'maj'],
|
||||
mode: 'minor',
|
||||
genre: ['Flamenco', 'Metal', 'Film'],
|
||||
songs: ['Game of Thrones theme', 'Spanish flamenco standards', 'heavy metal riffs', 'El Tango de Roxanne'],
|
||||
description: 'The ♭II (Neapolitan) chord creates extreme tension. Spanish fire and metal darkness.',
|
||||
tip: 'Just two chords — the half-step relationship between roots is what makes it feel so tense.',
|
||||
styleVariations: [
|
||||
{ label: 'Flamenco', pattern: 'im – ♭II – im with fast strumming', qualities: ['min','maj','min'] },
|
||||
{ label: 'Metal', pattern: 'im5 – ♭II5 power chord riff', qualities: ['min','maj'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'minor_pop',
|
||||
name: 'Sad Pop Minor',
|
||||
pattern: 'vi – IV – I – V',
|
||||
degrees: [9, 5, 0, 7],
|
||||
qualities: ['min', 'maj', 'maj', 'maj'],
|
||||
mode: 'major',
|
||||
genre: ['Pop', 'Rock', 'Indie'],
|
||||
songs: ['Zombie — Cranberries', 'Torn — Natalie Imbruglia', 'Apologize — Timbaland', 'Fix You — Coldplay'],
|
||||
description: 'Same chords as the Axis — just starting on the vi. Instantly feels darker and more yearning.',
|
||||
tip: 'Which chord you start on changes everything emotionally. This is the Axis heard through minor eyes.',
|
||||
styleVariations: [
|
||||
{ label: 'Soul', pattern: 'vim7 – IVmaj7 – Imaj7 – V9', qualities: ['min7','maj7','maj7','dom7'] },
|
||||
{ label: 'Indie', pattern: 'vim7 – IVadd9 – Iadd9 – Vsus4', qualities: ['min7','add9','add9','sus4'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lydian_float',
|
||||
name: 'Lydian Float',
|
||||
pattern: 'I – II',
|
||||
degrees: [0, 2],
|
||||
qualities: ['maj', 'maj'],
|
||||
mode: 'major',
|
||||
genre: ['Film', 'Jazz', 'Pop'],
|
||||
songs: ['The Simpsons theme', 'Joe Satriani — Flying in a Blue Dream', 'many John Williams cues', 'Man or Muppet'],
|
||||
description: 'The raised ♯4 in Lydian makes the II chord major instead of minor. Dreamy, floating, magical.',
|
||||
tip: 'In normal major, the II chord is minor. Making it major (Lydian) lifts the whole progression off the ground.',
|
||||
styleVariations: [
|
||||
{ label: 'Film', pattern: 'Imaj7 – IImaj7 slowly held', qualities: ['maj7','maj7'] },
|
||||
{ label: 'Jazz', pattern: 'Imaj7(#11) — the Lydian 7th chord', qualities: ['maj7','maj7'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sensitive',
|
||||
name: 'Sensitive Oscillation',
|
||||
pattern: 'vi – V – IV – V',
|
||||
degrees: [9, 7, 5, 7],
|
||||
qualities: ['min', 'maj', 'maj', 'maj'],
|
||||
mode: 'major',
|
||||
genre: ['Pop', 'Indie'],
|
||||
songs: ['Mad World — Tears for Fears', 'Every Breath You Take — Police', 'Losing My Religion — REM'],
|
||||
description: 'Oscillates between vi and IV through V. Aching, introspective, perpetually unresolved.',
|
||||
tip: 'The V never quite resolves to I — it keeps bouncing back to the IV or vi. That suspension is the emotion.',
|
||||
styleVariations: [
|
||||
{ label: 'Indie', pattern: 'vim7 – Vsus4 – IVadd9 – V', qualities: ['min7','sus4','add9','maj'] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Chord substitution options ───────────────────────────────────────────────
|
||||
// For each chord type: array of { type, label, tip } suggestions
|
||||
export const CHORD_SUBSTITUTIONS = {
|
||||
maj: [
|
||||
{ type: 'maj7', tip: 'Add the major 7th — dreamy jazz colour' },
|
||||
{ type: 'add9', tip: 'Add the 9th — modern, open, Coldplay-ish' },
|
||||
{ type: 'maj6', tip: 'Add major 6th — vintage Django jazz sweetness' },
|
||||
{ type: 'sus2', tip: 'Replace 3rd with 2nd — airy and ambiguous' },
|
||||
{ type: 'sus4', tip: 'Suspend then resolve — creates rhythmic motion' },
|
||||
],
|
||||
min: [
|
||||
{ type: 'min7', tip: 'Add the minor 7th — smooth soul and jazz' },
|
||||
{ type: 'add9', tip: 'Add 9th over minor — bittersweet modern ache (Radiohead)' },
|
||||
{ type: 'min6', tip: 'Add major 6th over minor — flamenco and tango colour' },
|
||||
{ type: 'sus2', tip: 'Remove 3rd entirely — ambiguous and floating' },
|
||||
{ type: 'half_dim', tip: 'Flatten the 5th — half-diminished, much darker' },
|
||||
],
|
||||
dom7: [
|
||||
{ type: 'maj7', tip: 'Raise the 7th — softer, much less tension' },
|
||||
{ type: 'min7', tip: 'Lower the 3rd too — darkens the dominant' },
|
||||
{ type: 'sus4', tip: 'Replace 3rd with 4th — funky unresolved suspension' },
|
||||
{ type: 'dim7', tip: 'Diminished substitute — extreme tension before resolution' },
|
||||
],
|
||||
maj7: [
|
||||
{ type: 'maj', tip: 'Simplify — strip back to triad, rawer feel' },
|
||||
{ type: 'add9', tip: 'Drop 7th, add 9th — brighter and more open' },
|
||||
{ type: 'maj6', tip: 'Swap 7th for 6th — retro jazz, less ethereal' },
|
||||
{ type: 'dom7', tip: 'Flatten 7th — suddenly bluesy with tension' },
|
||||
],
|
||||
min7: [
|
||||
{ type: 'min', tip: 'Strip back — rawer, more aggressive feel' },
|
||||
{ type: 'half_dim', tip: 'Flatten the 5th — half-dim, darker and more tense' },
|
||||
{ type: 'min6', tip: 'Add major 6th — sophisticated jazz minor colour' },
|
||||
{ type: 'dom7', tip: 'Make it dominant — strong pull to resolve' },
|
||||
],
|
||||
dim: [
|
||||
{ type: 'dim7', tip: 'Add dim7 — fully symmetric, equally unstable' },
|
||||
{ type: 'half_dim', tip: 'Half-dim — softer, more melodic tension' },
|
||||
{ type: 'min', tip: 'Simplify to minor — much less dissonant' },
|
||||
],
|
||||
dim7: [
|
||||
{ type: 'dim', tip: 'Remove 7th — triad version, slightly less tense' },
|
||||
{ type: 'half_dim', tip: 'Raise one note to half-dim — softer resolution' },
|
||||
{ type: 'min7', tip: 'Raise ♭5 — from dark to smooth in one note' },
|
||||
],
|
||||
aug: [
|
||||
{ type: 'maj', tip: 'Resolve the aug5 down to 5 — release the tension' },
|
||||
{ type: 'dom7', tip: 'Add ♭7 over aug — double tension before a big resolution' },
|
||||
],
|
||||
sus4: [
|
||||
{ type: 'maj', tip: 'Resolve — drop the 4th to the 3rd (classic sus → maj)' },
|
||||
{ type: 'sus2', tip: 'Switch suspension — from 4th to 2nd, different feel' },
|
||||
{ type: 'dom7', tip: 'Add ♭7 too — 7sus4, funky and unresolved' },
|
||||
],
|
||||
sus2: [
|
||||
{ type: 'maj', tip: 'Fill in the 3rd — resolve the suspension clearly' },
|
||||
{ type: 'sus4', tip: 'Swap 2nd for 4th — different flavour of openness' },
|
||||
{ type: 'add9', tip: 'Add the 3rd back — sus2 becomes a richer add9' },
|
||||
],
|
||||
half_dim: [
|
||||
{ type: 'dim7', tip: 'Add dim7 — more symmetric and tense' },
|
||||
{ type: 'min7', tip: 'Raise the ♭5 to 5 — suddenly much smoother' },
|
||||
{ type: 'min', tip: 'Strip back — simple minor triad' },
|
||||
],
|
||||
maj6: [
|
||||
{ type: 'maj7', tip: 'Swap 6th for 7th — more modern jazz, more ethereal' },
|
||||
{ type: 'add9', tip: 'Replace 6th with 9th — brighter, contemporary feel' },
|
||||
],
|
||||
min6: [
|
||||
{ type: 'min7', tip: 'Swap 6th for 7th — smoother, less exotic' },
|
||||
{ type: 'half_dim', tip: 'Enharmonic trick — min6 and half-dim share notes' },
|
||||
],
|
||||
add9: [
|
||||
{ type: 'maj7', tip: 'Add the 7th — full maj9 sound, very lush' },
|
||||
{ type: 'sus2', tip: 'Remove the 3rd — purely suspended' },
|
||||
{ type: 'maj', tip: 'Strip the 9th — clean triad' },
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Compute chord names for a famous progression in a given key ──────────────
|
||||
export function progressionInKey(famousProg, keyRoot) {
|
||||
const rootPc = NOTES.indexOf(keyRoot)
|
||||
if (rootPc === -1) return []
|
||||
return famousProg.degrees.map((d, i) => {
|
||||
const pc = (rootPc + d) % 12
|
||||
const type = famousProg.qualities[i] ?? 'maj'
|
||||
return chordName(pc, type)
|
||||
})
|
||||
}
|
||||
|
||||
// Style variation chords in a given key
|
||||
export function styleVariationInKey(styleVar, famousProg, keyRoot) {
|
||||
if (!styleVar.qualities?.length) return []
|
||||
const rootPc = NOTES.indexOf(keyRoot)
|
||||
if (rootPc === -1) return []
|
||||
return famousProg.degrees.slice(0, styleVar.qualities.length).map((d, i) => {
|
||||
const pc = (rootPc + d) % 12
|
||||
return chordName(pc, styleVar.qualities[i] ?? 'maj')
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Similarity matching ──────────────────────────────────────────────────────
|
||||
export function findSimilarProgressions(detectedChords, keyInfo) {
|
||||
if (!detectedChords?.length || !keyInfo?.root) return []
|
||||
const rootPc = NOTES.indexOf(keyInfo.root)
|
||||
if (rootPc === -1) return []
|
||||
|
||||
// Convert detected chords to semitone offsets from key root
|
||||
const detectedPcs = detectedChords
|
||||
.map(c => { const p = parseChord(c); return p ? (p.rootPc - rootPc + 12) % 12 : null })
|
||||
.filter(v => v !== null)
|
||||
if (!detectedPcs.length) return []
|
||||
|
||||
const detectedSet = new Set(detectedPcs)
|
||||
const results = []
|
||||
|
||||
for (const prog of FAMOUS_PROGRESSIONS) {
|
||||
const progPcs = prog.degrees.map(d => d % 12)
|
||||
const progSet = new Set(progPcs)
|
||||
|
||||
// Rotation match: does detected sequence appear in famous prog (as cyclic rotation)?
|
||||
let rotScore = 0
|
||||
const compareLen = Math.min(detectedPcs.length, progPcs.length)
|
||||
for (let rot = 0; rot < progPcs.length; rot++) {
|
||||
let hits = 0
|
||||
for (let i = 0; i < compareLen; i++) {
|
||||
if (detectedPcs[i] === progPcs[(rot + i) % progPcs.length]) hits++
|
||||
}
|
||||
rotScore = Math.max(rotScore, hits / compareLen)
|
||||
}
|
||||
|
||||
// Jaccard similarity (chord-set overlap regardless of order)
|
||||
const inter = [...detectedSet].filter(d => progSet.has(d)).length
|
||||
const union = new Set([...detectedSet, ...progSet]).size
|
||||
const jaccardScore = inter / union
|
||||
|
||||
const score = Math.max(rotScore, jaccardScore * 0.8)
|
||||
if (score >= 0.35) results.push({ ...prog, score })
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.score - a.score).slice(0, 4)
|
||||
}
|
||||
|
||||
// ─── Substitutions for a chord name ──────────────────────────────────────────
|
||||
export function getChordSubstitutions(chordName) {
|
||||
const p = parseChord(chordName)
|
||||
if (!p) return []
|
||||
const subs = CHORD_SUBSTITUTIONS[p.type] ?? []
|
||||
return subs.map(sub => ({
|
||||
...sub,
|
||||
chord: NOTES[p.rootPc] + (CHORD_TYPES[sub.type]?.suffix ?? ''),
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── Chord Playbook ───────────────────────────────────────────────────────────
|
||||
// Jam-focused knowledge base per chord type.
|
||||
// jamRole — one sentence on this chord's role in a live jam
|
||||
// voicings — 2-3 voicings with their jam use-case (not fingering lessons)
|
||||
// licks — 2-3 fills/licks with ASCII tab, style-labelled
|
||||
// jamTips — 3 tight bullet points for live playing
|
||||
// loopPractice — 1-2 concrete loop station exercises
|
||||
|
||||
export const CHORD_PLAYBOOK = {
|
||||
maj: {
|
||||
jamRole: 'The home chord in a major key — everything resolves here. Your job is to lock in the groove and hold the harmonic centre while others explore around you.',
|
||||
voicings: [
|
||||
{ name: 'Open position', use: 'Full, resonant. Use when you\'re the only chordal instrument — fills the room. Avoid it in a dense band (too much low-end clash with bass).' },
|
||||
{ name: 'Barre mid-neck (E or A shape)', use: 'Controlled and punchy. Use in a band — you\'re sitting above the bassist\'s register. Good for rock and funk comping.' },
|
||||
{ name: 'Partial 4-string (strings 1–4, upper neck)', use: 'Jazz and funk comping. Stays completely out of the low end. Ideal when there\'s a keys player — you add texture without competing.' },
|
||||
],
|
||||
licks: [
|
||||
{
|
||||
title: 'Major pentatonic walkup',
|
||||
style: 'Rock / Country',
|
||||
tab: 'e|-------------------------------5-7-|\nB|---------------------5-7-8---------|\nG|-----------4-5-7-------------------|\nD|-----4-5-7-------------------------|\nA|-5-7-------------------------------|\n (G major pentatonic — position shift for any key)',
|
||||
tip: 'End on the root or the 3rd for a resolved feel. Use this as a fill between chord changes.',
|
||||
},
|
||||
{
|
||||
title: 'Chord tone arpeggio fill',
|
||||
style: 'Jazz / Funk',
|
||||
tab: 'e|-----5-----------|\nB|---5---8---------|\nG|-5-------7-5-----|\nD|-----------------|\n (C major: R–3–5–high root)',
|
||||
tip: 'Highlight the 3rd and 5th — these define the chord. Avoid the root on a strong beat to keep it moving.',
|
||||
},
|
||||
],
|
||||
jamTips: [
|
||||
'If a bassist is locking down the root, move your voicing up the neck — let them own the low end.',
|
||||
'Behind a soloist: comp short stabs on beats 2 and 4. Full strums on every beat will drown them out.',
|
||||
'Pedal point trick: hold one string on the root and let the chord move underneath — creates harmonic motion without a chord change.',
|
||||
],
|
||||
loopPractice: [
|
||||
{ title: 'Comp loop + fill swap', body: 'Record a 2-bar major chord comp (stabs on beats 2 and 4). Loop it. Improvise fills over the top using the major pentatonic — aim to respond to the rhythm, not just run scales. Swap between "comping mode" and "fill mode" every 4 bars.' },
|
||||
],
|
||||
},
|
||||
|
||||
min: {
|
||||
jamRole: 'Sets the emotional temperature of the jam. Minor chords invite exploration — soloists will look to you for the harmonic anchor. Hold it firmly and let the mood develop.',
|
||||
voicings: [
|
||||
{ name: 'Open minor (Em, Am, Dm)', use: 'Resonant and sustaining. Use when the jam is sparse or acoustic — let it ring while others move around it.' },
|
||||
{ name: 'Barre mid-neck', use: 'Rock and pop jams. Punchy and controlled. Roll slightly onto the index bony edge for clean barres. Palm-mute lightly for a tighter comp.' },
|
||||
{ name: 'Partial upper voicing (4 strings)', use: 'Jazz-funk context. Stays completely out of the bass. Add light muting for a percussive comp that doesn\'t muddy the mix.' },
|
||||
],
|
||||
licks: [
|
||||
{
|
||||
title: 'Minor pentatonic box 1',
|
||||
style: 'Blues / Rock',
|
||||
tab: 'e|--8-5-----------|\nB|------8-5-6-5---|\nG|------------7-5-|\nD|---------7------|\nA|----------------|\n (A minor pentatonic at position 5)',
|
||||
tip: 'The b3 and b7 are your strongest notes over any minor chord. End phrases on one of them.',
|
||||
},
|
||||
{
|
||||
title: 'Dorian groove phrase',
|
||||
style: 'Jazz / Funk',
|
||||
tab: 'e|-------------------|\nB|-------------------|\nG|--2-4-2-4-5-4-2----|\nD|--2-4-2-4---4-2----|\nA|--0----------------|\n (A Dorian — note the F# = natural 6th)',
|
||||
tip: 'The natural 6th (F# in Am Dorian) is what gives Santana, Stevie Wonder, and jazz-funk their brightness over a minor chord.',
|
||||
},
|
||||
],
|
||||
jamTips: [
|
||||
'The minor pentatonic (5 notes) is the safe zone — you can\'t go wrong with it over any minor chord.',
|
||||
'On a long minor vamp, try the Dorian mode (raise the 6th by one semitone) — it shifts the feel from melancholic to funky.',
|
||||
'When the jam sits on a minor chord, play fewer chord tones and more single-note fills in the gaps — let the sustain breathe.',
|
||||
],
|
||||
loopPractice: [
|
||||
{ title: 'Minor vamp + Dorian exploration', body: 'Record a 4-bar minor chord groove with gaps (e.g. hit on beat 1, let it ring, stab again on beat 3). Loop it. Solo over it with minor pentatonic for 2 laps. Then raise the 6th by one semitone (into Dorian) on the next 2 laps. Hear the shift from dark to groove.' },
|
||||
],
|
||||
},
|
||||
|
||||
dom7: {
|
||||
jamRole: 'THE blues chord — in a 12-bar blues jam, every chord is a dom7. In jazz, it\'s the maximum-tension V chord. Either way: play with attitude and resolve with purpose.',
|
||||
voicings: [
|
||||
{ name: 'Full dom7 barre', use: 'Blues rhythm guitar. Combine with the shuffle pattern — bass note then top strings alternating. This IS the sound of a blues jam.' },
|
||||
{ name: 'Shell voicing (root + 3rd + b7, 3 strings only)', use: 'Jazz comping. Three notes, right in the pocket. Leaves room for piano, sax, everything — the most polite voicing you can play.' },
|
||||
{ name: '9th voicing (add the 2nd on top)', use: 'R&B and blues-rock upgrade. One extra note transforms the barre into something sophisticated without changing the function.' },
|
||||
],
|
||||
licks: [
|
||||
{
|
||||
title: 'Blues shuffle figure',
|
||||
style: 'Blues',
|
||||
tab: 'e|--------------------|\nB|--------------------|\nG|--------------------|\nD|--2-4--2-4----------|\nA|--2-4--2-4----------|\nE|--0----0------------|\n (E7 shuffle — repeat; move to A string for A7)',
|
||||
tip: 'The shuffle feel is long-short, long-short (triplet with the middle note removed). Lock this in before speed.',
|
||||
},
|
||||
{
|
||||
title: 'Mixolydian fill',
|
||||
style: 'Jazz / Rock',
|
||||
tab: 'e|--10-8---8-10-8--7---------|\nB|---------10---------10-8---|\nG|---------------------------|\n (G mixolydian: G A B C D E F)',
|
||||
tip: 'Mixolydian = major scale with b7. It\'s the natural scale over any dominant 7th chord — the b7 note is the defining colour.',
|
||||
},
|
||||
],
|
||||
jamTips: [
|
||||
'In a 12-bar blues, every chord is a dom7. The notes that shift between I7, IV7, and V7 are just the 3rd and b7 — learn those for each chord and you can navigate the whole form.',
|
||||
'The b7 note is the single best note to target in a fill over a dom7. It\'s immediately recognisable as the "blues note."',
|
||||
'On the V7 (the most tense moment), one well-placed phrase hits harder than ten frantic notes. Listen to what B.B. King leaves out.',
|
||||
],
|
||||
loopPractice: [
|
||||
{ title: '12-bar blues loop', body: 'Record a 2-bar I7 chord shuffle (e.g. E7 for 2 bars). Loop it and solo using the minor pentatonic. Then add the major 3rd occasionally (in E: G#) — you\'re now mixing major and minor pentatonic, which is the blues scale. Notice how that one note shifts the whole flavour.' },
|
||||
],
|
||||
},
|
||||
|
||||
maj7: {
|
||||
jamRole: 'The sophisticated home chord of jazz, bossa nova, and soul. It floats rather than resolves — gives the jam a dreamy, suspended quality. Play it quietly and let it hang.',
|
||||
voicings: [
|
||||
{ name: 'Open Cmaj7 / Amaj7 / Emaj7', use: 'Acoustic and light electric jams. Beautiful resonance. Let it ring — the maj7 note needs space to project.' },
|
||||
{ name: '4-string mid-neck (no 5th)', use: 'Jazz comping. Drop the 5th — it clutters. Root, 3rd, and maj7 is more elegant and sits better under a soloist.' },
|
||||
{ name: 'High-neck partial (strings 1–4)', use: 'Comping behind a vocalist. High, bright, completely out of the bass range. Keeps you from competing with anything.' },
|
||||
],
|
||||
licks: [
|
||||
{
|
||||
title: 'Maj7 arpeggio climb',
|
||||
style: 'Jazz / Bossa',
|
||||
tab: 'e|--------9----------|\nB|------9---9--------|\nG|----9-------9------|\nD|--10-----------10--|\nA|------------------|\n (Cmaj7: C–E–G–B)',
|
||||
tip: 'Land on the maj7 (the B over Cmaj7) — it\'s the characteristic note. Resolve it gently, don\'t snap down.',
|
||||
},
|
||||
{
|
||||
title: 'Lydian colour phrase',
|
||||
style: 'Jazz',
|
||||
tab: 'e|--9-10-12-10--9---------|\nB|----------------10-9----|\nG|----------------------9-|\n (#4 = Lydian mode over maj7 — the raised 4th creates a dreamy, floating quality)',
|
||||
tip: 'The #4 (raised 4th) is the Lydian note — one semitone above the perfect 4th. It creates instant "floating" colour over any maj7 chord.',
|
||||
},
|
||||
],
|
||||
jamTips: [
|
||||
'Maj7 sounds strongest quiet — dial back your volume. It\'s a listening chord that rewards the band for playing softly.',
|
||||
'In bossa nova, emphasise the bass note on beat 1 with your thumb and comp the chord on the upbeats with your fingers. No plectrum.',
|
||||
'The maj7 is one semitone below the root. If you play the root too prominently in the voicing, the 7th disappears. Favour the 3rd and 7th in your note choices.',
|
||||
],
|
||||
loopPractice: [
|
||||
{ title: 'Bossa nova comp loop', body: 'Record a 4-bar maj7 chord using bossa rhythm (bass on beat 1, chord on the "and"). Play it at half the volume you think you need. Then improvise a simple 3–4 note melody over it. Fewer notes = more space = better bossa feel.' },
|
||||
],
|
||||
},
|
||||
|
||||
min7: {
|
||||
jamRole: 'The smoothed-out minor — soul, R&B, funk, and jazz all live here. It invites groove without demanding resolution. Great for long vamps and ii chord setups in jazz.',
|
||||
voicings: [
|
||||
{ name: 'Open Am7 / Em7 / Dm7', use: 'Folk, acoustic, and light jazz. Clean and resonant. Good starting point — the open strings blend naturally with the chord.' },
|
||||
{ name: 'Barre mid-neck (muted)', use: 'Rock-funk. Mute slightly with your palm for a tighter, more percussive sound. Useful for rhythmic comping in a band.' },
|
||||
{ name: '4-string funk stab (high position)', use: 'THE funk voicing. 4 strings, quick stab and mute. Stay high on the neck — leave the low end for the bass. This is how you comp in a funk jam.' },
|
||||
],
|
||||
licks: [
|
||||
{
|
||||
title: 'Dorian funk phrase',
|
||||
style: 'Funk / Jazz',
|
||||
tab: 'e|---------------------|\nB|---------------------|\nG|--5-7-5-7-9-7-5------|\nD|--5-7-5-7---7-5------|\nA|--3------------------|\n (Am Dorian: natural 6th = F# = fret 9 on G string)',
|
||||
tip: 'The natural 6th (raised from the plain minor) is the single note that turns a minor vamp into a funk groove. Target it.',
|
||||
},
|
||||
{
|
||||
title: 'Min7 arpeggio fill',
|
||||
style: 'Jazz / Soul',
|
||||
tab: 'e|------8----------|\nB|----8---8--------|\nG|--7-------7-5----|\nD|--7-----------7--|\n (Am7: A–C–E–G)',
|
||||
tip: 'In jazz, the b7 (G over Am7) is the departure note — it creates the sense of movement to the V chord. Let it hang.',
|
||||
},
|
||||
],
|
||||
jamTips: [
|
||||
'In a funk jam, min7 stabs go on the upbeats. The muted scratches between are as musical as the chords themselves — they hold the groove.',
|
||||
'In jazz, min7 is almost always the ii chord — expect a dom7 to follow. Your job on ii is to build tension so the V chord feels even better.',
|
||||
'On a long minor 7th vamp, the Dorian mode (natural 6th) is your key. It\'s what separates "sad minor" from "soulful groove".',
|
||||
],
|
||||
loopPractice: [
|
||||
{ title: 'Funk stab loop', body: 'Record a 2-bar pattern: stab the min7 on the "and" of each beat, leaving the beats empty. Loop it. Add a second loop layer with a single bass note on beats 1 and 3. Hear how it locks into a groove. Then try improvising single notes on top.' },
|
||||
],
|
||||
},
|
||||
|
||||
dim: {
|
||||
jamRole: 'Almost never a destination — diminished is a bridge. In a live jam, use it to glide chromatically from one chord to another a half step away. One beat, maximum drama, then move.',
|
||||
voicings: [
|
||||
{ name: 'Chromatic passing shape', use: 'Move any dim shape up one fret to arrive at the next chord. This is how diminished works in practice — a one-fret slide between two stable chords.' },
|
||||
{ name: 'Compact 4-string voicing', use: 'Jazz context where you want the dim colour without full 6-string weight. Cleaner in a dense mix.' },
|
||||
],
|
||||
licks: [
|
||||
{
|
||||
title: 'Chromatic approach line',
|
||||
style: 'Jazz / Classical',
|
||||
tab: 'e|--8-9-10-----------|\nB|------10-11-12-----|\nG|---------------11--|\n (chromatic passing: approach Cmaj7 from B dim)',
|
||||
tip: 'Each note moves up by exactly one semitone. The half-step motion is what creates the magnetic pull toward the landing chord.',
|
||||
},
|
||||
{
|
||||
title: 'Symmetric dim arpeggio',
|
||||
style: 'Jazz',
|
||||
tab: 'e|--4-7-10-----------|\nB|------10-13--------|\nG|---------12--------|\nD|--5-8--------------|\n (B dim: B–D–F–Ab — all minor 3rds apart)',
|
||||
tip: 'Move this pattern up 3 frets and you have the same chord in a new inversion. Exploit the symmetry.',
|
||||
},
|
||||
],
|
||||
jamTips: [
|
||||
'Use dim as a chromatic approach: going from G to Am, insert G#dim for one beat. The half-step movement makes the landing feel inevitable.',
|
||||
'In jazz, you can substitute a dim7 a half step above the V chord — it creates even more tension before the resolution.',
|
||||
'Diminished chords are brief. Never sit on one in a jam — play it quickly and move. It\'s a dramatic pivot, not a home.',
|
||||
],
|
||||
loopPractice: [
|
||||
{ title: 'Chromatic approach exercise', body: 'Record a 2-bar Am loop. On the last beat before the loop repeats, land on G#dim for just one beat before the Am hits. Loop it. Feel how the approach makes the Am feel like a release. Try with different target chords.' },
|
||||
],
|
||||
},
|
||||
|
||||
dim7: {
|
||||
jamRole: 'The most symmetric chord in music — one shape, four roots. Use it as a dramatic pivot or tension builder before any chord. In a jazz jam it\'s a secret harmonic weapon.',
|
||||
voicings: [
|
||||
{ name: 'Moveable box (any position)', use: 'Move up 3 frets for each inversion — same notes, different voice. Use whichever inversion sits closest to the chord you\'re resolving to.' },
|
||||
{ name: 'High-neck 4-string', use: 'Clean and tight. Good for jazz where you want the tension colour without orchestral weight. The top string carries the characteristic tritone.' },
|
||||
],
|
||||
licks: [
|
||||
{
|
||||
title: 'Dim7 arpeggio (symmetric)',
|
||||
style: 'Jazz / Classical',
|
||||
tab: 'e|--1-4-7-10--------|\nB|----------10-13---|\nG|------1-4---------|\nD|--2-5-------------|\n (Bdim7: all notes 3 semitones apart)',
|
||||
tip: 'Every note is an equal minor 3rd apart. Moving up 3 frets lands you on the same chord — use this to shift register dramatically.',
|
||||
},
|
||||
{
|
||||
title: 'Chromatic resolution',
|
||||
style: 'Jazz',
|
||||
tab: 'e|--1-0--|\nB|--2-1--|\nG|--1-0--|\nD|--3-2--|\nA|--3-3--|\n Bdim7 → Cmaj7 (half-step resolution)',
|
||||
tip: 'Each voice moves by just one semitone. This is why dim7 resolves so smoothly — voice leading at its most efficient.',
|
||||
},
|
||||
],
|
||||
jamTips: [
|
||||
'A dim7 shape a half step above your target chord creates maximum tension before resolution. Before Cmaj7, play Bdim7 for one beat — the release will feel enormous.',
|
||||
'Every dim7 is an inversion of 3 others. Learn one shape and you have 12 roots covered — just move 3 frets at a time.',
|
||||
'In a jazz jam, place dim7 on the last beat of a phrase, not the first. The tension arrives just before the new section resolves.',
|
||||
],
|
||||
loopPractice: [
|
||||
{ title: 'Resolution contrast loop', body: 'Record a 4-bar loop: 3 bars Cmaj7, then exactly 1 beat of Bdim7 on the final "and" before the loop repeats. The contrast will be striking every time. Try different dim7 → resolution pairs to hear how universally this works.' },
|
||||
],
|
||||
},
|
||||
|
||||
half_dim: {
|
||||
jamRole: 'The ii chord in minor key jazz — where the tension journey begins. When you play this in a jam, you\'re saying "we\'re heading somewhere" and everyone leans in to hear where.',
|
||||
voicings: [
|
||||
{ name: 'Standard (5th-string root)', use: 'Most common voicing. The b5 on top is the characteristic tension note — let it ring clearly so everyone hears the harmonic direction.' },
|
||||
{ name: 'Compact 4-string (b3–b5–b7)', use: 'Jazz comping. Root, b3, b5, b7 — tight and dark. Perfect when comping behind a soloist who needs space.' },
|
||||
],
|
||||
licks: [
|
||||
{
|
||||
title: 'Locrian colour phrase',
|
||||
style: 'Jazz',
|
||||
tab: 'e|--7-8-10--------|\nB|--------10-8-7--|\nG|--7-8-----------|\nD|--7-------------|\n (Bm7b5: Locrian mode — b2, b3, b5, b7)',
|
||||
tip: 'The b5 is the defining note. Feature it in your phrase — it\'s what signals "half-diminished" to every musician in the room.',
|
||||
},
|
||||
{
|
||||
title: 'ii–V–i connection line',
|
||||
style: 'Jazz',
|
||||
tab: 'e|------7-8-10----------10-9---------|\nB|----8---------8-10-12------12-10---|\nG|--9---------------------------------|\n (Bm7b5 → E7 → Am: melodic line through the ii–V–i)',
|
||||
tip: 'This phrase flows across all three chords. That\'s the goal: melodic lines that connect chords, not stop-start at each change.',
|
||||
},
|
||||
],
|
||||
jamTips: [
|
||||
'The m7b5 almost always precedes a dominant 7th a 4th above it. If you\'re on Bm7b5, expect E7 next. Learn to anticipate — comp toward the V before it arrives.',
|
||||
'The characteristic note is the b5. Target it in your fills — it\'s the note that makes the chord sound "yearning" rather than just dark.',
|
||||
'In jazz comping, your chord stab on the ii should feel like tension loading. Don\'t resolve early — let the V chord do that work.',
|
||||
],
|
||||
loopPractice: [
|
||||
{ title: 'Minor ii–V–i drill', body: 'Record a 4-bar loop: 2 bars Bm7b5, 1 bar E7, 1 bar Am7. Practise this in Am until it\'s automatic, then transpose to other keys. Every jazz standard is built on variations of this — knowing it in all 12 keys is the single biggest unlock in jazz.' },
|
||||
],
|
||||
},
|
||||
|
||||
aug: {
|
||||
jamRole: 'A single beat of suspended tension. Augmented chords are almost always passing — one beat before resolving. In a jam, use one to add forward harmonic motion between two stable chords.',
|
||||
voicings: [
|
||||
{ name: 'Standard augmented shape (moveable)', use: 'It\'s symmetric — any inversion works. Use whichever position sits closest to where you\'re going next.' },
|
||||
{ name: 'Partial 4-string', use: 'Lighter touch. Use in a sparse jam where you want the aug colour without weight. The raised 5th on top is the defining note — keep it.' },
|
||||
],
|
||||
licks: [
|
||||
{
|
||||
title: 'Whole-tone run',
|
||||
style: 'Jazz',
|
||||
tab: 'e|--5-7-9-10-12------|\nB|-------------12----|\nG|--4-6-8------------|\n (whole-tone scale: all major 2nds — the natural scale over augmented)',
|
||||
tip: 'Every interval is a whole step. The scale has no "home" and sounds disorienting — which is exactly the aug chord feeling. Use it briefly then land on something stable.',
|
||||
},
|
||||
{
|
||||
title: 'I → aug → IV resolution',
|
||||
style: 'Jazz / Pop',
|
||||
tab: 'e|--0---0---1--|\nB|--1---1---1--|\nG|--0---1---2--|\nD|--2---2---3--|\nA|--3---3---3--|\nE|--x---x---x--|\n Cmaj Caug F',
|
||||
tip: 'The aug chord is only one note different from the major — the 5th raised by one semitone. That one note creates all the forward motion into the IV.',
|
||||
},
|
||||
],
|
||||
jamTips: [
|
||||
'The raised 5th wants to move up by a half step — follow it. C aug → F: the G# in Caug resolves naturally to A (the 3rd of F). That voice leading is the whole point.',
|
||||
'In jazz, if you hear an aug chord, stay light and brief. One voicing, then move — it\'s a raised eyebrow, not a full statement.',
|
||||
'Augmented works as a substitute for the V chord — both create tension that resolves to I. Try it in a major key jam as an unexpected colour.',
|
||||
],
|
||||
loopPractice: [
|
||||
{ title: 'I → aug → IV loop', body: 'Record a 4-bar loop: 1.5 bars C major, 0.5 bar Caug, 2 bars F. The augmented is just a moment — practise landing cleanly on the F. Once locked in, add a melodic phrase that follows the aug note (G#) upward to A on the F chord.' },
|
||||
],
|
||||
},
|
||||
|
||||
sus4: {
|
||||
jamRole: 'Creates anticipation without committing to major or minor. In a jam, use it to build tension before a major resolution, or sustain it for a floating open feel that invites any scale.',
|
||||
voicings: [
|
||||
{ name: 'Open sus4 (Dsus4, Asus4)', use: 'Ring out beautifully. Best for creating space and expectation — especially effective in acoustic and ambient jams.' },
|
||||
{ name: 'Barre sus4 → major', use: 'Rock and pop jams. Use the sus4 → major resolution as a rhythmic gesture: sus4 on the upbeat, major on the downbeat. The landing feels like a musical exhale.' },
|
||||
{ name: 'Open string drone + sus4 shape', use: 'Andy Summers / Sting technique. Let high E or B ring open while the fretted notes move. Lush complexity from simple shapes — works beautifully with delay.' },
|
||||
],
|
||||
licks: [
|
||||
{
|
||||
title: 'Sus4 → major rhythmic gesture',
|
||||
style: 'Rock / Pop',
|
||||
tab: 'e|--3---2---3---2--|\nB|--3---3---3---3--|\nG|--2---2---2---2--|\nD|--0---0---0---0--|\n (Dsus4 → D → Dsus4 → D)',
|
||||
tip: 'The 4th (that one suspended note) resolves down to the 3rd. Let the sus4 hang for a full beat before resolving — the longer the tension, the sweeter the landing.',
|
||||
},
|
||||
{
|
||||
title: 'Open drone riff',
|
||||
style: 'Ambient / Rock',
|
||||
tab: 'e|--0---0---0---0--|\nB|--5---5---3---3--|\nG|--6---6---4---4--|\nD|--7---7---5---5--|\nA|--7---7---5---5--|\nE|--5---5---3---3--|\n (C#sus4 riff — open high E rings throughout)',
|
||||
tip: 'The open high E string is the constant — it creates the suspension as the chord changes underneath it. This is The Police\'s entire harmonic language.',
|
||||
},
|
||||
],
|
||||
jamTips: [
|
||||
'Sus4 is most powerful just before resolving — the longer you hold it, the more satisfying the major landing. Use it on the upbeat, resolve on the downbeat.',
|
||||
'In a jam, switching to sus4 on the I chord while others keep playing instantly changes the feel without a real chord change. Subtle and powerful.',
|
||||
'Pair sus4 with a delay effect (or the loop station reverb tail) for ambient, textural comping that leaves maximum space for other musicians.',
|
||||
],
|
||||
loopPractice: [
|
||||
{ title: 'Tension-release loop', body: 'Record a 2-bar loop: 1 bar sus4, 1 bar major (e.g. Dsus4 → D). Improvise over it, emphasising the 4th over the sus4 bar and resolving to the 3rd on the major bar — your melody mirrors the exact motion of the chord. Teach your ear to hear and resolve suspension.' },
|
||||
],
|
||||
},
|
||||
|
||||
sus2: {
|
||||
jamRole: 'The neutral canvas. Sus2 removes the 3rd — it\'s neither major nor minor. In a jam, it gives any soloist total freedom and creates a floating, modern texture that never clashes.',
|
||||
voicings: [
|
||||
{ name: 'Open Asus2 / Dsus2', use: 'Key voicings. Open strings ring freely and the chord floats. Best in quieter or ambient jams — don\'t fight it with a heavy strum.' },
|
||||
{ name: 'Capo + open sus2 shape', use: 'Any key, same open feel. Capo up and play Dsus2 or Asus2 shapes. Constant by Sting, Coldplay, The Edge — sus2 resonance in any key.' },
|
||||
],
|
||||
licks: [
|
||||
{
|
||||
title: 'Sus2 arpeggio (floating)',
|
||||
style: 'Ambient / Pop',
|
||||
tab: 'e|--0-----------0--|\nB|--0---0---0------|\nG|----2---2--------|\nD|------2----------|\nA|--0-----------0--|\n (Asus2: A–B–E — root, 2nd, 5th)',
|
||||
tip: 'The 2nd (9th) is the highest note — feature it in your voicing. It\'s the note that makes the chord float.',
|
||||
},
|
||||
{
|
||||
title: 'Single note melody over sus2 drone',
|
||||
style: 'Ambient',
|
||||
tab: 'e|--0-2-3-5-3-2-0--|\nB|--0-----------0--|\nG|--2-----------2--|\nD|--2-----------2--|\nA|--0-----------0--|\n (Asus2 held, melody on string 1)',
|
||||
tip: 'Because there\'s no 3rd in the sus2, any major or minor scale note works as a melody. Total harmonic freedom.',
|
||||
},
|
||||
],
|
||||
jamTips: [
|
||||
'Sus2 has no 3rd — soloists can play major OR minor scales over it and neither will clash. Use it as a harmonic reset between more defined chords.',
|
||||
'In a dense jam, switching from a full major chord to a sus2 is like opening a window — everyone hears and appreciates the space.',
|
||||
'The 9th (the 2nd up an octave) is your featured note. Build your voicing so it sits on top — that\'s what gives the chord its shimmer.',
|
||||
],
|
||||
loopPractice: [
|
||||
{ title: 'Neutral canvas loop', body: 'Record a sparse 4-bar sus2 loop — just a few arpeggiated notes, played quietly. Then add a second loop layer with a single sustained melody note. Try different melody notes and notice that none of them clash. That\'s the power of the sus2 as a backdrop.' },
|
||||
],
|
||||
},
|
||||
|
||||
maj6: {
|
||||
jamRole: 'Sweetened major — used in jazz, bossa nova, and vintage pop where a plain major feels too simple. Lush and nostalgic, it sits perfectly under a vocalist or a soloist\'s upper-register lines.',
|
||||
voicings: [
|
||||
{ name: 'Open C6 / A6', use: 'Classic jazz and bossa starting points. Bright and warm — good comp chord for a vocalist. The 6th sits naturally on top.' },
|
||||
{ name: '4-string voicing (no 5th, 6th on top)', use: 'Jazz comping — elegant and uncluttered. Drop the 5th, feature the 6th as the top note. This is how the chord sings in a jazz context.' },
|
||||
],
|
||||
licks: [
|
||||
{
|
||||
title: 'Maj6 arpeggio',
|
||||
style: 'Jazz / Bossa',
|
||||
tab: 'e|--5-----------|\nB|----5---------|\nG|------5-4-2---|\nD|----------4-2-|\nA|--3-----------|\n (C6: C–E–G–A, target the 6th = A)',
|
||||
tip: 'The 6th (A over C6) is the top of the arpeggio — treat it as the melody note and let everything else support it.',
|
||||
},
|
||||
{
|
||||
title: 'Bossa comp rhythm',
|
||||
style: 'Bossa Nova',
|
||||
tab: 'Thumb (T) on bass string, fingers (f) on chord:\n T . f . T f . f | T . f . T f . f\n 1 . + . 2 + . + | 1 . + . 2 + . +\n (the classic João Gilberto bossa strum)',
|
||||
tip: 'The thumb bass is as important as the chord — it defines the bossa feel. Keep it locked with the kick drum if there is one.',
|
||||
},
|
||||
],
|
||||
jamTips: [
|
||||
'Maj6 and min7 are inversions of each other — C6 and Am7 share the same four notes. This means you can substitute one for the other in many contexts.',
|
||||
'Voice the chord so the 6th sits on top — it\'s the melody note. A maj6 with the root on top just sounds like a major chord with extra notes.',
|
||||
'In bossa nova, the I chord is often maj6 (not maj7). The 6th is sweeter and less mysterious than the 7th — it resolves more completely.',
|
||||
],
|
||||
loopPractice: [
|
||||
{ title: 'Bossa ii–V–I loop', body: 'Record a 4-bar loop: 1 bar Dm7, 1 bar G7, 2 bars Cmaj6. The most common jazz cadence. Practise until each change is effortless, then improvise a simple melody that lands on the 6th of the Cmaj6 as its final note — the sweetest possible resolution.' },
|
||||
],
|
||||
},
|
||||
|
||||
min6: {
|
||||
jamRole: 'Minor with unexpected brightness — the Dorian chord. The major 6th over a minor chord creates emotional complexity: dark and light at once. Essential in flamenco, tango, jazz, and anything Dorian.',
|
||||
voicings: [
|
||||
{ name: 'Open Am6 (x02212)', use: 'The classic voicing. The F# on top is the 6th — it must ring clearly. This is the chord that defines the flamenco-jazz crossover sound.' },
|
||||
{ name: 'Jazz tonic min6 (4-string)', use: 'In jazz minor jams, use min6 as the tonic instead of plain minor. It sounds resolved but never bland — more sophisticated than a plain triad.' },
|
||||
],
|
||||
licks: [
|
||||
{
|
||||
title: 'Dorian descending line',
|
||||
style: 'Jazz / Funk',
|
||||
tab: 'e|--5-3-----------|\nB|------5-3-2-----|\nG|----------4-2---|\nD|-----------4-2--|\nA|--0-------------|\n (A Dorian descending: A G F# E D C# B A)',
|
||||
tip: 'The F# (natural 6th) is what makes this Dorian, not natural minor. Feature it — it\'s the brightness inside the darkness.',
|
||||
},
|
||||
{
|
||||
title: 'Flamenco approach (Am → Am6)',
|
||||
style: 'Flamenco / Spanish',
|
||||
tab: 'e|--0---1---0------|\nB|--1---2---1------|\nG|--2---2---2------|\nD|--2---2---2------|\nA|--0---0---0------|\n Am Am6 Am (the 6th appears and disappears)',
|
||||
tip: 'Letting the Am6 appear for just one beat creates the characteristic Spanish colour — like a flash of sunlight inside the minor mood.',
|
||||
},
|
||||
],
|
||||
jamTips: [
|
||||
'The natural 6th in a minor chord IS the Dorian mode marker. If the jam is in Dorian (minor with raised 6th), min6 is the correct tonic — play it instead of plain minor.',
|
||||
'In a jazz minor jam, use min6 as the final resting point of a phrase. It sounds more resolved than min7 (which still wants to move) but darker than a plain major.',
|
||||
'The contrast between min6 and plain minor in the same phrase is a device — use it intentionally. Go to Am, then Am6, then back. The harmonic colour shifts are micro-movements of emotion.',
|
||||
],
|
||||
loopPractice: [
|
||||
{ title: 'Dorian vamp loop', body: 'Record a 4-bar Am loop — but on bar 3, switch to Am6 instead of plain Am. Notice how the F# lifts the sound. Improvise using the Dorian scale (natural minor with raised 6th) and specifically target the F# in your phrases. That note IS the sound.' },
|
||||
],
|
||||
},
|
||||
|
||||
add9: {
|
||||
jamRole: 'The modern open chord — spacious, unambiguous, never clashes. Add9 keeps the clarity of a triad but adds one note of colour. The sound of contemporary rock, pop, and folk jams.',
|
||||
voicings: [
|
||||
{ name: 'Open Cadd9 (x32033)', use: 'The quintessential modern voicing. Ring and middle fingers on 3rd fret, open G and high E ring as the 9th. Perfect for pop and acoustic jams.' },
|
||||
{ name: 'G / Cadd9 (anchor fingers 3–4)', use: 'Keep ring and pinky on strings 1–2 (fret 3) for BOTH G and Cadd9. Only your first two fingers move between the chords. This anchor technique is what makes the two chords sound seamless.' },
|
||||
{ name: 'High position with open string drone', use: 'Use open strings as drones while fretting the add9 shape further up. Creates lush textural layers — very effective in ambient or layered jams.' },
|
||||
],
|
||||
licks: [
|
||||
{
|
||||
title: 'Cadd9 arpeggio (the 9th rings)',
|
||||
style: 'Pop / Folk',
|
||||
tab: 'e|--3-----------3--|\nB|----3-------3----|\nG|------0---0------|\nD|--------2--------|\nA|--3-----------3--|\n (Cadd9: root → 3rd → 9th — let the 9th ring over the whole phrase)',
|
||||
tip: 'The 9th (D over C) is the featured note — let it ring as long as possible. It\'s the note that makes the chord feel modern and open.',
|
||||
},
|
||||
{
|
||||
title: 'Melodic fill on string 1 between stabs',
|
||||
style: 'Rock / Pop',
|
||||
tab: 'e|--3---5---3---0---3--|\nB|--3-----------3---3--|\nG|--0-----------0---0--|\nD|--2-----------2---2--|\n (Cadd9 chord stab, melody fill, back to chord)',
|
||||
tip: 'The fill on string 1 sits over the same chord shape — you\'re comping and filling at the same time. This technique makes you sound like two guitarists.',
|
||||
},
|
||||
],
|
||||
jamTips: [
|
||||
'The Cadd9 ↔ G movement with anchored fingers 3–4 is the single most useful two-chord pattern in modern pop guitar. Master the anchor first.',
|
||||
'Unlike maj9, add9 has no 7th — it stays clean and unambiguous in a dense mix. Great when there\'s a keys player already adding 7ths.',
|
||||
'Let the 9th ring as a pedal note in your fills. The 2nd degree of the scale — heard an octave up — sounds modern every time and never conflicts with the harmony.',
|
||||
],
|
||||
loopPractice: [
|
||||
{ title: 'Anchor pattern loop', body: 'Record a 2-bar loop alternating Cadd9 and G, using the anchor technique (fingers 3–4 never lift). When the transition is smooth, start adding single-note fills on string 1 between the chord stabs — a lick on the "and" of beat 2, back to the chord on beat 3. You\'re now comping and leading simultaneously.' },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Essentia HPCP pipeline — replaces our custom computeChroma() when enabled.
|
||||
//
|
||||
// Pipeline (using Web Audio FFT data directly to skip WASM re-FFT):
|
||||
// freqBuf (dB from AnalyserNode) → linear magnitude → SpectralPeaks → HPCP
|
||||
//
|
||||
// Pitch class ordering:
|
||||
// Essentia HPCP[0] = A (referenceFrequency=440)
|
||||
// Our chroma[0] = C
|
||||
// Rotation applied: ourChroma[(i + 9) % 12] = HPCP[i]
|
||||
|
||||
import { EssentiaWASM } from 'essentia.js/dist/essentia-wasm.es.js'
|
||||
import Essentia from 'essentia.js/dist/essentia.js-core.es.js'
|
||||
|
||||
let instance = null
|
||||
|
||||
// Call once at startup — safe to call multiple times (returns cached instance).
|
||||
export async function initEssentia() {
|
||||
if (instance) return instance
|
||||
console.log('[Essentia] loading WASM…')
|
||||
await EssentiaWASM['ready']
|
||||
instance = new Essentia(EssentiaWASM)
|
||||
console.log('[Essentia] ready —', instance.version)
|
||||
return instance
|
||||
}
|
||||
|
||||
export function getEssentia() { return instance }
|
||||
|
||||
const NOISE_FLOOR_DB = -65
|
||||
|
||||
// Compute 12-bin HPCP from Web Audio frequency-domain data.
|
||||
//
|
||||
// freqData — Float32Array from AnalyserNode.getFloatFrequencyData() (dB values)
|
||||
// sampleRate — AudioContext.sampleRate
|
||||
//
|
||||
// Returns Float32Array(12) with same [C, C#, D, …, B] ordering as computeChroma().
|
||||
export function computeHPCP(essentia, freqData, sampleRate) {
|
||||
const N = freqData.length
|
||||
|
||||
// Convert dB → linear magnitude. Bins below noise floor stay 0.
|
||||
const magSpectrum = new Float32Array(N)
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (freqData[i] > NOISE_FLOOR_DB) {
|
||||
magSpectrum[i] = Math.pow(10, freqData[i] / 20)
|
||||
}
|
||||
}
|
||||
|
||||
const specVec = essentia.arrayToVector(magSpectrum)
|
||||
|
||||
// SpectralPeaks derives bin → Hz as: freq = binIndex * sampleRate / (2*(N-1))
|
||||
// which matches Web Audio's FFT bin spacing (sampleRate / fftSize).
|
||||
const peaks = essentia.SpectralPeaks(
|
||||
specVec,
|
||||
0, // magnitudeThreshold — noise already zeroed above
|
||||
4000, // maxFrequency (Hz)
|
||||
60, // maxPeaks
|
||||
40, // minFrequency (Hz)
|
||||
'byMagnitude',
|
||||
sampleRate
|
||||
)
|
||||
specVec.delete()
|
||||
|
||||
// If no peaks found (silence / below noise floor), return zeros.
|
||||
if (peaks.frequencies.size() === 0) {
|
||||
peaks.frequencies.delete()
|
||||
peaks.magnitudes.delete()
|
||||
return new Float32Array(12)
|
||||
}
|
||||
|
||||
const hpcpResult = essentia.HPCP(
|
||||
peaks.frequencies,
|
||||
peaks.magnitudes,
|
||||
false, // bandPreset
|
||||
500, // bandSplitFrequency (unused when bandPreset=false)
|
||||
8, // harmonics
|
||||
4000, // maxFrequency (Hz)
|
||||
false, // maxShifted
|
||||
40, // minFrequency (Hz)
|
||||
false, // nonLinear
|
||||
'unitMax', // normalized
|
||||
440, // referenceFrequency (A4 = 440 Hz → HPCP[0] = A)
|
||||
sampleRate,
|
||||
12, // size (bins per octave)
|
||||
'squaredCosine', // weightType
|
||||
0.5 // windowSize (octaves)
|
||||
)
|
||||
peaks.frequencies.delete()
|
||||
peaks.magnitudes.delete()
|
||||
|
||||
// Rotate A-origin → C-origin to match our chroma convention.
|
||||
// HPCP[0]=A → ourChroma[9]=A, so ourChroma[(i+9)%12] = HPCP[i]
|
||||
const result = new Float32Array(12)
|
||||
for (let i = 0; i < 12; i++) {
|
||||
result[(i + 9) % 12] = hpcpResult.hpcp.get(i)
|
||||
}
|
||||
hpcpResult.hpcp.delete()
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
// ─── 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))
|
||||
}
|
||||
+19
-214
@@ -235,168 +235,6 @@ 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) {
|
||||
@@ -522,72 +360,40 @@ export function toRomanNumeral(chordName, keyRoot, keyMode) {
|
||||
|
||||
// ─── Repeating progression detection ─────────────────────────────────────────
|
||||
|
||||
// Returns true if arr is made of a shorter repeating unit (e.g. [A,B,A,B] → true)
|
||||
function isPeriodicPattern(arr) {
|
||||
for (let p = 1; p <= Math.floor(arr.length / 2); p++) {
|
||||
if (arr.length % p !== 0) continue
|
||||
const unit = arr.slice(0, p)
|
||||
if (arr.every((v, i) => v === unit[i % p])) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Returns the lexicographically smallest rotation so the same loop always
|
||||
// produces the same string regardless of where in the cycle we currently are.
|
||||
function canonicalize(pattern) {
|
||||
let best = pattern
|
||||
for (let i = 1; i < pattern.length; i++) {
|
||||
const rot = [...pattern.slice(i), ...pattern.slice(0, i)]
|
||||
if (rot.join('\0') < best.join('\0')) best = rot
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* detectRepeatingProgression(history) → chord[] or null
|
||||
*
|
||||
* Tests every unique subsequence of every length (not just the tail) so the
|
||||
* result is stable regardless of where in the loop the musician currently is.
|
||||
* Returns the canonical (rotation-normalised) form of the best pattern found.
|
||||
* Returns the most-recently-completed repeating pattern (length 2–6).
|
||||
* Uses non-overlapping match counting to avoid over-counting.
|
||||
*/
|
||||
export function detectRepeatingProgression(history) {
|
||||
if (!history || history.length < 6) return null
|
||||
if (!history || history.length < 4) return null
|
||||
|
||||
const win = history.slice(-32)
|
||||
const window = history.slice(-20)
|
||||
let best = null, bestScore = 0
|
||||
|
||||
for (let len = 2; len <= 6; len++) {
|
||||
if (len * 2 > win.length) break
|
||||
if (len * 2 > window.length) break
|
||||
|
||||
const seen = new Set()
|
||||
const candidate = window.slice(-len)
|
||||
let reps = 0, i = 0
|
||||
|
||||
for (let start = 0; start <= win.length - len; start++) {
|
||||
const candidate = win.slice(start, start + len)
|
||||
const key = candidate.join('\0')
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
|
||||
// A pattern that is itself a repetition of something shorter will be
|
||||
// found at that shorter length — skip it here to avoid inflating scores.
|
||||
if (len >= 4 && isPeriodicPattern(candidate)) continue
|
||||
|
||||
let reps = 0, i = 0
|
||||
while (i <= win.length - len) {
|
||||
if (candidate.every((c, j) => c === win[i + j])) { reps++; i += len }
|
||||
else i++
|
||||
while (i <= window.length - len) {
|
||||
if (candidate.every((c, j) => c === window[i + j])) {
|
||||
reps++
|
||||
i += len // skip past match — non-overlapping
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
if (reps < 2) continue
|
||||
|
||||
const score = reps * len * len // square length — prevents sub-patterns from beating full loop
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
best = candidate
|
||||
}
|
||||
const score = reps * len
|
||||
if (reps >= 2 && score > bestScore) {
|
||||
bestScore = score
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return best ? canonicalize(best) : null
|
||||
return best
|
||||
}
|
||||
|
||||
// ─── Debug / analysis helpers ─────────────────────────────────────────────────
|
||||
@@ -644,7 +450,6 @@ export function getNoteHistoryAnalysis(noteHistory) {
|
||||
}
|
||||
return {
|
||||
freq: normalized,
|
||||
total,
|
||||
topKeys: candidates.sort((a, b) => b.score - a.score).slice(0, 5),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,563 +0,0 @@
|
||||
// ─── Guitar voicing shapes ────────────────────────────────────────────────────
|
||||
// Open string pitches (standard tuning): E A D G B e
|
||||
const OPEN = [4, 9, 2, 7, 11, 4] // pitch class per string [s6 … s1]
|
||||
|
||||
// Barre shape: offsets[] are fret distances from the root fret, per string [s6…s1].
|
||||
// 'x' = muted. rootStr = 1-indexed string that holds the root (6=low E, 5=A…).
|
||||
// Open shape: frets[] are absolute fret numbers (0=open, 'x'=muted) for a specific root key.
|
||||
|
||||
const GUITAR_SHAPES = {
|
||||
maj: [
|
||||
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,2,1,0,0], fingers: [1,3,4,2,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
|
||||
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,2,2,0], fingers: [0,1,3,4,2,1], barre: { fromStr: 1, toStr: 5, fo: 0 } },
|
||||
{ label: 'D Shape', type: 'barre', rootStr: 4, offsets: ['x','x',0,2,3,2], fingers: [0,0,1,3,4,2] },
|
||||
{ label: 'G Shape', type: 'barre', rootStr: 6, offsets: [0,-1,-3,-3,-3,0], fingers: [4,3,1,1,1,4] },
|
||||
{ label: 'Open E', type: 'open', onlyRoot: 4, frets: [0,2,2,1,0,0], fingers: [0,2,3,1,0,0] },
|
||||
{ label: 'Open A', type: 'open', onlyRoot: 9, frets: ['x',0,2,2,2,0], fingers: [0,0,1,2,3,0] },
|
||||
{ label: 'Open G', type: 'open', onlyRoot: 7, frets: [3,2,0,0,0,3], fingers: [2,1,0,0,0,3] },
|
||||
{ label: 'Open C', type: 'open', onlyRoot: 0, frets: ['x',3,2,0,1,0], fingers: [0,3,2,0,1,0] },
|
||||
{ label: 'Open D', type: 'open', onlyRoot: 2, frets: ['x','x',0,2,3,2], fingers: [0,0,0,1,3,2] },
|
||||
],
|
||||
min: [
|
||||
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,2,0,0,0], fingers: [1,3,4,1,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
|
||||
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,2,1,0], fingers: [0,1,3,4,2,1], barre: { fromStr: 1, toStr: 5, fo: 0 } },
|
||||
{ label: 'D Shape', type: 'barre', rootStr: 4, offsets: ['x','x',0,2,3,1], fingers: [0,0,1,3,4,2] },
|
||||
{ label: 'Open Em', type: 'open', onlyRoot: 4, frets: [0,2,2,0,0,0], fingers: [0,2,3,0,0,0] },
|
||||
{ label: 'Open Am', type: 'open', onlyRoot: 9, frets: ['x',0,2,2,1,0], fingers: [0,0,2,3,1,0] },
|
||||
{ label: 'Open Dm', type: 'open', onlyRoot: 2, frets: ['x','x',0,2,3,1], fingers: [0,0,0,2,3,1] },
|
||||
],
|
||||
dom7: [
|
||||
{ label: 'E7 Barre', type: 'barre', rootStr: 6, offsets: [0,2,0,1,0,0], fingers: [1,3,0,2,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
|
||||
{ label: 'A7 Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,0,2,0], fingers: [0,1,2,0,3,0] },
|
||||
{ label: 'D Shape', type: 'barre', rootStr: 4, offsets: ['x','x',0,2,1,2], fingers: [0,0,1,3,2,4] },
|
||||
{ label: 'Open E7', type: 'open', onlyRoot: 4, frets: [0,2,0,1,0,0], fingers: [0,2,0,1,0,0] },
|
||||
{ label: 'Open A7', type: 'open', onlyRoot: 9, frets: ['x',0,2,0,2,0], fingers: [0,0,2,0,3,0] },
|
||||
{ label: 'Open G7', type: 'open', onlyRoot: 7, frets: [3,2,0,0,0,1], fingers: [3,2,0,0,0,1] },
|
||||
{ label: 'Open D7', type: 'open', onlyRoot: 2, frets: ['x','x',0,2,1,2], fingers: [0,0,0,2,1,3] },
|
||||
{ label: 'Open B7', type: 'open', onlyRoot: 11, frets: ['x',2,1,2,0,2], fingers: [0,2,1,3,0,4] },
|
||||
],
|
||||
maj7: [
|
||||
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,1,1,0,0], fingers: [1,3,2,2,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
|
||||
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,1,2,0], fingers: [0,1,3,2,4,1], barre: { fromStr: 1, toStr: 2, fo: 0 } },
|
||||
{ label: 'D Shape', type: 'barre', rootStr: 4, offsets: ['x','x',0,2,2,2], fingers: [0,0,1,2,3,4], barre: { fromStr: 1, toStr: 3, fo: 2 } },
|
||||
{ label: 'Open Cmaj7', type: 'open', onlyRoot: 0, frets: ['x',3,2,0,0,0], fingers: [0,3,2,0,0,0] },
|
||||
{ label: 'Open Amaj7', type: 'open', onlyRoot: 9, frets: ['x',0,2,1,2,0], fingers: [0,0,2,1,3,0] },
|
||||
{ label: 'Open Emaj7', type: 'open', onlyRoot: 4, frets: [0,2,1,1,0,0], fingers: [0,2,1,1,0,0] },
|
||||
],
|
||||
min7: [
|
||||
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,0,0,0,0], fingers: [1,3,1,1,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
|
||||
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,0,1,0], fingers: [0,1,3,1,2,1], barre: { fromStr: 1, toStr: 5, fo: 0 } },
|
||||
{ label: 'D Shape', type: 'barre', rootStr: 4, offsets: ['x','x',0,2,1,1], fingers: [0,0,1,3,2,2] },
|
||||
{ label: 'Open Em7', type: 'open', onlyRoot: 4, frets: [0,2,0,0,0,0], fingers: [0,2,0,0,0,0] },
|
||||
{ label: 'Open Am7', type: 'open', onlyRoot: 9, frets: ['x',0,2,0,1,0], fingers: [0,0,2,0,1,0] },
|
||||
{ label: 'Open Dm7', type: 'open', onlyRoot: 2, frets: ['x','x',0,2,1,1], fingers: [0,0,0,3,1,2] },
|
||||
],
|
||||
dim: [
|
||||
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,1,2,1,'x'], fingers: [0,1,2,4,3,0] },
|
||||
{ label: 'Compact', type: 'barre', rootStr: 4, offsets: ['x','x',0,1,3,1], fingers: [0,0,1,2,4,3] },
|
||||
],
|
||||
dim7: [
|
||||
{ label: 'Movable Box', type: 'barre', rootStr: 5, offsets: ['x',0,1,2,1,2], fingers: [0,1,2,4,3,4] },
|
||||
{ label: 'Compact', type: 'barre', rootStr: 4, offsets: ['x','x',0,1,0,1], fingers: [0,0,1,2,3,4] },
|
||||
],
|
||||
aug: [
|
||||
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,3,2,1,1,0], fingers: [1,4,3,2,2,1] },
|
||||
{ label: 'Compact', type: 'barre', rootStr: 5, offsets: ['x',0,3,2,2,'x'], fingers: [0,1,4,2,3,0] },
|
||||
],
|
||||
sus4: [
|
||||
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,2,2,0,0], fingers: [1,2,3,4,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
|
||||
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,2,3,0], fingers: [0,1,2,3,4,0] },
|
||||
{ label: 'D Shape', type: 'barre', rootStr: 4, offsets: ['x','x',0,2,3,3], fingers: [0,0,1,2,3,4] },
|
||||
{ label: 'Open Asus4', type: 'open', onlyRoot: 9, frets: ['x',0,2,2,3,0], fingers: [0,0,1,2,4,0] },
|
||||
{ label: 'Open Dsus4', type: 'open', onlyRoot: 2, frets: ['x','x',0,2,3,3], fingers: [0,0,0,1,2,3] },
|
||||
],
|
||||
sus2: [
|
||||
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,4,4,0,0], fingers: [1,2,4,4,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
|
||||
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,4,0,0], fingers: [0,1,2,4,1,1], barre: { fromStr: 1, toStr: 2, fo: 0 } },
|
||||
{ label: 'D Shape', type: 'barre', rootStr: 4, offsets: ['x','x',0,2,3,0], fingers: [0,0,1,2,3,0] },
|
||||
{ label: 'Open Asus2', type: 'open', onlyRoot: 9, frets: ['x',0,2,2,0,0], fingers: [0,0,1,2,0,0] },
|
||||
{ label: 'Open Dsus2', type: 'open', onlyRoot: 2, frets: ['x','x',0,2,3,0], fingers: [0,0,0,1,3,0] },
|
||||
],
|
||||
half_dim: [
|
||||
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,1,0,1,'x'], fingers: [0,1,2,0,3,0] },
|
||||
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,1,2,0,0,'x'], fingers: [1,2,3,1,1,0], barre: { fromStr: 2, toStr: 6, fo: 0 } },
|
||||
],
|
||||
maj6: [
|
||||
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,2,1,2,0], fingers: [1,3,4,2,4,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
|
||||
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,2,2,2], fingers: [0,1,2,3,4,4], barre: { fromStr: 1, toStr: 2, fo: 2 } },
|
||||
],
|
||||
min6: [
|
||||
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,2,0,2,0], fingers: [1,3,4,1,4,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
|
||||
],
|
||||
add9: [
|
||||
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,4,1,0,2], fingers: [1,2,4,3,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
|
||||
{ label: 'Open Cadd9', type: 'open', onlyRoot: 0, frets: ['x',3,2,0,3,0], fingers: [0,3,2,0,4,0] },
|
||||
{ label: 'Open Gadd9', type: 'open', onlyRoot: 7, frets: [3,2,0,2,3,3], fingers: [2,1,0,3,4,4] },
|
||||
{ label: 'Open Dadd9', type: 'open', onlyRoot: 2, frets: ['x','x',0,2,3,0], fingers: [0,0,0,1,3,0] },
|
||||
],
|
||||
}
|
||||
|
||||
// ─── Piano techniques ─────────────────────────────────────────────────────────
|
||||
// Each technique has intervals for left hand (LH) and right hand (RH) in semitones above root.
|
||||
// Negative values go one octave below root. Labels appear on the keys.
|
||||
|
||||
export const PIANO_TECHNIQUES = {
|
||||
maj: [
|
||||
{
|
||||
name: 'Full Chord',
|
||||
desc: 'Classic voicing — root in left, triad in right',
|
||||
tip: 'Anchor the root alone in your left hand, lock into the rhythm, let right hand sing.',
|
||||
lh: [0],
|
||||
rh: [0, 4, 7],
|
||||
},
|
||||
{
|
||||
name: 'Root + 5th',
|
||||
desc: 'Open, powerful — ambiguous major/minor quality',
|
||||
tip: 'Stack octave + fifth in left hand. Works over major or minor — great for tense moments.',
|
||||
lh: [0, 7],
|
||||
rh: [0, 4, 7, 12],
|
||||
},
|
||||
{
|
||||
name: 'Root + Octave',
|
||||
desc: 'Thunderous low end — fills space in a band',
|
||||
tip: 'Octave doubles in left hand, full chord in right. Huge sound in lower registers.',
|
||||
lh: [0, 12],
|
||||
rh: [4, 7, 12],
|
||||
},
|
||||
{
|
||||
name: 'Spread Voicing',
|
||||
desc: 'Wide, orchestral — two octaves apart',
|
||||
tip: 'Split the chord wide: 5th in left, 3rd + 5th above the octave in right. Very cinematic.',
|
||||
lh: [0, 7],
|
||||
rh: [12, 16, 19],
|
||||
},
|
||||
{
|
||||
name: 'Suspended Approach',
|
||||
desc: 'Play sus4 → resolve to major — creates motion',
|
||||
tip: 'Hit sus4 (replace 3rd with 4th) then release to the major 3rd. Works great in slow ballads.',
|
||||
lh: [0],
|
||||
rh: [0, 5, 7],
|
||||
},
|
||||
],
|
||||
min: [
|
||||
{
|
||||
name: 'Full Chord',
|
||||
desc: 'Classic minor voicing — dark and rich',
|
||||
tip: 'The minor 3rd is everything. Let it ring — do not rush to resolve.',
|
||||
lh: [0],
|
||||
rh: [0, 3, 7],
|
||||
},
|
||||
{
|
||||
name: 'Root + 5th',
|
||||
desc: 'Open ambiguity — dramatic without the sadness',
|
||||
tip: 'Power chord in both hands. Hides the minor quality — use when you want tension without gloom.',
|
||||
lh: [0, 7],
|
||||
rh: [0, 7, 12],
|
||||
},
|
||||
{
|
||||
name: 'Root + Octave',
|
||||
desc: 'Deep anchor — gives bass player space',
|
||||
tip: 'Octave in left only. Right hand plays the minor chord high up for contrast.',
|
||||
lh: [0, 12],
|
||||
rh: [3, 7, 12],
|
||||
},
|
||||
{
|
||||
name: 'Spread Minor',
|
||||
desc: 'Atmospheric and wide — film score territory',
|
||||
tip: 'Wide spacing on minor chords feels melancholic and vast. Popular in ambient and cinematic styles.',
|
||||
lh: [0, 7],
|
||||
rh: [12, 15, 19],
|
||||
},
|
||||
{
|
||||
name: 'Minor + Add9',
|
||||
desc: 'Add the 9th (2nd) — aching, bittersweet quality',
|
||||
tip: 'Replace or add the 9th to minor. Radiohead, Portishead, modern soul — this interval is gold.',
|
||||
lh: [0],
|
||||
rh: [0, 3, 7, 14],
|
||||
},
|
||||
],
|
||||
dom7: [
|
||||
{
|
||||
name: 'Full Dom7',
|
||||
desc: 'Classic dominant — loaded with tension',
|
||||
tip: 'The tritone between the 3rd and ♭7th creates all the tension. Let it ring before resolving.',
|
||||
lh: [0],
|
||||
rh: [0, 4, 7, 10],
|
||||
},
|
||||
{
|
||||
name: 'Shell Voicing (1-3-♭7)',
|
||||
desc: 'Skip the 5th — lean, jazz-approved',
|
||||
tip: 'Root + major 3rd + ♭7th. The tritone is intact, 5th is redundant. Classic jazz comp technique.',
|
||||
lh: [0],
|
||||
rh: [4, 10],
|
||||
},
|
||||
{
|
||||
name: 'Rootless Voicing',
|
||||
desc: 'Advanced comping — let bass hold the root',
|
||||
tip: 'No root in your hands at all. 3rd in left, upper structure in right. Very sophisticated sound.',
|
||||
lh: [4],
|
||||
rh: [7, 10, 14],
|
||||
},
|
||||
{
|
||||
name: 'Blues Stomp',
|
||||
desc: 'Root + 5th left, add ♭7 right — R&B classic',
|
||||
tip: 'Left hand pumps root-5th pattern, right hand stabs the dominant 7th chord. Classic gospel/blues.',
|
||||
lh: [0, 7],
|
||||
rh: [0, 4, 10],
|
||||
},
|
||||
{
|
||||
name: 'Tritone Sub',
|
||||
desc: 'Replace with the chord a tritone away',
|
||||
tip: 'G7 can be replaced with Db7 — they share the same tritone (F and B). Mind-bending jazz move.',
|
||||
lh: [6],
|
||||
rh: [10, 14, 16],
|
||||
},
|
||||
],
|
||||
maj7: [
|
||||
{
|
||||
name: 'Full Maj7',
|
||||
desc: 'Dreamy, floating — jazz and bossa nova',
|
||||
tip: 'The major 7th creates a luminous, slightly unresolved quality. Do not over-play it — let it breathe.',
|
||||
lh: [0],
|
||||
rh: [0, 4, 7, 11],
|
||||
},
|
||||
{
|
||||
name: 'Shell (1-3-7)',
|
||||
desc: 'Root + 3rd + maj7 — lush and clean',
|
||||
tip: 'Skip the 5th. The major 7th directly above the root defines the chord without clutter.',
|
||||
lh: [0],
|
||||
rh: [4, 11],
|
||||
},
|
||||
{
|
||||
name: 'Spread Maj7',
|
||||
desc: 'Maj7 in left hand — wide, orchestral texture',
|
||||
tip: 'Place the 7th below the root (or an octave down). Creates a spacious, choir-like sound.',
|
||||
lh: [0, 11],
|
||||
rh: [12, 16, 19],
|
||||
},
|
||||
{
|
||||
name: 'Add9 Variation',
|
||||
desc: 'Add the 9th for extra colour',
|
||||
tip: 'Maj9 territory. Remove the root in the right hand, add the 9th (D for Cmaj9). Very smooth.',
|
||||
lh: [0],
|
||||
rh: [4, 7, 11, 14],
|
||||
},
|
||||
],
|
||||
min7: [
|
||||
{
|
||||
name: 'Full Min7',
|
||||
desc: 'Smooth and mellow — soul and jazz workhorse',
|
||||
tip: 'The minor 7th chord is the most versatile in jazz. Comp behind everything with this.',
|
||||
lh: [0],
|
||||
rh: [0, 3, 7, 10],
|
||||
},
|
||||
{
|
||||
name: 'Shell (1-♭3-♭7)',
|
||||
desc: 'Just the 3rd and 7th in right — spacious',
|
||||
tip: 'Root in left, minor 3rd + minor 7th in right. Leaves maximum space for the soloist.',
|
||||
lh: [0],
|
||||
rh: [3, 10],
|
||||
},
|
||||
{
|
||||
name: 'Rootless Min7',
|
||||
desc: 'No root — upper structure only',
|
||||
tip: '♭3rd in left, build up from there. Very advanced jazz voicing — trust the bass player.',
|
||||
lh: [3],
|
||||
rh: [10, 14, 15],
|
||||
},
|
||||
{
|
||||
name: 'Spread Atmospheric',
|
||||
desc: 'Wide voicing — ambient and cinematic',
|
||||
tip: 'Minor 7ths voiced wide feel endless. Great for intro sections or building tension.',
|
||||
lh: [0, 7],
|
||||
rh: [10, 15, 19],
|
||||
},
|
||||
],
|
||||
dim: [
|
||||
{
|
||||
name: 'Full Diminished',
|
||||
desc: 'All three tones — tense and unstable',
|
||||
tip: 'Always wants to resolve. Use as a passing chord between diatonic chords.',
|
||||
lh: [0],
|
||||
rh: [0, 3, 6],
|
||||
},
|
||||
{
|
||||
name: 'Octave + Dim',
|
||||
desc: 'Root octave in left — more weight',
|
||||
tip: 'Dim triads are thin — doubling the root in left hand adds body.',
|
||||
lh: [0, 12],
|
||||
rh: [3, 6, 12],
|
||||
},
|
||||
],
|
||||
dim7: [
|
||||
{
|
||||
name: 'Full Dim7',
|
||||
desc: 'Symmetrical — repeats every 3 frets',
|
||||
tip: 'Any note in a dim7 chord can be the root. It modulates effortlessly. Horror/drama gold.',
|
||||
lh: [0],
|
||||
rh: [0, 3, 6, 9],
|
||||
},
|
||||
{
|
||||
name: 'Arpeggiated',
|
||||
desc: 'Roll the notes — tension without crash',
|
||||
tip: 'Roll from bottom to top quickly. Dim7 arpeggios feel like falling — use before a big resolve.',
|
||||
lh: [0, 3],
|
||||
rh: [6, 9, 12],
|
||||
},
|
||||
],
|
||||
aug: [
|
||||
{
|
||||
name: 'Full Augmented',
|
||||
desc: 'Dreamy, unresolved — whole-tone territory',
|
||||
tip: 'Aug chords are symmetrical like dim7 — every inversion sounds the same. Very otherworldly.',
|
||||
lh: [0],
|
||||
rh: [0, 4, 8],
|
||||
},
|
||||
{
|
||||
name: 'Spread Aug',
|
||||
desc: 'Wide voicing — maximises the instability',
|
||||
tip: 'Spread over two octaves. The raised 5th wants to resolve up — let the listener feel the pull.',
|
||||
lh: [0, 8],
|
||||
rh: [12, 16, 20],
|
||||
},
|
||||
],
|
||||
sus4: [
|
||||
{
|
||||
name: 'Full Sus4',
|
||||
desc: 'Suspended — neither major nor minor',
|
||||
tip: 'Ambiguous and open. Resolve down to the major 3rd for instant satisfaction.',
|
||||
lh: [0],
|
||||
rh: [0, 5, 7],
|
||||
},
|
||||
{
|
||||
name: 'Sus4 → Major',
|
||||
desc: 'Play sus4 then resolve — creates motion',
|
||||
tip: 'Hit sus4 on the beat, release to major on the off-beat. The oldest trick in the book.',
|
||||
lh: [0],
|
||||
rh: [5, 7, 12],
|
||||
},
|
||||
{
|
||||
name: 'Root + 5th + Sus',
|
||||
desc: 'Power chord with the fourth on top',
|
||||
tip: 'Very rock and dramatic. The sus4 on top gives it an anthemic, U2-esque quality.',
|
||||
lh: [0, 7],
|
||||
rh: [7, 12, 17],
|
||||
},
|
||||
],
|
||||
sus2: [
|
||||
{
|
||||
name: 'Full Sus2',
|
||||
desc: 'Open, airy — the 2nd instead of 3rd',
|
||||
tip: 'Sus2 chords feel free and unanchored. Great for intros and ambient sections.',
|
||||
lh: [0],
|
||||
rh: [0, 2, 7],
|
||||
},
|
||||
{
|
||||
name: 'Spread Sus2',
|
||||
desc: 'Wide and spacious — very ambient',
|
||||
tip: 'The 2nd voiced wide feels like an open landscape. Sigur Rós territory.',
|
||||
lh: [0, 7],
|
||||
rh: [12, 14, 19],
|
||||
},
|
||||
],
|
||||
half_dim: [
|
||||
{
|
||||
name: 'Full m7♭5',
|
||||
desc: 'Half-diminished — minor 7th with flat 5',
|
||||
tip: 'The ii chord in minor keys (e.g. Bø in C minor). Tense but smoother than full diminished.',
|
||||
lh: [0],
|
||||
rh: [0, 3, 6, 10],
|
||||
},
|
||||
{
|
||||
name: 'Shell (1-♭3-♭7)',
|
||||
desc: 'Skip the flat 5 — cleaner jazz comp',
|
||||
tip: 'The ♭5 is optional in jazz. Root + minor 3rd + minor 7th is clean and functional.',
|
||||
lh: [0],
|
||||
rh: [3, 10],
|
||||
},
|
||||
],
|
||||
maj6: [
|
||||
{
|
||||
name: 'Full Maj6',
|
||||
desc: 'Major with added 6th — vintage jazz sound',
|
||||
tip: 'Maj6 and min7 are inversions of each other. Interchangeable in many jazz contexts.',
|
||||
lh: [0],
|
||||
rh: [0, 4, 7, 9],
|
||||
},
|
||||
{
|
||||
name: 'Shell (1-3-6)',
|
||||
desc: 'Clean and retro — skip the 5th',
|
||||
tip: 'The 6th adds sweetness without too much colour. Django Reinhardt loved this voicing.',
|
||||
lh: [0],
|
||||
rh: [4, 9],
|
||||
},
|
||||
],
|
||||
min6: [
|
||||
{
|
||||
name: 'Full Min6',
|
||||
desc: 'Minor with major 6th — exotic and dark',
|
||||
tip: 'The major 6th over a minor triad is a flamenco and tango staple. Very striking colour.',
|
||||
lh: [0],
|
||||
rh: [0, 3, 7, 9],
|
||||
},
|
||||
{
|
||||
name: 'Shell (1-♭3-6)',
|
||||
desc: 'Tense and colourful — the ♭3+6 tension',
|
||||
tip: 'The minor 3rd + major 6th interval is the characteristic clash of min6. Lean into it.',
|
||||
lh: [0],
|
||||
rh: [3, 9],
|
||||
},
|
||||
],
|
||||
add9: [
|
||||
{
|
||||
name: 'Full Add9',
|
||||
desc: 'Major chord + 9th — no 7th, stays bright',
|
||||
tip: 'The 9th adds colour without the jazz sophistication of maj9. Feels modern and open.',
|
||||
lh: [0],
|
||||
rh: [0, 4, 7, 14],
|
||||
},
|
||||
{
|
||||
name: 'No Root Add9',
|
||||
desc: 'Skip root in right — 3rd + 9th float',
|
||||
tip: 'Root in left, right hand plays 3rd + 5th + 9th. Airy and modern — Radiohead / Coldplay territory.',
|
||||
lh: [0],
|
||||
rh: [4, 7, 14],
|
||||
},
|
||||
{
|
||||
name: 'Sus2-style',
|
||||
desc: 'Add9 voiced as sus2 clusters',
|
||||
tip: 'Place the 9th close to the root (2nd instead of 9th). Creates a shimmering cluster effect.',
|
||||
lh: [0, 7],
|
||||
rh: [2, 4, 7],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Fallback for chord types not in the map
|
||||
const GENERIC_PIANO = [
|
||||
{
|
||||
name: 'Full Chord',
|
||||
desc: 'Root in left, chord tones in right',
|
||||
tip: 'Play the chord tones in right hand while anchoring the root in the left.',
|
||||
lh: [0],
|
||||
rh: [0], // will be replaced by computed intervals
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Compute functions ────────────────────────────────────────────────────────
|
||||
|
||||
// Parse a chord name like "C#m7" → { rootPc: 1, type: 'min7' }
|
||||
// Mirrors the logic in theory.js parseChord
|
||||
function parseChord(name) {
|
||||
if (!name) return null
|
||||
const NOTES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
|
||||
const NOTES_FLAT = ['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B']
|
||||
const suffixMap = {
|
||||
'': 'maj', 'm': 'min', 'min': 'min', 'maj': 'maj',
|
||||
'7': 'dom7', 'maj7': 'maj7', 'm7': 'min7', 'min7': 'min7',
|
||||
'dim': 'dim', 'dim7': 'dim7', 'm7b5': 'half_dim', 'ø': 'half_dim', 'ø7': 'half_dim',
|
||||
'aug': 'aug', '+': 'aug',
|
||||
'sus4': 'sus4', 'sus2': 'sus2',
|
||||
'6': 'maj6', 'm6': 'min6', 'min6': 'min6',
|
||||
'add9': 'add9',
|
||||
}
|
||||
|
||||
let rest = name
|
||||
let root = ''
|
||||
if (rest.length > 1 && (rest[1] === '#' || rest[1] === 'b')) {
|
||||
root = rest.slice(0, 2); rest = rest.slice(2)
|
||||
} else {
|
||||
root = rest.slice(0, 1); rest = rest.slice(1)
|
||||
}
|
||||
|
||||
let rootPc = NOTES.indexOf(root)
|
||||
if (rootPc === -1) rootPc = NOTES_FLAT.indexOf(root)
|
||||
if (rootPc === -1) return null
|
||||
|
||||
const type = suffixMap[rest] ?? 'maj'
|
||||
return { rootPc, type }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of voicing objects for a given chord name.
|
||||
* Each voicing: { label, frets[6], fingers[6], barre?, baseFret }
|
||||
* frets values: number (fret) or 'x' (muted) or 0 (open)
|
||||
*/
|
||||
export function getGuitarVoicings(chordName) {
|
||||
const parsed = parseChord(chordName)
|
||||
if (!parsed) return []
|
||||
const { rootPc, type } = parsed
|
||||
const shapes = GUITAR_SHAPES[type] ?? GUITAR_SHAPES.maj
|
||||
|
||||
const result = []
|
||||
|
||||
for (const shape of shapes) {
|
||||
if (shape.type === 'open') {
|
||||
if (shape.onlyRoot !== undefined && shape.onlyRoot !== rootPc) continue
|
||||
result.push({
|
||||
label: shape.label,
|
||||
frets: shape.frets,
|
||||
fingers: shape.fingers,
|
||||
barre: null,
|
||||
baseFret: 1,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// barre shape: compute rootFret on rootStr
|
||||
const strIdx = shape.rootStr - 1 // 0=s6 … 5=s1
|
||||
const openPc = OPEN[strIdx]
|
||||
let rootFret = (rootPc - openPc + 12) % 12
|
||||
|
||||
// Compute absolute frets
|
||||
const frets = shape.offsets.map((off, i) => {
|
||||
if (off === 'x') return 'x'
|
||||
return rootFret + off
|
||||
})
|
||||
|
||||
// Skip impossible positions
|
||||
if (frets.some(f => typeof f === 'number' && f < 0)) continue
|
||||
const maxFret = Math.max(...frets.filter(f => f !== 'x'))
|
||||
if (maxFret > 15) continue
|
||||
|
||||
// baseFret: start diagram so the chord fits in 5 frets
|
||||
const minFret = Math.min(...frets.filter(f => f !== 'x' && f > 0))
|
||||
const baseFret = rootFret === 0 ? 1 : Math.max(1, minFret)
|
||||
|
||||
// Compute barre position if applicable
|
||||
let barre = null
|
||||
if (shape.barre) {
|
||||
barre = {
|
||||
fret: rootFret + shape.barre.fo,
|
||||
fromStr: shape.barre.fromStr,
|
||||
toStr: shape.barre.toStr,
|
||||
}
|
||||
}
|
||||
|
||||
// For open-string E/A chords (rootFret=0), no barre needed
|
||||
if (rootFret === 0) barre = null
|
||||
|
||||
result.push({
|
||||
label: shape.label + (rootFret === 0 ? ' (Open)' : rootFret > 5 ? ` — Fret ${rootFret}` : ''),
|
||||
frets,
|
||||
fingers: shape.fingers,
|
||||
barre,
|
||||
baseFret,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns piano techniques for a chord name.
|
||||
* Each technique has: { name, desc, tip, lh (intervals), rh (intervals) }
|
||||
*/
|
||||
export function getPianoTechniques(chordName) {
|
||||
const parsed = parseChord(chordName)
|
||||
if (!parsed) return []
|
||||
const { type } = parsed
|
||||
return PIANO_TECHNIQUES[type] ?? GENERIC_PIANO
|
||||
}
|
||||
|
||||
export { parseChord }
|
||||
@@ -78,17 +78,7 @@ export function useAudioTuner() {
|
||||
const startListening = async () => {
|
||||
if (isListening) return
|
||||
try {
|
||||
// Read saved device selection from config (if present)
|
||||
let deviceId = null
|
||||
try {
|
||||
const cfg = JSON.parse(localStorage.getItem('wtf_config') || '{}')
|
||||
deviceId = cfg.audioDeviceId || null
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
const constraints = deviceId ? { audio: { deviceId: { exact: deviceId } } } : { audio: true }
|
||||
console.log('Tuner requesting getUserMedia with', constraints)
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)()
|
||||
const analyser = ctx.createAnalyser()
|
||||
analyser.fftSize = 4096
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react'
|
||||
|
||||
const INITIAL_SLOT_COUNT = 4
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeWaveform(audioBuffer, points = 200) {
|
||||
const data = audioBuffer.getChannelData(0)
|
||||
const step = Math.max(1, Math.floor(data.length / points))
|
||||
const out = new Float32Array(points)
|
||||
for (let i = 0; i < points; i++) {
|
||||
let max = 0
|
||||
for (let j = 0; j < step; j++) {
|
||||
const v = Math.abs(data[i * step + j] ?? 0)
|
||||
if (v > max) max = v
|
||||
}
|
||||
out[i] = max
|
||||
}
|
||||
const peak = Math.max(...out, 0.001)
|
||||
for (let i = 0; i < points; i++) out[i] /= peak
|
||||
return out
|
||||
}
|
||||
|
||||
function makeEmptySlot() {
|
||||
return {
|
||||
status: 'empty', // 'empty'|'recording'|'trimming'|'playing'|'muted'
|
||||
audioBuffer: null,
|
||||
originalBuffer: null, // always the full recording, never overwritten
|
||||
waveform: null, // Float32Array(200) for canvas display
|
||||
trimStart: 0,
|
||||
trimEnd: 1,
|
||||
volume: 0.8,
|
||||
sourceNode: null,
|
||||
gainNode: null,
|
||||
recordingDuration: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useLoopEngine(bpm) {
|
||||
const audioCtxRef = useRef(null)
|
||||
const masterStartRef = useRef(null) // AudioContext timestamp of first loop start
|
||||
const masterLenRef = useRef(null) // master loop length in seconds
|
||||
const recorderRef = useRef(null) // { mr, chunks, slotIdx }
|
||||
const slotsRef = useRef(null)
|
||||
const streamRef = useRef(null)
|
||||
const timerRef = useRef(null)
|
||||
|
||||
const [slots, setSlots] = useState(() => {
|
||||
const s = Array.from({ length: INITIAL_SLOT_COUNT }, makeEmptySlot)
|
||||
slotsRef.current = s
|
||||
return s
|
||||
})
|
||||
const [masterLen, setMasterLen] = useState(null) // mirrors masterLenRef for display
|
||||
|
||||
const updateSlot = useCallback((idx, patch) => {
|
||||
setSlots(prev => {
|
||||
const next = prev.map((s, i) => i === idx ? { ...s, ...patch } : s)
|
||||
slotsRef.current = next
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ── AudioContext ────────────────────────────────────────────────────────────
|
||||
const ensureCtx = useCallback(() => {
|
||||
if (!audioCtxRef.current || audioCtxRef.current.state === 'closed') {
|
||||
audioCtxRef.current = new AudioContext()
|
||||
}
|
||||
if (audioCtxRef.current.state === 'suspended') {
|
||||
audioCtxRef.current.resume().catch(() => {})
|
||||
}
|
||||
return audioCtxRef.current
|
||||
}, [])
|
||||
|
||||
// ── Called by AudioCapture when mic stream is ready ─────────────────────────
|
||||
const setStream = useCallback((stream) => {
|
||||
streamRef.current = stream
|
||||
ensureCtx() // pre-warm so first record click has no AudioContext startup lag
|
||||
}, [ensureCtx])
|
||||
|
||||
// ── Start recording on a slot ───────────────────────────────────────────────
|
||||
const startRecord = useCallback((slotIdx) => {
|
||||
if (recorderRef.current) return // one at a time
|
||||
if (!streamRef.current) return
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/webm'
|
||||
|
||||
const mr = new MediaRecorder(streamRef.current, { mimeType })
|
||||
const chunks = []
|
||||
|
||||
mr.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data) }
|
||||
|
||||
mr.onstop = async () => {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
// Guard: was this slot cancelled before onstop fired?
|
||||
if (slotsRef.current[slotIdx]?.status !== 'recording') return
|
||||
try {
|
||||
const ctx = ensureCtx()
|
||||
const blob = new Blob(chunks, { type: mimeType })
|
||||
const arrayBuffer = await blob.arrayBuffer()
|
||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
|
||||
const waveform = makeWaveform(audioBuffer)
|
||||
updateSlot(slotIdx, {
|
||||
status: 'trimming',
|
||||
audioBuffer,
|
||||
originalBuffer: audioBuffer, // preserve forever
|
||||
waveform,
|
||||
trimStart: 0,
|
||||
trimEnd: 1,
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[LoopEngine] decode failed:', err)
|
||||
updateSlot(slotIdx, makeEmptySlot())
|
||||
}
|
||||
recorderRef.current = null
|
||||
}
|
||||
|
||||
mr.onerror = () => {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
recorderRef.current = null
|
||||
updateSlot(slotIdx, makeEmptySlot())
|
||||
}
|
||||
|
||||
recorderRef.current = { mr, chunks, slotIdx }
|
||||
mr.start(250)
|
||||
updateSlot(slotIdx, { status: 'recording', recordingDuration: 0 })
|
||||
|
||||
const t0 = Date.now()
|
||||
timerRef.current = setInterval(() => {
|
||||
updateSlot(slotIdx, { recordingDuration: (Date.now() - t0) / 1000 })
|
||||
}, 100)
|
||||
}, [ensureCtx, updateSlot])
|
||||
|
||||
// ── Stop recording → triggers onstop → trimming state ──────────────────────
|
||||
const stopRecord = useCallback((slotIdx) => {
|
||||
const rec = recorderRef.current
|
||||
if (!rec || rec.slotIdx !== slotIdx) return
|
||||
if (rec.mr.state !== 'inactive') rec.mr.stop()
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}, [])
|
||||
|
||||
// ── Cancel recording (discard) ──────────────────────────────────────────────
|
||||
const cancelRecord = useCallback((slotIdx) => {
|
||||
const rec = recorderRef.current
|
||||
if (rec && rec.slotIdx === slotIdx) {
|
||||
// Prevent onstop from processing by marking slot empty first
|
||||
updateSlot(slotIdx, makeEmptySlot())
|
||||
if (rec.mr.state !== 'inactive') rec.mr.stop()
|
||||
recorderRef.current = null
|
||||
} else {
|
||||
updateSlot(slotIdx, makeEmptySlot())
|
||||
}
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}, [updateSlot])
|
||||
|
||||
// ── Commit trim and start looping ───────────────────────────────────────────
|
||||
const commitTrim = useCallback((slotIdx, trimStart, trimEnd) => {
|
||||
const ctx = ensureCtx()
|
||||
const slot = slotsRef.current[slotIdx]
|
||||
if (!slot?.audioBuffer) return
|
||||
|
||||
const fullBuf = slot.audioBuffer
|
||||
const sr = fullBuf.sampleRate
|
||||
const startSample = Math.floor(trimStart * fullBuf.length)
|
||||
const endSample = Math.ceil(trimEnd * fullBuf.length)
|
||||
const rawLen = endSample - startSample
|
||||
if (rawLen < sr * 0.2) return // reject clips shorter than 200ms
|
||||
|
||||
const rawData = fullBuf.getChannelData(0).slice(startSample, endSample)
|
||||
const rawSec = rawLen / sr
|
||||
|
||||
// Master loop length is set exactly from the first loop's trimmed region.
|
||||
// Subsequent loops are padded/trimmed to match that length.
|
||||
const isFirst = masterLenRef.current === null
|
||||
const targetSec = isFirst ? rawSec : masterLenRef.current
|
||||
const targetLen = Math.round(targetSec * sr)
|
||||
|
||||
const finalData = new Float32Array(targetLen) // zero-padded by default
|
||||
finalData.set(rawData.slice(0, Math.min(rawLen, targetLen)))
|
||||
|
||||
const finalBuf = ctx.createBuffer(1, targetLen, sr)
|
||||
finalBuf.copyToChannel(finalData, 0)
|
||||
|
||||
if (isFirst) {
|
||||
masterLenRef.current = targetSec
|
||||
setMasterLen(targetSec)
|
||||
}
|
||||
|
||||
const gainNode = ctx.createGain()
|
||||
gainNode.gain.value = slot.volume
|
||||
gainNode.connect(ctx.destination)
|
||||
|
||||
const sourceNode = ctx.createBufferSource()
|
||||
sourceNode.buffer = finalBuf
|
||||
sourceNode.loop = true
|
||||
sourceNode.loopStart = 0
|
||||
sourceNode.loopEnd = targetSec
|
||||
sourceNode.connect(gainNode)
|
||||
|
||||
// Schedule: first loop starts immediately; subsequent loops snap to the
|
||||
// next master loop boundary so they play in perfect sync.
|
||||
let startTime
|
||||
if (isFirst) {
|
||||
startTime = ctx.currentTime + 0.05
|
||||
masterStartRef.current = startTime
|
||||
} else {
|
||||
const elapsed = ctx.currentTime - masterStartRef.current
|
||||
const loopLen = masterLenRef.current
|
||||
const posInLoop = elapsed % loopLen
|
||||
const remaining = loopLen - posInLoop
|
||||
const LOOKAHEAD = 0.05
|
||||
startTime = ctx.currentTime + (remaining > LOOKAHEAD ? remaining : remaining + loopLen)
|
||||
}
|
||||
sourceNode.start(startTime)
|
||||
|
||||
updateSlot(slotIdx, {
|
||||
status: 'playing',
|
||||
audioBuffer: finalBuf,
|
||||
originalBuffer: slot.originalBuffer ?? null, // preserve original
|
||||
waveform: makeWaveform(finalBuf),
|
||||
trimStart: 0,
|
||||
trimEnd: 1,
|
||||
sourceNode,
|
||||
gainNode,
|
||||
})
|
||||
}, [ensureCtx, updateSlot])
|
||||
|
||||
// ── Re-trim: return a playing/muted slot back to the trimmer ────────────────
|
||||
const retrimSlot = useCallback((slotIdx) => {
|
||||
const slot = slotsRef.current[slotIdx]
|
||||
if (!slot?.originalBuffer) return
|
||||
|
||||
// Stop current playback
|
||||
try { slot.sourceNode?.stop() } catch {}
|
||||
try { slot.sourceNode?.disconnect() } catch {}
|
||||
try { slot.gainNode?.disconnect() } catch {}
|
||||
|
||||
// Reset master timing if no other loops remain active
|
||||
const hasOthers = slotsRef.current.some((s, i) =>
|
||||
i !== slotIdx && (s.status === 'playing' || s.status === 'muted')
|
||||
)
|
||||
if (!hasOthers) {
|
||||
masterStartRef.current = null
|
||||
masterLenRef.current = null
|
||||
setMasterLen(null)
|
||||
}
|
||||
|
||||
updateSlot(slotIdx, {
|
||||
status: 'trimming',
|
||||
audioBuffer: slot.originalBuffer,
|
||||
waveform: makeWaveform(slot.originalBuffer),
|
||||
originalBuffer: slot.originalBuffer,
|
||||
trimStart: 0,
|
||||
trimEnd: 1,
|
||||
sourceNode: null,
|
||||
gainNode: null,
|
||||
})
|
||||
}, [updateSlot])
|
||||
|
||||
// ── Toggle mute ─────────────────────────────────────────────────────────────
|
||||
const toggleMute = useCallback((slotIdx) => {
|
||||
const ctx = audioCtxRef.current
|
||||
const slot = slotsRef.current[slotIdx]
|
||||
if (!slot || !ctx) return
|
||||
if (slot.status !== 'playing' && slot.status !== 'muted') return
|
||||
const muting = slot.status === 'playing'
|
||||
slot.gainNode?.gain.setTargetAtTime(muting ? 0 : slot.volume, ctx.currentTime, 0.02)
|
||||
updateSlot(slotIdx, { status: muting ? 'muted' : 'playing' })
|
||||
}, [updateSlot])
|
||||
|
||||
// ── Delete slot ─────────────────────────────────────────────────────────────
|
||||
const deleteSlot = useCallback((slotIdx) => {
|
||||
const slot = slotsRef.current[slotIdx]
|
||||
if (!slot) return
|
||||
if (slot.status === 'recording') cancelRecord(slotIdx)
|
||||
try { slot.sourceNode?.stop() } catch {}
|
||||
try { slot.sourceNode?.disconnect() } catch {}
|
||||
try { slot.gainNode?.disconnect() } catch {}
|
||||
|
||||
// Reset master timing when no loops remain active
|
||||
const hasOthers = slotsRef.current.some((s, i) =>
|
||||
i !== slotIdx && (s.status === 'playing' || s.status === 'muted')
|
||||
)
|
||||
if (!hasOthers) {
|
||||
masterStartRef.current = null
|
||||
masterLenRef.current = null
|
||||
setMasterLen(null)
|
||||
}
|
||||
updateSlot(slotIdx, makeEmptySlot())
|
||||
}, [cancelRecord, updateSlot])
|
||||
|
||||
// ── Volume ──────────────────────────────────────────────────────────────────
|
||||
const setVolume = useCallback((slotIdx, vol) => {
|
||||
const ctx = audioCtxRef.current
|
||||
const slot = slotsRef.current[slotIdx]
|
||||
if (slot?.gainNode && ctx && slot.status === 'playing') {
|
||||
slot.gainNode.gain.setTargetAtTime(vol, ctx.currentTime, 0.02)
|
||||
}
|
||||
updateSlot(slotIdx, { volume: vol })
|
||||
}, [updateSlot])
|
||||
|
||||
// ── Main click handler (drives the state machine) ───────────────────────────
|
||||
const handleSlotClick = useCallback((slotIdx) => {
|
||||
const slot = slotsRef.current[slotIdx]
|
||||
if (!slot) return
|
||||
switch (slot.status) {
|
||||
case 'empty': startRecord(slotIdx); break
|
||||
case 'recording': stopRecord(slotIdx); break
|
||||
case 'playing':
|
||||
case 'muted': toggleMute(slotIdx); break
|
||||
// 'trimming' is handled entirely by LoopTrimmer
|
||||
}
|
||||
}, [startRecord, stopRecord, toggleMute])
|
||||
|
||||
// ── Add a new empty slot ────────────────────────────────────────────────────
|
||||
const addSlot = useCallback(() => {
|
||||
setSlots(prev => {
|
||||
const next = [...prev, makeEmptySlot()]
|
||||
slotsRef.current = next
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ── Cleanup ─────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearInterval(timerRef.current)
|
||||
slotsRef.current?.forEach(s => {
|
||||
try { s.sourceNode?.stop() } catch {}
|
||||
try { s.sourceNode?.disconnect() } catch {}
|
||||
try { s.gainNode?.disconnect() } catch {}
|
||||
})
|
||||
try { audioCtxRef.current?.close() } catch {}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
slots,
|
||||
masterLen,
|
||||
setStream,
|
||||
handleSlotClick,
|
||||
commitTrim,
|
||||
cancelRecord,
|
||||
retrimSlot,
|
||||
deleteSlot,
|
||||
setVolume,
|
||||
addSlot,
|
||||
audioCtxRef,
|
||||
masterStartRef,
|
||||
masterLenRef,
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,9 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Essentia uses emscripten output that esbuild can't pre-bundle reliably.
|
||||
// Exclude it so Vite serves the files directly from node_modules.
|
||||
exclude: ['essentia.js'],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user