mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
fix(library): tuning filter answers for your instrument, not always guitar (#1003)
* fix(library): tuning filter answers for your instrument, not always guitar The library indexed exactly one tuning per song, chosen guitar-first (lead > rhythm > combo, bass only as a last resort), and nothing consulted the player's instrument. A bassist filtering by tuning was shown the guitar chart's tuning, so playlists built by tuning contained songs needing a retune. Reported by a tester building bass practice sets; Covet "Shibuya" is the clean case, with a custom guitar tuning over a standard bass chart. Indexes each arrangement role's own tuning and makes the facet, filter, sort and labels answer for one perspective. `guitar-lead` reads the original unprefixed columns and adds no payload keys, so the default response is unchanged. The same defect existed inside guitar -- lead and rhythm charts can disagree -- so perspective is three-valued (guitar-lead, guitar-rhythm, bass) driven by one PERSPECTIVES table rather than parallel column families. Songs with no chart for the perspective fall back to the song-level tuning rather than vanishing (18 of 59 packs in the test library have no bass chart), but the fallback is marked inferred in the facet counts and on the row instead of being silently coalesced. "Only real charts" reuses the existing `arrangements_has` filter rather than adding one. Bass-specific handling, from measured content: - Bass tuning arrays are padded to six entries; charts never reference string index 4 or 5. Truncated to four before naming and grouping. - Grouping uses a canonical open-pitch key, so [-2,0,0,0] and [-2,0,0,0,0,0] are one facet row instead of two. - Offsets above +1 semitone are refused a name. Bassists tune down, near never up; one pack ships [5,5,5,5,4,4] (A-D-G-C, unplayable, and its own notes sit in the song's real key under standard tuning). Naming that would send a player to retune to a tuning that does not exist. Rhythm deliberately does not truncate -- padding is a bass finding, and cutting a seven-string array would invent a tuning the chart lacks. Adds an opt-in `tuning_match=playable` mode alongside exact match: a chart is offered when your lowest open pitch is at or below its lowest open pitch, so a five-string bass covers four-string standard and drop-D with no retune. Open strings only -- note range is not indexed and the scan stays manifest-only -- so it fails conservative: unknown low pitch is excluded, and the upper bound is unchecked and documented rather than guessed. Existing installs would otherwise never populate: the tree-signature fast path reports "unchanged" forever on a settled library. Rows with NULL marker columns re-extract, and the fast path is disabled until that backfill converges (writes use '' rather than NULL, so it self-clears). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com> * test(v3): accept the tuning-perspective indirection in the badge guard The album-art badge now reads shownTuningName(), so the source-pattern guard no longer matched the inline `tuning_name || tuning` form and CI went red. Accept the helper, and pin the helper's own fallback in a companion test so the guard still fails if a guitar player's tuning label is ever dropped. Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.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:
co-authored by
Claude Opus 4.8
parent
f0d9c3abc0
commit
cc75cb876a
+71
-4
@@ -410,6 +410,51 @@ function _applyLibraryProviderToParams(params) {
|
||||
return params;
|
||||
}
|
||||
|
||||
// ── Instrument-aware tuning (the bass-player tuning-filter report) ───────────
|
||||
// A song's bass chart is often tuned differently from its guitar chart, so the
|
||||
// tuning facet, the `tunings` filter, the tuning sort and the row's tuning
|
||||
// badge must all speak for the instrument the player actually plays. Read the
|
||||
// host's working-tuning capability (the live selection, seeded from
|
||||
// /api/settings at boot) rather than adding another settings fetch; hosts
|
||||
// without the capability keep the guitar behaviour.
|
||||
const _LIB_PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass'];
|
||||
let _libSettingsProfile = '';
|
||||
|
||||
export function _setLibraryProfile(profileId) {
|
||||
_libSettingsProfile = _LIB_PERSPECTIVES.includes(profileId) ? profileId : '';
|
||||
}
|
||||
|
||||
export function _libraryInstrument() {
|
||||
// The PROFILE is the only three-valued source (lead / rhythm / bass); the
|
||||
// working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm,
|
||||
// so it is only the fallback.
|
||||
if (_libSettingsProfile) return _libSettingsProfile;
|
||||
try {
|
||||
const wt = window.feedBack?.workingTuning;
|
||||
if (wt && typeof wt.get === 'function') {
|
||||
const cur = wt.get();
|
||||
if (cur?.instrument === 'bass') return 'bass';
|
||||
}
|
||||
} catch { /* capability absent/erroring — lead guitar is the safe default */ }
|
||||
return 'guitar-lead';
|
||||
}
|
||||
|
||||
export function _libraryInstrumentLabel() {
|
||||
const p = _libraryInstrument();
|
||||
return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead';
|
||||
}
|
||||
|
||||
// The tuning a row should SHOW: the bass chart's for a bass player, falling
|
||||
// back to the song (guitar-derived) tuning when the song has no bass
|
||||
// arrangement — the common case, not an edge path.
|
||||
function _rowTuningRaw(song) {
|
||||
const p = _libraryInstrument();
|
||||
const field = p === 'bass' ? 'bass_tuning_name'
|
||||
: p === 'guitar-rhythm' ? 'rhythm_tuning_name' : '';
|
||||
if (field && song[field]) return song[field];
|
||||
return song.tuning || song.tuning_name || '';
|
||||
}
|
||||
|
||||
export function _resetLibraryProviderViewState() {
|
||||
L.libEpoch++;
|
||||
L.currentPage = 0;
|
||||
@@ -768,6 +813,8 @@ export function _applyLibFiltersToParams(params) {
|
||||
if (_libFilters.stemsLacks.length) params.set('stems_lacks', _libFilters.stemsLacks.join(','));
|
||||
if (_libFilters.lyrics !== null) params.set('has_lyrics', String(_libFilters.lyrics));
|
||||
if (_libFilters.tunings.length) params.set('tunings', _libFilters.tunings.join(','));
|
||||
// Which instrument's tuning the `tunings` filter + the tuning sort read.
|
||||
if (_libraryInstrument() !== 'guitar-lead') params.set('instrument', _libraryInstrument());
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -851,6 +898,7 @@ async function _renderTuningList() {
|
||||
c.innerHTML = '<div class="text-xs text-gray-500 px-2">Loading...</div>';
|
||||
try {
|
||||
const params = _applyLibraryProviderToParams(new URLSearchParams());
|
||||
params.set('instrument', _libraryInstrument());
|
||||
const resp = await fetch(`/api/library/tuning-names?${params}`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
@@ -869,6 +917,11 @@ async function _renderTuningList() {
|
||||
fetchError = e.message || 'request failed';
|
||||
}
|
||||
}
|
||||
// NAME the perspective: silent instrument-following is the original bug in
|
||||
// a new place — the user must be able to see which instrument these
|
||||
// tunings describe.
|
||||
const labelEl = document.getElementById('filter-tunings-label');
|
||||
if (labelEl) labelEl.textContent = `Tuning (${_libraryInstrumentLabel()})`;
|
||||
c.innerHTML = '';
|
||||
if (fetchError) {
|
||||
c.innerHTML = `<div class="text-xs text-red-400 px-2">Failed to load tunings (${esc(fetchError)}). Reopen the drawer to retry.</div>`;
|
||||
@@ -894,10 +947,17 @@ async function _renderTuningList() {
|
||||
const checked = _libFilters.tunings.includes(val);
|
||||
const row = document.createElement('label');
|
||||
row.className = 'tuning-row';
|
||||
// Be honest about the fallback: songs with no bass arrangement borrow
|
||||
// the guitar chart's tuning, and that must be visible rather than
|
||||
// presented as a measured bass tuning.
|
||||
const inferred = t.inferred_count || 0;
|
||||
if (inferred) {
|
||||
row.title = `${inferred} of ${t.count} inferred from the guitar chart (no bass arrangement)`;
|
||||
}
|
||||
row.innerHTML =
|
||||
`<input type="checkbox" ${checked ? 'checked' : ''} class="rounded border-gray-600 bg-dark-700 text-accent">` +
|
||||
`<span class="flex-1">${esc(label)}</span>` +
|
||||
`<span class="tuning-count">${t.count}</span>`;
|
||||
`<span class="tuning-count">${t.count}${inferred ? ` (${inferred}~)` : ''}</span>`;
|
||||
const cb = row.querySelector('input');
|
||||
cb.onchange = () => {
|
||||
const i = _libFilters.tunings.indexOf(val);
|
||||
@@ -1244,6 +1304,10 @@ export function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace
|
||||
const duration = song.duration ? formatTime(song.duration) : '';
|
||||
const tuningRaw = song.tuning || song.tuning_name || '';
|
||||
const tuning = displayTuningName(tuningRaw);
|
||||
// The BADGE follows the player's instrument; `tuning` above stays the
|
||||
// song's guitar-derived tuning because the retune action below rewrites
|
||||
// the chart to E Standard and must not key on the bass part.
|
||||
const tuningBadge = displayTuningName(_rowTuningRaw(song));
|
||||
const artUrl = _librarySongArtUrl(song, providerId);
|
||||
const isLocalProvider = _isLocalLibraryProvider(providerId);
|
||||
const isSloppak = song.format === 'sloppak';
|
||||
@@ -1299,7 +1363,7 @@ export function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap gap-1.5 mt-3 text-xs">
|
||||
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
|
||||
${tuning ? `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>` : ''}
|
||||
${tuningBadge ? `<span class="px-1.5 py-0.5 rounded ${tuningBadge === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuningBadge)}</span>` : ''}
|
||||
${song.has_lyrics ? `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>` : ''}
|
||||
${song.user_difficulty != null ? `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>` : ''}
|
||||
${duration ? `<span class="text-gray-600">${duration}</span>` : ''}
|
||||
@@ -1470,6 +1534,9 @@ export async function renderTreeInto(containerId, countId, stats, letter, q, fav
|
||||
const duration = song.duration ? formatTime(song.duration) : '';
|
||||
const tuningRaw = song.tuning || song.tuning_name || '';
|
||||
const tuning = displayTuningName(tuningRaw);
|
||||
// Badge follows the player's instrument; the retune action below
|
||||
// keeps operating on the song's guitar-derived tuning.
|
||||
const tuningBadge = displayTuningName(_rowTuningRaw(song));
|
||||
const isLocalProvider = _isLocalLibraryProvider(providerId);
|
||||
const isSloppak = song.format === 'sloppak';
|
||||
const stdRetune = isLocalProvider && localFilename && !isSloppak && tuningRaw && !song.has_estd &&
|
||||
@@ -1496,8 +1563,8 @@ export async function renderTreeInto(containerId, countId, stats, letter, q, fav
|
||||
{ const _nm = _getArrangementNamingMode();
|
||||
for (const arrangement of (song.arrangements || []))
|
||||
html += _arrangementBadgeHtml(arrangement, _nm); }
|
||||
if (tuning)
|
||||
html += `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>`;
|
||||
if (tuningBadge)
|
||||
html += `<span class="px-1.5 py-0.5 rounded ${tuningBadge === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuningBadge)}</span>`;
|
||||
if (song.has_lyrics)
|
||||
html += `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>`;
|
||||
if (song.user_difficulty != null)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
// back-import would close a cycle. player-controls keeps reading it through the host seam, and
|
||||
// app.js — the root, which imports both — wires it. That is exactly what the seam is for.
|
||||
import { hwcInitSettingsUI } from './highway-colors.js';
|
||||
import { _getArrangementNamingMode } from './library.js';
|
||||
import { _getArrangementNamingMode, _setLibraryProfile } from './library.js';
|
||||
import {
|
||||
_applyMastery, _autoplayExitEnabled, _exitConfirmEnabled, _showUpNextEnabled,
|
||||
} from './player-controls.js';
|
||||
@@ -111,6 +111,10 @@ export async function loadSettings() {
|
||||
if (dlcEl) dlcEl.value = data.dlc_dir || '';
|
||||
_defaultArrangement = data.default_arrangement || '';
|
||||
_syncDefaultArrangementSelect(_defaultArrangement);
|
||||
// Feed the library its tuning PERSPECTIVE (lead / rhythm / bass) — the
|
||||
// tuning facet, filter, sort and badges all answer for the profile the
|
||||
// player actually plays.
|
||||
_setLibraryProfile(data.active_instrument_profile);
|
||||
const pathwayEl = document.getElementById('setting-instrument-pathway');
|
||||
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
|
||||
const demucsEl = document.getElementById('demucs-server-url');
|
||||
|
||||
@@ -326,7 +326,7 @@
|
||||
<section>
|
||||
<details>
|
||||
<summary class="cursor-pointer flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">
|
||||
<span>Tuning</span>
|
||||
<span id="filter-tunings-label">Tuning</span>
|
||||
<span id="filter-tunings-summary" class="text-gray-600 normal-case font-normal text-xs">All tunings</span>
|
||||
</summary>
|
||||
<div id="filter-tunings" class="mt-3 space-y-1 max-h-64 overflow-y-auto pr-1"></div>
|
||||
|
||||
+199
-20
@@ -62,7 +62,7 @@
|
||||
artist: '', album: '',
|
||||
grouping: true, // one card per song (multi-chart grouping); persisted
|
||||
|
||||
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [] },
|
||||
filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' },
|
||||
page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [], genres: [],
|
||||
artistCatalog: [], renderedHash: '',
|
||||
scrollBound: false,
|
||||
@@ -120,7 +120,7 @@
|
||||
function activeFilterCount() {
|
||||
const f = state.filters;
|
||||
return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length +
|
||||
(f.lyrics ? 1 : 0) + f.tunings.length + (f.mastery ? f.mastery.length : 0) +
|
||||
(f.lyrics ? 1 : 0) + (f.tuningMatch === 'playable' ? 1 : f.tunings.length) + (f.mastery ? f.mastery.length : 0) +
|
||||
(f.match ? f.match.length : 0) + (f.genre ? f.genre.length : 0) +
|
||||
(state.artist ? 1 : 0) + (state.album ? 1 : 0);
|
||||
}
|
||||
@@ -280,7 +280,15 @@
|
||||
if (f.stem_has.length) p.set('stems_has', f.stem_has.join(','));
|
||||
if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(','));
|
||||
if (f.lyrics) p.set('has_lyrics', f.lyrics);
|
||||
if (f.tunings.length) p.set('tunings', f.tunings.join(','));
|
||||
// The two modes answer different questions, so only one filters at a
|
||||
// time: sending both would silently intersect them.
|
||||
if (f.tunings.length && f.tuningMatch !== 'playable') p.set('tunings', f.tunings.join(','));
|
||||
// Which perspective the `tunings` filter + the tuning sort read.
|
||||
if (libInstrument() !== 'guitar-lead') p.set('instrument', libInstrument());
|
||||
// "Playable without retuning": send the player's LIVE tuning and let the
|
||||
// server do the pitch maths (the pitch tables live in lib/tunings.py —
|
||||
// duplicating them here is how the two drift apart).
|
||||
applyPlayableParams(p, f);
|
||||
if (f.mastery && f.mastery.length) p.set('mastery', f.mastery.join(','));
|
||||
if (f.match && f.match.length) p.set('match', f.match.join(','));
|
||||
if (f.genre && f.genre.length) p.set('genre', f.genre.join(','));
|
||||
@@ -786,6 +794,10 @@
|
||||
// so without them the chips render exactly as before. Decoration runs AFTER the
|
||||
// (sync) window paint so scrolling stays snappy; a token cancels a superseded pass.
|
||||
let _tuningDecorToken = 0;
|
||||
// The instrument the current grid was queried/painted for, so a
|
||||
// working-tuning change can tell a guitar<->bass SWITCH (re-query) from a
|
||||
// retune within the same instrument (re-colour only).
|
||||
let _lastRenderInstrument = null;
|
||||
function _applyChipMatch(chip, stateName) {
|
||||
chip.classList.remove('bg-fb-mid', 'bg-emerald-500', 'bg-amber-400');
|
||||
chip.classList.add(stateName === 'match' ? 'bg-emerald-500'
|
||||
@@ -825,22 +837,31 @@
|
||||
const shown = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
|
||||
// In select mode the checkbox occupies top-2 left-2, so shift the
|
||||
// tuning chip right (left-9) to avoid overlapping it.
|
||||
// Bass players see the bass chart's tuning (guitar fallback) — the card
|
||||
// must agree with the facet/filter or the grid contradicts the pills.
|
||||
const shownTuning = shownTuningName(shown);
|
||||
const tuningLabel = (typeof window.displayTuningName === 'function')
|
||||
? window.displayTuningName(shown.tuning_name || shown.tuning)
|
||||
: (shown.tuning_name || '');
|
||||
? window.displayTuningName(shownTuning)
|
||||
: (shownTuning || '');
|
||||
let tuning = '';
|
||||
if (tuningLabel) {
|
||||
const rawOffsets = (typeof window.parseRawTuningOffsets === 'function')
|
||||
? (window.parseRawTuningOffsets(shown.tuning_offsets)
|
||||
|| window.parseRawTuningOffsets(shown.tuning_name || shown.tuning))
|
||||
? (window.parseRawTuningOffsets(shownTuningOffsets(shown))
|
||||
|| window.parseRawTuningOffsets(shownTuning))
|
||||
: null;
|
||||
const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets
|
||||
&& typeof window.displayTuningTargets === 'function')
|
||||
? window.displayTuningTargets(rawOffsets, { tuningName: tuningLabel })
|
||||
: '';
|
||||
const badgeTitle = targetNotes
|
||||
// Mark a tuning we INFERRED from the guitar chart (this song has no
|
||||
// bass arrangement) so a bass player isn't shown a borrowed tuning
|
||||
// as if it were their part's. `~` keeps the chip compact; the title
|
||||
// spells it out.
|
||||
const inferred = shown.tuning_inferred === true;
|
||||
const badgeTitle = (targetNotes
|
||||
? ('Custom Tuning: ' + targetNotes)
|
||||
: tuningLabel;
|
||||
: tuningLabel)
|
||||
+ (inferred ? ' — from the guitar chart (no bass arrangement)' : '');
|
||||
const pos = 'absolute top-2 ' + (state.selectMode ? 'left-9' : 'left-2');
|
||||
// Tag the chip with its offsets so decorateTuningChips() can colour it
|
||||
// green (matches your current tuning) / amber (needs a retune) after paint.
|
||||
@@ -848,8 +869,14 @@
|
||||
// scores its bass tuning against the bass base pitches, not guitar — otherwise
|
||||
// a 4-string bass tuning read as guitar can false-match a guitar player.
|
||||
const chipArrs = shown.arrangements || [];
|
||||
const chipIsBass = chipArrs.length > 0
|
||||
&& chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || ''));
|
||||
// Bass either because the chip is SHOWING the bass chart's tuning
|
||||
// (a bass player on a song that has one), or because every
|
||||
// arrangement is a bass part. Checked via libInstrument() rather
|
||||
// than comparing the two names — they are EQUAL for most songs, so
|
||||
// a value comparison would flag a guitarist's chip as bass.
|
||||
const chipIsBass = (libInstrument() === 'bass' && !!shown.bass_tuning_name)
|
||||
|| (chipArrs.length > 0
|
||||
&& chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || '')));
|
||||
const matchAttr = (rawOffsets && rawOffsets.length)
|
||||
? ' data-tuning-chip data-tuning-offsets="' + esc(rawOffsets.join(',')) + '"'
|
||||
+ (chipIsBass ? ' data-tuning-bass="1"' : '') : '';
|
||||
@@ -857,7 +884,7 @@
|
||||
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight max-w-[5.5rem] text-center"' + matchAttr + ' title="' + esc(badgeTitle) + '">'
|
||||
+ esc('Custom Tuning') + '<br><span class="font-semibold tracking-wide">' + esc(targetNotes) + '</span></span>';
|
||||
} else {
|
||||
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[0.625rem] font-bold px-1.5 py-0.5 rounded-sm"' + matchAttr + ' title="' + esc(badgeTitle) + '">' + esc(tuningLabel) + '</span>';
|
||||
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[0.625rem] font-bold px-1.5 py-0.5 rounded-sm"' + matchAttr + ' title="' + esc(badgeTitle) + '">' + esc(tuningLabel) + (inferred ? '<span class="opacity-60"> ~</span>' : '') + '</span>';
|
||||
}
|
||||
}
|
||||
// Display-only (pointer-events-none) so a click falls through to the
|
||||
@@ -2489,6 +2516,89 @@
|
||||
|
||||
function _artistHostEl() { return document.getElementById('v3-songs-artistpage'); }
|
||||
|
||||
// ── Instrument-aware tuning (the bass-player tuning-filter report) ────────
|
||||
// A song's bass chart is often in a different tuning from its guitar chart,
|
||||
// so the tuning facet/filter/sort and the card chip must speak for the
|
||||
// instrument the player actually plays. The host's working-tuning
|
||||
// capability already holds the live selection (seeded from /api/settings on
|
||||
// boot, updated when the player switches) — read it rather than adding
|
||||
// another settings fetch. `state.settingsInstrument` is the fallback for
|
||||
// hosts where the capability isn't mounted.
|
||||
// Three perspectives, matching `active_instrument_profile`: lead and rhythm
|
||||
// guitar charts can be tuned differently too, so a rhythm player hits the
|
||||
// same bug a bassist did. The PROFILE is the only three-valued source (the
|
||||
// working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm),
|
||||
// so it wins; the capability is the live fallback for hosts where the
|
||||
// profile hasn't loaded.
|
||||
const PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass'];
|
||||
function libInstrument() {
|
||||
if (PERSPECTIVES.indexOf(state.settingsProfile) >= 0) return state.settingsProfile;
|
||||
try {
|
||||
const wt = window.feedBack && window.feedBack.workingTuning;
|
||||
if (wt && typeof wt.get === 'function') {
|
||||
const cur = wt.get();
|
||||
if (cur && cur.instrument === 'bass') return 'bass';
|
||||
}
|
||||
} catch (_) { /* capability absent/erroring — fall through to settings */ }
|
||||
return state.settingsInstrument === 'bass' ? 'bass' : 'guitar-lead';
|
||||
}
|
||||
|
||||
// "Playable without retuning" mode reads the player's CURRENT tuning from
|
||||
// the working-tuning capability (the live session state the tuner writes),
|
||||
// not a separate setting. No capability => we cannot know the current
|
||||
// tuning, so the mode is unavailable rather than guessed.
|
||||
function currentWorkingTuning() {
|
||||
try {
|
||||
const wt = window.feedBack && window.feedBack.workingTuning;
|
||||
if (!wt || typeof wt.get !== 'function') return null;
|
||||
const cur = wt.get();
|
||||
if (!cur || !Array.isArray(cur.offsets) || !cur.offsets.length) return null;
|
||||
return cur;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function playableAvailable() { return !!currentWorkingTuning(); }
|
||||
|
||||
function applyPlayableParams(p, f) {
|
||||
if (f.tuningMatch !== 'playable') return;
|
||||
const cur = currentWorkingTuning();
|
||||
if (!cur) return;
|
||||
p.set('tuning_match', 'playable');
|
||||
p.set('playable_offsets', cur.offsets.join(','));
|
||||
p.set('playable_instrument', cur.instrument === 'bass' ? 'bass' : 'guitar');
|
||||
p.set('playable_string_count', String(cur.stringCount || cur.offsets.length));
|
||||
}
|
||||
|
||||
// Short human label for the perspective, for the facet/sort headers.
|
||||
function libInstrumentLabel() {
|
||||
const p = libInstrument();
|
||||
return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead';
|
||||
}
|
||||
|
||||
// The column a row's tuning lives in for the active perspective.
|
||||
function perspectiveTuningField() {
|
||||
const p = libInstrument();
|
||||
return p === 'bass' ? 'bass_tuning_name'
|
||||
: p === 'guitar-rhythm' ? 'rhythm_tuning_name' : '';
|
||||
}
|
||||
|
||||
// The tuning a card should SHOW: bass players see the bass chart's tuning,
|
||||
// falling back to the song (guitar-derived) tuning when the song has no
|
||||
// bass arrangement — the common case, so the fallback is not an edge path.
|
||||
function shownTuningName(song) {
|
||||
const f = perspectiveTuningField();
|
||||
if (f && song[f]) return song[f];
|
||||
return song.tuning_name || song.tuning;
|
||||
}
|
||||
|
||||
function shownTuningOffsets(song) {
|
||||
const f = perspectiveTuningField();
|
||||
if (f && song[f]) {
|
||||
return song[f.replace('_name', '_offsets')] || song.tuning_offsets;
|
||||
}
|
||||
return song.tuning_offsets;
|
||||
}
|
||||
|
||||
// Sync the two Settings gates into module state (fire-and-forget — the
|
||||
// cached flags gate entry-point rendering; openArtistPage re-checks).
|
||||
function refreshArtistPageGates() {
|
||||
@@ -2496,6 +2606,10 @@
|
||||
if (!cfg) return;
|
||||
state.artistPagesEnabled = cfg.artist_pages_enabled !== false;
|
||||
state.artistLinksEnabled = cfg.artist_external_links === true;
|
||||
// Fallback instrument for hosts without the working-tuning capability.
|
||||
state.settingsInstrument = cfg.instrument === 'bass' ? 'bass' : 'guitar';
|
||||
// The three-valued perspective source (lead / rhythm / bass).
|
||||
state.settingsProfile = cfg.active_instrument_profile || '';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2809,12 +2923,13 @@
|
||||
else if (s === 'has') lacksArr.push(value);
|
||||
// 'lacks' → cycles back to any (already removed)
|
||||
}
|
||||
function triPill(group, value, label, st) {
|
||||
function triPill(group, value, label, st, title) {
|
||||
const cls = st === 'has' ? 'bg-fb-good/30 text-fb-good border-fb-good/40'
|
||||
: st === 'lacks' ? 'bg-fb-low/30 text-fb-low border-fb-low/40'
|
||||
: 'bg-gray-800/50 text-fb-textDim border-gray-700';
|
||||
const mark = st === 'has' ? '✓ ' : st === 'lacks' ? '✕ ' : '';
|
||||
return '<button data-tri="' + group + '" data-val="' + esc(value) + '" class="px-2 py-1 rounded-md text-xs border ' + cls + '">' + mark + esc(label) + '</button>';
|
||||
const tip = title ? ' title="' + esc(title) + '"' : '';
|
||||
return '<button data-tri="' + group + '" data-val="' + esc(value) + '" class="px-2 py-1 rounded-md text-xs border ' + cls + '"' + tip + '>' + mark + esc(label) + '</button>';
|
||||
}
|
||||
function renderDrawer() {
|
||||
const d = document.getElementById('v3-songs-drawer');
|
||||
@@ -2834,7 +2949,32 @@
|
||||
section('Match', [['review', 'To review'], ['matched', 'Matched'], ['unmatched', 'Unmatched'], ['pending', 'Not scanned']].map((it) => '<button data-match="' + it[0] + '" class="px-2 py-1 rounded-md text-xs border ' + (f.match.includes(it[0]) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + it[1] + '</button>').join('')) +
|
||||
// Genre facet — dynamic list from /api/library/genres (primary genre).
|
||||
(state.genres && state.genres.length ? section('Genre', state.genres.map((g) => '<button data-genre="' + esc(g) + '" class="px-2 py-1 rounded-md text-xs border ' + (f.genre.includes(g) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + esc(g) + '</button>').join('')) : '') +
|
||||
section('Tuning', (state.tuningNames || []).map((t) => {
|
||||
// The facet header NAMES the perspective. Silent instrument-following
|
||||
// is the original bug in a new place: the user must be able to tell
|
||||
// which instrument these tunings describe.
|
||||
section('Tuning (' + libInstrumentLabel() + ')',
|
||||
// MODE toggle. Exact match answers "which tuning is this
|
||||
// labelled"; Playable answers "will this cost me a retune" —
|
||||
// which is what a player actually wants. Both are offered;
|
||||
// exact stays the default so nothing changes unasked.
|
||||
'<div class="flex gap-1 mb-2">'
|
||||
+ [['exact', 'Exact tuning'], ['playable', 'Playable without retuning']].map((m) => {
|
||||
const on = (f.tuningMatch || 'exact') === m[0];
|
||||
const dis = m[0] === 'playable' && !playableAvailable();
|
||||
return '<button data-tuning-match="' + m[0] + '"'
|
||||
+ (dis ? ' disabled' : '')
|
||||
+ (dis ? ' title="Needs your current tuning — open the tuner first"' : '')
|
||||
+ ' class="px-2 py-1 rounded-md text-xs border '
|
||||
+ (on ? 'bg-fb-primary text-white border-fb-primary'
|
||||
: 'bg-gray-800/50 text-fb-textDim border-gray-700')
|
||||
+ (dis ? ' opacity-40 cursor-not-allowed' : '') + '">'
|
||||
+ esc(m[1]) + '</button>';
|
||||
}).join('')
|
||||
+ '</div>'
|
||||
+ (f.tuningMatch === 'playable'
|
||||
? '<div class="text-xs text-fb-textDim mb-2">Charts you can play in your current tuning, no retune. Songs whose lowest string sits below yours are excluded.</div>'
|
||||
: '')
|
||||
+ ((state.tuningNames || []).map((t) => {
|
||||
// Filter on the server's grouping key (raw offsets for customs)
|
||||
// so two "Custom Tuning" entries are distinct; show their target
|
||||
// notes in the label so they're distinguishable.
|
||||
@@ -2847,8 +2987,17 @@
|
||||
const notes = offs ? window.displayTuningTargets(offs, { tuningName: t.name }) : '';
|
||||
if (notes) label = 'Custom · ' + notes;
|
||||
}
|
||||
return triPill('tuning', val, label + ' (' + t.count + ')', f.tunings.includes(val) ? 'has' : 'any');
|
||||
}).join('') || '<span class="text-xs text-fb-textDim">No tunings</span>') +
|
||||
// Be honest about the fallback: when some of a row's songs have
|
||||
// no bass chart and are borrowing the guitar tuning, say so
|
||||
// rather than presenting a borrowed tuning as a measured one.
|
||||
const inf = t.inferred_count || 0;
|
||||
const title = inf
|
||||
? inf + ' of ' + t.count + ' inferred from the guitar chart (no bass arrangement)'
|
||||
: '';
|
||||
const countLabel = inf ? t.count + ', ' + inf + ' inferred' : String(t.count);
|
||||
return triPill('tuning', val, label + ' (' + countLabel + ')',
|
||||
f.tunings.includes(val) ? 'has' : 'any', title);
|
||||
}).join('') || '<span class="text-xs text-fb-textDim">No tunings</span>')) +
|
||||
// Multi-chart grouping toggle (P5e) — a VIEW mode, not a filter
|
||||
// (never counted in the badge, never saved into collection rules).
|
||||
// Local provider only: it's the one that implements group=.
|
||||
@@ -2878,6 +3027,11 @@
|
||||
else if (g === 'tuning') { const i = f.tunings.indexOf(v); if (i >= 0) f.tunings.splice(i, 1); else f.tunings.push(v); }
|
||||
renderDrawer();
|
||||
}));
|
||||
d.querySelectorAll('[data-tuning-match]').forEach((b) => b.addEventListener('click', () => {
|
||||
if (b.disabled) return;
|
||||
f.tuningMatch = b.getAttribute('data-tuning-match');
|
||||
renderDrawer();
|
||||
}));
|
||||
d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); }));
|
||||
d.querySelectorAll('[data-mastery]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-mastery'); const i = f.mastery.indexOf(v); if (i >= 0) f.mastery.splice(i, 1); else f.mastery.push(v); renderDrawer(); }));
|
||||
d.querySelector('[data-grouping]')?.addEventListener('click', () => {
|
||||
@@ -2891,7 +3045,7 @@
|
||||
d.querySelector('[data-drawer-tidy]')?.addEventListener('click', openArtistTidyUp);
|
||||
d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer);
|
||||
d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => {
|
||||
state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [] };
|
||||
state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' };
|
||||
state.artist = '';
|
||||
state.album = '';
|
||||
renderDrawer();
|
||||
@@ -3478,13 +3632,15 @@
|
||||
const providers = await loadProviders();
|
||||
const [, tn] = await Promise.all([
|
||||
(async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(),
|
||||
jget('/api/library/tuning-names?provider=' + enc(state.provider)),
|
||||
jget('/api/library/tuning-names?provider=' + enc(state.provider)
|
||||
+ '&instrument=' + enc(libInstrument())),
|
||||
loadArtistCatalog(),
|
||||
// Artist-page gates (PR-B) ride the initial fetch batch so the
|
||||
// first card paint already knows whether artist lines are links.
|
||||
refreshArtistPageGates(),
|
||||
]);
|
||||
state.tuningNames = (tn && tn.tunings) || [];
|
||||
_lastRenderInstrument = libInstrument();
|
||||
try { const _g = await jget('/api/library/genres?provider=' + enc(state.provider)); state.genres = (_g && _g.genres) || []; } catch (e) { state.genres = []; }
|
||||
|
||||
const opt = (arr, sel) => arr.map(([v, l]) => '<option value="' + esc(v) + '"' + (v === sel ? ' selected' : '') + '>' + esc(l) + '</option>').join('');
|
||||
@@ -3512,7 +3668,13 @@
|
||||
'<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' +
|
||||
'<select id="v3-songs-album" class="' + ctrl + ' max-w-[11rem]" aria-label="Album"' + (state.artist ? '' : ' disabled') + '>' + albumSelectHtml() + '</select>' +
|
||||
'<div class="flex rounded-md overflow-hidden border border-gray-700"><button id="v3-songs-grid-btn" class="px-3 py-2 text-sm">▦</button><button id="v3-songs-tree-btn" class="px-3 py-2 text-sm">≣</button><button id="v3-songs-albums-btn" title="Albums" class="px-3 py-2 text-sm">💿</button><button id="v3-songs-folder-btn" class="px-3 py-2 text-sm" style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:2.25rem"><svg fill="currentColor" viewBox="0 0 16 16" style="width:12px;height:12px;flex-shrink:0"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg></button></div>' +
|
||||
'<select id="v3-songs-sort" class="' + ctrl + '">' + opt(SORTS, state.sort) + '</select>' +
|
||||
// Name the perspective on the SORT too, not just the filter: tuning
|
||||
// sort orders by musical distance from standard, and for a bass
|
||||
// player that distance is measured on the bass tuning. Unlabelled,
|
||||
// the grid silently reorders with no visible cause.
|
||||
'<select id="v3-songs-sort" class="' + ctrl + '">' + opt(
|
||||
SORTS.map(([v, l]) => [v, v === 'tuning' ? l + ' (' + libInstrumentLabel() + ')' : l]),
|
||||
state.sort) + '</select>' +
|
||||
'<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' +
|
||||
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
|
||||
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
|
||||
@@ -4130,6 +4292,23 @@
|
||||
// visible tuning chips against the new tuning. Cheap: re-decorates in place,
|
||||
// no re-fetch or re-paint. No-op off the Songs grid or without the capability.
|
||||
sm.on('working-tuning-changed', () => {
|
||||
// A guitar<->bass SWITCH changes which tuning the facet, the filter,
|
||||
// the sort and the card chip speak for, so the grid must re-query —
|
||||
// re-colouring chips would leave the guitar tuning on screen and a
|
||||
// guitar-keyed filter applied. A retune within one instrument still
|
||||
// takes the cheap in-place path below.
|
||||
const inst = libInstrument();
|
||||
if (inst !== _lastRenderInstrument) {
|
||||
_lastRenderInstrument = inst;
|
||||
// A tuning selection keyed to the old instrument means nothing
|
||||
// for the new one; clearing avoids an empty grid the user can't
|
||||
// explain (the pills are re-rendered from the new facet).
|
||||
state.filters.tunings = [];
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (active && active.id === 'v3-songs') reload();
|
||||
else _libraryDirty = true;
|
||||
return;
|
||||
}
|
||||
if (typeof songsActive === 'function' && !songsActive()) return;
|
||||
if (state.view !== 'grid') return;
|
||||
decorateTuningChips(_gridEl());
|
||||
|
||||
Reference in New Issue
Block a user