Skip to content

Commit 9441ac3

Browse files
grauxmusicclaude
andcommitted
feat: chord-based key detection + Scan button + Windows installer fix
- ChordDetector.h/.cpp: CHORDS SSOT — triad matching + chord-key scoring replaces direct chroma→K-S; percussive frame rejection filters drums - LocalDetector.cpp: updated to use same chord-based pipeline - PluginProcessor: add resetAnalysis() public method - PluginEditor: Re-Scan button triggers resetAnalysis() on click - CompanionWin: perMachine=false (no admin UAC) + VST3 to %APPDATA%\VST3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e58ca26 commit 9441ac3

10 files changed

Lines changed: 920 additions & 325 deletions

CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ juce_add_plugin(Morph
2020
FORMATS AU VST3
2121
PRODUCT_NAME "Morph"
2222
IS_SYNTH FALSE
23-
NEEDS_MIDI_INPUT FALSE
23+
NEEDS_MIDI_INPUT TRUE
2424
NEEDS_MIDI_OUTPUT FALSE
2525
IS_MIDI_EFFECT FALSE
2626
EDITOR_WANTS_KEYBOARD_FOCUS TRUE
@@ -39,6 +39,7 @@ target_sources(Morph PRIVATE
3939
Source/DropZoneComponent.cpp
4040
Source/WaterAPI.cpp
4141
Source/MorphEngine.cpp
42+
Source/ChordDetector.cpp
4243
Source/LocalDetector.cpp
4344
Source/CompanionLink.cpp
4445
)

CompanionWin/installer.nsh

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
; Custom NSIS sections injected by electron-builder
2-
; Installs Morph.vst3 to the system VST3 directory and removes it on uninstall.
3-
4-
!macro customHeader
5-
; Disable CRC integrity check — prevents "Installer integrity check has failed"
6-
; error when the original installer EXE is no longer in Downloads.
7-
CRCCheck off
8-
!macroend
2+
; Installs Morph.vst3 to the user VST3 directory (no admin required).
3+
; Standard user VST3 path: %APPDATA%\VST3\ — recognized by FL Studio, Ableton,
4+
; Bitwig, Studio One, Reaper, and most modern DAWs on Windows.
95

106
!macro customInstall
11-
CreateDirectory "$PROGRAMFILES64\Common Files\VST3"
12-
nsExec::ExecToLog 'cmd /C xcopy /E /I /Y "$INSTDIR\resources\Morph.vst3" "$PROGRAMFILES64\Common Files\VST3\Morph.vst3"'
7+
; $APPDATA = C:\Users\<user>\AppData\Roaming (no admin required)
8+
CreateDirectory "$APPDATA\VST3"
9+
nsExec::ExecToLog 'cmd /C xcopy /E /I /Y "$INSTDIR\resources\Morph.vst3" "$APPDATA\VST3\Morph.vst3"'
1310
!macroend
1411

1512
!macro customUnInstall
16-
RMDir /r "$PROGRAMFILES64\Common Files\VST3\Morph.vst3"
13+
RMDir /r "$APPDATA\VST3\Morph.vst3"
1714
!macroend

CompanionWin/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
}
2727
],
2828
"win": {
29+
"icon": "assets/icon.ico",
2930
"target": [
3031
{
3132
"target": "nsis",
@@ -34,9 +35,10 @@
3435
]
3536
}
3637
],
37-
"artifactName": "Water_Morph_1.0.5_Windows.exe"
38+
"artifactName": "Water_Morph_${version}_Windows.exe"
3839
},
3940
"mac": {
41+
"icon": "assets/icon.icns",
4042
"target": [
4143
{
4244
"target": "dmg",
@@ -46,11 +48,11 @@
4648
]
4749
}
4850
],
49-
"artifactName": "Water_Morph_1.0.3_macOS.dmg"
51+
"artifactName": "Water_Morph_${version}_macOS.dmg"
5052
},
5153
"nsis": {
5254
"oneClick": true,
53-
"perMachine": true,
55+
"perMachine": false,
5456
"allowToChangeInstallationDirectory": false,
5557
"runAfterFinish": true,
5658
"createStartMenuShortcut": true,

Source/ChordDetector.cpp

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
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

Comments
 (0)