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:
Joe Hallahan
2026-07-19 11:27:52 +02:00
committed by GitHub
co-authored by Byron Gamatos
parent f7942f3689
commit 05be9ebdbe
18 changed files with 1345 additions and 54 deletions
+2 -1
View File
@@ -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 (_) {}
})();
})();
+336
View File
@@ -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
View File
@@ -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
// instruments 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
View File
@@ -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;
+1
View File
@@ -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>