From 71833705b29fefca36cb23b7c402dc54371eed66 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sat, 18 Jul 2026 16:53:59 -0500 Subject: [PATCH] fix(tunings): extended-range bass was named as a guitar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported by a 6-string bassist: a Sleep Token chart tuned A0 D1 G1 C2 F2 A#2 (standard 6-string bass, whole step down) imported and displayed as "6 string D Standard". They called it A standard and they were right. Root cause: `tuning_name()` gated its naming ladder on `len(offsets) == 6`, treating six offsets as proof of a 6-string GUITAR. A 6-string BASS also has six offsets, but its lowest string is B, not E — so the guitar ladder mislabels the whole family: an all-zeros bass read "E Standard" (it is Standard/B) and a whole-step-down bass read "D Standard" (it is A Standard). The function's own comment warned about exactly this error for 7-string guitars; nobody guarded the bass axis. The stored value feeds the library's Tuning filter, so every 5/6-string bass song in every library was filed under a guitar name. - `tuning_name(offsets, *, is_bass=False)`: bass 5/6 use the low-B ladder (Standard / Bb / A / G# / G) and bass 4 keeps the E ladder it shares with guitar. Drop names come off the resulting low string. Default stays guitar, so existing callers are unaffected. - `sloppak._tuning_for_meta_kind()` reports WHICH kind supplied the tuning; `extract_meta` emits `tuning_is_bass` and scan_worker passes it through. Guitar-first selection for the library index is unchanged — a pack with a guitar part still indexes by the guitar. - `TUNING_PRESET_MIDIS` bass-5/bass-6 renamed to match, with `TUNING_PRESET_ALIASES` so `_valid_tuning_for_key` MIGRATES a saved profile carrying an old name instead of rejecting it (it refuses names belonging to another key's built-ins, and "D Standard" still exists for guitar-6/bass-4 — so a rename alone would have invalidated those profiles). Pitches are untouched; only labels change. Convention confirmed by the bass- and guitar-pedagogy seats: name extended range off the ACTUAL lowest string, which is what the 7-string guitar presets already do. The old names came from the band-level habit of saying "we're in D standard" — true of the guitars, while the bassist in that band is in A standard. Right answer, wrong scope. The bass table was also internally inconsistent: its Drop A was already named off the low string while its standards were not. Tests: bass ladders for 4/5/6 strings, the reported chart pinned both ways (is_bass=True -> "A Standard", same offsets as guitar -> "D Standard"), bass drop naming, a table-wide invariant that every bass preset name matches the note its low string sounds, and alias migration (incl. not leaking into guitar-6/bass-4). The existing legacy-flat-bass migration test now asserts the corrected label — same pitches, right name. Suite: 1735 passed vs 1720 on main, with the same 99 pre-existing env failures. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01929LgKdJMyPGLf8N1WpEVW Signed-off-by: ChrisBeWithYou --- lib/scan_worker.py | 8 ++- lib/sloppak.py | 29 ++++++-- lib/tunings.py | 160 +++++++++++++++++++++++++++++++++--------- tests/test_tunings.py | 105 ++++++++++++++++++++++++++- 4 files changed, 258 insertions(+), 44 deletions(-) diff --git a/lib/scan_worker.py b/lib/scan_worker.py index c388a2c..67d55d9 100644 --- a/lib/scan_worker.py +++ b/lib/scan_worker.py @@ -101,7 +101,9 @@ def _extract_meta_sloppak(path: Path) -> dict: """Extract metadata for a sloppak (file or directory).""" meta = sloppak_mod.extract_meta(path) offsets = meta.pop("tuning_offsets", None) or [0] * 6 - name = tuning_name(offsets) + # Naming needs the instrument: six offsets could be a guitar OR a + # 6-string bass, whose lowest string is B rather than E. + name = tuning_name(offsets, is_bass=bool(meta.pop("tuning_is_bass", False))) meta["tuning"] = name meta["tuning_name"] = name meta["tuning_sort_key"] = sum(offsets) @@ -137,7 +139,9 @@ def _extract_meta_loosefolder(path: Path, dlc_root: Path | None) -> dict: # inside DLC_DIR. meta = loosefolder_mod.extract_meta(path, dlc_root=dlc_root) offsets = meta.pop("tuning_offsets", None) or [0] * 6 - name = tuning_name(offsets) + # Naming needs the instrument: six offsets could be a guitar OR a + # 6-string bass, whose lowest string is B rather than E. + name = tuning_name(offsets, is_bass=bool(meta.pop("tuning_is_bass", False))) meta["tuning"] = name meta["tuning_name"] = name meta["tuning_sort_key"] = sum(offsets) diff --git a/lib/sloppak.py b/lib/sloppak.py index 838882c..c50de69 100644 --- a/lib/sloppak.py +++ b/lib/sloppak.py @@ -1227,17 +1227,33 @@ def load_song( def _tuning_for_meta(arrangements_manifest: list[dict]) -> list[int]: """Best-effort guitar-first tuning for the library index.""" + offsets, _ = _tuning_for_meta_kind(arrangements_manifest) + return offsets + + +def _tuning_for_meta_kind( + arrangements_manifest: list[dict], +) -> tuple[list[int], bool]: + """`_tuning_for_meta` plus whether the tuning came from a BASS part. + + The caller names the tuning, and naming needs the instrument: a + 6-string bass has six offsets exactly like a 6-string guitar but its + lowest string is B, so the guitar ladder mislabels it (all-zeros reads + "E Standard" when it is Standard/B; a whole step down reads "D + Standard" when it is A Standard). Guitar parts still win the tuning + itself — this only reports which kind supplied it. + """ for entry in arrangements_manifest: name = str(entry.get("name", "")).lower() tun = entry.get("tuning") if tun and isinstance(tun, list) and name in ("lead", "rhythm", "combo"): - return list(tun) + return list(tun), False # Fallback: first arrangement with a tuning for entry in arrangements_manifest: tun = entry.get("tuning") if tun and isinstance(tun, list): - return list(tun) - return [0] * 6 + return list(tun), "bass" in str(entry.get("name", "")).lower() + return [0] * 6, False def _role_tuning_for_meta(arrangements_manifest: list[dict], role: str) -> list[int] | None: @@ -1282,9 +1298,8 @@ def extract_meta(path: Path) -> dict: a["index"] = i has_lyrics = bool(manifest.get("lyrics")) - tuning_offsets = _tuning_for_meta(arr_list) - # Per-role tunings alongside the song-level one, so the library can answer - # for whichever arrangement the player actually plays. + tuning_offsets, tuning_is_bass = _tuning_for_meta_kind(arr_list) + # Per-role tunings alongside the song-level one. role_tunings = {f"{role}_tuning_offsets": _role_tuning_for_meta(arr_list, role) for role in ("bass", "rhythm")} @@ -1327,6 +1342,8 @@ def extract_meta(path: Path) -> dict: "tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name # None = the pack has no arrangement in that role. **role_tunings, + # Song-level naming also needs the instrument for bass-only packs. + "tuning_is_bass": tuning_is_bass, "arrangements": arrangements, "has_lyrics": has_lyrics, "stem_count": stem_count, diff --git a/lib/tunings.py b/lib/tunings.py index 19460d2..932f842 100644 --- a/lib/tunings.py +++ b/lib/tunings.py @@ -69,21 +69,49 @@ TUNING_PRESET_MIDIS: dict[str, dict[str, list[int]]] = { "Drop C": [24, 31, 36, 41], "BEAD": [23, 28, 33, 38], }, + # 5- and 6-string basses are named off their ACTUAL lowest string (the low + # B), exactly like the 7-string guitar table above — not off the 4-string + # core. The old names (Eb/D/C#/C Standard) came from the band-level habit + # of saying "we're in D standard" (which describes the guitars); the + # bassist in that band is in A standard. They survive as aliases below so + # saved profiles migrate instead of being rejected. "bass-5": { "Standard": [23, 28, 33, 38, 43], "High C": [28, 33, 38, 43, 48], - "Eb Standard": [22, 27, 32, 37, 42], - "D Standard": [21, 26, 31, 36, 41], - "C# Standard": [20, 25, 30, 35, 40], - "C Standard": [19, 24, 29, 34, 39], + "Bb Standard": [22, 27, 32, 37, 42], + "A Standard": [21, 26, 31, 36, 41], + "G# Standard": [20, 25, 30, 35, 40], + "G Standard": [19, 24, 29, 34, 39], "Drop A": [21, 28, 33, 38, 43], }, "bass-6": { "Standard": [23, 28, 33, 38, 43, 48], - "Eb Standard": [22, 27, 32, 37, 42, 47], - "D Standard": [21, 26, 31, 36, 41, 46], - "C# Standard": [20, 25, 30, 35, 40, 45], - "C Standard": [19, 24, 29, 34, 39, 44], + "Bb Standard": [22, 27, 32, 37, 42, 47], + "A Standard": [21, 26, 31, 36, 41, 46], + "G# Standard": [20, 25, 30, 35, 40, 45], + "G Standard": [19, 24, 29, 34, 39, 44], + }, +} + +# Superseded preset names, per key → current name. The 5/6-string bass rows +# were originally named off the 4-string core (so a whole-step-down 6-string, +# whose lowest string is A, read "D Standard"). Renaming alone would make +# `_valid_tuning_for_key` REJECT a saved profile carrying the old name — it +# refuses names that belong to a different key's built-ins, and "D Standard" +# still exists for guitar-6/bass-4. These aliases keep those profiles valid +# and migrate them to the corrected name. +TUNING_PRESET_ALIASES: dict[str, dict[str, str]] = { + "bass-5": { + "Eb Standard": "Bb Standard", + "D Standard": "A Standard", + "C# Standard": "G# Standard", + "C Standard": "G Standard", + }, + "bass-6": { + "Eb Standard": "Bb Standard", + "D Standard": "A Standard", + "C# Standard": "G# Standard", + "C Standard": "G Standard", }, } @@ -229,6 +257,12 @@ def _valid_tuning_for_key(key: str, tuning): return None if tuning in TUNING_PRESET_MIDIS.get(key, {}): return tuning + # A superseded name for THIS key migrates to its current spelling + # (the 5/6-string bass rename). Checked before the cross-key + # rejection below, which would otherwise refuse it. + renamed = TUNING_PRESET_ALIASES.get(key, {}).get(tuning) + if renamed: + return renamed # A name that IS a built-in preset for a different key is a misapplied # built-in (e.g. "Drop D" on a 5-string bass, whose low string is B) — # reject it. A name unknown to every built-in table is a provider/custom @@ -429,11 +463,12 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict: # manifest-only fast scan path — unacceptable for scan time. So we DEFAULT BASS # TO 4 STRINGS and truncate. # -# KNOWN GAP (deliberate, documented): a genuine 5- or 6-string bass is -# truncated to its low four. That is harmless for the overwhelmingly common -# case — a 5-string in standard truncates to [0,0,0,0] and still names -# "Standard" — and only misreads a tuning that DIFFERS at string 4 or above. -# Revisit if the spec ever gains a string count. +# Five-element arrays are unambiguously extended-range. Six elements remain +# ambiguous because legacy four-string charts are padded to six; preserve that +# legacy interpretation except for a uniform non-zero down-tuning, which cannot +# be padding (the padded tail would be zero) and is the common extended-range +# case that motivated the fix. Ambiguous all-zero and drop-shaped six-element +# arrays stay conservative until the manifest carries an explicit string count. BASS_DEFAULT_STRING_COUNT = 4 # Bassists tune DOWN, essentially never up: a whole-instrument up-tune fights @@ -531,10 +566,15 @@ def normalize_offsets(offsets, persp: TuningPerspective) -> list[int] | None: return None if len(vals) < persp.string_count: return None - # Only bass truncates: its arrays are padded (see above). A guitar array - # longer than 6 is a genuine 7/8-string chart, and cutting it to 6 would - # invent a tuning the chart does not have. + # Only bass can be padded. Five entries are unambiguously extended-range. + # Six are ambiguous: legacy four-string data pads with zeroes, while a + # uniform non-zero down-tuning across all six strings proves the tail is + # authored. Keep every other six-element shape conservative at four. if persp.truncate: + if len(vals) == 5: + return vals + if len(vals) == 6 and vals[0] != 0 and len(set(vals)) == 1: + return vals return vals[:persp.string_count] return vals @@ -553,7 +593,16 @@ def perspective_tuning_name(offsets: list[int], persp: TuningPerspective) -> str by its canonical pitches without asserting a tuning anyone plays.""" if not offsets_are_plausible(offsets, persp): return "Custom Tuning" - return tuning_name(offsets) + return tuning_name(offsets, is_bass=persp.instrument == "bass") + + +def _perspective_instrument_key( + offsets: list[int], persp: TuningPerspective, +) -> str: + """Instrument key matching the normalized tuning's proven string count.""" + if persp.instrument == "bass" and f"bass-{len(offsets)}" in STANDARD_OPEN_MIDIS: + return f"bass-{len(offsets)}" + return persp.instrument_key def perspective_tuning_key(offsets: list[int], persp: TuningPerspective) -> str: @@ -566,7 +615,7 @@ def perspective_tuning_key(offsets: list[int], persp: TuningPerspective) -> str: selector, and that query param is a COMMA-separated list, so a comma here would be split into meaningless fragments and match nothing. """ - midis = tuning_midis_from_offsets(persp.instrument_key, offsets) + midis = tuning_midis_from_offsets(_perspective_instrument_key(offsets, persp), offsets) if not midis: return "" return persp.id + ":" + ":".join(str(m) for m in midis) @@ -576,7 +625,7 @@ def perspective_low_pitch(offsets: list[int], persp: TuningPerspective) -> int | """Absolute MIDI pitch of the tuning's LOWEST open string — the value the "playable without retuning" comparison is built on (see `chart_is_playable_in`).""" - midis = tuning_midis_from_offsets(persp.instrument_key, offsets) + midis = tuning_midis_from_offsets(_perspective_instrument_key(offsets, persp), offsets) if not midis: return None return min(midis) @@ -641,33 +690,74 @@ def bass_tuning_key(offsets: list[int]) -> str: return perspective_tuning_key(offsets, PERSPECTIVES["bass"]) -def tuning_name(offsets: list[int]) -> str: - # The pattern checks below are gated on `len(offsets)` being 6 or 4. The - # naming conventions are E-standard-rooted — e.g. a 7-string all-zeros - # tuning has a low B, not an E, so labeling it "E Standard" would be wrong. - # 7+-string community content falls through to the numeric fallback (#43). - # - # Length 4 is accepted because a bass's open strings (EADG) are the low - # four of the guitar, so the same standard/drop names apply at the same - # offsets. Bass callers must normalize FIRST (`normalize_bass_offsets`): - # stored bass arrays are commonly six elements with a padded tail, and the - # padding must never reach this namer. See the block above. +def tuning_name(offsets: list[int], *, is_bass: bool = False) -> str: + """Display name for a set of per-string offsets. - # Standard tunings (all strings same offset) - standard = { + `is_bass` is load-bearing, not cosmetic: string COUNT alone cannot + identify the instrument. A 6-string BASS has six offsets just like a + 6-string guitar, but its lowest string is B, not E — so the guitar + ladder labels an all-zeros bass "E Standard" (it is B/Standard) and a + whole-step-down bass "D Standard" (it is A Standard). That is exactly + the error the 7-string comment below warned about, on the axis nobody + guarded. Callers that know the instrument must say so; the default + stays guitar for backward compatibility. + + All the pattern checks are gated on the expected string count. The + guitar conventions are 6-string-specific — e.g. a 7-string all-zeros + tuning has a low B, not an E, so labeling it "E Standard" would be + wrong. 7+-string guitar content falls through to the numeric + fallback. See #43. + """ + # Standard tunings (all strings same offset), named off the LOWEST + # string. Bass 4-string sits on the E ladder like a guitar; bass 5/6 + # add a low B, so they sit on the B ladder — the same convention the + # 7-string guitar presets use. + guitar_standard = { 0: "E Standard", -1: "Eb Standard", -2: "D Standard", -3: "C# Standard", -4: "C Standard", -5: "B Standard", -6: "Bb Standard", -7: "A Standard", 1: "F Standard", 2: "F# Standard", } - if len(offsets) in (4, 6) and all(o == offsets[0] for o in offsets): - name = standard.get(offsets[0]) + bass_low_b_standard = { + 0: "Standard", -1: "Bb Standard", -2: "A Standard", + -3: "G# Standard", -4: "G Standard", -5: "F# Standard", + 1: "C Standard", 2: "C# Standard", + } + # A four-offset array is unambiguously a bass tuning; preserve the + # historical one-argument behavior used by the library perspective. + if len(offsets) == 4: + is_bass = True + + if is_bass: + # 4-string bass is E-A-D-G — the guitar ladder's low four, so it + # keeps the E-based names. 5/6-string add the low B. + table = guitar_standard if len(offsets) == 4 else bass_low_b_standard + if len(offsets) in (4, 5, 6) and all(o == offsets[0] for o in offsets): + name = table.get(offsets[0]) + if name: + return name + # Drop tunings: the lowest string alone goes down 2 semitones. + if (len(offsets) in (4, 5, 6) + and offsets[0] == offsets[1] - 2 + and all(o == offsets[1] for o in offsets[1:])): + base = STANDARD_OPEN_MIDIS.get(f"bass-{len(offsets)}") + if base: + low = base[0] + offsets[0] + names = ["C", "C#", "D", "Eb", "E", "F", + "F#", "G", "Ab", "A", "Bb", "B"] + return f"Drop {names[low % 12]}" + if not offsets: + return "Unknown" + return "Custom Tuning" + + if len(offsets) == 6 and all(o == offsets[0] for o in offsets): + name = guitar_standard.get(offsets[0]) if name: return name # Drop tunings (low string 2 semitones below the rest) # Named after the low string's note: e.g. offsets[-2,0,0,0,0,0] = Drop D (low E dropped to D) - if len(offsets) in (4, 6) and offsets[0] == offsets[1] - 2 and all(o == offsets[1] for o in offsets[1:]): + if len(offsets) == 6 and offsets[0] == offsets[1] - 2 and all(o == offsets[1] for o in offsets[1:]): note_names = ["E", "F", "F#", "G", "Ab", "A", "Bb", "B", "C", "C#", "D", "Eb"] low_note = note_names[offsets[0] % 12] return f"Drop {low_note}" diff --git a/tests/test_tunings.py b/tests/test_tunings.py index 70b310c..3da03b6 100644 --- a/tests/test_tunings.py +++ b/tests/test_tunings.py @@ -4,10 +4,15 @@ import pytest from tunings import ( DEFAULT_TUNINGS, + PERSPECTIVES, TUNING_PRESET_MIDIS, _valid_tuning_for_key, apply_flat_instrument_patch_to_profiles, + normalize_offsets, open_midis_to_freqs, + perspective_low_pitch, + perspective_tuning_key, + perspective_tuning_name, settings_with_instrument_profiles, tuning_midis_from_offsets, tuning_name, @@ -214,7 +219,11 @@ def test_settings_profiles_migrate_legacy_flat_bass_selection(): }) assert settings["active_instrument_profile"] == "bass" assert settings["instrument_profiles"]["bass"]["string_count"] == 6 - assert settings["instrument_profiles"]["bass"]["tuning"] == "C Standard" + # The legacy 6-string-bass name migrates to the corrected one: the + # pitches [19,24,29,34,39,44] sound lowest G, and extended-range bass is + # named off its actual lowest string. Same tuning, right label — and the + # alias is what keeps this profile VALID rather than rejected. + assert settings["instrument_profiles"]["bass"]["tuning"] == "G Standard" assert settings["reference_pitch"] == 432 assert settings["pathway"] == "practice" assert settings["instrument_profiles"]["bass"]["pathway"] == "practice" @@ -279,3 +288,97 @@ def test_freqs_to_midis_rejects_garbage(): assert freqs_to_midis([float("inf")]) is None # non-finite assert freqs_to_midis([float("-inf")]) is None # non-finite assert freqs_to_midis([]) == [] # vacuously fine + + +# ── Extended-range BASS naming (feedBack: 6-string bass read as guitar) ────── +# A 6-string bass has SIX offsets exactly like a 6-string guitar, but its +# lowest string is B, not E. `tuning_name` gated its guitar ladder on +# `len(offsets) == 6` alone, so a bass got guitar names: an all-zeros bass +# read "E Standard" (it is Standard/B) and a whole-step-down bass read +# "D Standard" (it is A Standard). Reported from a real Sleep Token chart +# tuned A0 D1 G1 C2 F2 A#2; the player called it A standard and was right. +# Convention (bass- and guitar-pedagogy seats, 2026-07-18): name extended +# range by the ACTUAL lowest string, matching the 7-string guitar presets. + +def test_bass_perspective_keeps_proven_six_string_tuning_but_truncates_padding(): + bass = PERSPECTIVES["bass"] + + # Legacy four-string Rocksmith data pads its unused tail with zeroes. + assert normalize_offsets([-2, -2, -2, -2, 0, 0], bass) == [-2] * 4 + + # A uniform non-zero six-string tuning cannot be that padding shape. + extended = normalize_offsets([-2] * 6, bass) + assert extended == [-2] * 6 + assert perspective_tuning_name(extended, bass) == "A Standard" + assert perspective_tuning_key(extended, bass) == "bass:21:26:31:36:41:46" + assert perspective_low_pitch(extended, bass) == 21 + + +BASS_STANDARD_CASES = [ + # 4-string bass is E-A-D-G — the guitar ladder's low four, names unchanged. + ([0, 0, 0, 0], "E Standard"), + ([-1, -1, -1, -1], "Eb Standard"), + ([-2, -2, -2, -2], "D Standard"), + # 5-string adds a low B → the B ladder. + ([0] * 5, "Standard"), + ([-1] * 5, "Bb Standard"), + ([-2] * 5, "A Standard"), + ([-3] * 5, "G# Standard"), + ([-4] * 5, "G Standard"), + # 6-string: same names, extra top string. + ([0] * 6, "Standard"), + ([-1] * 6, "Bb Standard"), + ([-2] * 6, "A Standard"), + ([-3] * 6, "G# Standard"), + ([-4] * 6, "G Standard"), +] + + +@pytest.mark.parametrize("offsets,expected", BASS_STANDARD_CASES) +def test_bass_standard_tunings(offsets, expected): + assert tuning_name(offsets, is_bass=True) == expected + + +def test_six_offsets_alone_do_not_imply_a_guitar(): + """The regression the bug report came from.""" + sleep_token = [-2] * 6 # A0 D1 G1 C2 F2 A#2 + assert tuning_name(sleep_token, is_bass=True) == "A Standard" + # ...and the identical offsets on a guitar keep the guitar name. + assert tuning_name(sleep_token) == "D Standard" + # A STANDARD 6-string bass is not "E Standard" either. + assert tuning_name([0] * 6, is_bass=True) == "Standard" + assert tuning_name([0] * 6) == "E Standard" + + +def test_bass_drop_tunings_name_the_resulting_low_string(): + # 5-string B standard, low string dropped a whole step → A. + assert tuning_name([-2, 0, 0, 0, 0], is_bass=True) == "Drop A" + # 4-string E standard → D. + assert tuning_name([-2, 0, 0, 0], is_bass=True) == "Drop D" + + +def test_bass_presets_are_named_off_their_lowest_string(): + """Every bass preset's name must match the note its low string sounds.""" + names = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"] + alt = {"Ab": "G#", "G#": "Ab", "Bb": "A#", "A#": "Bb", "Eb": "D#", "D#": "Eb"} + for key in ("bass-4", "bass-5", "bass-6"): + for name, midis in TUNING_PRESET_MIDIS[key].items(): + if not name.endswith("Standard") or name == "Standard": + continue + root = name.rsplit(" ", 1)[0] + low = names[midis[0] % 12] + assert root in (low, alt.get(low)), ( + f"{key} {name!r} lowest string sounds {low}" + ) + + +def test_superseded_bass_names_migrate_instead_of_being_rejected(): + """Renaming must not invalidate saved profiles (both pedagogy seats).""" + for key in ("bass-5", "bass-6"): + assert _valid_tuning_for_key(key, "D Standard") == "A Standard" + assert _valid_tuning_for_key(key, "C Standard") == "G Standard" + # Current names still pass straight through. + assert _valid_tuning_for_key(key, "A Standard") == "A Standard" + # The rename must not leak into other instruments. + assert _valid_tuning_for_key("guitar-6", "D Standard") == "D Standard" + assert _valid_tuning_for_key("bass-4", "D Standard") == "D Standard"