feedBack-desktop/tests/ml-note-detection.test.js
2026-06-16 18:48:12 +02:00

122 lines
6.1 KiB
JavaScript

// Integration smoke test for the ML note detection addon path.
//
// Loads the built native addon directly (no Electron), verifies the Basic
// Pitch model loads through loadNoteModel(), that isMlNoteDetection() reports
// the ML path, that getPitchDetection()/scoreChord() keep their shapes, and
// that a bad model path fails soft to the YIN fallback (Constitution VII).
//
// Run: node --test tests/ml-note-detection.test.js
// Skips cleanly when the addon hasn't been built yet (npm run build:audio).
//
// WAV→MIDI detection accuracy is covered separately by the Phase 0 spike
// (tests/spike/) which exercises the identical model + ONNX Runtime.
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const ADDON = path.join(__dirname, '..', 'build', 'Release', 'slopsmith_audio.node');
const MODEL = path.join(__dirname, '..', 'resources', 'models', 'basic_pitch.onnx');
let audio = null;
try {
audio = require(ADDON);
} catch (e) {
if (!fs.existsSync(ADDON)) {
// Addon genuinely not built yet — skip cleanly.
test('ml-note-detection (skipped — addon not built)', { skip: true }, () => {});
} else {
// The addon file IS present but failed to load — a real staging
// regression (e.g. a missing/incompatible ONNX Runtime library next
// to it). Surface it as a failure so this smoke test catches it.
test('ml-note-detection addon loads', () => { throw e; });
}
}
if (audio) {
test('ML note detection addon path', async (t) => {
audio.init();
t.after(() => { try { audio.shutdown(); } catch { /* ignore */ } });
await t.test('loadNoteModel loads the bundled Basic Pitch model', () => {
assert.equal(typeof audio.loadNoteModel, 'function',
'addon exposes loadNoteModel');
assert.ok(fs.existsSync(MODEL), `model present at ${MODEL}`);
const ok = audio.loadNoteModel(MODEL);
// ok is false if ONNX support was compiled out — tolerate that.
assert.equal(typeof ok, 'boolean');
// isMlNoteDetection() reports *readiness* — that the detector has
// published an inference snapshot — not merely that a model
// loaded. Readiness needs a running audio device, which this
// smoke test never starts, so it is false here regardless of the
// load result (and also false when ONNX is compiled out).
assert.equal(audio.isMlNoteDetection(), false,
'isMlNoteDetection is false until the detector has run inference');
});
await t.test('loadNoteModel fails soft on a missing file', () => {
// loadNoteModel returns "is ML available after this call" (a model
// is loaded with a valid contract). A missing file never throws
// and never tears down a model that was already loaded, so it
// returns a boolean and leaves detector readiness unchanged.
const readyBefore = audio.isMlNoteDetection();
const ok = audio.loadNoteModel(path.join(__dirname, 'no-such-model.onnx'));
assert.equal(typeof ok, 'boolean', 'missing model returns a boolean, does not throw');
assert.equal(audio.isMlNoteDetection(), readyBefore,
'a failed load must not change detector readiness');
});
await t.test('getPitchDetection keeps its shape', () => {
const d = audio.getPitchDetection();
assert.equal(typeof d, 'object');
for (const k of ['frequency', 'confidence', 'midiNote', 'cents', 'noteName'])
assert.ok(k in d, `detection has ${k}`);
});
await t.test('getRawAudioFrame returns a sized Float32Array', () => {
assert.equal(typeof audio.getRawAudioFrame, 'function',
'addon exposes getRawAudioFrame');
// No device running, so the post-gate ring is empty: cold-start
// returns a zero-filled frame of exactly the requested length.
const frame = audio.getRawAudioFrame(2048);
assert.ok(frame instanceof Float32Array, 'returns a Float32Array');
assert.equal(frame.length, 2048, 'honours the requested sample count');
// Default (no arg) matches the 4096 default.
assert.equal(audio.getRawAudioFrame().length, 4096, 'defaults to 4096 samples');
// Over-large requests clamp to the ring capacity (16384), never throw.
assert.equal(audio.getRawAudioFrame(1 << 20).length, 16384,
'clamps to the ring capacity');
// Non-positive requests yield an empty frame.
assert.equal(audio.getRawAudioFrame(0).length, 0, 'zero samples → empty');
});
await t.test('scoreChord keeps its shape (no device running)', () => {
const res = audio.scoreChord({
arrangement: 'guitar',
stringCount: 6,
offsets: [0, 0, 0, 0, 0, 0],
notes: [{ s: 0, f: 3 }, { s: 1, f: 2 }, { s: 2, f: 0 }],
});
assert.ok(res && typeof res === 'object', 'scoreChord returns an object');
for (const k of ['score', 'hitStrings', 'totalStrings', 'isHit', 'results'])
assert.ok(k in res, `result has ${k}`);
assert.equal(res.results.length, 3, 'one result entry per note');
});
await t.test('detectNotes returns the polyphonic shape or null', () => {
assert.equal(typeof audio.detectNotes, 'function',
'addon exposes detectNotes');
const res = audio.detectNotes();
// null when ONNX support is compiled out / no model — otherwise a
// { notes: [], sampleRate } object.
if (res !== null) {
assert.ok('notes' in res && Array.isArray(res.notes),
'detectNotes result has a notes array');
assert.equal(typeof res.sampleRate, 'number',
'detectNotes result has a numeric sampleRate');
}
});
});
}