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 bd9025c..148fa49 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 }; @@ -3526,6 +3554,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') @@ -3540,6 +3569,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');