// Native end-to-end test for MlNoteDetector (the production class). // // Feeds a known guitar WAV through pushSamples() in 256-sample blocks — the // same path the audio callback uses — at roughly real time, lets the // background inference thread resample / window / run Basic Pitch, then asserts // the active-pitch snapshot matches the WAV's final content (a C-major triad). // // This exercises the streaming LagrangeInterpolator resample, the rolling // 22050 Hz window, the snapshot publishing and threshold logic — everything // the Phase 0 spike did not. Inference accuracy itself is also re-checked here. // // Build: see CMakeLists.txt. Run: ./mlnd_test // Exit 0 = pass. #include "MlNoteDetector.h" #include #include #include #include #include #include #include // Minimal WAV reader — 16-bit PCM / 32-bit float, any channels -> mono. namespace { uint32_t rdU32(const uint8_t* p) { return p[0] | (p[1]<<8) | (p[2]<<16) | (uint32_t(p[3])<<24); } uint16_t rdU16(const uint8_t* p) { return uint16_t(p[0] | (p[1]<<8)); } bool readWav(const std::string& path, std::vector& out, int& sampleRate) { std::ifstream f(path, std::ios::binary); if (!f) return false; std::vector buf((std::istreambuf_iterator(f)), std::istreambuf_iterator()); if (buf.size() < 44 || std::memcmp(buf.data(), "RIFF", 4) || std::memcmp(buf.data()+8, "WAVE", 4)) return false; uint16_t fmt = 0, channels = 0, bits = 0; uint32_t rate = 0, dataLen = 0; const uint8_t* data = nullptr; size_t pos = 12; while (pos + 8 <= buf.size()) { const char* id = reinterpret_cast(buf.data() + pos); const uint32_t sz = rdU32(buf.data() + pos + 4); const uint8_t* body = buf.data() + pos + 8; // Guard the fmt-body reads (up to body+14, i.e. 16 bytes) against a // truncated file: a declared sz >= 16 doesn't mean 16 bytes are // actually present. if (!std::memcmp(id, "fmt ", 4) && sz >= 16 && pos + 8 + 16 <= buf.size()) { fmt = rdU16(body); channels = rdU16(body+2); rate = rdU32(body+4); bits = rdU16(body+14); } else if (!std::memcmp(id, "data", 4)) { data = body; dataLen = std::min(sz, uint32_t(buf.size() - (pos + 8))); } pos += 8 + sz + (sz & 1); } if (!data || channels == 0 || rate == 0 || bits < 8) return false; sampleRate = int(rate); const int bps = bits / 8; const size_t frames = dataLen / (size_t(bps) * channels); out.resize(frames); for (size_t i = 0; i < frames; ++i) { double acc = 0.0; for (int c = 0; c < channels; ++c) { const uint8_t* s = data + (i * channels + c) * bps; if (fmt == 3 && bits == 32) { float v; std::memcpy(&v, s, 4); acc += v; } else if (fmt == 1 && bits == 16) { acc += int16_t(rdU16(s)) / 32768.0; } else return false; } out[i] = float(acc / channels); } return true; } } // namespace int main(int argc, char** argv) { if (argc < 3) { std::cerr << "usage: mlnd_test \n"; return 2; } std::vector wav; int sampleRate = 0; if (!readWav(argv[2], wav, sampleRate)) { std::cerr << "FAIL: cannot read WAV " << argv[2] << "\n"; return 1; } std::cout << "WAV: " << wav.size() << " samples @ " << sampleRate << " Hz\n"; MlNoteDetector det; if (!det.loadModel(juce::File(juce::String(argv[1])))) { std::cerr << "FAIL: loadModel('" << argv[1] << "') returned false\n"; return 1; } std::cout << "model loaded; isAvailable=" << det.isAvailable() << "\n"; det.prepare((double) sampleRate, 256); // Gate check: the pipeline defaults OFF. Feed the chord region (~1 s of // audio that detects cleanly once armed) while still DISABLED and confirm // the detector publishes nothing and never becomes ready — pushSamples must // no-op and the inference thread must run no Run(). { const int gateBlock = 256; const size_t gateStart = (size_t) (3.6 * sampleRate); const size_t gateEnd = std::min(wav.size(), (size_t) (4.8 * sampleRate)); for (size_t i = gateStart; i < gateEnd; i += gateBlock) { const int n = (int) std::min(gateBlock, gateEnd - i); det.pushSamples(wav.data() + i, n); std::this_thread::sleep_for(std::chrono::microseconds( (long long) (1e6 * n / sampleRate))); if (det.isReady() || ! det.getActiveNotes().empty()) { std::cerr << "FAIL: detector active while disabled (gate leak)\n"; return 1; } } std::cout << "gate OK: no detection while disabled\n"; } // Arm it so pushSamples feeds and the inference thread runs. setEnabled(true) // requests a thread-side cold-start reset before the first inference. det.setEnabled(true); // Feed the WAV in 256-sample blocks at ~real time so the background // inference thread drains the FIFO instead of overflowing it. The // detector reports "what is sounding now", so we poll the active set // *during* playback and accumulate every pitch seen while the WAV's // C-major triad (t≈3.3-4.5 s) is sounding — allowing for the // hop + inference lag, that window maps to feed time ≈ [3.6, 5.2] s. const int block = 256; std::vector seenDuringChord; auto noteActiveSomewhere = [&](int midi) { return std::find(seenDuringChord.begin(), seenDuringChord.end(), midi) != seenDuringChord.end(); }; for (size_t i = 0; i < wav.size(); i += block) { const int n = (int) std::min(block, wav.size() - i); det.pushSamples(wav.data() + i, n); std::this_thread::sleep_for(std::chrono::microseconds( (long long) (1e6 * n / sampleRate))); const double feedSec = double(i) / sampleRate; if (feedSec >= 3.6 && feedSec <= 5.2) for (const auto& nt : det.getActiveNotes()) if (!noteActiveSomewhere(nt.midi)) seenDuringChord.push_back(nt.midi); } // Drain: the chord is the WAV's final event, so let the trailing // inference finish and poll once more before stopping — otherwise the // last chord's notes can be dropped, making the test nondeterministic. std::this_thread::sleep_for(std::chrono::milliseconds(400)); for (const auto& nt : det.getActiveNotes()) if (!noteActiveSomewhere(nt.midi)) seenDuringChord.push_back(nt.midi); det.stop(); std::sort(seenDuringChord.begin(), seenDuringChord.end()); std::cout << "pitches seen active during the chord window (" << seenDuringChord.size() << "):\n"; for (int m : seenDuringChord) std::cout << " midi=" << m << "\n"; // The WAV's final sustained event is a C-major triad: C3=48, E3=52, G3=55. const int expected[] = { 48, 52, 55 }; int found = 0; for (int e : expected) if (noteActiveSomewhere(e)) ++found; if (found >= 2) { std::cout << "PASS: detected " << found << "/3 chord tones\n"; return 0; } std::cerr << "FAIL: detected only " << found << "/3 expected chord tones\n"; return 1; }