3 Commits

Author SHA1 Message Date
itsamejms 7cf92efe2f updating build config and main to make it easier to troubleshoot microphone permissions 2026-03-05 22:41:45 +00:00
itsamejms 84145de2cc trying to deal with the build error 2026-03-05 21:57:15 +00:00
itsamejms bb71abe827 removing backend, making electron app, adding tuner 2026-03-05 21:28:02 +00:00
37 changed files with 1142 additions and 2236 deletions
-41
View File
@@ -1,41 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run electron:build:win
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
WIN_CSC_LINK: ''
- uses: softprops/action-gh-release@v2
with:
files: releases/*.exe
build-mac:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run electron:build:mac
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
- uses: softprops/action-gh-release@v2
with:
files: releases/*.dmg
-11
View File
@@ -1,15 +1,4 @@
# Dependencies
node_modules/ node_modules/
frontend/node_modules/
# Build output
dist/ dist/
build/
# Env
.env
debug.log
releases/
release/ release/
.DS_Store .DS_Store
-74
View File
@@ -1,74 +0,0 @@
# 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 55220 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 26
**`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.
-98
View File
@@ -1,98 +0,0 @@
# 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/`.
## 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 804000 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

-55
View File
@@ -1,55 +0,0 @@
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',
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()
})
+65
View File
@@ -0,0 +1,65 @@
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();
});
+3 -6
View File
@@ -1,8 +1,5 @@
// Preload runs in a privileged context before the renderer. const { contextBridge } = require('electron');
// 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', { contextBridge.exposeInMainWorld('electronAPI', {
platform: process.platform, // Add IPC helpers here if needed later
}) });
+7
View File
@@ -3,6 +3,13 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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:;"> <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</title>
</head> </head>
+72 -114
View File
@@ -1,15 +1,14 @@
{ {
"name": "whattheflat", "name": "whattheflat",
"version": "0.6.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "whattheflat", "name": "whattheflat",
"version": "0.6.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"audiomotion-analyzer": "^4.5.4", "audiomotion-analyzer": "^4.5.4",
"essentia.js": "^0.1.3",
"pitchy": "^4.1.0", "pitchy": "^4.1.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
@@ -19,9 +18,11 @@
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.20",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"electron": "^40.7.0", "electron": "^40.7.0",
"electron-builder": "^26.8.1", "electron-builder": "26.8.1",
"postcss": "^8.4.47",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"vite": "^7.3.1", "vite": "^7.3.1",
"wait-on": "^9.0.4" "wait-on": "^9.0.4"
@@ -32,7 +33,6 @@
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-validator-identifier": "^7.28.5", "@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0", "js-tokens": "^4.0.0",
@@ -47,7 +47,6 @@
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -57,7 +56,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -88,7 +86,6 @@
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.0", "@babel/parser": "^7.29.0",
"@babel/types": "^7.29.0", "@babel/types": "^7.29.0",
@@ -105,7 +102,6 @@
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/compat-data": "^7.28.6", "@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1", "@babel/helper-validator-option": "^7.27.1",
@@ -122,7 +118,6 @@
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -132,7 +127,6 @@
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/traverse": "^7.28.6", "@babel/traverse": "^7.28.6",
"@babel/types": "^7.28.6" "@babel/types": "^7.28.6"
@@ -146,7 +140,6 @@
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-module-imports": "^7.28.6", "@babel/helper-module-imports": "^7.28.6",
"@babel/helper-validator-identifier": "^7.28.5", "@babel/helper-validator-identifier": "^7.28.5",
@@ -164,7 +157,6 @@
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", "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==", "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -174,7 +166,6 @@
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -184,7 +175,6 @@
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -194,7 +184,6 @@
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -204,7 +193,6 @@
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.28.6", "@babel/template": "^7.28.6",
"@babel/types": "^7.28.6" "@babel/types": "^7.28.6"
@@ -218,7 +206,6 @@
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.29.0" "@babel/types": "^7.29.0"
}, },
@@ -234,7 +221,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", "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==", "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -250,7 +236,6 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -266,7 +251,6 @@
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.28.6", "@babel/code-frame": "^7.28.6",
"@babel/parser": "^7.28.6", "@babel/parser": "^7.28.6",
@@ -281,7 +265,6 @@
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -300,7 +283,6 @@
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.27.1", "@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5" "@babel/helper-validator-identifier": "^7.28.5"
@@ -1393,7 +1375,6 @@
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24" "@jridgewell/trace-mapping": "^0.3.24"
@@ -1404,7 +1385,6 @@
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24" "@jridgewell/trace-mapping": "^0.3.24"
@@ -1415,7 +1395,6 @@
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
} }
@@ -1424,15 +1403,13 @@
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true, "dev": true
"license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31", "version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
@@ -1592,7 +1569,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -1606,7 +1582,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"android" "android"
@@ -1620,7 +1595,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -1634,7 +1608,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -1648,7 +1621,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -1662,7 +1634,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"freebsd" "freebsd"
@@ -1676,7 +1647,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1690,7 +1660,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1704,7 +1673,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1718,7 +1686,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1732,7 +1699,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1746,7 +1712,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1760,7 +1725,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1774,7 +1738,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1788,7 +1751,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1802,7 +1764,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1816,7 +1777,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1830,7 +1790,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1844,7 +1803,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"linux" "linux"
@@ -1858,7 +1816,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"openbsd" "openbsd"
@@ -1872,7 +1829,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"openharmony" "openharmony"
@@ -1886,7 +1842,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -1900,7 +1855,6 @@
"ia32" "ia32"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -1914,7 +1868,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -1928,7 +1881,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
@@ -2244,7 +2196,6 @@
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.20.7", "@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7", "@babel/types": "^7.20.7",
@@ -2258,7 +2209,6 @@
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.0.0" "@babel/types": "^7.0.0"
} }
@@ -2268,7 +2218,6 @@
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.1.0", "@babel/parser": "^7.1.0",
"@babel/types": "^7.0.0" "@babel/types": "^7.0.0"
@@ -2279,7 +2228,6 @@
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
@@ -2311,8 +2259,7 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true, "dev": true
"license": "MIT"
}, },
"node_modules/@types/fs-extra": { "node_modules/@types/fs-extra": {
"version": "9.0.13", "version": "9.0.13",
@@ -2776,6 +2723,42 @@
"url": "https://ko-fi.com/hvianna" "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": { "node_modules/axios": {
"version": "1.13.6", "version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
@@ -2824,7 +2807,6 @@
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.cjs" "baseline-browser-mapping": "dist/cli.cjs"
}, },
@@ -2885,7 +2867,6 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -3166,8 +3147,7 @@
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ]
"license": "CC-BY-4.0"
}, },
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
@@ -3403,8 +3383,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true, "dev": true
"license": "MIT"
}, },
"node_modules/core-util-is": { "node_modules/core-util-is": {
"version": "1.0.2", "version": "1.0.2",
@@ -3476,15 +3455,13 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "dev": true
"license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
}, },
@@ -3961,8 +3938,7 @@
"version": "1.5.307", "version": "1.5.307",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
"integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
"dev": true, "dev": true
"license": "ISC"
}, },
"node_modules/electron-winstaller": { "node_modules/electron-winstaller": {
"version": "5.4.0", "version": "5.4.0",
@@ -4165,7 +4141,6 @@
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -4184,15 +4159,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/essentia.js": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/essentia.js/-/essentia.js-0.1.3.tgz",
"integrity": "sha512-vVEPgeVMEBLRXbM5o5H5Rgu53EPHu25vyFKYg+flWLzI/nEoegJQez9FKRv8GR/KxIBwm+fXDEFL+MkQeoHaLw==",
"license": "AGPL-3.0",
"dependencies": {
"node-wav": "0.0.2"
}
},
"node_modules/exponential-backoff": { "node_modules/exponential-backoff": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -4277,8 +4243,7 @@
"node_modules/fft.js": { "node_modules/fft.js": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/fft.js/-/fft.js-4.0.4.tgz", "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": { "node_modules/filelist": {
"version": "1.0.6", "version": "1.0.6",
@@ -4388,6 +4353,19 @@
"node": ">= 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": { "node_modules/fs-extra": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@@ -4429,7 +4407,6 @@
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
@@ -4453,7 +4430,6 @@
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -5035,8 +5011,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true, "dev": true
"license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.1.1",
@@ -5056,7 +5031,6 @@
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true, "dev": true,
"license": "MIT",
"bin": { "bin": {
"jsesc": "bin/jsesc" "jsesc": "bin/jsesc"
}, },
@@ -5091,7 +5065,6 @@
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true, "dev": true,
"license": "MIT",
"bin": { "bin": {
"json5": "lib/cli.js" "json5": "lib/cli.js"
}, },
@@ -5426,7 +5399,6 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true, "dev": true,
"license": "ISC",
"dependencies": { "dependencies": {
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
@@ -5741,8 +5713,7 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true, "dev": true
"license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
@@ -5755,7 +5726,6 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"bin": { "bin": {
"nanoid": "bin/nanoid.cjs" "nanoid": "bin/nanoid.cjs"
}, },
@@ -5872,17 +5842,7 @@
"version": "2.0.36", "version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
"integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
"dev": true, "dev": true
"license": "MIT"
},
"node_modules/node-wav": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/node-wav/-/node-wav-0.0.2.tgz",
"integrity": "sha512-M6Rm/bbG6De/gKGxOpeOobx/dnGuP0dz40adqx38boqHhlWssBJZgLCPBNtb9NkrmnKYiV04xELq+R6PFOnoLA==",
"license": "MIT",
"engines": {
"node": ">=4.4.0"
}
}, },
"node_modules/nopt": { "node_modules/nopt": {
"version": "8.1.0", "version": "8.1.0",
@@ -6090,8 +6050,7 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true, "dev": true
"license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.3",
@@ -6110,7 +6069,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/pitchy/-/pitchy-4.1.0.tgz", "resolved": "https://registry.npmjs.org/pitchy/-/pitchy-4.1.0.tgz",
"integrity": "sha512-E8nQ8svBroGSMcc3qu2KvLHIuRYAIfrSkqDKWhjgj3WizseqWQXjQ+Q5t4g1CU73R5Euk+DAinyNFDK+KZw7zA==", "integrity": "sha512-E8nQ8svBroGSMcc3qu2KvLHIuRYAIfrSkqDKWhjgj3WizseqWQXjQ+Q5t4g1CU73R5Euk+DAinyNFDK+KZw7zA==",
"license": "MIT",
"dependencies": { "dependencies": {
"fft.js": "^4.0.4" "fft.js": "^4.0.4"
} }
@@ -6149,7 +6107,6 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -6159,6 +6116,12 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/postject": {
"version": "1.0.0-alpha.6", "version": "1.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
@@ -6446,7 +6409,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -6555,7 +6517,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true, "dev": true,
"license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
@@ -6726,7 +6687,6 @@
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true, "dev": true,
"license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -7142,7 +7102,6 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"escalade": "^3.2.0", "escalade": "^3.2.0",
"picocolors": "^1.1.1" "picocolors": "^1.1.1"
@@ -7383,8 +7342,7 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true, "dev": true
"license": "ISC"
}, },
"node_modules/yargs": { "node_modules/yargs": {
"version": "17.7.2", "version": "17.7.2",
+42 -33
View File
@@ -1,57 +1,39 @@
{ {
"name": "whattheflat", "name": "whattheflat",
"description": "A jam session companion app that provides a chromatic tuner, chord progressions, and more.", "version": "0.1.0",
"version": "0.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "electron/main.cjs", "main": "electron/main.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"electron:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5173 && electron .\"", "electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
"electron:build": "vite build && electron-builder", "electron:build": "vite build && electron-builder"
"electron:build:win": "vite build && electron-builder --win --publish never",
"electron:build:mac": "vite build && electron-builder --mac --publish never",
"electron:build:linux": "vite build && electron-builder --linux"
}, },
"dependencies": { "dependencies": {
"audiomotion-analyzer": "^4.5.4",
"essentia.js": "^0.1.3",
"pitchy": "^4.1.0", "pitchy": "^4.1.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"audiomotion-analyzer": "^4.5.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"concurrently": "^9.2.1", "autoprefixer": "^10.4.20",
"electron": "^40.7.0", "postcss": "^8.4.47",
"electron-builder": "^26.8.1",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"@tailwindcss/vite": "^4.2.1",
"vite": "^7.3.1", "vite": "^7.3.1",
"electron": "^40.7.0",
"electron-builder": "26.8.1",
"concurrently": "^9.2.1",
"wait-on": "^9.0.4" "wait-on": "^9.0.4"
}, },
"build": { "build": {
"appId": "com.whattheflat.app", "appId": "com.whattheflat.app",
"productName": "WhatTheFlat", "productName": "WhatTheFlat",
"files": [
"dist/**/*",
"electron/**/*",
"node_modules/**/*",
"package.json"
],
"directories": {
"buildResources": "dist",
"output": "release"
},
"win": {
"target": "nsis",
"icon": "assets/whattheflat-logo.png",
"forceCodeSigning": false
},
"mac": { "mac": {
"hardenedRuntime": true, "hardenedRuntime": true,
"entitlements": "electron/entitlements.mac.plist", "entitlements": "electron/entitlements.mac.plist",
@@ -60,13 +42,40 @@
"NSMicrophoneUsageDescription": "WhatTheFlat needs access to your microphone to detect pitch and provide the tuner." "NSMicrophoneUsageDescription": "WhatTheFlat needs access to your microphone to detect pitch and provide the tuner."
} }
}, },
"linux": { "win": {
"target": "AppImage", "target": [
"icon": "assets/whattheflat-logo.png" {
"target": "nsis",
"arch": [
"x64",
"ia32"
]
},
"zip"
]
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
"perMachine": false,
"allowElevation": true,
"allowToChangeInstallationDirectory": true "allowToChangeInstallationDirectory": true
},
"linux": {
"target": [
"AppImage",
"deb"
],
"category": "Audio"
},
"files": [
"dist/**/*",
"electron/**/*",
"node_modules/**/*",
"package.json"
],
"directories": {
"buildResources": "build",
"output": "release"
} }
} }
} }
+108 -405
View File
@@ -1,163 +1,72 @@
import { useState, useCallback, useRef, useEffect } from 'react' import { useState, useCallback, useRef, useEffect } from 'react'
import AudioCapture from './components/AudioCapture' import AudioCapture from './components/AudioCapture'
import ProgressionBanner from './components/ProgressionBanner' import ProgressionBanner from './components/ProgressionBanner'
import ProgressionSuggestions from './components/ProgressionSuggestions' import KeyDisplay from './components/KeyDisplay'
import ChordDisplay from './components/ChordDisplay'
import SafeNotes from './components/SafeNotes'
import Fretboard from './components/Fretboard' import Fretboard from './components/Fretboard'
import BassFretboard from './components/BassFretboard' import ProgressionSuggestions from './components/ProgressionSuggestions'
import ChatAssistant from './components/ChatAssistant'
import Tuner from './components/Tuner' import Tuner from './components/Tuner'
import Piano from './components/Piano' import { NOTES, detectKey, matchChordFromChroma, detectRepeatingProgression } from './lib/theory'
import Settings from './components/Settings'
import DebugView from './components/DebugView'
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
import settingIcon from './assets/setting-icon.png'
import viewIcon from './assets/view.png'
const DEFAULTS = { // Key detection tuning
// Key detection const NOTE_HISTORY_SIZE = 80
noteHistorySize: 2000, // ~60s of notes — stable across a song section const KEY_VOTE_WINDOW = 12
keyVoteWindow: 30, // rolling window of key votes const KEY_VOTE_THRESHOLD = 9 // out of 12 — very stable
keyVoteThreshold: 20, // 67% consensus — locks in after a few bars
chordNoteBoost: 3, // Chord detection tuning
// Chord detection const CHROMA_SMOOTH = 12 // frames to average (~200ms at 60fps)
chromaSmooth: 8, // 8 frames ≈ 130ms window, checks chord at ~7.5 Hz const CHORD_VOTE_THRESHOLD = 3 // consecutive identical detections required
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,
// Experimental
useEssentia: false,
}
export default function App() { export default function App() {
// ── Config ─────────────────────────────────────────────────────────────────── // ── Listening state ──────────────────────────────────────────────────────
const [config, setConfig] = useState(DEFAULTS)
const configRef = useRef(DEFAULTS)
useEffect(() => { configRef.current = config }, [config])
const [showSettings, setShowSettings] = useState(false)
function updateConfig(key, val) {
setConfig(prev => ({ ...prev, [key]: val }))
}
// ── Listening state ──────────────────────────────────────────────────────────
const [isListening, setIsListening] = useState(false) const [isListening, setIsListening] = useState(false)
// ── Instrument view + tuner ─────────────────────────────────────────────────── // ── App mode ─────────────────────────────────────────────────────────────
const [instrument, setInstrument] = useState('guitar') // 'guitar' | 'bass' | 'piano' const [appMode, setAppMode] = useState('beginner') // 'beginner' | 'advanced'
const [showTuner, setShowTuner] = useState(false)
const [showDebug, setShowDebug] = useState(false)
const [monoColor, setMonoColor] = useState(false)
// ── Mic permission error ────────────────────────────────────────────────────── // ── Key: auto-detected + optional lock ───────────────────────────────────
const [micError, setMicError] = useState(null)
// ── Debug data ────────────────────────────────────────────────────────────────
const [debugChroma, setDebugChroma] = useState(null)
const [debugCandidates, setDebugCandidates] = useState([])
const [debugNoteAnalysis, setDebugNoteAnalysis] = useState(null)
// ── Stable refs for values used inside callbacks ──────────────────────────────
const showDebugRef = useRef(showDebug)
const lockedKeyRef = useRef(null)
useEffect(() => { showDebugRef.current = showDebug }, [showDebug])
// ── BPM estimation from onset timestamps ─────────────────────────────────────
const [bpm, setBpm] = useState(null)
const onsetTimestampsRef = useRef([])
const bpmSmoothRef = useRef(null)
// ── Key: auto-detected + optional lock ───────────────────────────────────────
const [keyInfo, setKeyInfo] = useState(null) // auto-detected const [keyInfo, setKeyInfo] = useState(null) // auto-detected
const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null
useEffect(() => { lockedKeyRef.current = lockedKey }, [lockedKey])
const [lockRoot, setLockRoot] = useState('A') const [lockRoot, setLockRoot] = useState('A')
const [lockMode, setLockMode] = useState('minor') const [lockMode, setLockMode] = useState('minor')
// Effective key used by all components
const effectiveKey = lockedKey ?? keyInfo 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 [chordHistory, setChordHistory] = useState([])
const [detectedProgression, setDetectedProgression] = useState(null) const [detectedProgression, setDetectedProgression] = useState(null)
// ── Top key candidates (shown as quick-lock chips) ──────────────────────────── // ── Internal refs ─────────────────────────────────────────────────────────
const [topKeyCandidates, setTopKeyCandidates] = useState([])
// ── Internal refs ─────────────────────────────────────────────────────────────
const noteHistoryRef = useRef([]) const noteHistoryRef = useRef([])
const keyVotesRef = useRef([]) const keyVotesRef = useRef([])
const effectiveKeyRef = useRef(null) const effectiveKeyRef = useRef(null) // mirror for use inside callbacks
const chromaRingRef = useRef( const chromaRingRef = useRef(
Array.from({ length: DEFAULTS.chromaSmooth }, () => new Float32Array(12)) Array.from({ length: CHROMA_SMOOTH }, () => new Float32Array(12))
) )
const chromaIdxRef = useRef(0) const chromaIdxRef = useRef(0)
const chordVotesRef = useRef([]) const chordVotesRef = useRef([])
const progressionVoteRef = useRef(null)
const pendingKeyRef = useRef(null)
// Keep refs in sync // Keep ref in sync
useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey]) useEffect(() => { effectiveKeyRef.current = effectiveKey }, [effectiveKey])
// Re-init chroma ring when chromaSmooth changes // ── Detect progression whenever chord history changes ─────────────────────
useEffect(() => { useEffect(() => {
chromaRingRef.current = Array.from( setDetectedProgression(detectRepeatingProgression(chordHistory))
{ 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) return
const key = detected.join(',')
if (progressionVoteRef.current === key) {
setDetectedProgression(detected)
} else {
progressionVoteRef.current = key
}
}, [chordHistory]) }, [chordHistory])
// ── New song — full reset ───────────────────────────────────────────────────── // ── Key lock handlers ─────────────────────────────────────────────────────
function newSong() {
const cfg = configRef.current
noteHistoryRef.current = []
keyVotesRef.current = []
chordVotesRef.current = []
progressionVoteRef.current = null
pendingKeyRef.current = null
chromaIdxRef.current = 0
chromaRingRef.current = Array.from({ length: cfg.chromaSmooth }, () => new Float32Array(12))
onsetTimestampsRef.current = []
bpmSmoothRef.current = null
setKeyInfo(null)
setLockedKey(null)
effectiveKeyRef.current = null
setChordHistory([])
setDetectedProgression(null)
setTopKeyCandidates([])
setBpm(null)
setMicError(null)
setDebugChroma(null)
setDebugCandidates([])
setDebugNoteAnalysis(null)
}
// ── Key lock handlers ─────────────────────────────────────────────────────────
function applyLock() { function applyLock() {
const info = { root: lockRoot, mode: lockMode, confidence: 1 } const info = { root: lockRoot, mode: lockMode, confidence: 1 }
setLockedKey(info) setLockedKey(info)
effectiveKeyRef.current = info effectiveKeyRef.current = info
chordVotesRef.current = [] chordVotesRef.current = []
} setChordHistory([])
setDetectedProgression(null)
function quickLock({ root, mode, confidence }) {
const info = { root, mode, confidence }
setLockedKey(info)
effectiveKeyRef.current = info
chordVotesRef.current = []
} }
function removeLock() { function removeLock() {
@@ -165,217 +74,92 @@ export default function App() {
effectiveKeyRef.current = keyInfo effectiveKeyRef.current = keyInfo
} }
// ── Note handler: drives key detection (pitch-based) ────────────────────────── // ── Note handler: drives key detection (pitch-based) ──────────────────────
const handleNote = useCallback(({ pitchClass }) => { const handleNote = useCallback(({ pitchClass }) => {
const cfg = configRef.current
const history = noteHistoryRef.current const history = noteHistoryRef.current
history.push(pitchClass) history.push(pitchClass)
if (history.length > cfg.noteHistorySize) history.shift() if (history.length > NOTE_HISTORY_SIZE) history.shift()
if (history.length < 10) return if (history.length < 10) return
if (history.length % 5 !== 0) return if (history.length % 5 !== 0) return
const result = detectKey(history) const result = detectKey(history)
setTopKeyCandidates(detectTopKeys(history))
if (showDebugRef.current) setDebugNoteAnalysis(getNoteHistoryAnalysis(history))
if (result.confidence < 0.5) return if (result.confidence < 0.5) return
const votes = keyVotesRef.current const votes = keyVotesRef.current
votes.push(`${result.root}_${result.mode}`) votes.push(`${result.root}_${result.mode}`)
if (votes.length > cfg.keyVoteWindow) votes.shift() if (votes.length > KEY_VOTE_WINDOW) votes.shift()
const counts = {} const counts = {}
for (const v of votes) counts[v] = (counts[v] || 0) + 1 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] const [winner, count] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]
if (count >= cfg.keyVoteThreshold) { if (count >= KEY_VOTE_THRESHOLD) {
const [root, mode] = winner.split('_') const [root, mode] = winner.split('_')
const candidateKey = `${root}_${mode}`
setKeyInfo(prev => { setKeyInfo(prev => {
const currentKey = prev ? `${prev.root}_${prev.mode}` : null if (prev?.root === root && prev?.mode === mode) {
if (currentKey === candidateKey) {
pendingKeyRef.current = null
return { root, mode, confidence: result.confidence } return { root, mode, confidence: result.confidence }
} }
// Key changed — reset chord votes but keep history visible
if (pendingKeyRef.current === candidateKey) { if (!lockedKey) {
pendingKeyRef.current = null chordVotesRef.current = []
if (!lockedKeyRef.current) chordVotesRef.current = []
return { root, mode, confidence: result.confidence }
} }
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 handleChroma = useCallback((chroma, bassPC) => {
const cfg = configRef.current
const ring = chromaRingRef.current const ring = chromaRingRef.current
ring[chromaIdxRef.current % cfg.chromaSmooth] = chroma ring[chromaIdxRef.current % CHROMA_SMOOTH] = chroma
chromaIdxRef.current++ chromaIdxRef.current++
if (chromaIdxRef.current % cfg.chromaSmooth !== 0) return if (chromaIdxRef.current % CHROMA_SMOOTH !== 0) return
const key = effectiveKeyRef.current const key = effectiveKeyRef.current
if (!key) return if (!key) return
// Average ring buffer
const avg = new Float32Array(12) const avg = new Float32Array(12)
for (const frame of ring) for (let i = 0; i < 12; i++) avg[i] += frame[i] 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] /= cfg.chromaSmooth for (let i = 0; i < 12; i++) avg[i] /= CHROMA_SMOOTH
if (showDebugRef.current) { const chord = matchChordFromChroma(avg, key, bassPC, isStrictMode)
setDebugChroma([...avg])
setDebugCandidates(getChordCandidates(avg, key, bassPC))
}
// 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) {
chordVotesRef.current = []
return
}
const chord = matchChordFromChroma(avg, key, bassPC, false, cfg.chordMinScore)
if (!chord) { if (!chord) {
// Ambiguous moment (transition, silence) — reset streak, history is untouched
chordVotesRef.current = [] chordVotesRef.current = []
return return
} }
const votes = chordVotesRef.current const votes = chordVotesRef.current
votes.push(chord) votes.push(chord)
if (votes.length > cfg.chordVoteThreshold) votes.shift() if (votes.length > CHORD_VOTE_THRESHOLD) votes.shift()
if (votes.length >= cfg.chordVoteThreshold && votes.every(v => v === votes[0])) { // All last N detections must agree — one wrong reading resets the streak
if (votes.length >= CHORD_VOTE_THRESHOLD && votes.every(v => v === votes[0])) {
const winner = votes[0] const winner = votes[0]
setChordHistory(prev => { setChordHistory(prev => {
if (prev[prev.length - 1] === winner) return prev if (prev[prev.length - 1] === winner) return prev
return [...prev.slice(-30), winner] return [...prev.slice(-30), 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)
} }
while (history.length > cfg.noteHistorySize) history.shift() }, [isStrictMode])
}
}, [])
// ── 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 (55220)
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] const currentChord = chordHistory[chordHistory.length - 1]
if (showSettings) {
return ( return (
<Settings <div className="min-h-screen bg-surface text-white p-4 md:p-6">
config={config}
onChange={updateConfig}
onClose={() => setShowSettings(false)}
onReset={() => setConfig(DEFAULTS)}
/>
)
}
return (
<div className="min-h-screen bg-surface text-white p-3">
{/* ── Header ── */} {/* ── Header ── */}
<header className="mb-2 flex items-center justify-between"> <header className="mb-4 flex items-center justify-between">
<div> <div>
<h1 className="text-xl font-bold text-accent"> <h1 className="text-2xl font-bold text-accent">
WhatTheFlat <span className="text-gray-600">&#9837;?</span> WhatTheFlat <span className="text-gray-600">&#9837;?</span>
</h1> </h1>
<p className="text-xs text-gray-600">Real-time key detection for live jams</p> <p className="text-xs text-gray-600 mt-0.5">Real-time key detection for real humans</p>
</div> </div>
<div className="flex gap-2 items-center">
<button <button
onClick={() => setMonoColor(v => !v)} onClick={() => setIsListening(l => !l)}
className={`p-2 rounded-full border transition-all ${monoColor ? 'border-accent bg-accent/10' : 'border-border hover:border-gray-400'}`} className={`px-5 py-2.5 rounded-full font-semibold text-sm transition-all ${
title="Mono color mode"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" style={{ opacity: 0.75 }}>
<circle cx="6" cy="10" r="4" fill={monoColor ? '#a855f7' : '#a855f7'} />
<circle cx="13" cy="10" r="4" fill={monoColor ? '#c084fc' : '#f59e0b'} />
</svg>
</button>
<button
onClick={() => setShowDebug(v => !v)}
className={`p-2 rounded-full border transition-all ${showDebug ? 'border-accent bg-accent/10' : 'border-border hover:border-gray-400'}`}
title="Behind the scenes"
>
<img src={viewIcon} alt="Debug view" className="w-5 h-5" style={{ filter: 'invert(1) opacity(0.75)' }} />
</button>
<button
onClick={() => setShowSettings(true)}
className="p-2 rounded-full border border-border hover:border-gray-400 transition-all"
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 isListening
? 'bg-red-600 hover:bg-red-700 text-white' ? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-accent hover:bg-purple-600 text-white' : 'bg-accent hover:bg-purple-600 text-white'
@@ -383,181 +167,100 @@ export default function App() {
> >
{isListening ? 'Stop' : 'Start Listening'} {isListening ? 'Stop' : 'Start Listening'}
</button> </button>
</div>
</header> </header>
{/* ── Controls bar ── */} {/* ── Controls bar ── */}
<div className="mb-2 flex flex-wrap gap-2 items-center p-2 bg-panel border border-border rounded-xl"> <div className="mb-4 flex flex-wrap gap-3 items-center p-3 bg-panel border border-border rounded-xl">
{/* Mode toggle */}
{/* Instrument select */} <div className="flex bg-surface border border-border rounded-full p-0.5 text-sm">
<div className="relative"> {['beginner', 'advanced'].map(m => (
<select <button
value={instrument} key={m}
onChange={e => setInstrument(e.target.value)} onClick={() => setAppMode(m)}
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" className={`px-4 py-1 rounded-full capitalize transition-all ${
appMode === m ? 'bg-accent text-white' : 'text-gray-400 hover:text-gray-200'
}`}
> >
<option value="guitar">Guitar</option> {m}
<option value="bass">Bass</option> </button>
<option value="piano">Piano</option> ))}
</select>
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs"></span>
</div> </div>
{/* BPM badge */} {/* Key lock */}
{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>
)}
<div className="w-px h-5 bg-border shrink-0" />
{lockedKey ? ( {lockedKey ? (
<div className="flex items-center gap-2 px-3 py-1 bg-accent/20 border border-accent rounded-full"> <div className="flex items-center gap-2">
<span className="text-accent text-sm font-semibold shrink-0">🔒 {lockedKey.root}</span> <span className="px-3 py-1 bg-accent/20 border border-accent text-accent rounded-full text-sm font-semibold">
<div className="relative"> 🔒 {lockedKey.root} {lockedKey.mode}
<select </span>
value={lockedKey.mode} <button
onChange={e => { onClick={removeLock}
const info = { ...lockedKey, mode: e.target.value } className="text-xs text-gray-500 hover:text-gray-300 underline"
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 unlock
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex flex-wrap gap-2 items-center"> <div className="flex 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 <select
value={lockRoot} value={lockRoot}
onChange={e => setLockRoot(e.target.value)} onChange={e => setLockRoot(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" className="bg-surface border border-border rounded-lg px-2 py-1 text-sm text-gray-300"
> >
{NOTES.map(n => <option key={n}>{n}</option>)} {NOTES.map(n => <option key={n}>{n}</option>)}
</select> </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 <select
value={lockMode} value={lockMode}
onChange={e => setLockMode(e.target.value)} onChange={e => setLockMode(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" className="bg-surface border border-border rounded-lg px-2 py-1 text-sm text-gray-300"
> >
<option value="major">Major</option> <option value="major">Major</option>
<option value="minor">Minor</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> </select>
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 text-xs"></span>
</div>
<button <button
onClick={applyLock} onClick={applyLock}
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" className="px-3 py-1 bg-border hover:bg-accent/20 border border-border hover:border-accent text-sm rounded-lg transition-all"
> >
Lock key Lock Key
</button> </button>
</div> </div>
)} )}
</div>
<AudioCapture {/* Auto-detected key badge (advanced mode) */}
onNote={handleNote} {appMode === 'advanced' && keyInfo && !lockedKey && (
onChroma={handleChroma} <span className="text-xs text-gray-500">
onOnset={handleOnset} auto: {keyInfo.root} {keyInfo.mode} ({Math.round(keyInfo.confidence * 100)}%)
isListening={isListening} </span>
minClarity={config.minClarity}
minVolume={config.minVolume}
useEssentia={config.useEssentia}
onPermissionError={() => {
setMicError(true)
setIsListening(false)
}}
/>
{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>
)} )}
</div>
{/* ── Progression banner ── */} <AudioCapture onNote={handleNote} onChroma={handleChroma} isListening={isListening} />
{/* ── Progression banner — full width ── */}
<ProgressionBanner <ProgressionBanner
chordHistory={chordHistory} chordHistory={chordHistory}
keyInfo={effectiveKey} keyInfo={effectiveKey}
detectedProgression={detectedProgression} detectedProgression={detectedProgression}
currentChord={currentChord}
/> />
{/* ── Instrument + progressions row ── */} {/* ── Main grid ── */}
<div className="flex gap-3 mb-3 items-stretch"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="w-full lg:w-[70%] min-w-0"> <KeyDisplay keyInfo={effectiveKey} locked={!!lockedKey} />
{instrument === 'guitar' && <Fretboard keyInfo={effectiveKey} currentChord={currentChord} pentatonicOnly={false} monoColor={monoColor} />} <ChordDisplay history={chordHistory} />
{instrument === 'bass' && <BassFretboard keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />} <SafeNotes keyInfo={effectiveKey} currentChord={currentChord} />
{instrument === 'piano' && <Piano keyInfo={effectiveKey} currentChord={currentChord} monoColor={monoColor} />} <ProgressionSuggestions keyInfo={effectiveKey} />
</div> <div className="md:col-span-2">
<Fretboard
<div className="hidden lg:block w-[30%] min-w-0 relative">
<div className="absolute inset-0">
<ProgressionSuggestions keyInfo={effectiveKey} currentChord={currentChord} />
</div>
</div>
</div>
{/* ── Behind the scenes debug view ── */}
{showDebug && (
<div className="mb-3">
<DebugView
chroma={debugChroma}
chordCandidates={debugCandidates}
noteAnalysis={debugNoteAnalysis}
keyInfo={effectiveKey} keyInfo={effectiveKey}
currentChord={currentChord} currentChord={currentChord}
instrument={instrument} pentatonicOnly={appMode === 'beginner'}
/> />
</div> </div>
)} <div className="md:col-span-2">
<Tuner />
{/* ── Tuner — collapsible ── */} </div>
<div> {/* <div className="md:col-span-2">
<button <ChatAssistant keyInfo={effectiveKey} currentChord={currentChord} />
onClick={() => setShowTuner(v => !v)} </div> */}
className="w-full flex items-center justify-between px-4 py-2 bg-panel border border-border rounded-xl text-sm text-gray-400 hover:text-gray-200 hover:border-gray-500 transition-all"
>
<span>Tuner</span>
<span>{showTuner ? '▲' : '▼'}</span>
</button>
{showTuner && <div className="mt-2"><Tuner /></div>}
</div> </div>
</div> </div>
) )
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

+23 -72
View File
@@ -1,7 +1,6 @@
import { useEffect, useRef, useCallback } from 'react' import { useEffect, useRef, useCallback } from 'react'
import { PitchDetector } from 'pitchy' import { PitchDetector } from 'pitchy'
import { NOTES } from '../lib/theory' import { NOTES } from '../lib/theory'
import { initEssentia, getEssentia, computeHPCP } from '../lib/essentiaHPCP'
// ─── Why two analysers? ─────────────────────────────────────────────────────── // ─── Why two analysers? ───────────────────────────────────────────────────────
// //
@@ -27,6 +26,8 @@ import { initEssentia, getEssentia, computeHPCP } from '../lib/essentiaHPCP'
const PITCH_FFT = 4096 // ~90ms window — good temporal resolution for pitch 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 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 const NOISE_FLOOR = -65 // dB
// ─── Harmonic summation chroma ──────────────────────────────────────────────── // ─── Harmonic summation chroma ────────────────────────────────────────────────
@@ -82,57 +83,27 @@ function detectBassPC(freqData, sampleRate, fftSize) {
return ((bestMidi % 12) + 12) % 12 return ((bestMidi % 12) + 12) % 12
} }
export default function AudioCapture({ onNote, onChroma, onOnset, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError, useEssentia = false }) { export default function AudioCapture({ onNote, onChroma, isListening }) {
const audioCtxRef = useRef(null) const audioCtxRef = useRef(null)
const pitchAnalyser = useRef(null)
const chordAnalyser = useRef(null)
const timeBufRef = useRef(null) const timeBufRef = useRef(null)
const freqBufRef = useRef(null) const freqBufRef = useRef(null)
const detectorRef = useRef(null) const detectorRef = useRef(null)
const rafRef = useRef(null) const rafRef = useRef(null)
const streamRef = 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 onPermissionErrorRef = useRef(onPermissionError)
const minClarityRef = useRef(minClarity)
const minVolumeRef = useRef(minVolume)
const useEssentiaRef = useRef(useEssentia)
const smoothRmsRef = useRef(0)
const lastOnsetRef = useRef(0)
useEffect(() => { onNoteRef.current = onNote }, [onNote])
useEffect(() => { onChromaRef.current = onChroma }, [onChroma])
useEffect(() => { onOnsetRef.current = onOnset }, [onOnset])
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
useEffect(() => { useEssentiaRef.current = useEssentia }, [useEssentia])
// Pre-load Essentia WASM as soon as the component mounts — it's a singleton,
// so repeated calls just return the cached instance.
useEffect(() => { initEssentia().catch(console.error) }, [])
const stop = useCallback(() => { const stop = useCallback(() => {
activeRef.current = false if (rafRef.current) cancelAnimationFrame(rafRef.current)
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null } if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop())
if (streamRef.current) { streamRef.current.getTracks().forEach(t => t.stop()); streamRef.current = null } if (audioCtxRef.current) audioCtxRef.current.close()
if (audioCtxRef.current) { audioCtxRef.current.close(); audioCtxRef.current = null } audioCtxRef.current = null
}, []) }, [])
const start = useCallback(async () => { const start = useCallback(async () => {
stop() stop()
let stream const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (err) {
onPermissionErrorRef.current?.(err)
return
}
streamRef.current = stream streamRef.current = stream
activeRef.current = true
smoothRmsRef.current = 0
lastOnsetRef.current = 0
const ctx = new AudioContext() const ctx = new AudioContext()
audioCtxRef.current = ctx audioCtxRef.current = ctx
@@ -141,7 +112,8 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m
// Small analyser — pitch detection needs fast time-domain data // Small analyser — pitch detection needs fast time-domain data
const pa = ctx.createAnalyser() const pa = ctx.createAnalyser()
pa.fftSize = PITCH_FFT pa.fftSize = PITCH_FFT
pa.smoothingTimeConstant = 0.0 pa.smoothingTimeConstant = 0.0 // no smoothing: pitchy needs clean waveform
pitchAnalyser.current = pa
source.connect(pa) source.connect(pa)
timeBufRef.current = new Float32Array(pa.fftSize) timeBufRef.current = new Float32Array(pa.fftSize)
detectorRef.current = PitchDetector.forFloat32Array(pa.fftSize) detectorRef.current = PitchDetector.forFloat32Array(pa.fftSize)
@@ -149,52 +121,31 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m
// Large analyser — chord detection needs fine frequency resolution // Large analyser — chord detection needs fine frequency resolution
const ca = ctx.createAnalyser() const ca = ctx.createAnalyser()
ca.fftSize = CHORD_FFT ca.fftSize = CHORD_FFT
ca.smoothingTimeConstant = 0.5 // reduced from 0.65 — clears faster between chords ca.smoothingTimeConstant = 0.65 // smooth over time for stable chord reading
chordAnalyser.current = ca
source.connect(ca) source.connect(ca)
freqBufRef.current = new Float32Array(ca.frequencyBinCount) freqBufRef.current = new Float32Array(ca.frequencyBinCount)
function tick() { function tick() {
if (!activeRef.current) return // stop() was called — bail immediately
const timeBuf = timeBufRef.current const timeBuf = timeBufRef.current
pa.getFloatTimeDomainData(timeBuf) pa.getFloatTimeDomainData(timeBuf)
const rms = Math.sqrt(timeBuf.reduce((s, v) => s + v * v, 0) / timeBuf.length) const rms = Math.sqrt(timeBuf.reduce((s, v) => s + v * v, 0) / timeBuf.length)
if (rms >= MIN_VOLUME) {
// Onset detection — RMS spike significantly above smoothed baseline // Pitch via McLeod (autocorrelation)unaffected by FFT bin size
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?.()
}
if (rms >= minVolumeRef.current) {
const [freq, clarity] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate) const [freq, clarity] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
if (clarity >= minClarityRef.current && freq > 60 && freq < 4200) { if (clarity >= MIN_CLARITY && freq > 60 && freq < 4200) {
const midi = Math.round(12 * Math.log2(freq / 440) + 69) const midi = Math.round(12 * Math.log2(freq / 440) + 69)
const pitchClass = ((midi % 12) + 12) % 12 const pitchClass = ((midi % 12) + 12) % 12
onNoteRef.current({ noteName: NOTES[pitchClass], pitchClass, freq, midi, clarity }) onNote({ noteName: NOTES[pitchClass], pitchClass, freq, midi, clarity })
} }
if (onChromaRef.current) { // Chord chroma from the high-resolution FFT
if (onChroma) {
const freqBuf = freqBufRef.current const freqBuf = freqBufRef.current
ca.getFloatFrequencyData(freqBuf) ca.getFloatFrequencyData(freqBuf)
let chroma onChroma(
const essentia = useEssentiaRef.current ? getEssentia() : null computeChroma(freqBuf, ctx.sampleRate, ca.fftSize),
if (essentia) {
try {
chroma = computeHPCP(essentia, freqBuf, ctx.sampleRate)
} catch (err) {
console.error('[Essentia] computeHPCP failed, falling back to custom chroma:', err)
chroma = computeChroma(freqBuf, ctx.sampleRate, ca.fftSize)
}
} else {
chroma = computeChroma(freqBuf, ctx.sampleRate, ca.fftSize)
}
onChromaRef.current(
chroma,
detectBassPC(freqBuf, ctx.sampleRate, ca.fftSize) detectBassPC(freqBuf, ctx.sampleRate, ca.fftSize)
) )
} }
@@ -203,7 +154,7 @@ export default function AudioCapture({ onNote, onChroma, onOnset, isListening, m
rafRef.current = requestAnimationFrame(tick) rafRef.current = requestAnimationFrame(tick)
} }
tick() tick()
}, [stop]) }, [onNote, onChroma, stop])
useEffect(() => { useEffect(() => {
if (isListening) start().catch(console.error) if (isListening) start().catch(console.error)
-149
View File
@@ -1,149 +0,0 @@
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 12) */}
{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>
)
}
+89
View File
@@ -0,0 +1,89 @@
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>
)
}
+25
View File
@@ -0,0 +1,25 @@
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>
)
}
-303
View File
@@ -1,303 +0,0 @@
import { getScale, getChordTones, NOTES } from '../lib/theory'
// ─── SVG Piano — 2 octaves (C3B4) ───────────────────────────────────────────
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 }) {
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
? `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 > 0.04 && (
<rect
x={x+1} y={3 + keyH * (1 - Math.min(energy, 1) * 0.85)}
width={KEY_W-2} height={keyH * Math.min(energy, 1) * 0.85}
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 ? '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 bg = inChord
? `rgba(139,92,246,${0.4 + energy * 0.6})`
: inKey
? `rgba(180,130,0,${0.35 + energy * 0.55})`
: `rgba(12,12,16,0.95)`
return (
<g key={`b${i}`}>
<rect x={x} y={3} width={BLACK_W} height={BLACK_H}
rx={2} fill={bg} stroke="rgba(255,255,255,0.06)" strokeWidth={1} />
<text x={x + BLACK_W/2} y={BLACK_H - 5} textAnchor="middle" fontSize={7}
fill={inKey || inChord ? 'rgba(210,210,220,0.85)' : 'rgba(110,110,120,0.6)'}>
{NOTES[pc]}
</text>
</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 012
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 }) {
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.12) 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 = `rgba(245,158,11,${0.2 + energy * 0.75})`
textFill = 'rgba(0,0,0,0.85)'
} else {
fill = `rgba(100,100,120,${energy * 0.7})`
textFill = 'rgba(180,180,190,0.7)'
}
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)' : '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>
)
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function DebugView({ chroma, chordCandidates, noteAnalysis, keyInfo, currentChord, instrument = 'guitar' }) {
const keyPCs = new Set(keyInfo ? getScale(keyInfo.root, keyInfo.mode).map(n => NOTES.indexOf(n)) : [])
const chordPCs = new Set(currentChord ? getChordTones(currentChord).map(n => NOTES.indexOf(n)) : [])
const chromaArr = chroma ? [...chroma] : new Array(12).fill(0)
const histFreq = noteAnalysis ? noteAnalysis.freq : new Array(12).fill(0)
const topKeys = noteAnalysis ? noteAnalysis.topKeys : []
const topScore = chordCandidates[0]?.score ?? 1
const topKeyScore = topKeys[0]?.score ?? 1
return (
<div className="bg-panel border border-border rounded-2xl p-4 flex flex-col gap-4">
<p className="text-xs text-gray-500 uppercase tracking-widest shrink-0">Behind the Scenes</p>
{/* ── Live chroma visualization (instrument-synced) ── */}
<div>
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Live chroma what the engine hears right now</p>
{instrument === 'guitar'
? <MiniFretboard values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} />
: <PianoSVG values={chromaArr} keyNotes={keyPCs} chordNotes={chordPCs} keyH={90} />
}
</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>
<p className="text-xs text-gray-600 uppercase tracking-widest mb-2">Note history key evidence</p>
<PianoSVG values={histFreq} keyNotes={keyPCs} chordNotes={chordPCs} keyH={70} showPct={true} />
</div>
{/* Col 3: Key candidates */}
<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>
</div>
)
}
+13 -14
View File
@@ -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) // y centre of string si (0 = high e, 5 = low E)
const stringY = si => PAD_T + si * STRING_H const stringY = si => PAD_T + si * STRING_H
function noteColor(isChordTone, isPenta, isScale, mono = false) { function noteColor(isChordTone, isPenta, isScale) {
if (isChordTone) return { fill: '#a855f7', text: '#fff' } if (isChordTone) return { fill: '#f59e0b', text: '#000' } // amber
if (isPenta) return mono ? { fill: '#c084fc', text: '#1e1b4b' } : { fill: '#f59e0b', text: '#000' } if (isPenta) return { fill: '#a855f7', text: '#fff' } // purple
if (isScale) return mono ? { fill: '#e9d5ff', text: '#581c87' } : { fill: '#374151', text: '#d1d5db' } if (isScale) return { fill: '#374151', text: '#d1d5db' } // grey
return null return null
} }
export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false, monoColor = false }) { export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = false }) {
const { root, mode } = keyInfo ?? {} const { root, mode } = keyInfo ?? {}
if (!root) return null if (!root) return null
@@ -57,12 +57,11 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
{currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>} {currentChord && <span className="text-amber-400 ml-2">/ {currentChord}</span>}
</p> </p>
<div> <div className="overflow-x-auto">
<svg <svg
viewBox={`0 0 ${BOARD_W} ${BOARD_H}`} width={BOARD_W}
width="100%" height={BOARD_H}
height="auto" style={{ display: 'block', minWidth: BOARD_W }}
style={{ display: 'block' }}
> >
{/* Fretboard background */} {/* Fretboard background */}
<rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={5 * STRING_H + 12} <rect x={NUT_X} y={PAD_T - 6} width={BOARD_W - NUT_X - 4} height={5 * STRING_H + 12}
@@ -120,7 +119,7 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
{STRINGS.flatMap((str, si) => {STRINGS.flatMap((str, si) =>
Array.from({ length: NUM_FRETS }, (_, fi) => { Array.from({ length: NUM_FRETS }, (_, fi) => {
const pc = (str.root + fi) % 12 const pc = (str.root + fi) % 12
const color = noteColor(chordSet.has(pc), pentaSet.has(pc), scaleSet.has(pc), monoColor) const color = noteColor(chordSet.has(pc), pentaSet.has(pc), scaleSet.has(pc))
if (!color) return null if (!color) return null
const cx = fi === 0 ? OPEN_X : fretX(fi) const cx = fi === 0 ? OPEN_X : fretX(fi)
@@ -146,9 +145,9 @@ export default function Fretboard({ keyInfo, currentChord, pentatonicOnly = fals
</div> </div>
<div className="mt-3 flex gap-5 text-xs text-gray-500"> <div className="mt-3 flex gap-5 text-xs text-gray-500">
<span><span className="text-accent"></span> Chord tone</span> <span><span className="text-amber-400"></span> Chord tone</span>
<span style={{ color: monoColor ? '#c084fc' : '#f59e0b' }}></span><span> Pentatonic</span> <span><span className="text-accent"></span> Pentatonic</span>
<span style={{ color: monoColor ? '#e9d5ff' : '#6b7280' }}></span><span> Scale</span> <span><span className="text-gray-500"></span> Scale</span>
</div> </div>
</div> </div>
) )
+33
View File
@@ -0,0 +1,33 @@
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>
)
}
-142
View File
@@ -1,142 +0,0 @@
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>
)
}
+41 -66
View File
@@ -1,7 +1,7 @@
import { useRef, useEffect } from 'react' import { useRef, useEffect } from 'react'
import { toRomanNumeral } from '../lib/theory' import { toRomanNumeral } from '../lib/theory'
const HISTORY_SHOWN = 8 const HISTORY_SHOWN = 8 // ~2 bars at 4 chords/bar
function findLoopPosition(chordHistory, progression) { function findLoopPosition(chordHistory, progression) {
if (!progression?.length || !chordHistory.length) return -1 if (!progression?.length || !chordHistory.length) return -1
@@ -17,76 +17,67 @@ function findLoopPosition(chordHistory, progression) {
return progression.indexOf(last) return progression.indexOf(last)
} }
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, currentChord }) { export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression }) {
const { root, mode, confidence } = keyInfo ?? {} const { root, mode } = keyInfo ?? {}
// Newest chord is the last entry; we show the most recent HISTORY_SHOWN
const visible = chordHistory.slice(-HISTORY_SHOWN) const visible = chordHistory.slice(-HISTORY_SHOWN)
const current = visible[visible.length - 1] const current = visible[visible.length - 1]
const loopPos = findLoopPosition(chordHistory, detectedProgression)
const currentRN = root && current ? toRomanNumeral(current, root, mode) : ''
// Animate the current chord slot when it changes
const currentRef = useRef(null) const currentRef = useRef(null)
const prevChord = useRef(null) const prevChord = useRef(null)
useEffect(() => { useEffect(() => {
if (current && current !== prevChord.current && currentRef.current) { if (current && current !== prevChord.current && currentRef.current) {
currentRef.current.animate( 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' } { duration: 200, easing: 'ease-out', fill: 'forwards' }
) )
prevChord.current = current prevChord.current = current
} }
}, [current]) }, [current])
const loopPos = findLoopPosition(chordHistory, detectedProgression)
if (!chordHistory.length) {
return ( return (
<div className="bg-panel border border-border rounded-2xl p-4 mb-3 flex gap-4"> <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>
{/* ── 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> </div>
)
}
<div className="w-px h-6 bg-border shrink-0" /> return (
<div className="bg-panel border border-border rounded-2xl p-5 mb-4">
{!chordHistory.length ? ( {/* ── Chord history strip: all HISTORY_SHOWN chords at consistent size ── */}
<p className="text-gray-600 text-sm">Start listening</p> <div className="flex items-stretch gap-1 overflow-x-auto pb-1">
) : (
<div className="flex items-end gap-1 overflow-x-auto pb-1">
{visible.map((chord, i) => { {visible.map((chord, i) => {
const isCurrent = i === visible.length - 1 const isCurrent = i === visible.length - 1
const age = visible.length - 1 - i const age = visible.length - 1 - i // 0 = current, higher = older
const opacity = Math.max(0.25, 1 - age * 0.09) const opacity = Math.max(0.2, 1 - age * 0.1) // fade but stay readable
const rn = root ? toRomanNumeral(chord, root, mode) : '' const rn = root ? toRomanNumeral(chord, root, mode) : ''
return ( return (
<div <div
key={i} key={i}
ref={isCurrent ? currentRef : null} ref={isCurrent ? currentRef : null}
style={{ opacity }} style={{ opacity }}
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 ${ className={`
isCurrent 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' ? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20'
: 'border border-transparent' : 'border border-transparent'}
}`} `}
> >
<span className={`font-black leading-none tracking-tight ${ <span className={`font-black leading-none tracking-tight ${
isCurrent ? 'text-3xl text-accent' : 'text-xl text-gray-200' isCurrent ? 'text-5xl text-accent' : 'text-3xl text-gray-200'
}`}> }`}>
{chord} {chord}
</span> </span>
<span className={`text-xs font-semibold mt-0.5 ${ <span className={`text-xs font-semibold mt-1 ${
isCurrent ? 'text-amber-400' : 'text-gray-500' isCurrent ? 'text-amber-400' : 'text-gray-500'
}`}> }`}>
{rn || '\u00A0'} {rn || '\u00A0'}
@@ -95,53 +86,37 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
) )
})} })}
</div> </div>
)}
</div>
{/* Loop */} {/* ── Detected loop ── */}
{detectedProgression && ( {detectedProgression && (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="mt-4 pt-3 border-t border-border">
<span className="text-xs text-gray-500"></span> <p className="text-xs text-gray-500 uppercase tracking-widest mb-2"> Detected loop</p>
<div className="flex gap-2 flex-wrap">
{detectedProgression.map((chord, i) => { {detectedProgression.map((chord, i) => {
const isActive = i === loopPos const isActive = i === loopPos
const rn = root ? toRomanNumeral(chord, root, mode) : chord const rn = root ? toRomanNumeral(chord, root, mode) : chord
return ( return (
<div <div
key={i} key={i}
className={`flex flex-col items-center px-2 py-0.5 rounded-lg border transition-all duration-200 ${ className={`flex flex-col items-center px-4 py-2 rounded-xl border transition-all duration-200 ${
isActive isActive
? 'bg-accent/20 border-accent shadow-[0_0_10px_rgba(168,85,247,0.3)]' ? 'bg-accent/20 border-accent shadow-[0_0_14px_rgba(168,85,247,0.35)]'
: 'bg-border border-border' : 'bg-border border-border'
}`} }`}
> >
<span className={`text-sm font-bold leading-none ${isActive ? 'text-accent' : 'text-gray-300'}`}> <span className={`text-2xl font-bold leading-none ${isActive ? 'text-accent' : 'text-gray-200'}`}>
{chord} {chord}
</span> </span>
<span className={`text-xs ${isActive ? 'text-amber-400' : 'text-gray-600'}`}>{rn}</span> <span className={`text-xs mt-1 font-semibold ${isActive ? 'text-amber-400' : 'text-gray-500'}`}>
{rn}
</span>
</div> </div>
) )
})} })}
<span className="text-gray-600 text-xs"> loop</span> <span className="self-center text-gray-600 text-sm pl-1"> loop</span>
</div>
</div> </div>
)} )}
</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 ? (
<>
<p className="text-xs text-gray-600 uppercase tracking-widest">Now Playing</p>
<div className="text-6xl font-black text-amber-400 leading-none">{current}</div>
<div className="text-sm text-gray-500">{currentRN}</div>
</>
) : (
<p className="text-gray-600 text-xs text-center">Play a chord</p>
)}
</div>
</div>
) )
} }
+17 -171
View File
@@ -1,186 +1,32 @@
import { useState, useEffect } from 'react' import { getSuggestedProgressions } from '../lib/theory'
import { getSuggestedProgressions, getChordsInKey, toRomanNumeral, NOTES, NOTES_FLAT } from '../lib/theory'
// ─── Mood map ───────────────────────────────────────────────────────────────── export default function ProgressionSuggestions({ keyInfo }) {
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 { root, mode } = keyInfo ?? {}
const [suggestions, setSuggestions] = useState([])
useEffect(() => {
setSuggestions(buildSuggestions(currentChord, root, mode))
}, [currentChord, root, mode])
if (!root) return null if (!root) return null
const progressions = getSuggestedProgressions(root, mode)
return ( return (
<div className="bg-panel border border-border rounded-2xl p-3 flex flex-col h-full gap-1.5 overflow-hidden"> <div className="bg-panel border border-border rounded-2xl p-6">
<p className="text-sm text-gray-500 uppercase tracking-widest mb-4">
<p className="text-xs text-gray-500 uppercase tracking-widest shrink-0"> Progressions in {root} {mode}
Suggested Progression
</p> </p>
<div className="space-y-3">
{!currentChord || suggestions.length === 0 ? ( {progressions.map(prog => (
<div className="flex-1 flex items-center justify-center"> <div key={prog.genre} className="flex items-center gap-3">
<p className="text-gray-600 text-sm text-center"> <span className="text-xs text-gray-500 w-10 shrink-0">{prog.genre}</span>
{currentChord ? 'No suggestions' : 'Play a chord…'} <div className="flex gap-2 flex-wrap">
</p> {prog.chords.map((chord, i) => (
</div> <span key={i} className="px-3 py-1 bg-border rounded text-sm font-medium">
) : ( {chord}
<div className="flex-1 flex flex-col justify-start gap-1 overflow-y-auto min-h-0"> <span className="ml-1 text-gray-600 text-xs">({prog.rn[i]})</span>
{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> </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>
</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>
)}
</div> </div>
) )
} }
+46
View File
@@ -0,0 +1,46 @@
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>
)
}
-183
View File
@@ -1,183 +0,0 @@
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.',
},
],
},
]
const TOGGLES = [
{
section: 'Experimental',
items: [
{
key: 'useEssentia',
label: 'Essentia HPCP',
desc: 'Replace custom chroma with Essentia\'s Harmonic Pitch Class Profile (spectral peaks + 8 harmonics). More accurate on polyphonic input. May be slower.',
},
],
},
]
export default function Settings({ config, onChange, onClose, onReset }) {
return (
<div className="fixed inset-0 bg-surface z-50 overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
<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={onClose}
className="px-5 py-2 rounded-lg text-sm bg-accent hover:bg-purple-600 text-white font-semibold transition-all"
>
Done
</button>
</div>
</div>
<div className="space-y-8">
{TOGGLES.map(section => (
<div key={section.section}>
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
{section.section}
</h3>
<div className="space-y-4">
{section.items.map(item => (
<label key={item.key} className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
checked={!!config[item.key]}
onChange={e => onChange(item.key, e.target.checked)}
className="mt-0.5 accent-purple-500 w-4 h-4 flex-shrink-0"
/>
<div>
<div className="text-sm font-semibold text-gray-200 group-hover:text-white transition-colors">
{item.label}
</div>
<div className="text-xs text-gray-600 mt-0.5">{item.desc}</div>
</div>
</label>
))}
</div>
</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>
))}
</div>
</div>
</div>
)
}
-1
View File
@@ -1,5 +1,4 @@
@import "tailwindcss"; @import "tailwindcss";
@config "../tailwind.config.js";
body { body {
background-color: #0f0f0f; background-color: #0f0f0f;
-98
View File
@@ -1,98 +0,0 @@
// Essentia HPCP pipeline — replaces our custom computeChroma() when enabled.
//
// Pipeline (using Web Audio FFT data directly to skip WASM re-FFT):
// freqBuf (dB from AnalyserNode) → linear magnitude → SpectralPeaks → HPCP
//
// Pitch class ordering:
// Essentia HPCP[0] = A (referenceFrequency=440)
// Our chroma[0] = C
// Rotation applied: ourChroma[(i + 9) % 12] = HPCP[i]
import { EssentiaWASM } from 'essentia.js/dist/essentia-wasm.es.js'
import Essentia from 'essentia.js/dist/essentia.js-core.es.js'
let instance = null
// Call once at startup — safe to call multiple times (returns cached instance).
export async function initEssentia() {
if (instance) return instance
console.log('[Essentia] loading WASM…')
await EssentiaWASM['ready']
instance = new Essentia(EssentiaWASM)
console.log('[Essentia] ready —', instance.version)
return instance
}
export function getEssentia() { return instance }
const NOISE_FLOOR_DB = -65
// Compute 12-bin HPCP from Web Audio frequency-domain data.
//
// freqData — Float32Array from AnalyserNode.getFloatFrequencyData() (dB values)
// sampleRate — AudioContext.sampleRate
//
// Returns Float32Array(12) with same [C, C#, D, …, B] ordering as computeChroma().
export function computeHPCP(essentia, freqData, sampleRate) {
const N = freqData.length
// Convert dB → linear magnitude. Bins below noise floor stay 0.
const magSpectrum = new Float32Array(N)
for (let i = 0; i < N; i++) {
if (freqData[i] > NOISE_FLOOR_DB) {
magSpectrum[i] = Math.pow(10, freqData[i] / 20)
}
}
const specVec = essentia.arrayToVector(magSpectrum)
// SpectralPeaks derives bin → Hz as: freq = binIndex * sampleRate / (2*(N-1))
// which matches Web Audio's FFT bin spacing (sampleRate / fftSize).
const peaks = essentia.SpectralPeaks(
specVec,
0, // magnitudeThreshold — noise already zeroed above
4000, // maxFrequency (Hz)
60, // maxPeaks
40, // minFrequency (Hz)
'byMagnitude',
sampleRate
)
specVec.delete()
// If no peaks found (silence / below noise floor), return zeros.
if (peaks.frequencies.size() === 0) {
peaks.frequencies.delete()
peaks.magnitudes.delete()
return new Float32Array(12)
}
const hpcpResult = essentia.HPCP(
peaks.frequencies,
peaks.magnitudes,
false, // bandPreset
500, // bandSplitFrequency (unused when bandPreset=false)
8, // harmonics
4000, // maxFrequency (Hz)
false, // maxShifted
40, // minFrequency (Hz)
false, // nonLinear
'unitMax', // normalized
440, // referenceFrequency (A4 = 440 Hz → HPCP[0] = A)
sampleRate,
12, // size (bins per octave)
'squaredCosine', // weightType
0.5 // windowSize (octaves)
)
peaks.frequencies.delete()
peaks.magnitudes.delete()
// Rotate A-origin → C-origin to match our chroma convention.
// HPCP[0]=A → ourChroma[9]=A, so ourChroma[(i+9)%12] = HPCP[i]
const result = new Float32Array(12)
for (let i = 0; i < 12; i++) {
result[(i + 9) % 12] = hpcpResult.hpcp.get(i)
}
hpcpResult.hpcp.delete()
return result
}
+5 -115
View File
@@ -57,54 +57,31 @@ export const CHORD_TYPES = {
// Chord types considered during real-time chroma matching // Chord types considered during real-time chroma matching
const MATCH_CHORD_TYPES = [ const MATCH_CHORD_TYPES = [
'maj', 'min', 'dom7', 'maj7', 'min7', 'dim', 'half_dim', 'aug', 'sus4', 'sus2', 'add9', 'maj', 'min', 'dom7', 'min7', 'dim', 'half_dim', 'aug', 'sus4', 'add9',
] ]
// Minimum score for a chord match to be reported // Minimum score for a chord match to be reported
const CHORD_MATCH_MIN_SCORE = 0.42 const CHORD_MATCH_MIN_SCORE = 0.42
// Minimum margin over second-best for a match to be considered unambiguous // Minimum margin over second-best for a match to be considered unambiguous
const CHORD_MATCH_MIN_MARGIN = 0.07 const CHORD_MATCH_MIN_MARGIN = 0.04
// Chord quality for each scale degree, per mode // Chord quality for each scale degree in major and minor
const DEGREE_QUALITIES = { const DEGREE_QUALITIES = {
major: ['', 'm', 'm', '', '', 'm', 'dim'], major: ['', 'm', 'm', '', '', 'm', 'dim'],
minor: ['m', 'dim', '', 'm', 'm', '', '' ], 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'] const ROMAN_NUMERALS = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII']
// Common chord progressions by genre, expressed as semitone offsets from the root // Common chord progressions by genre, expressed as semitone offsets from the root
const PROGRESSIONS = { const PROGRESSIONS = {
// Pop
pop: { name: 'Pop', rn: ['I', 'V', 'vi', 'IV'], degrees: [0, 7, 9, 5] }, 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] }, 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] }, 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] }, 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] }, 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] }, '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] }, 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 ──────────────────────────────────────────────────────── // ─── Internal helpers ────────────────────────────────────────────────────────
@@ -138,31 +115,6 @@ function noteIndex(note) {
// ─── Key Detection ─────────────────────────────────────────────────────────── // ─── 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 } * detectKey(noteHistory) → { root, mode, confidence }
* Uses Krumhansl-Schmuckler: correlates pitch-class histogram with key profiles. * Uses Krumhansl-Schmuckler: correlates pitch-class histogram with key profiles.
@@ -286,13 +238,10 @@ export function matchChordFromChroma(
const tones = new Set(type.intervals.map(i => (r + i) % 12)) 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 let inEnergy = 0, outEnergy = 0
for (let pc = 0; pc < 12; pc++) { for (let pc = 0; pc < 12; pc++) {
if (pc === r) { if (pc === r) {
inEnergy += chroma[pc] * 1.5 // root weight reduced: 2→1.5 (less root bias) inEnergy += chroma[pc] * 2 // root carries strongest identity signal
} else if (tones.has(pc)) { } else if (tones.has(pc)) {
inEnergy += chroma[pc] inEnergy += chroma[pc]
} else { } else {
@@ -302,8 +251,7 @@ export function matchChordFromChroma(
if (inEnergy + outEnergy < 0.05) continue if (inEnergy + outEnergy < 0.05) continue
// Stricter outEnergy penalty (0.7 vs 0.5) — wrong notes hurt more const coverageScore = inEnergy / (inEnergy + outEnergy * 0.5)
const coverageScore = inEnergy / (inEnergy + outEnergy * 0.7)
const bassBonus = bassPC !== null && r === bassPC ? 0.15 : 0 const bassBonus = bassPC !== null && r === bassPC ? 0.15 : 0
const diatonicBonus = diatonicSet.has(chordName) ? 0.15 : 0 const diatonicBonus = diatonicSet.has(chordName) ? 0.15 : 0
@@ -396,64 +344,6 @@ export function detectRepeatingProgression(history) {
return best return best
} }
// ─── 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,
topKeys: candidates.sort((a, b) => b.score - a.score).slice(0, 5),
}
}
// ─── Utilities ─────────────────────────────────────────────────────────────── // ─── Utilities ───────────────────────────────────────────────────────────────
export function intervalName(semitones) { export function intervalName(semitones) {
+476
View File
@@ -0,0 +1,476 @@
// ─── 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 (011, 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 (011) 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 (011)
* @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 (01)
* @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 26.
*
* 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 (011).
* @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))
}
+1 -9
View File
@@ -3,17 +3,9 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
export default defineConfig({ export default defineConfig({
base: './',
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
base: './', // relative paths so Electron can load files from disk
server: { server: {
port: 5173, port: 5173,
}, },
build: {
outDir: 'dist',
},
optimizeDeps: {
// Essentia uses emscripten output that esbuild can't pre-bundle reliably.
// Exclude it so Vite serves the files directly from node_modules.
exclude: ['essentia.js'],
},
}) })