Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "slopsmith-plugin-notedetect",
"version": "1.32.0",
"version": "1.33.0",
"private": true,
"description": "Slopsmith note detection plugin — test harness for detection core",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "note_detect",
"name": "Note Detection",
"version": "1.32.0",
"version": "1.33.0",
"private": false,
"script": "screen.js",
"styles": "assets/plugin.css",
Expand Down
38 changes: 36 additions & 2 deletions screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ const _ND_AUTO_ENABLE_RETRY_MS = 1500;
// exact build that produced it. The script tag has no `import`/`fetch`
// hook to read package.json at load time, so this is the single
// hand-maintained constant the diagnostic path keys off of.
const _ND_VERSION = '1.32.0';
const _ND_VERSION = '1.33.0';

// Bleed-rescue tuning for the low-bass blind spot. A bass DI's fundamental is
// weaker than its 2nd harmonic and an open string / neighbour often rings the
Expand Down Expand Up @@ -613,6 +613,13 @@ function _ndMidiFromStringFret(string, fret, arrangement, stringCount, offsets,
return base[string] + offset + (capo || 0) + fret;
}

// How far before a chord's timing window the engine verifier's detect stamp
// can land and still mean "verified as the window opened": the renderer
// pushes the playhead the engine stamps with once per detect tick, so a
// stamp can trail the engine's own clock by about a poll. See
// _ndFinalizeChordVerdict.
const _ND_ENGINE_STAMP_SLACK_S = 0.1;

function _ndClassifyTiming(timingErrorMs, timingThresholdMs, lateGraceMs) {
if (!Number.isFinite(timingErrorMs)) return null;
const grace = Number.isFinite(lateGraceMs) && lateGraceMs > 0 ? lateGraceMs : 0;
Expand Down Expand Up @@ -6530,6 +6537,15 @@ function createNoteDetector(options = {}) {
continue;
}

// A strum that scored but landed EARLY (inside the match
// window, before the chord hit edge) is usually the previous
// chord of the same shape still ringing in a fast run. Don't
// lock it in as a miss: a later frame in the window can still
// hit, and checkMisses() retires the chord as before if none
// does. Its diagnostics aren't cached either, so an early
// ring can't pin the voicing rescue's judgedAt to EARLY.
if (!chordJudgment.hit && chordJudgment.timingState === 'EARLY') continue;

// Chord cleared. Mark the chord-level key 'hit' so the
// miss aggregator in checkMisses() treats it as a single
// resolved unit and skips per-string miss accounting.
Expand Down Expand Up @@ -14127,10 +14143,28 @@ function createNoteDetector(options = {}) {
const v = verdictMap.get(id);
if (v && v.detected) {
hitStrings++;
if (detectedTime === null) detectedTime = v.detectedSongTime;
// Stamp the chord with the member detected closest to the
// chart time, not the first one listed. In a fast run of one
// shape a string still ringing from the previous chord is
// "detected" as soon as this chord's window opens, while the
// others land on the strum.
const ts = v.detectedSongTime;
if (Number.isFinite(ts) && (detectedTime === null
|| Math.abs(ts - grp.t) < Math.abs(detectedTime - grp.t))) {
detectedTime = ts;
}
if (bestCents === null && Number.isFinite(v.centsError)) bestCents = v.centsError;
}
}
// The engine only verifies a chord inside its timing window, so a
// stamp just before the early hit edge means "present as the window
// opened" (the same shape still ringing), stamped by a playhead that
// can lag a poll behind. Counted as on time instead of EARLY by that
// jitter. A stamp further out than the slack is still EARLY.
if (detectedTime !== null && detectedTime < grp.t - chordTimingHitThreshold
&& detectedTime >= grp.t - timingTolerance - _ND_ENGINE_STAMP_SLACK_S) {
detectedTime = grp.t - chordTimingHitThreshold;
}
const totalStrings = grp.memberIds.length;
const score = totalStrings > 0 ? hitStrings / totalStrings : 0;
const lead = _ndVerifierChartById.get(grp.memberIds[0]) || { s: 0, f: 0 };
Expand Down
189 changes: 189 additions & 0 deletions test/fast_chord_runs.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Fast chord runs: a chord that's still ringing must not turn the next one
// into an EARLY miss.
//
// In a fast run the next chord is often the same shape, so it is already
// sounding when its timing window opens. Three places used to book that as a
// miss:
//
// 1. Engine verifier: the chord took the detect time of whichever member was
// listed first, even when another member was detected right on the beat.
// 2. Engine verifier: a chord the engine verified inside its window could be
// stamped a little before the window's early edge (the pushed playhead
// lags by up to a poll), which classified it EARLY at random.
// 3. Browser / scoreChord path: one EARLY strummed frame locked the chord in
// as a miss, so the on-time strum a few frames later couldn't count.

const { test } = require('node:test');
const assert = require('node:assert/strict');
const { loadDetectionCore } = require('./_loader');

async function flush(turns = 6) {
for (let i = 0; i < turns; i++) {
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setImmediate(r));
}
}

// One two-note power chord at t = 1.0 s, delivered through getNotes() so the
// engine verifier groups it by timestamp.
const CHORD_T = 1.0;
const CHART = [
{ t: CHORD_T, s: 0, f: 5, sus: 0 },
{ t: CHORD_T, s: 1, f: 7, sus: 0 },
];

function engineSandbox() {
const verdictQueue = [];
const intervals = [];
let pushed = null;
let hwTime = 0;
const audio = {
isAvailable: async () => true,
isAudioRunning: async () => true,
startAudio: async () => {},
getLevels: async () => ({ inputLevel: 0.3, inputPeak: 0.4, outputLevel: 0, outputPeak: 0 }),
getSampleRate: async () => 48000,
getPitchDetection: async () => ({ midiNote: -1, confidence: 0, frequency: -1, cents: 0, noteName: '' }),
setChart: async (chart) => { pushed = chart; return true; },
getNoteVerdicts: async () => (verdictQueue.length ? verdictQueue.shift() : []),
};
const judgments = [];
const { createNoteDetector } = loadDetectionCore({
sandboxBeforeRun(sb) {
sb.setInterval = (cb) => { if (typeof cb === 'function') intervals.push(cb); return intervals.length; };
sb.highway.getNotes = () => CHART.map((n) => ({ ...n }));
sb.highway.getChords = () => ([]);
sb.highway.getTime = () => hwTime;
sb.highway.getAvOffset = () => 0;
sb.window.feedBackDesktop = { isDesktop: true, platform: 'linux', audio };
sb.window.dispatchEvent = (ev) => {
if (ev && (ev.type === 'notedetect:hit' || ev.type === 'notedetect:miss')) judgments.push(ev.detail);
return true;
};
},
});
return {
createNoteDetector, intervals, verdictQueue, judgments,
ids: () => (pushed ? Array.from(pushed.notes, (n) => n.id) : []),
setHwTime: (t) => { hwTime = t; },
};
}

async function driveUntil(intervals, pred) {
for (const cb of intervals) {
// eslint-disable-next-line no-await-in-loop
await cb();
// eslint-disable-next-line no-await-in-loop
await flush();
if (pred()) return true;
}
return pred();
}

async function judgeEngineChord(stamps) {
const env = engineSandbox();
const det = env.createNoteDetector({ isDefault: false });
await det.enable();
await flush();
assert.ok(await driveUntil(env.intervals, () => env.ids().length === 2), 'chart pushed to the engine');
env.setHwTime(CHORD_T + 0.4);
const ids = env.ids();
env.verdictQueue.push(ids.map((id, i) => ({
id, detected: true, detectedSongTime: stamps[i], centsError: 0, snr: 6,
})));
// Every interval gets a turn; the detect tick drains the verdicts.
await driveUntil(env.intervals, () => det.getStats().hits + det.getStats().misses > 0);
const stats = det.getStats();
const chordJ = env.judgments.find((j) => j && j.chord);
det.destroy();
await flush();
return { stats, chordJ };
}

test('engine verifier: a chord is stamped with the member detected closest to the beat', async () => {
// Low string still ringing from the previous chord → reported 170 ms early;
// the other string was struck 20 ms early, i.e. on time.
const { stats, chordJ } = await judgeEngineChord([CHORD_T - 0.17, CHORD_T - 0.02]);
assert.equal(stats.hits, 1, 'the chord should count as a hit');
assert.equal(stats.misses, 0);
assert.ok(chordJ, 'a chord judgment was emitted');
assert.equal(chordJ.timingError, -20, 'timing comes from the closest member');
});

test('engine verifier: a chord verified at the early window edge counts as on time', async () => {
// Both members present as the window opened (same shape still ringing),
// stamped by a playhead that lags a poll behind: 180 ms early, just past
// the 150 ms chord threshold.
const { stats, chordJ } = await judgeEngineChord([CHORD_T - 0.18, CHORD_T - 0.18]);
assert.equal(stats.hits, 1, 'an engine-verified chord at the window edge is not EARLY');
assert.equal(stats.misses, 0);
assert.equal(chordJ.timingState, 'OK');
});

test('engine verifier: a chord stamped well before its window is still EARLY', async () => {
// 400 ms early is far outside anything poll lag explains.
const { stats } = await judgeEngineChord([CHORD_T - 0.4, CHORD_T - 0.4]);
assert.equal(stats.hits, 0);
assert.equal(stats.misses, 1);
});

test('scoreChord path: an EARLY strummed frame does not lock the chord in as a miss', async () => {
const intervals = [];
let hwTime = 0;
const { createNoteDetector } = loadDetectionCore({
sandboxBeforeRun(sb) {
sb.setInterval = (cb) => { if (typeof cb === 'function') intervals.push(cb); return intervals.length; };
sb.highway.getNotes = () => ([]);
sb.highway.getChords = () => ([{ t: CHORD_T, notes: CHART.map(({ s, f }) => ({ s, f })) }]);
sb.highway.getTime = () => hwTime;
sb.highway.getAvOffset = () => 0;
sb.window.feedBackDesktop = {
isDesktop: true,
platform: 'linux',
audio: {
isAvailable: async () => true,
isAudioRunning: async () => true,
startAudio: async () => {},
getPitchDetection: async () => ({ midiNote: -1, confidence: 0, frequency: -1, cents: 0, noteName: '' }),
getLevels: async () => ({ inputLevel: 0.2, inputPeak: 0.3, outputLevel: 0, outputPeak: 0 }),
getSampleRate: async () => 48000,
// The previous chord's identical shape is ringing: every
// frame in the window scores as the chord.
scoreChord: async (ctx) => ({
score: 1, hitStrings: ctx.notes.length, totalStrings: ctx.notes.length, isHit: true,
results: ctx.notes.map((n) => ({ s: n.s, f: n.f, hit: true, bandEnergy: 0.2, centsDiff: 0, centsError: 0 })),
}),
},
};
},
});
const det = createNoteDetector({ isDefault: false });
// A player who tightened the chord hit window inside the match window:
// frames 100–150 ms early are inside the window but EARLY.
det.applySettings({
timingTolerance: 0.15, timingHitThreshold: 0.08, chordTimingHitThreshold: 0.10, latencyOffset: 0,
});
await det.enable();
await flush();

const runAll = async () => {
for (const cb of intervals) {
// eslint-disable-next-line no-await-in-loop
await cb();
// eslint-disable-next-line no-await-in-loop
await flush();
}
};
// Frame 1: 130 ms early — the ringing previous chord, not a strum.
hwTime = CHORD_T - 0.13;
await runAll();
assert.equal(det.getStats().misses, 0, 'an EARLY frame inside the window must not book a miss');
// Frame 2: the on-time strum.
hwTime = CHORD_T;
await runAll();
const s = det.getStats();
assert.equal(s.misses, 0, 'still no miss');
assert.equal(s.hits, 1, 'the on-time strum counts');
det.destroy();
await flush();
});