Use instrument tuning in playlist checks

This commit is contained in:
ChrisBeWithYou 2026-07-18 23:36:57 -05:00
parent dbee1b2489
commit 8341d21416
4 changed files with 69 additions and 18 deletions

View File

@ -2715,7 +2715,9 @@ class MetadataDB:
rows = self.conn.execute(
f"""SELECT ps.filename, ps.position, s.title, s.artist, s.tuning_name,
ps.arrangement, ps.work_key, s.arrangements,
(s.filename IS NULL) AS dead, s.tuning_offsets
(s.filename IS NULL) AS dead, s.tuning_offsets,
s.bass_tuning_name, s.bass_tuning_offsets,
s.rhythm_tuning_name, s.rhythm_tuning_offsets
FROM playlist_songs ps LEFT JOIN songs s ON s.filename = ps.filename
WHERE ps.playlist_id = ? {dead_filter}
ORDER BY ps.position, ps.filename""",
@ -2733,6 +2735,10 @@ class MetadataDB:
# rows are different tunings), and coverage needs to know whether to
# measure against bass or guitar base pitches.
"tuning_offsets": r[9] or "",
"bass_tuning_name": r[10] or "",
"bass_tuning_offsets": r[11] or "",
"rhythm_tuning_name": r[12] or "",
"rhythm_tuning_offsets": r[13] or "",
"bass_only": _arrangements_all_bass(r[7]),
"art_url": f"/api/song/{quote(r[0])}/art",
}
@ -2762,7 +2768,8 @@ class MetadataDB:
self._ensure_work_display()
row = self.conn.execute(
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements, "
"s.tuning_offsets "
"s.tuning_offsets, s.bass_tuning_name, s.bass_tuning_offsets, "
"s.rhythm_tuning_name, s.rhythm_tuning_offsets "
"FROM work_display wd JOIN songs s ON s.filename = wd.filename "
"WHERE wd.effective_work_key = ? AND wd.is_group_representative = 1",
(work_key,)).fetchone()
@ -2777,6 +2784,10 @@ class MetadataDB:
return {"resolved_filename": row[0], "title": row[1] or row[0],
"artist": row[2] or "", "tuning_name": row[3] or "",
"tuning_offsets": row[5] or "",
"bass_tuning_name": row[6] or "",
"bass_tuning_offsets": row[7] or "",
"rhythm_tuning_name": row[8] or "",
"rhythm_tuning_offsets": row[9] or "",
"bass_only": _arrangements_all_bass(row[4]),
"arrangements": arrs,
"art_url": f"/api/song/{quote(row[0])}/art",

View File

@ -61,25 +61,25 @@
// without stopping. This flags them. It is READ-ONLY: nothing here edits a
// playlist — removal is a separate, explicit, itemised action.
// ── SEAM — repoint HERE when `feat/v3-tuning-filter-instrument` lands ────
// The tuning this row should be scored against for the player's instrument.
// main indexes exactly ONE tuning per chart, derived from the guitar
// arrangement, so today a bass player is scored against the guitar chart's
// tuning — the very data source behind the reported bug. The sibling PR adds
// per-instrument tuning columns (`bass_tuning_name` / `bass_tuning_offsets`)
// plus a `libInstrument()` perspective; when it lands, this ONE function
// picks the bass tuning for a bass player and everything downstream (the
// chips, the summary, the filter, the remove list) follows with no other
// change. Kept as a function, not an inline read, for exactly that reason.
// Pick the indexed perspective that matches the player's live instrument.
// #1003 supplies bass-specific columns; when a song has no bass chart we
// deliberately fall back to the historical song-level guitar tuning.
function rowTuningForCheck(s) {
let wantsBass = false;
try {
const wt = window.feedBack && window.feedBack.workingTuning;
const cur = wt && typeof wt.get === 'function' ? wt.get() : null;
wantsBass = !!cur && cur.instrument === 'bass';
} catch (_) { /* capability errors degrade to the song-level tuning */ }
const hasBassTuning = wantsBass && !!s.bass_tuning_offsets;
return {
offsets: s.tuning_offsets || s.tuning_name,
// Score a bass-only chart against bass base pitches — a 4-string bass
// tuning read as guitar can false-match a guitarist.
isBass: !!s.bass_only,
offsets: hasBassTuning
? s.bass_tuning_offsets : (s.tuning_offsets || s.tuning_name),
// The selected bass perspective uses bass base pitches. A bass-only
// fallback row does too; every other fallback is the lead chart.
isBass: hasBassTuning || !!s.bass_only,
};
}
// A coverage report says "not covered" BOTH for a real mismatch and for
// "I couldn't work it out" (missing settings/tuner data → an all-empty
// report). Only a report carrying an actual reason — named string changes,

View File

@ -60,7 +60,7 @@ function loadChecker(opts) {
const calls = [];
const window = {
parseRawTuningOffsets: loadParseRawTuningOffsets(),
feedBack: opts.noWorkingTuning ? {} : { workingTuning: { get: () => ({ instrument: 'bass' }) } },
feedBack: opts.noWorkingTuning ? {} : { workingTuning: { get: () => ({ instrument: opts.instrument || 'bass' }) } },
_tunerAutoOpen: opts.noCoverage ? undefined : {
coverageReport: async (info) => {
calls.push(info);
@ -176,6 +176,26 @@ test('a coverage call that throws degrades to unknown, not mismatch', async () =
assert.deepEqual(plain(out.map((r) => r.state)), ['unknown']);
});
test('the bass perspective uses #1003 bass offsets instead of guitar offsets', async () => {
const checker = loadChecker({ instrument: 'bass', coverage: async () => REPORT_COVERED });
await checker.checkPlaylistTuning([song({
tuning_offsets: '0 0 0 0 0 0',
bass_tuning_offsets: '-2 -2 -2 -2 -2 -2',
})]);
assert.deepEqual(plain(checker.calls[0].tuning), [-2, -2, -2, -2, -2, -2]);
assert.equal(checker.calls[0].arrangement, 'Bass');
});
test("the guitar perspective ignores a song's bass offsets", async () => {
const checker = loadChecker({ instrument: 'guitar', coverage: async () => REPORT_COVERED });
await checker.checkPlaylistTuning([song({
tuning_offsets: '0 0 0 0 0 0',
bass_tuning_offsets: '-2 -2 -2 -2 -2 -2',
})]);
assert.deepEqual(plain(checker.calls[0].tuning), [0, 0, 0, 0, 0, 0]);
assert.equal(checker.calls[0].arrangement, 'Lead');
});
test('a bass-only chart is scored against bass base pitches', async () => {
// Otherwise a 4-string bass tuning read as guitar can false-match — the
// cross-instrument confusion this whole feature exists to undo.

View File

@ -285,6 +285,26 @@ def test_playlist_songs_carry_tuning_offsets_for_the_check(client, server):
assert song["tuning_name"] == "Drop D"
def test_playlist_songs_carry_role_specific_tunings(client, server):
db = server.meta_db
db.put("roles.archive", 0, 0, {
"title": "Roles",
"tuning_name": "E Standard",
"tuning_offsets": "0 0 0 0 0 0",
"bass_tuning_name": "A Standard",
"bass_tuning_offsets": "-2 -2 -2 -2 -2 -2",
"rhythm_tuning_name": "Drop D",
"rhythm_tuning_offsets": "-2 0 0 0 0 0",
})
pid = client.post("/api/playlists", json={"name": "Roles"}).json()["id"]
client.post(f"/api/playlists/{pid}/songs", json={"filename": "roles.archive"})
song = client.get(f"/api/playlists/{pid}").json()["songs"][0]
assert song["bass_tuning_name"] == "A Standard"
assert song["bass_tuning_offsets"] == "-2 -2 -2 -2 -2 -2"
assert song["rhythm_tuning_name"] == "Drop D"
assert song["rhythm_tuning_offsets"] == "-2 0 0 0 0 0"
def test_playlist_songs_flag_bass_only_charts(client, server):
# Every arrangement a bass part → bass_only, so coverage scores the row
# against bass strings. A chart that ALSO has a guitar part must not be