From d5384824fab19a9824945c6ba60f7e521a8c5c50 Mon Sep 17 00:00:00 2001 From: mattromano <42412983+mattromano@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:10:56 -0700 Subject: [PATCH] fix(chord): stop ringing chords in fast runs scoring as EARLY misses In a fast run of one chord shape the next chord is already sounding when its window opens, and three spots booked that as an EARLY miss: - engine verifier: the chord took the detect time of the first member listed. It now takes the member detected closest to the chart time. - engine verifier: a chord verified as its window opened could be stamped just before the early hit edge by the lagging pushed playhead. Stamps within 100 ms of the window now count as on time. - scoreChord path: one EARLY strummed frame locked the chord in as a miss. It no longer does; a later frame can still hit, and checkMisses() retires the chord as before if none does. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mattromano <42412983+mattromano@users.noreply.github.com> --- package.json | 2 +- plugin.json | 2 +- screen.js | 38 ++++++- test/fast_chord_runs.test.js | 189 +++++++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 test/fast_chord_runs.test.js diff --git a/package.json b/package.json index 98485f7..c04f63e 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/plugin.json b/plugin.json index bb332a3..b5f4e05 100644 --- a/plugin.json +++ b/plugin.json @@ -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", diff --git a/screen.js b/screen.js index bd9025c..1fc8d75 100644 --- a/screen.js +++ b/screen.js @@ -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 @@ -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; @@ -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. @@ -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 }; diff --git a/test/fast_chord_runs.test.js b/test/fast_chord_runs.test.js new file mode 100644 index 0000000..7233006 --- /dev/null +++ b/test/fast_chord_runs.test.js @@ -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(); +});