feedBack/static/capabilities/note-detection.js
Bret Mogilefsky af2949677a
rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00

351 lines
17 KiB
JavaScript

// Core note-detection capability domain (spec 009 control-plane skeleton).
//
// Promotes `note-detection` as a provider-coordinator over detection
// providers. Doctrine (specs/009-note-detection-domain/spec.md): the domain
// exposes detection PRIMITIVES through requester-owned, context-scoped
// BINDINGS — a monophonic pitch estimate and a polyphonic "is this note set
// ringing now?" verification — while consumers own all judgment semantics
// (hit windows, streaks, accuracy). Hit/miss results flow through the domain
// as observability EVENTS, never as domain-owned scoring.
//
// This slice ships the control plane the piano/keys epic needs: provider
// registration (a Web-MIDI keys "detector" is an exact-verdict provider, the
// JUCE engine verifier and the JS harmonic-comb are DSP providers), binding
// lifecycle with per-binding context, target registration, event flow, shim
// accounting for the legacy chart-coupled `highway.setNoteStateProvider`
// surface, and redaction-safe diagnostics. The deeper spec-009 work — wiring
// per-binding tuning contexts down into the engine verifier and migrating
// the chart path onto bindings — lands with the full spec-009 slice.
(function () {
'use strict';
window.feedBack = window.feedBack || {};
const capabilities = window.feedBack.capabilities;
if (!capabilities || capabilities.version !== 1) return;
if (window.feedBack.noteDetection && window.feedBack.noteDetection.version === 1) return;
// providerId → { id, label, kind, primitives } — kind is 'midi' (exact
// verdicts from a digital instrument), 'engine' (desktop JUCE verifier),
// or 'js' (browser harmonic-comb / YIN fallback).
const providers = new Map();
// bindingId → { id, requester, providerId, context, target } — context is
// a REDACTED summary (arrangement kind, string count, capo, midi range);
// never device labels or song identity.
const bindings = new Map();
let bindingSeq = 0;
let lastOutcome = null;
function _handled(payload = {}) { return { outcome: 'handled', payload }; }
function _degraded(reason, payload = {}) { return { outcome: 'degraded', reason, payload }; }
function _unavailable(reason, payload = {}) { return { outcome: 'unavailable', reason, payload }; }
function _emit(name, detail) {
try { capabilities.emitEvent('note-detection', name, detail || {}); }
catch (_) { /* eventing must not break detection */ }
}
const _ARRANGEMENT_KINDS = new Set(['guitar', 'bass', 'keys', 'piano', 'ukulele', 'notation']);
function _contextSummary(context) {
const source = context && typeof context === 'object' ? context : {};
const summary = {};
const arrangement = String(source.arrangement || '').trim().toLowerCase();
if (_ARRANGEMENT_KINDS.has(arrangement)) summary.arrangement = arrangement;
if (Number.isFinite(Number(source.stringCount))) summary.stringCount = Number(source.stringCount);
if (Number.isFinite(Number(source.capo))) summary.capo = Number(source.capo);
if (Number.isFinite(Number(source.midiLow))) summary.midiLow = Number(source.midiLow);
if (Number.isFinite(Number(source.midiHigh))) summary.midiHigh = Number(source.midiHigh);
return summary;
}
function _snapshot(extra = {}) {
return {
available: providers.size > 0,
providers: Array.from(providers.values()).map(({ participantId: _, ...rest }) => ({
...rest,
primitives: Array.isArray(rest.primitives) ? rest.primitives.slice() : [],
})),
bindings: Array.from(bindings.values()).map(binding => ({
id: binding.id,
requester: binding.requester,
providerId: binding.providerId,
context: { ...binding.context },
hasTarget: !!binding.target,
targetSize: binding.target ? binding.target.length : 0,
})),
lastOutcome: lastOutcome ? { ...lastOutcome } : null,
...extra,
};
}
function _contributeDiagnostics() {
const diagnostics = window.feedBack && window.feedBack.diagnostics;
if (diagnostics && typeof diagnostics.contribute === 'function') {
try {
const snap = _snapshot();
// Omit provider labels from diagnostics: labels may contain
// hardware device names (MIDI/audio) that are PII-adjacent.
// Keep id, kind, and primitives for operational observability.
const redacted = {
...snap,
providers: snap.providers.map(({ label: _label, ...safe }) => safe),
};
diagnostics.contribute('note-detection-capability', {
schema: 'feedBack.note_detection_capability.v1',
...redacted,
});
} catch (_) { /* diagnostics must not break detection */ }
}
}
function _registerProvider(ctx = {}) {
const payload = ctx.payload && typeof ctx.payload === 'object' ? ctx.payload : {};
const providerId = String(payload.providerId || payload.id || '').trim();
if (!providerId) return _degraded('Provider registration requires a providerId', _snapshot());
const provider = {
id: providerId,
label: String(payload.label || providerId),
kind: ['midi', 'engine', 'js'].includes(payload.kind) ? payload.kind : 'js',
primitives: Array.isArray(payload.primitives)
? payload.primitives.filter(p => ['pitch.estimate', 'verify.target'].includes(p))
: [],
};
const participantId = String(ctx.source || ctx.requester || providerId);
// Ownership check: if a provider is already registered under this
// providerId by a different participant, reject the re-registration so a
// hostile or confused plugin cannot silently take over an existing provider
// entry and redirect all bindings that resolve to it.
const existing = providers.get(providerId);
if (existing && existing.participantId !== participantId) {
return _degraded(
`Provider ${providerId} is already registered by a different participant`,
_snapshot(),
);
}
provider.participantId = participantId;
// Track whether availability will flip before mutating the map.
const wasAvailable = providers.size > 0;
providers.set(providerId, provider);
capabilities.registerParticipant(participantId, {
'note-detection': {
roles: ['provider'],
operations: provider.primitives.slice(),
events: [],
mode: 'active',
safety: 'sensitive',
runtime: true,
description: `Note-detection provider ${provider.label} (${provider.kind}).`,
provider_policy: { providerId, label: provider.label, kind: provider.kind },
},
});
_emit('provider-registered', { providerId, kind: provider.kind });
// Emit availability-changed only on a real 0→1 transition so that
// re-registering or refreshing an existing provider does not produce
// a spurious edge that subscribers cannot distinguish from a genuine flip.
if (!wasAvailable) _emit('availability-changed', { available: true });
_contributeDiagnostics();
return _handled(_snapshot({ registered: providerId }));
}
function _unregisterProvider(ctx = {}) {
const payload = ctx.payload && typeof ctx.payload === 'object' ? ctx.payload : {};
const providerId = String(payload.providerId || payload.id || '').trim();
if (!providerId || !providers.has(providerId)) {
return _degraded(`Unknown note-detection provider: ${providerId || '(none)'}`, _snapshot());
}
const removedProvider = providers.get(providerId);
// Ownership check: only the original registrant may unregister a provider.
// A cross-owner unregister would silently cascade binding-closed for all
// other requesters whose bindings resolve to this provider.
const callerId = String(ctx.source || ctx.requester || providerId);
if (removedProvider.participantId && removedProvider.participantId !== callerId) {
return _degraded(
`Provider ${providerId} can only be unregistered by its original registrant`,
_snapshot(),
);
}
providers.delete(providerId);
for (const [id, binding] of Array.from(bindings.entries())) {
if (binding.providerId === providerId) {
bindings.delete(id);
_emit('binding-closed', { bindingId: id, requester: binding.requester, reason: 'provider-unregistered' });
}
}
// Only remove the participant when all of its providers are gone.
// A single plugin may have registered multiple providers under the same
// participantId; removing the participant on the first unregister would
// leave its remaining providers invisible in inspect() / diagnostics.
if (removedProvider && removedProvider.participantId &&
typeof capabilities.unregisterParticipant === 'function') {
const stillHasProvider = Array.from(providers.values())
.some(p => p.participantId === removedProvider.participantId);
if (!stillHasProvider) {
try { capabilities.unregisterParticipant(removedProvider.participantId, 'note-detection'); }
catch (_) { /* best-effort */ }
}
}
_emit('provider-unregistered', { providerId });
if (providers.size === 0) _emit('availability-changed', { available: false });
_contributeDiagnostics();
return _handled(_snapshot({ unregistered: providerId }));
}
function _openBinding(ctx = {}) {
const payload = ctx.payload && typeof ctx.payload === 'object' ? ctx.payload : {};
const requester = String(ctx.source || ctx.requester || 'unknown');
if (providers.size === 0) {
return _unavailable('No note-detection provider registered', _snapshot());
}
const requestedProvider = String(payload.providerId || '').trim();
const provider = requestedProvider
? providers.get(requestedProvider)
: providers.values().next().value;
if (!provider) {
return _degraded(`Unknown note-detection provider: ${requestedProvider}`, _snapshot());
}
const binding = {
id: `ndb-${++bindingSeq}`,
requester,
providerId: provider.id,
context: _contextSummary(payload.context),
target: null,
};
bindings.set(binding.id, binding);
_emit('binding-opened', { bindingId: binding.id, requester, providerId: provider.id, context: { ...binding.context } });
_contributeDiagnostics();
return _handled(_snapshot({ bindingId: binding.id }));
}
function _resolveOwnedBinding(ctx = {}) {
const payload = ctx.payload && typeof ctx.payload === 'object' ? ctx.payload : {};
const bindingId = String(payload.bindingId || '').trim();
const requester = String(ctx.source || ctx.requester || 'unknown');
const binding = bindings.get(bindingId);
if (!binding) {
return { error: _degraded(`Unknown note-detection binding: ${bindingId || '(none)'}`, _snapshot()) };
}
if (binding.requester !== requester) {
return { error: _degraded(`Note-detection binding ${bindingId} is owned by ${binding.requester}`, _snapshot()) };
}
return { bindingId, binding };
}
function _closeBinding(ctx = {}) {
const resolved = _resolveOwnedBinding(ctx);
if (resolved.error) return resolved.error;
const { bindingId, binding } = resolved;
bindings.delete(bindingId);
_emit('binding-closed', { bindingId, requester: binding.requester, reason: 'closed' });
_contributeDiagnostics();
return _handled(_snapshot({ closed: bindingId }));
}
function _setTarget(ctx = {}) {
const payload = ctx.payload && typeof ctx.payload === 'object' ? ctx.payload : {};
const resolved = _resolveOwnedBinding(ctx);
if (resolved.error) return resolved.error;
const { bindingId, binding } = resolved;
const notes = Array.isArray(payload.notes) ? payload.notes.slice() : null;
binding.target = notes;
_emit('target-changed', {
bindingId,
requester: binding.requester,
targetSize: notes ? notes.length : 0,
});
_contributeDiagnostics();
return _handled(_snapshot({ bindingId, targetSize: notes ? notes.length : 0 }));
}
// Observability flow: providers/consumers report detection results so the
// pipeline carries hit/miss/verdict events (roadmap: "hit/miss event
// flow") without the domain owning judgment. Payloads are bounded by the
// caller; this records the last outcome for diagnostics with safe fields
// only.
function _reportResult(name, detail) {
const source = detail && typeof detail === 'object' ? detail : {};
lastOutcome = {
event: name,
bindingId: source.bindingId ? String(source.bindingId) : null,
providerId: source.providerId ? String(source.providerId) : null,
midi: Number.isFinite(Number(source.midi)) ? Number(source.midi) : null,
hit: typeof source.hit === 'boolean' ? source.hit : null,
};
_emit(name, { ...lastOutcome });
_contributeDiagnostics();
}
capabilities.registerOwner('note-detection', {
pluginId: 'core.note-detection',
kind: 'provider-coordinator',
safety: 'sensitive',
commands: [
'inspect', 'register-provider', 'unregister-provider',
'open-binding', 'close-binding', 'set-target', 'clear-target',
],
operations: ['pitch.estimate', 'verify.target'],
events: [
'provider-registered', 'provider-unregistered', 'availability-changed',
'binding-opened', 'binding-closed', 'target-changed',
'hit', 'miss', 'verdict',
],
description: 'Coordinates note-detection providers and requester-owned, context-scoped detection bindings; consumers own judgment.',
handlers: {
inspect: (ctx) => _handled(_snapshot()),
'register-provider': (ctx) => _registerProvider(ctx),
'unregister-provider': (ctx) => _unregisterProvider(ctx),
'open-binding': (ctx) => _openBinding(ctx),
'close-binding': (ctx) => _closeBinding(ctx),
'set-target': (ctx) => _setTarget(ctx),
'clear-target': (ctx) => _setTarget({ ...ctx, payload: { ...(ctx.payload || {}), notes: null } }),
},
});
// ── Legacy chart-coupled shim accounting ────────────────────────────────
// The notedetect plugin drives the highway through
// `highway.setNoteStateProvider(fn)` — the single-global-detector surface
// spec 009 retires. Wrap it (behavior-preserving) to count bridge hits.
// highway.js loads after this host, so wrap lazily on the first
// opportunity and give up quietly if the surface never appears.
function _wrapLegacyNoteStateProvider() {
const highway = window.highway;
if (!highway || typeof highway.setNoteStateProvider !== 'function' || highway.__ndShimWrapped) return false;
const original = highway.setNoteStateProvider.bind(highway);
highway.setNoteStateProvider = function (fn) {
try {
capabilities.registerCompatibilityShim({
capability: 'note-detection',
shimId: 'note-detection:highway.setNoteStateProvider',
legacySurface: 'highway.setNoteStateProvider(fn)',
source: 'core.note-detection',
ownerPluginId: 'core.note-detection',
reason: 'Chart-coupled note-state provider predates spec-009 bindings.',
status: 'used',
hit: true,
});
} catch (_) { /* accounting must not break the legacy path */ }
return original(fn);
};
highway.__ndShimWrapped = true;
return true;
}
if (!_wrapLegacyNoteStateProvider()) {
const sm = window.feedBack;
if (typeof sm.on === 'function') {
try { sm.on('song:loaded', () => { _wrapLegacyNoteStateProvider(); }); }
catch (_) { /* best-effort */ }
}
if (window.document && typeof window.document.addEventListener === 'function') {
window.document.addEventListener('DOMContentLoaded', () => { _wrapLegacyNoteStateProvider(); }, { once: true });
}
}
window.feedBack.noteDetection = {
version: 1,
snapshot: _snapshot,
reportHit: (detail) => _reportResult('hit', detail),
reportMiss: (detail) => _reportResult('miss', detail),
reportVerdict: (detail) => _reportResult('verdict', detail),
};
_contributeDiagnostics();
})();