|
| 1 | +#define _USE_MATH_DEFINES |
| 2 | +#include "ChordDetector.h" |
| 3 | +#include <cmath> |
| 4 | +#include <algorithm> |
| 5 | +#include <vector> |
| 6 | +#include <array> |
| 7 | + |
| 8 | +//============================================================================== |
| 9 | +// CHORD-BASED KEY DETECTION (replaces weak chroma→K-S direct approach) |
| 10 | +// |
| 11 | +// Pipeline: |
| 12 | +// Pass 1 — FFT per frame → per-frame chroma + spectral flux |
| 13 | +// Pass 2 — reject percussive frames (drum transients pollute harmony) |
| 14 | +// Pass 3 — detect best triad chord per tonal frame (cosine similarity) |
| 15 | +// Pass 4 — score 24 keys by chord-key compatibility table (music theory) |
| 16 | +// Fallback — K-S correlation on accumulated chroma if chord data sparse |
| 17 | +// BPM — autocorrelation of full onset envelope (drums help here) |
| 18 | +//============================================================================== |
| 19 | +static constexpr int kFFTOrder = 15; // 2^15 = 32768 |
| 20 | +static constexpr int kFFTSize = 1 << kFFTOrder; |
| 21 | +static constexpr int kHopSize = 2048; |
| 22 | +static constexpr double kRefHz = 261.626; // C4 — NEVER change to 440 |
| 23 | + |
| 24 | +// K-S profiles — fallback only, same as Python BRAIN MAJ/MIN_PROFILE |
| 25 | +static const float kMaj[12] = { 6.35f,2.23f,3.48f,2.33f,4.38f,4.09f, |
| 26 | + 2.52f,5.19f,2.39f,3.66f,2.29f,2.88f }; |
| 27 | +static const float kMin[12] = { 6.33f,2.68f,3.52f,5.38f,2.60f,3.53f, |
| 28 | + 2.54f,4.75f,3.98f,2.69f,3.34f,3.17f }; |
| 29 | +static const char* kNotes[12] = { "C","Db","D","Eb","E","F", |
| 30 | + "Gb","G","Ab","A","Bb","B" }; |
| 31 | + |
| 32 | +//============================================================================== |
| 33 | +// Chord-key compatibility |
| 34 | +// Chord index: 0-11 = major (root 0-11), 12-23 = minor (root 0-11) |
| 35 | +// Key index: same encoding |
| 36 | +// Returns weight 0-5 (0 = not diatonic, 5 = tonic) |
| 37 | +//============================================================================== |
| 38 | +static int chordKeyCompat (int chord, int key) |
| 39 | +{ |
| 40 | + int cr = chord % 12; |
| 41 | + bool cmaj = chord < 12; |
| 42 | + int kr = key % 12; |
| 43 | + bool kmaj = key < 12; |
| 44 | + int iv = (cr - kr + 12) % 12; // chord root interval above key root |
| 45 | + |
| 46 | + if (kmaj) // major key diatonic chords |
| 47 | + { |
| 48 | + if (cmaj) { if (iv==0) return 5; // I |
| 49 | + if (iv==5) return 4; // IV |
| 50 | + if (iv==7) return 4; // V |
| 51 | + } |
| 52 | + else { if (iv==2) return 3; // ii |
| 53 | + if (iv==4) return 2; // iii |
| 54 | + if (iv==9) return 3; // vi |
| 55 | + if (iv==11) return 1; // vii° |
| 56 | + } |
| 57 | + } |
| 58 | + else // natural minor key diatonic chords |
| 59 | + { |
| 60 | + if (!cmaj) { if (iv==0) return 5; // i |
| 61 | + if (iv==5) return 3; // iv |
| 62 | + if (iv==7) return 2; // v |
| 63 | + if (iv==2) return 1; // ii° |
| 64 | + } |
| 65 | + else { if (iv==3) return 2; // III |
| 66 | + if (iv==8) return 3; // VI |
| 67 | + if (iv==10) return 4; // VII |
| 68 | + } |
| 69 | + } |
| 70 | + return 0; |
| 71 | +} |
| 72 | + |
| 73 | +//============================================================================== |
| 74 | +static float pearsonCorr (const float* a, const float* b, int n) |
| 75 | +{ |
| 76 | + float ma = 0.0f, mb = 0.0f; |
| 77 | + for (int i = 0; i < n; ++i) { ma += a[i]; mb += b[i]; } |
| 78 | + ma /= n; mb /= n; |
| 79 | + float num = 0.0f, varA = 0.0f, varB = 0.0f; |
| 80 | + for (int i = 0; i < n; ++i) |
| 81 | + { |
| 82 | + float ea = a[i] - ma, eb = b[i] - mb; |
| 83 | + num += ea * eb; varA += ea * ea; varB += eb * eb; |
| 84 | + } |
| 85 | + float denom = std::sqrt (varA * varB); |
| 86 | + return denom < 1e-9f ? 0.0f : num / denom; |
| 87 | +} |
| 88 | + |
| 89 | +//============================================================================== |
| 90 | +ChordDetectResult chordDetectBpmAndKey (const float* mono, |
| 91 | + int numSamples, |
| 92 | + double sampleRate) |
| 93 | +{ |
| 94 | + if (numSamples < kFFTSize) return {}; |
| 95 | + |
| 96 | + std::vector<float> hann (kFFTSize); |
| 97 | + for (int i = 0; i < kFFTSize; ++i) |
| 98 | + hann[i] = 0.5f * (1.0f - std::cos (2.0f * float (M_PI) * i / (kFFTSize - 1))); |
| 99 | + |
| 100 | + juce::dsp::FFT fft (kFFTOrder); |
| 101 | + std::vector<float> fftBuf (kFFTSize * 2); |
| 102 | + |
| 103 | + int numFrames = (numSamples - kFFTSize) / kHopSize; |
| 104 | + if (numFrames <= 0) return {}; |
| 105 | + |
| 106 | + // 24 chord templates: major triad = {root, M3 (+4), P5 (+7)}, minor = {root, m3 (+3), P5 (+7)} |
| 107 | + // Each template is L2-normalised (3 active bins → factor = 1/sqrt(3)) |
| 108 | + static constexpr float kInvSqrt3 = 0.57735027f; |
| 109 | + std::vector<std::array<float, 12>> tmpl (24); |
| 110 | + for (int r = 0; r < 12; ++r) |
| 111 | + { |
| 112 | + tmpl[r].fill (0.0f); |
| 113 | + tmpl[r][r % 12] = kInvSqrt3; |
| 114 | + tmpl[r][(r + 4) % 12] = kInvSqrt3; // major third |
| 115 | + tmpl[r][(r + 7) % 12] = kInvSqrt3; // perfect fifth |
| 116 | + |
| 117 | + tmpl[r + 12].fill (0.0f); |
| 118 | + tmpl[r + 12][r % 12] = kInvSqrt3; |
| 119 | + tmpl[r + 12][(r+3) % 12] = kInvSqrt3; // minor third |
| 120 | + tmpl[r + 12][(r+7) % 12] = kInvSqrt3; |
| 121 | + } |
| 122 | + |
| 123 | + // Per-frame storage (~32 KB for 15 s at 44100/2048) |
| 124 | + std::vector<std::array<double, 12>> frameCh (numFrames); |
| 125 | + std::vector<float> onsetEnv (numFrames, 0.0f); |
| 126 | + std::vector<float> prevMag (kFFTSize / 2 + 1, 0.0f); |
| 127 | + |
| 128 | + //========================================================================== |
| 129 | + // Pass 1 — FFT all frames: chroma + spectral flux |
| 130 | + //========================================================================== |
| 131 | + for (int frame = 0; frame < numFrames; ++frame) |
| 132 | + { |
| 133 | + const float* src = mono + frame * kHopSize; |
| 134 | + std::fill (fftBuf.begin(), fftBuf.end(), 0.0f); |
| 135 | + for (int i = 0; i < kFFTSize; ++i) |
| 136 | + fftBuf[i] = src[i] * hann[i]; |
| 137 | + |
| 138 | + fft.performFrequencyOnlyForwardTransform (fftBuf.data()); |
| 139 | + const int numBins = kFFTSize / 2; |
| 140 | + |
| 141 | + float peakMag = 0.0f; |
| 142 | + for (int b = 1; b < numBins; ++b) |
| 143 | + if (fftBuf[b] > peakMag) peakMag = fftBuf[b]; |
| 144 | + float noiseFloor = peakMag * peakMag * 1e-3f; |
| 145 | + |
| 146 | + // Spectral flux |
| 147 | + float flux = 0.0f; |
| 148 | + for (int b = 1; b < numBins; ++b) |
| 149 | + { |
| 150 | + float diff = fftBuf[b] - prevMag[b]; |
| 151 | + if (diff > 0.0f) flux += diff; |
| 152 | + prevMag[b] = fftBuf[b]; |
| 153 | + } |
| 154 | + onsetEnv[frame] = flux; |
| 155 | + |
| 156 | + // Chroma with inverse-freq weighting (fundmentals > harmonics, like CQT) |
| 157 | + auto& ch = frameCh[frame]; |
| 158 | + ch.fill (0.0); |
| 159 | + for (int bin = 1; bin < numBins; ++bin) |
| 160 | + { |
| 161 | + double freq = bin * sampleRate / kFFTSize; |
| 162 | + if (freq < 27.5 || freq > 4200.0) continue; |
| 163 | + float power = fftBuf[bin] * fftBuf[bin]; |
| 164 | + if (power < noiseFloor) continue; |
| 165 | + |
| 166 | + double pc = 12.0 * std::log2 (freq / kRefHz); |
| 167 | + pc = std::fmod (pc, 12.0); |
| 168 | + if (pc < 0.0) pc += 12.0; |
| 169 | + |
| 170 | + int lo = (int) pc % 12; |
| 171 | + int hi = (lo + 1) % 12; |
| 172 | + double frac = pc - std::floor (pc); |
| 173 | + double w = kRefHz / std::max (kRefHz, freq); // 1.0 at C4, falls above |
| 174 | + |
| 175 | + ch[lo] += power * (1.0 - frac) * w; |
| 176 | + ch[hi] += power * frac * w; |
| 177 | + } |
| 178 | + } |
| 179 | + |
| 180 | + //========================================================================== |
| 181 | + // Pass 2 — reject percussive frames (flux > median × 3) |
| 182 | + // Median is more robust than mean when hard drum hits skew the average |
| 183 | + //========================================================================== |
| 184 | + std::vector<float> sf = onsetEnv; |
| 185 | + std::sort (sf.begin(), sf.end()); |
| 186 | + float medFlux = sf[numFrames / 2]; |
| 187 | + float fluxThr = std::max (medFlux * 3.0f, 1e-6f); |
| 188 | + |
| 189 | + //========================================================================== |
| 190 | + // Pass 3 — chord detection on tonal frames |
| 191 | + //========================================================================== |
| 192 | + int chordHist[24] = {}; // how many frames each chord was detected |
| 193 | + double chromaTotal[12] = {}; // accumulated chroma for K-S fallback |
| 194 | + |
| 195 | + for (int frame = 0; frame < numFrames; ++frame) |
| 196 | + { |
| 197 | + if (onsetEnv[frame] > fluxThr) continue; // skip percussive frame |
| 198 | + |
| 199 | + const auto& ch = frameCh[frame]; |
| 200 | + |
| 201 | + // L2-normalise frame chroma |
| 202 | + double norm = 0.0; |
| 203 | + for (int i = 0; i < 12; ++i) norm += ch[i] * ch[i]; |
| 204 | + if (norm < 1e-9) continue; |
| 205 | + norm = std::sqrt (norm); |
| 206 | + |
| 207 | + float cn[12]; |
| 208 | + for (int i = 0; i < 12; ++i) |
| 209 | + { |
| 210 | + cn[i] = float (ch[i] / norm); |
| 211 | + chromaTotal[i] += ch[i]; |
| 212 | + } |
| 213 | + |
| 214 | + // Cosine similarity with 24 chord templates (templates already unit-norm) |
| 215 | + float bestSim = 0.0f; |
| 216 | + int bestC = -1; |
| 217 | + for (int c = 0; c < 24; ++c) |
| 218 | + { |
| 219 | + float sim = 0.0f; |
| 220 | + for (int i = 0; i < 12; ++i) sim += cn[i] * tmpl[c][i]; |
| 221 | + if (sim > bestSim) { bestSim = sim; bestC = c; } |
| 222 | + } |
| 223 | + // Threshold > flat-chroma baseline (~0.50) to require real harmonic structure |
| 224 | + if (bestC >= 0 && bestSim > 0.55f) |
| 225 | + chordHist[bestC]++; |
| 226 | + } |
| 227 | + |
| 228 | + //========================================================================== |
| 229 | + // Pass 4 — key scoring: chord-key compatibility (primary) + K-S (fallback) |
| 230 | + //========================================================================== |
| 231 | + int totalChordVotes = 0; |
| 232 | + for (int c = 0; c < 24; ++c) totalChordVotes += chordHist[c]; |
| 233 | + |
| 234 | + float keyScore[24] = {}; |
| 235 | + |
| 236 | + // Primary: sum chord votes × compatibility weight |
| 237 | + if (totalChordVotes > 0) |
| 238 | + for (int k = 0; k < 24; ++k) |
| 239 | + for (int c = 0; c < 24; ++c) |
| 240 | + keyScore[k] += float (chordHist[c]) * chordKeyCompat (c, k); |
| 241 | + |
| 242 | + // Secondary: K-S Pearson correlation on accumulated chroma |
| 243 | + // Weighted so chord votes dominate when abundant; K-S leads when chords sparse |
| 244 | + { |
| 245 | + float chromaF[12]; |
| 246 | + double sum = 0; |
| 247 | + for (int i = 0; i < 12; ++i) sum += chromaTotal[i]; |
| 248 | + for (int i = 0; i < 12; ++i) |
| 249 | + chromaF[i] = (sum > 0) ? float (chromaTotal[i] / sum) : 0.0f; |
| 250 | + |
| 251 | + // Scale: chord score can reach totalChordVotes*5; keep K-S influence small |
| 252 | + float ksW = (totalChordVotes > 10) ? float (totalChordVotes) * 0.05f : 5.0f; |
| 253 | + |
| 254 | + for (int root = 0; root < 12; ++root) |
| 255 | + { |
| 256 | + float rot[12]; |
| 257 | + for (int i = 0; i < 12; ++i) rot[i] = chromaF[(i + root) % 12]; |
| 258 | + keyScore[root] += pearsonCorr (rot, kMaj, 12) * ksW; |
| 259 | + keyScore[root + 12] += pearsonCorr (rot, kMin, 12) * ksW; |
| 260 | + } |
| 261 | + } |
| 262 | + |
| 263 | + // Best key |
| 264 | + int bestK = 0; |
| 265 | + for (int k = 1; k < 24; ++k) |
| 266 | + if (keyScore[k] > keyScore[bestK]) bestK = k; |
| 267 | + |
| 268 | + juce::String key = juce::String (kNotes[bestK % 12]) + (bestK < 12 ? "maj" : "min"); |
| 269 | + |
| 270 | + //========================================================================== |
| 271 | + // BPM — autocorrelation of full onset envelope (all frames, drums included) |
| 272 | + //========================================================================== |
| 273 | + double bpm = 120.0; |
| 274 | + int N = (int) onsetEnv.size(); |
| 275 | + |
| 276 | + if (N > 8) |
| 277 | + { |
| 278 | + double framesPerSec = sampleRate / kHopSize; |
| 279 | + int lagMin = std::max (1, (int) (framesPerSec * 60.0 / 220.0)); |
| 280 | + int lagMax = std::min (N / 2, (int) (framesPerSec * 60.0 / 50.0)); |
| 281 | + |
| 282 | + float bestAC = 0.0f; |
| 283 | + int bestLag = lagMin; |
| 284 | + for (int lag = lagMin; lag <= lagMax; ++lag) |
| 285 | + { |
| 286 | + float ac = 0.0f; |
| 287 | + for (int i = 0; i < N - lag; ++i) |
| 288 | + ac += onsetEnv[i] * onsetEnv[i + lag]; |
| 289 | + if (ac > bestAC) { bestAC = ac; bestLag = lag; } |
| 290 | + } |
| 291 | + |
| 292 | + bpm = framesPerSec * 60.0 / bestLag; |
| 293 | + if (bpm < 80.0 && bpm * 2.0 <= 220.0) bpm *= 2.0; |
| 294 | + if (bpm > 160.0 && bpm / 2.0 >= 50.0) bpm /= 2.0; |
| 295 | + } |
| 296 | + |
| 297 | + return { bpm, key }; |
| 298 | +} |
0 commit comments