mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 02:44:30 +00:00
fix(tunings): extended-range bass was named as a guitar
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01929LgKdJMyPGLf8N1WpEVW Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
040bb411df
commit
71833705b2
+6
-2
@@ -101,7 +101,9 @@ def _extract_meta_sloppak(path: Path) -> dict:
|
|||||||
"""Extract metadata for a sloppak (file or directory)."""
|
"""Extract metadata for a sloppak (file or directory)."""
|
||||||
meta = sloppak_mod.extract_meta(path)
|
meta = sloppak_mod.extract_meta(path)
|
||||||
offsets = meta.pop("tuning_offsets", None) or [0] * 6
|
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
|
||||||
meta["tuning_name"] = name
|
meta["tuning_name"] = name
|
||||||
meta["tuning_sort_key"] = sum(offsets)
|
meta["tuning_sort_key"] = sum(offsets)
|
||||||
@@ -137,7 +139,9 @@ def _extract_meta_loosefolder(path: Path, dlc_root: Path | None) -> dict:
|
|||||||
# inside DLC_DIR.
|
# inside DLC_DIR.
|
||||||
meta = loosefolder_mod.extract_meta(path, dlc_root=dlc_root)
|
meta = loosefolder_mod.extract_meta(path, dlc_root=dlc_root)
|
||||||
offsets = meta.pop("tuning_offsets", None) or [0] * 6
|
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
|
||||||
meta["tuning_name"] = name
|
meta["tuning_name"] = name
|
||||||
meta["tuning_sort_key"] = sum(offsets)
|
meta["tuning_sort_key"] = sum(offsets)
|
||||||
|
|||||||
+23
-6
@@ -1227,17 +1227,33 @@ def load_song(
|
|||||||
|
|
||||||
def _tuning_for_meta(arrangements_manifest: list[dict]) -> list[int]:
|
def _tuning_for_meta(arrangements_manifest: list[dict]) -> list[int]:
|
||||||
"""Best-effort guitar-first tuning for the library index."""
|
"""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:
|
for entry in arrangements_manifest:
|
||||||
name = str(entry.get("name", "")).lower()
|
name = str(entry.get("name", "")).lower()
|
||||||
tun = entry.get("tuning")
|
tun = entry.get("tuning")
|
||||||
if tun and isinstance(tun, list) and name in ("lead", "rhythm", "combo"):
|
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
|
# Fallback: first arrangement with a tuning
|
||||||
for entry in arrangements_manifest:
|
for entry in arrangements_manifest:
|
||||||
tun = entry.get("tuning")
|
tun = entry.get("tuning")
|
||||||
if tun and isinstance(tun, list):
|
if tun and isinstance(tun, list):
|
||||||
return list(tun)
|
return list(tun), "bass" in str(entry.get("name", "")).lower()
|
||||||
return [0] * 6
|
return [0] * 6, False
|
||||||
|
|
||||||
|
|
||||||
def _role_tuning_for_meta(arrangements_manifest: list[dict], role: str) -> list[int] | None:
|
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
|
a["index"] = i
|
||||||
|
|
||||||
has_lyrics = bool(manifest.get("lyrics"))
|
has_lyrics = bool(manifest.get("lyrics"))
|
||||||
tuning_offsets = _tuning_for_meta(arr_list)
|
tuning_offsets, tuning_is_bass = _tuning_for_meta_kind(arr_list)
|
||||||
# Per-role tunings alongside the song-level one, so the library can answer
|
# Per-role tunings alongside the song-level one.
|
||||||
# for whichever arrangement the player actually plays.
|
|
||||||
role_tunings = {f"{role}_tuning_offsets": _role_tuning_for_meta(arr_list, role)
|
role_tunings = {f"{role}_tuning_offsets": _role_tuning_for_meta(arr_list, role)
|
||||||
for role in ("bass", "rhythm")}
|
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
|
"tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name
|
||||||
# None = the pack has no arrangement in that role.
|
# None = the pack has no arrangement in that role.
|
||||||
**role_tunings,
|
**role_tunings,
|
||||||
|
# Song-level naming also needs the instrument for bass-only packs.
|
||||||
|
"tuning_is_bass": tuning_is_bass,
|
||||||
"arrangements": arrangements,
|
"arrangements": arrangements,
|
||||||
"has_lyrics": has_lyrics,
|
"has_lyrics": has_lyrics,
|
||||||
"stem_count": stem_count,
|
"stem_count": stem_count,
|
||||||
|
|||||||
+125
-35
@@ -69,21 +69,49 @@ TUNING_PRESET_MIDIS: dict[str, dict[str, list[int]]] = {
|
|||||||
"Drop C": [24, 31, 36, 41],
|
"Drop C": [24, 31, 36, 41],
|
||||||
"BEAD": [23, 28, 33, 38],
|
"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": {
|
"bass-5": {
|
||||||
"Standard": [23, 28, 33, 38, 43],
|
"Standard": [23, 28, 33, 38, 43],
|
||||||
"High C": [28, 33, 38, 43, 48],
|
"High C": [28, 33, 38, 43, 48],
|
||||||
"Eb Standard": [22, 27, 32, 37, 42],
|
"Bb Standard": [22, 27, 32, 37, 42],
|
||||||
"D Standard": [21, 26, 31, 36, 41],
|
"A Standard": [21, 26, 31, 36, 41],
|
||||||
"C# Standard": [20, 25, 30, 35, 40],
|
"G# Standard": [20, 25, 30, 35, 40],
|
||||||
"C Standard": [19, 24, 29, 34, 39],
|
"G Standard": [19, 24, 29, 34, 39],
|
||||||
"Drop A": [21, 28, 33, 38, 43],
|
"Drop A": [21, 28, 33, 38, 43],
|
||||||
},
|
},
|
||||||
"bass-6": {
|
"bass-6": {
|
||||||
"Standard": [23, 28, 33, 38, 43, 48],
|
"Standard": [23, 28, 33, 38, 43, 48],
|
||||||
"Eb Standard": [22, 27, 32, 37, 42, 47],
|
"Bb Standard": [22, 27, 32, 37, 42, 47],
|
||||||
"D Standard": [21, 26, 31, 36, 41, 46],
|
"A Standard": [21, 26, 31, 36, 41, 46],
|
||||||
"C# Standard": [20, 25, 30, 35, 40, 45],
|
"G# Standard": [20, 25, 30, 35, 40, 45],
|
||||||
"C Standard": [19, 24, 29, 34, 39, 44],
|
"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
|
return None
|
||||||
if tuning in TUNING_PRESET_MIDIS.get(key, {}):
|
if tuning in TUNING_PRESET_MIDIS.get(key, {}):
|
||||||
return tuning
|
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
|
# 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) —
|
# 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
|
# 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
|
# manifest-only fast scan path — unacceptable for scan time. So we DEFAULT BASS
|
||||||
# TO 4 STRINGS and truncate.
|
# TO 4 STRINGS and truncate.
|
||||||
#
|
#
|
||||||
# KNOWN GAP (deliberate, documented): a genuine 5- or 6-string bass is
|
# Five-element arrays are unambiguously extended-range. Six elements remain
|
||||||
# truncated to its low four. That is harmless for the overwhelmingly common
|
# ambiguous because legacy four-string charts are padded to six; preserve that
|
||||||
# case — a 5-string in standard truncates to [0,0,0,0] and still names
|
# legacy interpretation except for a uniform non-zero down-tuning, which cannot
|
||||||
# "Standard" — and only misreads a tuning that DIFFERS at string 4 or above.
|
# be padding (the padded tail would be zero) and is the common extended-range
|
||||||
# Revisit if the spec ever gains a string count.
|
# 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
|
BASS_DEFAULT_STRING_COUNT = 4
|
||||||
|
|
||||||
# Bassists tune DOWN, essentially never up: a whole-instrument up-tune fights
|
# 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
|
return None
|
||||||
if len(vals) < persp.string_count:
|
if len(vals) < persp.string_count:
|
||||||
return None
|
return None
|
||||||
# Only bass truncates: its arrays are padded (see above). A guitar array
|
# Only bass can be padded. Five entries are unambiguously extended-range.
|
||||||
# longer than 6 is a genuine 7/8-string chart, and cutting it to 6 would
|
# Six are ambiguous: legacy four-string data pads with zeroes, while a
|
||||||
# invent a tuning the chart does not have.
|
# 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 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[:persp.string_count]
|
||||||
return vals
|
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."""
|
by its canonical pitches without asserting a tuning anyone plays."""
|
||||||
if not offsets_are_plausible(offsets, persp):
|
if not offsets_are_plausible(offsets, persp):
|
||||||
return "Custom Tuning"
|
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:
|
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
|
selector, and that query param is a COMMA-separated list, so a comma here
|
||||||
would be split into meaningless fragments and match nothing.
|
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:
|
if not midis:
|
||||||
return ""
|
return ""
|
||||||
return persp.id + ":" + ":".join(str(m) for m in midis)
|
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
|
"""Absolute MIDI pitch of the tuning's LOWEST open string — the value the
|
||||||
"playable without retuning" comparison is built on (see
|
"playable without retuning" comparison is built on (see
|
||||||
`chart_is_playable_in`)."""
|
`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:
|
if not midis:
|
||||||
return None
|
return None
|
||||||
return min(midis)
|
return min(midis)
|
||||||
@@ -641,33 +690,74 @@ def bass_tuning_key(offsets: list[int]) -> str:
|
|||||||
return perspective_tuning_key(offsets, PERSPECTIVES["bass"])
|
return perspective_tuning_key(offsets, PERSPECTIVES["bass"])
|
||||||
|
|
||||||
|
|
||||||
def tuning_name(offsets: list[int]) -> str:
|
def tuning_name(offsets: list[int], *, is_bass: bool = False) -> str:
|
||||||
# The pattern checks below are gated on `len(offsets)` being 6 or 4. The
|
"""Display name for a set of per-string offsets.
|
||||||
# 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.
|
|
||||||
|
|
||||||
# Standard tunings (all strings same offset)
|
`is_bass` is load-bearing, not cosmetic: string COUNT alone cannot
|
||||||
standard = {
|
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",
|
0: "E Standard", -1: "Eb Standard", -2: "D Standard",
|
||||||
-3: "C# Standard", -4: "C Standard", -5: "B Standard",
|
-3: "C# Standard", -4: "C Standard", -5: "B Standard",
|
||||||
-6: "Bb Standard", -7: "A Standard",
|
-6: "Bb Standard", -7: "A Standard",
|
||||||
1: "F Standard", 2: "F# Standard",
|
1: "F Standard", 2: "F# Standard",
|
||||||
}
|
}
|
||||||
if len(offsets) in (4, 6) and all(o == offsets[0] for o in offsets):
|
bass_low_b_standard = {
|
||||||
name = standard.get(offsets[0])
|
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:
|
if name:
|
||||||
return name
|
return name
|
||||||
|
|
||||||
# Drop tunings (low string 2 semitones below the rest)
|
# 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)
|
# 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"]
|
note_names = ["E", "F", "F#", "G", "Ab", "A", "Bb", "B", "C", "C#", "D", "Eb"]
|
||||||
low_note = note_names[offsets[0] % 12]
|
low_note = note_names[offsets[0] % 12]
|
||||||
return f"Drop {low_note}"
|
return f"Drop {low_note}"
|
||||||
|
|||||||
+104
-1
@@ -4,10 +4,15 @@ import pytest
|
|||||||
|
|
||||||
from tunings import (
|
from tunings import (
|
||||||
DEFAULT_TUNINGS,
|
DEFAULT_TUNINGS,
|
||||||
|
PERSPECTIVES,
|
||||||
TUNING_PRESET_MIDIS,
|
TUNING_PRESET_MIDIS,
|
||||||
_valid_tuning_for_key,
|
_valid_tuning_for_key,
|
||||||
apply_flat_instrument_patch_to_profiles,
|
apply_flat_instrument_patch_to_profiles,
|
||||||
|
normalize_offsets,
|
||||||
open_midis_to_freqs,
|
open_midis_to_freqs,
|
||||||
|
perspective_low_pitch,
|
||||||
|
perspective_tuning_key,
|
||||||
|
perspective_tuning_name,
|
||||||
settings_with_instrument_profiles,
|
settings_with_instrument_profiles,
|
||||||
tuning_midis_from_offsets,
|
tuning_midis_from_offsets,
|
||||||
tuning_name,
|
tuning_name,
|
||||||
@@ -214,7 +219,11 @@ def test_settings_profiles_migrate_legacy_flat_bass_selection():
|
|||||||
})
|
})
|
||||||
assert settings["active_instrument_profile"] == "bass"
|
assert settings["active_instrument_profile"] == "bass"
|
||||||
assert settings["instrument_profiles"]["bass"]["string_count"] == 6
|
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["reference_pitch"] == 432
|
||||||
assert settings["pathway"] == "practice"
|
assert settings["pathway"] == "practice"
|
||||||
assert settings["instrument_profiles"]["bass"]["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([float("-inf")]) is None # non-finite
|
assert freqs_to_midis([float("-inf")]) is None # non-finite
|
||||||
assert freqs_to_midis([]) == [] # vacuously fine
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user