mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
Add new chart-transform plugin capability (#1000)
* Chart-transform plugin capability * PR comments * Cleanup * Fix markdown * CodeRabbit feedback Signed-off-by: Joe <jphinspace@gmail.com> --------- Signed-off-by: Joe <jphinspace@gmail.com> Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Byron Gamatos
parent
f7942f3689
commit
05be9ebdbe
@@ -128,6 +128,7 @@
|
||||
stems: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated stem automation, restore, manual override, and compatibility bridge surface backed by the active Stems provider.' }),
|
||||
visualization: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated highway renderer providers: discovery, picker selection, auto-match attribution, failure fallback, and redaction-safe diagnostics.' }),
|
||||
'note-detection': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Coordinates note-detection providers and requester-owned, context-scoped detection bindings (spec 009); consumers own judgment, hit/miss flow as observability events.' }),
|
||||
'chart-transform': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Coordinates chart-transform providers: pre-render/pre-scoring chart substitution applied after difficulty filtering, with persisted selection, refresh, and fixed-reason failure attribution (#952).' }),
|
||||
});
|
||||
const EXPECTED_COMPATIBILITY_SHIMS = Object.freeze({});
|
||||
|
||||
@@ -1536,4 +1537,4 @@
|
||||
window.dispatchEvent(new CustomEvent('feedBack:capabilities:ready', { detail: api }));
|
||||
_notifySubscribers('registered', { capability: '*', pluginId: 'core', timestamp: _now() });
|
||||
} catch (_) {}
|
||||
})();
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// Chart-transform provider registration, selection, and diagnostics.
|
||||
// Transformation stays on the synchronous highway data plane and runs after
|
||||
// difficulty filtering; the selected provider is shared by highway instances.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
window.feedBack = window.feedBack || {};
|
||||
const capabilities = window.feedBack.capabilities;
|
||||
if (!capabilities || capabilities.version !== 1) return;
|
||||
if (window.feedBack.chartTransformDomain && window.feedBack.chartTransformDomain.version === 1) return;
|
||||
|
||||
const STORAGE_KEY = 'feedBack.chartTransform.selectedProviderId';
|
||||
const PUBLIC_FAILURE_REASON = 'Chart transform provider failed';
|
||||
|
||||
// providerId → { id, label, pluginId, transform }
|
||||
const providers = new Map();
|
||||
let activeProviderId = null;
|
||||
let activeSource = 'startup';
|
||||
let lastFailure = null;
|
||||
// Count of highway instances the active provider is installed on
|
||||
// (the primary window.highway plus any announced via highway:created —
|
||||
// e.g. splitscreen panels). 0 = nothing capable exists yet.
|
||||
let installedCount = 0;
|
||||
// Known highway surfaces beyond window.highway, held weakly so closed
|
||||
// splitscreen panels can be collected. WeakRef is guarded for minimal
|
||||
// test environments; the strong-ref fallback only over-retains there.
|
||||
const _HasWeakRef = typeof WeakRef === 'function';
|
||||
let _surfaces = [];
|
||||
|
||||
function _handled(payload = {}) { return { outcome: 'handled', payload }; }
|
||||
function _degraded(reason, payload = {}) { return { outcome: 'degraded', reason, payload }; }
|
||||
|
||||
function _snapshot(extra = {}) {
|
||||
return {
|
||||
available: true,
|
||||
active: activeProviderId,
|
||||
activeSource,
|
||||
installed: installedCount > 0,
|
||||
surfaces: installedCount,
|
||||
providers: [...providers.values()].map(p => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
pluginId: p.pluginId,
|
||||
})),
|
||||
lastFailure: lastFailure ? { ...lastFailure } : null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function _emit(name, detail) {
|
||||
try { capabilities.emitEvent('chart-transform', name, detail || {}); }
|
||||
catch (_) { /* eventing must not break rendering */ }
|
||||
}
|
||||
|
||||
function _contributeDiagnostics() {
|
||||
const diagnostics = window.feedBack && window.feedBack.diagnostics;
|
||||
if (diagnostics && typeof diagnostics.contribute === 'function') {
|
||||
try {
|
||||
diagnostics.contribute('chart-transform-capability', {
|
||||
schema: 'feedBack.chart_transform.diagnostics.v1',
|
||||
..._snapshot(),
|
||||
});
|
||||
} catch (_) { /* diagnostics must not break rendering */ }
|
||||
}
|
||||
}
|
||||
|
||||
function _persistSelection(providerId) {
|
||||
try {
|
||||
if (providerId) window.localStorage.setItem(STORAGE_KEY, providerId);
|
||||
else window.localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (_) { /* storage unavailable → in-memory selection only */ }
|
||||
}
|
||||
|
||||
function _persistedSelection() {
|
||||
try { return window.localStorage.getItem(STORAGE_KEY) || null; }
|
||||
catch (_) { return null; }
|
||||
}
|
||||
|
||||
function _capable(hw) {
|
||||
return !!(hw && typeof hw.setChartTransform === 'function');
|
||||
}
|
||||
|
||||
// Every capable highway surface: window.highway plus live announced
|
||||
// instances (splitscreen panels), deduped, dead refs pruned in place.
|
||||
function _eachSurface(fn) {
|
||||
const seen = new Set();
|
||||
const primary = window.highway;
|
||||
if (_capable(primary)) { seen.add(primary); fn(primary); }
|
||||
const live = [];
|
||||
for (const ref of _surfaces) {
|
||||
const hw = _HasWeakRef ? ref.deref() : ref;
|
||||
if (!hw) continue;
|
||||
live.push(ref);
|
||||
if (seen.has(hw) || !_capable(hw)) continue;
|
||||
seen.add(hw);
|
||||
fn(hw);
|
||||
}
|
||||
_surfaces = live;
|
||||
return seen.size;
|
||||
}
|
||||
|
||||
function _rememberSurface(hw) {
|
||||
if (!_capable(hw) || hw === window.highway) return;
|
||||
let known = false;
|
||||
_eachSurface(() => {});
|
||||
for (const ref of _surfaces) {
|
||||
if ((_HasWeakRef ? ref.deref() : ref) === hw) { known = true; break; }
|
||||
}
|
||||
if (!known) _surfaces.push(_HasWeakRef ? new WeakRef(hw) : hw);
|
||||
}
|
||||
|
||||
// Hand the current selection to every highway surface (or clear it).
|
||||
// Selection survives with zero surfaces — it re-applies as instances
|
||||
// appear (song:ready for the primary, highway:created for panels).
|
||||
function _install() {
|
||||
const provider = activeProviderId ? providers.get(activeProviderId) : null;
|
||||
const payload = provider ? { id: provider.id, transform: provider.transform } : null;
|
||||
installedCount = 0;
|
||||
_eachSurface((hw) => {
|
||||
try {
|
||||
hw.setChartTransform(payload);
|
||||
if (payload) installedCount += 1;
|
||||
} catch (_) { /* one broken surface must not block the rest */ }
|
||||
});
|
||||
return installedCount > 0 || payload === null;
|
||||
}
|
||||
|
||||
function _setActive(providerId, source) {
|
||||
const from = activeProviderId;
|
||||
activeProviderId = providerId;
|
||||
activeSource = String(source || 'unknown');
|
||||
_persistSelection(providerId);
|
||||
_install();
|
||||
if (from !== providerId) {
|
||||
_emit('transform-changed', { from, to: providerId, source: activeSource });
|
||||
}
|
||||
_contributeDiagnostics();
|
||||
}
|
||||
|
||||
function _payload(ctx = {}) {
|
||||
return ctx.payload && typeof ctx.payload === 'object' ? ctx.payload : {};
|
||||
}
|
||||
|
||||
function _providersForParticipant(participantId) {
|
||||
return [...providers.values()].filter(provider => provider.pluginId === participantId);
|
||||
}
|
||||
|
||||
function _registerProviderParticipant(participantId) {
|
||||
const owned = _providersForParticipant(participantId);
|
||||
if (!owned.length) return;
|
||||
capabilities.registerParticipant(participantId, {
|
||||
'chart-transform': {
|
||||
roles: ['provider'],
|
||||
operations: ['chart.transform'],
|
||||
events: [],
|
||||
mode: 'active',
|
||||
compatibility: 'none',
|
||||
safety: 'safe',
|
||||
runtime: true,
|
||||
description: `${owned.length} registered chart transform provider${owned.length === 1 ? '' : 's'}.`,
|
||||
provider_policy: {
|
||||
providerIds: owned.map(provider => provider.id),
|
||||
providers: owned.map(provider => ({ id: provider.id, label: provider.label })),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function _registerProvider(ctx = {}) {
|
||||
const payload = _payload(ctx);
|
||||
const providerId = String(payload.providerId || payload.id || '').trim();
|
||||
if (!providerId) return _degraded('Provider registration requires a providerId', _snapshot());
|
||||
if (typeof payload.transform !== 'function') {
|
||||
return _degraded('Provider registration requires a transform(input) function', _snapshot());
|
||||
}
|
||||
const participantId = String(ctx.source || ctx.requester || providerId);
|
||||
const existing = providers.get(providerId);
|
||||
if (existing && existing.pluginId !== participantId) {
|
||||
return _degraded(
|
||||
`Provider ${providerId} is already registered by a different participant`,
|
||||
_snapshot(),
|
||||
);
|
||||
}
|
||||
providers.set(providerId, {
|
||||
id: providerId,
|
||||
label: String(payload.label || providerId),
|
||||
pluginId: participantId,
|
||||
transform: payload.transform,
|
||||
});
|
||||
_registerProviderParticipant(participantId);
|
||||
_emit('provider-registered', { providerId });
|
||||
// Restore a persisted selection the moment its provider appears.
|
||||
if (!activeProviderId && _persistedSelection() === providerId) {
|
||||
_setActive(providerId, 'restore-selection');
|
||||
} else if (activeProviderId === providerId) {
|
||||
// Re-registration after script rehydration: reinstall the fresh
|
||||
// transform closure so the highway isn't holding a stale one.
|
||||
_install();
|
||||
}
|
||||
_contributeDiagnostics();
|
||||
return _handled(_snapshot({ registered: providerId }));
|
||||
}
|
||||
|
||||
function _unregisterProvider(ctx = {}) {
|
||||
const payload = _payload(ctx);
|
||||
const providerId = String(payload.providerId || payload.id || '').trim();
|
||||
const provider = providers.get(providerId);
|
||||
if (!provider) return _degraded(`Unknown chart-transform provider: ${providerId || '(none)'}`, _snapshot());
|
||||
const callerId = String(ctx.source || ctx.requester || providerId);
|
||||
if (provider.pluginId !== callerId) {
|
||||
return _degraded(
|
||||
`Provider ${providerId} can only be unregistered by its original registrant`,
|
||||
_snapshot(),
|
||||
);
|
||||
}
|
||||
providers.delete(providerId);
|
||||
if (activeProviderId === providerId) {
|
||||
// Keep the persisted selection so the provider re-activates on
|
||||
// its next registration; just detach it from the highway.
|
||||
activeProviderId = null;
|
||||
_install();
|
||||
_emit('transform-changed', { from: providerId, to: null, source: 'provider-unregistered' });
|
||||
}
|
||||
const remainingProviders = _providersForParticipant(provider.pluginId);
|
||||
if (remainingProviders.length) {
|
||||
_registerProviderParticipant(provider.pluginId);
|
||||
} else if (typeof capabilities.unregisterParticipant === 'function') {
|
||||
const live = typeof capabilities.inspect === 'function' ? capabilities.inspect('chart-transform') : null;
|
||||
const participant = ((live && live.participants) || []).find(p => p.pluginId === provider.pluginId);
|
||||
const roles = participant && Array.isArray(participant.roles) ? participant.roles : [];
|
||||
const providerOnly = roles.length === 1 && roles[0] === 'provider';
|
||||
if (!participant || providerOnly) {
|
||||
try { capabilities.unregisterParticipant(provider.pluginId, 'chart-transform'); }
|
||||
catch (_) { /* participant cleanup is best-effort */ }
|
||||
}
|
||||
}
|
||||
_emit('provider-unregistered', { providerId });
|
||||
_contributeDiagnostics();
|
||||
return _handled(_snapshot({ unregistered: providerId }));
|
||||
}
|
||||
|
||||
function _targetProviderId(ctx = {}) {
|
||||
const payload = _payload(ctx);
|
||||
const target = ctx.target && typeof ctx.target === 'object' ? ctx.target : {};
|
||||
return String(
|
||||
target.providerId || target.provider_id || target.id
|
||||
|| payload.providerId || payload.provider_id || payload.id
|
||||
|| (typeof ctx.target === 'string' ? ctx.target : '') || ''
|
||||
).trim();
|
||||
}
|
||||
|
||||
function _selectProvider(ctx = {}) {
|
||||
const providerId = _targetProviderId(ctx);
|
||||
if (!providerId) return _degraded('Transform selection requires a provider id', _snapshot());
|
||||
if (!providers.has(providerId)) {
|
||||
return _degraded(`Unknown chart-transform provider: ${providerId}`, _snapshot());
|
||||
}
|
||||
_setActive(providerId, ctx.requester ? `command:${ctx.requester}` : 'command');
|
||||
return _handled(_snapshot({ selected: providerId }));
|
||||
}
|
||||
|
||||
function _clearProvider(ctx = {}) {
|
||||
_setActive(null, ctx.requester ? `command:${ctx.requester}` : 'command');
|
||||
return _handled(_snapshot({ cleared: true }));
|
||||
}
|
||||
|
||||
function _refresh() {
|
||||
if (!activeProviderId || installedCount === 0) return _handled(_snapshot({ refreshed: false }));
|
||||
let refreshed = 0;
|
||||
_eachSurface((hw) => {
|
||||
if (typeof hw.refreshChartTransform !== 'function') return;
|
||||
try { hw.refreshChartTransform(); refreshed += 1; }
|
||||
catch (_) { /* one broken surface must not block the rest */ }
|
||||
});
|
||||
return _handled(_snapshot({ refreshed: refreshed > 0 }));
|
||||
}
|
||||
|
||||
capabilities.registerOwner('chart-transform', {
|
||||
pluginId: 'core.chart-transform',
|
||||
kind: 'provider-coordinator',
|
||||
safety: 'safe',
|
||||
commands: ['inspect', 'list-providers', 'register-provider', 'unregister-provider', 'select-provider', 'clear-provider', 'refresh'],
|
||||
operations: ['chart.transform'],
|
||||
events: ['provider-registered', 'provider-unregistered', 'transform-changed', 'transform-failed'],
|
||||
description: 'Owns chart-transform providers: pre-render/pre-scoring chart substitution applied after difficulty filtering, with selection, refresh, and failure attribution.',
|
||||
handlers: {
|
||||
inspect: () => _handled(_snapshot()),
|
||||
'list-providers': () => _handled(_snapshot()),
|
||||
'register-provider': (ctx) => _registerProvider(ctx),
|
||||
'unregister-provider': (ctx) => _unregisterProvider(ctx),
|
||||
'select-provider': (ctx) => _selectProvider(ctx),
|
||||
'clear-provider': (ctx) => _clearProvider(ctx),
|
||||
refresh: () => _refresh(),
|
||||
},
|
||||
});
|
||||
|
||||
// Bus mirroring (guarded: the bus may not exist in minimal/test envs).
|
||||
const sm = window.feedBack;
|
||||
if (typeof sm.on === 'function') {
|
||||
try {
|
||||
sm.on('highway:chart-transform-failed', (e) => {
|
||||
const detail = (e && e.detail) || e || {};
|
||||
lastFailure = {
|
||||
providerId: String(detail.id || activeProviderId || 'unknown'),
|
||||
reason: PUBLIC_FAILURE_REASON,
|
||||
};
|
||||
_emit('transform-failed', { ...lastFailure });
|
||||
_contributeDiagnostics();
|
||||
});
|
||||
// The primary highway is created after this module evaluates —
|
||||
// install a pending selection once a song is loading/ready.
|
||||
sm.on('song:ready', () => {
|
||||
if (activeProviderId && installedCount === 0 && _install()) {
|
||||
// setChartTransform restages immediately, so the chart
|
||||
// that just became ready picks the transform up now.
|
||||
_contributeDiagnostics();
|
||||
}
|
||||
});
|
||||
// Additional instances restage the active provider against their
|
||||
// own chart state.
|
||||
sm.on('highway:created', (e) => {
|
||||
const detail = (e && e.detail) || e || {};
|
||||
if (!_capable(detail.highway)) return;
|
||||
_rememberSurface(detail.highway);
|
||||
if (activeProviderId) _install();
|
||||
_contributeDiagnostics();
|
||||
});
|
||||
} catch (_) { /* bus mirroring is best-effort */ }
|
||||
}
|
||||
|
||||
window.feedBack.chartTransformDomain = {
|
||||
version: 1,
|
||||
snapshot: _snapshot,
|
||||
};
|
||||
_contributeDiagnostics();
|
||||
})();
|
||||
+186
-23
@@ -267,6 +267,19 @@ function createHighway() {
|
||||
hwState._filteredChords = null;
|
||||
hwState._filteredAnchors = null;
|
||||
hwState._filteredHandShapes = null;
|
||||
// Transform stage; null fields fall through to filtered/original data.
|
||||
hwState._xfProvider = null; // { id, transform } or null
|
||||
hwState._xfNotes = null; // effective (post-filter) views
|
||||
hwState._xfChords = null;
|
||||
hwState._xfAnchors = null;
|
||||
hwState._xfNotesAll = null; // full-difficulty views (getNotes/getChords)
|
||||
hwState._xfChordsAll = null;
|
||||
hwState._xfChordTemplates = null;
|
||||
hwState._xfStringCount = null; // number or null
|
||||
hwState._xfTuning = null; // array or null
|
||||
hwState._xfCapo = null; // number or null
|
||||
hwState._xfHandShapes = null; // array or null
|
||||
hwState._xfCentOffset = null; // number or null
|
||||
// Tracks whether ANY phrase level carries handshape data. Lets us
|
||||
// distinguish "this difficulty has none" (respect strictly — even
|
||||
// when empty) from "the chart's phrase data never authored any
|
||||
@@ -397,7 +410,8 @@ function createHighway() {
|
||||
function getAnchorAt(t) {
|
||||
// Same master-difficulty fallback as the render loops — the
|
||||
// anchor ladder pairs with the note ladder.
|
||||
const src = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
|
||||
const src = hwState._xfAnchors !== null ? hwState._xfAnchors
|
||||
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
|
||||
let a = src[0] || { fret: 1, width: 4 };
|
||||
for (const anc of src) {
|
||||
if (anc.time > t) break;
|
||||
@@ -408,7 +422,8 @@ function createHighway() {
|
||||
|
||||
function getMaxFretInWindow(t) {
|
||||
// Find the highest fret needed across all anchors visible on screen
|
||||
const src = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
|
||||
const src = hwState._xfAnchors !== null ? hwState._xfAnchors
|
||||
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
|
||||
let maxFret = 0;
|
||||
for (const anc of src) {
|
||||
if (anc.time > t + VISIBLE_SECONDS + 2) break; // Skip anchors well in the future (with a little buffer to avoid moving early the cutoff)
|
||||
@@ -541,17 +556,20 @@ function createHighway() {
|
||||
|
||||
// Chart content (filter-aware — difficulty-filtered arrays
|
||||
// preferred; raw arrays are the fallback when no ladder data).
|
||||
b.notes = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
b.chords = hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
|
||||
b.anchors = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
|
||||
b.notes = hwState._xfNotes !== null ? hwState._xfNotes
|
||||
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
b.chords = hwState._xfChords !== null ? hwState._xfChords
|
||||
: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
|
||||
b.anchors = hwState._xfAnchors !== null ? hwState._xfAnchors
|
||||
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
|
||||
b.beats = hwState.beats;
|
||||
b.sections = hwState.sections;
|
||||
b.chordTemplates = hwState.chordTemplates;
|
||||
b.stringCount = hwState.stringCount;
|
||||
// Mirrors song_info tuning capo offsets (±semitones from the
|
||||
// instrument’s standard open-string layout). Live reference.
|
||||
b.tuning = hwState.songInfo?.tuning;
|
||||
b.capo = hwState.songInfo?.capo;
|
||||
b.chordTemplates = hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates;
|
||||
b.stringCount = hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount;
|
||||
// Effective tuning metadata; live references like the chart arrays.
|
||||
b.tuning = hwState._xfTuning !== null ? hwState._xfTuning : hwState.songInfo?.tuning;
|
||||
b.capo = hwState._xfCapo !== null ? hwState._xfCapo : hwState.songInfo?.capo;
|
||||
b.centOffset = hwState._xfCentOffset !== null ? hwState._xfCentOffset : hwState.songInfo?.centOffset;
|
||||
b.lyrics = hwState.lyrics;
|
||||
b.lyricsSource = hwState.lyricsSource;
|
||||
b.toneChanges = hwState.toneChanges;
|
||||
@@ -572,9 +590,10 @@ function createHighway() {
|
||||
// don't belong. Only fall back to the flat list when the
|
||||
// phrase data carries no handshapes at all (common on DLC
|
||||
// where handshapes ship on the arrangement root).
|
||||
b.handShapes = (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
|
||||
? hwState._filteredHandShapes
|
||||
: hwState.handShapes;
|
||||
b.handShapes = hwState._xfHandShapes !== null ? hwState._xfHandShapes
|
||||
: (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
|
||||
? hwState._filteredHandShapes
|
||||
: hwState.handShapes;
|
||||
|
||||
// Display flags
|
||||
b.inverted = hwState._inverted;
|
||||
@@ -1372,9 +1391,10 @@ function createHighway() {
|
||||
// slots, so 4 strings spread across the full band rather than
|
||||
// using the upper 4/6ths of the 6-string layout. The Math.max
|
||||
// guards against a hypothetical 1-string instrument (denom=0).
|
||||
const span = Math.max(1, hwState.stringCount - 1);
|
||||
for (let i = 0; i < hwState.stringCount; i++) {
|
||||
const yi = hwState._inverted ? (hwState.stringCount - 1 - i) : i;
|
||||
const sc = hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount;
|
||||
const span = Math.max(1, sc - 1);
|
||||
for (let i = 0; i < sc; i++) {
|
||||
const yi = hwState._inverted ? (sc - 1 - i) : i;
|
||||
const y = strTop + (yi / span) * (strBot - strTop);
|
||||
hwState.ctx.strokeStyle = hwState.STRING_COLORS[i] || '#888';
|
||||
hwState.ctx.lineWidth = 3;
|
||||
@@ -1477,6 +1497,7 @@ function createHighway() {
|
||||
hwState._filteredAnchors = null;
|
||||
hwState._filteredHandShapes = null;
|
||||
hwState._phrasesHaveHandShapes = false;
|
||||
_restageChartTransform();
|
||||
return;
|
||||
}
|
||||
const outNotes = [];
|
||||
@@ -1524,6 +1545,116 @@ function createHighway() {
|
||||
}
|
||||
hwState._filteredHandShapes = outHandShapes;
|
||||
hwState._phrasesHaveHandShapes = anyHandShapeInPhrases;
|
||||
_restageChartTransform();
|
||||
}
|
||||
|
||||
function _clearChartTransformStage() {
|
||||
hwState._xfNotes = null;
|
||||
hwState._xfChords = null;
|
||||
hwState._xfAnchors = null;
|
||||
hwState._xfNotesAll = null;
|
||||
hwState._xfChordsAll = null;
|
||||
hwState._xfChordTemplates = null;
|
||||
hwState._xfStringCount = null;
|
||||
hwState._xfTuning = null;
|
||||
hwState._xfCapo = null;
|
||||
hwState._xfHandShapes = null;
|
||||
hwState._xfCentOffset = null;
|
||||
}
|
||||
|
||||
function _cloneChartTransformValue(value, seen = new WeakMap()) {
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
if (seen.has(value)) return seen.get(value);
|
||||
const copy = Array.isArray(value) ? new Array(value.length) : {};
|
||||
seen.set(value, copy);
|
||||
for (const key of Object.keys(value)) {
|
||||
Object.defineProperty(copy, key, {
|
||||
value: _cloneChartTransformValue(value[key], seen),
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function _sortedChartTransformArray(items, key) {
|
||||
return items.slice().sort((a, b) => a[key] - b[key]);
|
||||
}
|
||||
|
||||
function _reportChartTransformFailure(provider, error) {
|
||||
_clearChartTransformStage();
|
||||
console.error('chart transform:', error);
|
||||
if (window.feedBack && typeof window.feedBack.emit === 'function') {
|
||||
try {
|
||||
window.feedBack.emit('highway:chart-transform-failed', {
|
||||
id: provider.id,
|
||||
});
|
||||
} catch (_) { /* eventing must not break rendering */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Stage one synchronous transform over the difficulty-filtered chart.
|
||||
function _restageChartTransform() {
|
||||
_clearChartTransformStage();
|
||||
const p = hwState._xfProvider;
|
||||
if (!p) return;
|
||||
// Pre-ready there is nothing meaningful to transform (chart arrays
|
||||
// are still streaming, songInfo may be empty) — keep the provider
|
||||
// attached and let the `ready` path (which sets hwState.ready BEFORE
|
||||
// _rebuildMasteryFilter) run the first real staging.
|
||||
if (!hwState.ready) return;
|
||||
const filterActive = hwState._filteredNotes !== null;
|
||||
try {
|
||||
let out = p.transform(_cloneChartTransformValue({
|
||||
notes: filterActive ? hwState._filteredNotes : hwState.notes,
|
||||
chords: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords,
|
||||
anchors: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors,
|
||||
allNotes: hwState.notes,
|
||||
allChords: hwState.chords,
|
||||
chordTemplates: hwState.chordTemplates,
|
||||
// Same effective selection the bundle uses (see b.handShapes).
|
||||
handShapes: (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
|
||||
? hwState._filteredHandShapes
|
||||
: hwState.handShapes,
|
||||
stringCount: hwState.stringCount,
|
||||
songInfo: hwState.songInfo,
|
||||
}));
|
||||
if (out && typeof out.then === 'function') {
|
||||
try {
|
||||
const catchAsyncFailure = out.catch;
|
||||
if (typeof catchAsyncFailure === 'function') {
|
||||
catchAsyncFailure.call(out, error => console.error('chart transform async:', error));
|
||||
}
|
||||
} catch (_) { /* the synchronous failure below remains authoritative */ }
|
||||
throw new TypeError('Chart transform providers must return synchronously');
|
||||
}
|
||||
if (!out || typeof out !== 'object') return;
|
||||
out = _cloneChartTransformValue(out);
|
||||
if (Array.isArray(out.notes)) hwState._xfNotes = _sortedChartTransformArray(out.notes, 't');
|
||||
if (Array.isArray(out.chords)) hwState._xfChords = _sortedChartTransformArray(out.chords, 't');
|
||||
if (Array.isArray(out.anchors)) hwState._xfAnchors = _sortedChartTransformArray(out.anchors, 'time');
|
||||
// Full-difficulty views: explicit allNotes/allChords, or reuse the
|
||||
// effective output when no filter is active (effective === raw then).
|
||||
if (Array.isArray(out.allNotes)) hwState._xfNotesAll = _sortedChartTransformArray(out.allNotes, 't');
|
||||
else if (!filterActive && Array.isArray(out.notes)) hwState._xfNotesAll = hwState._xfNotes;
|
||||
if (Array.isArray(out.allChords)) hwState._xfChordsAll = _sortedChartTransformArray(out.allChords, 't');
|
||||
else if (hwState._filteredChords === null && Array.isArray(out.chords)) hwState._xfChordsAll = hwState._xfChords;
|
||||
if (Array.isArray(out.chordTemplates)) hwState._xfChordTemplates = out.chordTemplates;
|
||||
if (Number.isFinite(out.stringCount) && out.stringCount >= 1) {
|
||||
// Same [1, 8] clamp as the song_info stringCount handler.
|
||||
hwState._xfStringCount = Math.max(1, Math.min(8, Math.trunc(out.stringCount)));
|
||||
}
|
||||
if (Array.isArray(out.tuning) && out.tuning.length) hwState._xfTuning = out.tuning;
|
||||
if (Number.isFinite(out.capo) && out.capo >= 0) hwState._xfCapo = Math.trunc(out.capo);
|
||||
if (Array.isArray(out.handShapes)) {
|
||||
hwState._xfHandShapes = _sortedChartTransformArray(out.handShapes, 'start_time');
|
||||
}
|
||||
if (Number.isFinite(out.centOffset)) hwState._xfCentOffset = out.centOffset;
|
||||
} catch (e) {
|
||||
_reportChartTransformFailure(p, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────
|
||||
@@ -1568,6 +1699,8 @@ function createHighway() {
|
||||
hwState._filteredAnchors = null;
|
||||
hwState._filteredHandShapes = null;
|
||||
hwState._phrasesHaveHandShapes = false;
|
||||
// Keep _xfProvider (persists across songs); drop staged output.
|
||||
_clearChartTransformStage();
|
||||
_resetChordRenderState();
|
||||
},
|
||||
|
||||
@@ -2454,8 +2587,11 @@ function createHighway() {
|
||||
hwState._domVisSampledFrame = NaN;
|
||||
return _isHighwayVisible();
|
||||
},
|
||||
getNotes() { return hwState.notes; },
|
||||
getChords() { return hwState.chords; },
|
||||
// When a chart transform is active these return its full-difficulty
|
||||
// views (falling through to the original arrays if the provider
|
||||
// supplied only the filtered view).
|
||||
getNotes() { return hwState._xfNotesAll !== null ? hwState._xfNotesAll : hwState.notes; },
|
||||
getChords() { return hwState._xfChordsAll !== null ? hwState._xfChordsAll : hwState.chords; },
|
||||
// Difficulty-filtered variants of getNotes()/getChords(). Returns the
|
||||
// master-difficulty-filtered arrays when the current song has phrase-level
|
||||
// data (i.e. the mastery slider is active). For songs with a single
|
||||
@@ -2463,8 +2599,14 @@ function createHighway() {
|
||||
// these fall through to the raw arrays, the same as getNotes()/getChords().
|
||||
// Plugins that score or analyse only the notes the player is currently
|
||||
// expected to play should prefer these over getNotes()/getChords(). Read-only.
|
||||
getFilteredNotes() { return hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes; },
|
||||
getFilteredChords() { return hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords; },
|
||||
getFilteredNotes() {
|
||||
if (hwState._xfNotes !== null) return hwState._xfNotes;
|
||||
return hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
},
|
||||
getFilteredChords() {
|
||||
if (hwState._xfChords !== null) return hwState._xfChords;
|
||||
return hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
|
||||
},
|
||||
// Live reference to the chord-template lookup table —
|
||||
// `getChords()[i].id` is an index into this array. Each
|
||||
// template carries `{ name, fingers, frets }`:
|
||||
@@ -2479,7 +2621,7 @@ function createHighway() {
|
||||
// its entries. Not difficulty-filter-aware (templates are
|
||||
// static metadata; every chord_id referenced by `getChords()`
|
||||
// is guaranteed valid).
|
||||
getChordTemplates() { return hwState.chordTemplates; },
|
||||
getChordTemplates() { return hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates; },
|
||||
getToneChanges() { return hwState.toneChanges; },
|
||||
getToneBase() { return hwState.toneBase; },
|
||||
getSections() { return hwState.sections; },
|
||||
@@ -2507,7 +2649,10 @@ function createHighway() {
|
||||
// string-indexed UI / geometry against THIS rather than
|
||||
// assuming 6. Defaults to 6 between songs (until the next
|
||||
// song_info message arrives).
|
||||
getStringCount() { return hwState.stringCount; },
|
||||
getStringCount() { return hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount; },
|
||||
getTuning() { return hwState._xfTuning !== null ? hwState._xfTuning : hwState.songInfo?.tuning; },
|
||||
getCapo() { return hwState._xfCapo !== null ? hwState._xfCapo : hwState.songInfo?.capo; },
|
||||
getCentOffset() { return hwState._xfCentOffset !== null ? hwState._xfCentOffset : hwState.songInfo?.centOffset; },
|
||||
addDrawHook(fn) {
|
||||
hwState._drawHooks.push(fn);
|
||||
},
|
||||
@@ -2531,6 +2676,17 @@ function createHighway() {
|
||||
*/
|
||||
setNoteStateProvider(fn) { hwState._noteStateProvider = (typeof fn === 'function') ? fn : null; },
|
||||
getNoteStateProvider() { return hwState._noteStateProvider; },
|
||||
// Install one synchronous provider for this highway. The capability
|
||||
// domain owns registration and selection; null clears the provider.
|
||||
setChartTransform(p) {
|
||||
hwState._xfProvider = (p && typeof p.transform === 'function')
|
||||
? { id: String(p.id || 'anonymous'), transform: p.transform }
|
||||
: null;
|
||||
_restageChartTransform();
|
||||
},
|
||||
getChartTransform() { return hwState._xfProvider; },
|
||||
// Re-run the installed provider (e.g. its target settings changed).
|
||||
refreshChartTransform() { _restageChartTransform(); },
|
||||
/** Current per-string base colors (copy). Index 0..7. */
|
||||
getStringColors() { return hwState.STRING_COLORS.slice(); },
|
||||
/**
|
||||
@@ -2638,6 +2794,8 @@ function createHighway() {
|
||||
hwState._filteredAnchors = null;
|
||||
hwState._filteredHandShapes = null;
|
||||
hwState._phrasesHaveHandShapes = false;
|
||||
// Keep _xfProvider (persists across songs); drop staged output.
|
||||
_clearChartTransformStage();
|
||||
_resetChordRenderState();
|
||||
const wsParams = new URLSearchParams();
|
||||
if (arrangement !== undefined) wsParams.set('arrangement', arrangement);
|
||||
@@ -2735,6 +2893,11 @@ function createHighway() {
|
||||
*/
|
||||
isDefaultRenderer() { return hwState._renderer === _defaultRenderer || hwState._renderer == null; },
|
||||
};
|
||||
// Let cross-instance coordinators discover this highway.
|
||||
if (window.feedBack && typeof window.feedBack.emit === 'function') {
|
||||
try { window.feedBack.emit('highway:created', { highway: api }); }
|
||||
catch (e) { console.error('highway:created emit:', e); }
|
||||
}
|
||||
return api;
|
||||
}
|
||||
const highway = createHighway();
|
||||
|
||||
+21
-10
@@ -400,8 +400,9 @@ export function drawSustains(hwState, W, H) {
|
||||
// Same master-difficulty fallback as drawNotes/drawChords —
|
||||
// without this, sustain bars for filtered-out notes would
|
||||
// still render, leaving orphan rectangles where no note head
|
||||
// is drawn.
|
||||
const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
// is drawn. An active chart transform substitutes its staged view.
|
||||
const src = hwState._xfNotes !== null ? hwState._xfNotes
|
||||
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
for (const n of src) {
|
||||
if (n.sus <= 0.01) continue;
|
||||
const end = n.t + n.sus;
|
||||
@@ -501,7 +502,9 @@ export function drawNotes(hwState, W, H) {
|
||||
// phrase-level ladder data, render from the mastery-filtered
|
||||
// array. _filteredNotes stays null for slider-disabled sources
|
||||
// so rendering falls through to the flat notes array unchanged.
|
||||
const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
// An active chart transform (_xfNotes) substitutes its staged view.
|
||||
const src = hwState._xfNotes !== null ? hwState._xfNotes
|
||||
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
|
||||
// Binary search for visible range
|
||||
const tMin = hwState.currentTime - 0.25;
|
||||
const tMax = hwState.currentTime + VISIBLE_SECONDS;
|
||||
@@ -649,7 +652,8 @@ export function drawUnisonBends(hwState, W, H, drawnNotes) {
|
||||
export function drawChords(hwState, W, H) {
|
||||
// See drawNotes — _filteredChords is null for slider-disabled
|
||||
// sources so we fall through to the flat chords array.
|
||||
const src = hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
|
||||
const src = hwState._xfChords !== null ? hwState._xfChords
|
||||
: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
|
||||
_ensureChordRenderCache(hwState, src);
|
||||
|
||||
const tMin = hwState.currentTime - 0.25;
|
||||
@@ -674,7 +678,7 @@ export function drawChords(hwState, W, H) {
|
||||
const actualSpread = Math.max(spread, minSpread);
|
||||
const actualTotalH = actualSpread * Math.max(0, sorted.length - 1);
|
||||
|
||||
const { tmpl, getTemplateFret } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
|
||||
const { tmpl, getTemplateFret } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
|
||||
const hasNonZero = nonZeroNotes.length >= 1;
|
||||
|
||||
const frameLeftFret = baseFret;
|
||||
@@ -1124,15 +1128,22 @@ export function getChordTemplateInfo(chordId, chordTemplates) {
|
||||
return { tmpl, tmplFrets, getTemplateFret, isOpen };
|
||||
}
|
||||
|
||||
// Effective chord templates: an active chart transform substitutes its
|
||||
// re-indexed table (identity change also invalidates the render cache).
|
||||
export function _effChordTemplates(hwState) {
|
||||
return hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates;
|
||||
}
|
||||
|
||||
// Build _chordRenderInfo for every chord in `src` if the cache is stale.
|
||||
// Two passes over the array: chain bounds, then base-fret resolution
|
||||
// (which can read previous chord's cached baseFret).
|
||||
export function _ensureChordRenderCache(hwState, src) {
|
||||
const templatesChanged = hwState._chordRenderCacheTemplates !== hwState.chordTemplates;
|
||||
const effTemplates = _effChordTemplates(hwState);
|
||||
const templatesChanged = hwState._chordRenderCacheTemplates !== effTemplates;
|
||||
if (hwState._chordRenderCacheSrc === src && hwState._chordRenderCacheInverted === hwState._inverted && !templatesChanged) return;
|
||||
hwState._chordRenderCacheSrc = src;
|
||||
hwState._chordRenderCacheInverted = hwState._inverted;
|
||||
hwState._chordRenderCacheTemplates = hwState.chordTemplates;
|
||||
hwState._chordRenderCacheTemplates = effTemplates;
|
||||
// Templates feed isOpen() — when they land after `chords`,
|
||||
// _updateFretLinePreview's stashed open/non-open classification
|
||||
// for the currently-active chord is also stale. It only refreshes
|
||||
@@ -1188,7 +1199,7 @@ export function _ensureChordRenderCache(hwState, src) {
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
const ch = src[i];
|
||||
const info = hwState._chordRenderInfo.get(ch);
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, effTemplates);
|
||||
const sortedNotes = [...ch.notes].sort((a, b) => hwState._inverted ? b.s - a.s : a.s - b.s);
|
||||
const nonZero = sortedNotes.filter(cn => !isOpen(cn));
|
||||
const nonZeroFrets = nonZero.map(cn => cn.f);
|
||||
@@ -1248,7 +1259,7 @@ export function _updateFretLinePreview(hwState, src, lo, hi) {
|
||||
ch.t > bestChordTime) {
|
||||
bestChordTime = ch.t;
|
||||
activeChord = ch;
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
|
||||
const nonZero = ch.notes.filter(cn => !isOpen(cn));
|
||||
activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : [];
|
||||
}
|
||||
@@ -1260,7 +1271,7 @@ export function _updateFretLinePreview(hwState, src, lo, hi) {
|
||||
const p = project(ch.t - hwState.currentTime);
|
||||
if (!p) continue;
|
||||
activeChord = ch;
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
|
||||
const { isOpen } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
|
||||
const nonZero = ch.notes.filter(cn => !isOpen(cn));
|
||||
activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : [];
|
||||
break;
|
||||
|
||||
@@ -133,6 +133,7 @@
|
||||
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). -->
|
||||
<script type="module" src="/static/capabilities/library-card-actions.js"></script>
|
||||
<script type="module" src="/static/capabilities/visualization.js"></script>
|
||||
<script type="module" src="/static/capabilities/chart-transform.js"></script>
|
||||
<script type="module" src="/static/capabilities/note-detection.js"></script>
|
||||
<script type="module" src="/static/capabilities/midi-input.js"></script>
|
||||
<script type="module" src="/static/capabilities/interface-scale.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user