mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-07-21 12:22:34 +00:00
122 lines
6.1 KiB
JavaScript
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');
|
|
}
|
|
});
|
|
});
|
|
}
|