additions and improvements
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# 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.
|
||||
|
||||
## Features
|
||||
|
||||
- Real-time chord detection from live audio
|
||||
- Automatic key detection (Krumhansl-Schmuckler profiles)
|
||||
- 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
|
||||
- AI chat assistant for music theory questions
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: React 18, Vite, Tailwind CSS
|
||||
- **Audio**: Web Audio API, [Pitchy](https://github.com/ianprime0509/pitchy) (McLeod pitch detection)
|
||||
- **Backend**: Python (Claude API for chat assistant)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:5173` in your browser and click **Start Listening**. Allow microphone access when prompted.
|
||||
|
||||
### Backend (chat assistant)
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
Audio is processed in two parallel paths:
|
||||
|
||||
1. **Pitch path** — small 4096-sample FFT with McLeod autocorrelation for fast, accurate single-note pitch detection. Feeds the key detection algorithm.
|
||||
2. **Chord path** — large 16384-sample FFT (2.7 Hz/bin resolution) with harmonic summation chroma extraction. The chroma vector is matched against chord templates (major, minor, dominant 7th, sus4, diminished) to identify the current chord.
|
||||
|
||||
Key detection uses a rolling vote over the last 12 detections and requires 9/12 agreement before committing, keeping the display stable during transitions.
|
||||
|
||||
@@ -15,8 +15,8 @@ const KEY_VOTE_WINDOW = 12
|
||||
const KEY_VOTE_THRESHOLD = 9 // out of 12 — very stable
|
||||
|
||||
// Chord detection tuning
|
||||
const CHROMA_SMOOTH = 16 // frames to average (~250ms at 60fps)
|
||||
const CHORD_VOTE_THRESHOLD = 5 // consecutive agreements before commit
|
||||
const CHROMA_SMOOTH = 8 // frames to average (~130ms at 60fps)
|
||||
const CHORD_VOTE_THRESHOLD = 3 // consecutive agreements before commit
|
||||
|
||||
export default function App() {
|
||||
// ── Listening state ──────────────────────────────────────────────────────
|
||||
@@ -97,10 +97,9 @@ export default function App() {
|
||||
if (prev?.root === root && prev?.mode === mode) {
|
||||
return { root, mode, confidence: result.confidence }
|
||||
}
|
||||
// Key changed — clear chord history only if not locked
|
||||
// Key changed — reset chord votes but keep history visible
|
||||
if (!lockedKey) {
|
||||
chordVotesRef.current = []
|
||||
setChordHistory([])
|
||||
}
|
||||
return { root, mode, confidence: result.confidence }
|
||||
})
|
||||
|
||||
@@ -2,48 +2,75 @@ import { useEffect, useRef, useCallback } from 'react'
|
||||
import { PitchDetector } from 'pitchy'
|
||||
import { NOTES } from '../lib/theory'
|
||||
|
||||
// ─── Why two analysers? ───────────────────────────────────────────────────────
|
||||
//
|
||||
// The Web Audio FFT has linearly-spaced bins: bin width = sampleRate / fftSize.
|
||||
//
|
||||
// fftSize 4096 → ~10.8 Hz/bin (default we were using)
|
||||
// fftSize 16384 → ~2.7 Hz/bin (multi-rate chord analyser)
|
||||
//
|
||||
// On the low guitar strings the gap between adjacent semitones is only ~5-6 Hz.
|
||||
// At 10.8 Hz/bin we literally cannot separate A2 (110 Hz) from A#2 (116 Hz).
|
||||
// That is the single biggest source of wrong chord notes on the low strings.
|
||||
//
|
||||
// Solution: run a second, larger analyser just for chord/chroma detection.
|
||||
// The pitch analyser stays small (4096) so pitchy has a 90ms window — fast
|
||||
// enough for responsive pitch detection. The chord analyser uses 16384 (~370ms
|
||||
// window) — slower to respond but with 2.7 Hz bins that can cleanly separate
|
||||
// every semitone across the guitar's entire range.
|
||||
//
|
||||
// This is an approximation of the Constant-Q Transform (CQT) your friend
|
||||
// mentioned: CQT achieves log-spaced bins mathematically; we approximate it
|
||||
// by simply using a much larger FFT window.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const PITCH_FFT = 4096 // ~90ms window — good temporal resolution for pitch
|
||||
const CHORD_FFT = 16384 // ~370ms window — 2.7 Hz/bin, separates low semitones
|
||||
const MIN_CLARITY = 0.85
|
||||
const MIN_VOLUME = 0.01
|
||||
const FFT_SIZE = 4096 // larger = better frequency resolution
|
||||
const NOISE_FLOOR = -60 // dB — ignore bins quieter than this
|
||||
const NOISE_FLOOR = -65 // dB
|
||||
|
||||
// ─── Harmonic summation chroma ────────────────────────────────────────────────
|
||||
// Each FFT bin votes back toward lower fundamentals that could have generated
|
||||
// it as an overtone. This undoes the harmonic contamination that makes minor
|
||||
// chords look like major ones (the 5th harmonic of the root lands on the major
|
||||
// 3rd, which is NOT in the minor chord).
|
||||
const HARMONIC_WEIGHTS = [1.0, 0.5, 0.33, 0.25, 0.2] // h = 1…5
|
||||
|
||||
// Build 12-bin chroma from FFT power spectrum.
|
||||
// Restricts to guitar fundamental range and applies log compression.
|
||||
function computeChroma(freqData, sampleRate, fftSize) {
|
||||
const chroma = new Float32Array(12)
|
||||
const binHz = sampleRate / fftSize
|
||||
const N = freqData.length
|
||||
|
||||
for (let bin = 2; bin < freqData.length; bin++) {
|
||||
for (let bin = 2; bin < N; bin++) {
|
||||
const freq = bin * binHz
|
||||
if (freq < 75 || freq > 1400) continue // guitar fundamentals only
|
||||
if (freq < 80 || freq > 6000) continue
|
||||
const db = freqData[bin]
|
||||
if (db < NOISE_FLOOR) continue
|
||||
|
||||
// Power (db/10) discriminates harmonics better than amplitude (db/20)
|
||||
const power = Math.pow(10, db / 10)
|
||||
const midi = 12 * Math.log2(freq / 440) + 69
|
||||
const amp = Math.sqrt(Math.pow(10, db / 10)) // amplitude, not power
|
||||
|
||||
for (let h = 1; h <= HARMONIC_WEIGHTS.length; h++) {
|
||||
const fundamental = freq / h
|
||||
if (fundamental < 40 || fundamental > 2000) continue
|
||||
const midi = 12 * Math.log2(fundamental / 440) + 69
|
||||
const pc = ((Math.round(midi) % 12) + 12) % 12
|
||||
chroma[pc] += power
|
||||
chroma[pc] += amp * HARMONIC_WEIGHTS[h - 1]
|
||||
}
|
||||
}
|
||||
|
||||
// Log compression reduces dominance of very loud partials
|
||||
for (let i = 0; i < 12; i++) chroma[i] = Math.log1p(chroma[i] * 100)
|
||||
|
||||
for (let i = 0; i < 12; i++) chroma[i] = Math.log1p(chroma[i])
|
||||
const max = Math.max(...chroma)
|
||||
if (max > 0) for (let i = 0; i < 12; i++) chroma[i] /= max
|
||||
|
||||
return chroma
|
||||
}
|
||||
|
||||
// Find the dominant pitch class in the bass range (guitar lowest notes).
|
||||
// This gives us a strong root-note hint for chord matching.
|
||||
function detectBassPC(freqData, sampleRate, fftSize) {
|
||||
const binHz = sampleRate / fftSize
|
||||
let maxPower = 0, bestMidi = -1
|
||||
|
||||
for (let bin = 2; bin < freqData.length; bin++) {
|
||||
const freq = bin * binHz
|
||||
if (freq < 75 || freq > 350) continue
|
||||
if (freq < 40 || freq > 350) continue
|
||||
const db = freqData[bin]
|
||||
if (db < NOISE_FLOOR) continue
|
||||
const power = Math.pow(10, db / 10)
|
||||
@@ -58,10 +85,11 @@ function detectBassPC(freqData, sampleRate, fftSize) {
|
||||
|
||||
export default function AudioCapture({ onNote, onChroma, isListening }) {
|
||||
const audioCtxRef = useRef(null)
|
||||
const analyserRef = useRef(null)
|
||||
const detectorRef = useRef(null)
|
||||
const pitchAnalyser = useRef(null)
|
||||
const chordAnalyser = useRef(null)
|
||||
const timeBufRef = useRef(null)
|
||||
const freqBufRef = useRef(null)
|
||||
const detectorRef = useRef(null)
|
||||
const rafRef = useRef(null)
|
||||
const streamRef = useRef(null)
|
||||
|
||||
@@ -79,25 +107,32 @@ export default function AudioCapture({ onNote, onChroma, isListening }) {
|
||||
|
||||
const ctx = new AudioContext()
|
||||
audioCtxRef.current = ctx
|
||||
const source = ctx.createMediaStreamSource(stream)
|
||||
|
||||
const analyser = ctx.createAnalyser()
|
||||
analyser.fftSize = FFT_SIZE
|
||||
analyser.smoothingTimeConstant = 0.6 // smooth FFT over time
|
||||
analyserRef.current = analyser
|
||||
// Small analyser — pitch detection needs fast time-domain data
|
||||
const pa = ctx.createAnalyser()
|
||||
pa.fftSize = PITCH_FFT
|
||||
pa.smoothingTimeConstant = 0.0 // no smoothing: pitchy needs clean waveform
|
||||
pitchAnalyser.current = pa
|
||||
source.connect(pa)
|
||||
timeBufRef.current = new Float32Array(pa.fftSize)
|
||||
detectorRef.current = PitchDetector.forFloat32Array(pa.fftSize)
|
||||
|
||||
ctx.createMediaStreamSource(stream).connect(analyser)
|
||||
|
||||
timeBufRef.current = new Float32Array(analyser.fftSize)
|
||||
freqBufRef.current = new Float32Array(analyser.frequencyBinCount)
|
||||
detectorRef.current = PitchDetector.forFloat32Array(analyser.fftSize)
|
||||
// Large analyser — chord detection needs fine frequency resolution
|
||||
const ca = ctx.createAnalyser()
|
||||
ca.fftSize = CHORD_FFT
|
||||
ca.smoothingTimeConstant = 0.65 // smooth over time for stable chord reading
|
||||
chordAnalyser.current = ca
|
||||
source.connect(ca)
|
||||
freqBufRef.current = new Float32Array(ca.frequencyBinCount)
|
||||
|
||||
function tick() {
|
||||
const timeBuf = timeBufRef.current
|
||||
analyser.getFloatTimeDomainData(timeBuf)
|
||||
pa.getFloatTimeDomainData(timeBuf)
|
||||
|
||||
const rms = Math.sqrt(timeBuf.reduce((s, v) => s + v * v, 0) / timeBuf.length)
|
||||
if (rms >= MIN_VOLUME) {
|
||||
// Pitch — used for key detection
|
||||
// Pitch via McLeod (autocorrelation) — unaffected by FFT bin size
|
||||
const [freq, clarity] = detectorRef.current.findPitch(timeBuf, ctx.sampleRate)
|
||||
if (clarity >= MIN_CLARITY && freq > 60 && freq < 4200) {
|
||||
const midi = Math.round(12 * Math.log2(freq / 440) + 69)
|
||||
@@ -105,13 +140,14 @@ export default function AudioCapture({ onNote, onChroma, isListening }) {
|
||||
onNote({ noteName: NOTES[pitchClass], pitchClass, freq, midi, clarity })
|
||||
}
|
||||
|
||||
// Chroma + bass — used for chord detection
|
||||
// Chord chroma from the high-resolution FFT
|
||||
if (onChroma) {
|
||||
const freqBuf = freqBufRef.current
|
||||
analyser.getFloatFrequencyData(freqBuf)
|
||||
const chroma = computeChroma(freqBuf, ctx.sampleRate, analyser.fftSize)
|
||||
const bassPC = detectBassPC(freqBuf, ctx.sampleRate, analyser.fftSize)
|
||||
onChroma(chroma, bassPC)
|
||||
ca.getFloatFrequencyData(freqBuf)
|
||||
onChroma(
|
||||
computeChroma(freqBuf, ctx.sampleRate, ca.fftSize),
|
||||
detectBassPC(freqBuf, ctx.sampleRate, ca.fftSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +1,38 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { toRomanNumeral } from '../lib/theory'
|
||||
|
||||
// Sizes for the chord trail (oldest → current)
|
||||
const TRAIL_SIZES = [
|
||||
'text-lg opacity-20',
|
||||
'text-xl opacity-30',
|
||||
'text-2xl opacity-45',
|
||||
'text-3xl opacity-60',
|
||||
'text-4xl opacity-80',
|
||||
]
|
||||
const CURRENT_SIZE = 'text-7xl opacity-100'
|
||||
const HISTORY_SHOWN = 8 // ~2 bars at 4 chords/bar
|
||||
|
||||
function findLoopPosition(chordHistory, progression) {
|
||||
if (!progression?.length || !chordHistory.length) return -1
|
||||
const len = progression.length
|
||||
// Walk backwards through the progression to find where current chord sits
|
||||
for (let p = len - 1; p >= 0; p--) {
|
||||
if (progression[p] !== chordHistory[chordHistory.length - 1]) continue
|
||||
const last = chordHistory[chordHistory.length - 1]
|
||||
for (let p = progression.length - 1; p >= 0; p--) {
|
||||
if (progression[p] !== last) continue
|
||||
let match = true
|
||||
for (let i = 1; i < Math.min(p + 1, chordHistory.length); i++) {
|
||||
if (progression[p - i] !== chordHistory[chordHistory.length - 1 - i]) {
|
||||
match = false; break
|
||||
}
|
||||
if (progression[p - i] !== chordHistory[chordHistory.length - 1 - i]) { match = false; break }
|
||||
}
|
||||
if (match) return p
|
||||
}
|
||||
return progression.indexOf(chordHistory[chordHistory.length - 1])
|
||||
return progression.indexOf(last)
|
||||
}
|
||||
|
||||
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression }) {
|
||||
const { root, mode } = keyInfo ?? {}
|
||||
|
||||
// Show up to 5 previous chords + current
|
||||
const trail = chordHistory.slice(-6, -1) // up to 5 previous
|
||||
const current = chordHistory[chordHistory.length - 1]
|
||||
// Newest chord is the last entry; we show the most recent HISTORY_SHOWN
|
||||
const visible = chordHistory.slice(-HISTORY_SHOWN)
|
||||
const current = visible[visible.length - 1]
|
||||
|
||||
// Flash the current chord when it changes
|
||||
// Animate the current chord slot when it changes
|
||||
const currentRef = useRef(null)
|
||||
const prevChord = useRef(null)
|
||||
useEffect(() => {
|
||||
if (current && current !== prevChord.current && currentRef.current) {
|
||||
currentRef.current.animate(
|
||||
[{ opacity: 0, transform: 'translateY(8px) scale(0.9)' },
|
||||
{ opacity: 1, transform: 'translateY(0) scale(1)' }],
|
||||
{ duration: 220, easing: 'ease-out', fill: 'forwards' }
|
||||
[{ opacity: 0, transform: 'scale(0.85)' },
|
||||
{ opacity: 1, transform: 'scale(1)' }],
|
||||
{ duration: 200, easing: 'ease-out', fill: 'forwards' }
|
||||
)
|
||||
prevChord.current = current
|
||||
}
|
||||
@@ -53,49 +42,55 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
||||
|
||||
if (!chordHistory.length) {
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6 mb-4 flex items-center justify-center h-36">
|
||||
<p className="text-gray-600 text-lg">Start listening to detect chords…</p>
|
||||
<div className="bg-panel border border-border rounded-2xl p-5 mb-4 flex items-center justify-center h-28">
|
||||
<p className="text-gray-600">Start listening to detect chords…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-panel border border-border rounded-2xl p-6 mb-4">
|
||||
{/* ── Chord trail ── */}
|
||||
<div className="flex items-end gap-3 overflow-x-auto pb-1 min-h-[96px]">
|
||||
{trail.map((chord, i) => {
|
||||
const sizeClass = TRAIL_SIZES[Math.max(0, i - (trail.length - TRAIL_SIZES.length))]
|
||||
<div className="bg-panel border border-border rounded-2xl p-5 mb-4">
|
||||
|
||||
{/* ── Chord history strip: all HISTORY_SHOWN chords at consistent size ── */}
|
||||
<div className="flex items-stretch gap-1 overflow-x-auto pb-1">
|
||||
{visible.map((chord, i) => {
|
||||
const isCurrent = i === visible.length - 1
|
||||
const age = visible.length - 1 - i // 0 = current, higher = older
|
||||
const opacity = Math.max(0.2, 1 - age * 0.1) // fade but stay readable
|
||||
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
||||
|
||||
return (
|
||||
<div key={`${chord}-${i}`} className={`flex flex-col items-center shrink-0 transition-all duration-300 ${sizeClass}`}>
|
||||
<span className="font-bold text-gray-300 leading-none">{chord}</span>
|
||||
<span className="text-xs text-gray-600 mt-1">{rn}</span>
|
||||
<div
|
||||
key={i}
|
||||
ref={isCurrent ? currentRef : null}
|
||||
style={{ opacity }}
|
||||
className={`
|
||||
flex flex-col items-center justify-end shrink-0 px-3 py-2 rounded-xl
|
||||
transition-colors duration-200
|
||||
${isCurrent
|
||||
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20'
|
||||
: 'border border-transparent'}
|
||||
`}
|
||||
>
|
||||
<span className={`font-black leading-none tracking-tight ${
|
||||
isCurrent ? 'text-5xl text-accent' : 'text-3xl text-gray-200'
|
||||
}`}>
|
||||
{chord}
|
||||
</span>
|
||||
<span className={`text-xs font-semibold mt-1 ${
|
||||
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
||||
}`}>
|
||||
{rn || '\u00A0'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Arrow between trail and current */}
|
||||
{trail.length > 0 && (
|
||||
<span className="text-gray-600 text-2xl mb-2 shrink-0">›</span>
|
||||
)}
|
||||
|
||||
{/* Current chord — BIG */}
|
||||
{current && (
|
||||
<div ref={currentRef} className={`flex flex-col items-center shrink-0 ${CURRENT_SIZE}`}>
|
||||
<span className="font-black text-accent leading-none tracking-tight">{current}</span>
|
||||
<span className="text-base text-amber-400 mt-1 font-semibold">
|
||||
{root ? toRomanNumeral(current, root, mode) : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Detected loop ── */}
|
||||
{detectedProgression && (
|
||||
<div className="mt-5 pt-4 border-t border-border">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest mb-3">
|
||||
♻ Detected loop
|
||||
</p>
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-widest mb-2">♻ Detected loop</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{detectedProgression.map((chord, i) => {
|
||||
const isActive = i === loopPos
|
||||
@@ -105,7 +100,7 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
||||
key={i}
|
||||
className={`flex flex-col items-center px-4 py-2 rounded-xl border transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-accent/20 border-accent shadow-[0_0_12px_rgba(168,85,247,0.4)]'
|
||||
? 'bg-accent/20 border-accent shadow-[0_0_14px_rgba(168,85,247,0.35)]'
|
||||
: 'bg-border border-border'
|
||||
}`}
|
||||
>
|
||||
@@ -118,9 +113,7 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex items-center text-gray-600 text-sm pl-1">
|
||||
→ loop
|
||||
</div>
|
||||
<span className="self-center text-gray-600 text-sm pl-1">→ loop</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+23
-11
@@ -177,12 +177,19 @@ export function matchChordFromChroma(chroma, keyInfo, bassPC = null, strictDiato
|
||||
|
||||
const diatonic = new Set(getChordsInKey(keyInfo.root, keyInfo.mode))
|
||||
|
||||
// Only match triads — more reliable for live guitar than extended chords
|
||||
const triadTypes = [CHORD_TYPES.maj, CHORD_TYPES.min, CHORD_TYPES.dim]
|
||||
// Match triads + dominant 7ths (blues/rock/band) + sus chords (rock guitar)
|
||||
const matchTypes = [
|
||||
CHORD_TYPES.maj,
|
||||
CHORD_TYPES.min,
|
||||
CHORD_TYPES.dom7,
|
||||
CHORD_TYPES.min7,
|
||||
CHORD_TYPES.dim,
|
||||
CHORD_TYPES.sus4,
|
||||
]
|
||||
let best = { name: null, score: -Infinity }
|
||||
|
||||
for (let r = 0; r < 12; r++) {
|
||||
for (const type of triadTypes) {
|
||||
for (const type of matchTypes) {
|
||||
const tones = new Set(type.intervals.map(i => (r + i) % 12))
|
||||
const chordName = noteName(r) + type.suffix
|
||||
|
||||
@@ -190,23 +197,28 @@ export function matchChordFromChroma(chroma, keyInfo, bassPC = null, strictDiato
|
||||
|
||||
let inEnergy = 0, outEnergy = 0
|
||||
for (let pc = 0; pc < 12; pc++) {
|
||||
if (tones.has(pc)) inEnergy += chroma[pc]
|
||||
else outEnergy += chroma[pc]
|
||||
if (pc === r) {
|
||||
// Root note is the strongest identity signal — weight it double
|
||||
inEnergy += chroma[pc] * 2
|
||||
} else if (tones.has(pc)) {
|
||||
inEnergy += chroma[pc]
|
||||
} else {
|
||||
outEnergy += chroma[pc]
|
||||
}
|
||||
}
|
||||
if (inEnergy + outEnergy < 0.05) continue
|
||||
|
||||
// Core score: fraction of energy on chord tones, penalise noise
|
||||
const coverageScore = inEnergy / (inEnergy + outEnergy * 0.6)
|
||||
// Bass note matching the chord root is a strong harmonic signal
|
||||
const bassBonus = (bassPC !== null && r === bassPC) ? 0.4 : 0
|
||||
const diatonicBonus = diatonic.has(chordName) ? 0.2 : 0
|
||||
const coverageScore = inEnergy / (inEnergy + outEnergy * 0.5)
|
||||
// Bass note matching chord root is a strong harmonic signal
|
||||
const bassBonus = (bassPC !== null && r === bassPC) ? 0.35 : 0
|
||||
const diatonicBonus = diatonic.has(chordName) ? 0.15 : 0
|
||||
|
||||
const finalScore = coverageScore + bassBonus + diatonicBonus
|
||||
if (finalScore > best.score) best = { name: chordName, score: finalScore }
|
||||
}
|
||||
}
|
||||
|
||||
return best.score > 0.45 ? best.name : null
|
||||
return best.score > 0.42 ? best.name : null
|
||||
}
|
||||
|
||||
// ─── Roman numeral notation ───────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user