mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 12:21:49 +00:00
* settings: add host instrument profiles Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> * settings: add instrument pathway selection Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> * fix(settings): profile-aware saves/resets/switch, provider tunings, bass-5 Five regressions from the instrument-profiles rework: 1. save_settings canonicalized profiles on EVERY save -> empty/unrelated POST froze default profiles into config.json (broke test_empty_post_preserves_all_existing_keys). Gate on the save touching instrument settings; GET already virtualizes profiles. 2. pathway is profile-mirrored, so the Gameplay reset (flat-key delete) was a no-op. reset_settings now resets pathway inside the persisted profiles too. 3. Per-profile tuning validation rejected provider/custom tunings (tuner plugin, /api/tunings). _valid_tuning_for_key now accepts a name unknown to every built-in table while still rejecting a built-in misapplied to the wrong key. 4. First-migration overwrote an explicit active_instrument_profile with the legacy-inferred one, so a fresh-config switch to 'bass' was lost. Use setdefault so an explicit request wins. 5. Pre-existing test_instrument_fields_persist used bass-5 + 'Drop D' (a 4-string tuning). Updated to the valid 'Drop A'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): partial-merge instrument_profiles; clamp tuning on string-count switch Two partial-update follow-ups: - save_settings normalized a POSTed instrument_profiles by FILLING every omitted profile with defaults and replacing wholesale, so a one-profile update reset the others. Validate each PROVIDED profile individually and merge the partial over the persisted set inside the lock — /api/settings is partial-merge. - the string-count picker posted only string_count, so the backend silently reset a now-invalid tuning to Standard while the UI kept the old value (settings/tuner desync). Clamp + post the valid tuning too, mirroring the instrument-switch path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
391 lines
19 KiB
JavaScript
391 lines
19 KiB
JavaScript
// Core "working tuning" capability domain — the live, host-authoritative CURRENT
|
|
// instrument tuning (session state), distinct from the soft opt-in default and from
|
|
// any one song's tuning. This is the single source of truth the whole app reads:
|
|
// the highway, the library/song-picker, Virtuoso, and the minigames all consult it,
|
|
// and the tuner is the sole WRITER (it updates this when the player retunes, clears
|
|
// the gate, or switches instruments).
|
|
//
|
|
// PER-INSTRUMENT: a player has separate physical instruments, each in its OWN tuning
|
|
// ("I'm not tuning two instruments when I pick a song"). So state is a MAP keyed by
|
|
// instrument — `${instrument}-${stringCount}` (e.g. "guitar-6", "bass-4"), the same key
|
|
// the v3 instrument selector uses. `get()` returns the CURRENTLY-SELECTED instrument's
|
|
// tuning; switching the selector surfaces that instrument's own remembered tuning. You
|
|
// only ever deal with the one you've picked.
|
|
//
|
|
// Design: WORKING-TUNING-STATE-DESIGN.md (host-first PR series, PR 1 = this file).
|
|
// Pattern mirrors `capabilities/tuning.js` (capability registration) + the host theme
|
|
// read-API (`window.feedBack.theme`): a synchronous `get()` plus a `working-tuning-
|
|
// changed` event that also fires once on hydration.
|
|
//
|
|
// State is IN-MEMORY and NOT persisted — reset-to-home on restart is deliberate (a
|
|
// stale "you're in drop-A" assumption is worse than re-asking). The opt-in "default
|
|
// tuning on app open" lands later; for now we seed the selected instrument from
|
|
// /api/settings.
|
|
//
|
|
// PR 1 is PURE PLUMBING: it introduces the state + read/write surface + event, but
|
|
// nothing writes to it yet and no behavior changes. The tuner becomes the writer (and
|
|
// the gate's E->C# asymmetry is fixed) in a later PR.
|
|
(function () {
|
|
'use strict';
|
|
|
|
window.feedBack = window.feedBack || {};
|
|
// Idempotent: a second injection of this module must not replace the live state
|
|
// with a fresh (empty) one — once we're registered, re-running is a no-op.
|
|
if (window.feedBack.workingTuning && window.feedBack.workingTuning.version === 1) return;
|
|
const capabilities = window.feedBack.capabilities;
|
|
|
|
const _byInstrument = {}; // key -> tuning state (the per-instrument map)
|
|
let _currentKey = null; // the selected instrument's key; cached so get() is sync
|
|
let _hydrated = false;
|
|
let _touched = false; // set once anything explicitly writes/selects; gates the async seed
|
|
|
|
function _normInstrument(instrument) {
|
|
return instrument === 'bass' ? 'bass' : 'guitar';
|
|
}
|
|
function _keyOf(instrument, stringCount) {
|
|
const inst = _normInstrument(instrument);
|
|
const sc = Number(stringCount) || (inst === 'bass' ? 4 : 6);
|
|
return inst + '-' + sc;
|
|
}
|
|
// Like _keyOf, but when the caller omits a string count we resolve it against the
|
|
// current selection (if it's the same instrument) before falling back to the
|
|
// per-instrument default — so `set({instrument:'bass'})` targets the selected
|
|
// bass-5, not a hard-coded bass-4.
|
|
function _keyOfResolved(instrument, stringCount) {
|
|
const inst = _normInstrument(instrument);
|
|
let sc = Number(stringCount);
|
|
if (!sc) {
|
|
if (_currentKey) {
|
|
const cur = _splitKey(_currentKey);
|
|
if (cur.instrument === inst) sc = cur.stringCount;
|
|
}
|
|
if (!sc) sc = (inst === 'bass' ? 4 : 6);
|
|
}
|
|
return inst + '-' + sc;
|
|
}
|
|
function _splitKey(key) {
|
|
const parts = (typeof key === 'string' ? key : '').split('-');
|
|
const inst = parts[0] === 'bass' ? 'bass' : 'guitar';
|
|
return { instrument: inst, stringCount: Number(parts[1]) || (inst === 'bass' ? 4 : 6) };
|
|
}
|
|
|
|
// The shape every consumer reads. `offsets` are per-string semitone offsets from
|
|
// standard (same vocabulary as song_info.tuning and /api/tunings); `instrument`
|
|
// disambiguates the open-string base so offsets resolve to real pitches. A drop-A
|
|
// 8-string is just an offsets array — fully custom tunings are first-class.
|
|
// `provenance` is the honesty flag: 'verified' means the tuner did a choreographed
|
|
// per-string mic check this session; everything else is 'assumed'.
|
|
function _defaultState(key) {
|
|
const id = _splitKey(key);
|
|
return {
|
|
offsets: null,
|
|
stringCount: id.stringCount,
|
|
instrument: id.instrument,
|
|
referencePitch: 440,
|
|
provenance: 'assumed',
|
|
verifiedStrings: null,
|
|
verifiedAt: null,
|
|
source: 'default',
|
|
};
|
|
}
|
|
|
|
// Resolve which instrument key a get/set targets: an explicit arg wins (a string
|
|
// key "guitar-6", a bare "guitar"/"bass", or { instrument, stringCount }); else the
|
|
// cached current selection.
|
|
function _resolveKey(instrument) {
|
|
if (instrument && typeof instrument === 'object') return _keyOfResolved(instrument.instrument, instrument.stringCount);
|
|
if (typeof instrument === 'string' && instrument) {
|
|
return instrument.indexOf('-') > 0 ? instrument : _keyOfResolved(instrument, null);
|
|
}
|
|
return _currentKey || _keyOf('guitar', 6);
|
|
}
|
|
|
|
// Synchronous read of an instrument's current tuning (default = selected
|
|
// instrument). Returns a deep-enough copy — the object plus its mutable array
|
|
// fields (`offsets`, `verifiedStrings`) — so a reader can't mutate the live state.
|
|
function get(instrument) {
|
|
const key = _resolveKey(instrument);
|
|
const state = Object.assign(_defaultState(key), _byInstrument[key] || {});
|
|
if (Array.isArray(state.offsets)) state.offsets = state.offsets.slice();
|
|
if (Array.isArray(state.verifiedStrings)) state.verifiedStrings = state.verifiedStrings.slice();
|
|
return state;
|
|
}
|
|
|
|
function _emitChanged(key) {
|
|
if (window.feedBack && typeof window.feedBack.emit === 'function') {
|
|
window.feedBack.emit('working-tuning-changed', { key: key, instrument: _splitKey(key).instrument, tuning: get(key) });
|
|
}
|
|
}
|
|
|
|
// The single mutator. The tuner calls this on retune / gate-clear / swap. Writes to
|
|
// the instrument the state targets (opts.instrument, or next.instrument+stringCount,
|
|
// or the current selection) and makes that the active instrument. `opts.provenance`
|
|
// stamps 'verified' (mic-confirmed) vs the default 'assumed'. Changing the tuning
|
|
// invalidates a prior verification unless fresh verifiedStrings are supplied — fail
|
|
// toward "assumed".
|
|
function set(next, opts) {
|
|
opts = opts || {};
|
|
next = next || {};
|
|
// Resolve the target key. An explicit opts.instrument wins; otherwise a
|
|
// next.instrument/next.stringCount targets that slot — but a bare stringCount
|
|
// (no instrument) applies to the CURRENTLY-SELECTED instrument, not a hard-coded
|
|
// guitar, so `set({stringCount:5})` on a selected bass writes bass-5.
|
|
let key;
|
|
if (opts.instrument) {
|
|
key = _resolveKey(opts.instrument);
|
|
} else if (next.instrument || next.stringCount) {
|
|
const inst = next.instrument ? _normInstrument(next.instrument)
|
|
: (_currentKey ? _splitKey(_currentKey).instrument : 'guitar');
|
|
key = _keyOfResolved(inst, next.stringCount);
|
|
} else {
|
|
key = _currentKey || _resolveKey();
|
|
}
|
|
const id = _splitKey(key);
|
|
const merged = Object.assign(get(key), next); // get() gives copies, so `merged` is ours to mutate
|
|
merged.instrument = id.instrument; // keep coherent with the key
|
|
merged.stringCount = id.stringCount; // the key is authoritative for string count
|
|
const tuningChanged = ('offsets' in next) || ('stringCount' in next) || ('referencePitch' in next);
|
|
|
|
// Provenance: explicit opts wins; a bare tuning change downgrades to 'assumed'.
|
|
if (opts.provenance) {
|
|
merged.provenance = opts.provenance;
|
|
} else if (tuningChanged) {
|
|
merged.provenance = 'assumed';
|
|
}
|
|
|
|
// Verification metadata is coherent by construction: a tuning change invalidates
|
|
// prior per-string verification unless the caller supplies a fresh bundle, and the
|
|
// metadata exists ONLY while provenance === 'verified'. So verified <=> we hold
|
|
// verifiedStrings — a "verified with no strings" state is impossible.
|
|
if (!('verifiedStrings' in next) && tuningChanged) {
|
|
merged.verifiedStrings = null;
|
|
}
|
|
if (merged.provenance === 'verified' && !Array.isArray(merged.verifiedStrings)) {
|
|
merged.provenance = 'assumed'; // claimed verified but no evidence — fail toward assumed
|
|
}
|
|
if (merged.provenance === 'verified') {
|
|
// verified always carries a real timestamp — a caller-supplied null/NaN/absent
|
|
// verifiedAt is stamped now, so 'verified' can never mean "at no known time".
|
|
if (typeof merged.verifiedAt !== 'number' || !isFinite(merged.verifiedAt)) {
|
|
merged.verifiedAt = Date.now();
|
|
}
|
|
} else {
|
|
merged.verifiedStrings = null;
|
|
merged.verifiedAt = null;
|
|
}
|
|
|
|
// Store copies of the mutable arrays so a caller can't mutate live state post-set.
|
|
if (Array.isArray(merged.offsets)) merged.offsets = merged.offsets.slice();
|
|
if (Array.isArray(merged.verifiedStrings)) merged.verifiedStrings = merged.verifiedStrings.slice();
|
|
_byInstrument[key] = merged;
|
|
_currentKey = key; // writing a tuning makes that instrument the active one
|
|
_touched = true; // an explicit write must not be clobbered by the async seed
|
|
_emitChanged(key);
|
|
return get(key);
|
|
}
|
|
|
|
// Tell the host which instrument is now selected (the v3 selector calls this when
|
|
// the player switches guitar<->bass / string count) so get() returns the right
|
|
// instrument's tuning. Emits if the selection actually changed.
|
|
function setCurrentInstrument(instrument, stringCount) {
|
|
const key = (typeof instrument === 'string' && instrument.indexOf('-') > 0) ? instrument : _keyOfResolved(instrument, stringCount);
|
|
_touched = true; // an explicit selection must not be reverted by the async seed
|
|
if (key === _currentKey) return get(key);
|
|
_currentKey = key;
|
|
_emitChanged(key);
|
|
return get(key);
|
|
}
|
|
|
|
// Reset an instrument's live tuning back to its baseline (the home/default).
|
|
function resetToDefault(instrument) {
|
|
const key = _resolveKey(instrument);
|
|
_byInstrument[key] = _defaultState(key);
|
|
_touched = true;
|
|
_emitChanged(key);
|
|
return get(key);
|
|
}
|
|
|
|
// Per-string semitone offsets of a named tuning relative to Standard, derived from
|
|
// the /api/tunings frequency tables. The reference pitch cancels in the ratio, so
|
|
// this is pitch-independent. Returns null if either row is missing/mismatched.
|
|
function _offsetsFromFreqs(named, standard) {
|
|
if (!Array.isArray(named) || !Array.isArray(standard) || named.length !== standard.length) return null;
|
|
const out = [];
|
|
for (let i = 0; i < named.length; i++) {
|
|
const a = Number(named[i]);
|
|
const b = Number(standard[i]);
|
|
if (!(a > 0) || !(b > 0)) return null;
|
|
out.push(Math.round(12 * Math.log2(a / b)));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---- Opt-in "launch tuning" default (soft, per-instrument) -------------------
|
|
// A convenience the player opts into: "start me in THIS tuning on app open." Off
|
|
// by default (nothing stored) → boot seeds from /api/settings as before. It is
|
|
// only a SEED — the live working tuning still resets on restart.
|
|
const LAUNCH_KEY = 'v3-working-tuning-launch-default';
|
|
function _readLaunchMap() {
|
|
try { return JSON.parse(localStorage.getItem(LAUNCH_KEY) || '{}') || {}; }
|
|
catch (_) { return {}; }
|
|
}
|
|
function _writeLaunchMap(map) {
|
|
try {
|
|
if (map && Object.keys(map).length) localStorage.setItem(LAUNCH_KEY, JSON.stringify(map));
|
|
else localStorage.removeItem(LAUNCH_KEY);
|
|
} catch (_) { /* private mode */ }
|
|
}
|
|
function getLaunchDefault(instrument) {
|
|
const key = _resolveKey(instrument);
|
|
const d = _readLaunchMap()[key];
|
|
return d ? Object.assign(_defaultState(key), d, { source: 'launch-default' }) : null;
|
|
}
|
|
// Remember an instrument's CURRENT working tuning (or a supplied state) as its
|
|
// launch default. Opt-in — nothing calls this unless the player asks.
|
|
function setLaunchDefault(instrument, state) {
|
|
const key = _resolveKey(instrument);
|
|
const src = state || get(key);
|
|
const map = _readLaunchMap();
|
|
map[key] = {
|
|
offsets: Array.isArray(src.offsets) ? src.offsets.slice() : null,
|
|
stringCount: src.stringCount,
|
|
instrument: _splitKey(key).instrument,
|
|
referencePitch: src.referencePitch || 440,
|
|
};
|
|
_writeLaunchMap(map);
|
|
return getLaunchDefault(key);
|
|
}
|
|
function clearLaunchDefault(instrument) {
|
|
const key = _resolveKey(instrument);
|
|
const map = _readLaunchMap();
|
|
if (key in map) { delete map[key]; _writeLaunchMap(map); }
|
|
}
|
|
|
|
// Seed the SELECTED instrument's slot on boot (best-effort 'assumed' starting
|
|
// point, NOT a persisted working tuning): the player's opt-in launch default if one
|
|
// is set for this instrument, else /api/settings — where a NAMED tuning ("Drop D") is
|
|
// resolved to offsets via /api/tunings so it isn't lost. If neither can be read we
|
|
// still hydrate so consumers aren't stuck waiting; an explicit set()/select before we
|
|
// resolve wins (no clobber).
|
|
function _seedFromSettings() {
|
|
fetch('/api/settings')
|
|
.then(function (r) { return r && r.ok ? r.json() : null; })
|
|
.then(function (s) {
|
|
if (!s || _touched) return; // nothing to seed, or a consumer already wrote — don't clobber
|
|
const inst = _normInstrument(s.instrument);
|
|
const sc = Number(s.string_count) || (inst === 'bass' ? 4 : 6);
|
|
const key = _keyOf(inst, sc);
|
|
|
|
function commit(offsets) {
|
|
if (_touched) return; // re-check: a write may have raced the /api/tunings fetch
|
|
_currentKey = key;
|
|
// Opt-in launch default wins over the raw profile; otherwise use the
|
|
// resolved `offsets` (a named settings tuning was already turned into
|
|
// offsets via /api/tunings before commit()).
|
|
const launch = _readLaunchMap()[key];
|
|
_byInstrument[key] = launch
|
|
? {
|
|
offsets: Array.isArray(launch.offsets) ? launch.offsets.slice(0, sc) : null,
|
|
stringCount: sc, instrument: inst,
|
|
referencePitch: Number(launch.referencePitch) || 440,
|
|
provenance: 'assumed', verifiedStrings: null, verifiedAt: null,
|
|
source: 'launch-default',
|
|
}
|
|
: {
|
|
offsets: Array.isArray(offsets) ? offsets.slice(0, sc) : null,
|
|
stringCount: sc, instrument: inst,
|
|
referencePitch: Number(s.reference_pitch) || 440,
|
|
provenance: 'assumed', verifiedStrings: null, verifiedAt: null,
|
|
source: 'settings',
|
|
};
|
|
}
|
|
|
|
if (Array.isArray(s.tuning)) { commit(s.tuning); return; }
|
|
if (typeof s.tuning === 'string' && s.tuning) {
|
|
return fetch('/api/tunings')
|
|
.then(function (r) { return r && r.ok ? r.json() : null; })
|
|
.then(function (t) {
|
|
const byName = t && ((t.tunings && t.tunings[key]) || t[key]);
|
|
commit(byName ? _offsetsFromFreqs(byName[s.tuning], byName.Standard) : null);
|
|
})
|
|
.catch(function () { commit(null); });
|
|
}
|
|
commit(null);
|
|
})
|
|
.catch(function () { /* keep defaults */ })
|
|
.then(function () { _hydrate(); });
|
|
}
|
|
|
|
function _hydrate() {
|
|
if (_hydrated) return;
|
|
_hydrated = true;
|
|
_emitChanged(_currentKey || _resolveKey());
|
|
}
|
|
|
|
// A per-string mic verification is only trustworthy for the context it was done
|
|
// in — a new song means the player may have retuned, so a stale 'verified' must
|
|
// never suppress a needed prompt (fail toward re-checking). Decay the CURRENT
|
|
// instrument's verification back to 'assumed' on each song load; offsets are kept.
|
|
function _decayVerifiedOnSongLoad() {
|
|
const key = _currentKey || _resolveKey();
|
|
const st = _byInstrument[key];
|
|
if (st && st.provenance === 'verified') {
|
|
st.provenance = 'assumed';
|
|
st.verifiedStrings = null;
|
|
st.verifiedAt = null;
|
|
_emitChanged(key);
|
|
}
|
|
}
|
|
if (typeof window.feedBack.on === 'function') {
|
|
window.feedBack.on('song:loading', _decayVerifiedOnSongLoad);
|
|
}
|
|
|
|
// ---- Capability registration (mirrors capabilities/tuning.js) ----------------
|
|
if (capabilities && capabilities.version === 1 &&
|
|
!(window.feedBack.workingTuning && window.feedBack.workingTuning.version === 1)) {
|
|
capabilities.registerOwner('working-tuning', {
|
|
description: 'The live, host-authoritative current instrument tuning (session state), per ' +
|
|
'instrument: offsets + string-count + reference pitch + assumed/verified provenance. ' +
|
|
'Written by the tuner, read by the highway/library/Virtuoso/minigames.',
|
|
operations: ['get-working-tuning', 'set-working-tuning'],
|
|
events: ['working-tuning-changed'],
|
|
kind: 'command',
|
|
ownership: 'exclusive-owner',
|
|
});
|
|
capabilities.registerParticipant('plugin.tuner', {
|
|
'working-tuning': {
|
|
roles: ['contributor', 'requester'],
|
|
operations: ['get-working-tuning', 'set-working-tuning'],
|
|
emits: ['working-tuning-changed'],
|
|
mode: 'active',
|
|
compatibility: 'none',
|
|
safety: 'safe',
|
|
},
|
|
});
|
|
capabilities.registerParticipant('core.settings.instruments', {
|
|
'working-tuning': {
|
|
roles: ['requester'],
|
|
operations: ['get-working-tuning'],
|
|
events: ['working-tuning-changed'],
|
|
mode: 'active',
|
|
compatibility: 'none',
|
|
safety: 'safe',
|
|
},
|
|
});
|
|
}
|
|
|
|
// ---- Public read/write surface (attached defensively, like feedBack.theme) ----
|
|
window.feedBack.workingTuning = Object.freeze({
|
|
version: 1,
|
|
get: get,
|
|
set: set,
|
|
setCurrentInstrument: setCurrentInstrument,
|
|
resetToDefault: resetToDefault,
|
|
getLaunchDefault: getLaunchDefault,
|
|
setLaunchDefault: setLaunchDefault,
|
|
clearLaunchDefault: clearLaunchDefault,
|
|
});
|
|
|
|
_seedFromSettings();
|
|
})();
|