settings: add host instrument profiles (#753)

* 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>
This commit is contained in:
ChrisBeWithYou
2026-07-05 00:16:42 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 41e907fa52
commit a86abadb14
11 changed files with 786 additions and 53 deletions
+53 -5
View File
@@ -21,7 +21,13 @@
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5] };
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5, 6] };
const PATHWAY_OPTIONS = [
{ id: 'songs', label: 'Songs' },
{ id: 'practice', label: 'Practice' },
{ id: 'learn', label: 'Learn' },
{ id: 'studio', label: 'Studio' },
];
// Tuning names per instrument key (e.g. 'guitar-6', 'bass-4'), loaded from
// GET /api/tunings. Falls back to empty arrays until the fetch resolves.
let _tuningsByKey = {};
@@ -106,7 +112,7 @@
}
}
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 };
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440, pathway: 'songs', instrument_profiles: {}, active_instrument_profile: 'guitar-lead' };
async function loadTunings() {
try {
@@ -126,6 +132,15 @@
} catch (_) { /* non-fatal — TUNINGS falls back to empty, dropdown shows nothing */ }
}
function pathwayForProfile(profiles, profileId, fallback) {
const p = profiles && profiles[profileId];
return p && PATHWAY_OPTIONS.some((o) => o.id === p.pathway) ? p.pathway : (fallback || 'songs');
}
function profileIdForInstrument(inst) {
return inst === 'bass' ? 'bass' : 'guitar-lead';
}
async function loadSettings() {
try {
const r = await fetch('/api/settings');
@@ -150,16 +165,34 @@
if (typeof s.tuning === 'string') tuning = tunings.includes(s.tuning) ? s.tuning : (tunings[0] || 'Standard');
else if (Array.isArray(s.tuning)) tuning = s.tuning;
else tuning = tunings[0] || 'Standard';
const profiles = s.instrument_profiles && typeof s.instrument_profiles === 'object' ? s.instrument_profiles : {};
const pathway = PATHWAY_OPTIONS.some((o) => o.id === s.pathway) ? s.pathway : 'songs';
settings = {
instrument: instrument,
string_count: scValid,
tuning: tuning,
reference_pitch: Math.min(450, Math.max(430, ref)),
pathway: pathway,
instrument_profiles: profiles,
active_instrument_profile: typeof s.active_instrument_profile === 'string' ? s.active_instrument_profile : profileIdForInstrument(instrument),
};
}
} catch (e) { /* settings endpoint always present */ }
}
function syncLocalProfilePatch(patch) {
const profileId = profileIdForInstrument(patch.instrument || settings.instrument);
if (!settings.instrument_profiles || typeof settings.instrument_profiles !== 'object') settings.instrument_profiles = {};
if (patch.instrument) settings.active_instrument_profile = profileId;
const profile = Object.assign({}, settings.instrument_profiles[profileId] || {});
let changed = false;
if (patch.instrument) { profile.instrument = patch.instrument; changed = true; }
if (patch.string_count != null) { profile.string_count = patch.string_count; changed = true; }
if (patch.tuning != null) { profile.tuning = patch.tuning; changed = true; }
if (patch.reference_pitch != null) { profile.reference_pitch = patch.reference_pitch; changed = true; }
if (patch.pathway != null) { profile.pathway = patch.pathway; changed = true; }
if (changed) settings.instrument_profiles[profileId] = profile;
}
async function saveSettings(patch) {
// Only adopt the patch once the server accepts it. /api/settings returns
// {error: ...} with HTTP 200 on a validation failure, so a rejected
@@ -177,8 +210,9 @@
} catch (e) { /* non-fatal — leave settings unchanged */ }
if (!accepted) return false;
Object.assign(settings, patch);
syncLocalProfilePatch(patch);
if (sm && sm.emit) sm.emit('instrument:changed', {
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning,
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning, pathway: settings.pathway,
});
pushToTuner();
renderTuner(); // reflect new tuning on the tuner card
@@ -424,6 +458,9 @@
// (picking a named tuning still works and replaces the custom one).
(typeof settings.tuning === 'string' ? '' : '<option selected disabled>Custom</option>') +
_tuningsForInstrument(settings.instrument, settings.string_count).map((t) => '<option' + (t === settings.tuning ? ' selected' : '') + '>' + esc(t) + '</option>').join('') + '</select></div>' +
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Pathway</div>' +
'<select data-inst-pathway class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
PATHWAY_OPTIONS.map((p) => '<option value="' + esc(p.id) + '"' + (p.id === settings.pathway ? ' selected' : '') + '>' + esc(p.label) + '</option>').join('') + '</select></div>' +
'<div><div class="flex justify-between text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' +
'<input data-inst-ref type="range" min="430" max="450" step="1" value="' + settings.reference_pitch + '" class="w-full slider-input"></div>' +
'</div></div>';
@@ -454,6 +491,7 @@
instrument: v,
string_count: newSc,
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
pathway: pathwayForProfile(settings.instrument_profiles, profileIdForInstrument(v), settings.pathway),
});
// Only move the working-tuning context once the switch was actually persisted —
// otherwise the selector stays on the old instrument while the card shows the
@@ -462,11 +500,21 @@
renderInstrument(); keepOpen();
}));
menu.querySelectorAll('[data-pill="strings"]').forEach((b) => b.addEventListener('click', async () => {
await saveSettings({ string_count: Number(b.getAttribute('data-val')) });
setWorkingInstrument(settings.instrument, settings.string_count);
const newSc = Number(b.getAttribute('data-val'));
// Clamp the tuning to one valid for the new string count and post it
// alongside string_count — otherwise the backend silently resets a
// now-invalid tuning to Standard while this UI keeps showing the old
// one (settings/tuner desync). Mirrors the instrument-switch clamp.
const tunings = _tuningsForInstrument(settings.instrument, newSc);
await saveSettings({
string_count: newSc,
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
});
setWorkingInstrument(settings.instrument, newSc);
renderInstrument(); keepOpen();
}));
menu.querySelector('[data-inst-tuning]').addEventListener('change', (e) => saveSettings({ tuning: e.target.value }));
menu.querySelector('[data-inst-pathway]').addEventListener('change', (e) => saveSettings({ pathway: e.target.value }));
const ref = menu.querySelector('[data-inst-ref]');
ref.addEventListener('input', (e) => { menu.querySelector('[data-ref-val]').textContent = e.target.value + ' Hz'; });
ref.addEventListener('change', (e) => saveSettings({ reference_pitch: Number(e.target.value) }));
+17
View File
@@ -429,6 +429,23 @@
</select>
</div>
</div>
<!-- Instrument pathway -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Instrument pathway</div>
<div class="fb-srow-desc">Preferred path for the selected instrument. This is remembered per instrument profile.</div>
</div>
<div class="fb-srow-control">
<select id="setting-instrument-pathway" onchange="setInstrumentPathway(this.value)"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="songs">Songs</option>
<option value="practice">Practice</option>
<option value="learn">Learn</option>
<option value="studio">Studio</option>
</select>
</div>
</div>
<!-- Arrangement routes (naming mode) -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
+1 -1
View File
@@ -28,7 +28,7 @@
var RESET_MAP = {
gameplay: {
server: ['master_difficulty', 'av_offset_ms', 'miss_penalty',
'fail_behavior', 'countdown_before_song', 'default_arrangement'],
'fail_behavior', 'countdown_before_song', 'default_arrangement', 'pathway'],
local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'],
after: function () {
// Left-handed is held on the highway object, not re-derived