Clean release snapshot

This commit is contained in:
Byron Gamatos
2026-06-16 18:48:12 +02:00
commit bd603184d5
291 changed files with 47318 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
#pragma once
// ChordScorer — native port of the notedetect plugin's polyphonic
// chord-scoring math (slopsmith-plugin-notedetect/screen.js). Constitution
// II requires audio analysis to live in JUCE, not renderer JS; the
// renderer calls in over IPC (`audio:scoreChord`) and consumes the
// returned result object.
//
// The math is a direct C++ translation of the JS originals
// (`_ndFftMagnitude`, `_ndStringBandHz`, `_ndBandEnergy`, `_ndTotalEnergy`,
// `_ndConstraintCheckString`, `_ndScoreChord`). The custom radix-2
// Cooley-Tukey in JS is replaced with `juce::dsp::FFT`; the rest of the
// helpers are line-for-line translations. Behavioural parity with the
// browser path is the target — bin-for-bin floating-point identity is
// not, since the JS FFT and `juce::dsp::FFT` evaluate the butterflies
// in different orders and may also use vectorised intrinsics natively.
#include <juce_dsp/juce_dsp.h>
#include <memory>
#include <string>
#include <vector>
class ChordScorer
{
public:
// Standard-tuning MIDI base for the supported (arrangement, stringCount)
// pairs. Lifted from screen.js `_ND_TUNING_*` constants verbatim so the
// open-string MIDI values match between the native and JS paths.
// Returns `nullptr` for unsupported pairs (e.g. "guitar" + 5-string,
// or any unknown arrangement string) — caller is expected to fail
// the request rather than guess a fallback tuning.
static const std::vector<int>* standardMidiFor(const std::string& arrangement, int stringCount);
// Hard upper bound on the FFT size we will ever build. The 3 Hz
// bin-width floor in scoreChord() implies fftSize ≈ nextPow2(SR/3),
// which is 16384 at 48 kHz, 32768 at 96 kHz, 65536 at 192 kHz —
// already the largest realistic audio-interface rate. Bounding this
// here protects the addon against caller-controlled `numSamples`
// forcing pathological reallocations of the FFT plan and scratch
// buffers over IPC.
static constexpr int kMaxFftSize = 65536;
// One chord-note in the request payload. Mirrors the chart-note shape
// the JS chord scorer consumes from `matchNotes()`: `s` = string
// index, `f` = fret, plus optional technique flags that adjust
// per-string thresholds.
struct Note
{
int string = 0;
int fret = 0;
bool hammerOn = false; // ho — no pick attack, lower energy threshold
bool pullOff = false; // po — same
bool bend = false; // b — pitch moving, widen pitch window
bool slide = false; // sl — same
bool harmonic = false; // hm — energy-only check, skip pitch
};
// Per-note scoring result. Same field names as the JS shape so the
// N-API wrapper can map straight through and the renderer-side
// consumer is identical to the browser path.
struct NoteResult
{
int string = 0;
int fret = 0;
bool hit = false;
float bandEnergy = 0.0f;
// centsDiff is the absolute pitch deviation; centsError is signed
// (positive = sharp). Both are valid only when the band-energy
// threshold passed AND pitch-check was requested; otherwise
// hasCents is false and the renderer treats them as null.
bool hasCents = false;
float centsDiff = 0.0f;
float centsError = 0.0f;
};
struct Request
{
int numSamples = 4096; // window read out of the engine's input ring
std::string arrangement = "guitar"; // "guitar" | "bass"
int stringCount = 6;
std::vector<int> tuningOffsets; // size == stringCount, semitones per string
int capo = 0;
float pitchCheckCents = 0.0f; // 0 = energy-only chord check
float minHitRatio = 0.6f;
// Force the DSP band-energy scorer even when an ML model is
// loaded. The ML path is onset-driven and silently drops notes
// the detector never fires an onset for; the renderer sets this
// to verify a chart note purely from spectral energy at its
// expected fundamental (the harmonic-comb check).
bool bypassMl = false;
// Harmonic-comb verification. The default per-note check sums energy
// across a whole string's frequency band and divides by the total
// spectrum — a metric a bright or broadband signal dilutes to ~1-3%,
// below threshold, so correctly-played notes are rejected. With this
// set, each note is instead scored by the energy at its EXPECTED
// harmonics (f, 2f, 3f, 4f, 5f) relative to the off-harmonic spectral
// floor between them. That is the harmonic-comb targeted check:
// robust to brightness/distortion because distortion adds energy AT
// the harmonics, and free of whole-spectrum dilution.
bool harmonicVerify = false;
// Minimum harmonic-to-floor ratio for a note to count as present.
// Tunable over IPC so the renderer can calibrate without a rebuild.
float harmonicSnr = 3.0f;
// Fundamental-presence gate (harmonicVerify only): a note is rejected
// when its f0 peak is weaker than `fundamentalRatio × strongest
// partial`. This is the specificity guard against octave-up impostors
// (and power-chord-root-only feeding a fifth's comb), which have their
// energy at f0's MULTIPLES with f0 itself near the floor.
//
// The 0.20 default suits guitar, whose DI fundamental is healthy. Bass
// DI fundamentals are routinely WEAKER than the 2nd harmonic (amp-sim
// DIs, compressed/rolled-off-below-60 Hz tones — see `_ndHpsDetect`),
// so the renderer lowers this for bass to stop false rejects. <= 0
// disables the gate entirely. Tunable over IPC like harmonicSnr.
float fundamentalRatio = 0.20f;
std::vector<Note> notes;
};
struct Result
{
float score = 0.0f; // hitStrings / totalStrings
int hitStrings = 0;
int totalStrings = 0;
bool isHit = false;
std::vector<NoteResult> results;
};
ChordScorer() = default;
// Score a chord against `buffer` (numSamples mono floats). Audio is
// not stored; the caller (AudioEngine) snapshots its input ring and
// passes the pointer in. The FFT plan, the complex scratch buffer
// and the magnitude buffer are reused across calls, so the
// FFT/peak-pick path itself is allocation-free in steady state. The
// returned `Result` still allocates its `results` vector (one entry
// per requested note) — that's a tiny per-call cost the IPC layer
// pays anyway when serialising the response.
Result scoreChord(const float* buffer, int numSamples, double sampleRate, const Request& req);
private:
// Lazily-built FFT for whatever size the current sampleRate dictates.
// The JS version targets ~3 Hz bin width, so the size depends on
// sampleRate; we rebuild only when that derived size changes.
void ensureFft(int fftSize);
void computeMagnitudes(const float* buffer, int numSamples);
int currentFftSize = 0;
int currentFftOrder = 0;
std::unique_ptr<juce::dsp::FFT> fft;
// FFT scratch as a vector of complex bins, length fftSize. Storing
// it as std::complex<float> (which juce::dsp::Complex aliases) lets
// us pass &scratch[0] to fft->perform() without reinterpret_cast'ing
// a float buffer through a stricter aliasing boundary — the C++
// standard only guarantees that a std::complex<T> is layout-
// compatible with T[2] in one direction (complex → T[2]), not the
// other, so a float* → complex* cast is undefined.
std::vector<juce::dsp::Complex<float>> fftScratch;
// Output buffer for fft->perform(). JUCE's FFT::perform is documented
// out-of-place (juce_FFT.h: "Performs an out-of-place FFT"). Aliasing
// input and output silently corrupts the result on the Ooura fallback
// engine that ships on Linux/Windows builds — radix decomposition
// reads input positions and writes output positions in overlapping
// iteration patterns, so the same memory gets read after write and
// intermediate values cascade through butterflies into ~1e27-magnitude
// garbage bins. Keep a distinct output buffer of the same size.
std::vector<juce::dsp::Complex<float>> fftOutScratch;
// Magnitude spectrum, length fftSize/2 + 1 (Nyquist-inclusive).
std::vector<float> magnitudes;
double lastBinHz = 0.0;
};