mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 12:21:49 +00:00
* 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>
351 lines
17 KiB
JavaScript
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();
|
|
})();
|