From 7b6aca3a960296e39856eba7e33c89098fb221d3 Mon Sep 17 00:00:00 2001 From: mattromano <42412983+mattromano@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:19:34 -0700 Subject: [PATCH 1/9] fix(techniques): read bend/slide flags off the wire format Core's highway sends `sl`/`slu` as the slide-to fret (-1 = no slide) and the bend amount as `bn`. note_detect read `!!cn.sl` / `cn.b`, so every plain note looked like a slide (widened pitch window / lenient pitch gate) and real bends were never seen. Read both through new _ndHasSlide/_ndHasBend helpers that accept the wire numbers and the booleans note_detect's own payloads carry, including the chart pushed to the desktop engine. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mattromano <42412983+mattromano@users.noreply.github.com> --- screen.js | 56 ++++++++++------ test/_loader.js | 3 + test/technique_flags.test.js | 122 +++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 test/technique_flags.test.js diff --git a/screen.js b/screen.js index bd9025c..ae69e80 100644 --- a/screen.js +++ b/screen.js @@ -2143,6 +2143,23 @@ function _ndKeysToReopenOnSeek(lastT, t, tolerance, keys) { return out; } +// Technique-flag readers. Core's highway wire format (lib/song.py +// note_to_wire) sends `sl` / `slu` as the slide-to fret with -1 meaning +// "no slide", and the bend amount as `bn` (0 = none). A bare `!!cn.sl` reads +// that -1 as true, so EVERY note looked like a slide and got the widened pitch +// window. These accept both the wire numbers and the booleans note_detect's +// own payloads carry (`{ b: true, sl: true }` — calibration lab, verify +// targets, the engine chart), so they're safe on either shape. +function _ndHasSlide(n) { + if (!n) return false; + const on = (v) => v === true || (typeof v === 'number' && v >= 0); + return on(n.sl) || on(n.slu); +} +function _ndHasBend(n) { + if (!n) return false; + return !!(n.b || (typeof n.bn === 'number' ? n.bn > 0 : n.bn)); +} + // Score a chord by checking each of its constituent notes against their // respective string frequency bands. Returns { score, hitStrings, totalStrings }. // @@ -2150,7 +2167,8 @@ function _ndKeysToReopenOnSeek(lastT, t, tolerance, keys) { // minHitRatio — fraction of strings that must ring for the chord to count as a hit. // // Each `chordNotes` entry may carry abbreviated technique flags from the chart -// note data (`cn.ho`, `cn.po`, `cn.b`, `cn.sl`, `cn.hm`), used to adjust +// note data (`cn.ho`, `cn.po`, bend, slide, `cn.hm` — bend/slide read via +// _ndHasBend/_ndHasSlide so wire-format notes work), used to adjust // per-string thresholds: // - ho/po (hammer-on / pull-off): lower energyThreshold (no fresh pick attack) // - b/sl (bend / slide): widen pitchCheckCents (pitch is in motion) @@ -2179,7 +2197,7 @@ function _ndScoreChord(buffer, sampleRate, chordNotes, arrangement, stringCount, // Hammer-on / pull-off: no pick attack, energy will be lower energyThreshold = 0.015; } - if (cn.b || cn.sl) { + if (_ndHasBend(cn) || _ndHasSlide(cn)) { // Bend / slide: pitch is moving, widen the pitch window cents = Math.max(cents, 100); } @@ -5745,8 +5763,8 @@ function createNoteDetector(options = {}) { function _diagTechFlags(n) { if (!n) return null; const flags = []; - if (n.bn) flags.push('B'); // bend - if (n.sl != null && n.sl >= 0) flags.push('S'); // slide + if (_ndHasBend(n)) flags.push('B'); // bend + if (_ndHasSlide(n)) flags.push('S'); // slide if (n.hm || n.hp) flags.push('H'); // harmonic / pinch if (n.ho) flags.push('h'); // hammer-on if (n.po) flags.push('p'); // pull-off @@ -6192,7 +6210,7 @@ function createNoteDetector(options = {}) { notes: _ndSingleNotes.map(cn => ({ s: cn.s, f: cn.f, ho: !!cn.ho, po: !!cn.po, - b: !!cn.b, sl: !!cn.sl, hm: !!cn.hm, + b: _ndHasBend(cn), sl: _ndHasSlide(cn), hm: !!cn.hm, })), }; const gen = sessionGen; @@ -6332,7 +6350,7 @@ function createNoteDetector(options = {}) { notes: group.map(cn => ({ s: cn.s, f: cn.f, ho: !!cn.ho, po: !!cn.po, - b: !!cn.b, sl: !!cn.sl, hm: !!cn.hm, + b: _ndHasBend(cn), sl: _ndHasSlide(cn), hm: !!cn.hm, })), }; const gen = sessionGen; @@ -10154,7 +10172,7 @@ function createNoteDetector(options = {}) { function _calLabEffectivePitchTol(chartPitch, flags) { let cents = chartPitch; - if (flags && (flags.b || flags.sl)) cents = Math.max(cents, 100); + if (flags && (_ndHasBend(flags) || _ndHasSlide(flags))) cents = Math.max(cents, 100); if (flags && flags.hm) cents = 0; return cents; } @@ -10498,8 +10516,8 @@ function createNoteDetector(options = {}) { f: n.f, ho: !!n.ho, po: !!n.po, - b: !!n.b, - sl: !!n.sl, + b: _ndHasBend(n), + sl: _ndHasSlide(n), hm: !!n.hm, })), }; @@ -10519,7 +10537,7 @@ function createNoteDetector(options = {}) { const r = batch.results[0]; const flags = { ho: !!noteDef.ho, po: !!noteDef.po, - b: !!noteDef.b, sl: !!noteDef.sl, hm: !!noteDef.hm, + b: _ndHasBend(noteDef), sl: _ndHasSlide(noteDef), hm: !!noteDef.hm, }; const tick = _calLabTickFromResult(r, flags); return { @@ -10540,7 +10558,7 @@ function createNoteDetector(options = {}) { const nd = notes[i] || { s: r.s, f: r.f }; const flags = { ho: !!nd.ho, po: !!nd.po, - b: !!nd.b, sl: !!nd.sl, hm: !!nd.hm, + b: _ndHasBend(nd), sl: _ndHasSlide(nd), hm: !!nd.hm, }; return { string: nd.s, fret: nd.f, ..._calLabTickFromResult(r, flags) }; }); @@ -13704,8 +13722,8 @@ function createNoteDetector(options = {}) { mix(Math.round((Number.isFinite(nn.t) ? nn.t : 0) * 1000)); mix(((nn.s | 0) * 100) + (nn.f | 0)); mix(Math.round((Number.isFinite(nn.sus) ? nn.sus : 0) * 1000)); - mix((nn.ho ? 1 : 0) | (nn.po ? 2 : 0) | (nn.b ? 4 : 0) - | (nn.sl ? 8 : 0) | (nn.hm ? 16 : 0) | (nn.mt ? 32 : 0)); + mix((nn.ho ? 1 : 0) | (nn.po ? 2 : 0) | (_ndHasBend(nn) ? 4 : 0) + | (_ndHasSlide(nn) ? 8 : 0) | (nn.hm ? 16 : 0) | (nn.mt ? 32 : 0)); }; for (const nn of notes) if (nn) mixNote(nn); for (const ch of _ndChartChords()) { @@ -13773,7 +13791,7 @@ function createNoteDetector(options = {}) { s: n.s, f: n.f, sus: Number.isFinite(n.sus) ? n.sus : 0, - ho: !!n.ho, po: !!n.po, b: !!n.b, sl: !!n.sl, hm: !!n.hm, + ho: !!n.ho, po: !!n.po, b: _ndHasBend(n), sl: _ndHasSlide(n), hm: !!n.hm, }; notes.push(entry); byId.set(id, { ...n }); @@ -13792,7 +13810,7 @@ function createNoteDetector(options = {}) { s: cn.s, f: cn.f, sus: Number.isFinite(cn.sus) ? cn.sus : 0, - ho: !!cn.ho, po: !!cn.po, b: !!cn.b, sl: !!cn.sl, hm: !!cn.hm, + ho: !!cn.ho, po: !!cn.po, b: _ndHasBend(cn), sl: _ndHasSlide(cn), hm: !!cn.hm, }; notes.push(entry); byId.set(id, { ...cn, t: c.t }); @@ -14020,7 +14038,7 @@ function createNoteDetector(options = {}) { // A bend / slide / harmonic note's pitch deliberately leaves // the chart pitch; widen the pitch gate so the hit rides on // presence + timing, not a moving target. - const lenientPitch = !!(cn.b || cn.sl || cn.hm); + const lenientPitch = _ndHasBend(cn) || _ndHasSlide(cn) || !!cn.hm; const judgment = makeMatchedJudgment( cn, cn.t, judgedAt, expectedMidi, detectedMidiForJudgment, 1, { pitchError, pitchThresholdCents: lenientPitch ? 600 : undefined } @@ -14223,7 +14241,7 @@ function createNoteDetector(options = {}) { // That false-`hit` would then make noteStateFor return // 'miss' for the constituent, undoing the whole point of // stamping it with the chord verdict. - const lenientPitch = !!(mcn.b || mcn.sl || mcn.hm); + const lenientPitch = _ndHasBend(mcn) || _ndHasSlide(mcn) || !!mcn.hm; const mDetMidi = Number.isFinite(bestCents) ? mExpectedMidi + bestCents / 100 : null; mJudgment = makeMatchedJudgment( @@ -14347,7 +14365,7 @@ function createNoteDetector(options = {}) { s: n.s, f: n.f, sus: Number.isFinite(n.sus) ? n.sus : 0, - ho: !!n.ho, po: !!n.po, b: !!n.b, sl: !!n.sl, hm: !!n.hm, + ho: !!n.ho, po: !!n.po, b: _ndHasBend(n), sl: _ndHasSlide(n), hm: !!n.hm, }; entries.push(entry); byId.set(n.id, { ...n }); @@ -18123,7 +18141,7 @@ function createNoteDetector(options = {}) { && Number.isInteger(n.f) && n.f >= 0) .map(n => ({ s: n.s, f: n.f, - ho: !!n.ho, po: !!n.po, b: !!n.b, sl: !!n.sl, hm: !!n.hm, + ho: !!n.ho, po: !!n.po, b: _ndHasBend(n), sl: _ndHasSlide(n), hm: !!n.hm, })); let cleanNotes = clean.length ? clean : null; // A ctx only makes sense alongside a live target. diff --git a/test/_loader.js b/test/_loader.js index 660c9f3..05e255e 100644 --- a/test/_loader.js +++ b/test/_loader.js @@ -310,6 +310,9 @@ function loadDetectionCore({ sandboxBeforeRun } = {}) { }; }, calibrateOffsetMs: sandbox._ndCalibrateOffsetMs, + // Technique-flag readers (wire `sl`/`slu`/`bn` or boolean `b`/`sl`). + hasSlide: sandbox._ndHasSlide, + hasBend: sandbox._ndHasBend, // Game-scoring pure helpers (points / multiplier / grade layer). scoringHealthy: sandbox._ndScoringHealthy, multiplierForStreak: sandbox._ndMultiplierForStreak, diff --git a/test/technique_flags.test.js b/test/technique_flags.test.js new file mode 100644 index 0000000..e6cc04a --- /dev/null +++ b/test/technique_flags.test.js @@ -0,0 +1,122 @@ +// Bend / slide technique flags must be read off core's WIRE format. +// +// Core's highway (lib/song.py note_to_wire) sends `sl` / `slu` as the +// slide-to fret with -1 meaning "no slide", and the bend amount as `bn` +// (0 = none) — there is no `b` key. note_detect used to read `!!cn.sl` / +// `cn.b`, so every plain note (sl: -1) looked like a slide and got the +// widened pitch window, while real bends were never seen at all. +// _ndHasSlide / _ndHasBend read the wire numbers AND the booleans +// note_detect's own payloads carry (calibration lab, verify targets, the +// engine chart), and every consumer goes through them. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { loadDetectionCore } = require('./_loader'); +const { sine } = require('./_signals'); + +const core = loadDetectionCore(); + +// A plain note exactly as the highway WebSocket delivers it. +const WIRE_PLAIN = { t: 1.0, s: 1, f: 0, sus: 0, sl: -1, slu: -1, bn: 0, ho: false, po: false, hm: false }; + +test('hasSlide: wire -1 is "no slide"; a slide-to fret (incl. 0) or unpitched slide is a slide', () => { + assert.equal(core.hasSlide(WIRE_PLAIN), false); + assert.equal(core.hasSlide({ ...WIRE_PLAIN, sl: 7 }), true); + assert.equal(core.hasSlide({ ...WIRE_PLAIN, sl: 0 }), true); + assert.equal(core.hasSlide({ ...WIRE_PLAIN, slu: 3 }), true); + assert.equal(core.hasSlide({ s: 1, f: 0 }), false, 'keys omitted → no slide'); + assert.equal(core.hasSlide(null), false); +}); + +test('hasSlide: note_detect\'s own boolean payloads still read correctly', () => { + assert.equal(core.hasSlide({ sl: true }), true); + assert.equal(core.hasSlide({ sl: false }), false); +}); + +test('hasBend: wire `bn` amount, plus the legacy boolean `b`', () => { + assert.equal(core.hasBend(WIRE_PLAIN), false); + assert.equal(core.hasBend({ ...WIRE_PLAIN, bn: 1 }), true); + assert.equal(core.hasBend({ ...WIRE_PLAIN, bn: 0.5 }), true); + assert.equal(core.hasBend({ b: true }), true); + assert.equal(core.hasBend({ b: false }), false); + assert.equal(core.hasBend(null), false); +}); + +// ── _ndScoreChord: the widened (100¢) bend/slide pitch window ────────────── + +const SR = 48000; +const GUITAR_6 = [0, 0, 0, 0, 0, 0]; +// A2 (string 1, open) = 110 Hz; play it 70¢ sharp — outside a 50¢ window, +// inside the 100¢ bend/slide window. +const SHARP_A2 = sine(110 * Math.pow(2, 70 / 1200), SR, 16384 / SR); +const score = (note) => core.scoreChord(SHARP_A2, SR, [note], 'guitar', 6, GUITAR_6, 0, 50, 0.5); + +test('scoreChord: a plain wire-format note keeps the normal pitch window', () => { + const r = score({ ...WIRE_PLAIN }); + assert.equal(r.hitStrings, 0, + `a 70¢-sharp plain note must miss at 50¢ — sl:-1 is not a slide (centsDiff=${r.results[0].centsDiff})`); +}); + +test('scoreChord: wire-format slides and bends still get the widened window', () => { + assert.equal(score({ ...WIRE_PLAIN, sl: 2 }).hitStrings, 1, 'slide (sl: 2)'); + assert.equal(score({ ...WIRE_PLAIN, bn: 1 }).hitStrings, 1, 'bend (bn: 1) — was never detected before'); + assert.equal(score({ s: 1, f: 0, b: true }).hitStrings, 1, 'legacy boolean b'); +}); + +// ── Desktop engine: the chart pushed to the native verifier ──────────────── +// +// The engine (feedBack-desktop ChordScorer) coerces `b` / `sl` to bool and +// widens its pitch window on either, so the JS must hand it real booleans. + +async function flushPendingAsync(turns = 6) { + for (let i = 0; i < turns; i++) { + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setImmediate(r)); + } +} + +test('engine chart: wire-format flags become correct b / sl booleans', async () => { + const chartNotes = [ + { ...WIRE_PLAIN, t: 1.0 }, + { ...WIRE_PLAIN, t: 2.0, sl: 5 }, + { ...WIRE_PLAIN, t: 3.0, bn: 1 }, + ]; + const chords = [{ t: 4.0, notes: [{ ...WIRE_PLAIN, s: 0, f: 3, t: 4.0 }, { ...WIRE_PLAIN, s: 2, f: 5, t: 4.0, sl: 7 }] }]; + const charts = []; + const audio = { + isAvailable: async () => true, + isAudioRunning: async () => true, + startAudio: async () => {}, + getLevels: async () => ({ inputLevel: 0, inputPeak: 0, outputLevel: 0, outputPeak: 0 }), + getSampleRate: async () => 48000, + getPitchDetection: async () => ({ midiNote: -1, confidence: 0, frequency: -1, cents: 0, noteName: '' }), + setChart: async (chart) => { charts.push(chart); return true; }, + getNoteVerdicts: async () => [], + }; + const { createNoteDetector } = loadDetectionCore({ + sandboxBeforeRun(sb) { + sb.highway.getNotes = () => chartNotes.map((n) => ({ ...n })); + sb.highway.getChords = () => chords; + sb.highway.getTime = () => 0; + sb.highway.getAvOffset = () => 0; + sb.window.feedBackDesktop = { isDesktop: true, platform: 'linux', audio }; + }, + }); + const det = createNoteDetector({ isDefault: false }); + await det.enable(); + await flushPendingAsync(); + + const chart = charts[charts.length - 1]; + assert.ok(chart, 'a chart should have been pushed to the engine'); + const flags = Array.from(chart.notes, (n) => `${n.t}:${n.s}:b=${n.b},sl=${n.sl}`).sort(); + assert.deepEqual(flags, [ + '1:1:b=false,sl=false', + '2:1:b=false,sl=true', + '3:1:b=true,sl=false', + '4:0:b=false,sl=false', + '4:2:b=false,sl=true', + ]); + + det.destroy(); + await flushPendingAsync(); +}); From b72023d69cd26a0a09d6bbfcb9e956ad099a6e6f Mon Sep 17 00:00:00 2001 From: mattromano <42412983+mattromano@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:21:18 -0700 Subject: [PATCH 2/9] fix(sections): track per-section stats on the desktop engine path currentSection only advanced at the tail of checkMisses(), which returns early under the engine verifier, so desktop sessions recorded no per-section stats and notedetect:session.sections was always empty. Keep the section tracker running on that path, and bin each judgment by the section its note sits in (engine verdicts land after the playhead may have crossed into the next section), falling back to the playhead's section when the note time is unknown. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mattromano <42412983+mattromano@users.noreply.github.com> --- screen.js | 59 ++++++---- test/engine_section_stats.test.js | 172 ++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 18 deletions(-) create mode 100644 test/engine_section_stats.test.js diff --git a/screen.js b/screen.js index ae69e80..c06655e 100644 --- a/screen.js +++ b/screen.js @@ -5891,7 +5891,7 @@ function createNoteDetector(options = {}) { if (_ndIsStreakMilestone(streak)) { dispatchFx({ fxType: 'milestone', streak, mult: multiplier }); } - updateSectionStat('hit'); + updateSectionStat('hit', judgment.noteTime); // A hit breaks the contiguous-miss run the auto-drill watches. _autoDrillMissStreak = 0; } else { @@ -5904,7 +5904,7 @@ function createNoteDetector(options = {}) { dispatchFx({ fxType: 'streakBreak', lostStreak, prevMult: multiplier }); } multiplier = 1; - updateSectionStat('miss'); + updateSectionStat('miss', judgment.noteTime); // Grow the contiguous-miss run and, at the threshold, drop into a // drill on the fluffed passage (auto-drill). Track the run's // chart-time span so the drill targets exactly those notes. @@ -6635,7 +6635,13 @@ function createNoteDetector(options = {}) { // (detected:false verdicts, drained by _ndDrainEngineVerdicts), so // this renderer-side retire scan is superseded. The browser path — // and any desktop session on a downlevel addon — still relies on it. - if (_ndUsingEngineVerifier) return; + // The section tracker below still has to advance, though, or the + // per-section stats stay empty on desktop. + if (_ndUsingEngineVerifier) { + const avOffsetSec = (hw.getAvOffset ? hw.getAvOffset() / 1000 : 0); + _ndTrackSection(hw.getTime() + avOffsetSec - latencyOffset); + return; + } // Reset the per-tick rescue-FFT budget (see _RESCUE_WINDOWS_PER_TICK). // checkNote spends against this; once exhausted, further conceded bass // misses this tick retire as plain misses instead of running FFTs. @@ -6889,27 +6895,44 @@ function createNoteDetector(options = {}) { } } + _ndTrackSection(t); + } + + // Name of the chart section containing chart time `t` (the last one that + // starts at or before it), or null when the chart has no sections. + function _ndSectionNameAt(t) { const sections = hw.getSections ? hw.getSections() : null; - if (sections) { - let current = null; - for (const sec of sections) { - if (sec.time <= t) current = sec.name; - else break; - } - if (current && current !== currentSection) { - currentSection = current; - if (!sectionStats.find(s => s.name === current)) { - sectionStats.push({ name: current, hits: 0, misses: 0 }); - } + if (!sections || !Number.isFinite(t)) return null; + let current = null; + for (const sec of sections) { + if (sec.time <= t) current = sec.name; + else break; + } + return current; + } + + // Advance currentSection to the playhead's section, opening its row in + // sectionStats so a section the player passes through still shows up. + function _ndTrackSection(t) { + const current = _ndSectionNameAt(t); + if (current && current !== currentSection) { + currentSection = current; + if (!sectionStats.find(s => s.name === current)) { + sectionStats.push({ name: current, hits: 0, misses: 0 }); } } } - function updateSectionStat(type) { - if (!currentSection) return; - let sec = sectionStats.find(s => s.name === currentSection); + // Bin a judgment by the section its NOTE sits in when we know the note's + // chart time. Engine verdicts land after a note's window closes, often once + // the playhead has crossed into the next section; the playhead's section + // (currentSection) is only the fallback. + function updateSectionStat(type, noteTime) { + const name = _ndSectionNameAt(noteTime) || currentSection; + if (!name) return; + let sec = sectionStats.find(s => s.name === name); if (!sec) { - sec = { name: currentSection, hits: 0, misses: 0 }; + sec = { name, hits: 0, misses: 0 }; sectionStats.push(sec); } if (type === 'hit') sec.hits++; diff --git a/test/engine_section_stats.test.js b/test/engine_section_stats.test.js new file mode 100644 index 0000000..229f5e2 --- /dev/null +++ b/test/engine_section_stats.test.js @@ -0,0 +1,172 @@ +// Per-section stats on the desktop engine-verifier path. +// +// Section tracking (`currentSection`) used to be advanced only at the tail of +// checkMisses(), which returns early when the engine verifier is in use — the +// engine finalizes misses itself. So on desktop every judgment hit +// updateSectionStat() with currentSection still null: getStats().sectionStats +// stayed empty and notedetect:session.sections shipped as [] for the practice +// journal and the results card. Engine verdicts also land late (a note's +// verdict arrives after its sustain window closes, often after the playhead has +// crossed into the next section), so a judgment is now binned by the section +// its NOTE sits in, not by where the playhead happens to be. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { loadDetectionCore } = require('./_loader'); + +async function flushPendingAsync(turns = 6) { + for (let i = 0; i < turns; i++) { + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setImmediate(r)); + } +} + +// Four notes: two in "Intro" (0–2 s), two in "Verse" (2 s →). +const CHART = [ + { s: 0, f: 3, t: 0.5, sus: 0 }, + { s: 0, f: 3, t: 1.5, sus: 0 }, + { s: 0, f: 5, t: 2.5, sus: 0 }, + { s: 0, f: 5, t: 3.5, sus: 0 }, +]; +const SECTIONS = [ + { name: 'Intro', time: 0 }, + { name: 'Verse', time: 2.0 }, +]; + +function sandbox() { + const calls = { getNoteVerdicts: 0, getPitchDetection: 0 }; + let pushedChart = null; + const verdictQueue = []; + const intervalCallbacks = []; + let hwTime = 0; + + const audio = { + isAvailable: async () => true, + isAudioRunning: async () => true, + startAudio: async () => {}, + getLevels: async () => ({ inputLevel: 0, inputPeak: 0, outputLevel: 0, outputPeak: 0 }), + getSampleRate: async () => 48000, + getPitchDetection: async () => { + calls.getPitchDetection++; + return { midiNote: -1, confidence: 0, frequency: -1, cents: 0, noteName: '' }; + }, + setChart: async (chart) => { pushedChart = chart; return true; }, + getNoteVerdicts: async () => { + calls.getNoteVerdicts++; + return verdictQueue.length ? verdictQueue.shift() : []; + }, + }; + + const { createNoteDetector } = loadDetectionCore({ + sandboxBeforeRun(sb) { + sb.setInterval = (cb) => { + if (typeof cb === 'function') intervalCallbacks.push(cb); + return intervalCallbacks.length; + }; + sb.highway.getNotes = () => CHART.map((n) => ({ ...n })); + sb.highway.getChords = () => ([]); + sb.highway.getSections = () => SECTIONS.map((s) => ({ ...s })); + sb.highway.getTime = () => hwTime; + sb.highway.getAvOffset = () => 0; + sb.window.feedBackDesktop = { isDesktop: true, platform: 'linux', audio }; + }, + }); + + return { + createNoteDetector, calls, intervalCallbacks, verdictQueue, + setHwTime: (t) => { hwTime = t; }, + chartIds: () => (pushedChart && pushedChart.notes + ? pushedChart.notes.map((n) => n.id) : []), + }; +} + +async function driveDetectTick(intervalCallbacks, calls) { + for (const cb of intervalCallbacks) { + const before = calls.getNoteVerdicts + calls.getPitchDetection; + // eslint-disable-next-line no-await-in-loop + await cb(); + // eslint-disable-next-line no-await-in-loop + await flushPendingAsync(); + if (calls.getNoteVerdicts + calls.getPitchDetection > before) return cb; + } + return null; +} + +// Plain-realm copy so deepStrictEqual doesn't trip on the vm's prototypes. +const plain = (rows) => Array.from(rows || [], (s) => ({ name: s.name, hits: s.hits, misses: s.misses })); + +test('engine verifier: verdicts are binned into per-section stats', async () => { + const env = sandbox(); + const det = env.createNoteDetector({ isDefault: false }); + await det.enable(); + await flushPendingAsync(); + const detectTick = await driveDetectTick(env.intervalCallbacks, env.calls); + assert.equal(typeof detectTick, 'function', 'a detect tick should be registered'); + + const ids = env.chartIds(); + assert.equal(ids.length, CHART.length); + + // Intro: hit + miss, both delivered while the playhead is still in the Intro. + env.setHwTime(1.9); + env.verdictQueue.push([ + { id: ids[0], detected: true, detectedSongTime: 0.5, centsError: 0, snr: 6 }, + { id: ids[1], detected: false }, + ]); + await detectTick(); + await flushPendingAsync(); + + // Verse: two hits, delivered after the song has run on past them. + env.setHwTime(4.5); + env.verdictQueue.push([ + { id: ids[2], detected: true, detectedSongTime: 2.5, centsError: 0, snr: 6 }, + { id: ids[3], detected: true, detectedSongTime: 3.5, centsError: 0, snr: 6 }, + ]); + await detectTick(); + await flushPendingAsync(); + + const stats = det.getStats(); + assert.equal(stats.hits, 3); + assert.equal(stats.misses, 1); + assert.deepEqual(plain(stats.sectionStats), [ + { name: 'Intro', hits: 1, misses: 1 }, + { name: 'Verse', hits: 2, misses: 0 }, + ]); + + det.destroy(); + await flushPendingAsync(); +}); + +test('engine verifier: a late verdict is binned by its note, not the playhead', async () => { + const env = sandbox(); + const det = env.createNoteDetector({ isDefault: false }); + await det.enable(); + await flushPendingAsync(); + const detectTick = await driveDetectTick(env.intervalCallbacks, env.calls); + const ids = env.chartIds(); + + // The playhead is already in the Verse when the Intro's last note is + // finalized — the engine only reports once the note's window has closed. + env.setHwTime(2.3); + // Let the other intervals (checkMisses' section tracker among them) see the + // playhead first, so currentSection really is the Verse. + for (const cb of env.intervalCallbacks) { + if (cb === detectTick) continue; + // eslint-disable-next-line no-await-in-loop + await cb(); + } + await flushPendingAsync(); + assert.ok(plain(det.getStats().sectionStats).some((s) => s.name === 'Verse'), + 'the section tracker should have opened the Verse on the engine path'); + env.verdictQueue.push([ + { id: ids[1], detected: true, detectedSongTime: 1.5, centsError: 0, snr: 6 }, + ]); + await detectTick(); + await flushPendingAsync(); + + const intro = plain(det.getStats().sectionStats).find((s) => s.name === 'Intro'); + assert.deepEqual(intro, { name: 'Intro', hits: 1, misses: 0 }, + 'the Intro note must count toward the Intro, not the Verse the playhead is in'); + + det.destroy(); + await flushPendingAsync(); +}); From a7c86ae19d8b23fc8e2c94103d4309bb6f93fd55 Mon Sep 17 00:00:00 2001 From: mattromano <42412983+mattromano@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:08:41 -0700 Subject: [PATCH 3/9] fix(capo): read capo charts' fretted notes from the nut Rocksmith capo charts write fretted notes at their absolute fret and only fret 0 means open (sounding at the capo): a capo-4 E chord is A7 D6 G0 B5. Expected pitch added the capo to every fret, so each fretted note was expected `capo` semitones sharp and capo charts barely scored. Expected pitch is now open + fret for fretted notes and open + capo for open ones. The display fallback reports frets from the nut too. The desktop engine applies open + capo + fret itself, so both bridge funnels hand it capo 0 with open notes moved to the capo fret. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mattromano <42412983+mattromano@users.noreply.github.com> --- README.md | 2 + package.json | 2 +- plugin.json | 2 +- screen.js | 38 ++++++++- test/capo_expected_pitch.test.js | 132 +++++++++++++++++++++++++++++ test/verify-target-context.test.js | 7 +- 6 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 test/capo_expected_pitch.test.js diff --git a/README.md b/README.md index d921626..6ec8154 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ git clone https://github.com/got-feedback/feedback-plugin-notedetect.git note_de 5. The note's **gem lights up brightly** on the highway when you hit it cleanly — and a sustained note keeps glowing for as long as you keep playing it on-pitch; a miss is flagged on the note too 6. Running accuracy and streak shown in the HUD +**Capo charts** are read the Rocksmith way: a fretted note's fret counts from the nut (it already includes the capo), and only fret 0 means an open string, which sounds at the capo. A capo-4 E chord is charted `A7 D6 G0 B5`. The expected pitch is `open + fret` for fretted notes and `open + capo` for open ones. A `setVerifyTarget` context that passes `tuning` + `capo` is read the same way. + > **How step 5 renders depends on the active highway renderer.** The plugin publishes a per-note judgment via Slopsmith core's `highway.setNoteStateProvider` hook (slopsmith#254 — honored by the bundled 2D and 3D highways; other renderers can read the same data from the core renderer `bundle`'s `getNoteState(note, chartTime)` method). On the **default 2D highway** the plugin *also* draws its own canvas overlay — the slide-down red ✕ miss markers below the now-line, the EARLY/LATE/SHARP/FLAT diagnostic labels, and the cyan "currently detected" indicator. On a **custom renderer** (e.g. the 3D highway) that canvas overlay is suppressed — the renderer owns the per-note feedback there (the 3D highway shows hit/active glow and a red miss outline + the diagnostic labels itself). The HUD (accuracy/streak), the end-of-song summary, and the optional screen-edge flash are DOM, not canvas, so they show under any renderer. On an **older core** without the hook, the plugin falls back to the 2D-canvas green hit ring / red miss marker overlay near the note regardless of renderer. ## Audio Input Channel Selection 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 c06655e..1314b17 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 @@ -514,7 +514,8 @@ function _ndVerifySigFor(arrangement, stringCount, offsets, capo) { // • `tuning` / `offsets`: per-string semitone OFFSETS from standard tuning // (the chart convention, matching the instance's tuningOffsets). `offsets` // is also accepted so getVerifyContext() output round-trips back in. This -// path honors ctx.capo. +// path honors ctx.capo, with the chart's fret convention (fretted notes +// count from the nut, fret 0 sounds at the capo; see _ndAbsoluteFret). // Either array is read BY STRING INDEX — a non-finite entry maps to 0 in place, // never reindexed, so one bad string can't shift the others' pitch mapping. // @@ -607,10 +608,35 @@ function _ndMidiToName(midi) { return _ND_PITCH_NAMES[pc] + octave; } +// Capo charts follow the Rocksmith convention: a FRETTED note's fret is +// absolute (counted from the nut, so it already includes the capo) and only +// fret 0 means "open", which with a capo on is the capo fret. A capo-4 E +// chord is charted A7 D6 G0 B5, not A3 D2 G0 B1. Adding the capo to every +// fret put each fretted note `capo` semitones sharp, so capo charts barely +// scored. Returns the fret counted from the nut. +function _ndAbsoluteFret(fret, capo) { + return fret === 0 && capo > 0 ? capo : fret; +} + function _ndMidiFromStringFret(string, fret, arrangement, stringCount, offsets, capo) { const base = _ndStandardMidiFor(arrangement, stringCount); const offset = offsets && offsets[string] !== undefined ? offsets[string] : 0; - return base[string] + offset + (capo || 0) + fret; + return base[string] + offset + _ndAbsoluteFret(fret, capo); +} + +// The desktop engine maps (string, fret) to pitch as open + capo + fret and +// has no switch for the chart convention above. So hand it capo 0 and +// rewrite open (fret 0) notes to the capo fret: the engine then expects the +// same pitches as _ndMidiFromStringFret. Used by both bridge funnels +// (setChart and scoreChord payloads both carry `capo` + `notes[].f`). +// Returns the payload unchanged when there's no capo. +function _ndEngineCapoPayload(payload) { + if (!payload || !(payload.capo > 0)) return payload; + const capo = payload.capo; + const notes = Array.isArray(payload.notes) + ? payload.notes.map(n => (n && n.f === 0 ? { ...n, f: capo } : n)) + : payload.notes; + return { ...payload, capo: 0, notes }; } function _ndClassifyTiming(timingErrorMs, timingThresholdMs, lateGraceMs) { @@ -1406,7 +1432,9 @@ function _ndMidiToStringFret(midiNote, arrangement, stringCount, offsets, capo) if (dist < bestDist) { bestDist = dist; bestString = s; - bestFret = fret; + // `fret` counts from the capo here; report it from the nut, + // the way the chart writes it (see _ndAbsoluteFret). + bestFret = fret > 0 && capo > 0 ? fret + capo : fret; } } return { string: bestString, fret: bestFret }; @@ -3544,6 +3572,7 @@ function createNoteDetector(options = {}) { } function _ndBridgeScoreChord(ctx) { const a = _ndBridgeAudio(); if (!a) return Promise.resolve(null); + ctx = _ndEngineCapoPayload(ctx); if (sourceId != null && typeof a.scoreSourceChord === 'function') return a.scoreSourceChord(sourceId, ctx); if (sourceId == null && typeof a.scoreChord === 'function') @@ -3558,6 +3587,7 @@ function createNoteDetector(options = {}) { } function _ndBridgeSetChart(chart) { const a = _ndBridgeAudio(); if (!a) return Promise.resolve(null); + chart = _ndEngineCapoPayload(chart); if (sourceId != null && typeof a.setSourceChart === 'function') return a.setSourceChart(sourceId, chart); if (sourceId == null && typeof a.setChart === 'function') diff --git a/test/capo_expected_pitch.test.js b/test/capo_expected_pitch.test.js new file mode 100644 index 0000000..95047a6 --- /dev/null +++ b/test/capo_expected_pitch.test.js @@ -0,0 +1,132 @@ +// Capo charts: expected pitch follows the Rocksmith fret convention. +// +// A capo chart writes FRETTED notes at their absolute fret (counted from the +// nut) and only fret 0 means "open", which with a capo on sounds at the capo +// fret. A capo-4 E major shape is charted A7 D6 G0 B5: A7 = E3, D6 = G#3, +// G0 (at the capo) = B3, B5 = E4. Adding the capo to every fret expected each +// fretted note 4 semitones sharp (A7 → G#3), so capo charts barely scored. +// +// The desktop engine computes open + capo + fret itself, so the bridge hands +// it capo 0 plus open notes rewritten to the capo fret — the same pitches. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { loadDetectionCore } = require('./_loader'); + +function loadWithSandbox(before) { + let sb = null; + const core = loadDetectionCore({ + sandboxBeforeRun(s) { sb = s; if (before) before(s); }, + }); + return { core, sb }; +} + +const G6 = [0, 0, 0, 0, 0, 0]; + +test('capo 4: fretted notes are absolute, fret 0 sounds at the capo', () => { + const { sb } = loadWithSandbox(); + const m = (s, f) => sb._ndMidiFromStringFret(s, f, 'guitar', 6, G6, 4); + // E major at capo 4: A7 D6 G0 B5 → E3 G#3 B3 E4. + assert.equal(m(1, 7), 52, 'A string fret 7 is E3, not G#3'); + assert.equal(m(2, 6), 56, 'D string fret 6 is G#3'); + assert.equal(m(3, 0), 59, 'open G under capo 4 is B3'); + assert.equal(m(4, 5), 64, 'B string fret 5 is E4'); + // Open low E under the capo is G#2. + assert.equal(m(0, 0), 44); +}); + +test('no capo: expected pitch is unchanged (open + fret)', () => { + const { sb } = loadWithSandbox(); + const m = (s, f, capo) => sb._ndMidiFromStringFret(s, f, 'guitar', 6, G6, capo); + assert.equal(m(0, 0, 0), 40); + assert.equal(m(1, 7, 0), 52); + assert.equal(m(5, 12, undefined), 76); +}); + +test('capo + tuning offset: the offset still applies to the absolute fret', () => { + const { sb } = loadWithSandbox(); + // Drop D (low string -2), capo 3: low string fret 5 is D2 + 5 = G2. + assert.equal(sb._ndMidiFromStringFret(0, 5, 'guitar', 6, [-2, 0, 0, 0, 0, 0], 3), 43); + // Open low string under the capo: D2 + 3 = F2. + assert.equal(sb._ndMidiFromStringFret(0, 0, 'guitar', 6, [-2, 0, 0, 0, 0, 0], 3), 41); +}); + +test('capo 4: display fallback reports the fret counted from the nut', () => { + const { sb } = loadWithSandbox(); + // E3 (52): first match is the low E string. Capo 4 → absolute fret 12, + // the fret the chart would write for it. + const r = sb._ndMidiToStringFret(52, 'guitar', 6, G6, 4); + assert.equal(r.string, 0); + assert.equal(r.fret, 12); + // A note sounding at the capo maps to fret 0 (open), as charted. + const open = sb._ndMidiToStringFret(44, 'guitar', 6, G6, 4); + assert.equal(open.string, 0); + assert.equal(open.fret, 0); + // And the round trip agrees with the expected-pitch mapping. + assert.equal(sb._ndMidiFromStringFret(r.string, r.fret, 'guitar', 6, G6, 4), 52); +}); + +test('engine payload: capo 0 plus open notes moved to the capo fret', () => { + const { sb } = loadWithSandbox(); + const out = sb._ndEngineCapoPayload({ + capo: 4, arrangement: 'guitar', notes: [{ s: 3, f: 0, ho: true }, { s: 1, f: 7 }], + }); + assert.equal(out.capo, 0); + assert.deepEqual(out.notes.map(n => ({ s: n.s, f: n.f })), [{ s: 3, f: 4 }, { s: 1, f: 7 }]); + assert.equal(out.notes[0].ho, true, 'technique flags travel with the rewritten note'); + // No capo → the very same payload object. + const plain = { capo: 0, notes: [{ s: 0, f: 0 }] }; + assert.equal(sb._ndEngineCapoPayload(plain), plain); +}); + +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)); + } +} + +test('desktop engine verifier receives capo 0 and absolute frets for a capo chart', async () => { + let pushed = null; + const audio = { + isAvailable: async () => true, + isAudioRunning: async () => true, + startAudio: async () => {}, + getLevels: async () => ({ inputLevel: 0, inputPeak: 0, 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 () => [], + }; + const intervals = []; + const { core } = loadWithSandbox((sb) => { + sb.setInterval = (cb) => { if (typeof cb === 'function') intervals.push(cb); return intervals.length; }; + sb.highway.getSongInfo = () => ({ arrangement: 'Rhythm', tuning: G6, capo: 4 }); + sb.highway.getNotes = () => ([ + { t: 1.0, s: 1, f: 7, sus: 0 }, + { t: 1.0, s: 2, f: 6, sus: 0 }, + { t: 1.0, s: 3, f: 0, sus: 0 }, + { t: 1.0, s: 4, f: 5, sus: 0 }, + ]); + sb.highway.getChords = () => ([]); + sb.highway.getTime = () => 0; + sb.highway.getAvOffset = () => 0; + sb.window.feedBackDesktop = { isDesktop: true, platform: 'linux', audio }; + }); + const det = core.createNoteDetector({ isDefault: false }); + await det.enable(); + await flush(); + for (const cb of intervals) { + if (pushed) break; + // eslint-disable-next-line no-await-in-loop + await cb(); + // eslint-disable-next-line no-await-in-loop + await flush(); + } + assert.ok(pushed, 'the chart should reach the engine'); + assert.equal(pushed.capo, 0, 'the engine must not add the capo on top of absolute frets'); + const frets = Array.from(pushed.notes, n => `${n.s}:${n.f}`).sort(); + assert.deepEqual(frets, ['1:7', '2:6', '3:4', '4:5']); + det.destroy(); + await flush(); +}); diff --git a/test/verify-target-context.test.js b/test/verify-target-context.test.js index d5899e3..f5e88c9 100644 --- a/test/verify-target-context.test.js +++ b/test/verify-target-context.test.js @@ -133,7 +133,12 @@ test('setVerifyTarget ctx: scoreChord is called with the caller tuning, not the const req = env.scoreChordRequests[env.scoreChordRequests.length - 1]; assert.equal(req.arrangement, 'bass', 'scoreChord arrangement must come from the ctx'); assert.equal(req.stringCount, 5, 'scoreChord stringCount must come from the ctx'); - assert.equal(req.capo, 2, 'scoreChord capo must come from the ctx'); + // The ctx capo reaches the engine already applied: capo charts write + // fretted notes from the nut, so the bridge hands the engine capo 0 and + // moves only open notes to the capo fret (see _ndEngineCapoPayload). + // The target here is fretted, so its fret passes through unchanged. + assert.equal(req.capo, 0, 'the ctx capo is folded into the frets for the engine'); + assert.deepEqual(Array.from(req.notes, n => n.f), [3], 'a fretted target keeps its fret'); assert.deepEqual([...req.offsets], [-2, 0, 0, 0, 0], 'scoreChord offsets must come from the ctx'); assert.ok(req.harmonicVerify === true && req.bypassMl === true, 'still the harmonic-comb DSP verify path'); From a65d9df69d2b2bc638e8d5e0604d8114b0c0792c 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 4/9] 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> --- screen.js | 36 ++++++- test/fast_chord_runs.test.js | 189 +++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 test/fast_chord_runs.test.js diff --git a/screen.js b/screen.js index 1314b17..efdfb50 100644 --- a/screen.js +++ b/screen.js @@ -639,6 +639,13 @@ function _ndEngineCapoPayload(payload) { return { ...payload, capo: 0, notes }; } +// 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; @@ -6578,6 +6585,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. @@ -14198,10 +14214,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(); +}); From 22c769e71dfe9a6bd0dac8e132cd7fb0bd707690 Mon Sep 17 00:00:00 2001 From: mattromano <42412983+mattromano@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:56:51 -0700 Subject: [PATCH 5/9] fix(tuning): score against the effective (chart-transform) tuning Expected pitches were mapped from getSongInfo().tuning/.capo, the ORIGINAL chart metadata, while the notes we score come from getNotes()/getChords(), which already carry any chart-transform provider's output. A retuned / converted-to-E-standard chart therefore showed the new notes but scored them against the old pitches (#24). - _syncChartStateFromHw reads core's effective getTuning()/getCapo()/ getCentOffset() (feedBack#952), falling back to song_info on hosts without them. - The effective centOffset is applied as a global reference shift, minus whole octaves (octave-folded matching and the bass base tables already cover extended-range bass's -1200), folded into the per-string offsets so it reaches every expected MIDI via _ndMidiFromStringFret. - A per-tick identity check resyncs when a provider restages mid-song (live retune) with no song/arrangement event; the effective offsets join the engine chart signature so the desktop verifier re-pushes. - The bridge funnels round offsets to whole semitones (the engine reads Int32), so a semitone retune reaches the engine exactly. This makes a pitch-shift plugin integrate by registering a chart-transform provider (tuning and/or centOffset) instead of a private event. Bumps 1.32.0 -> 1.33.0. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mattromano <42412983+mattromano@users.noreply.github.com> --- README.md | 2 + screen.js | 111 ++++++++++++++++++-- test/_loader.js | 7 ++ test/effective_tuning.test.js | 184 ++++++++++++++++++++++++++++++++++ 4 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 test/effective_tuning.test.js diff --git a/README.md b/README.md index 6ec8154..c42e12a 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,8 @@ On **Slopsmith Desktop**, when the native audio engine has the Spotify **Basic P This path is fully feature-detected and fail-soft: a desktop build without `detectNotes` still scores single notes via `getPitchDetection`; one without the `scoreChord` IPC skips chord scoring; and a build without the ML model falls back to YIN/`ChordScorer`. The browser build is unaffected and keeps the JS YIN/HPS/CREPE + constraint path. See `slopsmith-desktop/src/audio/MlNoteDetector.*`. +> **Retuned / transformed charts.** Expected pitches follow the chart the player actually sees. When a core [`chart-transform`](https://github.com/got-feedback/feedback/blob/main/docs/capability-recipes.md#chart-transform-provider) provider rewrites the chart (e.g. a retune, transposition or "convert to E standard" view), the plugin scores against the host's *effective* `highway.getTuning()`, `getCapo()` and `getCentOffset()` instead of the original `getSongInfo()` metadata, and re-syncs within a frame when a provider restages mid-song. The effective `centOffset` is applied too (minus whole octaves, which octave-folded matching and the bass base tables already cover — an extended-range bass's `-1200` changes nothing), so a pitch-shifted backing track (say `-100` cents for a half-step-down retune, or `+11.8` for an A443 recording) scores against where the audio really sits. A plugin that pitch-shifts the song should publish that shift as a chart-transform provider returning `tuning` and/or `centOffset`; note detection follows with no extra wiring. On the desktop engine path offsets are rounded to whole semitones (the engine reads integers), so a sub-semitone residue is a browser-path refinement only. + ## Requirements - Browser with `getUserMedia` support (all modern browsers) diff --git a/screen.js b/screen.js index efdfb50..59c4966 100644 --- a/screen.js +++ b/screen.js @@ -646,6 +646,34 @@ function _ndEngineCapoPayload(payload) { // _ndFinalizeChordVerdict. const _ND_ENGINE_STAMP_SLACK_S = 0.1; +// Whole-chart reference-pitch shift (cents) → the part note detection applies. +// The host's effective `centOffset` (`highway.getCentOffset()`: a chart- +// transform provider's value, else the chart's own ``) says the +// audio sits N cents off the chart's nominal pitch — a retune/pitch-shift +// transform, a true-tuned A443 recording (+11.8), a song shifted to E standard. +// Whole octaves are dropped: matching is already octave-folded and the +// per-arrangement base tables already place bass in its own octave, so the +// common extended-range-bass `-1200` must keep scoring exactly as before. The +// remainder lands in (-600, 600] and is folded into the per-string offsets +// (as fractional semitones) by _syncChartStateFromHw, so it reaches every +// expected MIDI / band range through _ndMidiFromStringFret unchanged. +function _ndRefShiftCents(centOffset) { + const c = Number(centOffset); + if (!Number.isFinite(c)) return 0; + const r = c - 1200 * Math.round(c / 1200); + return Math.abs(r) < 1e-6 ? 0 : r; +} + +// The desktop engine's chart/scoreChord contexts read tuning offsets as +// Int32 (truncating), so a fractional reference shift is rounded to the +// nearest semitone at the bridge boundary — a whole-semitone retune still +// reaches the engine exactly; a sub-semitone residue (e.g. A443) stays a +// JS-path refinement that sits inside the engine's pitch tolerance anyway. +function _ndEngineOffsets(offsets) { + if (!Array.isArray(offsets)) return offsets; + return offsets.map(o => Math.round(Number(o) || 0)); +} + function _ndClassifyTiming(timingErrorMs, timingThresholdMs, lateGraceMs) { if (!Number.isFinite(timingErrorMs)) return null; const grace = Number.isFinite(lateGraceMs) && lateGraceMs > 0 ? lateGraceMs : 0; @@ -3426,6 +3454,9 @@ function createNoteDetector(options = {}) { let tuningOffsets = [0, 0, 0, 0, 0, 0]; let capo = 0; let currentStringCount = 6; // kept in sync with tuningOffsets.length + // Effective-tuning inputs the fields above were last synced from (see + // _ndResyncIfTuningChanged); null = never synced. + let _ndSyncedTuningKey = null; // Audio buffers let accumBuffer = new Float32Array(0); @@ -3580,6 +3611,7 @@ function createNoteDetector(options = {}) { function _ndBridgeScoreChord(ctx) { const a = _ndBridgeAudio(); if (!a) return Promise.resolve(null); ctx = _ndEngineCapoPayload(ctx); + if (ctx && Array.isArray(ctx.offsets)) ctx = { ...ctx, offsets: _ndEngineOffsets(ctx.offsets) }; if (sourceId != null && typeof a.scoreSourceChord === 'function') return a.scoreSourceChord(sourceId, ctx); if (sourceId == null && typeof a.scoreChord === 'function') @@ -3595,6 +3627,9 @@ function createNoteDetector(options = {}) { function _ndBridgeSetChart(chart) { const a = _ndBridgeAudio(); if (!a) return Promise.resolve(null); chart = _ndEngineCapoPayload(chart); + if (chart && Array.isArray(chart.tuningOffsets)) { + chart = { ...chart, tuningOffsets: _ndEngineOffsets(chart.tuningOffsets) }; + } if (sourceId != null && typeof a.setSourceChart === 'function') return a.setSourceChart(sourceId, chart); if (sourceId == null && typeof a.setChart === 'function') @@ -4236,6 +4271,10 @@ function createNoteDetector(options = {}) { // song:loaded (and can switch mid-session); // re-push whenever the live chart diverges // from what the engine currently holds. + // A live retune restage changes the + // effective tuning, which is in the + // signature, so it re-pushes here too. + _ndResyncIfTuningChanged(); if (_ndChartSignature() !== _ndVerifierChartSig) { await _ndPushChartToBridge(); if (!enabled || gen !== sessionGen) return; @@ -5114,6 +5153,9 @@ function createNoteDetector(options = {}) { // ── Frame processing ────────────────────────────────────────────── async function processFrame(buffer) { + // Follow a mid-song chart-transform restage (live retune) before + // mapping any (string, fret) to an expected pitch this frame. + _ndResyncIfTuningChanged(); // Append to the rolling raw-audio buffer for the bass miss rescue // (checkMisses). Bass only — guitar's higher fundamentals resolve in // the short window, so it doesn't need (or pay for) this. @@ -13713,10 +13755,26 @@ function createNoteDetector(options = {}) { tuningOffsets = [0, 0, 0, 0, 0, 0]; capo = 0; const info = (hw && hw.getSongInfo) ? hw.getSongInfo() : null; + const eff = _ndReadEffectiveTuning(); + _ndSyncedTuningKey = eff; if (!info) return; if (info.arrangement) currentArrangement = _ndArrangementKindFromName(info.arrangement); - if (Array.isArray(info.tuning)) tuningOffsets = info.tuning; - if (Number.isFinite(info.capo)) capo = info.capo; + // Tuning / capo / reference pitch come from the host's EFFECTIVE + // getters, not getSongInfo(): getSongInfo() keeps the original chart + // metadata, while getNotes()/getChords() (what we score) already carry + // any chart-transform provider's output. Reading the original tuning + // against transformed frets made a retuned / converted-to-E-standard + // chart score against its old pitches. Hosts without the getters + // (pre-chart-transform) fall back to song_info. + const tuning = Array.isArray(eff.tuning) ? eff.tuning : info.tuning; + if (Array.isArray(tuning)) { + const shift = _ndRefShiftCents(eff.centOffset !== undefined ? eff.centOffset : info.centOffset); + tuningOffsets = shift + ? tuning.map(o => (Number(o) || 0) + shift / 100) + : tuning; + } + const effCapo = eff.capo !== undefined ? eff.capo : info.capo; + if (Number.isFinite(effCapo)) capo = effCapo; // String-count resolution, in precedence order: // 1. hw.getStringCount() — host's authoritative count. Asked @@ -13741,7 +13799,7 @@ function createNoteDetector(options = {}) { if (Number.isFinite(hostStringCount)) { currentStringCount = hostStringCount; } else if (info.arrangement) { - const tuneLen = Array.isArray(info.tuning) ? info.tuning.length : null; + const tuneLen = Array.isArray(tuning) ? tuning.length : null; const consistent = currentArrangement === 'bass' ? (tuneLen === 4 || tuneLen === 5) : (tuneLen === 6 || tuneLen === 7 || tuneLen === 8); @@ -13750,11 +13808,47 @@ function createNoteDetector(options = {}) { } else { currentStringCount = currentArrangement === 'bass' ? 4 : 6; } - } else if (Array.isArray(info.tuning)) { - currentStringCount = info.tuning.length; + } else if (Array.isArray(tuning)) { + currentStringCount = tuning.length; } } + // The host's effective tuning inputs, read raw (undefined when the host + // predates the chart-transform getters). Compared by identity in + // _ndResyncIfTuningChanged: the highway hands back the same array/number + // until a transform restages, so this is a few getter calls per tick. + function _ndReadEffectiveTuning() { + const read = (name) => { + if (!hw || typeof hw[name] !== 'function') return undefined; + try { return hw[name](); } catch (_) { return undefined; } + }; + return { + tuning: read('getTuning'), + capo: read('getCapo'), + centOffset: read('getCentOffset'), + }; + } + // A chart-transform provider can restage mid-song (select / clear / + // refresh — e.g. a live retune) without any song or arrangement event. + // Called from the detect tick so scoring follows the effective tuning + // within a frame; returns true when it resynced. + function _ndResyncIfTuningChanged() { + // Pre-chart-transform host: nothing can restage mid-song, and the + // song/arrangement events already cover every change. + if (!hw || (typeof hw.getTuning !== 'function' + && typeof hw.getCapo !== 'function' + && typeof hw.getCentOffset !== 'function')) return false; + const k = _ndSyncedTuningKey; + const now = _ndReadEffectiveTuning(); + if (k && k.tuning === now.tuning && k.capo === now.capo + && (k.centOffset === now.centOffset + || (Number.isNaN(k.centOffset) && Number.isNaN(now.centOffset)))) { + return false; + } + _syncChartStateFromHw(); + return true; + } + // ── Engine-side chart verification (desktop bridge) ─────────────────── // Build the song's note chart from the host and push it to the desktop // engine, which scores it continuously on a background thread. Replaces @@ -13810,7 +13904,8 @@ function createNoteDetector(options = {}) { // value up live — without this the popup sliders are inert on the // engine-verifier path until the next song/arrangement change. return n + ':' + c + ':' + currentArrangement + ':' + currentStringCount - + ':' + capo + ':' + timingTolerance + ':' + pitchTolerance + + ':' + capo + ':' + tuningOffsets.slice(0, currentStringCount).join(',') + + ':' + timingTolerance + ':' + pitchTolerance + ':' + (fnv >>> 0); } @@ -18646,6 +18741,7 @@ function createNoteDetector(options = {}) { _bindChartStateEvents: _chartStateBindEvents, _unbindChartStateEvents: _chartStateUnbindEvents, _syncChartStateFromHw: _syncChartStateFromHw, + _resyncIfTuningChanged: _ndResyncIfTuningChanged, // Auto-record test hooks — same rationale. Production binds from // enableImpl() / unbinds from destroy(); tests drive them without // the audio pipeline to assert the song:loaded auto-arm. @@ -18690,6 +18786,9 @@ function createNoteDetector(options = {}) { if (Number.isFinite(ctx.stringCount)) currentStringCount = ctx.stringCount; if (Array.isArray(ctx.tuningOffsets)) tuningOffsets = ctx.tuningOffsets.slice(); if (Number.isFinite(ctx.capo)) capo = ctx.capo; + // An injected context is authoritative — don't let the + // per-tick effective-tuning resync overwrite it. + _ndSyncedTuningKey = _ndReadEffectiveTuning(); }, setSettings: (s) => { s = s || {}; diff --git a/test/_loader.js b/test/_loader.js index 05e255e..087a129 100644 --- a/test/_loader.js +++ b/test/_loader.js @@ -259,6 +259,13 @@ function loadDetectionCore({ sandboxBeforeRun } = {}) { midiToStringFret: midiToStringFretWrapped, resolveDisplayFingering: resolveDisplayFingeringWrapped, nearestOctaveCents: sandbox._ndNearestOctaveCents, + // Effective-tuning helpers (chart-transform / centOffset follow). + rawMidiFromStringFret: sandbox._ndMidiFromStringFret, + refShiftCents: sandbox._ndRefShiftCents, + engineOffsets: (offsets) => { + const r = sandbox._ndEngineOffsets(offsets); + return Array.isArray(r) ? Array.from(r) : r; + }, stringBandHz: sandbox._ndStringBandHz, bandEnergy: sandbox._ndBandEnergy, constraintCheckString: sandbox._ndConstraintCheckString, diff --git a/test/effective_tuning.test.js b/test/effective_tuning.test.js new file mode 100644 index 0000000..cef80e0 --- /dev/null +++ b/test/effective_tuning.test.js @@ -0,0 +1,184 @@ +// Effective-tuning follow — scoring maps (string, fret) → pitch through the +// host's EFFECTIVE tuning / capo / reference pitch, not the original song_info. +// +// Regression target (notedetect#24): when the chart is retuned / converted to +// E standard, the highway shows the new notes but detection kept expecting the +// old pitches. Core's chart-transform domain (feedBack#952) is the official +// signal for "the chart the player sees differs from the original": a provider +// may replace tuning, capo and centOffset, exposed through +// `highway.getTuning()` / `getCapo()` / `getCentOffset()` while +// `getSongInfo()` keeps the original metadata. These tests pin: +// 1. _ndRefShiftCents keeps only the sub-octave part of a centOffset. +// 2. _syncChartStateFromHw prefers the effective getters over song_info. +// 3. A whole-semitone centOffset shifts every expected MIDI; the common +// extended-range-bass -1200 changes nothing. +// 4. A mid-song transform restage (no song/arrangement event) is picked up +// by the per-tick resync. +// 5. The desktop engine receives integer offsets (it reads Int32). +// 6. A pre-chart-transform host (no getters) keeps the song_info path. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { loadDetectionCore } = require('./_loader'); + +function mountWith(core, { info, tuning, capo, centOffset, stringCount = 6 }) { + core.highway.getSongInfo = () => info; + core.highway.getStringCount = () => stringCount; + if (tuning !== undefined) core.highway.getTuning = () => tuning; + if (capo !== undefined) core.highway.getCapo = () => capo; + if (centOffset !== undefined) core.highway.getCentOffset = () => centOffset; + return core.createNoteDetector(); +} + +test('_ndRefShiftCents drops whole octaves, keeps the remainder', () => { + const core = loadDetectionCore(); + const f = core.refShiftCents; + assert.equal(f(0), 0); + assert.equal(f(undefined), 0); + assert.equal(f(NaN), 0); + assert.equal(f('x'), 0); + assert.equal(f(-1200), 0, 'extended-range bass octave is not a retune'); + assert.equal(f(2400), 0); + assert.equal(f(-100), -100, 'a half-step-down retune'); + assert.equal(f(-1300), -100, 'octave + semitone keeps the semitone'); + assert.ok(Math.abs(f(11.8) - 11.8) < 1e-9, 'A443 true-tuning'); +}); + +test('_ndEngineOffsets rounds to whole semitones for the Int32 engine', () => { + const core = loadDetectionCore(); + assert.deepEqual(core.engineOffsets([-1, -1.3, 0.118, -2.6]), [-1, -1, 0, -3]); + assert.equal(core.engineOffsets(null), null); +}); + +test('effective tuning/capo from a chart transform beat original song_info', () => { + const core = loadDetectionCore(); + // Original chart: Eb standard, capo 0. Transform: converted to E standard + // with a capo-2 view. + const det = mountWith(core, { + info: { arrangement: 'Lead', tuning: [-1, -1, -1, -1, -1, -1], capo: 0, centOffset: 0 }, + tuning: [0, 0, 0, 0, 0, 0], + capo: 2, + centOffset: 0, + }); + det._syncChartStateFromHw(); + const st = det._getChartState(); + assert.equal(st.tuningOffsets.join(','), '0,0,0,0,0,0', 'transformed tuning, not the original'); + assert.equal(st.capo, 2, 'transformed capo'); + assert.equal(st.stringCount, 6); + det.destroy(); +}); + +test('a semitone centOffset shifts every expected pitch; -1200 bass does not', () => { + const core = loadDetectionCore(); + const det = mountWith(core, { + info: { arrangement: 'Lead', tuning: [0, 0, 0, 0, 0, 0], centOffset: 0 }, + tuning: [0, 0, 0, 0, 0, 0], + capo: 0, + centOffset: -100, + }); + det._syncChartStateFromHw(); + const st = det._getChartState(); + assert.equal(st.tuningOffsets.join(','), '-1,-1,-1,-1,-1,-1'); + // Low E open → Eb2 (39) instead of E2 (40); the chokepoint is unchanged. + assert.equal(core.rawMidiFromStringFret(0, 0, 'guitar', 6, st.tuningOffsets, st.capo), 39); + det.destroy(); + + const core2 = loadDetectionCore(); + const bass = mountWith(core2, { + info: { arrangement: 'Bass', tuning: [0, 0, 0, 0], centOffset: -1200 }, + tuning: [0, 0, 0, 0], + capo: 0, + centOffset: -1200, + stringCount: 4, + }); + bass._syncChartStateFromHw(); + const bs = bass._getChartState(); + assert.equal(bs.tuningOffsets.join(','), '0,0,0,0', 'octave offset leaves bass scoring as before'); + assert.equal(bs.arrangement, 'bass'); + bass.destroy(); +}); + +test('a mid-song transform restage is picked up by the per-tick resync', () => { + const core = loadDetectionCore(); + let tuning = [0, 0, 0, 0, 0, 0]; + let cents = 0; + core.highway.getSongInfo = () => ({ arrangement: 'Lead', tuning: [0, 0, 0, 0, 0, 0] }); + core.highway.getStringCount = () => 6; + core.highway.getTuning = () => tuning; + core.highway.getCapo = () => 0; + core.highway.getCentOffset = () => cents; + const det = core.createNoteDetector(); + det._syncChartStateFromHw(); + assert.equal(det._resyncIfTuningChanged(), false, 'unchanged inputs → no resync'); + + // Live retune provider restages: whole-step down via tuning… + tuning = [-2, -2, -2, -2, -2, -2]; + assert.equal(det._resyncIfTuningChanged(), true); + assert.equal(det._getChartState().tuningOffsets.join(','), '-2,-2,-2,-2,-2,-2'); + assert.equal(det._resyncIfTuningChanged(), false); + + // …then a reference-pitch nudge via centOffset. + cents = 50; + assert.equal(det._resyncIfTuningChanged(), true); + assert.equal(det._getChartState().tuningOffsets.join(','), '-1.5,-1.5,-1.5,-1.5,-1.5,-1.5'); + det.destroy(); +}); + +test('pre-chart-transform host (no getters) keeps the song_info path', () => { + const core = loadDetectionCore(); + delete core.highway.getTuning; + delete core.highway.getCapo; + delete core.highway.getCentOffset; + core.highway.getSongInfo = () => ({ arrangement: 'Lead', tuning: [-2, 0, 0, 0, 0, 0], capo: 3 }); + core.highway.getStringCount = () => 6; + const det = core.createNoteDetector(); + det._syncChartStateFromHw(); + const st = det._getChartState(); + assert.equal(st.tuningOffsets.join(','), '-2,0,0,0,0,0'); + assert.equal(st.capo, 3); + assert.equal(det._resyncIfTuningChanged(), false, 'nothing to poll without the getters'); + det.destroy(); +}); + +test('the desktop engine chart gets integer offsets under a fractional shift', async () => { + const pushed = []; + const audio = { + isAvailable: async () => true, + isAudioRunning: async () => true, + startAudio: async () => {}, + getLevels: async () => ({ inputLevel: 0, inputPeak: 0, outputLevel: 0, outputPeak: 0 }), + getSampleRate: async () => 48000, + getPitchDetection: async () => ({ midiNote: -1, confidence: 0, frequency: -1, cents: 0, noteName: '' }), + getRawPitch: async () => ({ midiNote: -1, confidence: 0, frequency: -1, cents: 0, noteName: '' }), + setChart: async (chart) => { pushed.push(chart); return true; }, + getNoteVerdicts: async () => [], + }; + const { createNoteDetector } = loadDetectionCore({ + sandboxBeforeRun(sandbox) { + sandbox.setInterval = () => 1; + sandbox.dispatchEvent = () => true; + sandbox.highway.getNotes = () => ([{ s: 1, f: 2, t: 3.0, sus: 0 }]); + sandbox.highway.getChords = () => ([]); + sandbox.highway.getTime = () => 0; + sandbox.highway.getAvOffset = () => 0; + sandbox.highway.getSongInfo = () => ({ arrangement: 'Lead', tuning: [0, 0, 0, 0, 0, 0] }); + sandbox.highway.getStringCount = () => 6; + sandbox.highway.getTuning = () => [-1, -1, -1, -1, -1, -1]; + sandbox.highway.getCapo = () => 0; + sandbox.highway.getCentOffset = () => -30; // -1.3 semitones + sandbox.window.feedBackDesktop = { isDesktop: true, platform: 'linux', audio }; + }, + }); + const det = createNoteDetector({ isDefault: false }); + await det.enable(); + for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r)); + // Host live tuning (no ctx) → the funnel rounds before the engine sees it. + const armed = await det.setContainedChart([{ id: 'x', t: 0.5, s: 0, f: 0 }]); + assert.equal(armed, true); + const last = pushed[pushed.length - 1]; + assert.equal(last.tuningOffsets.join(','), '-1,-1,-1,-1,-1,-1'); + assert.equal(det._getChartState().tuningOffsets.join(','), '-1.3,-1.3,-1.3,-1.3,-1.3,-1.3', + 'the JS path keeps the sub-semitone shift'); + det.destroy(); + for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r)); +}); From e73d131b10f443188a4e4b22e62b6039659528af Mon Sep 17 00:00:00 2001 From: mattromano <42412983+mattromano@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:14:20 -0700 Subject: [PATCH 6/9] feat(events): richer notedetect:hit/miss payloads for stats plugins A stats or scorecard plugin could only get per-string chord results, technique names, latency or section by patching screen.js. Every emitted judgment now also carries: - key: the note id (same as getNoteState's popKey) - strings: per-string verdicts { s, f, hit, centsError, snr?, detectedAt? } for chords on every path (a missed browser chord reports its best frame) and for engine-path single notes - techniques: named techniques from the chart note's wire flags - latencyMs / avOffsetMs, detector, section (by note time) - counted, points, multiplier, streak, isDefault All additive; existing fields are unchanged. README documents the contract. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mattromano <42412983+mattromano@users.noreply.github.com> --- CLAUDE.md | 2 +- README.md | 27 +++++ screen.js | 128 +++++++++++++++++++++- test/judgment_events.test.js | 198 +++++++++++++++++++++++++++++++++++ 4 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 test/judgment_events.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 9229fa0..9636877 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ defer the window copy a task and drop it if the element copy arrived | Event | Bus too? | Payload (additive-only contract) | |---|---|---| -| `notedetect:hit` / `notedetect:miss` | as `note:hit`/`note:miss` | the full judgment (`{ hit, chord, note, notes, noteTime, timingState, timingError, pitchState, pitchError, ... }`) | +| `notedetect:hit` / `notedetect:miss` | as `note:hit`/`note:miss` | the full judgment (`{ hit, chord, note, notes, noteTime, timingState, timingError, pitchState, pitchError, ... }`), plus from v1.33 `{ key, strings, techniques, latencyMs, avOffsetMs, detector, section, counted, points, multiplier, streak, isDefault }` (field reference in README "Events"; stamped by `_ndEnrichJudgment` at emit) | | `notedetect:fx` | yes | `{ fxType: 'multiplier', mult, prevMult, streak }` on tier change; `{ fxType: 'milestone', streak, mult }` at 25/50/every 100; `{ fxType: 'streakBreak', lostStreak, prevMult }` after a ≥10 run dies. All carry `{ isDefault, ts }` | | `notedetect:session` | no | end-of-song summary: `{ title, artist, arrangement, accuracy, hits, misses, bestStreak, score, maxMultiplier, grade, fullCombo, sections, timestamp }` (consumed by the practice journal — never rename/remove keys) | | `notedetect:skin` | yes | `{ skin }` — re-resolve any cached palette on this | diff --git a/README.md b/README.md index c42e12a..2197049 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,18 @@ Field reference for the per-note events: | `hit` | `true` only when timing and pitch are both clean | all events (`false` for misses) | | `timingState` / `pitchState` | Independent diagnostic axes: `OK`, `EARLY`, `LATE`, `SHARP`, or `FLAT` | clean hits and matched diagnostic misses; `null` for pure misses | | `timingError` / `pitchError` | Signed timing error in milliseconds and signed pitch error in cents; `pitchError` is octave-folded to the nearest octave (so it is not necessarily equal to `(detectedMidi - expectedMidi) * 100`) | clean hits and matched diagnostic misses; `null` for pure misses | +| `chord` / `notes` | `true` for a chord-level judgment; `notes` lists the chord's `{ s, f }` (`note` is then the lead string) | all events | +| `hitStrings` / `totalStrings` / `score` | How many of the chord's strings rang, out of how many, and the ratio the Chord Leniency setting is compared with | chord judgments where the scorer ran | +| `key` | Stable id of the judged chart note, the same key the highway's `getNoteState` hands back as `popKey`: `"