Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f583979b3e | |||
| 8cd9b01941 | |||
| 4a45e82abe | |||
| b94f79f408 | |||
| 7d5ad306b4 | |||
| 2efc785694 | |||
| 3ddf854123 | |||
| a1048b0e55 | |||
| 2ce6d1e244 | |||
| dec0fc1595 | |||
| b989238373 | |||
| 3aadf1afbb | |||
| 22b8f1fe04 | |||
| e3e4554d1f | |||
| 3a7b6b24e0 | |||
| 49df62ff73 | |||
| 4500d0c209 | |||
| a871ad5325 | |||
| 105c5b3334 | |||
| 9c5ec89948 | |||
| b30267a50a | |||
| 6111cfedd1 | |||
| 7a108ad0e2 | |||
| c08cd619f7 | |||
| f133abfdee | |||
| 0eb00784a6 | |||
| ca6b901bc8 | |||
| d3a2840454 | |||
| cfa7b877f6 | |||
| 4ed382393e | |||
| 1697a14716 | |||
| 8f96afe015 | |||
| 850e87f90f | |||
| 10962077bf |
@@ -0,0 +1,77 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,4 +1,15 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Env
|
||||
.env
|
||||
debug.log
|
||||
|
||||
releases/
|
||||
release/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,74 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development (Vite dev server + Electron window with hot reload)
|
||||
npm run electron:dev
|
||||
|
||||
# Browser-only dev (no Electron)
|
||||
npm run dev
|
||||
|
||||
# Build installers
|
||||
npm run electron:build:win # Windows NSIS installer → releases/
|
||||
npm run electron:build:mac # macOS DMG → releases/
|
||||
npm run electron:build:linux # Linux AppImage → releases/
|
||||
```
|
||||
|
||||
No test suite exists. There is no lint script — no ESLint config is present.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Electron shell** (`electron/main.cjs`) loads `dist/index.html` in production or `localhost:5173` in dev. The renderer process has full Web Audio API access (`sandbox: false`). `preload.cjs` uses `contextIsolation: true` with no exposed IPC — Electron is purely a window host; all logic lives in the renderer.
|
||||
|
||||
**Two audio pipelines run in parallel** inside `AudioCapture.jsx`:
|
||||
|
||||
| Path | FFT | Purpose |
|
||||
|---|---|---|
|
||||
| Pitch | 4096 samples (~90ms, `smoothingTimeConstant=0.0`) | McLeod pitch detection via `pitchy` → feeds key detection |
|
||||
| Chord | 16384 samples (~370ms, `smoothingTimeConstant=0.5`) | Harmonic summation chroma → feeds chord detection |
|
||||
|
||||
The 16384 FFT gives 2.7 Hz/bin resolution, which is necessary to separate adjacent semitones on low guitar strings (~5-6 Hz apart). The chord analyser uses `computeChroma()` — a harmonic summation that folds each FFT bin back through 5 harmonics to cancel overtone contamination (prevents minor chords from reading as major).
|
||||
|
||||
**State and detection logic lives entirely in `App.jsx`:**
|
||||
|
||||
- `handleNote` (pitch callback) → accumulates `noteHistoryRef`, runs Krumhansl-Schmuckler key detection every 5 notes, votes in `keyVotesRef` (rolling window, requires strong consensus before committing)
|
||||
- `handleChroma` (chord callback) → averages a ring buffer of `chromaSmooth` frames, runs a **chroma stability gate** (per-bin variance check — bails if still in transition), then matches against chord templates via `matchChordFromChroma`, votes in `chordVotesRef`
|
||||
- `handleOnset` (onset callback from RMS spike detection) → builds a **tempo histogram** from pairwise inter-onset intervals, folding all intervals into 55–220 BPM range; the histogram peak drives BPM display
|
||||
|
||||
Both `handleNote` and `handleChroma` use `useCallback(fn, [])` (empty deps). All values they need from render scope are kept in refs synced via `useEffect` — this prevents `AudioCapture`'s `start` from recreating on every render.
|
||||
|
||||
**All music theory is in `src/lib/theory.js`:**
|
||||
|
||||
- `detectKey` / `detectTopKeys` — Krumhansl-Schmuckler correlation against major/minor profiles only (K-S cannot distinguish modes — Dorian vs natural minor look the same; user manually picks mode)
|
||||
- `matchChordFromChroma` — weighted coverage score (inEnergy / (inEnergy + outEnergy×0.7)), requires root presence (`chroma[r] >= 0.08`), margin over second-best, diatonic/bass bonuses
|
||||
- `MATCH_CHORD_TYPES` — the subset of chord types used in real-time detection (not all of `CHORD_TYPES`)
|
||||
- `detectRepeatingProgression` — non-overlapping pattern match over last 20 chords, length 2–6
|
||||
|
||||
**`src/services/audioService.js`** is a self-contained tuner hook (`useAudioTuner`) used only by `Tuner.jsx`. It uses its own separate `AudioContext` with simple autocorrelation — independent from the main pitch/chord pipeline.
|
||||
|
||||
## Design tokens (Tailwind)
|
||||
|
||||
Defined in `tailwind.config.js`: `bg-surface` (#0f0f0f), `bg-panel` (#1a1a1a), `border-border` (#2a2a2a), `text-accent` / `bg-accent` (#a855f7 purple). Use these rather than raw hex in components.
|
||||
|
||||
## Key configuration (`DEFAULTS` in `App.jsx`)
|
||||
|
||||
| Key | Purpose |
|
||||
|---|---|
|
||||
| `chromaSmooth` | Ring buffer size (frames averaged before chord check) |
|
||||
| `chordVoteThreshold` | Consecutive matching chord frames required to commit |
|
||||
| `chordMinScore` | Minimum coverage score from `matchChordFromChroma` |
|
||||
| `keyVoteWindow` / `keyVoteThreshold` | Rolling window size and consensus count for key lock |
|
||||
| `noteHistorySize` | Max pitch-class history kept for K-S key detection |
|
||||
|
||||
These are exposed in `Settings.jsx` as sliders. `configRef` keeps a ref in sync so stable callbacks can read current values.
|
||||
|
||||
## Instrument views
|
||||
|
||||
`Fretboard.jsx` and `Piano.jsx` are SVG-rendered visualisers. Both accept `keyInfo`, `currentChord`, and `monoColor`. They call `getPentatonicScale`, `getFullScale`, `getChordTones` from `theory.js` and colour notes by tier: chord tone (purple `#a855f7`) > pentatonic (amber or light purple in mono) > scale (dark gray or lightest purple in mono).
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
`.github/workflows/release.yml` builds Windows and macOS installers on tagged pushes (`v*`) using `softprops/action-gh-release@v2`. Build scripts use `--publish never` to prevent electron-builder's own publish step.
|
||||
@@ -0,0 +1,134 @@
|
||||
# WhatTheFlat
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **App shell** | Electron |
|
||||
| **UI** | React 18, Tailwind CSS, Vite |
|
||||
| **Audio** | Web Audio API, [Pitchy](https://github.com/ianprime0509/pitchy) (McLeod pitch detection) |
|
||||
| **Music theory** | Custom JS — Krumhansl-Schmuckler key detection, chroma-based chord matching |
|
||||
|
||||
### Dependencies (`frontend/package.json`)
|
||||
|
||||
**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
|
||||
|
||||
## Development
|
||||
|
||||
```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/`.
|
||||
|
||||
## Releasing / Tagging
|
||||
|
||||
To create a GitHub release and trigger the CI build pipeline, create an annotated tag and push it to origin. The release workflow runs on tags matching `v*` (for example `v0.6.1`).
|
||||
|
||||
Local tagging example:
|
||||
|
||||
```bash
|
||||
# update package.json version first if desired
|
||||
git tag -a v0.6.1 -m "Release v0.6.1"
|
||||
git push origin v0.6.1
|
||||
```
|
||||
|
||||
What the GitHub Action does (`.github/workflows/release.yml`):
|
||||
|
||||
- Listens for pushed tags `v*` and runs a matrix build across Windows, macOS and Linux.
|
||||
- macOS is built as a universal binary (`--universal`) so a single DMG supports both Intel and Apple Silicon.
|
||||
- Each matrix job builds the installer using `electron-builder`, uploads its artifacts, and a final `publish` job aggregates all artifacts into one GitHub release.
|
||||
|
||||
If you prefer to run builds locally before tagging, use the npm scripts in the repository root:
|
||||
|
||||
```bash
|
||||
# Windows NSIS
|
||||
npm run electron:build:win
|
||||
|
||||
# macOS DMG (universal)
|
||||
npm run electron:build:mac -- --universal
|
||||
|
||||
# Linux AppImage
|
||||
npm run electron:build:linux
|
||||
```
|
||||
|
||||
CI notes / troubleshooting
|
||||
- The workflow uploads artifacts from `release/` into the release. Ensure `package.json` build `directories.output` matches the workflow's expected `release/` folder.
|
||||
- If mac packaging for x64 on ARM-hosted runners fails, switch to `--universal` (already configured) or build x64 on an Intel runner.
|
||||
- To test the workflow locally, consider using `nektos/act` or push a temporary tag like `vtest`.
|
||||
|
||||
## 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.
|
||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,55 @@
|
||||
const { app, BrowserWindow, systemPreferences } = require('electron')
|
||||
const path = require('path')
|
||||
|
||||
const isDev = !app.isPackaged
|
||||
|
||||
async function handlePermissions() {
|
||||
// macOS requires an explicit native request for microphone access in packaged apps
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const status = systemPreferences.getMediaAccessStatus('microphone')
|
||||
if (status !== 'granted') {
|
||||
await systemPreferences.askForMediaAccess('microphone')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Main] Microphone permission error:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 900,
|
||||
minWidth: 620,
|
||||
minHeight: 600,
|
||||
title: 'WhatTheFlat ♭? - JamBuddy',
|
||||
icon: path.join(__dirname, '../assets/whattheflat-logo.png'),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false, // allows renderer getUserMedia to work on all platforms
|
||||
},
|
||||
})
|
||||
|
||||
if (isDev) {
|
||||
win.loadURL('http://localhost:5173')
|
||||
win.webContents.openDevTools()
|
||||
} else {
|
||||
win.loadFile(path.join(__dirname, '../dist/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
await handlePermissions()
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import { app, BrowserWindow, systemPreferences } from 'electron';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const isDev = !app.isPackaged;
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
async function handlePermissions() {
|
||||
// macOS requires an explicit request for microphone access in packaged apps
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const status = systemPreferences.getMediaAccessStatus('microphone');
|
||||
console.log(`[Main Process] Current Microphone Status: ${status}`);
|
||||
|
||||
if (status !== 'granted') {
|
||||
const granted = await systemPreferences.askForMediaAccess('microphone');
|
||||
console.log(`[Main Process] Microphone permission granted after request: ${granted}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Main Process] Error checking/requesting microphone access:', err);
|
||||
}
|
||||
} else {
|
||||
// Windows/Linux typically grant access by default or via the Renderer process
|
||||
console.log(`[Main Process] Platform is ${process.platform}, skipping native macOS permission prompt.`);
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1024,
|
||||
height: 768,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// Ensure the browser engine treats the app as "secure" to allow microphone access
|
||||
sandbox: false
|
||||
}
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
const devUrl = process.env.ELECTRON_START_URL || 'http://localhost:5173';
|
||||
win.loadURL(devUrl);
|
||||
// Open DevTools automatically in dev
|
||||
win.webContents.openDevTools();
|
||||
} else {
|
||||
// In production, load the built index.html from the dist folder
|
||||
// This path assumes your structure is /electron/main.js and /dist/index.html
|
||||
win.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
await handlePermissions();
|
||||
createWindow();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
const { contextBridge } = require('electron');
|
||||
// Preload runs in a privileged context before the renderer.
|
||||
// Expose only what the app actually needs from Node/Electron here.
|
||||
// Currently the app is pure browser JS so nothing needs exposing.
|
||||
const { contextBridge } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Add IPC helpers here if needed later
|
||||
});
|
||||
platform: process.platform,
|
||||
})
|
||||
|
||||
@@ -3,15 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!--
|
||||
Content Security Policy:
|
||||
- In development Vite uses eval() for HMR/source maps; avoid setting
|
||||
'unsafe-eval' here or HMR may break. The dev server will warn but
|
||||
packaged apps will use built files and respect this CSP.
|
||||
-->
|
||||
<!-- Allow 'unsafe-eval' during development for Vite HMR. Remove or tighten for production packaging. -->
|
||||
<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</title>
|
||||
<title>WhatTheFlat ♭? - JamBuddy</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "whattheflat",
|
||||
"version": "0.1.0",
|
||||
"name": "jambuddy",
|
||||
"version": "0.6.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "whattheflat",
|
||||
"version": "0.1.0",
|
||||
"name": "jambuddy",
|
||||
"version": "0.6.2",
|
||||
"dependencies": {
|
||||
"audiomotion-analyzer": "^4.5.4",
|
||||
"pitchy": "^4.1.0",
|
||||
@@ -18,11 +18,9 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^40.7.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"postcss": "^8.4.47",
|
||||
"electron-builder": "^26.8.1",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vite": "^7.3.1",
|
||||
"wait-on": "^9.0.4"
|
||||
@@ -33,6 +31,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"js-tokens": "^4.0.0",
|
||||
@@ -47,6 +46,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
||||
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -56,6 +56,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -86,6 +87,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
@@ -102,6 +104,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
@@ -118,6 +121,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -127,6 +131,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
@@ -140,6 +145,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
@@ -157,6 +163,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
|
||||
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -166,6 +173,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -175,6 +183,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -184,6 +193,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -193,6 +203,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
|
||||
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
@@ -206,6 +217,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
||||
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
},
|
||||
@@ -221,6 +233,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
|
||||
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
@@ -236,6 +249,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
|
||||
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
@@ -251,6 +265,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
@@ -265,6 +280,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -283,6 +299,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
@@ -1375,6 +1392,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
@@ -1385,6 +1403,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
@@ -1395,6 +1414,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
@@ -1403,13 +1423,15 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
@@ -1569,6 +1591,7 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -1582,6 +1605,7 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@@ -1595,6 +1619,7 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -1608,6 +1633,7 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -1621,6 +1647,7 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -1634,6 +1661,7 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@@ -1647,6 +1675,7 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1660,6 +1689,7 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1673,6 +1703,7 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1686,6 +1717,7 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1699,6 +1731,7 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1712,6 +1745,7 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1725,6 +1759,7 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1738,6 +1773,7 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1751,6 +1787,7 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1764,6 +1801,7 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1777,6 +1815,7 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1790,6 +1829,7 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1803,6 +1843,7 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1816,6 +1857,7 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@@ -1829,6 +1871,7 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
@@ -1842,6 +1885,7 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -1855,6 +1899,7 @@
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -1868,6 +1913,7 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -1881,6 +1927,7 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -2142,6 +2189,70 @@
|
||||
"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",
|
||||
@@ -2196,6 +2307,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.20.7",
|
||||
"@babel/types": "^7.20.7",
|
||||
@@ -2209,6 +2321,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
|
||||
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
@@ -2218,6 +2331,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
||||
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.1.0",
|
||||
"@babel/types": "^7.0.0"
|
||||
@@ -2228,6 +2342,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
|
||||
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
@@ -2259,7 +2374,8 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/fs-extra": {
|
||||
"version": "9.0.13",
|
||||
@@ -2723,42 +2839,6 @@
|
||||
"url": "https://ko-fi.com/hvianna"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.27",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
|
||||
"integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.1",
|
||||
"caniuse-lite": "^1.0.30001774",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"autoprefixer": "bin/autoprefixer"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
@@ -2807,6 +2887,7 @@
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
@@ -2867,6 +2948,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3147,7 +3229,8 @@
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
]
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
@@ -3383,7 +3466,8 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.2",
|
||||
@@ -3455,13 +3539,15 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
@@ -3938,7 +4024,8 @@
|
||||
"version": "1.5.307",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
|
||||
"integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-winstaller": {
|
||||
"version": "5.4.0",
|
||||
@@ -4141,6 +4228,7 @@
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -4243,7 +4331,8 @@
|
||||
"node_modules/fft.js": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fft.js/-/fft.js-4.0.4.tgz",
|
||||
"integrity": "sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw=="
|
||||
"integrity": "sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/filelist": {
|
||||
"version": "1.0.6",
|
||||
@@ -4353,19 +4442,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
@@ -4407,6 +4483,7 @@
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -4430,6 +4507,7 @@
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -5011,7 +5089,8 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
@@ -5031,6 +5110,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jsesc": "bin/jsesc"
|
||||
},
|
||||
@@ -5065,6 +5145,7 @@
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
@@ -5399,6 +5480,7 @@
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
@@ -5713,7 +5795,8 @@
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
@@ -5726,6 +5809,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
@@ -5842,7 +5926,8 @@
|
||||
"version": "2.0.36",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
|
||||
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nopt": {
|
||||
"version": "8.1.0",
|
||||
@@ -6050,7 +6135,8 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
@@ -6069,6 +6155,7 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pitchy/-/pitchy-4.1.0.tgz",
|
||||
"integrity": "sha512-E8nQ8svBroGSMcc3qu2KvLHIuRYAIfrSkqDKWhjgj3WizseqWQXjQ+Q5t4g1CU73R5Euk+DAinyNFDK+KZw7zA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fft.js": "^4.0.4"
|
||||
}
|
||||
@@ -6107,6 +6194,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -6116,12 +6204,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/postject": {
|
||||
"version": "1.0.0-alpha.6",
|
||||
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
|
||||
@@ -6409,6 +6491,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -6517,6 +6600,7 @@
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
@@ -6687,6 +6771,7 @@
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7102,6 +7187,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escalade": "^3.2.0",
|
||||
"picocolors": "^1.1.1"
|
||||
@@ -7342,7 +7428,8 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
|
||||
@@ -1,39 +1,56 @@
|
||||
{
|
||||
"name": "whattheflat",
|
||||
"version": "0.1.0",
|
||||
"name": "jambuddy",
|
||||
"description": "A jam session companion app that provides a chromatic tuner, chord progressions, and more.",
|
||||
"version": "0.6.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "electron/main.js",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
|
||||
"electron:build": "vite build && electron-builder"
|
||||
"electron:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5173 && electron .\"",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"audiomotion-analyzer": "^4.5.4",
|
||||
"pitchy": "^4.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"audiomotion-analyzer": "^4.5.4"
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"vite": "^7.3.1",
|
||||
"electron": "^40.7.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^40.7.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vite": "^7.3.1",
|
||||
"wait-on": "^9.0.4"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.whattheflat.app",
|
||||
"productName": "WhatTheFlat",
|
||||
"appId": "com.jambuddy.app",
|
||||
"productName": "JamBuddy",
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
"node_modules/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"directories": {
|
||||
"buildResources": "dist",
|
||||
"output": "release"
|
||||
},
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"icon": "assets/whattheflat-logo.png",
|
||||
"forceCodeSigning": false
|
||||
},
|
||||
"mac": {
|
||||
"hardenedRuntime": true,
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
@@ -42,40 +59,13 @@
|
||||
"NSMicrophoneUsageDescription": "WhatTheFlat needs access to your microphone to detect pitch and provide the tuner."
|
||||
}
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64",
|
||||
"ia32"
|
||||
]
|
||||
},
|
||||
"zip"
|
||||
]
|
||||
"linux": {
|
||||
"target": "AppImage",
|
||||
"icon": "assets/whattheflat-logo.png"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowElevation": true,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"category": "Audio"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
"node_modules/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"directories": {
|
||||
"buildResources": "build",
|
||||
"output": "release"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,210 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import AudioCapture from './components/AudioCapture'
|
||||
import ProgressionBanner from './components/ProgressionBanner'
|
||||
import KeyDisplay from './components/KeyDisplay'
|
||||
import ChordDisplay from './components/ChordDisplay'
|
||||
import SafeNotes from './components/SafeNotes'
|
||||
import Fretboard from './components/Fretboard'
|
||||
import ProgressionSuggestions from './components/ProgressionSuggestions'
|
||||
import ChatAssistant from './components/ChatAssistant'
|
||||
import Fretboard from './components/Fretboard'
|
||||
import BassFretboard from './components/BassFretboard'
|
||||
import Tuner from './components/Tuner'
|
||||
import { NOTES, detectKey, matchChordFromChroma, detectRepeatingProgression } from './lib/theory'
|
||||
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 { useLoopEngine } from './services/loopEngine'
|
||||
import settingIcon from './assets/setting-icon.png'
|
||||
|
||||
// Key detection tuning
|
||||
const NOTE_HISTORY_SIZE = 80
|
||||
const KEY_VOTE_WINDOW = 12
|
||||
const KEY_VOTE_THRESHOLD = 9 // out of 12 — very stable
|
||||
const DEFAULTS = {
|
||||
// Key detection
|
||||
noteHistorySize: 2000, // ~60s of notes — stable across a song section
|
||||
keyVoteWindow: 30, // rolling window of key votes
|
||||
keyVoteThreshold: 20, // 67% consensus — locks in after a few bars
|
||||
chordNoteBoost: 3,
|
||||
// Chord detection
|
||||
chromaSmooth: 8, // 8 frames ≈ 130ms window, checks chord at ~7.5 Hz
|
||||
chordVoteThreshold: 2, // 2 consecutive matches ≈ 260ms — works at any BPM
|
||||
chordMinScore: 0.35, // lenient enough for live guitar signal
|
||||
// Audio input
|
||||
minClarity: 0.80,
|
||||
minVolume: 0.01,
|
||||
// Selected device (null = system default)
|
||||
audioDeviceId: null,
|
||||
}
|
||||
|
||||
// Chord detection tuning
|
||||
const CHROMA_SMOOTH = 12 // frames to average (~200ms at 60fps)
|
||||
const CHORD_VOTE_THRESHOLD = 3 // consecutive identical detections required
|
||||
function loadStored(key, fallback) {
|
||||
try { const v = localStorage.getItem(key); return v !== null ? JSON.parse(v) : fallback }
|
||||
catch { return fallback }
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// ── Listening state ──────────────────────────────────────────────────────
|
||||
// ── 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 [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
function updateConfig(key, val) {
|
||||
setConfig(prev => ({ ...prev, [key]: val }))
|
||||
}
|
||||
|
||||
// ── Listening state ──────────────────────────────────────────────────────────
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
|
||||
// ── App mode ─────────────────────────────────────────────────────────────
|
||||
const [appMode, setAppMode] = useState('beginner') // 'beginner' | 'advanced'
|
||||
// ── Instrument view + tuner ───────────────────────────────────────────────────
|
||||
const [instrument, setInstrument] = useState('piano') // 'piano' | 'guitar' | 'bass'
|
||||
const [showTuner, setShowTuner] = useState(false)
|
||||
const [showDebug, setShowDebug] = useState(false)
|
||||
const [showDrumView, setShowDrumView] = useState(false)
|
||||
const [monoColor, setMonoColor] = useState(() => loadStored('wtf_monoColor', false))
|
||||
|
||||
// ── Key: auto-detected + optional lock ───────────────────────────────────
|
||||
// ── Mic permission error ──────────────────────────────────────────────────────
|
||||
const [micError, setMicError] = useState(null)
|
||||
|
||||
// ── Debug data ────────────────────────────────────────────────────────────────
|
||||
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)
|
||||
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
|
||||
useEffect(() => { lockedKeyRef.current = lockedKey }, [lockedKey])
|
||||
const [lockRoot, setLockRoot] = useState('A')
|
||||
const [lockMode, setLockMode] = useState('minor')
|
||||
|
||||
// Effective key used by all components
|
||||
const effectiveKey = lockedKey ?? keyInfo
|
||||
// Locked key = only match diatonic chords regardless of mode — far fewer candidates
|
||||
const isStrictMode = lockedKey !== null
|
||||
|
||||
// ── Chord state ───────────────────────────────────────────────────────────
|
||||
// ── Chord state ───────────────────────────────────────────────────────────────
|
||||
const [chordHistory, setChordHistory] = useState([])
|
||||
const [detectedProgression, setDetectedProgression] = useState(null)
|
||||
const [selectedChord, setSelectedChord] = useState(null)
|
||||
|
||||
// ── Internal refs ─────────────────────────────────────────────────────────
|
||||
// ── Top key candidates (shown as quick-lock chips) ────────────────────────────
|
||||
const [topKeyCandidates, setTopKeyCandidates] = useState([])
|
||||
|
||||
// ── Internal refs ─────────────────────────────────────────────────────────────
|
||||
const noteHistoryRef = useRef([])
|
||||
const keyVotesRef = useRef([])
|
||||
const effectiveKeyRef = useRef(null) // mirror for use inside callbacks
|
||||
const effectiveKeyRef = useRef(null)
|
||||
const chromaRingRef = useRef(
|
||||
Array.from({ length: CHROMA_SMOOTH }, () => new Float32Array(12))
|
||||
Array.from({ length: DEFAULTS.chromaSmooth }, () => new Float32Array(12))
|
||||
)
|
||||
const chromaIdxRef = useRef(0)
|
||||
const chordVotesRef = useRef([])
|
||||
const progressionVoteRef = useRef(null)
|
||||
const progressionMissRef = useRef(0)
|
||||
const pendingKeyRef = useRef(null)
|
||||
|
||||
// Keep ref in sync
|
||||
// Keep refs in sync
|
||||
useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey])
|
||||
|
||||
// ── Detect progression whenever chord history changes ─────────────────────
|
||||
// Re-init chroma ring when chromaSmooth changes
|
||||
useEffect(() => {
|
||||
setDetectedProgression(detectRepeatingProgression(chordHistory))
|
||||
chromaRingRef.current = Array.from(
|
||||
{ length: config.chromaSmooth },
|
||||
() => new Float32Array(12)
|
||||
)
|
||||
chromaIdxRef.current = 0
|
||||
}, [config.chromaSmooth])
|
||||
|
||||
// ── 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
|
||||
const key = detected.join(',')
|
||||
if (progressionVoteRef.current === key) {
|
||||
setDetectedProgression(detected)
|
||||
} else {
|
||||
progressionVoteRef.current = key
|
||||
}
|
||||
}, [chordHistory])
|
||||
|
||||
// ── Key lock handlers ─────────────────────────────────────────────────────
|
||||
// ── New song — full reset ─────────────────────────────────────────────────────
|
||||
function newSong() {
|
||||
const cfg = configRef.current
|
||||
noteHistoryRef.current = []
|
||||
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
|
||||
setChordHistory([])
|
||||
setDetectedProgression(null)
|
||||
setTopKeyCandidates([])
|
||||
setBpm(null)
|
||||
setMicError(null)
|
||||
setDebugChroma(null)
|
||||
setDebugCandidates([])
|
||||
setDebugNoteAnalysis(null)
|
||||
setDebugWaveform(null)
|
||||
}
|
||||
|
||||
// ── Key lock handlers ─────────────────────────────────────────────────────────
|
||||
function applyLock() {
|
||||
const info = { root: lockRoot, mode: lockMode, confidence: 1 }
|
||||
setLockedKey(info)
|
||||
effectiveKeyRef.current = info
|
||||
chordVotesRef.current = []
|
||||
setChordHistory([])
|
||||
setDetectedProgression(null)
|
||||
}
|
||||
|
||||
function quickLock({ root, mode, confidence }) {
|
||||
const info = { root, mode, confidence }
|
||||
setLockedKey(info)
|
||||
effectiveKeyRef.current = info
|
||||
chordVotesRef.current = []
|
||||
}
|
||||
|
||||
function removeLock() {
|
||||
@@ -74,92 +212,210 @@ export default function App() {
|
||||
effectiveKeyRef.current = keyInfo
|
||||
}
|
||||
|
||||
// ── Note handler: drives key detection (pitch-based) ──────────────────────
|
||||
// ── 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
|
||||
const history = noteHistoryRef.current
|
||||
history.push(pitchClass)
|
||||
if (history.length > NOTE_HISTORY_SIZE) history.shift()
|
||||
if (history.length > cfg.noteHistorySize) history.shift()
|
||||
if (history.length < 10) return
|
||||
if (history.length % 5 !== 0) return
|
||||
|
||||
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 (result.confidence < 0.5) return
|
||||
|
||||
const votes = keyVotesRef.current
|
||||
votes.push(`${result.root}_${result.mode}`)
|
||||
if (votes.length > KEY_VOTE_WINDOW) votes.shift()
|
||||
if (votes.length > cfg.keyVoteWindow) votes.shift()
|
||||
|
||||
const counts = {}
|
||||
for (const v of votes) counts[v] = (counts[v] || 0) + 1
|
||||
const [winner, count] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]
|
||||
|
||||
if (count >= KEY_VOTE_THRESHOLD) {
|
||||
if (count >= cfg.keyVoteThreshold) {
|
||||
const [root, mode] = winner.split('_')
|
||||
const candidateKey = `${root}_${mode}`
|
||||
|
||||
setKeyInfo(prev => {
|
||||
if (prev?.root === root && prev?.mode === mode) {
|
||||
const currentKey = prev ? `${prev.root}_${prev.mode}` : null
|
||||
|
||||
if (currentKey === candidateKey) {
|
||||
pendingKeyRef.current = null
|
||||
return { root, mode, confidence: result.confidence }
|
||||
}
|
||||
// Key changed — reset chord votes but keep history visible
|
||||
if (!lockedKey) {
|
||||
chordVotesRef.current = []
|
||||
}
|
||||
|
||||
if (pendingKeyRef.current === candidateKey) {
|
||||
pendingKeyRef.current = null
|
||||
if (!lockedKeyRef.current) chordVotesRef.current = []
|
||||
return { root, mode, confidence: result.confidence }
|
||||
}
|
||||
|
||||
pendingKeyRef.current = candidateKey
|
||||
return prev
|
||||
})
|
||||
}
|
||||
}, [lockedKey])
|
||||
}, [])
|
||||
|
||||
// ── Chroma handler: drives chord detection ────────────────────────────────
|
||||
// ── Chroma handler: drives chord detection ────────────────────────────────────
|
||||
const handleChroma = useCallback((chroma, bassPC) => {
|
||||
const cfg = configRef.current
|
||||
const ring = chromaRingRef.current
|
||||
ring[chromaIdxRef.current % CHROMA_SMOOTH] = chroma
|
||||
ring[chromaIdxRef.current % cfg.chromaSmooth] = chroma
|
||||
chromaIdxRef.current++
|
||||
if (chromaIdxRef.current % CHROMA_SMOOTH !== 0) return
|
||||
if (chromaIdxRef.current % cfg.chromaSmooth !== 0) return
|
||||
|
||||
const key = effectiveKeyRef.current
|
||||
if (!key) return
|
||||
|
||||
// Average ring buffer
|
||||
const avg = new Float32Array(12)
|
||||
for (const frame of ring) for (let i = 0; i < 12; i++) avg[i] += frame[i]
|
||||
for (let i = 0; i < 12; i++) avg[i] /= CHROMA_SMOOTH
|
||||
for (let i = 0; i < 12; i++) avg[i] /= cfg.chromaSmooth
|
||||
|
||||
const chord = matchChordFromChroma(avg, key, bassPC, isStrictMode)
|
||||
if (showDebugRef.current) {
|
||||
setDebugChroma([...avg])
|
||||
setDebugCandidates(getChordCandidates(avg, key, bassPC, 5))
|
||||
}
|
||||
|
||||
// Stability gate — if chroma is still changing across frames, we're mid-transition.
|
||||
// Compute per-bin variance across the ring; bail if any bin is fluctuating heavily.
|
||||
let maxVar = 0
|
||||
for (let i = 0; i < 12; i++) {
|
||||
let v = 0
|
||||
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
|
||||
|
||||
const chord = matchChordFromChroma(avg, key, bassPC, false, cfg.chordMinScore)
|
||||
if (!chord) {
|
||||
// Ambiguous moment (transition, silence) — reset streak, history is untouched
|
||||
chordVotesRef.current = []
|
||||
return
|
||||
}
|
||||
|
||||
const votes = chordVotesRef.current
|
||||
votes.push(chord)
|
||||
if (votes.length > CHORD_VOTE_THRESHOLD) votes.shift()
|
||||
if (votes.length > cfg.chordVoteThreshold) votes.shift()
|
||||
|
||||
// All last N detections must agree — one wrong reading resets the streak
|
||||
if (votes.length >= CHORD_VOTE_THRESHOLD && votes.every(v => v === votes[0])) {
|
||||
if (votes.length >= cfg.chordVoteThreshold && votes.every(v => v === votes[0])) {
|
||||
const winner = votes[0]
|
||||
setChordHistory(prev => {
|
||||
if (prev[prev.length - 1] === winner) return prev
|
||||
return [...prev.slice(-30), winner]
|
||||
return [...prev.slice(-48), winner]
|
||||
})
|
||||
|
||||
// Inject chord tones into note history to anchor key detection
|
||||
const chordPCs = getChordTones(winner)
|
||||
.map(n => NOTES.indexOf(n))
|
||||
.filter(i => i >= 0)
|
||||
const history = noteHistoryRef.current
|
||||
for (let j = 0; j < cfg.chordNoteBoost; j++) {
|
||||
for (const pc of chordPCs) history.push(pc)
|
||||
}
|
||||
}, [isStrictMode])
|
||||
while (history.length > cfg.noteHistorySize) history.shift()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── Onset handler: drives BPM estimation via tempo histogram ────────────────
|
||||
// Pairwise inter-onset intervals are folded into 55-220 BPM and vote in a
|
||||
// histogram. Works with drums, guitar, piano, or mixed — whatever fires most
|
||||
// consistently wins. Only updates when there's a clear peak (≥20% of votes).
|
||||
const handleOnset = useCallback(() => {
|
||||
const ts = onsetTimestampsRef.current
|
||||
ts.push(performance.now())
|
||||
if (ts.length > 64) ts.shift()
|
||||
if (ts.length < 4) return
|
||||
|
||||
const recent = ts.slice(-24)
|
||||
const bins = new Float32Array(221) // index = BPM (55–220)
|
||||
|
||||
for (let i = 0; i < recent.length - 1; i++) {
|
||||
for (let j = i + 1; j < recent.length && j < i + 8; j++) {
|
||||
const ms = recent[j] - recent[i]
|
||||
if (ms < 140 || ms > 6000) continue
|
||||
|
||||
// Fold interval into 55-220 BPM range (handles subdivisions & half-time)
|
||||
let beatMs = ms
|
||||
while (beatMs > 1091) beatMs /= 2
|
||||
while (beatMs < 273) beatMs *= 2
|
||||
if (beatMs < 273 || beatMs > 1091) continue
|
||||
|
||||
const bpm = Math.round(60000 / beatMs)
|
||||
if (bpm >= 55 && bpm <= 220) bins[bpm] += 1 / (j - i) // weight closer pairs more
|
||||
}
|
||||
}
|
||||
|
||||
// Find peak with ±1 BPM smoothing
|
||||
let best = 0, bestBpm = 0
|
||||
for (let b = 56; b <= 219; b++) {
|
||||
const s = bins[b - 1] + bins[b] + bins[b + 1]
|
||||
if (s > best) { best = s; bestBpm = b }
|
||||
}
|
||||
|
||||
const total = bins.reduce((a, v) => a + v, 0)
|
||||
if (total < 1 || best / total < 0.2) return // no clear consensus yet
|
||||
|
||||
const prev = bpmSmoothRef.current
|
||||
bpmSmoothRef.current = prev === null ? bestBpm : 0.25 * bestBpm + 0.75 * prev
|
||||
setBpm(Math.round(bpmSmoothRef.current))
|
||||
}, [])
|
||||
|
||||
const currentChord = chordHistory[chordHistory.length - 1]
|
||||
|
||||
if (showSettings) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface text-white p-4 md:p-6">
|
||||
<Settings
|
||||
config={config}
|
||||
onChange={updateConfig}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onReset={() => { setConfig(DEFAULTS); setMonoColor(false) }}
|
||||
monoColor={monoColor}
|
||||
onMonoColorChange={setMonoColor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface text-white p-3">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<header className="mb-4 flex items-center justify-between">
|
||||
<header className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-accent">
|
||||
WhatTheFlat <span className="text-gray-600">♭?</span>
|
||||
<h1 className="text-xl font-bold text-accent">
|
||||
WhatTheFlat <span className="text-gray-600">♭?</span> <span className="text-amber-400">- JamBuddy</span>
|
||||
</h1>
|
||||
<p className="text-xs text-gray-600 mt-0.5">Real-time key detection for real humans</p>
|
||||
<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={() => setIsListening(l => !l)}
|
||||
className={`px-5 py-2.5 rounded-full font-semibold text-sm transition-all ${
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="p-2 rounded-full border border-border hover:border-gray-400 transition-all"
|
||||
title="Settings"
|
||||
>
|
||||
<img src={settingIcon} alt="Settings" className="w-5 h-5" style={{ filter: 'invert(1) opacity(0.75)' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={newSong}
|
||||
className="group px-5 py-2 rounded-full text-sm font-semibold border border-border text-gray-400 hover:text-gray-200 hover:border-gray-400 transition-all"
|
||||
>
|
||||
<span className="group-hover:hidden">New Song</span>
|
||||
<span className="hidden group-hover:inline">Clear History</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setMicError(null); setIsListening(l => !l) }}
|
||||
className={`px-5 py-2 rounded-full font-semibold text-sm transition-all ${
|
||||
isListening
|
||||
? 'bg-red-600 hover:bg-red-700 text-white'
|
||||
: 'bg-accent hover:bg-purple-600 text-white'
|
||||
@@ -167,100 +423,240 @@ export default function App() {
|
||||
>
|
||||
{isListening ? 'Stop' : 'Start Listening'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Controls bar ── */}
|
||||
<div className="mb-4 flex flex-wrap gap-3 items-center p-3 bg-panel border border-border rounded-xl">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex bg-surface border border-border rounded-full p-0.5 text-sm">
|
||||
{['beginner', 'advanced'].map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setAppMode(m)}
|
||||
className={`px-4 py-1 rounded-full capitalize transition-all ${
|
||||
appMode === m ? 'bg-accent text-white' : 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
<div className="mb-2 flex flex-wrap gap-2 items-center p-2 bg-panel border border-border rounded-xl">
|
||||
|
||||
{/* Instrument select */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={instrument}
|
||||
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"
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
<option value="piano">Piano</option>
|
||||
<option value="guitar">Guitar</option>
|
||||
<option value="bass">Bass</option>
|
||||
</select>
|
||||
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs">▾</span>
|
||||
</div>
|
||||
|
||||
{/* Key lock */}
|
||||
{lockedKey ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-3 py-1 bg-accent/20 border border-accent text-accent rounded-full text-sm font-semibold">
|
||||
🔒 {lockedKey.root} {lockedKey.mode}
|
||||
{/* BPM badge */}
|
||||
{bpm && (
|
||||
<span className="px-3 py-1 bg-accent/10 border border-accent/30 rounded-lg text-sm text-accent font-mono tabular-nums">
|
||||
♩ <span className="inline-block w-[3ch] text-right">{Math.round(bpm)}</span> <span className="text-accent/50 text-xs">BPM</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={removeLock}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 underline"
|
||||
)}
|
||||
|
||||
<div className="w-px h-5 bg-border shrink-0" />
|
||||
|
||||
{lockedKey ? (
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-accent/20 border border-accent rounded-full">
|
||||
<span className="text-accent text-sm font-semibold shrink-0">🔒 {lockedKey.root}</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={lockedKey.mode}
|
||||
onChange={e => {
|
||||
const info = { ...lockedKey, mode: e.target.value }
|
||||
setLockedKey(info)
|
||||
effectiveKeyRef.current = info
|
||||
chordVotesRef.current = []
|
||||
}}
|
||||
className="appearance-none bg-transparent text-accent text-sm font-semibold border-none outline-none cursor-pointer pr-4"
|
||||
>
|
||||
<option value="major">Major</option>
|
||||
<option value="minor">Minor</option>
|
||||
<option value="dorian">Dorian</option>
|
||||
<option value="mixolydian">Mixolydian</option>
|
||||
<option value="phrygian">Phrygian</option>
|
||||
<option value="lydian">Lydian</option>
|
||||
</select>
|
||||
<span className="pointer-events-none absolute right-0 top-1/2 -translate-y-1/2 text-accent/60 text-xs">▾</span>
|
||||
</div>
|
||||
<button onClick={removeLock} className="text-xs text-accent/50 hover:text-accent transition-colors">
|
||||
unlock
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{topKeyCandidates.map((k, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => quickLock(k)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-semibold border transition-all ${
|
||||
i === 0
|
||||
? 'border-accent text-accent hover:bg-accent/10'
|
||||
: 'border-border text-gray-400 hover:border-gray-500 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{k.root} {k.mode === 'major' ? 'maj' : 'min'} · {Math.round(k.confidence * 100)}%
|
||||
</button>
|
||||
))}
|
||||
{topKeyCandidates.length > 0 && <span className="text-gray-600 text-xs">or</span>}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={lockRoot}
|
||||
onChange={e => setLockRoot(e.target.value)}
|
||||
className="bg-surface border border-border rounded-lg px-2 py-1 text-sm text-gray-300"
|
||||
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"
|
||||
>
|
||||
{NOTES.map(n => <option key={n}>{n}</option>)}
|
||||
</select>
|
||||
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs">▾</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={lockMode}
|
||||
onChange={e => setLockMode(e.target.value)}
|
||||
className="bg-surface border border-border rounded-lg px-2 py-1 text-sm text-gray-300"
|
||||
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="major">Major</option>
|
||||
<option value="minor">Minor</option>
|
||||
<option value="dorian">Dorian</option>
|
||||
<option value="mixolydian">Mixolydian</option>
|
||||
<option value="phrygian">Phrygian</option>
|
||||
<option value="lydian">Lydian</option>
|
||||
</select>
|
||||
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs">▾</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={applyLock}
|
||||
className="px-3 py-1 bg-border hover:bg-accent/20 border border-border hover:border-accent text-sm rounded-lg transition-all"
|
||||
className="px-3 py-1 bg-accent/10 hover:bg-accent/20 border border-accent/40 hover:border-accent text-accent text-sm rounded-lg transition-all"
|
||||
>
|
||||
Lock Key
|
||||
Lock key
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto-detected key badge (advanced mode) */}
|
||||
{appMode === 'advanced' && keyInfo && !lockedKey && (
|
||||
<span className="text-xs text-gray-500">
|
||||
auto: {keyInfo.root} {keyInfo.mode} ({Math.round(keyInfo.confidence * 100)}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AudioCapture onNote={handleNote} onChroma={handleChroma} isListening={isListening} />
|
||||
<AudioCapture
|
||||
onNote={handleNote}
|
||||
onChroma={handleChroma}
|
||||
onOnset={handleOnset}
|
||||
onWaveform={handleWaveform}
|
||||
isListening={isListening}
|
||||
minClarity={config.minClarity}
|
||||
minVolume={config.minVolume}
|
||||
audioDeviceId={config.audioDeviceId}
|
||||
onPermissionError={() => {
|
||||
setMicError(true)
|
||||
setIsListening(false)
|
||||
}}
|
||||
onStreamReady={loopSetStream}
|
||||
/>
|
||||
|
||||
{/* ── Progression banner — full width ── */}
|
||||
{micError && (
|
||||
<div className="mb-2 px-4 py-3 rounded-xl border border-red-800 bg-red-900/20 text-sm text-red-400 flex items-center justify-between">
|
||||
<span>Microphone permission denied. Please allow microphone access in your browser or OS settings and try again.</span>
|
||||
<button onClick={() => setMicError(null)} className="ml-4 text-red-600 hover:text-red-400 text-lg leading-none">×</button>
|
||||
</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}
|
||||
/>
|
||||
|
||||
{/* ── Main grid ── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<KeyDisplay keyInfo={effectiveKey} locked={!!lockedKey} />
|
||||
<ChordDisplay history={chordHistory} />
|
||||
<SafeNotes keyInfo={effectiveKey} currentChord={currentChord} />
|
||||
<ProgressionSuggestions keyInfo={effectiveKey} />
|
||||
<div className="md:col-span-2">
|
||||
<Fretboard
|
||||
{/* ── Instrument + progressions row ── */}
|
||||
<div className="flex gap-3 mb-3 items-stretch">
|
||||
<div className="w-full lg:w-[70%] min-w-0">
|
||||
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} />}
|
||||
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
||||
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />}
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block w-[30%] min-w-0 relative">
|
||||
<div className="absolute inset-0">
|
||||
<ProgressionSuggestions keyInfo={effectiveKey} currentChord={currentChord} />
|
||||
</div>
|
||||
</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}
|
||||
pentatonicOnly={appMode === 'beginner'}
|
||||
instrument={instrument}
|
||||
monoColor={monoColor}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Tuner />
|
||||
)}
|
||||
</div>
|
||||
{/* <div className="md:col-span-2">
|
||||
<ChatAssistant keyInfo={effectiveKey} currentChord={currentChord} />
|
||||
</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>
|
||||
|
||||
{/* ── Tuner — collapsible ── */}
|
||||
<div className="bg-panel border border-border rounded-xl overflow-hidden">
|
||||
<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"
|
||||
>
|
||||
<span>TUNER</span>
|
||||
<span>{showTuner ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{showTuner && <div className="border-t border-border"><Tuner /></div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 202 KiB |
@@ -26,8 +26,6 @@ import { NOTES } from '../lib/theory'
|
||||
|
||||
const PITCH_FFT = 4096 // ~90ms window — good temporal resolution for pitch
|
||||
const CHORD_FFT = 16384 // ~370ms window — 2.7 Hz/bin, separates low semitones
|
||||
const MIN_CLARITY = 0.80
|
||||
const MIN_VOLUME = 0.01
|
||||
const NOISE_FLOOR = -65 // dB
|
||||
|
||||
// ─── Harmonic summation chroma ────────────────────────────────────────────────
|
||||
@@ -83,37 +81,101 @@ function detectBassPC(freqData, sampleRate, fftSize) {
|
||||
return ((bestMidi % 12) + 12) % 12
|
||||
}
|
||||
|
||||
export default function AudioCapture({ onNote, onChroma, isListening }) {
|
||||
export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError, audioDeviceId = null, onStreamReady = null }) {
|
||||
const audioCtxRef = useRef(null)
|
||||
const pitchAnalyser = useRef(null)
|
||||
const chordAnalyser = useRef(null)
|
||||
const timeBufRef = useRef(null)
|
||||
const freqBufRef = useRef(null)
|
||||
const detectorRef = useRef(null)
|
||||
const rafRef = useRef(null)
|
||||
const streamRef = useRef(null)
|
||||
const activeRef = useRef(false) // guards against stale tick callbacks
|
||||
|
||||
// All callbacks and thresholds read via refs — so start/stop never need to recreate
|
||||
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 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])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop())
|
||||
if (audioCtxRef.current) audioCtxRef.current.close()
|
||||
audioCtxRef.current = null
|
||||
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 }
|
||||
}, [])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
stop()
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
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)
|
||||
} 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
|
||||
|
||||
const ctx = new AudioContext()
|
||||
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
|
||||
pa.smoothingTimeConstant = 0.0 // no smoothing: pitchy needs clean waveform
|
||||
pitchAnalyser.current = pa
|
||||
pa.smoothingTimeConstant = 0.0
|
||||
source.connect(pa)
|
||||
timeBufRef.current = new Float32Array(pa.fftSize)
|
||||
detectorRef.current = PitchDetector.forFloat32Array(pa.fftSize)
|
||||
@@ -121,30 +183,79 @@ export default function AudioCapture({ onNote, onChroma, isListening }) {
|
||||
// Large analyser — chord detection needs fine frequency resolution
|
||||
const ca = ctx.createAnalyser()
|
||||
ca.fftSize = CHORD_FFT
|
||||
ca.smoothingTimeConstant = 0.65 // smooth over time for stable chord reading
|
||||
chordAnalyser.current = ca
|
||||
ca.smoothingTimeConstant = 0.5 // reduced from 0.65 — clears faster between chords
|
||||
source.connect(ca)
|
||||
freqBufRef.current = new Float32Array(ca.frequencyBinCount)
|
||||
|
||||
function tick() {
|
||||
if (!activeRef.current) return // stop() was called — bail immediately
|
||||
|
||||
const timeBuf = timeBufRef.current
|
||||
pa.getFloatTimeDomainData(timeBuf)
|
||||
|
||||
const rms = Math.sqrt(timeBuf.reduce((s, v) => s + v * v, 0) / timeBuf.length)
|
||||
if (rms >= MIN_VOLUME) {
|
||||
// Pitch via McLeod (autocorrelation) — unaffected by FFT bin size
|
||||
const [freq, clarity] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
|
||||
if (clarity >= MIN_CLARITY && freq > 60 && freq < 4200) {
|
||||
const midi = Math.round(12 * Math.log2(freq / 440) + 69)
|
||||
const pitchClass = ((midi % 12) + 12) % 12
|
||||
onNote({ noteName: NOTES[pitchClass], pitchClass, freq, midi, clarity })
|
||||
|
||||
// Onset detection — RMS spike significantly above smoothed baseline
|
||||
const sr = smoothRmsRef.current
|
||||
smoothRmsRef.current = 0.85 * sr + 0.15 * rms
|
||||
const nowMs = performance.now()
|
||||
if (rms > sr * 2.2 && rms > minVolumeRef.current * 1.5 && nowMs - lastOnsetRef.current > 120) {
|
||||
lastOnsetRef.current = nowMs
|
||||
onOnsetRef.current?.()
|
||||
}
|
||||
|
||||
// Chord chroma from the high-resolution FFT
|
||||
if (onChroma) {
|
||||
// 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)
|
||||
onChroma(
|
||||
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) {
|
||||
const midi = Math.round(12 * Math.log2(freq / 440) + 69)
|
||||
const pitchClass = ((midi % 12) + 12) % 12
|
||||
onNoteRef.current({ noteName: NOTES[pitchClass], pitchClass, freq, midi, clarity })
|
||||
}
|
||||
|
||||
if (onChromaRef.current) {
|
||||
const freqBuf = freqBufRef.current
|
||||
ca.getFloatFrequencyData(freqBuf)
|
||||
onChromaRef.current(
|
||||
computeChroma(freqBuf, ctx.sampleRate, ca.fftSize),
|
||||
detectBassPC(freqBuf, ctx.sampleRate, ca.fftSize)
|
||||
)
|
||||
@@ -154,7 +265,7 @@ export default function AudioCapture({ onNote, onChroma, isListening }) {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
tick()
|
||||
}, [onNote, onChroma, stop])
|
||||
}, [stop])
|
||||
|
||||
useEffect(() => {
|
||||
if (isListening) start().catch(console.error)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { getPentatonicScale, getFullScale, getChordTones, NOTES } from '../lib/theory'
|
||||
|
||||
// Standard bass tuning (top of diagram = highest string)
|
||||
const STRINGS = [
|
||||
{ label: 'G', root: 7, thickness: 1.5 },
|
||||
{ label: 'D', root: 2, thickness: 2 },
|
||||
{ label: 'A', root: 9, thickness: 2.5 },
|
||||
{ label: 'E', root: 4, thickness: 3 },
|
||||
]
|
||||
|
||||
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 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 fretX = f => NUT_X + (f - 0.5) * FRET_W
|
||||
const stringY = si => PAD_T + si * STRING_H
|
||||
|
||||
function noteColor(isChordTone, isPenta, isScale, mono = false) {
|
||||
if (isChordTone) return { fill: '#a855f7', text: '#fff' }
|
||||
if (isPenta) return mono ? { fill: '#c084fc', text: '#1e1b4b' } : { fill: '#f59e0b', text: '#000' }
|
||||
if (isScale) return mono ? { fill: '#e9d5ff', text: '#581c87' } : { fill: '#374151', text: '#d1d5db' }
|
||||
return null
|
||||
}
|
||||
|
||||
export default function BassFretboard({ keyInfo, currentChord, monoColor = false }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
|
||||
if (!root) return null
|
||||
|
||||
const pentaSet = new Set(getPentatonicScale(root, mode).map(n => NOTES.indexOf(n)))
|
||||
const scaleSet = new Set(getFullScale(root, mode).map(n => NOTES.indexOf(n)))
|
||||
const chordSet = currentChord
|
||||
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
|
||||
: new Set()
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
||||
Bass — {root} {mode}
|
||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
{/* Fretboard background */}
|
||||
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={3 * STRING_H + 12}
|
||||
fill="#1a120b" rx={2} />
|
||||
|
||||
{/* Position marker dots (centred between strings 1–2) */}
|
||||
{FRET_MARKERS.map(f => (
|
||||
<circle key={f}
|
||||
cx={fretX(f)} cy={PAD_T + 1.5 * STRING_H}
|
||||
r={5} fill="#3a2a1a" />
|
||||
))}
|
||||
{/* Double dot at 12 */}
|
||||
<circle cx={fretX(DOUBLE_MARKER)} cy={PAD_T + 0.5 * STRING_H} r={5} fill="#3a2a1a" />
|
||||
<circle cx={fretX(DOUBLE_MARKER)} cy={PAD_T + 2.5 * STRING_H} r={5} fill="#3a2a1a" />
|
||||
|
||||
{/* Fret lines */}
|
||||
{Array.from({ length: NUM_FRETS - 1 }, (_, i) => i + 1).map(f => (
|
||||
<line key={f}
|
||||
x1={NUT_X + f * FRET_W} y1={PAD_T - 6}
|
||||
x2={NUT_X + f * FRET_W} y2={PAD_T + 3 * STRING_H + 6}
|
||||
stroke={f === DOUBLE_MARKER ? '#888' : '#4a3a2a'}
|
||||
strokeWidth={f === DOUBLE_MARKER ? 2 : 1} />
|
||||
))}
|
||||
|
||||
{/* Nut */}
|
||||
<line x1={NUT_X} y1={PAD_T - 6} x2={NUT_X} y2={PAD_T + 3 * STRING_H + 6}
|
||||
stroke="#c0b090" strokeWidth={4} />
|
||||
|
||||
{/* Strings — thicker as pitch drops */}
|
||||
{STRINGS.map((s, si) => (
|
||||
<line key={si}
|
||||
x1={OPEN_X - DOT_R - 2} y1={stringY(si)}
|
||||
x2={BOARD_W - 8} y2={stringY(si)}
|
||||
stroke="#9ca3af"
|
||||
strokeWidth={s.thickness} />
|
||||
))}
|
||||
|
||||
{/* Fret numbers */}
|
||||
{[3, 5, 7, 9, 12].map(f => (
|
||||
<text key={f}
|
||||
x={fretX(f)} y={PAD_T - 10}
|
||||
textAnchor="middle" fontSize={10} fill="#6b7280"
|
||||
>{f}</text>
|
||||
))}
|
||||
|
||||
{/* String labels */}
|
||||
{STRINGS.map((s, si) => (
|
||||
<text key={si}
|
||||
x={6} y={stringY(si) + 4}
|
||||
textAnchor="middle" fontSize={10} fill="#6b7280"
|
||||
>{s.label}</text>
|
||||
))}
|
||||
|
||||
{/* Note dots */}
|
||||
{STRINGS.flatMap((str, si) =>
|
||||
Array.from({ length: NUM_FRETS }, (_, fi) => {
|
||||
const pc = (str.root + fi) % 12
|
||||
const color = noteColor(chordSet.has(pc), pentaSet.has(pc), scaleSet.has(pc), monoColor)
|
||||
if (!color) return null
|
||||
|
||||
const cx = fi === 0 ? OPEN_X : fretX(fi)
|
||||
const cy = stringY(si)
|
||||
|
||||
return (
|
||||
<g key={`${si}-${fi}`}>
|
||||
<circle cx={cx} cy={cy} r={DOT_R} fill={color.fill} />
|
||||
<text
|
||||
x={cx} y={cy + 4}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
fontWeight="600"
|
||||
fill={color.text}
|
||||
>
|
||||
{NOTES[pc]}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
export default function ChatAssistant({ keyInfo, currentChord }) {
|
||||
const [messages, setMessages] = useState([])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const bottomRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
async function send(e) {
|
||||
e.preventDefault()
|
||||
if (!input.trim() || loading) return
|
||||
|
||||
const userMsg = { role: 'user', content: input.trim() }
|
||||
const next = [...messages, userMsg]
|
||||
setMessages(next)
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const context = {}
|
||||
if (keyInfo?.root) context.key = `${keyInfo.root} ${keyInfo.mode}`
|
||||
if (currentChord) context.chord = currentChord
|
||||
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: next, context }),
|
||||
})
|
||||
const data = await res.json()
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: data.reply }])
|
||||
} catch (err) {
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: 'Could not reach the AI assistant. Is the backend running?',
|
||||
}])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6 flex flex-col h-80">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-3">Theory Assistant</p>
|
||||
<div className="flex-1 overflow-y-auto space-y-3 pr-1">
|
||||
{messages.length === 0 && (
|
||||
<p className="text-gray-600 text-sm">Ask anything: "What lick works over this chord?" or "Why does the IV sound so resolved?"</p>
|
||||
)}
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={`text-sm ${m.role === 'user' ? 'text-right' : 'text-left'}`}>
|
||||
<span className={`inline-block px-3 py-2 rounded-xl max-w-[85%] ${
|
||||
m.role === 'user'
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-border text-gray-200'
|
||||
}`}>
|
||||
{m.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="text-left">
|
||||
<span className="inline-block px-3 py-2 rounded-xl bg-border text-gray-400 text-sm animate-pulse">
|
||||
Thinking…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<form onSubmit={send} className="mt-3 flex gap-2">
|
||||
<input
|
||||
className="flex-1 bg-surface border border-border rounded-lg px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
placeholder="Ask about music theory…"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !input.trim()}
|
||||
className="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium disabled:opacity-40"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// SVG chord diagram — 6 strings × 5 visible frets
|
||||
// Props:
|
||||
// frets[] — [s6…s1]: fret number or 'x' (muted)
|
||||
// fingers[] — [s6…s1]: finger 1-4, 0 = open/barre indicator
|
||||
// barre — { fret, fromStr, toStr } or null
|
||||
// baseFret — which fret number is at the top of the diagram (1 = standard)
|
||||
// label — caption below the box
|
||||
|
||||
const STRINGS = 6
|
||||
const ROWS = 5 // visible frets
|
||||
const SX = 32 // left margin (open/mute indicators)
|
||||
const SY = 28 // top margin (nut / baseFret label)
|
||||
const GX = 26 // gap between strings
|
||||
const GY = 22 // gap between frets
|
||||
const DOT_R = 9 // dot radius
|
||||
const W = SX + GX * (STRINGS - 1) + 24 // total width
|
||||
const H = SY + GY * ROWS + 20 // total height
|
||||
|
||||
function strX(s) { return SX + (STRINGS - 1 - s) * GX } // s=0 is s6 (low E, leftmost)
|
||||
function fretY(f) { return SY + f * GY } // f=0 is above first fret, f=1…5 are fret centers
|
||||
|
||||
export default function ChordBox({ frets, fingers, barre, baseFret = 1, label }) {
|
||||
const isOpen = baseFret === 1
|
||||
|
||||
// Map fret numbers to diagram row (0-indexed from top)
|
||||
function toRow(absF) {
|
||||
return absF - baseFret + 1 // fret at baseFret → row 1 (center of first fret)
|
||||
}
|
||||
|
||||
// Barre bar: draw a rounded rect across strings
|
||||
function renderBarre() {
|
||||
if (!barre) return null
|
||||
const row = toRow(barre.fret)
|
||||
if (row < 1 || row > ROWS) return null
|
||||
const x1 = strX(STRINGS - barre.toStr) // toStr is highest string number = leftmost
|
||||
const x2 = strX(STRINGS - barre.fromStr) // fromStr is lowest string number = rightmost
|
||||
const cy = fretY(row) - GY / 2
|
||||
return (
|
||||
<rect
|
||||
key="barre"
|
||||
x={x1 - DOT_R}
|
||||
y={cy - DOT_R}
|
||||
width={x2 - x1 + DOT_R * 2}
|
||||
height={DOT_R * 2}
|
||||
rx={DOT_R}
|
||||
fill="#a855f7"
|
||||
opacity={0.9}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="overflow-visible">
|
||||
|
||||
{/* ── Nut or baseFret indicator ── */}
|
||||
{isOpen ? (
|
||||
<rect x={SX - 2} y={SY - 4} width={GX * (STRINGS - 1) + 4} height={4} rx={2} fill="#e5e7eb" />
|
||||
) : (
|
||||
<text x={SX - 6} y={SY + GY * 0.5} textAnchor="end" dominantBaseline="middle"
|
||||
fill="#9ca3af" fontSize={10} fontFamily="monospace">
|
||||
{baseFret}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* ── Fret lines ── */}
|
||||
{Array.from({ length: ROWS + 1 }, (_, i) => (
|
||||
<line key={`fl${i}`}
|
||||
x1={SX} y1={fretY(i) - GY / 2}
|
||||
x2={SX + GX * (STRINGS - 1)} y2={fretY(i) - GY / 2}
|
||||
stroke="#374151" strokeWidth={i === 0 && isOpen ? 3 : 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* ── String lines ── */}
|
||||
{Array.from({ length: STRINGS }, (_, s) => (
|
||||
<line key={`sl${s}`}
|
||||
x1={strX(s)} y1={SY - GY / 2}
|
||||
x2={strX(s)} y2={fretY(ROWS) - GY / 2}
|
||||
stroke="#4b5563" strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* ── Barre ── */}
|
||||
{renderBarre()}
|
||||
|
||||
{/* ── Dots + open/mute indicators ── */}
|
||||
{frets.map((f, s) => {
|
||||
const cx = strX(STRINGS - 1 - s)
|
||||
if (f === 'x') {
|
||||
return (
|
||||
<text key={`m${s}`} x={cx} y={SY - GY / 2 - 7}
|
||||
textAnchor="middle" fill="#6b7280" fontSize={12} fontWeight="bold">
|
||||
×
|
||||
</text>
|
||||
)
|
||||
}
|
||||
if (f === 0) {
|
||||
return (
|
||||
<circle key={`o${s}`} cx={cx} cy={SY - GY / 2 - 7}
|
||||
r={5} fill="none" stroke="#6b7280" strokeWidth={1.5} />
|
||||
)
|
||||
}
|
||||
const row = toRow(f)
|
||||
if (row < 1 || row > ROWS) return null
|
||||
const cy = fretY(row) - GY / 2
|
||||
const finger = fingers?.[s] ?? 0
|
||||
return (
|
||||
<g key={`d${s}`}>
|
||||
<circle cx={cx} cy={cy} r={DOT_R} fill="#a855f7" />
|
||||
{finger > 0 && (
|
||||
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="white" fontSize={9} fontWeight="bold">
|
||||
{finger}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── String name labels ── */}
|
||||
{['e','B','G','D','A','E'].map((n, i) => (
|
||||
<text key={`sn${i}`}
|
||||
x={strX(i)} y={H - 4}
|
||||
textAnchor="middle" fill="#4b5563" fontSize={8}>
|
||||
{n}
|
||||
</text>
|
||||
))}
|
||||
|
||||
</svg>
|
||||
|
||||
{label && (
|
||||
<p className="text-[11px] text-gray-400 text-center leading-tight max-w-[120px]">{label}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
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,25 +0,0 @@
|
||||
export default function ChordDisplay({ history }) {
|
||||
const current = history[history.length - 1]
|
||||
const past = history.slice(-8, -1)
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-3">Chord</p>
|
||||
<p className="text-5xl font-bold text-amber-400">
|
||||
{current ?? '—'}
|
||||
</p>
|
||||
{past.length > 0 && (
|
||||
<div className="mt-4 flex gap-2 flex-wrap">
|
||||
{past.map((chord, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-sm px-2 py-1 bg-border rounded text-gray-400"
|
||||
>
|
||||
{chord}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useState } from 'react'
|
||||
import ChordBox from './ChordBox'
|
||||
import RiffDiagram from './RiffDiagram'
|
||||
import { getGuitarVoicings, parseChord } from '../lib/voicings'
|
||||
import { CHORD_TYPES, NOTES, toRomanNumeral } from '../lib/theory'
|
||||
import { findSimilarProgressions, progressionInKey } from '../lib/education'
|
||||
|
||||
// ─── Scale ideas per mode ─────────────────────────────────────────────────────
|
||||
const SCALE_IDEAS = {
|
||||
major: [
|
||||
{ name: 'Major Pentatonic', intervals: '1–2–3–5–6', scaleIntervals: [0,2,4,7,9], desc: 'Safe and bright. Everything you play will land. Start on the root, end on the root.' },
|
||||
{ name: 'Mixolydian', intervals: '1–2–3–4–5–6–♭7', scaleIntervals: [0,2,4,5,7,9,10], desc: 'Major with a bluesy ♭7. The defining sound of classic rock — Sweet Home Alabama lives here.' },
|
||||
{ name: 'Lydian', intervals: '1–2–3–♯4–5–6–7', scaleIntervals: [0,2,4,6,7,9,11], desc: 'Dreamy and floating. The ♯4 is the magic note — use it on long sustained notes for instant wonder.' },
|
||||
],
|
||||
minor: [
|
||||
{ name: 'Minor Pentatonic', intervals: '1–♭3–4–5–♭7', scaleIntervals: [0,3,5,7,10], desc: 'The blues box. Bends on ♭3 and slides to 5 are gold. Start here every time.' },
|
||||
{ name: 'Natural Minor', intervals: '1–2–♭3–4–5–♭6–♭7', scaleIntervals: [0,2,3,5,7,8,10], desc: 'Full Aeolian scale. Melodic and dark. The ♭6 gives it a cinematic quality.' },
|
||||
{ name: 'Dorian', intervals: '1–2–♭3–4–5–6–♭7', scaleIntervals: [0,2,3,5,7,9,10], desc: "Minor with a raised 6th — smooth and soulful. Santana's go-to. That major 6th is everything." },
|
||||
],
|
||||
dorian: [
|
||||
{ name: 'Dorian Mode', intervals: '1–2–♭3–4–5–6–♭7', scaleIntervals: [0,2,3,5,7,9,10], desc: "The raised 6th over minor is the colour. Mix freely with minor pentatonic and touch that 6th note." },
|
||||
{ name: 'Minor Pentatonic', intervals: '1–♭3–4–5–♭7', scaleIntervals: [0,3,5,7,10], desc: 'Safe backbone in Dorian. You can ignore the 6th — or highlight it for that Dorian sparkle.' },
|
||||
{ name: 'Blues Scale', intervals: '1–♭3–4–♭5–5–♭7', scaleIntervals: [0,3,5,6,7,10], desc: 'Add the ♭5 passing tone through the 5 — that slide is the essence of blues expression.' },
|
||||
],
|
||||
mixolydian: [
|
||||
{ name: 'Mixolydian Mode', intervals: '1–2–3–4–5–6–♭7', scaleIntervals: [0,2,4,5,7,9,10], desc: 'The ♭7 is your signature note. Hit it and slide down — instant swagger.' },
|
||||
{ name: 'Major Pentatonic', intervals: '1–2–3–5–6', scaleIntervals: [0,2,4,7,9], desc: 'Works beautifully over the I chord. Clean and reliable when you need to land safely.' },
|
||||
{ name: 'Blues Scale', intervals: '1–♭3–3–4–5–♭7', scaleIntervals: [0,3,4,5,7,10], desc: 'The hybrid blues scale. Bend the ♭3 up to the 3 — that tension and release is everything.' },
|
||||
],
|
||||
phrygian: [
|
||||
{ name: 'Phrygian Mode', intervals: '1–♭2–♭3–4–5–♭6–♭7', scaleIntervals: [0,1,3,5,7,8,10], desc: 'That ♭2 is the spine-chilling note. Lean on it. Spanish fire and metal darkness in one scale.' },
|
||||
{ name: 'Phrygian Dominant',intervals: '1–♭2–3–4–5–♭6–♭7', scaleIntervals: [0,1,4,5,7,8,10], desc: 'Raise the ♭3 to a major 3rd. Flamenco and Middle-Eastern intensity. Dramatic every time.' },
|
||||
{ name: 'Minor Pentatonic', intervals: '1–♭3–4–5–♭7', scaleIntervals: [0,3,5,7,10], desc: 'Avoid the ♭2 and play safe pentatonic runs — then hit the ♭2 as a surprise.' },
|
||||
],
|
||||
lydian: [
|
||||
{ name: 'Lydian Mode', intervals: '1–2–3–♯4–5–6–7', scaleIntervals: [0,2,4,6,7,9,11], desc: 'Float on the ♯4. John Williams writes entire film scores in Lydian. Sustain everything.' },
|
||||
{ name: 'Major Pentatonic', intervals: '1–2–3–5–6', scaleIntervals: [0,2,4,7,9], desc: 'The reliable base. Use Lydian mode sparingly on top for colour.' },
|
||||
{ name: 'Lydian Dominant', intervals: '1–2–3–♯4–5–6–♭7', scaleIntervals: [0,2,4,6,7,9,10], desc: 'Lydian with a ♭7 — the jazz/fusion ♯4 chord sound. Herbie Hancock territory.' },
|
||||
],
|
||||
}
|
||||
|
||||
// Fallback
|
||||
const SCALE_FALLBACK = SCALE_IDEAS.major
|
||||
|
||||
// ─── Style variations for a progression ──────────────────────────────────────
|
||||
const STYLE_VARIATIONS = [
|
||||
{
|
||||
key: 'open',
|
||||
label: 'Open & Spacious',
|
||||
desc: 'Sus2 and add9 voicings — airy, gentle. Great for quiet intros and ambient sections.',
|
||||
typeMap: { maj: 'sus2', min: 'sus2', dom7: 'sus4', maj7: 'add9', min7: 'sus2', add9: 'sus2', sus4: 'sus4', sus2: 'sus2', dim: 'dim', aug: 'aug', half_dim: 'half_dim', maj6: 'sus2', min6: 'sus2' },
|
||||
color: 'text-blue-400',
|
||||
border: 'border-blue-900/40',
|
||||
},
|
||||
{
|
||||
key: 'jazz',
|
||||
label: 'Jazz Upgrade',
|
||||
desc: 'Triads → 7ths — instant sophistication. Works at any tempo, in any band context.',
|
||||
typeMap: { maj: 'maj7', min: 'min7', dom7: 'dom7', add9: 'maj7', sus2: 'sus2', sus4: 'sus4', dim: 'dim7', aug: 'aug', half_dim: 'half_dim', maj6: 'maj6', min6: 'min6' },
|
||||
color: 'text-amber-400',
|
||||
border: 'border-amber-900/40',
|
||||
},
|
||||
{
|
||||
key: 'blues',
|
||||
label: 'Blues Stomp',
|
||||
desc: 'Everything → dom7. Gritty, raw, powerful. All three chords want to slide and bend.',
|
||||
typeMap: { maj: 'dom7', min: 'dom7', maj7: 'dom7', min7: 'dom7', add9: 'dom7', sus2: 'dom7', sus4: 'dom7', dim: 'dim7', aug: 'aug', half_dim: 'dom7', maj6: 'dom7', min6: 'dom7' },
|
||||
color: 'text-red-400',
|
||||
border: 'border-red-900/40',
|
||||
},
|
||||
{
|
||||
key: 'modern',
|
||||
label: 'Neo-Soul / Modern',
|
||||
desc: "Add9 on majors, m7 on minors. D'Angelo, Thundercat, Childish Gambino territory.",
|
||||
typeMap: { maj: 'add9', min: 'min7', dom7: 'dom7', maj7: 'add9', min7: 'min7', add9: 'add9', sus2: 'sus2', sus4: 'sus4', dim: 'dim', aug: 'aug', half_dim: 'half_dim', maj6: 'add9', min6: 'min7' },
|
||||
color: 'text-purple-400',
|
||||
border: 'border-purple-900/40',
|
||||
},
|
||||
]
|
||||
|
||||
// Transform a chord via a type map
|
||||
function transformChord(chordStr, typeMap) {
|
||||
const p = parseChord(chordStr)
|
||||
if (!p) return chordStr
|
||||
const newType = typeMap[p.type] ?? p.type
|
||||
return NOTES[p.rootPc] + (CHORD_TYPES[newType]?.suffix ?? '')
|
||||
}
|
||||
|
||||
// Get best voicings for a chord — prefer open shapes, then low-fret barre
|
||||
function getBestVoicings(chordStr, max = 4) {
|
||||
const all = getGuitarVoicings(chordStr)
|
||||
// Sort: open shapes first (label contains "Open"), then barre
|
||||
const open = all.filter(v => v.label.includes('Open'))
|
||||
const barre = all.filter(v => !v.label.includes('Open'))
|
||||
return [...open, ...barre].slice(0, max)
|
||||
}
|
||||
|
||||
// ─── Per-chord voicing strip ──────────────────────────────────────────────────
|
||||
function ChordStrip({ chordStr, keyInfo, onChordClick }) {
|
||||
const voicings = getBestVoicings(chordStr, 4)
|
||||
const rn = keyInfo?.root ? toRomanNumeral(chordStr, keyInfo.root, keyInfo.mode) : ''
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => onChordClick?.(chordStr)}
|
||||
className="px-3 py-1 bg-accent/10 border border-accent/40 rounded-lg font-black text-lg text-accent hover:bg-accent/20 transition-colors">
|
||||
{chordStr}
|
||||
</button>
|
||||
{rn && <span className="text-amber-400 text-sm font-semibold">{rn}</span>}
|
||||
<span className="text-[11px] text-gray-600 ml-auto">click for all voicings</span>
|
||||
</div>
|
||||
{voicings.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{voicings.map((v, i) => (
|
||||
<div key={i} className="flex flex-col items-center">
|
||||
<ChordBox frets={v.frets} fingers={v.fingers} barre={v.barre} baseFret={v.baseFret} />
|
||||
<p className="text-[10px] text-gray-600 text-center mt-1 max-w-[100px]">{v.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-600 text-xs">No voicings available.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Style variation section ──────────────────────────────────────────────────
|
||||
function StyleSection({ progression, onChordClick }) {
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{STYLE_VARIATIONS.map(style => {
|
||||
const isOpen = expanded === style.key
|
||||
const transformed = progression.map(c => transformChord(c, style.typeMap))
|
||||
|
||||
return (
|
||||
<div key={style.key} className={`border rounded-xl overflow-hidden transition-colors ${style.border} hover:border-opacity-70`}>
|
||||
<button onClick={() => setExpanded(isOpen ? null : style.key)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-bold text-sm ${style.color}`}>{style.label}</span>
|
||||
<div className="flex gap-1">
|
||||
{transformed.map((c, i) => (
|
||||
<span key={i} className="text-xs font-bold text-gray-300">{c}{i < transformed.length - 1 ? ' →' : ''}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-gray-500">{style.desc}</span>
|
||||
</div>
|
||||
<span className="text-gray-600 shrink-0 ml-3">{isOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t border-border/50 px-4 py-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{transformed.map((c, i) => {
|
||||
const voicings = getBestVoicings(c, 2)
|
||||
return (
|
||||
<div key={i} className="flex flex-col items-center gap-2">
|
||||
<button onClick={() => onChordClick?.(c)}
|
||||
className="px-2 py-0.5 bg-panel border border-border hover:border-accent/50 rounded-lg font-bold text-sm text-gray-200 hover:text-accent transition-all">
|
||||
{c}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
{voicings.map((v, vi) => (
|
||||
<div key={vi} className="flex flex-col items-center">
|
||||
<ChordBox frets={v.frets} fingers={v.fingers} barre={v.barre} baseFret={v.baseFret} />
|
||||
<p className="text-[9px] text-gray-700 text-center mt-0.5 max-w-[90px]">{v.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Similar famous progressions ─────────────────────────────────────────────
|
||||
function SimilarSection({ progression, keyInfo, onChordClick }) {
|
||||
const similar = findSimilarProgressions(progression, keyInfo)
|
||||
if (!similar.length) return (
|
||||
<p className="text-gray-600 text-sm text-center py-3">Play more and lock a key — similar progressions will appear here.</p>
|
||||
)
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{similar.slice(0, 3).map(prog => {
|
||||
const chordsHere = keyInfo?.root ? progressionInKey(prog, keyInfo.root) : []
|
||||
return (
|
||||
<div key={prog.id} className="p-3 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-center flex-wrap gap-2 mb-2">
|
||||
<span className="font-bold text-white text-sm">{prog.name}</span>
|
||||
<span className="text-[10px] font-mono text-gray-600">{prog.pattern}</span>
|
||||
<span className="text-xs text-gray-600 ml-auto">{Math.round(prog.score * 100)}% match</span>
|
||||
</div>
|
||||
{chordsHere.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 items-center mb-2">
|
||||
{chordsHere.map((c, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<button onClick={() => onChordClick?.(c)}
|
||||
className={`px-2 py-0.5 rounded-lg font-bold text-xs border transition-all ${
|
||||
i === 0 ? 'bg-accent border-accent text-white' : 'bg-panel border-border text-gray-300 hover:border-accent/50 hover:text-accent'
|
||||
}`}>
|
||||
{c}
|
||||
</button>
|
||||
{i < chordsHere.length - 1 && <span className="text-gray-700 text-xs">→</span>}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-[10px] text-gray-600 ml-1">in {keyInfo?.root} {keyInfo?.mode}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-gray-600">{prog.songs.slice(0, 3).join(' · ')}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main panel ───────────────────────────────────────────────────────────────
|
||||
export default function CurrentJamPanel({ keyInfo, chordHistory, detectedProgression, onChordClick }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState('voicings') // voicings | scales | styles | similar
|
||||
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
|
||||
// Working progression: detected loop or last 4 unique chords
|
||||
const workingProgression = detectedProgression?.length
|
||||
? detectedProgression
|
||||
: [...new Set([...chordHistory].reverse())].reverse().slice(-4)
|
||||
|
||||
const scaleIdeas = SCALE_IDEAS[mode] ?? SCALE_FALLBACK
|
||||
const rootPc = root ? NOTES.indexOf(root) : null
|
||||
const hasSession = workingProgression.length > 0
|
||||
|
||||
return (
|
||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||
<button onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>CURRENT JAM</span>
|
||||
{root && (
|
||||
<span className="text-[10px] px-2 py-0.5 bg-accent/10 border border-accent/30 rounded text-accent">
|
||||
{root} {mode} {detectedProgression?.length ? `· ${workingProgression.join(' → ')}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span>{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-border p-4 flex flex-col gap-4">
|
||||
|
||||
{!hasSession ? (
|
||||
<p className="text-gray-600 text-sm text-center py-6">Start listening and play some chords — your jam will appear here.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* ── Progression summary ── */}
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2 bg-surface border border-border rounded-xl">
|
||||
{root ? (
|
||||
<span className="text-accent font-bold text-sm">{root} {mode}</span>
|
||||
) : (
|
||||
<span className="text-gray-600 text-sm">Key detecting…</span>
|
||||
)}
|
||||
{workingProgression.length > 0 && (
|
||||
<>
|
||||
<span className="text-gray-700">·</span>
|
||||
{workingProgression.map((c, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className="text-gray-300 font-bold text-sm">{c}</span>
|
||||
{root && <span className="text-amber-400/60 text-[10px]">{toRomanNumeral(c, root, mode)}</span>}
|
||||
{i < workingProgression.length - 1 && <span className="text-gray-700">→</span>}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── View tabs ── */}
|
||||
<div className="flex gap-1 bg-surface border border-border rounded-xl p-1 overflow-x-auto">
|
||||
{[
|
||||
{ key: 'voicings', label: '🎸 Open Voicings' },
|
||||
{ key: 'scales', label: '🎵 Scales to Solo' },
|
||||
{ key: 'styles', label: '🎨 Style Options' },
|
||||
{ key: 'similar', label: '🔗 Similar Progressions' },
|
||||
].map(t => (
|
||||
<button key={t.key} onClick={() => setView(t.key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all whitespace-nowrap ${
|
||||
view === t.key ? 'bg-accent text-white' : 'text-gray-400 hover:text-white'
|
||||
}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Voicings: per chord open shapes ── */}
|
||||
{view === 'voicings' && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-gray-500">
|
||||
Best open and barre voicings for each chord in your jam. Click a chord name to see all its voicings.
|
||||
</p>
|
||||
{workingProgression.map(chord => (
|
||||
<ChordStrip key={chord} chordStr={chord} keyInfo={keyInfo} onChordClick={onChordClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Scales ── */}
|
||||
{view === 'scales' && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-gray-500">
|
||||
Scales and modes that fit {root ? `${root} ${mode}` : 'your current key'}.
|
||||
Start with the pentatonic — add the extra notes once you feel comfortable.
|
||||
</p>
|
||||
{scaleIdeas.map(idea => (
|
||||
<div key={idea.name} className="p-3 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="font-bold text-white text-sm">{idea.name}</span>
|
||||
<span className="font-mono text-xs text-accent">{idea.intervals}</span>
|
||||
</div>
|
||||
{rootPc !== null && idea.scaleIntervals && (
|
||||
<div className="mb-2 overflow-x-auto">
|
||||
<RiffDiagram rootPc={rootPc} scaleIntervals={idea.scaleIntervals} />
|
||||
<p className="text-[10px] text-gray-600 mt-1">
|
||||
Purple = root · Grey = scale tone · Fret numbers above
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 leading-snug">{idea.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[11px] text-gray-700 text-center">
|
||||
Pro tip: always resolve to a chord tone at the end of a phrase — ♭7 leading to root, or 3rd landing on the 1.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Style options ── */}
|
||||
{view === 'styles' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
Your progression re-voiced four ways. Expand any style to see the chord boxes.
|
||||
</p>
|
||||
<StyleSection progression={workingProgression} onChordClick={onChordClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Similar progressions ── */}
|
||||
{view === 'similar' && (
|
||||
<SimilarSection progression={workingProgression} keyInfo={keyInfo} onChordClick={onChordClick} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
import { useRef } from 'react'
|
||||
import { getScale, getChordTones, NOTES } from '../lib/theory'
|
||||
|
||||
// ─── SVG Piano — 2 octaves (C3–B4) ───────────────────────────────────────────
|
||||
const KEY_W = 30
|
||||
const KEY_H = 80
|
||||
const BLACK_W = 18
|
||||
const BLACK_H = 50
|
||||
const PIANO_W = 14 * KEY_W
|
||||
|
||||
const WHITE_OCT = [0, 2, 4, 5, 7, 9, 11] // pitch classes per octave
|
||||
const BLACK_OCT = [
|
||||
{ pc: 1, wi: 0 }, { pc: 3, wi: 1 }, { pc: 6, wi: 3 },
|
||||
{ pc: 8, wi: 4 }, { pc: 10, wi: 5 },
|
||||
]
|
||||
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 }) {
|
||||
const max = Math.max(...values, 0.01)
|
||||
const wKeys = []
|
||||
const bKeys = []
|
||||
for (let oct = 0; oct < 2; oct++) {
|
||||
WHITE_OCT.forEach((pc, wi) => wKeys.push({ pc, wi: oct * 7 + wi }))
|
||||
BLACK_OCT.forEach(({ pc, wi }) => bKeys.push({ pc, wi: oct * 7 + wi }))
|
||||
}
|
||||
const svgH = keyH + 6 + (showPct ? 16 : 0)
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${PIANO_W} ${svgH}`} width="100%" style={{ display: 'block' }}>
|
||||
{/* White keys */}
|
||||
{wKeys.map(({ pc, wi }) => {
|
||||
const energy = values[pc] / max
|
||||
const inChord = chordNotes?.has(pc)
|
||||
const inKey = keyNotes?.has(pc)
|
||||
const x = wi * KEY_W
|
||||
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(180,180,190,${0.05 + energy * 0.2})`
|
||||
const pct = showPct ? Math.round(values[pc] * 100) : 0
|
||||
|
||||
return (
|
||||
<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) && (
|
||||
<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}
|
||||
rx={2} fill={fillColor} />
|
||||
)}
|
||||
<text x={x + KEY_W/2} y={keyH - 4} textAnchor="middle" fontSize={8}
|
||||
fill={inKey || inChord ? 'rgba(200,200,210,0.9)' : 'rgba(90,90,100,0.8)'}>
|
||||
{WHITE_LABELS[wi]}
|
||||
</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)'}>
|
||||
{pct}%
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Black keys */}
|
||||
{bKeys.map(({ pc, wi }, i) => {
|
||||
const energy = values[pc] / max
|
||||
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)'
|
||||
: 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
|
||||
|
||||
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}
|
||||
fill={inKey || inChord ? 'rgba(210,210,220,0.85)' : 'rgba(110,110,120,0.6)'}>
|
||||
{NOTES[pc]}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Mini fretboard — guitar mode chroma view ─────────────────────────────────
|
||||
const STRINGS = [
|
||||
{ label: 'e', root: 4 },
|
||||
{ label: 'B', root: 11 },
|
||||
{ label: 'G', root: 7 },
|
||||
{ label: 'D', root: 2 },
|
||||
{ label: 'A', root: 9 },
|
||||
{ label: 'E', root: 4 },
|
||||
]
|
||||
const MF_NUT_X = 22
|
||||
const MF_OPEN_X = 10
|
||||
const MF_FRET_W = 28
|
||||
const MF_STR_H = 16
|
||||
const MF_PAD_T = 16
|
||||
const MF_PAD_B = 8
|
||||
const MF_FRETS = 13 // frets 0–12
|
||||
const MF_DOT_R = 6
|
||||
const MF_W = MF_NUT_X + (MF_FRETS - 1) * MF_FRET_W + 10
|
||||
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 }) {
|
||||
const max = Math.max(...values, 0.01)
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${MF_W} ${MF_H}`} width="100%" style={{ display: 'block' }}>
|
||||
{/* Board background */}
|
||||
<rect x={MF_NUT_X} y={MF_PAD_T - 5}
|
||||
width={MF_W - MF_NUT_X - 6} height={5 * MF_STR_H + 10}
|
||||
fill="#1a120b" rx={2} />
|
||||
|
||||
{/* Fret position dots */}
|
||||
{[3, 5, 7, 9].map(f => (
|
||||
<circle key={f} cx={mfFretX(f)} cy={MF_PAD_T + 2.5 * MF_STR_H} r={3} fill="#3a2a1a" />
|
||||
))}
|
||||
<circle cx={mfFretX(12)} cy={MF_PAD_T + 1.5 * MF_STR_H} r={3} fill="#3a2a1a" />
|
||||
<circle cx={mfFretX(12)} cy={MF_PAD_T + 3.5 * MF_STR_H} r={3} fill="#3a2a1a" />
|
||||
|
||||
{/* Fret lines */}
|
||||
{Array.from({ length: MF_FRETS - 1 }, (_, i) => i + 1).map(f => (
|
||||
<line key={f}
|
||||
x1={MF_NUT_X + f * MF_FRET_W} y1={MF_PAD_T - 5}
|
||||
x2={MF_NUT_X + f * MF_FRET_W} y2={MF_PAD_T + 5 * MF_STR_H + 5}
|
||||
stroke="#4a3a2a" strokeWidth={1} />
|
||||
))}
|
||||
|
||||
{/* Nut */}
|
||||
<line x1={MF_NUT_X} y1={MF_PAD_T - 5} x2={MF_NUT_X} y2={MF_PAD_T + 5 * MF_STR_H + 5}
|
||||
stroke="#c0b090" strokeWidth={3} />
|
||||
|
||||
{/* Strings */}
|
||||
{STRINGS.map((_, si) => (
|
||||
<line key={si}
|
||||
x1={MF_OPEN_X - MF_DOT_R - 2} y1={mfStringY(si)}
|
||||
x2={MF_W - 6} y2={mfStringY(si)}
|
||||
stroke="#9ca3af"
|
||||
strokeWidth={si < 2 ? 0.8 : si < 4 ? 1.2 : 1.8} />
|
||||
))}
|
||||
|
||||
{/* Fret numbers */}
|
||||
{[3, 5, 7, 9, 12].map(f => (
|
||||
<text key={f} x={mfFretX(f)} y={MF_PAD_T - 5}
|
||||
textAnchor="middle" fontSize={8} fill="#6b7280">{f}</text>
|
||||
))}
|
||||
|
||||
{/* String labels */}
|
||||
{STRINGS.map((s, si) => (
|
||||
<text key={si} x={5} y={mfStringY(si) + 3.5}
|
||||
textAnchor="middle" fontSize={9} fill="#6b7280">{s.label}</text>
|
||||
))}
|
||||
|
||||
{/* Note dots — colored by energy */}
|
||||
{STRINGS.flatMap((str, si) =>
|
||||
Array.from({ length: MF_FRETS }, (_, fi) => {
|
||||
const pc = (str.root + fi) % 12
|
||||
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
|
||||
|
||||
const cx = fi === 0 ? MF_OPEN_X : mfFretX(fi)
|
||||
const cy = mfStringY(si)
|
||||
|
||||
let fill, textFill
|
||||
if (inChord) {
|
||||
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)'
|
||||
} else {
|
||||
fill = `rgba(100,100,120,${energy * 0.7})`
|
||||
textFill = 'rgba(180,180,190,0.7)'
|
||||
}
|
||||
|
||||
return (
|
||||
<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)'}
|
||||
style={{ filter: 'blur(4px)' }} />
|
||||
)}
|
||||
<circle cx={cx} cy={cy} r={MF_DOT_R} fill={fill} />
|
||||
<text x={cx} y={cy + 3.5} textAnchor="middle" fontSize={7} fontWeight="600" fill={textFill}>
|
||||
{NOTES[pc]}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 }) {
|
||||
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 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>
|
||||
<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} />
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* ── Bottom three columns ── */}
|
||||
<div className="grid grid-cols-[2fr_1.5fr_1fr] gap-5">
|
||||
|
||||
{/* Col 1: Chord candidates */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Chord candidates</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{chordCandidates.length === 0 && (
|
||||
<p className="text-gray-700 text-xs">No signal detected</p>
|
||||
)}
|
||||
{chordCandidates.map((c, i) => (
|
||||
<div
|
||||
key={c.name}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg ${
|
||||
i === 0 ? 'bg-accent/10 border border-accent/25' : 'border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs text-gray-600 w-3 shrink-0">{i + 1}</span>
|
||||
<span className={`text-sm font-bold w-14 shrink-0 ${i === 0 ? 'text-white' : 'text-gray-400'}`}>
|
||||
{c.name}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${i === 0 ? 'bg-accent' : 'bg-gray-600'}`}
|
||||
style={{ width: `${(c.score / topScore) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 w-8 text-right tabular-nums">{c.score.toFixed(2)}</span>
|
||||
<div className="flex gap-1 w-14 justify-end">
|
||||
{c.diatonic && <span className="text-[9px] px-1 rounded bg-green-900/50 text-green-400">key</span>}
|
||||
{c.bassBonus > 0 && <span className="text-[9px] px-1 rounded bg-blue-900/50 text-blue-400">bass</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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} />
|
||||
</div>
|
||||
|
||||
{/* Col 3: Key candidates */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Key match scores</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{topKeys.length === 0 && (
|
||||
<p className="text-gray-700 text-xs">Not enough history</p>
|
||||
)}
|
||||
{topKeys.map((k, i) => (
|
||||
<div key={`${k.root}-${k.mode}`} className="flex items-center gap-2">
|
||||
<span className={`text-xs w-16 shrink-0 ${i === 0 ? 'text-white font-semibold' : 'text-gray-500'}`}>
|
||||
{k.root} {k.mode === 'major' ? 'maj' : 'min'}
|
||||
</span>
|
||||
<div className="flex-1 h-1 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${i === 0 ? 'bg-amber-400' : 'bg-gray-600'}`}
|
||||
style={{ width: `${Math.max(0, (k.score / topKeyScore) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-600 tabular-nums w-8 text-right">{k.score.toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Oscilloscope + spectrum ── */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Oscilloscope waveform={waveform} />
|
||||
<SpectrumPanel spectrum={waveform?.spectrum} detectedFreq={waveform?.detectedFreq} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useState } from 'react'
|
||||
import { toRomanNumeral, CHORD_TYPES, NOTES } from '../lib/theory'
|
||||
import {
|
||||
findSimilarProgressions,
|
||||
getChordSubstitutions,
|
||||
progressionInKey,
|
||||
styleVariationInKey,
|
||||
parseChord,
|
||||
} from '../lib/education'
|
||||
|
||||
// ─── Session Snapshot ─────────────────────────────────────────────────────────
|
||||
function SessionSnapshot({ keyInfo, detectedProgression, chordHistory }) {
|
||||
const { root, mode, confidence } = keyInfo ?? {}
|
||||
const uniqueChords = [...new Set(chordHistory)]
|
||||
const totalPlayed = chordHistory.length
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-4 p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="flex flex-col gap-1 min-w-[120px]">
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600">Key</p>
|
||||
{root ? (
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-black text-accent">{root}</span>
|
||||
<span className="text-sm text-gray-400 capitalize">{mode}</span>
|
||||
{confidence && <span className="text-xs text-gray-600">{Math.round(confidence * 100)}%</span>}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-600 text-sm">Detecting…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px bg-border shrink-0" />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600">Detected Loop</p>
|
||||
{detectedProgression?.length ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{detectedProgression.map((chord, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-accent/10 border border-accent/30 rounded text-xs font-bold text-accent">
|
||||
{chord}
|
||||
{root && <span className="text-amber-400/70 ml-1 font-normal text-[10px]">
|
||||
{toRomanNumeral(chord, root, mode)}
|
||||
</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-600 text-sm">None yet — keep playing!</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-px bg-border shrink-0" />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600">Session</p>
|
||||
<p className="text-sm text-gray-300">
|
||||
<span className="font-bold text-white">{totalPlayed}</span> chords ·
|
||||
<span className="font-bold text-white">{uniqueChords.length}</span> unique
|
||||
</p>
|
||||
{uniqueChords.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{uniqueChords.slice(0, 10).map(c => (
|
||||
<span key={c} className="text-[10px] text-gray-500 bg-border px-1.5 py-0.5 rounded">{c}</span>
|
||||
))}
|
||||
{uniqueChords.length > 10 && <span className="text-[10px] text-gray-600">+{uniqueChords.length - 10}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Similar Progressions ─────────────────────────────────────────────────────
|
||||
function SimilarProgressions({ similar, keyInfo, onChordClick }) {
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
if (!similar.length) return (
|
||||
<p className="text-gray-600 text-sm text-center py-4">
|
||||
Play more chords and lock a key to find similar famous progressions.
|
||||
</p>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{similar.map(prog => {
|
||||
const isOpen = expanded === prog.id
|
||||
const chordsInKey = keyInfo?.root ? progressionInKey(prog, keyInfo.root) : []
|
||||
|
||||
return (
|
||||
<div key={prog.id}
|
||||
className="border border-border rounded-xl overflow-hidden hover:border-accent/30 transition-colors">
|
||||
|
||||
{/* Header row */}
|
||||
<button
|
||||
onClick={() => setExpanded(isOpen ? null : prog.id)}
|
||||
className="w-full flex items-start justify-between gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-bold text-white text-sm">{prog.name}</span>
|
||||
{prog.genre.map(g => (
|
||||
<span key={g} className="px-1.5 py-0.5 bg-accent/10 border border-accent/20 rounded text-[10px] text-accent">{g}</span>
|
||||
))}
|
||||
<span className="text-[11px] text-gray-500 font-mono">{prog.pattern}</span>
|
||||
<span className="ml-auto text-xs text-gray-600">{Math.round(prog.score * 100)}% match</span>
|
||||
</div>
|
||||
{/* Chords in current key */}
|
||||
{chordsInKey.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{chordsInKey.map((c, i) => (
|
||||
<button key={i}
|
||||
onClick={e => { e.stopPropagation(); onChordClick?.(c) }}
|
||||
className="px-2 py-0.5 bg-surface border border-border hover:border-accent/50 rounded text-xs font-bold text-gray-200 hover:text-accent transition-colors"
|
||||
title={`See voicings for ${c}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
<span className="text-[10px] text-gray-600 self-center ml-1">in {keyInfo.root} {keyInfo.mode}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-600 shrink-0 text-sm mt-0.5">{isOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{isOpen && (
|
||||
<div className="border-t border-border px-4 py-4 flex flex-col gap-4">
|
||||
<p className="text-sm text-gray-400">{prog.description}</p>
|
||||
{prog.tip && (
|
||||
<p className="text-xs text-amber-400/80">
|
||||
<span className="text-amber-400 font-semibold">Insight:</span> {prog.tip}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Song examples */}
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Famous examples</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{prog.songs.map(s => (
|
||||
<span key={s} className="px-2 py-1 bg-surface border border-border rounded-lg text-xs text-gray-400">{s}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Style variations */}
|
||||
{prog.styleVariations.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Style variations</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{prog.styleVariations.map(sv => {
|
||||
const svChords = keyInfo?.root ? styleVariationInKey(sv, prog, keyInfo.root) : []
|
||||
return (
|
||||
<div key={sv.label} className="flex items-start gap-3 p-2 bg-surface rounded-lg border border-border">
|
||||
<span className="text-xs font-bold text-accent shrink-0 w-16">{sv.label}</span>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="text-xs text-gray-500 font-mono">{sv.pattern}</span>
|
||||
{svChords.length > 0 && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{svChords.map((c, i) => (
|
||||
<button key={i}
|
||||
onClick={() => onChordClick?.(c)}
|
||||
className="px-1.5 py-0.5 bg-accent/10 border border-accent/20 hover:border-accent rounded text-[11px] font-bold text-accent/90 hover:text-accent transition-colors"
|
||||
title={`See voicings for ${c}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
<span className="text-[10px] text-gray-600 self-center ml-1">in {keyInfo.root}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Play It Differently ──────────────────────────────────────────────────────
|
||||
function PlayDifferently({ progression, onChordClick }) {
|
||||
if (!progression?.length) return (
|
||||
<p className="text-gray-600 text-sm text-center py-4">
|
||||
Keep playing — a repeating progression will appear here with substitution ideas.
|
||||
</p>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-gray-500">
|
||||
Tap any substitution to see how to play it. These are harmonic replacements — same role, different colour.
|
||||
</p>
|
||||
{progression.map(chord => {
|
||||
const subs = getChordSubstitutions(chord)
|
||||
return (
|
||||
<div key={chord} className="flex flex-wrap items-start gap-3 p-3 bg-surface border border-border rounded-xl">
|
||||
{/* Original chord */}
|
||||
<button
|
||||
onClick={() => onChordClick?.(chord)}
|
||||
className="px-3 py-1.5 bg-accent text-white font-black rounded-lg text-sm shrink-0 hover:bg-purple-600 transition-colors"
|
||||
title="See voicings"
|
||||
>
|
||||
{chord}
|
||||
</button>
|
||||
|
||||
<span className="text-gray-700 self-center">→</span>
|
||||
|
||||
{/* Substitutions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subs.map(sub => (
|
||||
<div key={sub.chord} className="relative group">
|
||||
<button
|
||||
onClick={() => onChordClick?.(sub.chord)}
|
||||
className="px-2.5 py-1.5 bg-panel border border-border hover:border-accent/50 hover:text-accent rounded-lg text-sm font-bold text-gray-300 transition-all"
|
||||
>
|
||||
{sub.chord}
|
||||
</button>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 w-48 px-2 py-1.5 bg-gray-900 border border-border rounded-lg text-[11px] text-gray-300 leading-snug opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-xl">
|
||||
{sub.tip}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className="text-[11px] text-gray-700 text-center">
|
||||
Hover substitutions to see what they change · click to see voicings
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Chord Variation Ideas ────────────────────────────────────────────────────
|
||||
const VARIATION_ROWS = [
|
||||
{
|
||||
label: '7th Upgrade',
|
||||
desc: 'Add 7ths throughout — jazz and soul texture',
|
||||
typeMap: { maj: 'maj7', min: 'min7', dom7: 'dom7', maj7: 'maj7', min7: 'min7', dim: 'dim7', add9: 'maj7', sus2: 'sus2', sus4: 'sus4', aug: 'aug', half_dim: 'half_dim', maj6: 'maj6', min6: 'min6' },
|
||||
},
|
||||
{
|
||||
label: 'Sus2 Wash',
|
||||
desc: 'Replace triads with sus2 — ambient and spacious',
|
||||
typeMap: { maj: 'sus2', min: 'sus2', dom7: 'sus4', maj7: 'sus2', min7: 'sus2', add9: 'sus2', dim: 'dim', aug: 'aug', sus4: 'sus4', sus2: 'sus2', half_dim: 'half_dim', maj6: 'sus2', min6: 'sus2' },
|
||||
},
|
||||
{
|
||||
label: 'Add9 Modern',
|
||||
desc: 'Add9 on majors, m7 on minors — indie and neo-soul',
|
||||
typeMap: { maj: 'add9', min: 'min7', dom7: 'dom7', maj7: 'add9', min7: 'min7', add9: 'add9', sus2: 'sus2', sus4: 'sus4', dim: 'dim', aug: 'aug', half_dim: 'half_dim', maj6: 'add9', min6: 'min7' },
|
||||
},
|
||||
{
|
||||
label: 'Blues Dominant',
|
||||
desc: 'All chords → dom7 — instant 12-bar blues energy',
|
||||
typeMap: { maj: 'dom7', min: 'dom7', dom7: 'dom7', maj7: 'dom7', min7: 'dom7', add9: 'dom7', sus2: 'dom7', sus4: 'dom7', dim: 'dim7', aug: 'aug', half_dim: 'dom7', maj6: 'dom7', min6: 'dom7' },
|
||||
},
|
||||
]
|
||||
|
||||
function ProgressionVariationIdeas({ progression, onChordClick }) {
|
||||
if (!progression?.length) return (
|
||||
<p className="text-gray-600 text-sm text-center py-4">
|
||||
Keep playing — your progression will appear here.
|
||||
</p>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-gray-500">
|
||||
Your progression re-harmonised four ways. Click any chord to open its voicing explorer.
|
||||
</p>
|
||||
{VARIATION_ROWS.map(row => {
|
||||
const transformed = progression.map(chord => {
|
||||
const p = parseChord(chord)
|
||||
if (!p) return chord
|
||||
const newType = row.typeMap[p.type] ?? p.type
|
||||
return NOTES[p.rootPc] + (CHORD_TYPES[newType]?.suffix ?? '')
|
||||
})
|
||||
return (
|
||||
<div key={row.label} className="p-3 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<span className="text-xs font-bold text-accent">{row.label}</span>
|
||||
<span className="text-[11px] text-gray-500">{row.desc}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{progression.map((orig, i) => (
|
||||
<span key={i} className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-gray-600">{orig}</span>
|
||||
<span className="text-gray-700 text-xs">→</span>
|
||||
<button
|
||||
onClick={() => onChordClick?.(transformed[i])}
|
||||
className="px-2.5 py-1 bg-panel border border-border hover:border-accent/50 hover:text-accent rounded-lg font-bold text-sm text-gray-200 transition-all"
|
||||
>
|
||||
{transformed[i]}
|
||||
</button>
|
||||
{i < progression.length - 1 && <span className="text-gray-700">·</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
export default function EducationPanel({ chordHistory, keyInfo, detectedProgression, onChordClick }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [section, setSection] = useState('similar')
|
||||
|
||||
// Use detected progression if available, else last 4 unique chords from history
|
||||
const workingProgression = detectedProgression?.length
|
||||
? detectedProgression
|
||||
: [...new Set([...chordHistory].reverse())].reverse().slice(-4)
|
||||
|
||||
const similar = findSimilarProgressions(workingProgression, keyInfo)
|
||||
|
||||
return (
|
||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>EDUCATION</span>
|
||||
{similar.length > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-accent/20 border border-accent/30 rounded text-accent">
|
||||
{similar.length} match{similar.length !== 1 ? 'es' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span>{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-border">
|
||||
|
||||
{/* Section nav */}
|
||||
<div className="flex gap-1 px-4 pt-4 pb-0 border-b border-border overflow-x-auto">
|
||||
{[
|
||||
{ key: 'snapshot', label: '📊 Session' },
|
||||
{ key: 'similar', label: `🎵 Similar Progressions${similar.length ? ` (${similar.length})` : ''}` },
|
||||
{ key: 'play', label: '🎨 Play Differently' },
|
||||
{ key: 'variations',label: '🔀 Progression Variations' },
|
||||
].map(s => (
|
||||
<button key={s.key}
|
||||
onClick={() => setSection(s.key)}
|
||||
className={`px-3 py-2 text-xs font-semibold whitespace-nowrap border-b-2 transition-all shrink-0 ${
|
||||
section === s.key
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||
}`}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{section === 'snapshot' && (
|
||||
<SessionSnapshot
|
||||
keyInfo={keyInfo}
|
||||
detectedProgression={detectedProgression}
|
||||
chordHistory={chordHistory}
|
||||
/>
|
||||
)}
|
||||
{section === 'similar' && (
|
||||
<SimilarProgressions
|
||||
similar={similar}
|
||||
keyInfo={keyInfo}
|
||||
onChordClick={onChordClick}
|
||||
/>
|
||||
)}
|
||||
{section === 'play' && (
|
||||
<PlayDifferently
|
||||
progression={workingProgression}
|
||||
onChordClick={onChordClick}
|
||||
/>
|
||||
)}
|
||||
{section === 'variations' && (
|
||||
<ProgressionVariationIdeas
|
||||
progression={workingProgression}
|
||||
onChordClick={onChordClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -30,14 +30,14 @@ const fretX = f => NUT_X + (f - 0.5) * FRET_W
|
||||
// y centre of string si (0 = high e, 5 = low E)
|
||||
const stringY = si => PAD_T + si * STRING_H
|
||||
|
||||
function noteColor(isChordTone, isPenta, isScale) {
|
||||
if (isChordTone) return { fill: '#f59e0b', text: '#000' } // amber
|
||||
if (isPenta) return { fill: '#a855f7', text: '#fff' } // purple
|
||||
if (isScale) return { fill: '#374151', text: '#d1d5db' } // grey
|
||||
function noteColor(isChordTone, isPenta, isScale, mono = false) {
|
||||
if (isChordTone) return { fill: '#a855f7', text: '#fff' }
|
||||
if (isPenta) return mono ? { fill: '#c084fc', text: '#1e1b4b' } : { fill: '#f59e0b', text: '#000' }
|
||||
if (isScale) return mono ? { fill: '#e9d5ff', text: '#581c87' } : { fill: '#374151', text: '#d1d5db' }
|
||||
return null
|
||||
}
|
||||
|
||||
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false }) {
|
||||
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
|
||||
if (!root) return null
|
||||
@@ -57,11 +57,12 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div>
|
||||
<svg
|
||||
width={BOARD_W}
|
||||
height={BOARD_H}
|
||||
style={{ display: 'block', minWidth: BOARD_W }}
|
||||
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`}
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
{/* Fretboard background */}
|
||||
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={5 * STRING_H + 12}
|
||||
@@ -119,7 +120,7 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
||||
{STRINGS.flatMap((str, si) =>
|
||||
Array.from({ length: NUM_FRETS }, (_, fi) => {
|
||||
const pc = (str.root + fi) % 12
|
||||
const color = noteColor(chordSet.has(pc), pentaSet.has(pc), scaleSet.has(pc))
|
||||
const color = noteColor(chordSet.has(pc), pentaSet.has(pc), scaleSet.has(pc), monoColor)
|
||||
if (!color) return null
|
||||
|
||||
const cx = fi === 0 ? OPEN_X : fretX(fi)
|
||||
@@ -145,9 +146,9 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
||||
<span><span className="text-amber-400">●</span> Chord tone</span>
|
||||
<span><span className="text-accent">●</span> Pentatonic</span>
|
||||
<span><span className="text-gray-500">●</span> Scale</span>
|
||||
<span><span className="text-accent">●</span> Chord tone</span>
|
||||
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span><span> Pentatonic</span>
|
||||
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span><span> Scale</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
export default function KeyDisplay({ keyInfo, locked = false }) {
|
||||
const { root, mode, confidence } = keyInfo ?? {}
|
||||
const pct = confidence ? Math.round(confidence * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6 text-center">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-1">
|
||||
{locked ? '🔒 Key (locked)' : 'Detected Key'}
|
||||
</p>
|
||||
{root ? (
|
||||
<>
|
||||
<p className="text-6xl font-bold text-accent leading-none">
|
||||
{root}
|
||||
<span className="text-3xl text-gray-400 ml-2">{mode}</span>
|
||||
</p>
|
||||
{!locked && (
|
||||
<div className="mt-3 flex items-center justify-center gap-2">
|
||||
<div className="h-1.5 w-32 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{pct}% confident</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-2xl text-gray-600 mt-2">Listening…</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
|
||||
const STYLE = {
|
||||
empty: { border: 'border-border', bg: 'bg-surface', icon: '●', iconColor: 'text-gray-700' },
|
||||
recording: { border: 'border-red-500', bg: 'bg-red-950/20', icon: '⏺', iconColor: 'text-red-400' },
|
||||
trimming: { border: 'border-amber-400', bg: 'bg-amber-950/20', icon: '✂', iconColor: 'text-amber-400'},
|
||||
playing: { border: 'border-accent', bg: 'bg-accent/10', icon: '▶', iconColor: 'text-accent' },
|
||||
muted: { border: 'border-border', bg: 'bg-surface', icon: '⏸', iconColor: 'text-gray-500' },
|
||||
}
|
||||
|
||||
const LABEL = {
|
||||
empty: 'tap to rec',
|
||||
recording: 'tap to stop',
|
||||
trimming: 'trimming…',
|
||||
playing: 'tap to mute',
|
||||
muted: 'tap to play',
|
||||
}
|
||||
|
||||
export default function LoopSlot({ slot, slotIdx, audioCtxRef, masterStartRef, masterLenRef, onClick, onRetrim, onDelete, onVolumeChange }) {
|
||||
const progressRef = useRef(null)
|
||||
const rafRef = useRef(null)
|
||||
const [showVol, setShowVol] = useState(false)
|
||||
const [holdTimer, setHoldTimer] = useState(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const { status, recordingDuration, volume, originalBuffer } = slot
|
||||
const style = STYLE[status] ?? STYLE.empty
|
||||
const isActive = status === 'playing' || status === 'muted'
|
||||
|
||||
// ── Progress bar via rAF ─────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
if (progressRef.current) progressRef.current.style.width = '0%'
|
||||
return
|
||||
}
|
||||
function tick() {
|
||||
const ctx = audioCtxRef.current
|
||||
const mStart = masterStartRef.current
|
||||
const mLen = masterLenRef.current
|
||||
if (ctx && mStart !== null && mLen && progressRef.current) {
|
||||
const pos = ((ctx.currentTime - mStart) % mLen) / mLen * 100
|
||||
progressRef.current.style.width = `${pos}%`
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
|
||||
}, [isActive, audioCtxRef, masterStartRef, masterLenRef])
|
||||
|
||||
// ── Long-press to delete ──────────────────────────────────────────────────
|
||||
const onPointerDown = useCallback((e) => {
|
||||
e.preventDefault()
|
||||
const t = setTimeout(() => setDeleting(true), 500)
|
||||
setHoldTimer(t)
|
||||
}, [])
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
if (holdTimer) { clearTimeout(holdTimer); setHoldTimer(null) }
|
||||
if (!deleting) onClick(slotIdx)
|
||||
}, [holdTimer, deleting, onClick, slotIdx])
|
||||
|
||||
const onPointerLeave = useCallback(() => {
|
||||
if (holdTimer) { clearTimeout(holdTimer); setHoldTimer(null) }
|
||||
}, [holdTimer])
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
setDeleting(false)
|
||||
onDelete(slotIdx)
|
||||
}, [onDelete, slotIdx])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 select-none relative">
|
||||
|
||||
{/* Delete confirmation overlay (long-press) */}
|
||||
{deleting && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 rounded-xl bg-black/90 border border-red-500">
|
||||
<button
|
||||
className="text-[10px] font-bold text-red-400 px-2 py-0.5 rounded bg-red-900/50 hover:bg-red-900"
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="text-[10px] text-gray-500 hover:text-gray-300"
|
||||
onClick={() => setDeleting(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick clear button — visible on non-empty slots */}
|
||||
{status !== 'empty' && !deleting && (
|
||||
<button
|
||||
className="absolute -top-1.5 -right-1.5 z-10 w-4 h-4 rounded-full bg-gray-800 border border-border text-gray-500 hover:bg-red-900/60 hover:text-red-400 hover:border-red-700 text-[9px] leading-none flex items-center justify-center transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(slotIdx) }}
|
||||
title="Clear slot"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Main button */}
|
||||
<button
|
||||
className={`relative w-[76px] h-[54px] rounded-xl border-2 overflow-hidden flex flex-col items-center justify-center gap-0.5 transition-colors cursor-pointer ${style.bg} ${style.border} ${status === 'recording' ? 'animate-pulse' : ''}`}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerLeave}
|
||||
>
|
||||
<span className={`text-lg leading-none ${style.iconColor}`}>
|
||||
{style.icon}
|
||||
</span>
|
||||
<span className={`text-[9px] font-bold uppercase tracking-widest leading-none ${style.iconColor} opacity-70`}>
|
||||
{status === 'recording'
|
||||
? `${(recordingDuration ?? 0).toFixed(1)}s`
|
||||
: `Loop ${slotIdx + 1}`}
|
||||
</span>
|
||||
|
||||
{/* Progress bar */}
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-border">
|
||||
<div
|
||||
ref={progressRef}
|
||||
className="h-full bg-accent"
|
||||
style={{ width: '0%', transition: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Controls row (playing/muted only) */}
|
||||
{isActive && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Volume toggle */}
|
||||
<button
|
||||
className={`text-[11px] transition-colors ${showVol ? 'text-accent' : 'text-gray-600 hover:text-gray-400'}`}
|
||||
onClick={() => setShowVol(v => !v)}
|
||||
title="Volume"
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
{showVol && (
|
||||
<input
|
||||
type="range" min={0} max={1} step={0.05}
|
||||
value={volume}
|
||||
onChange={e => onVolumeChange(slotIdx, parseFloat(e.target.value))}
|
||||
className="w-12 h-1 cursor-pointer accent-purple-500"
|
||||
/>
|
||||
)}
|
||||
{/* Re-trim button — only when original recording exists */}
|
||||
{originalBuffer && (
|
||||
<button
|
||||
className="text-[11px] text-gray-600 hover:text-amber-400 transition-colors"
|
||||
onClick={() => onRetrim(slotIdx)}
|
||||
title="Re-trim this loop"
|
||||
>
|
||||
✂
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status label */}
|
||||
<span className={`text-[9px] uppercase tracking-wider leading-none ${style.iconColor} opacity-50`}>
|
||||
{LABEL[status] ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import LoopTrimmer from './LoopTrimmer'
|
||||
|
||||
const H_TRACK = 56 // track canvas height px
|
||||
const H_MASTER = 26 // master timeline height px
|
||||
|
||||
// ── Canvas draw helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function drawGrid(canvas, totalSec, bpm) {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
const dpr = window.devicePixelRatio ?? 1
|
||||
canvas.width = rect.width * dpr
|
||||
canvas.height = rect.height * dpr
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(dpr, dpr)
|
||||
const W = rect.width, H = rect.height
|
||||
|
||||
ctx.fillStyle = '#0f0f0f'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
if (!bpm || !totalSec) return
|
||||
|
||||
const beatSec = 60 / bpm
|
||||
const barSec = beatSec * 4
|
||||
|
||||
// Beat lines
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
|
||||
ctx.lineWidth = 1
|
||||
for (let t = beatSec; t < totalSec; t += beatSec) {
|
||||
if ((t % barSec) < beatSec * 0.4) continue
|
||||
const x = (t / totalSec) * W
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||
}
|
||||
// Bar lines + numbers
|
||||
for (let t = 0; t <= totalSec; t += barSec) {
|
||||
ctx.strokeStyle = 'rgba(168,85,247,0.45)'
|
||||
ctx.lineWidth = 1.5
|
||||
const x = (t / totalSec) * W
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||
const n = Math.round(t / barSec)
|
||||
if (n > 0) {
|
||||
ctx.fillStyle = 'rgba(168,85,247,0.55)'
|
||||
ctx.font = '9px monospace'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(String(n), x + 3, H - 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawWaveform(canvas, waveform, muted) {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
const dpr = window.devicePixelRatio ?? 1
|
||||
canvas.width = rect.width * dpr
|
||||
canvas.height = rect.height * dpr
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(dpr, dpr)
|
||||
const W = rect.width, H = rect.height
|
||||
|
||||
ctx.fillStyle = '#0f0f0f'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
|
||||
const N = waveform.length
|
||||
const mid = H / 2
|
||||
for (let i = 0; i < N; i++) {
|
||||
const x = (i / N) * W
|
||||
const barW = Math.max(1, W / N - 0.3)
|
||||
ctx.fillStyle = muted ? '#3b1f55' : '#a855f7'
|
||||
const h = waveform[i] * mid * 0.85
|
||||
ctx.fillRect(x, mid - h, barW, h * 2)
|
||||
}
|
||||
}
|
||||
|
||||
function drawRecording(canvas, duration, bpm) {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
const dpr = window.devicePixelRatio ?? 1
|
||||
canvas.width = rect.width * dpr
|
||||
canvas.height = rect.height * dpr
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(dpr, dpr)
|
||||
const W = rect.width, H = rect.height
|
||||
|
||||
ctx.fillStyle = '#0f0f0f'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
|
||||
const beatSec = bpm ? 60 / bpm : null
|
||||
const barSec = beatSec ? beatSec * 4 : null
|
||||
const viewDur = barSec
|
||||
? Math.max(barSec * 4, Math.ceil(duration / barSec + 1) * barSec)
|
||||
: Math.max(8, duration * 1.4)
|
||||
|
||||
// Grid
|
||||
if (beatSec) {
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
|
||||
ctx.lineWidth = 1
|
||||
for (let t = beatSec; t < viewDur; t += beatSec) {
|
||||
if (barSec && (t % barSec) < beatSec * 0.4) continue
|
||||
const x = (t / viewDur) * W
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||
}
|
||||
if (barSec) {
|
||||
for (let t = barSec; t <= viewDur; t += barSec) {
|
||||
ctx.strokeStyle = 'rgba(239,68,68,0.3)'
|
||||
ctx.lineWidth = 1.5
|
||||
const x = (t / viewDur) * W
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||
const n = Math.round(t / barSec)
|
||||
ctx.fillStyle = 'rgba(239,68,68,0.45)'
|
||||
ctx.font = '9px monospace'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(String(n), x + 2, H - 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Growing fill + cursor
|
||||
const fillX = (duration / viewDur) * W
|
||||
ctx.fillStyle = 'rgba(239,68,68,0.18)'
|
||||
ctx.fillRect(0, 0, fillX, H)
|
||||
ctx.strokeStyle = 'rgba(239,68,68,0.85)'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath(); ctx.moveTo(fillX, 0); ctx.lineTo(fillX, H); ctx.stroke()
|
||||
|
||||
// Counter label
|
||||
const bars = barSec ? Math.floor(duration / barSec) + 1 : null
|
||||
const label = bars !== null
|
||||
? `● REC BAR ${bars} ${duration.toFixed(1)}s — tap to stop`
|
||||
: `● REC ${duration.toFixed(1)}s — tap to stop`
|
||||
ctx.fillStyle = 'rgba(239,68,68,0.9)'
|
||||
ctx.font = 'bold 11px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(label, W / 2, H / 2)
|
||||
}
|
||||
|
||||
// ── Master Timeline ───────────────────────────────────────────────────────────
|
||||
|
||||
function MasterTimeline({ masterStartRef, masterLenRef, audioCtxRef, bpm, masterLen }) {
|
||||
const canvasRef = useRef(null)
|
||||
const playheadRef = useRef(null)
|
||||
const rafRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const id = requestAnimationFrame(() => drawGrid(canvas, masterLen, bpm))
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [masterLen, bpm])
|
||||
|
||||
useEffect(() => {
|
||||
if (!masterLen) { cancelAnimationFrame(rafRef.current); return }
|
||||
function tick() {
|
||||
const ac = audioCtxRef.current
|
||||
const t0 = masterStartRef.current
|
||||
const len = masterLenRef.current
|
||||
if (ac && t0 !== null && len && playheadRef.current) {
|
||||
const pos = ((ac.currentTime - t0) % len) / len
|
||||
playheadRef.current.style.left = `${pos * 100}%`
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(rafRef.current)
|
||||
}, [masterLen, audioCtxRef, masterStartRef, masterLenRef])
|
||||
|
||||
return (
|
||||
<div className="relative mx-4 mb-3 rounded overflow-hidden bg-surface border border-border"
|
||||
style={{ height: `${H_MASTER}px` }}>
|
||||
<canvas ref={canvasRef} className="w-full h-full block" />
|
||||
{!masterLen && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="text-[10px] text-gray-700">
|
||||
record first loop to set master length
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{masterLen && (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute top-0 bottom-0 w-px bg-white/50 pointer-events-none"
|
||||
style={{ left: '0%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Track Row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TrackRow({ slot, slotIdx, bpm, audioCtxRef, masterStartRef, masterLenRef,
|
||||
onSlotClick, onRetrim, onDelete, onVolumeChange }) {
|
||||
const [showVol, setShowVol] = useState(false)
|
||||
const canvasRef = useRef(null)
|
||||
const playheadRef = useRef(null)
|
||||
const rafRef = useRef(null)
|
||||
|
||||
const DOT_CLASS = {
|
||||
empty: 'bg-gray-700',
|
||||
recording: 'bg-red-500 animate-pulse',
|
||||
trimming: 'bg-amber-500',
|
||||
playing: 'bg-accent',
|
||||
muted: 'bg-gray-500',
|
||||
}
|
||||
const BORDER_CLASS = {
|
||||
empty: 'border-border',
|
||||
recording: 'border-red-800',
|
||||
trimming: 'border-amber-800/60',
|
||||
playing: 'border-accent/40',
|
||||
muted: 'border-border',
|
||||
}
|
||||
|
||||
const dotClass = DOT_CLASS[slot.status] ?? 'bg-gray-700'
|
||||
const borderClass = BORDER_CLASS[slot.status] ?? 'border-border'
|
||||
|
||||
// Draw waveform when data arrives or mute state changes
|
||||
useEffect(() => {
|
||||
if (!slot.waveform) return
|
||||
if (slot.status === 'recording' || slot.status === 'trimming') return
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const id = requestAnimationFrame(() =>
|
||||
drawWaveform(canvas, slot.waveform, slot.status === 'muted')
|
||||
)
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [slot.waveform, slot.status])
|
||||
|
||||
// Draw recording progress on each duration tick
|
||||
useEffect(() => {
|
||||
if (slot.status !== 'recording') return
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
drawRecording(canvas, slot.recordingDuration, bpm)
|
||||
}, [slot.recordingDuration, slot.status, bpm])
|
||||
|
||||
// Playhead animation
|
||||
useEffect(() => {
|
||||
const active = slot.status === 'playing' || slot.status === 'muted'
|
||||
if (!active) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
if (playheadRef.current) playheadRef.current.style.left = '-2px'
|
||||
return
|
||||
}
|
||||
function tick() {
|
||||
const ac = audioCtxRef.current
|
||||
const t0 = masterStartRef.current
|
||||
const len = masterLenRef.current
|
||||
if (ac && t0 !== null && len && playheadRef.current) {
|
||||
const pos = ((ac.currentTime - t0) % len) / len
|
||||
playheadRef.current.style.left = `${pos * 100}%`
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(rafRef.current)
|
||||
}, [slot.status, audioCtxRef, masterStartRef, masterLenRef])
|
||||
|
||||
const isClickable = slot.status !== 'trimming'
|
||||
const isActive = slot.status === 'playing' || slot.status === 'muted'
|
||||
|
||||
return (
|
||||
<div className={`flex items-stretch border rounded-lg mb-1.5 overflow-hidden transition-colors ${borderClass}`}>
|
||||
|
||||
{/* Left: tap button (state dot + track number) */}
|
||||
<button
|
||||
onClick={() => isClickable && onSlotClick(slotIdx)}
|
||||
disabled={!isClickable}
|
||||
title={
|
||||
slot.status === 'empty' ? 'Tap to record' :
|
||||
slot.status === 'recording' ? 'Tap to stop' :
|
||||
slot.status === 'playing' ? 'Tap to mute' :
|
||||
slot.status === 'muted' ? 'Tap to unmute' : ''
|
||||
}
|
||||
className="flex flex-col items-center justify-center gap-1 px-3 bg-surface border-r border-border shrink-0 hover:bg-white/5 transition-colors disabled:cursor-default"
|
||||
style={{ width: '44px' }}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||
<span className="text-[10px] text-gray-600 font-mono">{slotIdx + 1}</span>
|
||||
</button>
|
||||
|
||||
{/* Canvas: waveform / recording / empty */}
|
||||
<div
|
||||
className="relative flex-1 cursor-pointer"
|
||||
style={{ height: `${H_TRACK}px` }}
|
||||
onClick={() => isClickable && onSlotClick(slotIdx)}
|
||||
>
|
||||
<canvas ref={canvasRef} className="w-full h-full block" />
|
||||
|
||||
{slot.status === 'empty' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="text-xs text-gray-700">tap to record</span>
|
||||
</div>
|
||||
)}
|
||||
{slot.status === 'trimming' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-amber-950/20 pointer-events-none">
|
||||
<span className="text-xs text-amber-500/60">trimming ↓</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Moving playhead */}
|
||||
{isActive && (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute top-0 bottom-0 w-px bg-white/40 pointer-events-none"
|
||||
style={{ left: '-2px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: controls */}
|
||||
<div className="flex items-center gap-1 px-2 bg-surface border-l border-border shrink-0">
|
||||
{showVol && (
|
||||
<input
|
||||
type="range"
|
||||
min={0} max={1} step={0.01}
|
||||
value={slot.volume}
|
||||
onChange={e => onVolumeChange(slotIdx, parseFloat(e.target.value))}
|
||||
className="w-14 accent-purple-500"
|
||||
title="Volume"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowVol(v => !v)}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded text-base transition-colors ${
|
||||
showVol ? 'text-accent' : 'text-gray-600 hover:text-gray-300'
|
||||
}`}
|
||||
title="Volume"
|
||||
>
|
||||
{slot.status === 'muted' ? '🔇' : '🔊'}
|
||||
</button>
|
||||
{isActive && slot.originalBuffer && (
|
||||
<button
|
||||
onClick={() => onRetrim(slotIdx)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded text-gray-600 hover:text-amber-400 transition-colors text-sm"
|
||||
title="Re-trim loop"
|
||||
>
|
||||
✂
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(slotIdx)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded text-gray-700 hover:text-red-400 transition-colors text-sm"
|
||||
title="Clear track"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function LoopStation({
|
||||
slots,
|
||||
bpm,
|
||||
masterLen,
|
||||
audioCtxRef,
|
||||
masterStartRef,
|
||||
masterLenRef,
|
||||
onSlotClick,
|
||||
onCommitTrim,
|
||||
onCancelRecord,
|
||||
onRetrim,
|
||||
onDelete,
|
||||
onVolumeChange,
|
||||
onAddSlot,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [gridBpm, setGridBpm] = useState(bpm ?? '')
|
||||
|
||||
// Pre-fill BPM when detection arrives
|
||||
useEffect(() => {
|
||||
if (bpm && !gridBpm) setGridBpm(bpm)
|
||||
}, [bpm]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const gridBpmNum = parseFloat(gridBpm) || null
|
||||
const trimmingIdx = slots.findIndex(s => s.status === 'trimming')
|
||||
|
||||
const recordingCount = slots.filter(s => s.status === 'recording').length
|
||||
const playingCount = slots.filter(s => s.status === 'playing').length
|
||||
|
||||
const dotClass = recordingCount > 0
|
||||
? 'bg-red-500 animate-pulse'
|
||||
: playingCount > 0
|
||||
? 'bg-accent'
|
||||
: 'bg-gray-700'
|
||||
|
||||
const masterLabel = (() => {
|
||||
if (!masterLen) return null
|
||||
if (gridBpmNum) {
|
||||
const bars = Math.round(masterLen / ((60 / gridBpmNum) * 4))
|
||||
return `${bars} bar${bars !== 1 ? 's' : ''} · ${masterLen.toFixed(2)}s`
|
||||
}
|
||||
return masterLen.toFixed(2) + 's'
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-400">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex items-center gap-2 hover:text-gray-200 transition-colors text-left"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||
<span>LOOP STATION</span>
|
||||
{masterLabel && (
|
||||
<span className="text-[11px] text-gray-600 font-mono">{masterLabel}</span>
|
||||
)}
|
||||
{recordingCount > 0 && (
|
||||
<span className="text-[11px] text-red-400">● rec</span>
|
||||
)}
|
||||
{playingCount > 0 && recordingCount === 0 && (
|
||||
<span className="text-[11px] text-accent">
|
||||
{playingCount} loop{playingCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 bg-surface border border-border rounded-lg px-2 py-1">
|
||||
<span className="text-[10px] text-gray-600 uppercase tracking-wider">BPM</span>
|
||||
<input
|
||||
type="number"
|
||||
min={40} max={300} step={1}
|
||||
value={gridBpm}
|
||||
onChange={e => setGridBpm(e.target.value)}
|
||||
placeholder={bpm ? String(Math.round(bpm)) : '—'}
|
||||
className="w-10 bg-transparent text-xs text-gray-300 text-center focus:outline-none focus:text-white"
|
||||
style={{ MozAppearance: 'textfield' }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-all"
|
||||
title={open ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
{open
|
||||
? <polyline points="2,8 6,4 10,8" />
|
||||
: <polyline points="2,4 6,8 10,4" />
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ────────────────────────────────────────────────────────────── */}
|
||||
{open && (
|
||||
<div className="border-t border-border pt-3">
|
||||
|
||||
{/* Master timeline */}
|
||||
<MasterTimeline
|
||||
masterStartRef={masterStartRef}
|
||||
masterLenRef={masterLenRef}
|
||||
audioCtxRef={audioCtxRef}
|
||||
bpm={gridBpmNum}
|
||||
masterLen={masterLen}
|
||||
/>
|
||||
|
||||
{/* Track rows */}
|
||||
<div className="px-4">
|
||||
{slots.map((slot, i) => (
|
||||
<TrackRow
|
||||
key={i}
|
||||
slot={slot}
|
||||
slotIdx={i}
|
||||
bpm={gridBpmNum}
|
||||
audioCtxRef={audioCtxRef}
|
||||
masterStartRef={masterStartRef}
|
||||
masterLenRef={masterLenRef}
|
||||
onSlotClick={onSlotClick}
|
||||
onRetrim={onRetrim}
|
||||
onDelete={onDelete}
|
||||
onVolumeChange={onVolumeChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add track */}
|
||||
<div className="px-4 pb-3">
|
||||
<button
|
||||
onClick={onAddSlot}
|
||||
className="w-full py-1.5 rounded-lg border border-dashed border-border text-gray-700 hover:border-accent/40 hover:text-accent/60 transition-colors text-xs flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="text-base leading-none">+</span>
|
||||
<span>Add Track</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LoopTrimmer — shown below tracks when trimming */}
|
||||
{trimmingIdx !== -1 && (
|
||||
<LoopTrimmer
|
||||
slot={slots[trimmingIdx]}
|
||||
slotIdx={trimmingIdx}
|
||||
bpm={gridBpmNum}
|
||||
audioCtxRef={audioCtxRef}
|
||||
onCommit={onCommitTrim}
|
||||
onCancel={onCancelRecord}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
|
||||
function fmtMs(sec) {
|
||||
return `${(sec * 1000).toFixed(0)}ms`
|
||||
}
|
||||
|
||||
function fmtSec(sec) {
|
||||
return sec < 10 ? `${sec.toFixed(2)}s` : `${sec.toFixed(1)}s`
|
||||
}
|
||||
|
||||
export default function LoopTrimmer({ slot, slotIdx, bpm, audioCtxRef, onCommit, onCancel }) {
|
||||
const canvasRef = useRef(null)
|
||||
const containerRef = useRef(null)
|
||||
const previewRef = useRef(null) // AudioBufferSourceNode for preview
|
||||
|
||||
const [trimStart, setTrimStart] = useState(slot.trimStart)
|
||||
const [trimEnd, setTrimEnd] = useState(slot.trimEnd)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
|
||||
// Refs so drag closures always have current values
|
||||
const trimStartRef = useRef(trimStart)
|
||||
const trimEndRef = useRef(trimEnd)
|
||||
useEffect(() => { trimStartRef.current = trimStart }, [trimStart])
|
||||
useEffect(() => { trimEndRef.current = trimEnd }, [trimEnd])
|
||||
|
||||
const duration = slot.audioBuffer?.duration ?? 0
|
||||
const startSec = trimStart * duration
|
||||
const endSec = trimEnd * duration
|
||||
const selectedSec = endSec - startSec
|
||||
|
||||
// Beat grid info — visual reference only, no snapping
|
||||
const beatSec = bpm ? 60 / bpm : null
|
||||
const barSec = beatSec ? beatSec * 4 : null
|
||||
|
||||
// Stop preview when handles change
|
||||
useEffect(() => {
|
||||
if (previewing) stopPreview()
|
||||
}, [trimStart, trimEnd]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => stopPreview()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function stopPreview() {
|
||||
try { previewRef.current?.stop() } catch {}
|
||||
previewRef.current = null
|
||||
setPreviewing(false)
|
||||
}
|
||||
|
||||
function togglePreview() {
|
||||
if (previewing) { stopPreview(); return }
|
||||
const ctx = audioCtxRef?.current
|
||||
const buf = slot.audioBuffer
|
||||
if (!ctx || !buf) return
|
||||
|
||||
// Resume context if suspended
|
||||
if (ctx.state === 'suspended') ctx.resume().catch(() => {})
|
||||
|
||||
const sr = buf.sampleRate
|
||||
const startSample = Math.floor(trimStartRef.current * buf.length)
|
||||
const endSample = Math.ceil(trimEndRef.current * buf.length)
|
||||
const len = Math.max(1, endSample - startSample)
|
||||
const data = buf.getChannelData(0).slice(startSample, endSample)
|
||||
|
||||
const previewBuf = ctx.createBuffer(1, len, sr)
|
||||
previewBuf.copyToChannel(data, 0)
|
||||
|
||||
const node = ctx.createBufferSource()
|
||||
node.buffer = previewBuf
|
||||
node.loop = true
|
||||
node.loopStart = 0
|
||||
node.loopEnd = len / sr
|
||||
node.connect(ctx.destination)
|
||||
node.start()
|
||||
node.onended = () => { previewRef.current = null; setPreviewing(false) }
|
||||
|
||||
previewRef.current = node
|
||||
setPreviewing(true)
|
||||
}
|
||||
|
||||
// Snap end handle to N bars from current start
|
||||
function snapBars(n) {
|
||||
if (!barSec || !duration) return
|
||||
const newEnd = Math.min(1, trimStartRef.current + (n * barSec) / duration)
|
||||
setTrimEnd(newEnd)
|
||||
trimEndRef.current = newEnd
|
||||
draw()
|
||||
}
|
||||
|
||||
// ── Canvas draw ─────────────────────────────────────────────────────────────
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !slot.waveform) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (rect.width === 0) return
|
||||
const dpr = window.devicePixelRatio ?? 1
|
||||
canvas.width = rect.width * dpr
|
||||
canvas.height = rect.height * dpr
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(dpr, dpr)
|
||||
const W = rect.width
|
||||
const H = rect.height
|
||||
const wf = slot.waveform
|
||||
const N = wf.length
|
||||
const ts = trimStartRef.current
|
||||
const te = trimEndRef.current
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = '#0f0f0f'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
|
||||
// Dim regions outside selection
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.6)'
|
||||
ctx.fillRect(0, 0, ts * W, H)
|
||||
ctx.fillRect(te * W, 0, W - te * W, H)
|
||||
|
||||
// Beat grid — visual only, beat lines then bar lines (bars on top)
|
||||
if (beatSec && duration) {
|
||||
// Beat lines
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.10)'
|
||||
ctx.lineWidth = 1
|
||||
for (let t = 0; t <= duration; t += beatSec) {
|
||||
const isBar = barSec ? (t % barSec) < beatSec * 0.4 : false
|
||||
if (!isBar) {
|
||||
const x = (t / duration) * W
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||
}
|
||||
}
|
||||
// Bar lines (brighter, thicker)
|
||||
if (barSec) {
|
||||
ctx.strokeStyle = 'rgba(168,85,247,0.55)'
|
||||
ctx.lineWidth = 1.5
|
||||
for (let t = 0; t <= duration; t += barSec) {
|
||||
const x = (t / duration) * W
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||
// Bar number label
|
||||
const barNum = Math.round(t / barSec)
|
||||
if (barNum > 0) {
|
||||
ctx.fillStyle = 'rgba(168,85,247,0.5)'
|
||||
ctx.font = `${9 * dpr / dpr}px monospace`
|
||||
ctx.fillText(`${barNum}`, x + 3, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Waveform bars
|
||||
const mid = H / 2
|
||||
for (let i = 0; i < N; i++) {
|
||||
const x = (i / N) * W
|
||||
const barW = Math.max(1, W / N - 0.5)
|
||||
const inSel = (i / N) >= ts && (i / N) <= te
|
||||
ctx.fillStyle = inSel ? '#a855f7' : '#3b0764'
|
||||
const h = wf[i] * mid * 0.88
|
||||
ctx.fillRect(x, mid - h, barW, h * 2)
|
||||
}
|
||||
|
||||
// Handle lines
|
||||
ctx.strokeStyle = '#a855f7'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath(); ctx.moveTo(ts * W, 0); ctx.lineTo(ts * W, H); ctx.stroke()
|
||||
ctx.beginPath(); ctx.moveTo(te * W, 0); ctx.lineTo(te * W, H); ctx.stroke()
|
||||
}, [slot.waveform, beatSec, barSec, duration])
|
||||
|
||||
useEffect(() => { draw() }, [draw, trimStart, trimEnd])
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => draw())
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [draw])
|
||||
|
||||
// ── Pointer → fraction ──────────────────────────────────────────────────────
|
||||
function fracFromClientX(clientX) {
|
||||
const el = containerRef.current
|
||||
if (!el) return 0
|
||||
const rect = el.getBoundingClientRect()
|
||||
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
}
|
||||
|
||||
// ── Drag handles — free movement, no snapping ───────────────────────────────
|
||||
function handleMouseDown(handle) {
|
||||
return (e) => {
|
||||
e.preventDefault()
|
||||
function onMove(ev) {
|
||||
const raw = fracFromClientX(ev.clientX)
|
||||
if (handle === 'start') {
|
||||
const c = Math.max(0, Math.min(raw, trimEndRef.current - 0.01))
|
||||
setTrimStart(c); trimStartRef.current = c
|
||||
} else {
|
||||
const c = Math.max(trimStartRef.current + 0.01, Math.min(1, raw))
|
||||
setTrimEnd(c); trimEndRef.current = c
|
||||
}
|
||||
draw()
|
||||
}
|
||||
function onUp() {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}
|
||||
}
|
||||
|
||||
// Click canvas to move nearest handle
|
||||
function handleCanvasClick(e) {
|
||||
if (e.target !== canvasRef.current) return
|
||||
const raw = fracFromClientX(e.clientX)
|
||||
if (Math.abs(raw - trimStart) <= Math.abs(raw - trimEnd)) {
|
||||
const c = Math.max(0, Math.min(raw, trimEndRef.current - 0.01))
|
||||
setTrimStart(c); trimStartRef.current = c
|
||||
} else {
|
||||
const c = Math.max(trimStartRef.current + 0.01, Math.min(1, raw))
|
||||
setTrimEnd(c); trimEndRef.current = c
|
||||
}
|
||||
draw()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between px-4 pt-3 pb-1">
|
||||
<span className="text-[11px] uppercase tracking-wider text-gray-500">
|
||||
Trim — Loop {slotIdx + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 font-mono">
|
||||
<span className="text-gray-600">{fmtMs(startSec)} → {fmtMs(endSec)}</span>
|
||||
<span className="text-accent font-semibold">{fmtSec(selectedSec)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waveform + handles */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative mx-4 h-20 rounded-lg overflow-visible cursor-crosshair select-none"
|
||||
onClick={handleCanvasClick}
|
||||
>
|
||||
<canvas ref={canvasRef} className="w-full h-full block rounded-lg" />
|
||||
|
||||
{/* Left handle */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-5 -translate-x-1/2 cursor-ew-resize flex items-center justify-center group z-10"
|
||||
style={{ left: `${trimStart * 100}%` }}
|
||||
onMouseDown={handleMouseDown('start')}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-0.5 h-full bg-accent/70 group-hover:bg-accent group-hover:w-1 transition-all" />
|
||||
<div className="absolute w-3.5 h-3.5 rounded-full bg-accent border-2 border-white/20 shadow-lg top-1/2 -translate-y-1/2" />
|
||||
<div className="absolute bottom-full mb-1 text-[9px] font-mono text-accent bg-panel border border-border rounded px-1 py-0.5 whitespace-nowrap pointer-events-none">
|
||||
{fmtMs(startSec)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right handle */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-5 -translate-x-1/2 cursor-ew-resize flex items-center justify-center group z-10"
|
||||
style={{ left: `${trimEnd * 100}%` }}
|
||||
onMouseDown={handleMouseDown('end')}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-0.5 h-full bg-accent/70 group-hover:bg-accent group-hover:w-1 transition-all" />
|
||||
<div className="absolute w-3.5 h-3.5 rounded-full bg-accent border-2 border-white/20 shadow-lg top-1/2 -translate-y-1/2" />
|
||||
<div className="absolute bottom-full mb-1 text-[9px] font-mono text-accent bg-panel border border-border rounded px-1 py-0.5 whitespace-nowrap pointer-events-none">
|
||||
{fmtMs(endSec)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: preview + bar snap + hint */}
|
||||
<div className="flex items-center gap-2 px-4 pt-2 pb-1 flex-wrap">
|
||||
{/* Preview play/stop */}
|
||||
<button
|
||||
onClick={togglePreview}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg border text-xs font-medium transition-all ${
|
||||
previewing
|
||||
? 'bg-accent/20 border-accent text-accent'
|
||||
: 'bg-surface border-border text-gray-400 hover:text-white hover:border-gray-500'
|
||||
}`}
|
||||
title="Preview loop selection"
|
||||
>
|
||||
{previewing
|
||||
? <><span>⏹</span><span>Stop</span></>
|
||||
: <><span>▶</span><span>Preview</span></>
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* Bar snap buttons — only if BPM is set */}
|
||||
{barSec && duration && (
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<span className="text-[10px] text-gray-600 uppercase tracking-wider mr-0.5">snap end →</span>
|
||||
{[1, 2, 4].map(n => {
|
||||
const endFrac = trimStart + (n * barSec) / duration
|
||||
const fits = endFrac <= 1.02
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => snapBars(n)}
|
||||
disabled={!fits}
|
||||
className="px-2 py-0.5 rounded border border-border text-[10px] text-gray-400 hover:text-accent hover:border-accent/50 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={`Set end to ${n} bar${n > 1 ? 's' : ''} from start`}
|
||||
>
|
||||
{n} bar{n > 1 ? 's' : ''}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="ml-auto text-[10px] text-gray-700">
|
||||
{bpm ? `${bpm} BPM grid` : 'no BPM — trim freely'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 px-4 pb-3">
|
||||
<button
|
||||
onClick={() => { stopPreview(); onCommit(slotIdx, trimStart, trimEnd) }}
|
||||
className="px-5 py-1.5 bg-accent text-white text-sm font-bold rounded-lg hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
Set Loop ▶
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { stopPreview(); onCommit(slotIdx, 0, 1) }}
|
||||
className="px-4 py-1.5 bg-surface border border-border text-gray-400 text-sm rounded-lg hover:text-white hover:border-gray-500 transition-colors"
|
||||
title="Use the full recording without trimming"
|
||||
>
|
||||
Use Full
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { stopPreview(); onCancel(slotIdx) }}
|
||||
className="px-4 py-1.5 bg-surface border border-border text-gray-500 text-sm rounded-lg hover:text-red-400 hover:border-red-800 transition-colors ml-auto"
|
||||
>
|
||||
Re-record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
|
||||
const MODEL = 'claude-sonnet-4-6'
|
||||
const API_URL = 'https://api.anthropic.com/v1/messages'
|
||||
const LS_KEY = 'wtf_teacher_key'
|
||||
|
||||
// ── System prompt — rebuilt with live session context on every request ────────
|
||||
function buildSystemPrompt({ keyInfo, currentChord, bpm, chordHistory }) {
|
||||
const keyStr = keyInfo ? `${keyInfo.root} ${keyInfo.mode}` : 'not detected yet'
|
||||
const chordStr = currentChord?.name ?? 'none detected'
|
||||
const bpmStr = bpm ? `${Math.round(bpm)} BPM` : 'not detected'
|
||||
const histStr = chordHistory?.length
|
||||
? chordHistory.map(c => c.name).join(' → ')
|
||||
: 'none yet'
|
||||
|
||||
return `You are an expert music teacher and session musician embedded in JamBuddy, a real-time chord and key detection app for guitarists and keyboard players at live jam sessions.
|
||||
|
||||
LIVE SESSION CONTEXT (updated in real time):
|
||||
• Detected key: ${keyStr}
|
||||
• Current chord: ${chordStr}
|
||||
• BPM: ${bpmStr}
|
||||
• Recent chord history: ${histStr}
|
||||
|
||||
YOUR ROLE:
|
||||
- Explain chords, scales, and music theory in plain, friendly language
|
||||
- Suggest what to practice based on the current key and chord progression
|
||||
- Teach playing techniques: fretting, strumming patterns, chord voicings, fingerpicking
|
||||
- Help musicians understand WHY things sound the way they do
|
||||
- Suggest progressions that work with whatever the user is currently playing
|
||||
- Adjust depth to the user — explain basics if they seem new, go deep if they ask for it
|
||||
- Point out interesting connections: "that Dm7 works here because it's the ii chord in C major"
|
||||
|
||||
STYLE:
|
||||
- Keep responses focused and practical — this is a live jam, not a classroom
|
||||
- Use plain text, not markdown. Short paragraphs. Bullet points with "-" are fine.
|
||||
- If someone asks about the current chord or key, use the live context above
|
||||
- Max ~150 words unless someone asks for a deep dive`
|
||||
}
|
||||
|
||||
// ── Quick-action chips ────────────────────────────────────────────────────────
|
||||
const CHIPS = [
|
||||
{ label: 'What should I practice?', msg: 'Based on what I\'m playing right now, what\'s the most useful thing I could practice?' },
|
||||
{ label: 'Explain current chord', msg: 'Explain the current chord I\'m playing — what it is, why it sounds the way it does, and where it tends to appear.' },
|
||||
{ label: 'Scales that work here', msg: 'What scales work over the current key and chord? Which notes sound best to improvise with?' },
|
||||
{ label: 'Suggest a progression', msg: 'Suggest a chord progression that fits the current key. Give me something interesting to try.' },
|
||||
{ label: 'Technique tip', msg: 'Give me one technique tip — something I can work on in the next few minutes to sound better.' },
|
||||
{ label: 'Why does this sound good?', msg: 'Looking at my recent chord history, why do these chords sound good together? What\'s the music theory behind it?' },
|
||||
]
|
||||
|
||||
// ── Simple text renderer (bold + line breaks) ─────────────────────────────────
|
||||
function MessageText({ text }) {
|
||||
const lines = text.split('\n')
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{lines.map((line, i) => {
|
||||
if (!line.trim()) return <div key={i} className="h-1" />
|
||||
// Bold: **text**
|
||||
const parts = line.split(/(\*\*[^*]+\*\*)/)
|
||||
return (
|
||||
<p key={i} className="leading-relaxed">
|
||||
{parts.map((part, j) =>
|
||||
part.startsWith('**') && part.endsWith('**')
|
||||
? <strong key={j} className="text-white font-semibold">{part.slice(2, -2)}</strong>
|
||||
: part
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
export default function MusicTeacher({ keyInfo, currentChord, bpm, chordHistory }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [apiKey, setApiKey] = useState(() => localStorage.getItem(LS_KEY) ?? '')
|
||||
const [showKeyInput, setShowKeyInput] = useState(false)
|
||||
const [messages, setMessages] = useState([]) // [{role, content}]
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streaming, setStreaming] = useState('') // partial response being streamed
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const scrollRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
const abortRef = useRef(null)
|
||||
|
||||
// Always scroll to bottom on new content
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messages, streaming])
|
||||
|
||||
// Focus input when panel opens
|
||||
useEffect(() => {
|
||||
if (open && apiKey && inputRef.current) {
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open, apiKey])
|
||||
|
||||
function saveKey(k) {
|
||||
setApiKey(k)
|
||||
localStorage.setItem(LS_KEY, k)
|
||||
}
|
||||
|
||||
function clearKey() {
|
||||
setApiKey('')
|
||||
localStorage.removeItem(LS_KEY)
|
||||
setShowKeyInput(true)
|
||||
}
|
||||
|
||||
const sendMessage = useCallback(async (userText) => {
|
||||
if (!userText.trim() || loading || !apiKey) return
|
||||
|
||||
setError(null)
|
||||
const userMsg = { role: 'user', content: userText.trim() }
|
||||
const nextMessages = [...messages, userMsg]
|
||||
setMessages(nextMessages)
|
||||
setInput('')
|
||||
setLoading(true)
|
||||
setStreaming('')
|
||||
|
||||
const context = { keyInfo, currentChord, bpm, chordHistory }
|
||||
|
||||
try {
|
||||
const ctrl = new AbortController()
|
||||
abortRef.current = ctrl
|
||||
|
||||
const res = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
signal: ctrl.signal,
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
max_tokens: 1024,
|
||||
stream: true,
|
||||
system: buildSystemPrompt(context),
|
||||
messages: nextMessages,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body?.error?.message ?? `API error ${res.status}`)
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let full = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const data = line.slice(6).trim()
|
||||
if (data === '[DONE]' || !data) continue
|
||||
try {
|
||||
const ev = JSON.parse(data)
|
||||
if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
|
||||
full += ev.delta.text
|
||||
setStreaming(full)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: full }])
|
||||
setStreaming('')
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
setError(err.message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
abortRef.current = null
|
||||
}
|
||||
}, [messages, loading, apiKey, keyInfo, currentChord, bpm, chordHistory])
|
||||
|
||||
function stopGeneration() {
|
||||
abortRef.current?.abort()
|
||||
if (streaming) {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: streaming }])
|
||||
setStreaming('')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendMessage(input)
|
||||
}
|
||||
}
|
||||
|
||||
const hasKey = apiKey.trim().length > 0
|
||||
|
||||
// Dot: purple when API key set, gray otherwise
|
||||
const dotClass = hasKey ? 'bg-accent' : 'bg-gray-700'
|
||||
|
||||
return (
|
||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-400">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex items-center gap-2 hover:text-gray-200 transition-colors text-left"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||
<span>MUSIC TEACHER</span>
|
||||
<span className="text-[11px] text-gray-600">AI · Claude</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Key indicator */}
|
||||
<button
|
||||
onClick={() => setShowKeyInput(v => !v)}
|
||||
className="text-[10px] text-gray-600 hover:text-gray-400 transition-colors px-1.5 py-0.5 rounded border border-transparent hover:border-border"
|
||||
title={hasKey ? 'API key set — click to change' : 'Set API key'}
|
||||
>
|
||||
{hasKey ? '🔑 key set' : '🔑 add key'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-all"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
{open
|
||||
? <polyline points="2,8 6,4 10,8" />
|
||||
: <polyline points="2,4 6,8 10,4" />
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ───────────────────────────────────────────────────────────── */}
|
||||
{open && (
|
||||
<div className="border-t border-border">
|
||||
|
||||
{/* API key input (shown when no key or user wants to change) */}
|
||||
{(!hasKey || showKeyInput) && (
|
||||
<div className="px-4 py-3 bg-surface/50 border-b border-border">
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Enter your <a className="text-accent underline" href="https://console.anthropic.com/keys" target="_blank" rel="noreferrer">Anthropic API key</a> to enable the music teacher. Stored locally on your device only.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
className="flex-1 px-2.5 py-1.5 bg-surface border border-border rounded-lg text-xs text-gray-300 focus:outline-none focus:border-accent font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { saveKey(apiKey); setShowKeyInput(false) }}
|
||||
disabled={!apiKey.trim()}
|
||||
className="px-3 py-1.5 bg-accent text-white text-xs font-bold rounded-lg hover:bg-accent/80 transition-colors disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
{hasKey && (
|
||||
<button
|
||||
onClick={() => setShowKeyInput(false)}
|
||||
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasKey && (
|
||||
<>
|
||||
{/* Live session context strip */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border text-[10px] font-mono">
|
||||
<span className="text-gray-600 uppercase tracking-wider">Now:</span>
|
||||
{keyInfo ? (
|
||||
<span className="text-accent">{keyInfo.root} {keyInfo.mode}</span>
|
||||
) : (
|
||||
<span className="text-gray-700">no key</span>
|
||||
)}
|
||||
<span className="text-gray-800">·</span>
|
||||
{currentChord ? (
|
||||
<span className="text-white">{currentChord.name}</span>
|
||||
) : (
|
||||
<span className="text-gray-700">no chord</span>
|
||||
)}
|
||||
<span className="text-gray-800">·</span>
|
||||
<span className="text-gray-500">{bpm ? `${Math.round(bpm)} bpm` : '— bpm'}</span>
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={() => { setMessages([]); setError(null) }}
|
||||
className="ml-auto text-gray-700 hover:text-gray-400 transition-colors"
|
||||
title="Clear conversation"
|
||||
>
|
||||
clear chat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat messages */}
|
||||
{messages.length > 0 || streaming ? (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-72 overflow-y-auto px-4 py-3 space-y-3 text-xs"
|
||||
>
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={m.role === 'user' ? 'flex justify-end' : ''}>
|
||||
{m.role === 'user' ? (
|
||||
<div className="max-w-[80%] bg-accent/20 border border-accent/30 rounded-xl rounded-tr-sm px-3 py-2 text-gray-200">
|
||||
{m.content}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-300 leading-relaxed">
|
||||
<MessageText text={m.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{streaming && (
|
||||
<div className="text-gray-300 text-xs leading-relaxed">
|
||||
<MessageText text={streaming} />
|
||||
<span className="inline-block w-1.5 h-3.5 bg-accent/70 animate-pulse ml-0.5 align-middle" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-red-400 text-xs bg-red-950/30 border border-red-900/50 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Quick-action chips (shown when no chat history yet) */
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-[10px] text-gray-700 mb-2 uppercase tracking-wider">Ask something</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CHIPS.map(chip => (
|
||||
<button
|
||||
key={chip.label}
|
||||
onClick={() => sendMessage(chip.msg)}
|
||||
className="px-2.5 py-1 bg-surface border border-border rounded-full text-[10px] text-gray-400 hover:text-white hover:border-accent/50 hover:bg-accent/10 transition-all"
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input row */}
|
||||
<div className="px-4 py-3 border-t border-border flex gap-2 items-end">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything about music theory, technique, or what to play…"
|
||||
rows={1}
|
||||
className="flex-1 px-3 py-2 bg-surface border border-border rounded-xl text-xs text-gray-300 placeholder-gray-700 focus:outline-none focus:border-accent resize-none leading-relaxed"
|
||||
style={{ maxHeight: '80px', overflowY: 'auto' }}
|
||||
/>
|
||||
{loading ? (
|
||||
<button
|
||||
onClick={stopGeneration}
|
||||
className="px-3 py-2 bg-surface border border-border text-gray-500 hover:text-red-400 hover:border-red-800 text-xs rounded-xl transition-colors shrink-0"
|
||||
title="Stop"
|
||||
>
|
||||
⏹
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={!input.trim()}
|
||||
className="px-3 py-2 bg-accent text-white text-xs font-bold rounded-xl hover:bg-accent/80 transition-colors disabled:opacity-40 shrink-0"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick chips after first message */}
|
||||
{messages.length > 0 && (
|
||||
<div className="px-4 pb-3 flex flex-wrap gap-1.5">
|
||||
{CHIPS.slice(0, 4).map(chip => (
|
||||
<button
|
||||
key={chip.label}
|
||||
onClick={() => sendMessage(chip.msg)}
|
||||
disabled={loading}
|
||||
className="px-2 py-0.5 bg-surface border border-border rounded-full text-[9px] text-gray-600 hover:text-gray-300 hover:border-accent/30 transition-all disabled:opacity-30"
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { getPentatonicScale, getFullScale, getChordTones, NOTES } from '../lib/theory'
|
||||
|
||||
// White keys in order within an octave, mapped to pitch class
|
||||
const WHITE_KEYS = [
|
||||
{ pc: 0, label: 'C' },
|
||||
{ pc: 2, label: 'D' },
|
||||
{ pc: 4, label: 'E' },
|
||||
{ pc: 5, label: 'F' },
|
||||
{ pc: 7, label: 'G' },
|
||||
{ pc: 9, label: 'A' },
|
||||
{ pc: 11, label: 'B' },
|
||||
]
|
||||
|
||||
// Black keys: position (in white-key units from left of octave) and pitch class
|
||||
const BLACK_KEYS = [
|
||||
{ pc: 1, offset: 0.7 }, // C#
|
||||
{ pc: 3, offset: 1.7 }, // D#
|
||||
{ pc: 6, offset: 3.7 }, // F#
|
||||
{ pc: 8, offset: 4.7 }, // G#
|
||||
{ pc: 10, offset: 5.7 }, // A#
|
||||
]
|
||||
|
||||
const OCTAVES = 2 // number of octaves shown
|
||||
const KEY_W = 40 // white key width
|
||||
const KEY_H = 130 // white key height
|
||||
const BLACK_W = 26 // black key width
|
||||
const BLACK_H = 82 // black key height
|
||||
const LABEL_Y = KEY_H - 10 // y of note label on white key
|
||||
const BLACK_LABEL_Y = BLACK_H - 8
|
||||
|
||||
function keyColor(isChordTone, isPenta, isScale, isBlack, mono = false) {
|
||||
if (isChordTone) return { fill: '#a855f7', text: '#fff' }
|
||||
if (isPenta) return mono ? { fill: '#c084fc', text: '#1e1b4b' } : { fill: '#f59e0b', text: '#000' }
|
||||
if (isScale) return mono ? { fill: '#e9d5ff', text: '#581c87' } : { fill: '#374151', text: '#d1d5db' }
|
||||
return isBlack
|
||||
? { fill: '#1f1f1f', text: '#6b7280' }
|
||||
: { fill: '#f5f5f5', text: '#6b7280' }
|
||||
}
|
||||
|
||||
export default function Piano({ keyInfo, currentChord, monoColor = false }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
if (!root) return null
|
||||
|
||||
const pentaSet = new Set(getPentatonicScale(root, mode).map(n => NOTES.indexOf(n)))
|
||||
const scaleSet = new Set(getFullScale(root, mode).map(n => NOTES.indexOf(n)))
|
||||
const chordSet = currentChord
|
||||
? new Set(getChordTones(currentChord).map(n => NOTES.indexOf(n)))
|
||||
: new Set()
|
||||
|
||||
const totalWhite = WHITE_KEYS.length * OCTAVES
|
||||
const svgW = totalWhite * KEY_W + 2
|
||||
const svgH = KEY_H + 20 // +20 for octave labels
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
||||
Piano — {root} {mode}
|
||||
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${svgW} ${svgH}`} width="100%" height="auto" style={{ display: 'block' }}>
|
||||
|
||||
{/* White keys */}
|
||||
{Array.from({ length: OCTAVES }, (_, oct) =>
|
||||
WHITE_KEYS.map((k, wi) => {
|
||||
const x = (oct * WHITE_KEYS.length + wi) * KEY_W + 1
|
||||
const isChordTone = chordSet.has(k.pc)
|
||||
const isPenta = pentaSet.has(k.pc)
|
||||
const isScale = scaleSet.has(k.pc)
|
||||
const { fill, text } = keyColor(isChordTone, isPenta, isScale, false, monoColor)
|
||||
return (
|
||||
<g key={`w-${oct}-${wi}`}>
|
||||
<rect
|
||||
x={x} y={0}
|
||||
width={KEY_W - 1} height={KEY_H}
|
||||
fill={fill}
|
||||
rx={3}
|
||||
stroke="#2a2a2a"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
{(isChordTone || isPenta || isScale) && (
|
||||
<text
|
||||
x={x + KEY_W / 2} y={LABEL_Y}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
fontWeight="600"
|
||||
fill={text}
|
||||
>
|
||||
{k.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Black keys (drawn on top) */}
|
||||
{Array.from({ length: OCTAVES }, (_, oct) =>
|
||||
BLACK_KEYS.map((k, bi) => {
|
||||
const x = (oct * WHITE_KEYS.length + k.offset) * KEY_W + 1
|
||||
const isChordTone = chordSet.has(k.pc)
|
||||
const isPenta = pentaSet.has(k.pc)
|
||||
const isScale = scaleSet.has(k.pc)
|
||||
const { fill, text } = keyColor(isChordTone, isPenta, isScale, true, monoColor)
|
||||
return (
|
||||
<g key={`b-${oct}-${bi}`}>
|
||||
<rect
|
||||
x={x} y={0}
|
||||
width={BLACK_W} height={BLACK_H}
|
||||
fill={fill}
|
||||
rx={2}
|
||||
stroke="#111"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
{(isChordTone || isPenta || isScale) && (
|
||||
<text
|
||||
x={x + BLACK_W / 2} y={BLACK_LABEL_Y}
|
||||
textAnchor="middle"
|
||||
fontSize={8}
|
||||
fontWeight="600"
|
||||
fill={text}
|
||||
>
|
||||
{NOTES[k.pc]}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-5 text-xs text-gray-500">
|
||||
<span><span className="text-accent">●</span> Chord tone</span>
|
||||
<span><span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}>●</span> Pentatonic</span>
|
||||
<span><span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}>●</span> Scale</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { toRomanNumeral } from '../lib/theory'
|
||||
|
||||
const HISTORY_SHOWN = 8 // ~2 bars at 4 chords/bar
|
||||
const HISTORY_SHOWN = 8
|
||||
|
||||
function findLoopPosition(chordHistory, progression) {
|
||||
if (!progression?.length || !chordHistory.length) return -1
|
||||
@@ -17,67 +17,77 @@ function findLoopPosition(chordHistory, progression) {
|
||||
return progression.indexOf(last)
|
||||
}
|
||||
|
||||
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, currentChord, onChordClick }) {
|
||||
const { root, mode, confidence } = keyInfo ?? {}
|
||||
|
||||
// Newest chord is the last entry; we show the most recent HISTORY_SHOWN
|
||||
const visible = chordHistory.slice(-HISTORY_SHOWN)
|
||||
const current = visible[visible.length - 1]
|
||||
const loopPos = findLoopPosition(chordHistory, detectedProgression)
|
||||
const currentRN = root && current ? toRomanNumeral(current, root, mode) : ''
|
||||
|
||||
// Animate the current chord slot when it changes
|
||||
const currentRef = useRef(null)
|
||||
const prevChord = useRef(null)
|
||||
useEffect(() => {
|
||||
if (current && current !== prevChord.current && currentRef.current) {
|
||||
currentRef.current.animate(
|
||||
[{ opacity: 0, transform: 'scale(0.85)' },
|
||||
{ opacity: 1, transform: 'scale(1)' }],
|
||||
[{ opacity: 0, transform: 'scale(0.85)' }, { opacity: 1, transform: 'scale(1)' }],
|
||||
{ duration: 200, easing: 'ease-out', fill: 'forwards' }
|
||||
)
|
||||
prevChord.current = current
|
||||
}
|
||||
}, [current])
|
||||
|
||||
const loopPos = findLoopPosition(chordHistory, detectedProgression)
|
||||
|
||||
if (!chordHistory.length) {
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-5 mb-4 flex items-center justify-center h-28">
|
||||
<p className="text-gray-600">Start listening to detect chords…</p>
|
||||
<div className="bg-panel border border-border rounded-2xl p-4 mb-3 flex gap-4">
|
||||
|
||||
{/* ── Left: key + chord history + loop ── */}
|
||||
<div className="w-full lg:w-[70%] min-w-0 flex flex-col gap-2">
|
||||
|
||||
{/* Key + history on one row */}
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="shrink-0 flex items-baseline gap-1.5">
|
||||
{root ? (
|
||||
<>
|
||||
<span className="text-2xl font-bold text-accent">{root}</span>
|
||||
<span className="text-gray-400 text-sm">{mode}</span>
|
||||
{confidence && (
|
||||
<span className="text-xs text-gray-600">{Math.round(confidence * 100)}%</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-600 text-sm">Detecting key…</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-5 mb-4">
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* ── Chord history strip: all HISTORY_SHOWN chords at consistent size ── */}
|
||||
<div className="flex items-stretch gap-1 overflow-x-auto pb-1">
|
||||
{!chordHistory.length ? (
|
||||
<p className="text-gray-600 text-sm">Start listening…</p>
|
||||
) : (
|
||||
<div className="flex items-end gap-1 overflow-x-auto pb-1">
|
||||
{visible.map((chord, i) => {
|
||||
const isCurrent = i === visible.length - 1
|
||||
const age = visible.length - 1 - i // 0 = current, higher = older
|
||||
const opacity = Math.max(0.2, 1 - age * 0.1) // fade but stay readable
|
||||
const age = visible.length - 1 - i
|
||||
const opacity = Math.max(0.25, 1 - age * 0.09)
|
||||
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
ref={isCurrent ? currentRef : null}
|
||||
style={{ opacity }}
|
||||
className={`
|
||||
flex flex-col items-center justify-end shrink-0 px-3 py-2 rounded-xl
|
||||
transition-colors duration-200
|
||||
${isCurrent
|
||||
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20'
|
||||
: 'border border-transparent'}
|
||||
`}
|
||||
onClick={() => onChordClick?.(chord)}
|
||||
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 cursor-pointer ${
|
||||
isCurrent
|
||||
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20 hover:bg-accent/20'
|
||||
: 'border border-transparent hover:border-border hover:bg-panel'
|
||||
}`}
|
||||
>
|
||||
<span className={`font-black leading-none tracking-tight ${
|
||||
isCurrent ? 'text-5xl text-accent' : 'text-3xl text-gray-200'
|
||||
isCurrent ? 'text-3xl text-accent' : 'text-xl text-gray-200'
|
||||
}`}>
|
||||
{chord}
|
||||
</span>
|
||||
<span className={`text-xs font-semibold mt-1 ${
|
||||
<span className={`text-xs font-semibold mt-0.5 ${
|
||||
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{rn || '\u00A0'}
|
||||
@@ -86,37 +96,59 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Detected loop ── */}
|
||||
{/* Loop */}
|
||||
{detectedProgression && (
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest mb-2">♻ Detected loop</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs text-gray-500">♻</span>
|
||||
{detectedProgression.map((chord, i) => {
|
||||
const isActive = i === loopPos
|
||||
const rn = root ? toRomanNumeral(chord, root, mode) : chord
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex flex-col items-center px-4 py-2 rounded-xl border transition-all duration-200 ${
|
||||
onClick={() => onChordClick?.(chord)}
|
||||
className={`flex flex-col items-center px-2 py-0.5 rounded-lg border transition-all duration-200 cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-accent/20 border-accent shadow-[0_0_14px_rgba(168,85,247,0.35)]'
|
||||
: 'bg-border border-border'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-2xl font-bold leading-none ${isActive ? 'text-accent' : 'text-gray-200'}`}>
|
||||
<span className={`text-sm font-bold leading-none ${isActive ? 'text-accent' : 'text-gray-300'}`}>
|
||||
{chord}
|
||||
</span>
|
||||
<span className={`text-xs mt-1 font-semibold ${isActive ? 'text-amber-400' : 'text-gray-500'}`}>
|
||||
{rn}
|
||||
</span>
|
||||
<span className={`text-xs ${isActive ? 'text-amber-400' : 'text-gray-600'}`}>{rn}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<span className="self-center text-gray-600 text-sm pl-1">→ loop</span>
|
||||
</div>
|
||||
<span className="text-gray-600 text-xs">→ loop</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Divider ── */}
|
||||
<div className="hidden lg:block w-px bg-border shrink-0" />
|
||||
|
||||
{/* ── 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-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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,186 @@
|
||||
import { getSuggestedProgressions } from '../lib/theory'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getSuggestedProgressions, getChordsInKey, toRomanNumeral, NOTES, NOTES_FLAT } from '../lib/theory'
|
||||
|
||||
export default function ProgressionSuggestions({ keyInfo }) {
|
||||
// ─── Mood map ─────────────────────────────────────────────────────────────────
|
||||
const MOOD = {
|
||||
'I': 'Resolved', 'i': 'Settled',
|
||||
'II': 'Lifted', 'ii': 'Yearning',
|
||||
'III': 'Hopeful', 'iii': 'Tender',
|
||||
'IV': 'Uplifting', 'iv': 'Longing',
|
||||
'V': 'Tense', 'v': 'Unsettled',
|
||||
'VI': 'Bright', 'vi': 'Melancholic',
|
||||
'VII': 'Driving', 'vii': 'Uneasy',
|
||||
'♭VII': 'Bluesy', 'bVII': 'Bluesy',
|
||||
'♭VI': 'Dramatic', 'bVI': 'Dramatic',
|
||||
'♭III': 'Epic', '♭II': 'Mysterious',
|
||||
}
|
||||
|
||||
function getMood(rn) {
|
||||
return MOOD[rn] ?? MOOD[rn?.replace(/[0-9]/g, '')] ?? 'Adventurous'
|
||||
}
|
||||
|
||||
// ─── Genre accent colors ──────────────────────────────────────────────────────
|
||||
const GENRE_COLOR = {
|
||||
'Pop': 'text-pink-400',
|
||||
'Blues': 'text-blue-400',
|
||||
'Folk': 'text-green-400',
|
||||
'Jazz': 'text-yellow-400',
|
||||
'Rock': 'text-red-400',
|
||||
"'50s": 'text-orange-400',
|
||||
'Flamenco': 'text-rose-400',
|
||||
'Circle ↑': 'text-cyan-400',
|
||||
'Circle ↓': 'text-teal-400',
|
||||
'Relative': 'text-violet-400',
|
||||
'Thirds': 'text-indigo-400',
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function noteIndex(note) {
|
||||
const i = NOTES.indexOf(note)
|
||||
return i >= 0 ? i : NOTES_FLAT.indexOf(note)
|
||||
}
|
||||
|
||||
function chordRoot(chord) {
|
||||
const m = chord.match(/^([A-G][b#]?)/)
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
// ─── Circle-of-fifths padding ─────────────────────────────────────────────────
|
||||
// Moves tried in order to fill up to 6 total suggestions:
|
||||
// +7 perfect 5th up (dominant direction — most common resolution)
|
||||
// +5 perfect 4th up (subdominant direction)
|
||||
// +9 major/minor 6th (relative minor/major feel)
|
||||
// +3 minor 3rd up (mediant movement, dark → light)
|
||||
const COF_MOVES = [
|
||||
{ semitones: 7, genre: 'Circle ↑' },
|
||||
{ semitones: 5, genre: 'Circle ↓' },
|
||||
{ semitones: 9, genre: 'Relative' },
|
||||
{ semitones: 3, genre: 'Thirds' },
|
||||
]
|
||||
|
||||
function cofSuggestions(currentChord, root, mode, exclude) {
|
||||
const rootPc = noteIndex(chordRoot(currentChord) ?? '')
|
||||
if (rootPc < 0) return []
|
||||
|
||||
const diatonic = getChordsInKey(root, mode)
|
||||
const results = []
|
||||
|
||||
for (const { semitones, genre } of COF_MOVES) {
|
||||
const targetPc = ((rootPc + semitones) % 12 + 12) % 12
|
||||
const match = diatonic.find(c => {
|
||||
const r = chordRoot(c)
|
||||
return r !== null && noteIndex(r) === targetPc
|
||||
})
|
||||
if (!match || exclude.has(match)) continue
|
||||
const rn = toRomanNumeral(match, root, mode)
|
||||
results.push({ genre, chord: match, rn, mood: getMood(rn) })
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// ─── Main suggestion builder ──────────────────────────────────────────────────
|
||||
function buildSuggestions(currentChord, root, mode) {
|
||||
if (!currentChord || !root) return []
|
||||
|
||||
const genreSuggestions = getSuggestedProgressions(root, mode)
|
||||
.map(prog => {
|
||||
const idx = prog.chords.indexOf(currentChord)
|
||||
if (idx < 0) return null
|
||||
const next = (n) => prog.chords[(idx + n) % prog.chords.length]
|
||||
const rnAt = (n) => prog.rn[(idx + n) % prog.chords.length]
|
||||
const c1 = next(1), c2 = next(2), c3 = next(3)
|
||||
const chord2 = c2 !== c1 ? c2 : null
|
||||
const chord3 = chord2 && c3 !== c2 && c3 !== c1 ? c3 : null
|
||||
return {
|
||||
genre: prog.genre,
|
||||
chord: c1, rn: rnAt(1), mood: getMood(rnAt(1)),
|
||||
chord2, rn2: chord2 ? rnAt(2) : null, mood2: chord2 ? getMood(rnAt(2)) : null,
|
||||
chord3, rn3: chord3 ? rnAt(3) : null, mood3: chord3 ? getMood(rnAt(3)) : null,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (genreSuggestions.length >= 4) return genreSuggestions.slice(0, 5)
|
||||
|
||||
// Pad with circle-of-fifths moves not already covered
|
||||
const used = new Set(genreSuggestions.map(s => s.chord))
|
||||
const padded = cofSuggestions(currentChord, root, mode, used)
|
||||
|
||||
return [...genreSuggestions, ...padded].slice(0, 5)
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
export default function ProgressionSuggestions({ keyInfo, currentChord }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
const [suggestions, setSuggestions] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
setSuggestions(buildSuggestions(currentChord, root, mode))
|
||||
}, [currentChord, root, mode])
|
||||
|
||||
if (!root) return null
|
||||
|
||||
const progressions = getSuggestedProgressions(root, mode)
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
|
||||
Progressions in {root} {mode}
|
||||
<div className="bg-panel border border-border rounded-2xl p-3 flex flex-col h-full gap-1.5 overflow-hidden">
|
||||
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest shrink-0">
|
||||
Suggested Progression
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{progressions.map(prog => (
|
||||
<div key={prog.genre} className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 w-10 shrink-0">{prog.genre}</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{prog.chords.map((chord, i) => (
|
||||
<span key={i} className="px-3 py-1 bg-border rounded text-sm font-medium">
|
||||
{chord}
|
||||
<span className="ml-1 text-gray-600 text-xs">({prog.rn[i]})</span>
|
||||
|
||||
{!currentChord || suggestions.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-gray-600 text-sm text-center">
|
||||
{currentChord ? 'No suggestions' : 'Play a chord…'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col justify-start gap-1 overflow-y-auto min-h-0">
|
||||
{suggestions.map((s, i) => (
|
||||
<div
|
||||
key={`${s.genre}-${i}`}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-xl border border-border bg-surface/40 hover:border-gray-600 transition-colors duration-200"
|
||||
>
|
||||
<span className={`text-xs font-bold w-12 shrink-0 ${GENRE_COLOR[s.genre] ?? 'text-gray-400'}`}>
|
||||
{s.genre}
|
||||
</span>
|
||||
))}
|
||||
<div className="min-w-0">
|
||||
<div className="text-lg font-black text-white leading-none">{s.chord}</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className="text-xs font-semibold text-amber-400">{s.rn}</span>
|
||||
<span className="text-gray-700 text-xs">·</span>
|
||||
<span className="text-xs text-gray-500 truncate">{s.mood}</span>
|
||||
</div>
|
||||
</div>
|
||||
{s.chord2 && (
|
||||
<>
|
||||
<span className="text-gray-600 text-xs shrink-0">|</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-lg font-black text-white/70 leading-none">{s.chord2}</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className="text-xs font-semibold text-amber-400/70">{s.rn2}</span>
|
||||
<span className="text-gray-700 text-xs">·</span>
|
||||
<span className="text-xs text-gray-500 truncate">{s.mood2}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{s.chord3 && (
|
||||
<>
|
||||
<span className="text-gray-600 text-xs shrink-0">|</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-lg font-black text-white/50 leading-none">{s.chord3}</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className="text-xs font-semibold text-amber-400/50">{s.rn3}</span>
|
||||
<span className="text-gray-700 text-xs">·</span>
|
||||
<span className="text-xs text-gray-500 truncate">{s.mood3}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// ─── Mini fretboard scale diagram ─────────────────────────────────────────────
|
||||
// Shows a 6-string × 5-fret window of scale tones.
|
||||
// Root notes → purple fill. Scale tones → dark grey fill.
|
||||
// Strings: top = s6 (low E), bottom = s1 (high e).
|
||||
// Window starts at the root fret on string 6.
|
||||
|
||||
const OPEN_PITCHES = [4, 9, 2, 7, 11, 4] // E A D G B e (s6 … s1)
|
||||
const FRETS = 5
|
||||
|
||||
export default function RiffDiagram({ rootPc, scaleIntervals = [0, 3, 5, 7, 10] }) {
|
||||
if (rootPc === undefined || rootPc === null) return null
|
||||
|
||||
// Fret window starts where the root lands on s6 (low E)
|
||||
const startFret = (rootPc - OPEN_PITCHES[0] + 12) % 12
|
||||
|
||||
// Which pitch classes are in the scale?
|
||||
const scaleSet = new Set(scaleIntervals.map(i => (rootPc + i) % 12))
|
||||
|
||||
// Collect dots: { s (0=s6…5=s1), f (0-4 within window), isRoot }
|
||||
const dots = []
|
||||
for (let s = 0; s < 6; s++) {
|
||||
for (let f = 0; f < FRETS; f++) {
|
||||
const pc = (OPEN_PITCHES[s] + startFret + f) % 12
|
||||
if (scaleSet.has(pc)) {
|
||||
dots.push({ s, f, isRoot: pc === rootPc })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SVG layout
|
||||
const W = 152, H = 70
|
||||
const mL = 6, mT = 14, mR = 6, mB = 4
|
||||
const innerW = W - mL - mR // 140
|
||||
const innerH = H - mT - mB // 52
|
||||
|
||||
const cellW = innerW / FRETS // 28
|
||||
const strGap = innerH / 5 // gap between 6 strings (5 gaps)
|
||||
|
||||
const sx = (f) => mL + f * cellW // left edge of fret cell
|
||||
const cx = (f) => mL + (f + 0.5) * cellW // centre of fret cell
|
||||
const sy = (s) => mT + s * strGap // y of string s
|
||||
|
||||
return (
|
||||
<svg width={W} height={H} className="shrink-0 overflow-visible">
|
||||
{/* Fret separators (vertical lines) */}
|
||||
{Array.from({ length: FRETS + 1 }, (_, f) => (
|
||||
<line key={f}
|
||||
x1={sx(f)} y1={mT - 2}
|
||||
x2={sx(f)} y2={H - mB}
|
||||
stroke={f === 0 ? '#555' : '#2a2a2a'}
|
||||
strokeWidth={f === 0 ? 2 : 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* String lines (horizontal) */}
|
||||
{Array.from({ length: 6 }, (_, s) => (
|
||||
<line key={s}
|
||||
x1={mL} y1={sy(s)}
|
||||
x2={W - mR} y2={sy(s)}
|
||||
stroke="#3a3a3a"
|
||||
strokeWidth={s === 0 ? 1.5 : 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Fret numbers above */}
|
||||
{Array.from({ length: FRETS }, (_, f) => (
|
||||
<text key={f}
|
||||
x={cx(f)} y={9}
|
||||
textAnchor="middle" fontSize={8}
|
||||
fill={f === 0 && startFret > 0 ? '#a855f7' : '#555'}
|
||||
fontWeight={f === 0 && startFret > 0 ? 'bold' : 'normal'}>
|
||||
{startFret + f === 0 ? 'O' : startFret + f}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Scale dots */}
|
||||
{dots.map((d, i) => (
|
||||
<circle key={i}
|
||||
cx={cx(d.f)} cy={sy(d.s)}
|
||||
r={4.5}
|
||||
fill={d.isRoot ? '#a855f7' : '#3d3d3d'}
|
||||
stroke={d.isRoot ? '#c084fc' : '#606060'}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Root labels */}
|
||||
{dots.filter(d => d.isRoot).map((d, i) => (
|
||||
<text key={i}
|
||||
x={cx(d.f)} y={sy(d.s) + 3.5}
|
||||
textAnchor="middle" fontSize={6}
|
||||
fill="white" fontWeight="bold">
|
||||
R
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { getPentatonicScale, getFullScale, getChordTones } from '../lib/theory'
|
||||
|
||||
const ALL_NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||||
|
||||
export default function SafeNotes({ keyInfo, currentChord }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
|
||||
if (!root) return null
|
||||
|
||||
const penta = getPentatonicScale(root, mode)
|
||||
const full = getFullScale(root, mode)
|
||||
const chordTones = currentChord ? getChordTones(currentChord) : []
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6">
|
||||
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">Safe Notes</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{ALL_NOTES.map(note => {
|
||||
const isChordTone = chordTones.includes(note)
|
||||
const isPenta = penta.includes(note)
|
||||
const isScale = full.includes(note)
|
||||
|
||||
let cls = 'px-3 py-2 rounded-lg text-sm font-semibold border transition-all '
|
||||
if (isChordTone) {
|
||||
cls += 'bg-accent text-white border-accent scale-105'
|
||||
} else if (isPenta) {
|
||||
cls += 'bg-accent/20 text-accent border-accent/40'
|
||||
} else if (isScale) {
|
||||
cls += 'bg-border text-gray-300 border-border'
|
||||
} else {
|
||||
cls += 'bg-transparent text-gray-700 border-transparent'
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={note} className={cls}>{note}</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-4 text-xs text-gray-500">
|
||||
<span><span className="text-accent">■</span> Chord tone</span>
|
||||
<span><span className="text-accent/60">■</span> Pentatonic</span>
|
||||
<span><span className="text-gray-500">■</span> Scale</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
|
||||
const SETTINGS = [
|
||||
{
|
||||
section: 'Chord Detection',
|
||||
items: [
|
||||
{
|
||||
key: 'chromaSmooth',
|
||||
label: 'Chroma Smoothing',
|
||||
min: 1, max: 20, step: 1,
|
||||
desc: 'Frames to average for chord chroma. More = smoother but slower to react to chord changes.',
|
||||
},
|
||||
{
|
||||
key: 'chordVoteThreshold',
|
||||
label: 'Chord Vote Threshold',
|
||||
min: 1, max: 8, step: 1,
|
||||
desc: 'Consecutive identical detections required to confirm a chord. Higher = more stable, slower.',
|
||||
},
|
||||
{
|
||||
key: 'chordMinScore',
|
||||
label: 'Chord Min Score',
|
||||
min: 0.10, max: 0.80, step: 0.01,
|
||||
desc: 'Minimum coverage score to accept a chord match. Lower = more chord types detected (may add false positives).',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'Key Detection',
|
||||
items: [
|
||||
{
|
||||
key: 'noteHistorySize',
|
||||
label: 'Note History Size',
|
||||
min: 100, max: 12000, step: 100,
|
||||
desc: 'Pitch readings kept for key detection. ~2000 ≈ 1 min, 12000 ≈ whole session. Larger = more stable key.',
|
||||
},
|
||||
{
|
||||
key: 'keyVoteWindow',
|
||||
label: 'Key Vote Window',
|
||||
min: 4, max: 30, step: 1,
|
||||
desc: 'Rolling window of key votes. Larger = more inertia — key changes need sustained evidence.',
|
||||
},
|
||||
{
|
||||
key: 'keyVoteThreshold',
|
||||
label: 'Key Vote Threshold',
|
||||
min: 1, max: 30, step: 1,
|
||||
desc: 'Votes needed within the window to confirm a key. Higher = stricter consensus required.',
|
||||
},
|
||||
{
|
||||
key: 'chordNoteBoost',
|
||||
label: 'Chord Note Boost',
|
||||
min: 0, max: 10, step: 1,
|
||||
desc: 'Times confirmed chord tones are injected into note history. Higher = chords dominate over transient melody notes.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'Audio Input',
|
||||
items: [
|
||||
{
|
||||
key: 'minClarity',
|
||||
label: 'Min Pitch Clarity',
|
||||
min: 0.50, max: 0.99, step: 0.01,
|
||||
desc: 'Autocorrelation clarity threshold to accept a pitch reading. Higher = only clean, in-tune notes count.',
|
||||
},
|
||||
{
|
||||
key: 'minVolume',
|
||||
label: 'Min Volume (RMS)',
|
||||
min: 0.001, max: 0.05, step: 0.001,
|
||||
desc: 'Minimum signal level before processing. Increase to cut through room noise.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-surface z-50 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-accent">Detection Settings</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Tune chord and key recognition sensitivity in real time</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onReset}
|
||||
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"
|
||||
>
|
||||
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
|
||||
</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>
|
||||
</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">
|
||||
{section.section}
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
{section.items.map(item => (
|
||||
<div key={item.key}>
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<label className="text-sm font-semibold text-gray-200">{item.label}</label>
|
||||
<span className="text-sm font-mono text-accent w-20 text-right">
|
||||
{Number.isInteger(config[item.key])
|
||||
? config[item.key]
|
||||
: config[item.key].toFixed(item.step < 0.01 ? 3 : 2)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={item.min}
|
||||
max={item.max}
|
||||
step={item.step}
|
||||
value={config[item.key]}
|
||||
onChange={e => {
|
||||
const val = item.step < 1
|
||||
? parseFloat(e.target.value)
|
||||
: parseInt(e.target.value, 10)
|
||||
onChange(item.key, val)
|
||||
}}
|
||||
className="w-full accent-purple-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-700 mt-0.5">
|
||||
<span>{item.min}</span>
|
||||
<span className="text-gray-600 text-center flex-1 px-2">{item.desc}</span>
|
||||
<span>{item.max}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -108,10 +108,8 @@ export default function Tuner() {
|
||||
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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'}`}
|
||||
@@ -119,7 +117,6 @@ export default function Tuner() {
|
||||
{isListening ? 'Stop' : 'Start'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div className="w-full max-w-3xl">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.js";
|
||||
|
||||
body {
|
||||
background-color: #0f0f0f;
|
||||
|
||||
@@ -0,0 +1,873 @@
|
||||
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.' },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -57,31 +57,54 @@ export const CHORD_TYPES = {
|
||||
|
||||
// Chord types considered during real-time chroma matching
|
||||
const MATCH_CHORD_TYPES = [
|
||||
'maj', 'min', 'dom7', 'min7', 'dim', 'half_dim', 'aug', 'sus4', 'add9',
|
||||
'maj', 'min', 'dom7', 'maj7', 'min7', 'dim', 'half_dim', 'aug', 'sus4', 'sus2', 'add9',
|
||||
]
|
||||
|
||||
// Minimum score for a chord match to be reported
|
||||
const CHORD_MATCH_MIN_SCORE = 0.42
|
||||
// Minimum margin over second-best for a match to be considered unambiguous
|
||||
const CHORD_MATCH_MIN_MARGIN = 0.04
|
||||
const CHORD_MATCH_MIN_MARGIN = 0.07
|
||||
|
||||
// Chord quality for each scale degree in major and minor
|
||||
// Chord quality for each scale degree, per mode
|
||||
const DEGREE_QUALITIES = {
|
||||
major: ['', 'm', 'm', '', '', 'm', 'dim'],
|
||||
minor: ['m', 'dim', '', 'm', 'm', '', '' ],
|
||||
dorian: ['m', 'm', '', '', 'm', 'dim', '' ],
|
||||
phrygian: ['m', '', '', 'm', 'dim','', 'm' ],
|
||||
lydian: ['', '', 'm', 'dim', '', 'm', 'm' ],
|
||||
mixolydian: ['', 'm', 'dim', '', 'm', 'm', '' ],
|
||||
}
|
||||
|
||||
const ROMAN_NUMERALS = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII']
|
||||
|
||||
// Common chord progressions by genre, expressed as semitone offsets from the root
|
||||
const PROGRESSIONS = {
|
||||
// Pop
|
||||
pop: { name: 'Pop', rn: ['I', 'V', 'vi', 'IV'], degrees: [0, 7, 9, 5] },
|
||||
pop2: { name: 'Pop', rn: ['I', 'IV', 'vi', 'V'], degrees: [0, 5, 9, 7] },
|
||||
pop3: { name: 'Pop', rn: ['I', 'vi', 'ii', 'V'], degrees: [0, 9, 2, 7] },
|
||||
// Blues
|
||||
blues: { name: 'Blues', rn: ['I', 'IV', 'V'], degrees: [0, 5, 7] },
|
||||
blues2: { name: 'Blues', rn: ['I', 'IV', 'V', 'IV'], degrees: [0, 5, 7, 5] },
|
||||
blues3: { name: 'Blues', rn: ['I', 'I', 'IV', 'V'], degrees: [0, 0, 5, 7] },
|
||||
// Folk
|
||||
folk: { name: 'Folk', rn: ['I', 'IV', 'I', 'V'], degrees: [0, 5, 0, 7] },
|
||||
folk2: { name: 'Folk', rn: ['I', 'V', 'IV', 'I'], degrees: [0, 7, 5, 0] },
|
||||
folk3: { name: 'Folk', rn: ['I', 'ii', 'IV', 'V'], degrees: [0, 2, 5, 7] },
|
||||
// Jazz
|
||||
jazz: { name: 'Jazz', rn: ['ii', 'V', 'I'], degrees: [2, 7, 0] },
|
||||
jazz2: { name: 'Jazz', rn: ['I', 'vi', 'ii', 'V'], degrees: [0, 9, 2, 7] },
|
||||
jazz3: { name: 'Jazz', rn: ['iii', 'vi', 'ii', 'V'], degrees: [4, 9, 2, 7] },
|
||||
// Rock
|
||||
rock: { name: 'Rock', rn: ['I', 'bVII', 'IV', 'I'], degrees: [0, 10, 5, 0] },
|
||||
rock2: { name: 'Rock', rn: ['I', 'IV', 'V', 'I'], degrees: [0, 5, 7, 0] },
|
||||
rock3: { name: 'Rock', rn: ['I', 'bVII', 'bVI', 'bVII'], degrees: [0, 10, 8, 10] },
|
||||
// '50s
|
||||
'50s': { name: "'50s", rn: ['I', 'vi', 'IV', 'V'], degrees: [0, 9, 5, 7] },
|
||||
'50s2': { name: "'50s", rn: ['I', 'V', 'vi', 'iii'], degrees: [0, 7, 9, 4] },
|
||||
// Flamenco
|
||||
flamen: { name: 'Flamenco', rn: ['i', 'bVII', 'bVI', 'V'], degrees: [0, 10, 8, 7] },
|
||||
flamen2: { name: 'Flamenco', rn: ['i', 'bVI', 'bVII', 'i'], degrees: [0, 8, 10, 0] },
|
||||
}
|
||||
|
||||
// ─── Internal helpers ────────────────────────────────────────────────────────
|
||||
@@ -115,6 +138,31 @@ function noteIndex(note) {
|
||||
|
||||
// ─── Key Detection ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* detectTopKeys(noteHistory, n) → top N key candidates sorted by confidence.
|
||||
* Each entry: { root, mode, confidence }
|
||||
*/
|
||||
export function detectTopKeys(noteHistory, n = 3) {
|
||||
if (!noteHistory || noteHistory.length < 8) return []
|
||||
|
||||
const freq = new Array(12).fill(0)
|
||||
for (const note of noteHistory) freq[((note % 12) + 12) % 12]++
|
||||
|
||||
const candidates = []
|
||||
for (let root = 0; root < 12; root++) {
|
||||
const rotated = Array.from({ length: 12 }, (_, i) => freq[(i + root) % 12])
|
||||
const scoreMaj = pearsonCorrelation(rotated, KS_MAJOR)
|
||||
const scoreMin = pearsonCorrelation(rotated, KS_MINOR)
|
||||
candidates.push({ root: noteName(root), mode: 'major', score: scoreMaj,
|
||||
confidence: Math.max(0, Math.min(1, (scoreMaj + 1) / 2)) })
|
||||
candidates.push({ root: noteName(root), mode: 'minor', score: scoreMin,
|
||||
confidence: Math.max(0, Math.min(1, (scoreMin + 1) / 2)) })
|
||||
}
|
||||
|
||||
return candidates.sort((a, b) => b.score - a.score).slice(0, n)
|
||||
.map(({ root, mode, confidence }) => ({ root, mode, confidence }))
|
||||
}
|
||||
|
||||
/**
|
||||
* detectKey(noteHistory) → { root, mode, confidence }
|
||||
* Uses Krumhansl-Schmuckler: correlates pitch-class histogram with key profiles.
|
||||
@@ -238,10 +286,13 @@ export function matchChordFromChroma(
|
||||
|
||||
const tones = new Set(type.intervals.map(i => (r + i) % 12))
|
||||
|
||||
// Skip if root has no meaningful energy — chord without its root is unreliable
|
||||
if (chroma[r] < 0.08) continue
|
||||
|
||||
let inEnergy = 0, outEnergy = 0
|
||||
for (let pc = 0; pc < 12; pc++) {
|
||||
if (pc === r) {
|
||||
inEnergy += chroma[pc] * 2 // root carries strongest identity signal
|
||||
inEnergy += chroma[pc] * 1.5 // root weight reduced: 2→1.5 (less root bias)
|
||||
} else if (tones.has(pc)) {
|
||||
inEnergy += chroma[pc]
|
||||
} else {
|
||||
@@ -251,7 +302,8 @@ export function matchChordFromChroma(
|
||||
|
||||
if (inEnergy + outEnergy < 0.05) continue
|
||||
|
||||
const coverageScore = inEnergy / (inEnergy + outEnergy * 0.5)
|
||||
// Stricter outEnergy penalty (0.7 vs 0.5) — wrong notes hurt more
|
||||
const coverageScore = inEnergy / (inEnergy + outEnergy * 0.7)
|
||||
const bassBonus = bassPC !== null && r === bassPC ? 0.15 : 0
|
||||
const diatonicBonus = diatonicSet.has(chordName) ? 0.15 : 0
|
||||
|
||||
@@ -308,40 +360,131 @@ 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
|
||||
* Returns the most-recently-completed repeating pattern (length 2–6).
|
||||
* Uses non-overlapping match counting to avoid over-counting.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function detectRepeatingProgression(history) {
|
||||
if (!history || history.length < 4) return null
|
||||
if (!history || history.length < 6) return null
|
||||
|
||||
const window = history.slice(-20)
|
||||
const win = history.slice(-32)
|
||||
let best = null, bestScore = 0
|
||||
|
||||
for (let len = 2; len <= 6; len++) {
|
||||
if (len * 2 > window.length) break
|
||||
if (len * 2 > win.length) break
|
||||
|
||||
const seen = new Set()
|
||||
|
||||
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
|
||||
|
||||
const candidate = window.slice(-len)
|
||||
let reps = 0, i = 0
|
||||
|
||||
while (i <= window.length - len) {
|
||||
if (candidate.every((c, j) => c === window[i + j])) {
|
||||
reps++
|
||||
i += len // skip past match — non-overlapping
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
while (i <= win.length - len) {
|
||||
if (candidate.every((c, j) => c === win[i + j])) { reps++; i += len }
|
||||
else i++
|
||||
}
|
||||
|
||||
const score = reps * len
|
||||
if (reps >= 2 && score > bestScore) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
return best ? canonicalize(best) : null
|
||||
}
|
||||
|
||||
// ─── Debug / analysis helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns top N chord candidates with full score breakdown for the given chroma.
|
||||
*/
|
||||
export function getChordCandidates(chroma, keyInfo, bassPC = null, topN = 8) {
|
||||
if (!keyInfo?.root || !chroma) return []
|
||||
const diatonicSet = new Set(getChordsInKey(keyInfo.root, keyInfo.mode))
|
||||
const candidates = []
|
||||
|
||||
for (let r = 0; r < 12; r++) {
|
||||
for (const typeKey of MATCH_CHORD_TYPES) {
|
||||
const type = CHORD_TYPES[typeKey]
|
||||
const chordName = noteName(r) + type.suffix
|
||||
const tones = new Set(type.intervals.map(i => (r + i) % 12))
|
||||
|
||||
let inEnergy = 0, outEnergy = 0
|
||||
for (let pc = 0; pc < 12; pc++) {
|
||||
if (pc === r) inEnergy += chroma[pc] * 2
|
||||
else if (tones.has(pc)) inEnergy += chroma[pc]
|
||||
else outEnergy += chroma[pc]
|
||||
}
|
||||
if (inEnergy + outEnergy < 0.05) continue
|
||||
|
||||
const coverage = inEnergy / (inEnergy + outEnergy * 0.5)
|
||||
const bassBonus = bassPC !== null && r === bassPC ? 0.15 : 0
|
||||
const diatBonus = diatonicSet.has(chordName) ? 0.15 : 0
|
||||
const score = coverage + bassBonus + diatBonus
|
||||
candidates.push({ name: chordName, score, coverage, bassBonus, diatBonus, diatonic: diatonicSet.has(chordName) })
|
||||
}
|
||||
}
|
||||
return candidates.sort((a, b) => b.score - a.score).slice(0, topN)
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyses note history: returns normalised pitch-class frequencies and
|
||||
* top K-S key candidates with correlation scores.
|
||||
*/
|
||||
export function getNoteHistoryAnalysis(noteHistory) {
|
||||
const freq = new Array(12).fill(0)
|
||||
if (!noteHistory?.length) return { freq, topKeys: [] }
|
||||
|
||||
for (const note of noteHistory) freq[((note % 12) + 12) % 12]++
|
||||
const total = noteHistory.length
|
||||
const normalized = freq.map(f => f / total)
|
||||
|
||||
const candidates = []
|
||||
for (let root = 0; root < 12; root++) {
|
||||
const rotated = Array.from({ length: 12 }, (_, i) => normalized[(i + root) % 12])
|
||||
candidates.push({ root: noteName(root), mode: 'major', score: pearsonCorrelation(rotated, KS_MAJOR) })
|
||||
candidates.push({ root: noteName(root), mode: 'minor', score: pearsonCorrelation(rotated, KS_MINOR) })
|
||||
}
|
||||
return {
|
||||
freq: normalized,
|
||||
total,
|
||||
topKeys: candidates.sort((a, b) => b.score - a.score).slice(0, 5),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Utilities ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,476 +0,0 @@
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const NOTES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
|
||||
export const NOTES_FLAT = ['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B']
|
||||
|
||||
// Semitone intervals for each scale mode
|
||||
export const SCALES = {
|
||||
major: [0, 2, 4, 5, 7, 9, 11],
|
||||
minor: [0, 2, 3, 5, 7, 8, 10],
|
||||
dorian: [0, 2, 3, 5, 7, 9, 10],
|
||||
phrygian: [0, 1, 3, 5, 7, 8, 10],
|
||||
lydian: [0, 2, 4, 6, 7, 9, 11],
|
||||
mixolydian: [0, 2, 4, 5, 7, 9, 10],
|
||||
pentatonic_major: [0, 2, 4, 7, 9],
|
||||
pentatonic_minor: [0, 3, 5, 7, 10],
|
||||
blues: [0, 3, 5, 6, 7, 10],
|
||||
diminished: [0, 2, 3, 5, 6, 8, 9, 11],
|
||||
whole_tone: [0, 2, 4, 6, 8, 10],
|
||||
}
|
||||
|
||||
// Human-readable scale labels
|
||||
export const SCALE_LABELS = {
|
||||
major: 'Major',
|
||||
minor: 'Natural Minor',
|
||||
dorian: 'Dorian',
|
||||
phrygian: 'Phrygian',
|
||||
lydian: 'Lydian',
|
||||
mixolydian: 'Mixolydian',
|
||||
pentatonic_major: 'Major Pentatonic',
|
||||
pentatonic_minor: 'Minor Pentatonic',
|
||||
blues: 'Blues',
|
||||
diminished: 'Diminished',
|
||||
whole_tone: 'Whole Tone',
|
||||
}
|
||||
|
||||
// Krumhansl-Schmuckler key profiles (only major/minor used for key detection)
|
||||
const KS_MAJOR = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]
|
||||
const KS_MINOR = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]
|
||||
|
||||
// Chord type definitions: intervals (semitones from root) and display suffix
|
||||
export const CHORD_TYPES = {
|
||||
maj: { intervals: [0, 4, 7], suffix: '' },
|
||||
min: { intervals: [0, 3, 7], suffix: 'm' },
|
||||
dom7: { intervals: [0, 4, 7, 10], suffix: '7' },
|
||||
maj7: { intervals: [0, 4, 7, 11], suffix: 'maj7' },
|
||||
min7: { intervals: [0, 3, 7, 10], suffix: 'm7' },
|
||||
dim: { intervals: [0, 3, 6], suffix: 'dim' },
|
||||
dim7: { intervals: [0, 3, 6, 9], suffix: 'dim7' },
|
||||
half_dim:{ intervals: [0, 3, 6, 10], suffix: 'm7b5' },
|
||||
aug: { intervals: [0, 4, 8], suffix: 'aug' },
|
||||
sus4: { intervals: [0, 5, 7], suffix: 'sus4' },
|
||||
sus2: { intervals: [0, 2, 7], suffix: 'sus2' },
|
||||
maj6: { intervals: [0, 4, 7, 9], suffix: '6' },
|
||||
min6: { intervals: [0, 3, 7, 9], suffix: 'm6' },
|
||||
add9: { intervals: [0, 2, 4, 7], suffix: 'add9' },
|
||||
}
|
||||
|
||||
// Chord types considered during real-time chroma matching
|
||||
const MATCH_CHORD_TYPES = [
|
||||
'maj', 'min', 'dom7', 'min7', 'dim', 'half_dim', 'aug', 'sus4', 'add9',
|
||||
]
|
||||
|
||||
// Minimum score for a chord match to be reported
|
||||
const CHORD_MATCH_MIN_SCORE = 0.42
|
||||
|
||||
// Minimum margin over second-best for a match to be considered unambiguous
|
||||
const CHORD_MATCH_MIN_MARGIN = 0.08
|
||||
|
||||
// Chord quality arrays for each scale degree in major and minor
|
||||
const DEGREE_QUALITIES = {
|
||||
major: ['', 'm', 'm', '', '', 'm', 'dim'],
|
||||
minor: ['m', 'dim','', 'm', 'm', '', '' ],
|
||||
}
|
||||
|
||||
const ROMAN_NUMERALS = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII']
|
||||
|
||||
// Common chord progressions by genre, expressed as semitone offsets from the root
|
||||
const PROGRESSIONS = {
|
||||
pop: { name: 'Pop', rn: ['I', 'V', 'vi', 'IV'], degrees: [0, 7, 9, 5] },
|
||||
blues: { name: 'Blues', rn: ['I', 'IV', 'V'], degrees: [0, 5, 7] },
|
||||
folk: { name: 'Folk', rn: ['I', 'IV', 'I', 'V'], degrees: [0, 5, 0, 7] },
|
||||
jazz: { name: 'Jazz', rn: ['ii', 'V', 'I'], degrees: [2, 7, 0] },
|
||||
rock: { name: 'Rock', rn: ['I', 'bVII', 'IV', 'I'], degrees: [0, 10, 5, 0] },
|
||||
'50s': { name: "'50s", rn: ['I', 'vi', 'IV', 'V'], degrees: [0, 9, 5, 7] },
|
||||
flamen: { name: 'Flamenco',rn: ['i', 'bVII', 'bVI', 'V'],degrees:[0, 10, 8, 7] },
|
||||
}
|
||||
|
||||
// ─── Internal helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the note name for a given semitone value (0–11, wraps automatically).
|
||||
* @param {number} semitone
|
||||
* @param {boolean} [preferFlat=false]
|
||||
* @returns {string}
|
||||
*/
|
||||
function noteName(semitone, preferFlat = false) {
|
||||
const pc = ((semitone % 12) + 12) % 12
|
||||
return preferFlat ? NOTES_FLAT[pc] : NOTES[pc]
|
||||
}
|
||||
|
||||
/**
|
||||
* Pearson correlation between two equal-length numeric arrays.
|
||||
* Returns a value in [-1, 1]. A small epsilon avoids division by zero.
|
||||
* @param {number[]} a
|
||||
* @param {number[]} b
|
||||
* @returns {number}
|
||||
*/
|
||||
function pearsonCorrelation(a, b) {
|
||||
const n = a.length
|
||||
const meanA = a.reduce((s, v) => s + v, 0) / n
|
||||
const meanB = b.reduce((s, v) => s + v, 0) / n
|
||||
let num = 0, denA = 0, denB = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const da = a[i] - meanA
|
||||
const db = b[i] - meanB
|
||||
num += da * db
|
||||
denA += da * da
|
||||
denB += db * db
|
||||
}
|
||||
return num / Math.sqrt(denA * denB + 1e-10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the semitone offset (0–11) of a note name, or -1 if not found.
|
||||
* Accepts both sharp and flat spellings.
|
||||
* @param {string} note
|
||||
* @returns {number}
|
||||
*/
|
||||
function noteIndex(note) {
|
||||
const idx = NOTES.indexOf(note)
|
||||
if (idx !== -1) return idx
|
||||
return NOTES_FLAT.indexOf(note) // handles Db, Eb, etc.
|
||||
}
|
||||
|
||||
// ─── Key Detection ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detects the most likely musical key from a recent history of played notes.
|
||||
*
|
||||
* Uses the Krumhansl-Schmuckler algorithm: builds a pitch-class frequency
|
||||
* vector and correlates it against major and minor profiles for all 12 roots.
|
||||
*
|
||||
* @param {number[]} noteHistory MIDI note numbers or pitch-class integers (0–11)
|
||||
* @returns {{ root: string, mode: 'major'|'minor', confidence: number }}
|
||||
* confidence is normalised to [0, 1]; values below ~0.5 are unreliable.
|
||||
*/
|
||||
export function detectKey(noteHistory) {
|
||||
if (!noteHistory || noteHistory.length < 4) {
|
||||
return { root: 'C', mode: 'major', confidence: 0 }
|
||||
}
|
||||
|
||||
// Build pitch-class frequency vector
|
||||
const freq = new Array(12).fill(0)
|
||||
for (const note of noteHistory) {
|
||||
freq[((note % 12) + 12) % 12]++
|
||||
}
|
||||
|
||||
let best = { root: 0, mode: 'major', score: -Infinity }
|
||||
|
||||
for (let root = 0; root < 12; root++) {
|
||||
// Rotate the observed frequencies to align with the profile's C-root
|
||||
const rotated = Array.from({ length: 12 }, (_, i) => freq[(i + root) % 12])
|
||||
|
||||
const scoreMaj = pearsonCorrelation(rotated, KS_MAJOR)
|
||||
const scoreMin = pearsonCorrelation(rotated, KS_MINOR)
|
||||
|
||||
if (scoreMaj > best.score) best = { root, mode: 'major', score: scoreMaj }
|
||||
if (scoreMin > best.score) best = { root, mode: 'minor', score: scoreMin }
|
||||
}
|
||||
|
||||
// Map correlation [-1, 1] to a rough confidence in [0, 1]
|
||||
const confidence = Math.max(0, Math.min(1, (best.score + 1) / 2))
|
||||
|
||||
return { root: noteName(best.root), mode: best.mode, confidence }
|
||||
}
|
||||
|
||||
// ─── Scale helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns all note names in a given scale.
|
||||
* @param {string} root e.g. 'G', 'F#'
|
||||
* @param {keyof SCALES} mode
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getScale(root, mode) {
|
||||
const rootIdx = noteIndex(root)
|
||||
if (rootIdx === -1) return []
|
||||
const intervals = SCALES[mode] ?? SCALES.major
|
||||
return intervals.map(i => noteName(rootIdx + i))
|
||||
}
|
||||
|
||||
// Legacy aliases kept for backwards compatibility
|
||||
export const getFullScale = (root, mode) => getScale(root, mode)
|
||||
export const getPentatonicScale = (root, mode) =>
|
||||
getScale(root, mode === 'minor' ? 'pentatonic_minor' : 'pentatonic_major')
|
||||
|
||||
/**
|
||||
* Returns all scale modes that contain every note in `playedNotes`.
|
||||
* Useful for suggesting compatible scales from a detected chord or melody.
|
||||
* @param {string[]} playedNotes e.g. ['C', 'E', 'G']
|
||||
* @param {string} root
|
||||
* @returns {{ mode: string, label: string, notes: string[] }[]}
|
||||
*/
|
||||
export function getCompatibleScales(playedNotes, root) {
|
||||
const played = new Set(playedNotes)
|
||||
return Object.entries(SCALES)
|
||||
.map(([mode]) => ({ mode, label: SCALE_LABELS[mode] ?? mode, notes: getScale(root, mode) }))
|
||||
.filter(({ notes }) => [...played].every(n => notes.includes(n)))
|
||||
}
|
||||
|
||||
// ─── Chord helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parses a chord name and returns its component note names.
|
||||
* @param {string} chordName e.g. 'Am', 'Gmaj7', 'Fdim', 'Baug'
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getChordTones(chordName) {
|
||||
const match = chordName.match(/^([A-G][b#]?)(.*)$/)
|
||||
if (!match) return []
|
||||
const root = noteIndex(match[1])
|
||||
const suffix = match[2] ?? ''
|
||||
|
||||
const type = Object.values(CHORD_TYPES).find(t => t.suffix === suffix)
|
||||
?? CHORD_TYPES.maj // default to major triad
|
||||
|
||||
return type.intervals.map(i => noteName(root + i))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the diatonic chords (triads) for every scale degree.
|
||||
* @param {string} root
|
||||
* @param {'major'|'minor'} mode
|
||||
* @returns {string[]} e.g. ['C', 'Dm', 'Em', 'F', 'G', 'Am', 'Bdim']
|
||||
*/
|
||||
export function getChordsInKey(root, mode) {
|
||||
const rootIdx = noteIndex(root)
|
||||
if (rootIdx === -1) return []
|
||||
const scale = SCALES[mode] ?? SCALES.major
|
||||
const qualities = DEGREE_QUALITIES[mode] ?? DEGREE_QUALITIES.major
|
||||
|
||||
return scale.map((degree, i) => noteName(rootIdx + degree) + qualities[i])
|
||||
}
|
||||
|
||||
// ─── Progression suggestions ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns common chord progressions transposed to the given key.
|
||||
*
|
||||
* For non-diatonic degrees (e.g. bVII in rock), the quality falls back to a
|
||||
* major triad rather than silently producing a wrong chord name.
|
||||
*
|
||||
* @param {string} root
|
||||
* @param {'major'|'minor'} mode
|
||||
* @returns {{ genre: string, rn: string[], chords: string[] }[]}
|
||||
*/
|
||||
export function getSuggestedProgressions(root, mode) {
|
||||
const rootIdx = noteIndex(root)
|
||||
if (rootIdx === -1) return []
|
||||
const scale = SCALES[mode] ?? SCALES.major
|
||||
const qualities = DEGREE_QUALITIES[mode] ?? DEGREE_QUALITIES.major
|
||||
|
||||
return Object.values(PROGRESSIONS).map(prog => {
|
||||
const chords = prog.degrees.map(semitones => {
|
||||
const noteIdx = (rootIdx + semitones) % 12
|
||||
const name = noteName(noteIdx)
|
||||
const degreeIdx = scale.indexOf(semitones)
|
||||
// Diatonic degree → use proper quality; chromatic (e.g. bVII) → major triad
|
||||
const quality = degreeIdx >= 0 ? qualities[degreeIdx] : ''
|
||||
return name + quality
|
||||
})
|
||||
return { genre: prog.name, rn: prog.rn, chords }
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Chroma-based chord matching ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Matches the most likely chord from a chroma energy vector.
|
||||
*
|
||||
* The algorithm scores each candidate chord by comparing in-chord vs
|
||||
* out-of-chord energy, with bonuses for bass-note and diatonic alignment.
|
||||
* Returns null when no unambiguous winner is found (e.g. during a transition).
|
||||
*
|
||||
* @param {Float32Array|number[]} chroma 12-element pitch-class energy (0–1)
|
||||
* @param {{ root: string, mode: string }} keyInfo
|
||||
* @param {number|null} [bassPC=null] Pitch class of the detected bass note
|
||||
* @param {boolean} [strictDiatonic=false] Only consider diatonic chords
|
||||
* @param {number} [minScore] Override default minimum match score
|
||||
* @param {number} [minMargin] Override default ambiguity margin
|
||||
* @returns {string|null} Chord name, e.g. 'Am7', or null if ambiguous
|
||||
*/
|
||||
export function matchChordFromChroma(
|
||||
chroma,
|
||||
keyInfo,
|
||||
bassPC = null,
|
||||
strictDiatonic = false,
|
||||
minScore = CHORD_MATCH_MIN_SCORE,
|
||||
minMargin = CHORD_MATCH_MIN_MARGIN,
|
||||
) {
|
||||
if (!keyInfo?.root) return null
|
||||
|
||||
const diatonicSet = new Set(getChordsInKey(keyInfo.root, keyInfo.mode))
|
||||
|
||||
let best = { name: null, score: -Infinity }
|
||||
let secondScore = -Infinity
|
||||
|
||||
for (let r = 0; r < 12; r++) {
|
||||
for (const typeKey of MATCH_CHORD_TYPES) {
|
||||
const type = CHORD_TYPES[typeKey]
|
||||
const chordName = noteName(r) + type.suffix
|
||||
|
||||
if (strictDiatonic && !diatonicSet.has(chordName)) continue
|
||||
|
||||
const tones = new Set(type.intervals.map(i => (r + i) % 12))
|
||||
|
||||
let inEnergy = 0, outEnergy = 0
|
||||
for (let pc = 0; pc < 12; pc++) {
|
||||
if (pc === r) {
|
||||
// Root carries the strongest identity signal — double weight
|
||||
inEnergy += chroma[pc] * 2
|
||||
} else if (tones.has(pc)) {
|
||||
inEnergy += chroma[pc]
|
||||
} else {
|
||||
outEnergy += chroma[pc]
|
||||
}
|
||||
}
|
||||
|
||||
// Skip near-silence
|
||||
if (inEnergy + outEnergy < 0.05) continue
|
||||
|
||||
const coverageScore = inEnergy / (inEnergy + outEnergy * 0.5)
|
||||
const bassBonus = bassPC !== null && r === bassPC ? 0.15 : 0
|
||||
const diatonicBonus = diatonicSet.has(chordName) ? 0.10 : 0
|
||||
|
||||
const finalScore = coverageScore + bassBonus + diatonicBonus
|
||||
|
||||
if (finalScore > best.score) {
|
||||
secondScore = best.score
|
||||
best = { name: chordName, score: finalScore }
|
||||
} else if (finalScore > secondScore) {
|
||||
secondScore = finalScore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearWinner = best.score >= minScore && (best.score - secondScore) >= minMargin
|
||||
return clearWinner ? best.name : null
|
||||
}
|
||||
|
||||
// ─── Roman numeral notation ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Converts a chord name to its Roman numeral relative to a key.
|
||||
*
|
||||
* Non-diatonic (borrowed/chromatic) chords are returned with a flat prefix,
|
||||
* e.g. 'Bb' in C major → '♭VII'. Previously this always returned '♭I'.
|
||||
*
|
||||
* @param {string} chordName e.g. 'Am', 'G7'
|
||||
* @param {string} keyRoot e.g. 'C'
|
||||
* @param {string} keyMode e.g. 'major'
|
||||
* @returns {string}
|
||||
*/
|
||||
export function toRomanNumeral(chordName, keyRoot, keyMode) {
|
||||
if (!chordName || !keyRoot) return '?'
|
||||
|
||||
const match = chordName.match(/^([A-G][b#]?)(.*)$/)
|
||||
if (!match) return '?'
|
||||
const [, root, quality] = match
|
||||
|
||||
const chordRootIdx = noteIndex(root)
|
||||
const keyRootIdx = noteIndex(keyRoot)
|
||||
if (chordRootIdx < 0 || keyRootIdx < 0) return '?'
|
||||
|
||||
const semitones = ((chordRootIdx - keyRootIdx) + 12) % 12
|
||||
const scale = SCALES[keyMode] ?? SCALES.major
|
||||
const degreeIdx = scale.indexOf(semitones)
|
||||
|
||||
let rn
|
||||
if (degreeIdx >= 0) {
|
||||
rn = ROMAN_NUMERALS[degreeIdx]
|
||||
} else {
|
||||
// Chromatic chord: find the nearest diatonic degree above and flat it
|
||||
const nearestAbove = scale.findIndex(d => d > semitones)
|
||||
const refDegree = nearestAbove >= 0 ? nearestAbove : 0
|
||||
rn = '♭' + ROMAN_NUMERALS[refDegree]
|
||||
}
|
||||
|
||||
const isMinorQuality = /^m(?!aj)/.test(quality) || quality === 'dim' || quality === 'm7b5'
|
||||
return isMinorQuality ? rn.toLowerCase() : rn
|
||||
}
|
||||
|
||||
// ─── Repeating progression detection ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scans recent chord history for a repeating pattern of length 2–6.
|
||||
*
|
||||
* Returns the most recently completed pattern that appears at least twice
|
||||
* within the last 20 chords. Longer patterns that repeat are preferred over
|
||||
* shorter ones via a `repetitions × length` score.
|
||||
*
|
||||
* @param {string[]} history Ordered list of chord names
|
||||
* @returns {string[]|null} Detected repeating pattern, or null
|
||||
*/
|
||||
export function detectRepeatingProgression(history) {
|
||||
if (!history || history.length < 4) return null
|
||||
|
||||
const window = history.slice(-20)
|
||||
let best = null, bestScore = 0
|
||||
|
||||
for (let len = 2; len <= 6; len++) {
|
||||
if (len * 2 > window.length) break
|
||||
|
||||
const candidate = window.slice(-len)
|
||||
let reps = 0
|
||||
|
||||
// Count non-overlapping matches from left to right
|
||||
let i = 0
|
||||
while (i <= window.length - len) {
|
||||
const matches = candidate.every((c, j) => c === window[i + j])
|
||||
if (matches) {
|
||||
reps++
|
||||
i += len // skip past this match to avoid overlaps
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
const score = reps * len
|
||||
if (reps >= 2 && score > bestScore) {
|
||||
bestScore = score
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
// ─── Utility ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the interval name for a semitone distance (0–11).
|
||||
* @param {number} semitones
|
||||
* @returns {string}
|
||||
*/
|
||||
export function intervalName(semitones) {
|
||||
const names = [
|
||||
'Unison', 'Minor 2nd', 'Major 2nd', 'Minor 3rd', 'Major 3rd',
|
||||
'Perfect 4th', 'Tritone', 'Perfect 5th', 'Minor 6th',
|
||||
'Major 6th', 'Minor 7th', 'Major 7th',
|
||||
]
|
||||
return names[((semitones % 12) + 12) % 12] ?? 'Unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Transposes a chord name by a given number of semitones.
|
||||
* @param {string} chordName e.g. 'Am7'
|
||||
* @param {number} semitones Positive = up, negative = down
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transposeChord(chordName, semitones) {
|
||||
const match = chordName.match(/^([A-G][b#]?)(.*)$/)
|
||||
if (!match) return chordName
|
||||
const newRoot = noteName(noteIndex(match[1]) + semitones)
|
||||
return newRoot + match[2]
|
||||
}
|
||||
|
||||
/**
|
||||
* Transposes an entire progression by a given number of semitones.
|
||||
* @param {string[]} chords
|
||||
* @param {number} semitones
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function transposeProgression(chords, semitones) {
|
||||
return chords.map(c => transposeChord(c, semitones))
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
// ─── 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,7 +78,17 @@ export function useAudioTuner() {
|
||||
const startListening = async () => {
|
||||
if (isListening) return
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
// 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 ctx = new (window.AudioContext || window.webkitAudioContext)()
|
||||
const analyser = ctx.createAnalyser()
|
||||
analyser.fftSize = 4096
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,12 @@ import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react(), tailwindcss()],
|
||||
base: './', // relative paths so Electron can load files from disk
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
})
|
||||
|
||||