mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-23 13:21:21 +00:00
* Initial commit — clean relaunch * Initial commit — clean relaunch * Remove external game/format terminology from docs and code Reword references to the external game and its proprietary file formats in comments, docstrings, UI text and identifiers; no behaviour change. * Repoint dead slopsmith URLs -> got-feedback Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(midi): consume core midi-input domain + session robustness fixes (#2) * feat(midi): consume the core midi-input domain instead of private requestMIDIAccess (#881) Route Web-MIDI device access through window.slopsmith.midiInput (discover/list/select/open/close) rather than a private navigator.requestMIDIAccess(). One shared device-access boundary with piano/drums/onboarding; retires this plugin's private Web-MIDI. The note-detection 'midi' exact-verdict provider role and the audio-input source export are preserved (now backed by midi-input source data). Saved pick ({id,name}) stays compatible — domain sourceId == the old MIDIInput.id. Live listener is the domain handle's addListener/removeListener; the async init/resume/pause gate and device-vanish handling are kept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): stop exporting MIDI into the audio-input domain Keys MIDI now lives in the dedicated midi-input domain (#881). Continuing to register pseudonymized 'midi-input-N' sources into audio-input polluted audio device pickers — notably the onboarding guitar audio-input dropdown showed MIDI devices under cryptic names. Unregister any leftovers and register none. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): guard async connect races, denied-discovery latch, key consistency Codex preflight findings on the midi-input consumption: - Don't latch `_midiReady` when discover() resolves denied/unavailable — only on a handled outcome — so reopening retries the permission prompt. - Add a generation guard to async `_midiConnect`: a slower open() from an earlier selection can resolve after a device/None switch and install a stale handle/listener. Discard (and close) superseded opens. - Carry the domain `logicalSourceKey` on the source/selection descriptor and use it for select/open/close instead of synthesizing `web-midi::<id>`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): invalidate pending opens on detach; clear phantom selection; manifest Codex re-review (round 2): - A detach driven by device removal (sources-changed) didn't advance _midiConnectSeq, so a pending _midiConnect open could resume and install a handle/listener for a now-gone source. Bump the generation inside _midiDetach and capture myGen after it, so any later detach supersedes an in-flight open. - If mi.open() yields no handle, _midiInput stayed set — a phantom connected device the render loop's miss-sweeping would penalize. Clear it on the no-handle and catch paths. - plugin.json: replace the stale audio-input provider role (the removed MIDI-into-audio-input export bridge) with the midi-input requester it now uses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): make _midiResume idempotent under multiple live instances Codex re-review (round 3): with the domain addListener API, a second live renderer instance (splitscreen/overlapping lifetimes) calling _midiResume() while already active could register the same listener again and double-deliver a MIDI note to the focused instance (hit + duplicate misses). Return early when already active rather than relying on the provider's Set-backed de-dup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): release domain session on teardown; counter-free legacy source cleanup Codex re-review (round 4): - [P2] Last-instance teardown only detached the listener and never released the midi-input domain session, leaking the requester ref. Add _midiReleaseSession() (delegates to _midiDetach: close + null + generation bump) at the destroy site; re-mount's _midiInit auto-connects from the saved pick. - [P3] The legacy audio-input MIDI-source cleanup looped over the module-local _aiRegisteredCount, which resets to 0 on an in-page upgrade so the prior build's 'keys-midi:input-N' entries were never unregistered. Iterate a fixed bound over the known sourceId pattern instead (unregister of an absent source is a no-op). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): gate miss-sweep on live handle; don't open without a live renderer Codex re-review (round 5): - Miss-sweeping was gated on _midiInput, set as soon as a device is picked, but the async mi.open() may still be pending (slow / permission prompt) — notes passing during that window banked false misses. Gate on _midiHandle, truthy only after a handle is opened and wired. - A settings-only ensure-init (or a discover resolving after the last instance was torn down) could open a midi-input session with no renderer to release it. Gate mi.open() on _instances.size > 0; the pick is saved and a later mount re-runs auto-connect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): guard open-failure clobber + don't clobber saved device on unplug Bringing keys-highway-3d in line with the piano/drums hardening: - The _midiConnect catch cleared _midiInput unconditionally; a stale older open's rejection could wipe a newer connect's _midiInput/_midiHandle (and leak the handle). Only clear when myGen === _midiConnectSeq. - The sources-changed recovery called _midiAutoConnect(), whose fallback persists a substitute device — overwriting the user's saved pick on a transient multi-device unplug. Parameterize _midiAutoConnect(allowFallback); recovery passes false (reconnect the saved device only, never a persisted fallback). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): keep first-hotplug working; don't tear down a live session on re-init Codex round-11: - The unplug-recovery _midiAutoConnect(false) skipped ALL fallbacks, which broke first-hotplug (open visualizer with no keyboard, plug one in → never picked → no connect). Skip the substitute only when a saved pick exists but is absent (preserve it); allow the fallback when nothing was ever picked. - _midiInit re-ran _midiAutoConnect on every ready re-init (settings/splitscreen), tearing down the live handle + releasing held keys. Only re-connect when there's no live handle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(midi): consume core midi-input domain + session robustness fixes (#2) * feat(midi): consume the core midi-input domain instead of private requestMIDIAccess (#881) Route Web-MIDI device access through window.slopsmith.midiInput (discover/list/select/open/close) rather than a private navigator.requestMIDIAccess(). One shared device-access boundary with piano/drums/keys/onboarding. Saved pick ({id,name}, with legacy id-only fallback) stays compatible — domain sourceId == the old MIDIInput.id. The async in-flight init guard, _midiActive teardown gate, device-vanish handling, and settings device APIs are preserved; the live listener is the domain handle's addListener/removeListener. Degrades to no-MIDI when the domain is absent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): guard async connect races, denied-discovery latch, key consistency Codex preflight findings on the midi-input consumption: - Don't latch `_midiReady` when discover() resolves denied/unavailable — only on a handled outcome — so reopening retries the permission prompt. - Add a generation guard to async `_midiConnect`: a slower open() from an earlier selection can resolve after a device/None switch and install a stale handle/listener. Discard (and close) superseded opens. - Carry the domain `logicalSourceKey` on the source/selection descriptor and use it for select/open/close instead of synthesizing `web-midi::<id>`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): invalidate pending opens on detach; clear phantom selection; manifest Codex re-review (round 2): - A detach driven by device removal (sources-changed) didn't advance _midiConnectSeq, so a pending _midiConnect open could resume and install a handle/listener for a now-gone source. Bump the generation inside _midiDetach and capture myGen after it, so any later detach supersedes an in-flight open. - If mi.open() yields no handle, _midiInput stayed set — a phantom connected device the render loop's miss-sweeping would penalize. Clear it on the no-handle and catch paths. - Declare the midi-input requester capability (degrade-noop) in plugin.json. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): make _midiResume idempotent under multiple live instances Proactively mirror the keys-highway-3d round-3 fix (same multi-instance lifecycle and identical code): a second live renderer instance calling _midiResume() while already active could double-register the listener and double-deliver a MIDI hit. Return early when already active. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): release the domain session on final teardown Mirror the keys-highway-3d round-4 fix: last-instance teardown only detached the listener and never released the midi-input domain session, leaking the requester ref. Add _midiReleaseSession() (delegates to _midiDetach: close + null + generation bump) at the destroy site; re-mount's _midiInit auto-connects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): gate miss-sweep on live handle; don't open without a live renderer Codex re-review (round 5): - [P2] drumH3dEnsureMidiInit (settings-only, no renderer) auto-connected and opened a midi-input session that destroy()/_midiReleaseSession would never run to release — held until reload. Gate mi.open() on _instances.size > 0; the pick is saved and a later renderer mount re-runs auto-connect. - Mirror the keys-highway-3d miss-sweep fix: gate accumulation on _midiHandle (live wired session), not _midiInput (set before the async open resolves), so notes during a pending open don't bank false misses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): guard open-failure clobber + reconnect saved device on replug Bringing drum-highway-3d in line with the piano/drums hardening: - The _midiConnect catch cleared _midiInput unconditionally; a stale older open's rejection could wipe a newer connect's _midiInput/_midiHandle. Only clear when myGen === _midiConnectSeq. - The sources-changed handler only refreshed the list (never reconnected), so a replug of the saved device didn't reattach. Reconnect on sources-changed via _midiAutoConnect(false) — saved device only, no fallback, so a transient unplug can't switch to / persist another input. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): keep first-hotplug working; don't tear down a live session on re-init Same two round-11 fixes as keys-highway-3d (shared structure): the recovery _midiAutoConnect(false) now allows a fallback when nothing was ever picked (first-hotplug) while still preserving a saved-but-absent pick; and _midiInit only re-connects when there's no live handle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): select by logicalSourceKey, not provider-local sourceId (#4) Codex review of the midi-input migration: resolving a selection with find(s => s.id === id) treats the provider-local sourceId as globally unique, so when the core midi-input domain aggregates multiple providers two devices sharing a sourceId can't be distinguished — the wrong device opens and unplug detection can miss that the selected key vanished. Thread the globally-unique logicalSourceKey through save/read, _midiConnect, auto-connect reconnect, the public keysH3dSetMidiInput entry, and the capability _aiOpen path, preferring the key and keeping the bare sourceId / name only as a legacy fallback. Mirrors the already-merged drums/piano migration (_midiResolveSaved). Self-reviewed against that reference. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(midi): select by logicalSourceKey, not provider-local sourceId (#3) Codex review of the midi-input migration (same issue as keys-highway-3d): resolving a selection with find(s => s.id === id) treats the provider-local sourceId as globally unique, so when the core midi-input domain aggregates multiple providers two devices sharing a sourceId can't be distinguished — the wrong device opens and unplug detection can miss the vanished key. Thread the globally-unique logicalSourceKey through save/read, _midiConnect, the auto-connect reconnect, and the public drumH3dSetMidiInput entry, preferring the key with the bare sourceId / name only as a legacy fallback. Mirrors the already-merged drums/piano migration. Self-reviewed. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: add plugin metadata (category, description, thumbnail) (#5) * chore: add plugin metadata (category/description/thumbnail) Refs got-feedback/feedBack#571 * chore: add placeholder thumbnail Refs got-feedback/feedBack#571 * chore: add plugin metadata (category, description, thumbnail) (#4) * chore: add plugin metadata (category/description/thumbnail) Refs got-feedback/feedBack#571 * chore: add placeholder thumbnail Refs got-feedback/feedBack#571 * fix(viz): register feedBackViz_<id> so the player viz picker lists this plugin (#5) After the slopsmith->feedBack rename the factory was still registered as window.slopsmithViz_<id>; the host viz picker only looks up window.feedBackViz_<id> (no slopsmithViz alias exists) and silently skipped this plugin. Add the feedBackViz_ global (aliased to the existing slopsmithViz_ one for back-compat). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(viz): register feedBackViz_<id> so the player viz picker lists this plugin (#6) After the slopsmith->feedBack rename the factory was still registered as window.slopsmithViz_<id>; the host viz picker only looks up window.feedBackViz_<id> (no slopsmithViz alias exists) and silently skipped this plugin. Add the feedBackViz_ global (aliased to the existing slopsmithViz_ one for back-compat). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(plugins): bundle drum_highway_3d + keys_highway_3d as in-tree core plugins Import both 3D highway plugins from their standalone repos via git subtree (history preserved), ahead of the visual-parity epic that ports the guitar highway's polish to them. - .gitignore: !plugins/drum_highway_3d/ + !plugins/keys_highway_3d/ exceptions - keys plugin.json: "bundled": true (drum already had it) - CI: JS test step gains 'plugins/*/tests/*.test.js' (+20 keys tests) - static/tailwind.min.css regenerated (core build scans plugins/**) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drum_highway_3d): narrow Auto-mode predicate so bundling can't steal guitar arrangements has_drum_tab is pack-level; first-match-wins Auto order sorts this plugin before highway_3d. Claim only drum arrangements, or packs nothing more specific can render (Codex preflight P2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: CHANGELOG — note the deliberate Auto-predicate narrowing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drum_highway_3d): MIDI lifecycle — no false misses on mid-song connect, promote survivor on destroy - _missSweepFloor exempts notes that passed before the device wired up from the miss sweep (lowered on seek-back; cleared with scoring resets) - destroy() promotes a surviving instance to _activeInstance so splitscreen panel teardown doesn't drop all MIDI routing (Codex preflight round 2, both pre-existing in the imported code) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Sin <deathlysin@outlook.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
6.4 KiB
JavaScript
155 lines
6.4 KiB
JavaScript
// Pure MIDI-scoring tests: load screen.js in a bare vm window and exercise
|
|
// the __test exports (no DOM, no WebGL, no MIDI device, no network).
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
|
|
function load() {
|
|
const window = {
|
|
console,
|
|
location: { protocol: 'http:', host: 'localhost' },
|
|
slopsmith: {},
|
|
};
|
|
window.window = window;
|
|
window.globalThis = window;
|
|
const context = vm.createContext(window);
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
|
vm.runInContext(src, context, { filename: 'screen.js' });
|
|
return window.slopsmithViz_keys_highway_3d.__test;
|
|
}
|
|
|
|
const TOL = 0.10;
|
|
|
|
test('accuracyOf/scoreOf mirror the notedetect stats formula', () => {
|
|
const { accuracyOf, scoreOf } = load();
|
|
// accuracy = hits / max(1, hits + misses)
|
|
assert.equal(accuracyOf(0, 0), 0);
|
|
assert.equal(accuracyOf(10, 0), 1);
|
|
assert.equal(accuracyOf(3, 1), 0.75);
|
|
// score = round(hits * 100 * accuracy)
|
|
assert.equal(scoreOf(0, 0), 0);
|
|
assert.equal(scoreOf(10, 0), 1000);
|
|
assert.equal(scoreOf(3, 1), Math.round(3 * 100 * 0.75));
|
|
// Monotonic in accuracy at fixed hits.
|
|
assert.ok(scoreOf(5, 0) > scoreOf(5, 5));
|
|
});
|
|
|
|
test('judgeHit: exact note inside ±0.10 s window hits; outside misses', () => {
|
|
const { judgeHit } = load();
|
|
const notes = [
|
|
{ midi: 60, t: 1.0 },
|
|
{ midi: 64, t: 1.0 },
|
|
{ midi: 62, t: 2.0 },
|
|
];
|
|
const hitKeys = new Set();
|
|
// On time.
|
|
assert.equal(judgeHit(notes, 60, 1.0, hitKeys, TOL), '1.000|60');
|
|
// Near the edge of the window (the exact ±0.10 boundary is float-
|
|
// representation dependent, same as the piano plugin).
|
|
assert.equal(judgeHit(notes, 62, 2.099, hitKeys, TOL), '2.000|62');
|
|
assert.equal(judgeHit(notes, 64, 0.901, hitKeys, TOL), '1.000|64');
|
|
// Just outside the window.
|
|
assert.equal(judgeHit(notes, 60, 1.11, hitKeys, TOL), null);
|
|
// Wrong note — nothing at that midi anywhere near.
|
|
assert.equal(judgeHit(notes, 65, 1.0, hitKeys, TOL), null);
|
|
});
|
|
|
|
test('judgeHit: dedupes by t|midi — a chart note can only be hit once', () => {
|
|
const { judgeHit } = load();
|
|
const notes = [{ midi: 60, t: 1.0 }, { midi: 60, t: 1.15 }];
|
|
const hitKeys = new Set();
|
|
const first = judgeHit(notes, 60, 1.02, hitKeys, TOL);
|
|
assert.equal(first, '1.000|60');
|
|
hitKeys.add(first);
|
|
// Second strike near the same time falls through to the NEXT un-hit
|
|
// chart note at the same midi (double-stop repeats).
|
|
const second = judgeHit(notes, 60, 1.06, hitKeys, TOL);
|
|
assert.equal(second, '1.150|60');
|
|
hitKeys.add(second);
|
|
// Third strike: both consumed → wrong note.
|
|
assert.equal(judgeHit(notes, 60, 1.1, hitKeys, TOL), null);
|
|
});
|
|
|
|
test('judgeHit: empty/absent chart never judges', () => {
|
|
const { judgeHit } = load();
|
|
assert.equal(judgeHit([], 60, 1.0, new Set(), TOL), null);
|
|
assert.equal(judgeHit(null, 60, 1.0, new Set(), TOL), null);
|
|
});
|
|
|
|
test('sweepMissed: marks elapsed unhit notes once, respects hit + floor', () => {
|
|
const { sweepMissed, noteKey } = load();
|
|
const notes = [
|
|
{ midi: 60, t: 1.0 },
|
|
{ midi: 62, t: 1.5 },
|
|
{ midi: 64, t: 5.0 },
|
|
];
|
|
const hitKeys = new Set([noteKey(1.0, 60)]); // 60@1.0 was hit
|
|
const missedKeys = new Set();
|
|
const missed = [];
|
|
// At t=2.0 the windows for 1.0 and 1.5 have elapsed; 5.0 is pending.
|
|
const n1 = sweepMissed(notes, 2.0, hitKeys, missedKeys, TOL, null, n => missed.push(n.midi));
|
|
assert.equal(n1, 1);
|
|
assert.deepEqual(missed, [62]);
|
|
assert.ok(missedKeys.has(noteKey(1.5, 62)));
|
|
// Sweeping again counts nothing new (idempotent per note).
|
|
assert.equal(sweepMissed(notes, 2.1, hitKeys, missedKeys, TOL, null), 0);
|
|
// Floor: a device connected at t=6 must not retro-miss the 5.0 note.
|
|
const hk2 = new Set(), mk2 = new Set();
|
|
assert.equal(sweepMissed(notes, 6.0, hk2, mk2, TOL, 6.0), 0);
|
|
});
|
|
|
|
test('sweepMissed: a note exactly at the connect floor is not retro-missed', () => {
|
|
// Off-by-one guard: floor is the connect instant; a note whose onset
|
|
// equals it (device connected exactly as the onset passed) must be
|
|
// excluded, not swept. Floor comparison is `<=`, not `<`.
|
|
const { sweepMissed } = load();
|
|
const notes = [{ midi: 60, t: 5.0 }, { midi: 62, t: 6.0 }];
|
|
const missedKeys = new Set();
|
|
const missed = [];
|
|
const n = sweepMissed(notes, 7.0, new Set(), missedKeys, TOL, 5.0,
|
|
m => missed.push(m.midi));
|
|
assert.equal(n, 1);
|
|
assert.deepEqual(missed, [62]);
|
|
});
|
|
|
|
test('sweepMissed: a long frame stall cannot let elapsed notes slip past', () => {
|
|
const { sweepMissed } = load();
|
|
const notes = [{ midi: 60, t: 1.0 }, { midi: 62, t: 3.0 }];
|
|
const missedKeys = new Set();
|
|
// The previous sweep ran at t≈0; the next runs 10 s later (backgrounded
|
|
// tab / render hitch). Both elapsed notes must still be counted.
|
|
assert.equal(sweepMissed(notes, 10.0, new Set(), missedKeys, TOL, null), 2);
|
|
});
|
|
|
|
test('sweepMissed: cursor advances monotonically and never recounts', () => {
|
|
const { sweepMissed } = load();
|
|
const notes = [
|
|
{ midi: 60, t: 1.0 },
|
|
{ midi: 62, t: 2.0 },
|
|
{ midi: 64, t: 9.0 },
|
|
];
|
|
const hitKeys = new Set(), missedKeys = new Set();
|
|
const cursor = { idx: 0 };
|
|
assert.equal(sweepMissed(notes, 1.5, hitKeys, missedKeys, TOL, null, null, cursor), 1);
|
|
assert.equal(cursor.idx, 1);
|
|
// Stall to t=8: the 2.0 note is counted exactly once from the cursor.
|
|
assert.equal(sweepMissed(notes, 8.0, hitKeys, missedKeys, TOL, null, null, cursor), 1);
|
|
assert.equal(cursor.idx, 2);
|
|
// Seek BACKWARDS: the cursor does not rewind, nothing is recounted.
|
|
assert.equal(sweepMissed(notes, 1.5, hitKeys, missedKeys, TOL, null, null, cursor), 0);
|
|
// The cursor still advances past pre-floor notes without counting them.
|
|
const c2 = { idx: 0 };
|
|
const mk2 = new Set();
|
|
assert.equal(sweepMissed(notes, 8.0, new Set(), mk2, TOL, 5.0, null, c2), 0);
|
|
assert.equal(c2.idx, 2);
|
|
});
|
|
|
|
test('noteKey quantises time to ms so float drift cannot double-count', () => {
|
|
const { noteKey } = load();
|
|
assert.equal(noteKey(1.0004, 60), noteKey(1.0001, 60));
|
|
assert.notEqual(noteKey(1.002, 60), noteKey(1.0001, 60));
|
|
assert.notEqual(noteKey(1.0, 60), noteKey(1.0, 61));
|
|
});
|